Script pour déplacer des articles
This commit is contained in:
202
tools/lib/article_move.js
Normal file
202
tools/lib/article_move.js
Normal file
@@ -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,
|
||||
};
|
||||
404
tools/lib/lemmy.js
Normal file
404
tools/lib/lemmy.js
Normal file
@@ -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<LemmyHttp>} 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<object|null>} 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<string, string>} 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<string, string>} overrides Remplacements issus de la configuration.
|
||||
* @returns {Record<string, string>} 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,
|
||||
};
|
||||
221
tools/move_article.js
Normal file
221
tools/move_article.js
Normal file
@@ -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 <chemin_source> <chemin_destination>");
|
||||
}
|
||||
|
||||
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<boolean>} 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]];
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user