#!/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]]; }