From 03dee1a03d7914471905dd73d27a914adf7d5dc6 Mon Sep 17 00:00:00 2001 From: Richard Dern Date: Fri, 12 Dec 2025 06:25:23 +0100 Subject: [PATCH] Fix post-deploiement sur lemmy --- .../images/{IMG_1069.jpeg => img_1069.jpeg} | Bin deploy.sh | 2 + tools/update_lemmy_post_dates.js | 458 +++++++++++++++++- 3 files changed, 449 insertions(+), 11 deletions(-) rename content/interets/divers/2025/12/04/etude-de-ma-collection-lego-jurassic-world/images/{IMG_1069.jpeg => img_1069.jpeg} (100%) diff --git a/content/interets/divers/2025/12/04/etude-de-ma-collection-lego-jurassic-world/images/IMG_1069.jpeg b/content/interets/divers/2025/12/04/etude-de-ma-collection-lego-jurassic-world/images/img_1069.jpeg similarity index 100% rename from content/interets/divers/2025/12/04/etude-de-ma-collection-lego-jurassic-world/images/IMG_1069.jpeg rename to content/interets/divers/2025/12/04/etude-de-ma-collection-lego-jurassic-world/images/img_1069.jpeg diff --git a/deploy.sh b/deploy.sh index 2c0b699a..cb3b633a 100755 --- a/deploy.sh +++ b/deploy.sh @@ -100,4 +100,6 @@ else ssh "$DEST_USER@$DEST_HOST" "$CHOWN_BIN -R $TARGET_OWNER '$DEST_DIR'" fi +sudo -u lemmy node tools/update_lemmy_post_dates.js + echo "==> Déploiement terminé avec succès." diff --git a/tools/update_lemmy_post_dates.js b/tools/update_lemmy_post_dates.js index 6dfe8d43..fbd04d56 100644 --- a/tools/update_lemmy_post_dates.js +++ b/tools/update_lemmy_post_dates.js @@ -1,7 +1,7 @@ #!/usr/bin/env node /** - * Met à jour la date de publication des posts Lemmy à partir des articles Hugo. + * 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). @@ -10,17 +10,30 @@ * - 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( () => { @@ -39,6 +52,7 @@ 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); @@ -51,24 +65,112 @@ async function main() { 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); + 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(); - if (currentIso === targetDate.toUTC().toISO()) { + 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; } - await applyDate(pool, article.postId, iso); + + 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; - console.log(`✅ Post ${article.postId} mis à ${iso} (${article.bundle.relativePath})`); + 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(); @@ -126,6 +228,8 @@ function collectArticlesWithPostId(bundles) { articles.push({ bundle, publication, + title, + frontmatter, postId, }); } @@ -141,6 +245,113 @@ function collectArticlesWithPostId(bundles) { 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. @@ -163,10 +374,14 @@ function extractPostId(url) { * 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) { - const result = await pool.query("select id, published from post where id = $1", [postId]); +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; } @@ -174,11 +389,232 @@ async function fetchPost(pool, postId) { } /** - * Applique la date ISO fournie sur le post ciblé. + * Applique la date et le titre fournis sur le post ciblé. * @param {Pool} pool Pool Postgres. * @param {number} postId Identifiant du post. - * @param {string} isoDate Timestamp ISO. + * @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 applyDate(pool, postId, isoDate) { - await pool.query("update post set published = $1 where id = $2", [isoDate, postId]); +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); }