Générateur de portfolios PDF Figures Libres (app Nuxt autonome)
App Nuxt 3 + Nuxt UI qui lit les projets du CMS Grav et compose des portfolios A3 paysage exportés en PDF (gabarit book_v3) : composeur WYSIWYG, templates cover/toc/projet/grille/page libre, pipeline Playwright + Ghostscript, gel du contenu à la génération, partage public opt-in. Dépôt autonome : Dockerfile (base Playwright + Ghostscript + ffmpeg + rsync) inclus. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
140
server/lib/content-source.ts
Normal file
140
server/lib/content-source.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
// Abstraction ContentSource (spec §5) : DEV = FS local (grav-reference monté RO),
|
||||
// PROD = cache local alimenté par rsync/SSH. Le reste de l'app ne voit que cette interface.
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import { join, normalize } from 'node:path'
|
||||
import type { Project } from '~~/types'
|
||||
import { readAllProjects } from './grav-reader'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
export interface SyncResult {
|
||||
ok: boolean
|
||||
summary: string
|
||||
syncedAt: string
|
||||
}
|
||||
|
||||
export interface ContentSource {
|
||||
/** Racine FS des projets (catégories/<slug>/…) */
|
||||
readonly rootDir: string
|
||||
listProjects(): Promise<Project[]>
|
||||
getProject(id: string): Promise<Project | null>
|
||||
/** Chemin absolu d'un média, borné à rootDir (anti path-traversal). */
|
||||
resolveMediaPath(projectId: string, filename: string): string | null
|
||||
/** Re-synchronise le contenu (no-op en DEV). */
|
||||
refresh(): Promise<SyncResult>
|
||||
/** Invalide le cache mémoire. */
|
||||
invalidate(): void
|
||||
}
|
||||
|
||||
const CACHE_TTL_MS = 30_000
|
||||
|
||||
abstract class FsContentSource implements ContentSource {
|
||||
private cache: { at: number; projects: Project[] } | null = null
|
||||
|
||||
constructor(public readonly rootDir: string) {}
|
||||
|
||||
async listProjects(): Promise<Project[]> {
|
||||
if (this.cache && Date.now() - this.cache.at < CACHE_TTL_MS) {
|
||||
return this.cache.projects
|
||||
}
|
||||
const projects = await readAllProjects(this.rootDir)
|
||||
this.cache = { at: Date.now(), projects }
|
||||
return projects
|
||||
}
|
||||
|
||||
async getProject(id: string): Promise<Project | null> {
|
||||
const projects = await this.listProjects()
|
||||
return projects.find(p => p.id === id) ?? null
|
||||
}
|
||||
|
||||
resolveMediaPath(projectId: string, filename: string): string | null {
|
||||
const abs = normalize(join(this.rootDir, projectId, filename))
|
||||
if (!abs.startsWith(normalize(this.rootDir) + '/')) return null
|
||||
return abs
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.cache = null
|
||||
}
|
||||
|
||||
abstract refresh(): Promise<SyncResult>
|
||||
}
|
||||
|
||||
/** Mode DEV : lecture directe du Grav local monté en lecture seule. */
|
||||
export class DevContentSource extends FsContentSource {
|
||||
async refresh(): Promise<SyncResult> {
|
||||
this.invalidate()
|
||||
const projects = await this.listProjects()
|
||||
return {
|
||||
ok: true,
|
||||
summary: `Mode dev : contenu local relu (${projects.length} projets).`,
|
||||
syncedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mode PROD : cache local `data/content-cache/` alimenté par rsync/SSH
|
||||
* depuis la prod (spec §6). Lecture seule côté prod, incrémental.
|
||||
*/
|
||||
export class ProdContentSource extends FsContentSource {
|
||||
constructor(
|
||||
rootDir: string,
|
||||
private readonly remote: string, // ex. user@host:/chemin/user/pages/02.projets/
|
||||
private readonly sshKey?: string,
|
||||
) {
|
||||
super(rootDir)
|
||||
}
|
||||
|
||||
async refresh(): Promise<SyncResult> {
|
||||
if (!this.remote) {
|
||||
return {
|
||||
ok: false,
|
||||
summary: 'NUXT_SYNC_REMOTE non configuré : impossible de synchroniser.',
|
||||
syncedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
await mkdir(this.rootDir, { recursive: true })
|
||||
const ssh = ['ssh', '-o', 'StrictHostKeyChecking=accept-new', '-o', 'BatchMode=yes']
|
||||
if (this.sshKey) ssh.push('-i', this.sshKey)
|
||||
|
||||
const started = Date.now()
|
||||
try {
|
||||
const { stdout } = await execFileAsync('rsync', [
|
||||
'-az',
|
||||
'--delete',
|
||||
'--itemize-changes',
|
||||
'-e', ssh.join(' '),
|
||||
this.remote.endsWith('/') ? this.remote : `${this.remote}/`,
|
||||
`${this.rootDir}/`,
|
||||
], { timeout: 15 * 60_000 })
|
||||
this.invalidate()
|
||||
const changed = stdout.split('\n').filter(l => /^[<>ch.]\S+\s/.test(l)).length
|
||||
const projects = await this.listProjects()
|
||||
return {
|
||||
ok: true,
|
||||
summary: `${changed} fichier(s) mis à jour en ${Math.round((Date.now() - started) / 1000)} s — ${projects.length} projets.`,
|
||||
syncedAt: new Date().toISOString(),
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
summary: `Échec rsync : ${err instanceof Error ? err.message : String(err)}`,
|
||||
syncedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let source: ContentSource | null = null
|
||||
|
||||
export function useContentSource(): ContentSource {
|
||||
if (source) return source
|
||||
const config = useRuntimeConfig()
|
||||
source = config.contentSource === 'prod'
|
||||
? new ProdContentSource(join(config.dataDir, 'content-cache'), config.syncRemote, config.syncSshKey || undefined)
|
||||
: new DevContentSource(config.contentDir)
|
||||
return source
|
||||
}
|
||||
Reference in New Issue
Block a user