1

Compare commits

..

3 Commits

Author SHA1 Message Date
08f33a1409 Déplacement d'articles 2025-12-22 00:55:41 +01:00
7b1aeb78a3 Script pour déplacer des articles 2025-12-22 00:38:55 +01:00
08dd21653f Mise à jour de l'URL des commentaires 2025-12-22 00:07:20 +01:00
66 changed files with 867 additions and 54 deletions

View File

@@ -1,5 +1,5 @@
---
date: "2021-04-01 17:57:23"
date: '2021-04-01 17:57:23'
title: 'Covid-19 : Vous avez tous tort'
weather:
temperature: 18.1
@@ -12,6 +12,8 @@ weather:
source:
- open-meteo
comments_url: https://com.richard-dern.fr/post/264
aliases:
- /interets/informatique/2021/04/01/covid-19-vous-avez-tous-tort/
---
Vous n'êtes pas obligés de lire cet article si vous n'avez pas envie de vous sentir insultés. Mais je vous préviens : tout le monde va en prendre pour son grade.

View File

@@ -1,5 +1,5 @@
---
date: "2021-08-28 01:57:23"
date: '2021-08-28 01:57:23'
title: De retour
weather:
temperature: 11.6
@@ -12,6 +12,8 @@ weather:
source:
- open-meteo
comments_url: https://com.richard-dern.fr/post/265
aliases:
- /interets/informatique/2021/08/28/de-retour/
---
## Au chômage... et nouveau travail

View File

