#!/usr/bin/env node const crypto = require("node:crypto"); const fs = require("node:fs"); const path = require("node:path"); const sharp = require("sharp"); const { LemmyHttp } = require("lemmy-js-client"); const { collectBundles } = require("./lib/content"); const { loadToolsConfig } = require("./lib/config"); const { parseFrontmatterDate } = require("./lib/datetime"); const { readFrontmatterFile, writeFrontmatterFile } = require("./lib/frontmatter"); const CONTENT_ROOT = path.join(__dirname, "..", "content"); const FRONTMATTER_COMMENT_FIELD = "comments_url"; const FRONTMATTER_COVER_FIELD = "cover"; const MAX_COMMUNITY_NAME_LENGTH = 20; const MIN_COMMUNITY_NAME_LENGTH = 3; const MAX_THUMBNAIL_WIDTH = 320; const MAX_THUMBNAIL_HEIGHT = 240; const THUMBNAIL_QUALITY = 82; const THUMBNAIL_CACHE_DIR = path.join(__dirname, "cache", "lemmy_thumbnails"); const THUMBNAIL_FORMAT = "png"; main().then( () => { process.exit(0); }, (error) => { console.error(`❌ Synchronisation Lemmy interrompue : ${error.message}`); process.exit(1); } ); /** * Point d'entrée principal : charge la configuration, collecte les articles et orchestre la synchronisation. */ async function main() { const toolsConfig = await loadToolsConfig(path.join(__dirname, "config", "config.json")); const lemmyConfig = normalizeLemmyConfig(toolsConfig.lemmy); const client = await createLemmyClient(lemmyConfig); const bundles = await collectBundles(CONTENT_ROOT); console.log("Vérification des communautés Lemmy pour les fils existants…"); await ensureRestrictedCommunitiesForExistingThreads(bundles, lemmyConfig, client); const articles = selectArticles(bundles); if (articles.length === 0) { console.log("Aucun article à synchroniser."); return; } for (const article of articles) { await synchroniseArticle(article, lemmyConfig, client); } } /** * Valide et normalise la configuration Lemmy issue des fichiers et variables d'environnement. * @param {object} rawConfig Configuration telle que chargée par loadToolsConfig. * @returns {object} Configuration prête à l'emploi pour Lemmy. */ 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."); } const prefixOverrides = buildOverrides(rawConfig.community?.prefixOverrides || {}); const descriptionTemplate = typeof rawConfig.community?.descriptionTemplate === "string" && rawConfig.community.descriptionTemplate.trim().length > 0 ? rawConfig.community.descriptionTemplate.trim() : "Espace dédié aux échanges autour de {{path}}."; return { instanceUrl, siteUrl, auth: { jwt: hasJwt ? auth.jwt.trim() : null, username: hasCredentials ? auth.username.trim() : null, password: hasCredentials ? auth.password : null, }, community: { visibility: rawConfig.community?.visibility || "Public", nsfw: rawConfig.community?.nsfw === true, descriptionTemplate, prefixOverrides, }, }; } /** * Crée un client Lemmy authentifié via JWT ou couple utilisateur/mot de passe. * @param {object} lemmyConfig Configuration normalisée. * @returns {Promise} Client prêt pour les appels API. */ async function createLemmyClient(lemmyConfig) { const client = new LemmyHttp(lemmyConfig.instanceUrl); if (lemmyConfig.auth.jwt) { client.setHeaders({ Authorization: `Bearer ${lemmyConfig.auth.jwt}` }); return client; } const loginResponse = await client.login({ username_or_email: lemmyConfig.auth.username, password: lemmyConfig.auth.password, }); client.setHeaders({ Authorization: `Bearer ${loginResponse.jwt}` }); return client; } /** * Prépare la liste des articles à synchroniser : frontmatter présent, date valide, comments_url absent. * Le tri est effectué par date croissante, puis par chemin en cas d'égalité. * @param {Array} bundles Bundles collectés sous content/. * @returns {Array} Articles prêts pour la synchronisation. */ function selectArticles(bundles) { const articles = []; for (const bundle of bundles) { const frontmatter = readFrontmatterFile(bundle.indexPath); if (!frontmatter) { console.warn(`⚠️ ${bundle.relativePath} : frontmatter introuvable, article ignoré.`); continue; } const existingComments = typeof frontmatter.data?.[FRONTMATTER_COMMENT_FIELD] === "string" ? frontmatter.data[FRONTMATTER_COMMENT_FIELD].trim() : ""; if (existingComments) { continue; } const rawDate = frontmatter.data?.date; if (rawDate === null || rawDate === undefined || (typeof rawDate === "string" && rawDate.trim().length === 0)) { continue; } const publication = parseFrontmatterDate(rawDate); if (!publication) { console.warn(`⚠️ ${bundle.relativePath} : date invalide, article ignoré.`); continue; } const title = typeof frontmatter.data?.title === "string" ? frontmatter.data.title.trim() : ""; if (!title) { console.warn(`⚠️ ${bundle.relativePath} : titre manquant, article ignoré.`); continue; } articles.push({ bundle, frontmatter, publication, title, }); } 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; } /** * Garantit que les communautés associées aux articles déjà reliés à Lemmy * sont configurées avec une publication restreinte aux modérateurs. * @param {Array} bundles Bundles collectés sous content/. * @param {object} lemmyConfig Configuration Lemmy. * @param {LemmyHttp} client Client Lemmy. * @returns {Promise} Promesse résolue une fois toutes les communautés vérifiées. */ async function ensureRestrictedCommunitiesForExistingThreads(bundles, lemmyConfig, client) { const visited = new Set(); for (const bundle of bundles) { const frontmatter = readFrontmatterFile(bundle.indexPath); if (!frontmatter) { continue; } const existingComments = typeof frontmatter.data?.[FRONTMATTER_COMMENT_FIELD] === "string" ? frontmatter.data[FRONTMATTER_COMMENT_FIELD].trim() : ""; if (!existingComments) { continue; } const descriptor = buildCommunityDescriptor(bundle.parts, lemmyConfig.community); if (visited.has(descriptor.name)) { continue; } await ensureCommunity(client, descriptor, lemmyConfig.community); visited.add(descriptor.name); } } /** * Synchronise un article unique : communauté, miniature, post Lemmy et mise à jour du frontmatter. * @param {object} article Article préparé par selectArticles. * @param {object} lemmyConfig Configuration Lemmy. * @param {LemmyHttp} client Client Lemmy. */ async function synchroniseArticle(article, lemmyConfig, client) { const communityDescriptor = buildCommunityDescriptor(article.bundle.parts, lemmyConfig.community); const community = await ensureCommunity(client, communityDescriptor, lemmyConfig.community); const coverPath = resolveCoverPath(article.bundle, article.frontmatter.data); let thumbnailUrl = null; let thumbnailCachePath = null; if (coverPath) { const coverRelative = path.relative(process.cwd(), coverPath); console.log(`Préparation de ${article.bundle.relativePath} (couverture : ${coverRelative})`); const thumbnail = await buildThumbnailAsset(coverPath, article.bundle); thumbnailCachePath = thumbnail.cachePath; console.log(`Miniature générée : ${thumbnail.cachePath}`); thumbnailUrl = await uploadThumbnail(client, thumbnail.buffer, { cachePath: thumbnail.cachePath, bundlePath: article.bundle.relativePath, }); } else { console.warn( `⚠️ ${article.bundle.relativePath} : aucune couverture exploitable, création du post sans miniature.` ); } const articleUrl = buildArticleUrl(lemmyConfig.siteUrl, article.bundle.parts); const post = await ensurePost(client, community, { title: article.title, articleUrl, thumbnailUrl, }); const commentsUrl = buildCommentsUrl(lemmyConfig.instanceUrl, post.id); article.frontmatter.data[FRONTMATTER_COMMENT_FIELD] = commentsUrl; writeFrontmatterFile(article.bundle.indexPath, article.frontmatter.data, article.frontmatter.body); cleanupThumbnail(thumbnailCachePath); console.log(`✅ ${article.bundle.relativePath} → ${community.name}`); } /** * 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}`; } /** * Transforme les segments de chemin en nom et titre de communauté Lemmy. * @param {string[]} parts Segments du chemin du bundle. * @param {object} communityConfig Configuration de la section community. * @returns {{ name: string, title: string, description: string }} */ function buildCommunityDescriptor(parts, communityConfig) { const intermediate = stripDateSegments(parts.slice(0, -1)); if (intermediate.length === 0) { throw new Error(`Impossible de déduire une communauté depuis ${parts.join("/")}.`); } const normalized = intermediate.map((segment) => applyOverride(segment, communityConfig.prefixOverrides)); const sanitized = normalized.map((segment) => sanitizeSegment(segment)).filter(Boolean); if (sanitized.length === 0) { throw new Error(`Les segments ${intermediate.join("/")} sont invalides pour une communauté.`); } const name = enforceCommunityLength(sanitized); if (name.length < MIN_COMMUNITY_NAME_LENGTH) { throw new Error(`Nom de communauté trop court pour ${parts.join("/")}.`); } const title = normalized.map((segment) => capitalizeLabel(segment)).join(" / "); const labelPath = normalized.join("/"); const description = buildCommunityDescription(communityConfig.descriptionTemplate, labelPath); return { name, title, description }; } /** * Supprime les segments correspondant à un pattern année/mois/jour consécutif. * @param {string[]} segments Segments intermédiaires du chemin. * @returns {string[]} Segments épurés des dates. */ function stripDateSegments(segments) { const filtered = []; let index = 0; while (index < segments.length) { if ( isYearSegment(segments[index]) && isMonthSegment(segments[index + 1]) && isDaySegment(segments[index + 2]) ) { index += 3; continue; } filtered.push(segments[index]); index += 1; } return filtered; } /** * Indique si un segment représente une année sur 4 chiffres. * @param {string|undefined} value Segment à tester. * @returns {boolean} true si le segment est une année. */ function isYearSegment(value) { if (typeof value !== "string") { return false; } return /^\d{4}$/.test(value); } /** * Indique si un segment représente un mois numérique valide. * @param {string|undefined} value Segment à tester. * @returns {boolean} true si le segment est un mois. */ function isMonthSegment(value) { if (typeof value !== "string") { return false; } if (!/^\d{1,2}$/.test(value)) { return false; } const numeric = Number.parseInt(value, 10); return numeric >= 1 && numeric <= 12; } /** * Indique si un segment représente un jour numérique valide. * @param {string|undefined} value Segment à tester. * @returns {boolean} true si le segment est un jour. */ function isDaySegment(value) { if (typeof value !== "string") { return false; } if (!/^\d{1,2}$/.test(value)) { return false; } const numeric = Number.parseInt(value, 10); return numeric >= 1 && numeric <= 31; } /** * Applique une table de remplacements sur un segment en respectant la casse initiale. * @param {string} segment Segment brut. * @param {Record} overrides Table de substitutions. * @returns {string} Segment éventuellement remplacé. */ function applyOverride(segment, overrides) { const lookup = segment.toLowerCase(); if (overrides[lookup]) { return overrides[lookup]; } return segment; } /** * Nettoie un segment pour l'utiliser dans un nom de communauté Lemmy. * @param {string} segment Valeur brute. * @returns {string} Segment assaini. */ function sanitizeSegment(segment) { return segment .normalize("NFKD") .replace(/[\u0300-\u036f]/g, "") .toLowerCase() .replace(/[^a-z0-9]+/g, "_") .replace(/_{2,}/g, "_") .replace(/^_|_$/g, ""); } /** * Garantit que le nom de communauté respecte la longueur imposée par Lemmy. * @param {string[]} segments Segments déjà assainis. * @returns {string} Nom final. */ function enforceCommunityLength(segments) { const reduced = segments.map((segment) => segment.slice(0, MAX_COMMUNITY_NAME_LENGTH)); let current = reduced.join("_"); if (current.length <= MAX_COMMUNITY_NAME_LENGTH) { return current; } const working = [...reduced]; let cursor = working.length - 1; while (current.length > MAX_COMMUNITY_NAME_LENGTH && cursor >= 0) { if (working[cursor].length > 1) { working[cursor] = working[cursor].slice(0, -1); current = working.join("_"); continue; } cursor -= 1; } if (current.length <= MAX_COMMUNITY_NAME_LENGTH) { return current; } const compactSource = segments.join("_"); const compact = compactSource.replace(/_/g, "").slice(0, Math.max(1, MAX_COMMUNITY_NAME_LENGTH - 5)); const hash = crypto.createHash("sha1").update(compactSource).digest("hex"); const suffixLength = Math.max(2, MAX_COMMUNITY_NAME_LENGTH - compact.length - 1); const suffix = hash.slice(0, suffixLength); return `${compact}_${suffix}`; } /** * Construit un titre lisible pour la communauté. * @param {string} value Segment brut. * @returns {string} Version capitalisée. */ function capitalizeLabel(value) { const spaced = value.replace(/[-_]+/g, " ").replace(/\s+/g, " ").trim(); if (!spaced) { return value; } return spaced.charAt(0).toUpperCase() + spaced.slice(1); } /** * Crée une description de communauté à partir du template configuré. * @param {string} template Modèle issu de la configuration. * @param {string} labelPath Chemin textuel des segments. * @returns {string} Description finale. */ function buildCommunityDescription(template, labelPath) { if (template.includes("{{path}}")) { return template.replace("{{path}}", labelPath); } return `${template} (${labelPath})`; } /** * 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 }; } /** * Recherche une communauté par nom, la crée si nécessaire et force la restriction de publication aux modérateurs. * @param {LemmyHttp} client Client Lemmy. * @param {object} descriptor Nom, titre et description attendus. * @param {object} communityConfig Paramètres nsfw/visibilité. * @returns {Promise<{ id: number, name: string, title: string, restrictedToMods: boolean, created: boolean, updated: boolean }>} Communauté finalisée. */ async function ensureCommunity(client, descriptor, communityConfig) { const existing = await searchCommunity(client, descriptor.name); if (existing) { const existingCommunity = existing.community; if (existingCommunity.posting_restricted_to_mods !== true) { console.log( `🔓→🔒 Communauté Lemmy ${existingCommunity.title} verrouillée (déjà existante).` ); const edited = await client.editCommunity({ community_id: existingCommunity.id, posting_restricted_to_mods: true, }); const editedCommunity = edited.community_view.community; return { id: editedCommunity.id, name: editedCommunity.name, title: editedCommunity.title, restrictedToMods: editedCommunity.posting_restricted_to_mods === true, created: false, updated: true, }; } return { id: existingCommunity.id, name: existingCommunity.name, title: existingCommunity.title, restrictedToMods: existingCommunity.posting_restricted_to_mods === true, created: false, updated: false, }; } console.log(`🆕🔒 Communauté Lemmy ${descriptor.title} créée et verrouillée.`); const response = await client.createCommunity({ name: descriptor.name, title: descriptor.title, description: descriptor.description, nsfw: communityConfig.nsfw, posting_restricted_to_mods: true, visibility: communityConfig.visibility, }); const createdCommunity = response.community_view.community; return { id: createdCommunity.id, name: createdCommunity.name, title: createdCommunity.title, restrictedToMods: createdCommunity.posting_restricted_to_mods === true, created: true, updated: false, }; } /** * Recherche une communauté précise via l'API de recherche. * @param {LemmyHttp} client Client Lemmy. * @param {string} name Nom recherché. * @returns {Promise} Vue de communauté ou null. */ async function searchCommunity(client, name) { const response = await client.search({ q: name, type_: "Communities", limit: 50 }); if (!response.communities || response.communities.length === 0) { return null; } return response.communities.find((communityView) => communityView.community.name === name) || null; } /** * 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. * @returns {Promise} URL de la miniature hébergée par Lemmy. */ async function uploadThumbnail(client, thumbnailBuffer, info) { 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}`); } return upload.url; } /** * Crée ou met à jour un post Lemmy correspondant à l'article. * @param {LemmyHttp} client Client Lemmy. * @param {{ id: number, name: string }} community Communauté cible. * @param {{ title: string, articleUrl: string, thumbnailUrl: string|null }} payload Données du post. * @returns {Promise<{ id: number }>} Post final. */ async function ensurePost(client, community, payload) { const existing = await searchPostByUrl(client, community, payload.articleUrl); if (existing) { if (shouldUpdatePost(existing, payload.title, payload.thumbnailUrl, payload.articleUrl)) { const edited = await client.editPost({ post_id: existing.post.id, name: payload.title, url: payload.articleUrl, custom_thumbnail: payload.thumbnailUrl || undefined, }); return { id: edited.post_view.post.id }; } return { id: existing.post.id }; } const response = await client.createPost({ name: payload.title, community_id: community.id, url: payload.articleUrl, custom_thumbnail: payload.thumbnailUrl || undefined, }); return { id: response.post_view.post.id }; } /** * Vérifie la nécessité d'une mise à jour du post existant. * @param {object} postView Post obtenu par recherche. * @param {string} expectedTitle Titre attendu. * @param {string|null} thumbnailUrl URL attendue pour la miniature. * @param {string} articleUrl URL de l'article. * @returns {boolean} true si une édition est requise. */ function shouldUpdatePost(postView, expectedTitle, thumbnailUrl, articleUrl) { const currentTitle = typeof postView.post.name === "string" ? postView.post.name : ""; const currentUrl = typeof postView.post.url === "string" ? postView.post.url : ""; const currentThumbnail = typeof postView.post.thumbnail_url === "string" ? postView.post.thumbnail_url : null; if (currentTitle !== expectedTitle) { return true; } if (currentUrl !== articleUrl) { return true; } if (thumbnailUrl && currentThumbnail !== thumbnailUrl) { return true; } return false; } /** * Cherche un post existant pointant vers l'URL de l'article dans la communauté donnée. * @param {LemmyHttp} client Client Lemmy. * @param {{ id: number, name: string }} community Communauté cible. * @param {string} articleUrl URL Hugo recherchée. * @returns {Promise} PostView correspondant ou null. */ async function searchPostByUrl(client, community, articleUrl) { const response = await client.search({ q: articleUrl, type_: "Url", community_name: community.name, limit: 50, }); if (!response.posts || response.posts.length === 0) { return null; } return response.posts.find( (postView) => typeof postView.post.url === "string" && postView.post.url.trim() === articleUrl && postView.post.community_id === community.id ) || null; } /** * Construit l'URL publique des commentaires Lemmy. * @param {string} instanceUrl Domaine de l'instance Lemmy. * @param {number} postId Identifiant du post. * @returns {string} URL des commentaires. */ function buildCommentsUrl(instanceUrl, postId) { return `${instanceUrl}/post/${postId}`; } /** * 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; } /** * Nettoie les URLs en supprimant les slashs terminaux et espaces superflus. * @param {string|null} 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(/\/+$/, ""); } /** * Transforme l'objet de remplacements brut en table normalisée. * @param {Record} overrides Remplacements issus de la configuration. * @returns {Record} Table prête à l'emploi. */ function buildOverrides(overrides) { const table = {}; for (const [key, value] of Object.entries(overrides)) { if (typeof key !== "string" || typeof value !== "string") { continue; } const normalizedKey = key.trim().toLowerCase(); const normalizedValue = value.trim(); if (normalizedKey && normalizedValue) { table[normalizedKey] = normalizedValue; } } return table; } /** * É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 créé. * @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); }