const crypto = require("node:crypto"); const { LemmyHttp } = require("lemmy-js-client"); const MAX_COMMUNITY_NAME_LENGTH = 20; const MIN_COMMUNITY_NAME_LENGTH = 3; /** * 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 }, community: object }} 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."); } const prefixOverrides = buildOverrides(rawConfig.community?.prefixOverrides || {}); let jwt = null; let username = null; let password = null; if (hasJwt) { jwt = auth.jwt.trim(); } if (hasCredentials) { username = auth.username.trim(); password = auth.password; } let descriptionTemplate = "Espace dédié aux échanges autour de {{path}}."; if (typeof rawConfig.community?.descriptionTemplate === "string") { const trimmed = rawConfig.community.descriptionTemplate.trim(); if (trimmed) { descriptionTemplate = trimmed; } } return { instanceUrl, siteUrl, auth: { jwt, username, password, }, community: { prefixOverrides, visibility: rawConfig.community?.visibility || "Public", nsfw: rawConfig.community?.nsfw === true, descriptionTemplate, }, }; } /** * 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; } /** * 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 }; } /** * 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 }>} 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) { 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, }; } return { id: existingCommunity.id, name: existingCommunity.name, title: existingCommunity.title, }; } 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, }; } /** * 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; } /** * 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})`; } /** * 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; } /** * 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); } /** * 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; } /** * 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; } module.exports = { normalizeLemmyConfig, createLemmyClient, buildArticleUrl, buildCommunityDescriptor, ensureCommunity, isYearSegment, isMonthSegment, isDaySegment, };