@@ -1,5 +1,5 @@
---
date: "2021-12-30 12:00:00"
date: '2021-12-30 12:00:00'
title: Bonne année 2022 !
oeuvres:
- L'Humain, cette espèce primitive
@@ -14,6 +14,8 @@ weather:
source:
- open-meteo
comments_url: https://com.richard-dern.fr/post/279
aliases:
- /interets/informatique/2021/12/30/bonne-annee-2022/
---
> Attention, article très long !
@@ -44,7 +46,7 @@ plus de votes positifs sont les suivants :
- [L'éco-responsabilité en informatique](/interets/informatique/2021/09/25/l-eco-responsabilite-en-informatique/) (publié le 25 septembre, avec une note de [6](https://www.journalduhacker.net/s/gvlelh/l_co_responsabilit_en_informatique))
J'ai essentiellement écrit des articles techniques, mais j'ai aussi écrit sur
[le Covid-19](/interets/informatique/2021/04/01/covid-19-vous-avez-tous-tort/), et je m'essaye
[le Covid-19](/interets/divers/2021/04/01/covid-19-vous-avez-tous-tort/), et je m'essaye
occasionnellement à l'art de l'aphorisme.
À peu près en même temps que le blog, je me suis mis au [Fediverse](https://fr.wikipedia.org/wiki/Fediverse),
@@ -227,7 +229,7 @@ ma vraie résolution 😁
C'était [ma résolution au 1er janvier 2021](/interets/informatique/2021/01/01/introduction/) : je
voulais garder mon blog en vie pendant au moins un an. On y est ! Il y a eu
[une pause](/interets/informatique/2021/08/28/de-retour/) due à mon retour à l'emploi depuis
[une pause](/interets/divers/2021/08/28/de-retour/) due à mon retour à l'emploi depuis
avril, mais mon blog est toujours là, et j'aimerais que ça soit toujours le cas
en 2022, surtout que je viens tout juste d'inaugurer
mon webring et une nouvelle page pour
@@ -374,7 +376,7 @@ parce que ça voudrait dire qu'ils ont enfin compris que "le Père Noël n'exist
pas, mais aussi que le Covid n'y est pour rien.
Dans un tout autre registre (et je rappelle, à ce titre, que j'ai déjà écrit
[un article sur la question en avril](/interets/informatique/2021/04/01/covid-19-vous-avez-tous-tort/))
[un article sur la question en avril](/interets/divers/2021/04/01/covid-19-vous-avez-tous-tort/))
ça ne sert à rien de pester contre les prises de décision du gouvernement si, de
son côté, le peuple fait de la merde. Si le peuple continue de se rassembler,
d'ignorer les règles d'hygiène les plus élémentaires, sous des prétextes

View File

@@ -1,5 +1,5 @@
---
date: "2022-05-16 12:00:00"
date: '2022-05-16 12:00:00'
title: Spam de Médecins Sans Frontières
weather:
temperature: 21.3
@@ -12,6 +12,8 @@ weather:
source:
- open-meteo
comments_url: https://com.richard-dern.fr/post/301
aliases:
- /interets/informatique/2022/05/16/spam-de-medecins-sans-frontieres/
---
J'ai reçu ce matin dans ma boîte aux lettres (physique) un curieux courrier, fort épais. C'est Médecins Sans Frontières qui me remercie pour ma contribution.

View File

@@ -1,5 +1,5 @@
---
date: "2022-10-19 12:00:00"
date: '2022-10-19 12:00:00'
title: Un opilion curieux
weather:
temperature: 15.6
@@ -12,6 +12,8 @@ weather:
source:
- open-meteo
comments_url: https://com.richard-dern.fr/post/306
aliases:
- /interets/informatique/2022/10/19/un-opilion-curieux/
---
Avec ses huit pattes, et les quelques vidéos qui circulent sur le net où on les voit massés par millions, les opilions ne sont pas forcément considérés comme des animaux particulièrement affectueux.

View File

@@ -13,21 +13,23 @@ weather:
wind_direction: 226
source:
- open-meteo
comments_url: https://com.richard-dern.fr/post/309
comments_url: https://com.richard-dern.fr/post/464
aliases:
- /interets/informatique/2022/12/23/bonne-annee-2023/
---
## Rétrospective de 2022
Autant le dire tout de suite, 2022 a été une année de merde, sur tous les plans.
Ainsi, alors que je m'enthousiasmais [l'an dernier](/interets/informatique/2021/12/30/bonne-annee-2022/#dans-ma-vie-professionnelle) de mon nouvel environnement de travail, celui-ci s'est considérablement dégradé en 2022.
Ainsi, alors que je m'enthousiasmais [l'an dernier](/interets/divers/2021/12/30/bonne-annee-2022/#dans-ma-vie-professionnelle) de mon nouvel environnement de travail, celui-ci s'est considérablement dégradé en 2022.
En sus de cette dégradation (que je ne souhaite pas détailler ici), c'est aussi à cause du travail que j'ai chopé le covid en mars.
Je suis en 100% télétravail toute l'année, je sors une fois...
J'ai fait chier tout le monde avec le masque, les règles d'hygiène, etc., et je suis le seul de mon entourage proche à l'avoir chopé.
Et je l'ai refilé à mon épouse.
Il était prévu que [nous partions aux États-Unis](/interets/informatique/2022/08/29/depart-aux-us-imminent/) en août.
Il était prévu que nous partions aux États-Unis en août.
Sauf que, attention surprise, mon épouse est partie seule.
Ma situation professionnelle s'est tellement dégradée que j'ai fait un "genre de _brown-out_" entre juin et août.

View File

@@ -1,5 +1,5 @@
---
date: "2023-05-07 12:00:00"
date: '2023-05-07 12:00:00'
title: Test de l'Ecovacs Deebot X1e Omni
weather:
temperature: 19
@@ -12,6 +12,8 @@ weather:
source:
- open-meteo
comments_url: https://com.richard-dern.fr/post/327
aliases:
- /interets/informatique/2023/05/08/test-de-l-ecovacs-deebot-x1-e-omni/
---
On a cassé la tirelire pour s'offrir une aide ménagère en provenance d'[Ecovacs](https://www.ecovacs.com/fr) : le [Deebot X1e Omni](https://www.ecovacs.com/fr/deebot-robotic-vacuum-cleaner/deebot-x1e-omni) (normalement tout orthographié en majuscules mais je n'ai pas envie de crier).

View File

@@ -1,5 +1,5 @@
---
date: "2023-12-31 12:00:00"
date: '2023-12-31 12:00:00'
title: Rétrospective 2023
oeuvres:
- L'Humain, cette espèce primitive
@@ -14,6 +14,8 @@ weather:
source:
- open-meteo
comments_url: https://com.richard-dern.fr/post/371
aliases:
- /interets/informatique/2023/12/31/retrospective-2023/
---
N'y allons pas par quatre chemins : 2023 a peut-être été pire que 2022.
@@ -186,7 +188,7 @@ Je me suis aussi abonné au magnifique trimestriel [*Espèces*](https://especes.
## Résolutions
[Je ne suis pas encore passé à la 4K](/interets/informatique/2022/12/23/bonne-annee-2023/#mes-résolutions) mais ça va venir début d'année 2024.
[Je ne suis pas encore passé à la 4K](/interets/divers/2022/12/23/bonne-annee-2023/#mes-résolutions) mais ça va venir début d'année 2024.
- ✅ j'ai gardé le blog un an de plus
- ❌ je n'ai pas publié une nouvelle version de Cyca, et je ne le ferai plus maintenant pour diverses raisons

View File

@@ -1,6 +1,6 @@
---
cover: images/dOZTmB.webp
date: "2024-03-04 12:00:00"
date: '2024-03-04 12:00:00'
title: Mon poulailler Omlet
weather:
temperature: 6.9
@@ -13,6 +13,8 @@ weather:
source:
- open-meteo
comments_url: https://com.richard-dern.fr/post/185
aliases:
- /interets/informatique/2024/03/04/mon-poulailler-omlet/
---
> Les liens présents dans cet article sont affiliés : je touche une commission de 5% sur chaque vente générée à partir de cet article, ce qui peut contribuer à faire vivre ce site.

View File

@@ -95,7 +95,7 @@ En bref, mon parcours professionnel est un désastre, mais je garde espoir de tr
<details class="update"><summary>Mise à jour du 28 août 2021</summary>
A priori, [j'ai trouvé](/interets/informatique/2021/08/28/de-retour/#au-chômage-et-nouveau-travail) :smile:
A priori, [j'ai trouvé](/interets/divers/2021/08/28/de-retour/#au-chômage-et-nouveau-travail) :smile:
</details>

View File

@@ -1,32 +0,0 @@
---
date: "2022-08-29 12:00:00"
title: 🇺🇸 Départ aux US imminent 🇺🇸
weather:
temperature: 24.3
humidity: 50
pressure: 1019
illuminance: 92491
precipitations: false
wind_speed: 13.4
wind_direction: 84
source:
- open-meteo
comments_url: https://com.richard-dern.fr/post/304
---
Nous (moi et mon épouse) adorons les États-Unis.
La nation, les paysages, les gens.
Nous avons fait un road-trip californien en 2016 que nous avons adoré de bout en bout.
En 2022, ça sera le Colorado, le Wyoming, l'Utah, sur la route des dinosaures, mais pas que.
Nous avons mis en place un partage de photos via iCloud pour ceux que ça intéresse, principalement la famille et les amis.
Si ça vous intéresse aussi, vous n'avez rien d'autre à faire que consulter régulièrement le lien ci-dessous.
- <https://www.icloud.com/sharedalbum/fr-fr/#B0rGI9HKKu20DY7>
Nous partons le vendredi 2 septembre.
Il ne faudra donc pas s'attendre à voir grand chose avant le week end suivant.
Le blog ne sera pas mis à jour pendant cette période, mais je serai connecté de temps en temps sur mon salon Matrix, et il est toujours possible de [me contacter](/contact/).
Bonnes vacances, ou bon retour de vacances !

View File

@@ -14,7 +14,7 @@ weather:
comments_url: https://com.richard-dern.fr/post/344
---
Présent sur le [fediverse](https://fr.wikipedia.org/wiki/Fediverse) dès les premiers jours de ce blog, [je m'en suis retiré pour ouvrir un serveur Matrix](/interets/informatique/2021/12/30/bonne-annee-2022/) avant de finalement décider que [je n'avais plus envie de le maintenir](/interets/informatique/2023/03/24/matrix-c-est-fini/).
Présent sur le [fediverse](https://fr.wikipedia.org/wiki/Fediverse) dès les premiers jours de ce blog, [je m'en suis retiré pour ouvrir un serveur Matrix](/interets/divers/2021/12/30/bonne-annee-2022/) avant de finalement décider que [je n'avais plus envie de le maintenir](/interets/informatique/2023/03/24/matrix-c-est-fini/).
## Contexte

View File

@@ -14,7 +14,7 @@ weather:
comments_url: https://com.richard-dern.fr/post/372
---
Après [une année 2023 catastrophique](/interets/informatique/2023/12/31/retrospective-2023/), il est temps d'ouvrir un nouveau chapitre de mon existence.
Après [une année 2023 catastrophique](/interets/divers/2023/12/31/retrospective-2023/), il est temps d'ouvrir un nouveau chapitre de mon existence.
Je dois consolider un état d'esprit positif, et étant matérialiste, ça passe évidemment par l'acquisition de biens matériel.
La banque a fait la gueule, moi par contre, ça va mieux, pourvu que ça dure 😊

View File

@@ -37,7 +37,7 @@ Passer par une régie offre un certain nombre davantages :
- La gestion unifiée des liens daffiliation (un même code source de mon côté pour générer des liens pour de nombreux partenaires)
- Les campagnes promotionnelles qui peuvent me donner loccasion de publier des articles à forte valeur ajoutée
Ce dernier point nest pas spécifique aux régies : [Omlet](https://www.omlet.fr/) propose une affiliation directe, ça ne ma pas empêché de publier [un article](/interets/informatique/2024/03/04/mon-poulailler-omlet/) sur leurs produits que jutilise quotidiennement.
Ce dernier point nest pas spécifique aux régies : [Omlet](https://www.omlet.fr/) propose une affiliation directe, ça ne ma pas empêché de publier [un article](/interets/divers/2024/03/04/mon-poulailler-omlet/) sur leurs produits que jutilise quotidiennement.
Mais, je nai pas publié cet article dans le cadre dune vraie campagne publicitaire organisée par lentreprise.
Cest là que les régies se révèlent intéressantes puisque je suis régulièrement notifié de la disponibilité dune campagne pour lun de mes annonceurs.

View File

@@ -253,7 +253,7 @@ Ici encore, un esprit critique permet de sinterroger :
Ce type de biais est omniprésent, notamment dans les contenus sponsorisés déguisés en analyses neutres.
On ne cherche pas forcément à décrédibiliser l'auteur du contenu : il s'agit simplement de s'assurer de l'authenticité de son contenu.
Par exemple, quand [je parle du poulailler Omlet](/interets/informatique/2024/03/04/mon-poulailler-omlet/), seul article sponsorisé sur l'ensemble de mes sites, c'est sincère et authentique 😎
Par exemple, quand [je parle du poulailler Omlet](/interets/divers/2024/03/04/mon-poulailler-omlet/), seul article sponsorisé sur l'ensemble de mes sites, c'est sincère et authentique 😎
Dernier exemple dont je voulais vous parler : la méthode scientifique est précieuse pour détecter les orientations idéologiques et les biais médiatiques.
Une émission, une chaîne de télévision ou un journal peut présenter des faits réels, mais en les sélectionnant de manière à favoriser une narration spécifique.

View File

@@ -100,6 +100,6 @@ else
ssh "$DEST_USER@$DEST_HOST" "$CHOWN_BIN -R $TARGET_OWNER '$DEST_DIR'"
fi
sudo -u lemmy node tools/update_lemmy_post_dates.js
node tools/update_lemmy_post_dates.js
echo "==> Déploiement terminé avec succès."

202
tools/lib/article_move.js Normal file
View 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
View 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
View 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]];
}

View File

@@ -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";