Fix post-deploiement sur lemmy
This commit is contained in:
@@ -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<boolean>} 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<object|null>} 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<Buffer>} 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<string>} 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<void>} 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user