From 7b1aeb78a3db9ccc73444d03a5526d8e762b0b57 Mon Sep 17 00:00:00 2001 From: Richard Dern Date: Mon, 22 Dec 2025 00:38:55 +0100 Subject: [PATCH] =?UTF-8?q?Script=20pour=20d=C3=A9placer=20des=20articles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy.sh | 2 +- tools/lib/article_move.js | 202 ++++++++++++++++ tools/lib/lemmy.js | 404 +++++++++++++++++++++++++++++++ tools/move_article.js | 221 +++++++++++++++++ tools/update_lemmy_post_dates.js | 2 +- 5 files changed, 829 insertions(+), 2 deletions(-) create mode 100644 tools/lib/article_move.js create mode 100644 tools/lib/lemmy.js create mode 100644 tools/move_article.js diff --git a/deploy.sh b/deploy.sh index cb3b633a..1805a209 100755 --- a/deploy.sh +++ b/deploy.sh @@ -100,6 +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 +node tools/update_lemmy_post_dates.js echo "==> Déploiement terminé avec succès." diff --git a/tools/lib/article_move.js b/tools/lib/article_move.js new file mode 100644 index 00000000..d9957c5a --- /dev/null +++ b/tools/lib/article_move.js @@ -0,0 +1,202 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { readFrontmatterFile, writeFrontmatterFile } = require("./frontmatter"); + +/** + * Résout un chemin source vers le dossier du bundle. + * @param {string} input Chemin fourni par l'utilisateur. + * @returns {string} Chemin absolu du bundle. + */ +function resolveBundlePath(input) { + const resolved = path.resolve(input); + if (resolved.toLowerCase().endsWith(`${path.sep}index.md`)) { + return path.dirname(resolved); + } + return resolved; +} + +/** + * Vérifie la présence d'un bundle Hugo. + * @param {string} bundleDir Chemin absolu du bundle. + */ +function ensureBundleExists(bundleDir) { + if (!fs.existsSync(bundleDir)) { + throw new Error(`Le bundle ${bundleDir} est introuvable.`); + } + const stats = fs.statSync(bundleDir); + if (!stats.isDirectory()) { + throw new Error(`Le bundle ${bundleDir} n'est pas un dossier.`); + } + const indexPath = path.join(bundleDir, "index.md"); + if (!fs.existsSync(indexPath)) { + throw new Error(`Le bundle ${bundleDir} ne contient pas index.md.`); + } +} + +/** + * Vérifie que le chemin reste sous content/. + * @param {string} targetPath Chemin absolu à contrôler. + * @param {string} contentRoot Racine content/. + */ +function ensureWithinContent(targetPath, contentRoot) { + const relative = path.relative(contentRoot, targetPath); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error(`Le chemin ${targetPath} est en dehors de content/.`); + } +} + +/** + * Découpe un chemin de bundle en segments relatifs. + * @param {string} bundleDir Chemin absolu du bundle. + * @param {string} contentRoot Racine content/. + * @returns {string[]} Segments relatifs. + */ +function splitRelativeParts(bundleDir, contentRoot) { + const relative = path.relative(contentRoot, bundleDir); + return relative.split(path.sep).filter(Boolean); +} + +/** + * Résout la destination finale en tenant compte de l'arborescence de dates. + * @param {string} input Chemin de destination fourni. + * @param {string} slug Slug du bundle source. + * @param {{ segments: string[] }|null} sourceDate Segments de date de la source. + * @param {string} contentRoot Racine content/. + * @param {Function} isDateSegment Fonction de détection de date. + * @returns {{ bundleDir: string }} Chemin final du bundle. + */ +function resolveDestination(input, slug, sourceDate, contentRoot, isDateSegment) { + const resolved = path.resolve(input); + let destinationDir = resolved; + if (resolved.toLowerCase().endsWith(`${path.sep}index.md`)) { + destinationDir = path.dirname(resolved); + } + + let includesSlug = false; + if (path.basename(destinationDir) === slug) { + includesSlug = true; + } + + let baseDir = destinationDir; + if (includesSlug) { + baseDir = path.dirname(destinationDir); + } + + const destDate = findDateSegments(splitRelativeParts(baseDir, contentRoot), isDateSegment); + if (sourceDate && !destDate) { + baseDir = path.join(baseDir, ...sourceDate.segments); + } + + let bundleDir = baseDir; + if (!includesSlug) { + bundleDir = path.join(baseDir, slug); + } + + return { bundleDir }; +} + +/** + * Déplace un bundle dans sa nouvelle destination. + * @param {string} sourceDir Chemin source. + * @param {string} destinationDir Chemin cible. + */ +function moveBundle(sourceDir, destinationDir) { + fs.mkdirSync(path.dirname(destinationDir), { recursive: true }); + fs.renameSync(sourceDir, destinationDir); +} + +/** + * Ajoute un alias Hugo vers l'ancien chemin. + * @param {string} bundleDir Chemin du bundle déplacé. + * @param {string[]} oldParts Segments de l'ancien chemin relatif. + */ +function addAlias(bundleDir, oldParts) { + const indexPath = path.join(bundleDir, "index.md"); + const frontmatter = readFrontmatterFile(indexPath); + if (!frontmatter) { + throw new Error(`Frontmatter introuvable pour ${bundleDir}.`); + } + + const alias = `/${oldParts.join("/")}/`; + const aliases = normalizeAliases(frontmatter.data.aliases); + if (!aliases.includes(alias)) { + aliases.push(alias); + } + frontmatter.data.aliases = aliases; + writeFrontmatterFile(indexPath, frontmatter.data, frontmatter.body); +} + +/** + * Normalise un champ aliases en tableau de chaînes. + * @param {unknown} value Valeur brute du frontmatter. + * @returns {string[]} Tableau nettoyé. + */ +function normalizeAliases(value) { + const aliases = []; + if (Array.isArray(value)) { + for (const entry of value) { + if (typeof entry === "string" && entry.trim()) { + aliases.push(entry.trim()); + } + } + return aliases; + } + if (typeof value === "string" && value.trim()) { + aliases.push(value.trim()); + } + return aliases; +} + +/** + * Supprime les dossiers parents vides jusqu'à content/. + * @param {string} startDir Dossier de départ. + * @param {string} stopDir Dossier racine à préserver. + */ +function cleanupEmptyParents(startDir, stopDir) { + let current = startDir; + while (current.startsWith(stopDir)) { + if (!fs.existsSync(current)) { + current = path.dirname(current); + continue; + } + const entries = fs.readdirSync(current); + if (entries.length > 0) { + return; + } + fs.rmdirSync(current); + if (current === stopDir) { + return; + } + current = path.dirname(current); + } +} + +/** + * Détecte une arborescence de date dans un chemin. + * @param {string[]} parts Segments à analyser. + * @param {Function} isDateSegment Fonction de détection de date. + * @returns {{ segments: string[] }|null} Segments de date ou null. + */ +function findDateSegments(parts, isDateSegment) { + let index = 0; + while (index < parts.length - 2) { + const dateSegments = isDateSegment(parts, index); + if (dateSegments) { + return { segments: dateSegments }; + } + index += 1; + } + return null; +} + +module.exports = { + resolveBundlePath, + ensureBundleExists, + ensureWithinContent, + splitRelativeParts, + resolveDestination, + moveBundle, + addAlias, + cleanupEmptyParents, + findDateSegments, +}; diff --git a/tools/lib/lemmy.js b/tools/lib/lemmy.js new file mode 100644 index 00000000..0bde24a3 --- /dev/null +++ b/tools/lib/lemmy.js @@ -0,0 +1,404 @@ +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, +}; diff --git a/tools/move_article.js b/tools/move_article.js new file mode 100644 index 00000000..e23fdfd4 --- /dev/null +++ b/tools/move_article.js @@ -0,0 +1,221 @@ +#!/usr/bin/env node + +const fs = require("node:fs"); +const path = require("node:path"); +const { Pool } = require("pg"); +const { loadEnv } = require("./lib/env"); +const { loadToolsConfig } = require("./lib/config"); +const { readFrontmatterFile } = require("./lib/frontmatter"); +const { + resolveBundlePath, + ensureBundleExists, + ensureWithinContent, + splitRelativeParts, + resolveDestination, + moveBundle, + addAlias, + cleanupEmptyParents, + findDateSegments, +} = require("./lib/article_move"); +const { + normalizeLemmyConfig, + createLemmyClient, + buildArticleUrl, + buildCommunityDescriptor, + ensureCommunity, + isYearSegment, + isMonthSegment, + isDaySegment, +} = require("./lib/lemmy"); + +const CONTENT_ROOT = path.join(__dirname, "..", "content"); +const FRONTMATTER_COMMENT_FIELD = "comments_url"; +const DEFAULT_DATABASE_URL = "postgres:///lemmy?host=/run/postgresql&user=richard"; +main().then( + () => { + process.exit(0); + }, + (error) => { + console.error(`❌ Déplacement interrompu : ${error.message}`); + process.exit(1); + } +); + +/** + * Point d'entrée : déplace le bundle et synchronise Lemmy si possible. + */ +async function main() { + loadEnv(); + const args = process.argv.slice(2); + if (args.length < 2) { + throw new Error("Usage: node tools/move_article.js "); + } + + const sourceInput = args[0]; + const destinationInput = args[1]; + + const sourceBundle = resolveBundlePath(sourceInput); + ensureBundleExists(sourceBundle); + ensureWithinContent(sourceBundle, CONTENT_ROOT); + + const sourceRelativeParts = splitRelativeParts(sourceBundle, CONTENT_ROOT); + const sourceSlug = sourceRelativeParts[sourceRelativeParts.length - 1]; + const sourceDate = findDateSegments(sourceRelativeParts.slice(0, -1), isDateSegment); + + const destination = resolveDestination( + destinationInput, + sourceSlug, + sourceDate, + CONTENT_ROOT, + isDateSegment + ); + ensureWithinContent(destination.bundleDir, CONTENT_ROOT); + + if (fs.existsSync(destination.bundleDir)) { + throw new Error(`Le bundle ${destination.bundleDir} existe déjà.`); + } + + const sourceFrontmatter = readFrontmatterFile(path.join(sourceBundle, "index.md")); + if (!sourceFrontmatter) { + throw new Error(`Frontmatter introuvable pour ${sourceBundle}.`); + } + + moveBundle(sourceBundle, destination.bundleDir); + addAlias(destination.bundleDir, sourceRelativeParts); + cleanupEmptyParents(path.dirname(sourceBundle), CONTENT_ROOT); + + await updateLemmyIfNeeded(sourceFrontmatter.data, destination.bundleDir); +} + +/** + * Met à jour Lemmy si un comments_url est présent. + * @param {object} frontmatterData Données du frontmatter. + * @param {string} bundleDir Chemin du bundle après déplacement. + */ +async function updateLemmyIfNeeded(frontmatterData, bundleDir) { + const commentsUrl = extractCommentsUrl(frontmatterData); + if (!commentsUrl) { + return; + } + + const postId = extractPostId(commentsUrl); + if (!postId) { + console.warn("⚠️ comments_url invalide, mise à jour Lemmy ignorée."); + return; + } + + const toolsConfig = await loadToolsConfig(path.join(__dirname, "config", "config.json")); + const lemmyConfig = normalizeLemmyConfig(toolsConfig.lemmy); + const client = await createLemmyClient(lemmyConfig); + const databaseUrl = resolveDatabaseUrl(); + const pool = new Pool({ connectionString: databaseUrl }); + + const bundleParts = splitRelativeParts(bundleDir, CONTENT_ROOT); + const descriptor = buildCommunityDescriptor(bundleParts, lemmyConfig.community); + const community = await ensureCommunity(client, descriptor, lemmyConfig.community); + const communityId = community.id; + await updatePostForMove(pool, postId, communityId, buildArticleUrl(lemmyConfig.siteUrl, bundleParts)); + + await pool.end(); +} + +/** + * Extrait une URL de commentaires depuis les données du frontmatter. + * @param {object} frontmatterData Données du frontmatter. + * @returns {string} URL des commentaires ou chaîne vide. + */ +function extractCommentsUrl(frontmatterData) { + if (!frontmatterData) { + return ""; + } + if (typeof frontmatterData[FRONTMATTER_COMMENT_FIELD] !== "string") { + return ""; + } + return frontmatterData[FRONTMATTER_COMMENT_FIELD].trim(); +} + +/** + * 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); +} + +/** + * Met à jour le post Lemmy après déplacement. + * @param {Pool} pool Pool Postgres. + * @param {number} postId Identifiant du post. + * @param {number} communityId Communauté cible. + * @param {string} newUrl Nouvelle URL Hugo. + */ +async function updatePostForMove(pool, postId, communityId, newUrl) { + await pool.query("update post set community_id = $1, url = $2 where id = $3", [ + communityId, + newUrl, + postId, + ]); + + const hasAggregates = await tableHasColumn(pool, "post_aggregates", "community_id"); + if (hasAggregates) { + await pool.query("update post_aggregates set community_id = $1 where post_id = $2", [ + communityId, + postId, + ]); + } +} + +/** + * Indique si une table possède une colonne donnée. + * @param {Pool} pool Pool Postgres. + * @param {string} tableName Nom de la table. + * @param {string} columnName Nom de la colonne. + * @returns {Promise} true si la colonne existe. + */ +async function tableHasColumn(pool, tableName, columnName) { + const result = await pool.query( + "select column_name from information_schema.columns where table_name = $1 and column_name = $2 limit 1", + [tableName, columnName] + ); + return result.rowCount === 1; +} + +/** + * Résout l'URL de connexion Postgres pour Lemmy. + * @returns {string} URL 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(); + } + return DEFAULT_DATABASE_URL; +} + +/** + * Détermine si des segments forment une date au format année/mois/jour. + * @param {string[]} parts Segments du chemin. + * @param {number} index Position de départ. + * @returns {string[]|null} Segments de date ou null. + */ +function isDateSegment(parts, index) { + if (!isYearSegment(parts[index])) { + return null; + } + if (!isMonthSegment(parts[index + 1])) { + return null; + } + if (!isDaySegment(parts[index + 2])) { + return null; + } + return [parts[index], parts[index + 1], parts[index + 2]]; +} diff --git a/tools/update_lemmy_post_dates.js b/tools/update_lemmy_post_dates.js index fbd04d56..7876296e 100644 --- a/tools/update_lemmy_post_dates.js +++ b/tools/update_lemmy_post_dates.js @@ -26,7 +26,7 @@ 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 DEFAULT_DATABASE_URL = "postgres:///lemmy?host=/run/postgresql&user=richard"; const TOOLS_CONFIG_PATH = path.join(__dirname, "config", "config.json"); const THUMBNAIL_CACHE_DIR = path.join(__dirname, "cache", "lemmy_thumbnails"); const THUMBNAIL_FORMAT = "png";