#!/usr/bin/env node /** * Met à jour la date de publication et le titre des posts Lemmy à partir des articles Hugo. * Pré-requis : accès en écriture à la base Postgres de Lemmy * (par exemple via LEMMY_DATABASE_URL=postgres:///lemmy?host=/run/postgresql&user=lemmy * et exécution en tant qu'utilisateur système lemmy). * * Règles : * - L'article doit contenir un frontmatter valide avec un champ date. * - L'article doit contenir un comments_url pointant vers /post/{id}. * - La date est appliquée sur post.published (timestamp avec fuseau Hugo). * - Le titre Lemmy est aligné sur le titre Hugo (post.name et, si disponible, post.embed_title). */ const path = require("node:path"); const fs = require("node:fs"); const crypto = require("node:crypto"); const sharp = require("sharp"); const { LemmyHttp } = require("lemmy-js-client"); const { Pool } = require("pg"); const { collectBundles } = require("./lib/content"); const { parseFrontmatterDate } = require("./lib/datetime"); const { readFrontmatterFile } = require("./lib/frontmatter"); const { loadEnv } = require("./lib/env"); const { loadToolsConfig } = require("./lib/config"); const CONTENT_ROOT = path.join(__dirname, "..", "content"); const DEFAULT_DATABASE_URL = "postgres:///lemmy?host=/run/postgresql&user=lemmy"; const TOOLS_CONFIG_PATH = path.join(__dirname, "config", "config.json"); const THUMBNAIL_CACHE_DIR = path.join(__dirname, "cache", "lemmy_thumbnails"); const THUMBNAIL_FORMAT = "png"; const MAX_THUMBNAIL_WIDTH = 320; const MAX_THUMBNAIL_HEIGHT = 240; const THUMBNAIL_QUALITY = 82; const FRONTMATTER_COVER_FIELD = "cover"; main().then( () => { process.exit(0); }, (error) => { console.error(`❌ Mise à jour interrompue : ${error.message}`); process.exit(1); } ); /** * Point d'entrée : collecte les articles, se connecte à Postgres, applique les dates. */ async function main() { loadEnv(); const databaseUrl = resolveDatabaseUrl(); const pool = new Pool({ connectionString: databaseUrl }); const hasEmbedTitle = await detectEmbedTitleColumn(pool); const bundles = await collectBundles(CONTENT_ROOT); const articles = collectArticlesWithPostId(bundles); if (articles.length === 0) { console.log("Aucun article muni d'un comments_url et d'une date valide."); await pool.end(); return; } let updated = 0; let unchanged = 0; let missing = 0; let lemmyClient = null; let lemmyConfig = null; for (const article of articles) { const targetDate = article.publication.set({ millisecond: 0 }); const iso = targetDate.toISO(); const row = await fetchPost(pool, article.postId, hasEmbedTitle); if (!row) { missing += 1; console.warn(`⚠️ Post ${article.postId} introuvable pour ${article.bundle.relativePath}`); continue; } const currentIso = new Date(row.published).toISOString(); const expectedUtcIso = targetDate.toUTC().toISO(); const expectedTitle = article.title; const currentTitle = typeof row.name === "string" ? row.name.trim() : ""; const needsDateUpdate = currentIso !== expectedUtcIso; const needsTitleUpdate = currentTitle !== expectedTitle; let needsEmbedTitleUpdate = false; if (hasEmbedTitle) { const currentEmbedTitle = typeof row.embed_title === "string" ? row.embed_title.trim() : ""; needsEmbedTitleUpdate = currentEmbedTitle !== expectedTitle; } const currentThumbnailUrl = typeof row.thumbnail_url === "string" ? row.thumbnail_url.trim() : ""; let needsThumbnailCreation = false; let coverPath = null; const currentUrl = typeof row.url === "string" ? row.url.trim() : ""; const urlLooksLikePictrsImage = currentUrl.includes("/pictrs/image/"); if (!currentThumbnailUrl || urlLooksLikePictrsImage) { coverPath = resolveCoverPath(article.bundle, article.frontmatter.data); if (coverPath) { needsThumbnailCreation = true; } } if (!needsDateUpdate && !needsTitleUpdate && !needsEmbedTitleUpdate && !needsThumbnailCreation) { unchanged += 1; continue; } if (needsDateUpdate || needsTitleUpdate || needsEmbedTitleUpdate) { await applyPostUpdates( pool, article.postId, needsDateUpdate ? iso : null, needsTitleUpdate ? expectedTitle : null, needsEmbedTitleUpdate ? expectedTitle : null, hasEmbedTitle ); } let createdThumbnailDescription = null; if (needsThumbnailCreation && coverPath) { if (!lemmyClient) { const lemmySetup = await createLemmyClientFromConfig(); lemmyClient = lemmySetup.client; lemmyConfig = lemmySetup.config; } const thumbnail = await buildThumbnailAsset(coverPath, article.bundle); console.log(`Miniature générée : ${thumbnail.cachePath}`); const thumbnailUrl = await uploadThumbnail( lemmyClient, thumbnail.buffer, { cachePath: thumbnail.cachePath, bundlePath: article.bundle.relativePath, }, lemmyConfig.instanceUrl ); const articleUrl = buildArticleUrl(lemmyConfig.siteUrl, article.bundle.parts); await attachThumbnailToPost( lemmyClient, article.postId, expectedTitle, currentUrl || null, articleUrl, thumbnailUrl ); cleanupThumbnail(thumbnail.cachePath); createdThumbnailDescription = "miniature"; } updated += 1; const operations = []; if (needsDateUpdate) { operations.push(`date ${iso}`); } if (needsTitleUpdate || needsEmbedTitleUpdate) { operations.push("titre"); } if (createdThumbnailDescription) { operations.push(createdThumbnailDescription); } const details = operations.length > 0 ? ` (${operations.join(" + ")})` : ""; console.log(`✅ Post ${article.postId} mis à jour${details} (${article.bundle.relativePath})`); } await pool.end(); console.log(""); console.log("Résumé des ajustements Lemmy"); console.log(`Posts mis à jour : ${updated}`); console.log(`Posts déjà alignés : ${unchanged}`); console.log(`Posts introuvables : ${missing}`); } /** * Détermine l'URL de connexion Postgres. * @returns {string} Chaîne de connexion. */ function resolveDatabaseUrl() { if (typeof process.env.LEMMY_DATABASE_URL === "string" && process.env.LEMMY_DATABASE_URL.trim()) { return process.env.LEMMY_DATABASE_URL.trim(); } if (typeof process.env.DATABASE_URL === "string" && process.env.DATABASE_URL.trim()) { return process.env.DATABASE_URL.trim(); } return DEFAULT_DATABASE_URL; } /** * Construit la liste des articles éligibles avec identifiant de post Lemmy. * @param {Array} bundles Bundles Hugo. * @returns {Array} Articles prêts à être appliqués. */ function collectArticlesWithPostId(bundles) { const articles = []; for (const bundle of bundles) { const frontmatter = readFrontmatterFile(bundle.indexPath); if (!frontmatter) { continue; } const publication = parseFrontmatterDate(frontmatter.data?.date); if (!publication) { continue; } const title = typeof frontmatter.data?.title === "string" ? frontmatter.data.title.trim() : ""; if (!title) { continue; } const commentsUrl = typeof frontmatter.data?.comments_url === "string" ? frontmatter.data.comments_url.trim() : ""; if (!commentsUrl) { continue; } const postId = extractPostId(commentsUrl); if (postId === null) { continue; } articles.push({ bundle, publication, title, frontmatter, postId, }); } articles.sort((a, b) => { const diff = a.publication.toMillis() - b.publication.toMillis(); if (diff !== 0) { return diff; } return a.bundle.relativePath.localeCompare(b.bundle.relativePath); }); return articles; } /** * Indique si la colonne embed_title est présente sur la table post. * @param {Pool} pool Pool Postgres. * @returns {Promise} true si embed_title est disponible. */ async function detectEmbedTitleColumn(pool) { const result = await pool.query( "select column_name from information_schema.columns where table_name = 'post' and column_name = 'embed_title' limit 1" ); return result.rowCount === 1; } /** * Normalise une URL simple en supprimant les espaces et slashs finaux. * @param {string|null|undefined} url URL brute. * @returns {string|null} URL normalisée ou null. */ function normalizeUrl(url) { if (typeof url !== "string") { return null; } const trimmed = url.trim(); if (!trimmed) { return null; } return trimmed.replace(/\/+$/, ""); } /** * Normalise la configuration Lemmy extraite des fichiers tools/config. * @param {object} rawConfig Configuration brute. * @returns {{ instanceUrl: string, siteUrl: string, auth: { jwt: string|null, username: string|null, password: string|null } }} Configuration prête à l'emploi. */ function normalizeLemmyConfig(rawConfig) { if (!rawConfig || typeof rawConfig !== "object") { throw new Error("La configuration Lemmy est manquante (tools/config/config.json)."); } const instanceUrl = normalizeUrl(rawConfig.instanceUrl); if (!instanceUrl) { throw new Error( "lemmy.instanceUrl doit être renseigné dans tools/config/config.json ou via l'environnement." ); } const siteUrl = normalizeUrl(rawConfig.siteUrl); if (!siteUrl) { throw new Error("lemmy.siteUrl doit être défini pour construire les URLs des articles."); } const auth = rawConfig.auth || {}; const hasJwt = typeof auth.jwt === "string" && auth.jwt.trim().length > 0; const hasCredentials = typeof auth.username === "string" && auth.username.trim().length > 0 && typeof auth.password === "string" && auth.password.length > 0; if (!hasJwt && !hasCredentials) { throw new Error("lemmy.auth.jwt ou lemmy.auth.username + lemmy.auth.password doivent être fournis."); } return { instanceUrl, siteUrl, auth: { jwt: hasJwt ? auth.jwt.trim() : null, username: hasCredentials ? auth.username.trim() : null, password: hasCredentials ? auth.password : null, }, }; } /** * Crée un client Lemmy configuré à partir de tools/config/config.json. * @returns {Promise<{ client: LemmyHttp, config: { instanceUrl: string, siteUrl: string, auth: object } }>} */ async function createLemmyClientFromConfig() { const toolsConfig = await loadToolsConfig(TOOLS_CONFIG_PATH); const rawLemmy = toolsConfig.lemmy || {}; const config = normalizeLemmyConfig(rawLemmy); const client = new LemmyHttp(config.instanceUrl); if (config.auth.jwt) { client.setHeaders({ Authorization: `Bearer ${config.auth.jwt}` }); return { client, config }; } const loginResponse = await client.login({ username_or_email: config.auth.username, password: config.auth.password, }); client.setHeaders({ Authorization: `Bearer ${loginResponse.jwt}` }); return { client, config }; } /** * Construit l'URL publique d'un article à partir de son chemin Hugo. * @param {string} siteUrl Domaine du site. * @param {string[]} parts Segments du chemin du bundle. * @returns {string} URL finale. */ function buildArticleUrl(siteUrl, parts) { const relative = parts.join("/"); return `${siteUrl}/${relative}`; } /** * Extrait l'identifiant numérique d'un comments_url Lemmy. * @param {string} url URL issue du frontmatter. * @returns {number|null} Identifiant ou null si non reconnu. */ function extractPostId(url) { const trimmed = url.trim(); if (!trimmed) { return null; } const normalized = trimmed.replace(/\/+$/, ""); const match = normalized.match(/\/(?:post|c\/[^/]+\/post)\/(\d+)(?:$|\?)/i); if (!match) { return null; } return Number.parseInt(match[1], 10); } /** * Récupère un post Lemmy par identifiant. * @param {Pool} pool Pool Postgres. * @param {number} postId Identifiant du post. * @param {boolean} withEmbedTitle true pour récupérer embed_title si disponible. * @returns {Promise} Enregistrement ou null. */ async function fetchPost(pool, postId, withEmbedTitle) { const columns = withEmbedTitle ? "id, published, name, embed_title, url, thumbnail_url" : "id, published, name, url, thumbnail_url"; const result = await pool.query(`select ${columns} from post where id = $1`, [postId]); if (result.rowCount !== 1) { return null; } return result.rows[0]; } /** * Applique la date et le titre fournis sur le post ciblé. * @param {Pool} pool Pool Postgres. * @param {number} postId Identifiant du post. * @param {string|null} isoDate Timestamp ISO (null pour laisser inchangé). * @param {string|null} title Titre attendu (null pour laisser inchangé). * @param {string|null} embedTitle Titre embarqué attendu (null pour laisser inchangé). * @param {boolean} withEmbedTitle true si embed_title peut être mis à jour. */ async function applyPostUpdates(pool, postId, isoDate, title, embedTitle, withEmbedTitle) { const fields = []; const values = []; let index = 1; if (isoDate !== null) { fields.push(`published = $${index}`); values.push(isoDate); index += 1; } if (title !== null) { fields.push(`name = $${index}`); values.push(title); index += 1; } if (withEmbedTitle && embedTitle !== null) { fields.push(`embed_title = $${index}`); values.push(embedTitle); index += 1; } if (fields.length === 0) { return; } values.push(postId); const sql = `update post set ${fields.join(", ")} where id = $${index}`; await pool.query(sql, values); } /** * Détermine le chemin absolu vers l'image de couverture déclarée. * @param {object} bundle Bundle en cours de traitement. * @param {object} frontmatterData Données du frontmatter. * @returns {string|null} Chemin absolu ou null si inexistant. */ function resolveCoverPath(bundle, frontmatterData) { const cover = typeof frontmatterData?.[FRONTMATTER_COVER_FIELD] === "string" ? frontmatterData[FRONTMATTER_COVER_FIELD].trim() : ""; if (!cover) { return null; } const normalized = cover.replace(/^\.?\//, "").replace(/\/{2,}/g, "/"); if (!normalized) { return null; } const absolute = path.join(bundle.dir, normalized); if (!fs.existsSync(absolute)) { console.warn(`⚠️ ${bundle.relativePath} : couverture ${normalized} introuvable.`); return null; } const stats = fs.statSync(absolute); if (!stats.isFile()) { console.warn(`⚠️ ${bundle.relativePath} : ${normalized} n'est pas un fichier image.`); return null; } return absolute; } /** * Génère une miniature et la sauvegarde sur disque pour inspection si besoin. * @param {string} coverPath Chemin absolu de l'image source. * @param {object} bundle Bundle concerné. * @returns {Promise<{ buffer: Buffer, cachePath: string }>} Miniature prête à l'emploi. */ async function buildThumbnailAsset(coverPath, bundle) { const buffer = await createThumbnail(coverPath, THUMBNAIL_FORMAT); const cachePath = writeThumbnailToCache(buffer, bundle, coverPath, THUMBNAIL_FORMAT); return { buffer, cachePath }; } /** * Construit la miniature à partir de l'image de couverture. * @param {string} absolutePath Chemin absolu vers l'image source. * @param {string} format Format de sortie (jpeg|png). * @returns {Promise} Données JPEG redimensionnées. */ async function createThumbnail(absolutePath, format = "jpeg") { const base = sharp(absolutePath).resize({ width: MAX_THUMBNAIL_WIDTH, height: MAX_THUMBNAIL_HEIGHT, fit: "inside", withoutEnlargement: true, }); if (format === "png") { return base .png({ compressionLevel: 9, palette: true, effort: 5, }) .toBuffer(); } return base.jpeg({ quality: THUMBNAIL_QUALITY, mozjpeg: true }).toBuffer(); } /** * Téléverse une miniature vers Lemmy et retourne l'URL publique. * @param {LemmyHttp} client Client Lemmy. * @param {Buffer} thumbnailBuffer Données de l'image. * @param {object} info Informations de contexte pour les erreurs. * @param {string|null|undefined} instanceUrl URL de l'instance Lemmy (pour harmoniser le schéma). * @returns {Promise} URL de la miniature hébergée par Lemmy. */ async function uploadThumbnail(client, thumbnailBuffer, info, instanceUrl) { const label = info?.bundlePath ? ` pour ${info.bundlePath}` : ""; const location = info?.cachePath ? ` (miniature : ${info.cachePath})` : ""; const upload = await client.uploadImage({ image: thumbnailBuffer }).catch((error) => { const reason = typeof error?.message === "string" && error.message.trim() ? error.message : "erreur inconnue"; throw new Error(`Téléversement Lemmy échoué${label}${location} : ${reason}`); }); if (!upload) { throw new Error(`Miniature rejetée${label}${location} : réponse vide`); } if (upload.error) { throw new Error(`Miniature rejetée${label}${location} : ${upload.error}`); } if (upload.msg !== "ok" || !upload.url) { const details = JSON.stringify(upload); throw new Error(`Miniature rejetée${label}${location} : réponse inattendue ${details}`); } let url = upload.url; if (typeof url !== "string") { throw new Error(`Miniature rejetée${label}${location} : URL de réponse invalide`); } url = url.trim(); if (!url) { throw new Error(`Miniature rejetée${label}${location} : URL de réponse vide`); } if ( typeof instanceUrl === "string" && instanceUrl.startsWith("https://") && url.startsWith("http://") ) { url = `https://${url.slice("http://".length)}`; } return url; } /** * Associe une miniature à un post Lemmy existant via l'API. * @param {LemmyHttp} client Client Lemmy. * @param {number} postId Identifiant du post. * @param {string} title Titre attendu. * @param {string|null} currentUrl URL actuelle du post. * @param {string} articleUrl URL Hugo de l'article. * @param {string} thumbnailUrl URL de la miniature hébergée. * @returns {Promise} Promesse résolue une fois l'édition terminée. */ async function attachThumbnailToPost(client, postId, title, currentUrl, articleUrl, thumbnailUrl) { const normalizedCurrentUrl = typeof currentUrl === "string" && currentUrl.trim().length > 0 ? currentUrl.trim() : null; const payload = { post_id: postId, name: title, custom_thumbnail: thumbnailUrl, }; let targetUrl = normalizedCurrentUrl; if (!targetUrl || targetUrl.includes("/pictrs/image/")) { targetUrl = articleUrl; } if (targetUrl) { payload.url = targetUrl; } await client.editPost(payload); } /** * Écrit la miniature générée sur disque dans tools/cache pour inspection. * @param {Buffer} buffer Miniature prête. * @param {object} bundle Bundle concerné. * @param {string} coverPath Chemin de la couverture d'origine. * @param {string} format Format de la miniature (jpeg|png). * @returns {string} Chemin du fichier écrit. */ function writeThumbnailToCache(buffer, bundle, coverPath, format) { const targetPath = computeThumbnailCachePath(bundle, coverPath, format); fs.mkdirSync(path.dirname(targetPath), { recursive: true }); fs.writeFileSync(targetPath, buffer); return targetPath; } /** * Supprime la miniature mise en cache une fois le post Lemmy mis à jour. * @param {string|null} cachePath Chemin éventuel de la miniature. */ function cleanupThumbnail(cachePath) { if (!cachePath) { return; } if (fs.existsSync(cachePath)) { fs.unlinkSync(cachePath); } } /** * Construit un nom de fichier stable et assaini pour la miniature en cache. * @param {object} bundle Bundle concerné. * @param {string} coverPath Chemin de la couverture. * @param {string} format Format de la miniature. * @returns {string} Chemin cible dans tools/cache/lemmy_thumbnails. */ function computeThumbnailCachePath(bundle, coverPath, format = "jpeg") { const base = `${bundle.relativePath}__${path.basename(coverPath)}`; const safeBase = base .replace(/[^a-zA-Z0-9._-]+/g, "_") .replace(/_{2,}/g, "_") .replace(/^_|_$/g, ""); const hash = crypto.createHash("sha1").update(base).digest("hex").slice(0, 10); const extension = format === "png" ? "png" : "jpg"; const name = `${safeBase || "thumbnail"}_${hash}.${extension}`; return path.join(THUMBNAIL_CACHE_DIR, name); }