621 lines
20 KiB
JavaScript
621 lines
20 KiB
JavaScript
#!/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<object>} bundles Bundles Hugo.
|
|
* @returns {Array<object>} 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<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.
|
|
* @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<object|null>} 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<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);
|
|
}
|