From 181223d3d9e8e9c9f2b9ff84aa0f1e785e8d1921 Mon Sep 17 00:00:00 2001 From: Richard Dern Date: Thu, 11 Dec 2025 00:02:53 +0100 Subject: [PATCH] Synchronisation du contenu avec lemmy --- deploy.sh | 3 + package-lock.json | 193 ++++++-- package.json | 6 +- tools/config/config.json | 26 +- tools/lib/config.js | 27 ++ tools/lib/content.js | 45 ++ tools/lib/datetime.js | 40 ++ tools/lib/frontmatter.js | 53 +++ tools/sync_lemmy_comments.js | 741 +++++++++++++++++++++++++++++++ tools/update_lemmy_post_dates.js | 184 ++++++++ 10 files changed, 1268 insertions(+), 50 deletions(-) create mode 100644 tools/lib/frontmatter.js create mode 100644 tools/sync_lemmy_comments.js create mode 100644 tools/update_lemmy_post_dates.js diff --git a/deploy.sh b/deploy.sh index df946842..2c0b699a 100755 --- a/deploy.sh +++ b/deploy.sh @@ -47,6 +47,9 @@ node "$SCRIPT_DIR/tools/check_internal_links.js" echo "==> Enrichissement météo des articles" node "$SCRIPT_DIR/tools/add_weather.js" +echo "==> Synchronisation des commentaires Lemmy" +node "$SCRIPT_DIR/tools/sync_lemmy_comments.js" + echo "==> Génération des statistiques" npm run stats:generate diff --git a/package-lock.json b/package-lock.json index 1e2b5fce..3339931d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,9 @@ "@napi-rs/canvas": "^0.1.59", "chart.js": "^4.4.4", "chartjs-node-canvas": "^5.0.0", + "lemmy-js-client": "^0.19.6", "luxon": "^3.7.2", + "pg": "^8.16.3", "playwright": "^1.49.0", "postcss-import": "^16.1.0", "postcss-nested": "^7.0.2", @@ -945,28 +947,6 @@ "node": ">= 8" } }, - "node_modules/@puppeteer/browsers": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.8.0.tgz", - "integrity": "sha512-yTwt2KWRmCQAfhvbCRjebaSX8pV1//I0Y3g+A7f/eS7gf0l4eRJoUCvcYdVtboeU4CTOZQuqYbZNS8aBYb8ROQ==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "debug": "^4.4.0", - "extract-zip": "^2.0.1", - "progress": "^2.0.3", - "proxy-agent": "^6.5.0", - "semver": "^7.7.1", - "tar-fs": "^3.0.8", - "yargs": "^17.7.2" - }, - "bin": { - "browsers": "lib/cjs/main-cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@react-aria/focus": { "version": "3.21.2", "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.21.2.tgz", @@ -2034,20 +2014,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/chromium-bidi": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-2.1.2.tgz", - "integrity": "sha512-vtRWBK2uImo5/W2oG6/cDkkHSm+2t6VHgnj+Rcwhb0pP74OoUb4GipyRX/T/y39gYQPhioP0DPShn+A7P6CHNw==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "mitt": "^3.0.1", - "zod": "^3.24.1" - }, - "peerDependencies": { - "devtools-protocol": "*" - } - }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -3728,6 +3694,12 @@ "node": ">=0.10.0" } }, + "node_modules/lemmy-js-client": { + "version": "0.19.6", + "resolved": "https://registry.npmjs.org/lemmy-js-client/-/lemmy-js-client-0.19.6.tgz", + "integrity": "sha512-5bs9NfjKcXVFJE3goHvu0Uo3u6+CMyQsccT2XCJyp0JExtNZM3F9oBl3t1/wiOMMGyNKiDJ+mCC9Y9gXwa1/iw==", + "license": "AGPL-3.0" + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -4205,6 +4177,96 @@ "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", "license": "MIT" }, + "node_modules/pg": { + "version": "8.16.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", + "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", + "license": "MIT", + "peer": true, + "dependencies": { + "pg-connection-string": "^2.9.1", + "pg-pool": "^3.10.1", + "pg-protocol": "^1.10.3", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.2.7" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz", + "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", + "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz", + "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", + "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4627,6 +4689,45 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -5464,7 +5565,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "dev": true, "license": "ISC", "engines": { "node": ">= 10.x" @@ -6115,6 +6215,15 @@ } } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -6173,16 +6282,6 @@ "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } - }, - "node_modules/zod": { - "version": "3.24.2", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", - "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", - "license": "MIT", - "optional": true, - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } } } } diff --git a/package.json b/package.json index cabba0b6..7d2874c8 100644 --- a/package.json +++ b/package.json @@ -4,17 +4,19 @@ "stats:generate": "node tools/generate_stats.js" }, "dependencies": { - "@napi-rs/canvas": "^0.1.59", "@influxdata/influxdb-client": "^1.35.0", + "@napi-rs/canvas": "^0.1.59", "chart.js": "^4.4.4", "chartjs-node-canvas": "^5.0.0", + "lemmy-js-client": "^0.19.6", "luxon": "^3.7.2", + "pg": "^8.16.3", + "playwright": "^1.49.0", "postcss-import": "^16.1.0", "postcss-nested": "^7.0.2", "puppeteer": "^23.11.1", "puppeteer-extra": "^3.3.6", "puppeteer-extra-plugin-stealth": "^2.11.2", - "playwright": "^1.49.0", "sharp": "^0.33.5", "undici": "^7.16.0", "user-agents": "^1.1.480" diff --git a/tools/config/config.json b/tools/config/config.json index 6df4d470..21a97350 100644 --- a/tools/config/config.json +++ b/tools/config/config.json @@ -108,5 +108,29 @@ }, "goaccess": { "url": null + }, + "lemmy": { + "instanceUrl": null, + "siteUrl": "https://richard-dern.fr", + "auth": { + "username": null, + "password": null, + "jwt": null + }, + "community": { + "prefixOverrides": { + "collections": "collection", + "critiques": "critique", + "interets": "interet" + }, + "visibility": "Public", + "nsfw": false, + "descriptionTemplate": "Espace dédié aux échanges autour de {{path}}." + }, + "cacheFile": "tools/cache/lemmy_sync.json", + "verificationTtlHours": { + "community": 168, + "post": 24 + } } -} +} \ No newline at end of file diff --git a/tools/lib/config.js b/tools/lib/config.js index a050439e..fa63a423 100644 --- a/tools/lib/config.js +++ b/tools/lib/config.js @@ -46,6 +46,33 @@ function applyEnvOverrides(config = {}) { merged.goaccess.url = process.env.GOACCESS_URL; } + const lemmy = config.lemmy || {}; + const community = lemmy.community || {}; + merged.lemmy = { + ...lemmy, + auth: { ...(lemmy.auth || {}) }, + community: { + ...community, + prefixOverrides: { ...(community.prefixOverrides || {}) }, + }, + verificationTtlHours: { ...(lemmy.verificationTtlHours || {}) }, + }; + if (process.env.LEMMY_INSTANCE_URL) { + merged.lemmy.instanceUrl = process.env.LEMMY_INSTANCE_URL; + } + if (process.env.LEMMY_SITE_URL) { + merged.lemmy.siteUrl = process.env.LEMMY_SITE_URL; + } + if (process.env.LEMMY_JWT) { + merged.lemmy.auth.jwt = process.env.LEMMY_JWT; + } + if (process.env.LEMMY_USERNAME) { + merged.lemmy.auth.username = process.env.LEMMY_USERNAME; + } + if (process.env.LEMMY_PASSWORD) { + merged.lemmy.auth.password = process.env.LEMMY_PASSWORD; + } + return merged; } diff --git a/tools/lib/content.js b/tools/lib/content.js index e8f0463a..ab12911e 100644 --- a/tools/lib/content.js +++ b/tools/lib/content.js @@ -92,8 +92,53 @@ async function resolveMarkdownTargets(inputs, { rootDir = process.cwd(), skipInd return Array.from(targets); } +async function collectBundles(rootDir) { + const bundles = []; + await walk(rootDir, rootDir, bundles); + bundles.sort((a, b) => a.relativePath.localeCompare(b.relativePath)); + return bundles; +} + +async function walk(rootDir, currentDir, bucket) { + let entries; + try { + entries = await fs.readdir(currentDir, { withFileTypes: true }); + } catch (error) { + console.warn(`⚠️ Lecture impossible de ${currentDir}: ${error.message}`); + return; + } + + let hasIndex = false; + for (const entry of entries) { + if (entry.isFile() && entry.name === "index.md") { + hasIndex = true; + break; + } + } + + if (hasIndex) { + const relative = path.relative(rootDir, currentDir); + const parts = relative.split(path.sep).filter(Boolean); + const slug = parts[parts.length - 1] || path.basename(currentDir); + bucket.push({ + dir: currentDir, + indexPath: path.join(currentDir, "index.md"), + relativePath: parts.join("/"), + parts, + slug, + }); + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.name === ".git" || entry.name === "node_modules") continue; + await walk(rootDir, path.join(currentDir, entry.name), bucket); + } +} + module.exports = { collectMarkdownFiles, collectSectionIndexDirs, resolveMarkdownTargets, + collectBundles, }; diff --git a/tools/lib/datetime.js b/tools/lib/datetime.js index b277898a..c4bb91d1 100644 --- a/tools/lib/datetime.js +++ b/tools/lib/datetime.js @@ -100,8 +100,48 @@ function formatDateTime(value = null) { return formatted; } +/** + * Convertit une valeur de frontmatter en DateTime si elle est valide. + * @param {import("luxon").DateTime|Date|string|number|null|undefined} value Valeur lue depuis le frontmatter. + * @returns {import("luxon").DateTime|null} DateTime utilisable ou null si invalide. + */ +function parseFrontmatterDate(value) { + const zone = getHugoTimeZone(); + + if (DateTime.isDateTime(value)) { + const zoned = value.setZone(zone); + return zoned.isValid ? zoned : null; + } + + if (value instanceof Date) { + const zoned = DateTime.fromJSDate(value, { zone }); + return zoned.isValid ? zoned : null; + } + + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + const iso = DateTime.fromISO(trimmed, { setZone: true }).setZone(zone); + if (iso.isValid) { + return iso; + } + const rfc2822 = DateTime.fromRFC2822(trimmed, { setZone: true }).setZone(zone); + return rfc2822.isValid ? rfc2822 : null; + } + + if (typeof value === "number" && Number.isFinite(value)) { + const zoned = DateTime.fromMillis(value, { zone }).setZone(zone); + return zoned.isValid ? zoned : null; + } + + return null; +} + module.exports = { formatDateTime, getHugoTimeZone, toHugoDateTime, + parseFrontmatterDate, }; diff --git a/tools/lib/frontmatter.js b/tools/lib/frontmatter.js new file mode 100644 index 00000000..7976e277 --- /dev/null +++ b/tools/lib/frontmatter.js @@ -0,0 +1,53 @@ +const fs = require("node:fs"); +const yaml = require("js-yaml"); + +const FRONTMATTER_PATTERN = /^---\n([\s\S]*?)\n---\n?/; + +/** + * Lit le frontmatter d'un fichier Markdown et retourne son contenu brut. + * La fonction préserve également le corps du fichier afin de permettre + * une réécriture propre après modification. + * @param {string} filePath Chemin absolu du fichier à analyser. + * @returns {{ data: Record, body: string, frontmatterText: string, raw: string }|null} + */ +function readFrontmatterFile(filePath) { + const raw = fs.readFileSync(filePath, "utf8"); + const match = raw.match(FRONTMATTER_PATTERN); + if (!match) { + return null; + } + const frontmatterText = match[1]; + const data = yaml.load(frontmatterText) || {}; + const body = raw.slice(match[0].length); + return { + data, + body, + frontmatterText, + raw, + }; +} + +/** + * Réécrit complètement le fichier Markdown avec un frontmatter mis à jour. + * @param {string} filePath Chemin absolu du fichier. + * @param {Record} frontmatter Objet contenant les métadonnées. + * @param {string} body Corps Markdown déjà prêt à être réinséré. + */ +function writeFrontmatterFile(filePath, frontmatter, body) { + if ( + typeof frontmatter !== "object" || + frontmatter === null || + Array.isArray(frontmatter) + ) { + throw new Error(`Frontmatter invalide pour ${filePath}`); + } + const serialized = yaml.dump(frontmatter, { lineWidth: 120, sortKeys: false }).trimEnd(); + const contentBody = typeof body === "string" ? body : ""; + const rewritten = `---\n${serialized}\n---\n${contentBody}`; + fs.writeFileSync(filePath, rewritten, "utf8"); +} + +module.exports = { + readFrontmatterFile, + writeFrontmatterFile, +}; diff --git a/tools/sync_lemmy_comments.js b/tools/sync_lemmy_comments.js new file mode 100644 index 00000000..f9a173d9 --- /dev/null +++ b/tools/sync_lemmy_comments.js @@ -0,0 +1,741 @@ +#!/usr/bin/env node + +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const path = require("node:path"); +const sharp = require("sharp"); +const { LemmyHttp } = require("lemmy-js-client"); +const { collectBundles } = require("./lib/content"); +const { loadToolsConfig } = require("./lib/config"); +const { parseFrontmatterDate } = require("./lib/datetime"); +const { readFrontmatterFile, writeFrontmatterFile } = require("./lib/frontmatter"); + +const CONTENT_ROOT = path.join(__dirname, "..", "content"); +const FRONTMATTER_COMMENT_FIELD = "comments_url"; +const FRONTMATTER_COVER_FIELD = "cover"; +const MAX_COMMUNITY_NAME_LENGTH = 20; +const MIN_COMMUNITY_NAME_LENGTH = 3; +const MAX_THUMBNAIL_WIDTH = 320; +const MAX_THUMBNAIL_HEIGHT = 240; +const THUMBNAIL_QUALITY = 82; +const THUMBNAIL_CACHE_DIR = path.join(__dirname, "cache", "lemmy_thumbnails"); +const THUMBNAIL_FORMAT = "png"; + +main().then( + () => { + process.exit(0); + }, + (error) => { + console.error(`❌ Synchronisation Lemmy interrompue : ${error.message}`); + process.exit(1); + } +); + +/** + * Point d'entrée principal : charge la configuration, collecte les articles et orchestre la synchronisation. + */ +async function main() { + const toolsConfig = await loadToolsConfig(path.join(__dirname, "config", "config.json")); + const lemmyConfig = normalizeLemmyConfig(toolsConfig.lemmy); + const client = await createLemmyClient(lemmyConfig); + const bundles = await collectBundles(CONTENT_ROOT); + const articles = selectArticles(bundles); + + if (articles.length === 0) { + console.log("Aucun article à synchroniser."); + return; + } + + for (const article of articles) { + await synchroniseArticle(article, lemmyConfig, client); + } +} + +/** + * Valide et normalise la configuration Lemmy issue des fichiers et variables d'environnement. + * @param {object} rawConfig Configuration telle que chargée par loadToolsConfig. + * @returns {object} Configuration prête à l'emploi pour Lemmy. + */ +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 || {}); + const descriptionTemplate = + typeof rawConfig.community?.descriptionTemplate === "string" && + rawConfig.community.descriptionTemplate.trim().length > 0 + ? rawConfig.community.descriptionTemplate.trim() + : "Espace dédié aux échanges autour de {{path}}."; + + return { + instanceUrl, + siteUrl, + auth: { + jwt: hasJwt ? auth.jwt.trim() : null, + username: hasCredentials ? auth.username.trim() : null, + password: hasCredentials ? auth.password : null, + }, + community: { + visibility: rawConfig.community?.visibility || "Public", + nsfw: rawConfig.community?.nsfw === true, + descriptionTemplate, + prefixOverrides, + }, + }; +} + +/** + * 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; +} + +/** + * Prépare la liste des articles à synchroniser : frontmatter présent, date valide, comments_url absent. + * Le tri est effectué par date croissante, puis par chemin en cas d'égalité. + * @param {Array} bundles Bundles collectés sous content/. + * @returns {Array} Articles prêts pour la synchronisation. + */ +function selectArticles(bundles) { + const articles = []; + + for (const bundle of bundles) { + const frontmatter = readFrontmatterFile(bundle.indexPath); + if (!frontmatter) { + console.warn(`⚠️ ${bundle.relativePath} : frontmatter introuvable, article ignoré.`); + continue; + } + + const existingComments = + typeof frontmatter.data?.[FRONTMATTER_COMMENT_FIELD] === "string" + ? frontmatter.data[FRONTMATTER_COMMENT_FIELD].trim() + : ""; + if (existingComments) { + continue; + } + + const publication = parseFrontmatterDate(frontmatter.data?.date); + if (!publication) { + console.warn(`⚠️ ${bundle.relativePath} : date absente ou invalide, article ignoré.`); + continue; + } + + const title = typeof frontmatter.data?.title === "string" ? frontmatter.data.title.trim() : ""; + if (!title) { + console.warn(`⚠️ ${bundle.relativePath} : titre manquant, article ignoré.`); + continue; + } + + articles.push({ + bundle, + frontmatter, + publication, + title, + }); + } + + 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; +} + +/** + * Synchronise un article unique : communauté, miniature, post Lemmy et mise à jour du frontmatter. + * @param {object} article Article préparé par selectArticles. + * @param {object} lemmyConfig Configuration Lemmy. + * @param {LemmyHttp} client Client Lemmy. + */ +async function synchroniseArticle(article, lemmyConfig, client) { + const communityDescriptor = buildCommunityDescriptor(article.bundle.parts, lemmyConfig.community); + const community = await ensureCommunity(client, communityDescriptor, lemmyConfig.community); + const coverPath = resolveCoverPath(article.bundle, article.frontmatter.data); + + let thumbnailUrl = null; + let thumbnailCachePath = null; + if (coverPath) { + const coverRelative = path.relative(process.cwd(), coverPath); + console.log(`Préparation de ${article.bundle.relativePath} (couverture : ${coverRelative})`); + + const thumbnail = await buildThumbnailAsset(coverPath, article.bundle); + thumbnailCachePath = thumbnail.cachePath; + console.log(`Miniature générée : ${thumbnail.cachePath}`); + + thumbnailUrl = await uploadThumbnail(client, thumbnail.buffer, { + cachePath: thumbnail.cachePath, + bundlePath: article.bundle.relativePath, + }); + } else { + console.warn( + `⚠️ ${article.bundle.relativePath} : aucune couverture exploitable, création du post sans miniature.` + ); + } + + const articleUrl = buildArticleUrl(lemmyConfig.siteUrl, article.bundle.parts); + const post = await ensurePost(client, community, { + title: article.title, + articleUrl, + thumbnailUrl, + }); + const commentsUrl = buildCommentsUrl(lemmyConfig.instanceUrl, post.id); + + article.frontmatter.data[FRONTMATTER_COMMENT_FIELD] = commentsUrl; + writeFrontmatterFile(article.bundle.indexPath, article.frontmatter.data, article.frontmatter.body); + cleanupThumbnail(thumbnailCachePath); + + console.log(`✅ ${article.bundle.relativePath} → ${community.name}`); +} + +/** + * 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 }; +} + +/** + * 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; +} + +/** + * 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; +} + +/** + * 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); +} + +/** + * 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})`; +} + +/** + * 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 }; +} + +/** + * Recherche une communauté par nom, et la crée si nécessaire. + * @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 }>} Communauté finalisée. + */ +async function ensureCommunity(client, descriptor, communityConfig) { + const existing = await searchCommunity(client, descriptor.name); + if (existing) { + return { id: existing.community.id, name: existing.community.name }; + } + const response = await client.createCommunity({ + name: descriptor.name, + title: descriptor.title, + description: descriptor.description, + nsfw: communityConfig.nsfw, + visibility: communityConfig.visibility, + }); + return { + id: response.community_view.community.id, + name: response.community_view.community.name, + }; +} + +/** + * 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; +} + +/** + * 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} 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. + * @returns {Promise} URL de la miniature hébergée par Lemmy. + */ +async function uploadThumbnail(client, thumbnailBuffer, info) { + 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}`); + } + return upload.url; +} + +/** + * Crée ou met à jour un post Lemmy correspondant à l'article. + * @param {LemmyHttp} client Client Lemmy. + * @param {{ id: number, name: string }} community Communauté cible. + * @param {{ title: string, articleUrl: string, thumbnailUrl: string|null }} payload Données du post. + * @returns {Promise<{ id: number }>} Post final. + */ +async function ensurePost(client, community, payload) { + const existing = await searchPostByUrl(client, community, payload.articleUrl); + if (existing) { + if (shouldUpdatePost(existing, payload.title, payload.thumbnailUrl, payload.articleUrl)) { + const edited = await client.editPost({ + post_id: existing.post.id, + name: payload.title, + url: payload.articleUrl, + custom_thumbnail: payload.thumbnailUrl || undefined, + }); + return { id: edited.post_view.post.id }; + } + return { id: existing.post.id }; + } + + const response = await client.createPost({ + name: payload.title, + community_id: community.id, + url: payload.articleUrl, + custom_thumbnail: payload.thumbnailUrl || undefined, + }); + return { id: response.post_view.post.id }; +} + +/** + * Vérifie la nécessité d'une mise à jour du post existant. + * @param {object} postView Post obtenu par recherche. + * @param {string} expectedTitle Titre attendu. + * @param {string|null} thumbnailUrl URL attendue pour la miniature. + * @param {string} articleUrl URL de l'article. + * @returns {boolean} true si une édition est requise. + */ +function shouldUpdatePost(postView, expectedTitle, thumbnailUrl, articleUrl) { + const currentTitle = typeof postView.post.name === "string" ? postView.post.name : ""; + const currentUrl = typeof postView.post.url === "string" ? postView.post.url : ""; + const currentThumbnail = + typeof postView.post.thumbnail_url === "string" ? postView.post.thumbnail_url : null; + + if (currentTitle !== expectedTitle) { + return true; + } + if (currentUrl !== articleUrl) { + return true; + } + if (thumbnailUrl && currentThumbnail !== thumbnailUrl) { + return true; + } + return false; +} + +/** + * Cherche un post existant pointant vers l'URL de l'article dans la communauté donnée. + * @param {LemmyHttp} client Client Lemmy. + * @param {{ id: number, name: string }} community Communauté cible. + * @param {string} articleUrl URL Hugo recherchée. + * @returns {Promise} PostView correspondant ou null. + */ +async function searchPostByUrl(client, community, articleUrl) { + const response = await client.search({ + q: articleUrl, + type_: "Url", + community_name: community.name, + limit: 50, + }); + if (!response.posts || response.posts.length === 0) { + return null; + } + return response.posts.find( + (postView) => + typeof postView.post.url === "string" && + postView.post.url.trim() === articleUrl && + postView.post.community_id === community.id + ) || null; +} + +/** + * Construit l'URL publique des commentaires Lemmy. + * @param {string} instanceUrl Domaine de l'instance Lemmy. + * @param {number} postId Identifiant du post. + * @returns {string} URL des commentaires. + */ +function buildCommentsUrl(instanceUrl, postId) { + return `${instanceUrl}/post/${postId}`; +} + +/** + * 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; +} + +/** + * 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; +} + +/** + * É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 créé. + * @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); +} diff --git a/tools/update_lemmy_post_dates.js b/tools/update_lemmy_post_dates.js new file mode 100644 index 00000000..6dfe8d43 --- /dev/null +++ b/tools/update_lemmy_post_dates.js @@ -0,0 +1,184 @@ +#!/usr/bin/env node + +/** + * Met à jour la date de publication 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). + */ + +const path = require("node:path"); +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 CONTENT_ROOT = path.join(__dirname, "..", "content"); +const DEFAULT_DATABASE_URL = "postgres:///lemmy?host=/run/postgresql&user=lemmy"; + +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 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; + + for (const article of articles) { + const targetDate = article.publication.set({ millisecond: 0 }); + const iso = targetDate.toISO(); + const row = await fetchPost(pool, article.postId); + 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()) { + unchanged += 1; + continue; + } + await applyDate(pool, article.postId, iso); + updated += 1; + console.log(`✅ Post ${article.postId} mis à ${iso} (${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} bundles Bundles Hugo. + * @returns {Array} 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, + 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; +} + +/** + * 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. + * @returns {Promise} Enregistrement ou null. + */ +async function fetchPost(pool, postId) { + const result = await pool.query("select id, published from post where id = $1", [postId]); + if (result.rowCount !== 1) { + return null; + } + return result.rows[0]; +} + +/** + * Applique la date ISO fournie sur le post ciblé. + * @param {Pool} pool Pool Postgres. + * @param {number} postId Identifiant du post. + * @param {string} isoDate Timestamp ISO. + */ +async function applyDate(pool, postId, isoDate) { + await pool.query("update post set published = $1 where id = $2", [isoDate, postId]); +}