405 lines
12 KiB
JavaScript
405 lines
12 KiB
JavaScript
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,
|
|
};
|