1

Compare commits

..

2 Commits

Author SHA1 Message Date
5b355aca6a Jurassic World Evolution 3 2026-03-05 11:00:09 +01:00
a849a21e59 Gérer le statut draft pour Lemmy et la météo 2026-03-04 23:51:12 +01:00
69 changed files with 742 additions and 8 deletions

View File

@@ -0,0 +1,4 @@
#title: ""
#attribution: ""
description: "L'écran d'accueil du jeu est à la hauteur de tout le reste : magnifique"
#prompt: ""

View File

@@ -0,0 +1,4 @@
#title: ""
#attribution: ""
description: "Première page de mes réglages graphiques"
#prompt: ""

View File

@@ -0,0 +1,4 @@
#title: ""
#attribution: ""
description: "Deuxième page de mes réglages graphiques"
#prompt: ""

View File

@@ -0,0 +1,4 @@
#title: ""
#attribution: ""
description: "Les écrans de chargement sont agrémentés du clin d'oeil à ces pauvres chèvres qui servent de repas."
#prompt: ""

View File

@@ -0,0 +1,4 @@
#title: ""
#attribution: ""
description: "La nouvelle carte de la campagne, partiellement révélée."
#prompt: ""

View File

@@ -0,0 +1,4 @@
#title: ""
#attribution: ""
description: "Couché de soleil sur une volière à Vegas."
#prompt: ""

View File

@@ -0,0 +1,4 @@
#title: ""
#attribution: ""
description: "Restaurants, boutiques de souvenirs et stands de boissons forment toujours le trio de base pour contenter les visiteurs."
#prompt: ""

View File

@@ -0,0 +1,4 @@
#title: ""
#attribution: ""
description: "Ils n'ont toujours pas de plumes, mais je les emmènerai bien en balade quand même..."
#prompt: ""

View File

@@ -0,0 +1,4 @@
#title: ""
#attribution: ""
description: "_JWE3_ a fait monter d'un cran l'aspect organique des parcs, mêlant objets naturels et constructions artificielles. On pourrait presque respirer la poussière et sentir le métal et le béton chauffés par le soleil."
#prompt: ""

View File

@@ -0,0 +1,4 @@
#title: ""
#attribution: ""
description: "Les _Diplodocus_ dans leur enclôs du Montana."
#prompt: ""

View File

@@ -0,0 +1,4 @@
#title: ""
#attribution: ""
#description: ""
#prompt: ""

View File

@@ -0,0 +1,4 @@
#title: ""
#attribution: ""
description: "Sur l'île de Kauai."
#prompt: ""

View File

@@ -0,0 +1,4 @@
#title: ""
#attribution: ""
description: "Et certaines parties sont mobiles ! Ici, l'antenne s'anime !"
#prompt: ""

View File

@@ -0,0 +1,4 @@
#title: ""
#attribution: ""
#description: ""
#prompt: ""

View File

@@ -0,0 +1,4 @@
#title: ""
#attribution: ""
description: "Là aussi, belle photo qui met en évidence la robe des _Parasaurolophus_, prise alors que je chutais de mon piédestal rocheux."
#prompt: ""

View File

@@ -0,0 +1,4 @@
#title: ""
#attribution: ""
description: "Katsuyama, au Japon."
#prompt: ""

View File

@@ -0,0 +1,4 @@
#title: ""
#attribution: ""
#description: ""
#prompt: ""

View File

@@ -0,0 +1,4 @@
#title: ""
#attribution: ""
#description: ""
#prompt: ""

View File

@@ -0,0 +1,4 @@
#title: ""
#attribution: ""
description: "Dire que je n'ai encore vu qu'un cinquième de la campagne à ce stade !"
#prompt: ""

View File

@@ -0,0 +1,4 @@
#title: ""
#attribution: ""
description: "Katsuyama, au Japon."
#prompt: ""

View File

@@ -0,0 +1,4 @@
#title: ""
#attribution: ""
description: "Je suis plutôt content de cette capture, même si j'étais en train de me casser la gueule entre les rochers..."
#prompt: ""

View File

@@ -0,0 +1,4 @@
#title: ""
#attribution: ""
description: "Plus près et... on commence à voir les faiblesses de certaines textures."
#prompt: ""

View File

@@ -0,0 +1,4 @@
#title: ""
#attribution: ""
description: "Jouant avec son frère."
#prompt: ""

View File

@@ -0,0 +1,4 @@
#title: ""
#attribution: ""
description: "Câliné par sa mère."
#prompt: ""

View File

@@ -0,0 +1,4 @@
#title: ""
#attribution: ""
#description: ""
#prompt: ""

View File

@@ -0,0 +1,4 @@
#title: ""
#attribution: ""
description: "Il y a quantité d'éléments de décor dans le catalogue, même sans compter sur le _marketplace_ du _workshop_."
#prompt: ""

View File

@@ -0,0 +1,4 @@
#title: ""
#attribution: ""
description: "Je n'ai pas résisté à la tentation, si déjà j'ai les éléments à portée de main. J'avoue, la composition aurait pu être bien meilleure."
#prompt: ""

View File

@@ -0,0 +1,4 @@
#title: ""
#attribution: ""
#description: ""
#prompt: ""

View File

@@ -0,0 +1,4 @@
#title: ""
#attribution: ""
description: "Le bateau de _Jurassic Park III_."
#prompt: ""

View File

@@ -0,0 +1,4 @@
#title: ""
#attribution: ""
description: "De quoi se faire un manoir musée, façon Lockwood."
#prompt: ""

View File

@@ -0,0 +1,4 @@
#title: ""
#attribution: ""
#description: ""
#prompt: ""

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 MiB

View File

@@ -0,0 +1,182 @@
---
title: Jurassic World Evolution 3
date: "2026-03-05 01:38:12"
cover: images/2958130_20260302225758_1.png
genres:
- jeu de gestion économique
entreprises:
- Epic Games Store
- Frontier Developments
- Microsoft Store
- PlayStation Store
- Steam
links:
- name: Page Wikipédia
url: https://en.wikipedia.org/wiki/Jurassic_World_Evolution_3
lang: en
- name: Site officiel
url: https://www.frontier.co.uk/games/jurassic-world-evolution-3
wikidata_id: Q134723499
weather:
temperature: 7.77777777777778
humidity: 62
pressure: 1024.38256370315
wind_speed: 3.5405568
wind_direction: 108
illuminance: 0
precipitations: false
source:
- influxdb
comments_url: https://com.richard-dern.fr/post/479
---
## En bref
- Le meilleur de la saga
- Bravo à Frontier qui a écouté les critiques des joueurs
## Configuration
Ma [machine de jeu](/interets/informatique/2022/04/15/mon-nouveau-pc-de-jeu-est-arrive/) est un Ryzen 9 5900X, 32G de mémoire vive, et une RTX3080 10G.
Elle tourne sous [NixOS](https://nixos.org/) 25.11.
Je joue sur un téléviseur [4K@120Hz](/interets/informatique/2024/01/15/2024-l-annee-du-changement/) (LG C3 55 pouces).
![](images/2958130_20260302225811_1.png)
![](images/2958130_20260302225818_1.png)
## Rappels
[Le premier opus](/critiques/jeux-video/jurassic-world-evolution/) était excellent, mais il manquait cruellement de contenu, en particulier concernant les objets et les options pour personnaliser les parcs.
J'estimais qu'il était beaucoup plus accessible que _Planet Coaster_ que j'avais acheté en même temps.
[Le deuxième volet](/critiques/jeux-video/jurassic-world-evolution-2/) a un peu étoffé le contenu, mais se trouvait grevé par d'autres défauts qui l'ont rendu, à mes yeux, moins intéressant au fil du temps, malgré ma conclusion originale.
## Critique
La campagne s'est largement étoffée, et nous offre l'accès à des contrées encore jamais explorées, pas même dans la saga cinématographique ou ses _spin-offs_.
Mais avant d'y évoluer, il faudra passer par des lieux déjà connus.
Un bon moyen de s'ancrer dans le canon avant d'introduire des nouveautés bienvenues, tout en permettant au joueur de se familiariser avec son environnement de gestion de parc.
Les habitués de la série de jeux-vidéo trouveront sans doute ces présentations un peu laborieuses, considérant que les mécaniques sous-jacentes n'ont pas vraiment changé.
Il s'agit toujours de recruter des scientifiques, d'envoyer des expéditions chercher des fossiles, en extraire l'ADN ou les vendre, jusqu'à pouvoir incuber des œufs, relâchés dans un enclos approprié, tout en s'assurant que les visiteurs sont contents du parc.
![](images/2958130_20260302230629_1.png)
Mais cette redondance apparente avec les deux volets précédents n'est finalement que la mécanique de base d'un jeu de gestion de parc à dinosaures, et _Jurassic World Evolution 3_ a beaucoup plus à offrir.
Et c'est tout l'intérêt de commencer par la campagne, même si on maîtrise le jeu de bout en bout.
![](images/2958130_20260302225950_1.png)
Il n'est plus question ici d'épisodes indépendants à achever séquentiellement.
Ici, les allers-retours seront fréquents, et l'histoire conduira le joueur à switcher d'un parc à un autre dès le début de la campagne.
Heureusement, ce papillonnage n'a pas de conséquence négative sur les parcs laissés en attente.
Ces interruptions, ces voyages d'un parc à un autre, permettent une rupture visuelle ponctuelle, ce qui n'est pas pour me déplaire, compte tenu de la diversité des lieux visités.
![](images/2958130_20260302230305_1.png)
![](images/2958130_20260302231244_1.png)
![](images/2958130_20260302231655_1.png)
{.center }
La beauté du jeu est phénoménale, et je crois qu'elle est notamment due à l'introduction du [ray-tracing](https://fr.wikipedia.org/wiki/Ray_tracing).
La lumière et les ombres sont beaucoup plus naturelles, apportent plus de contraste, et contribuent finalement à une immersion que je n'attendais pas d'un jeu de gestion de parc.
![](images/2958130_20260302231458_1.png)
![](images/2958130_20260302234755_1.png)
{.center }
![](images/2958130_20260302234356_1.png)
![](images/2958130_20260302234701_1.png)
La caméra à la première personne offre un point de vue rapproché.
Accessible à tout moment depuis le menu principal, elle permet de visiter littéralement son parc.
Ce n'est pas une nouveauté de _Jurassic World Evolution 3_, mais c'est dans ce nouvel opus que j'en fais le plus usage.
La raison ?
La richesse et la diversité de la faune, de la flore, des paysages et des structures, qui atteignent des sommets.
On est comme Sarah Harding dans [_The Lost World_](/critiques/films/the-lost-world-jurassic-park/) : on ne peut se contenter de regarder, il faut qu'on "touche".
![](images/2958130_20260302234506_1.png)
![](images/2958130_20260302235320_1.png)
![](images/2958130_20260302231149_1.png)
![](images/2958130_20260302231821_1.png)
Mais cette vue à la première personne est quelque peu frustrante : instinctivement, je reprends mes réflexes dans [_ARK: Survival Evolved_](/critiques/jeux-video/ark-survival-evolved/), je veux bondir, aller partout, gratouiller un dinosaure, interagir avec eux.
J'oublie que je suis dans un jeu de gestion de parc, principalement parce qu'à la première personne, je peux aller là où je ne suis pas censé aller en tant que _visiteur_ : à l'intérieur des enclos.
Pourtant, les déplacements sont à la fois contraints et hasardeux : il n'est pas rare de rester coincé dans les rochers, à la recherche de l'emplacement parfait pour une photo.
Et nul saut ne viendra nous aider, sinon les sauts épileptiques de la caméra qui tente de suivre un chemin qui n'a pas été prévu pour elle.
![](images/2958130_20260302235235_1.png)
Donc, pour parfaitement apprécier la vue à la première personne : restez dans les chemins prévus à cet effet !
![](images/2958130_20260302231731_1.png)
![](images/2958130_20260302231341_1.png)
![](images/2958130_20260302232056_1.png)
Malgré la complexité visuelle, apportée par la diversification des lieux visités, je ne me sens pas comme d'habitude, surchargé, incapable de reproduire ou enrichir ce que je vois.
Je n'ai pas l'impression que les outils mis à ma disposition sont trop compliqués pour que je puisse assembler correctement deux formes.
Pourtant, c'est le point qui me faisait le plus peur avec ce nouveau volet : j'ai eu vent de la diversification des outils de constructions, plus proches de _Planet Coaster_ que de _Jurassic World Evolution_.
Et si j'ai si peu joué à _Planet Coaster_, c'est parce que cette complexité me faisait peur.
Dans les deux épisodes précédents, je posais mes routes, mes bâtiments, mes clôtures, je m'occupais de mes animaux, et je ne me préoccupais pas du tout de l'aspect esthétique de mes parcs.
Mais ici, ça serait une trahison intellectuelle de ne pas exploiter tout ce que le jeu peut nous offrir.
![](images/2958130_20260302234545_1.png)
![](images/2958130_20260305002008_1.png)
![](images/2958130_20260305002508_1.png)
Et, maintenant que le jeu offre un _workshop_ (malheureusement pas intégré à celui de Steam), on peut télécharger des _blueprints_ qui sont, pour certains, de toute beauté.
Il y a de gros, gros fans _hardcore_ qui ont déjà accompli un travail absolument  positivement monstrueux.
![](images/2958130_20260305002732_1.png)
![](images/2958130_20260305002836_1.png)
![](images/2958130_20260305002858_1.png)
![](images/2958130_20260305002940_1.png)
Tout cela ferait presque oublier la vue classique d'un jeu de gestion de parc, alors qu'elle n'est pas moins méritante.
![](images/2958130_20260305001637_1.png)
L'optimisation du jeu est excellente.
Je peux zoomer ou dézoomer comme un porc, me balader aux quatres coins d'une immense carte en mode créatif, je n'ai à déplorer que de rares ralentissements, parfaitement compréhensibles sur des cartes très fournies.
Il est très clair que, sur ce point aussi, _JWE3_ a bénéficié d'améliorations considérables.
![](images/2958130_20260302225905_1.png)
On pourra toutefois reprocher des temps de chargement relativement longs, mais, surtout, l'absence apparente de sauvegarde automatique en quittant le jeu.
On doit sauvegarder manuellement, puis quitter.
Mais la grande nouveauté de cet épisode est l'introduction des bébés dinosaures.
![](images/2958130_20260302235531_1.png)
![](images/2958130_20260302235840_1.png)
Réunissez un mâle et une femelle, offrez-leur un petit nid d'amour (littéralement), et quelques instants plus tard, vous pourriez y découvrir un œuf, duquel sortira un juvénile, et tout ce qu'il a à offrir de plus "kawaï".
Après, vous le gardez ou vous le vendez...
## Appréciation
Le jeu a atteint une maturité insoupçonnée.
Je compare ici avec la saga _Civilization_, qui a trébuché avec le sixième épisode et qui s'est franchement viandé avec le septième, au point qu'on se demande si la série peut y survivre.
C'est rare qu'un épisode ultérieur soit meilleur que les précédents.
Mais c'est indéniablement le cas pour _Jurassic World Evolution 3_ qui comble tous les déficits de ses prédécesseurs.
À part pouvoir visiter l'intérieur des laboratoires et observer les scientifiques travailler, j'ignore ce qu'il pourrait manquer à ce jeu.

View File

@@ -6,6 +6,7 @@ const { extractRawDate, readFrontmatter, writeFrontmatter } = require("./lib/wea
const { resolveArticleDate } = require("./lib/weather/time");
const { fetchWeather, hasConfiguredProvider, mergeWeather } = require("./lib/weather/providers");
const { loadWeatherConfig } = require("./lib/weather/config");
const { isEffectivelyPublishedDocument } = require("./lib/publication");
const CONTENT_ROOT = path.resolve("content");
@@ -16,6 +17,10 @@ async function processFile(filePath, config, { force = false } = {}) {
return { status: "no-frontmatter" };
}
if (isEffectivelyPublishedDocument(frontmatter.doc) === false) {
return { status: "draft" };
}
let existingWeather = null;
if (frontmatter.doc.has("weather")) {
existingWeather = frontmatter.doc.get("weather");
@@ -124,6 +129,10 @@ async function main() {
updated += 1;
console.log(`• Added empty weather to ${relativePath}`);
break;
case "draft":
skipped += 1;
console.log(`• Skipped draft article ${relativePath}`);
break;
default:
skipped += 1;
}

71
tools/lib/publication.js Normal file
View File

@@ -0,0 +1,71 @@
/**
* Interprète une valeur booléenne potentiellement sérialisée.
* @param {unknown} value Valeur brute issue du frontmatter.
* @returns {boolean|null} true/false si interprétable, sinon null.
*/
function parseBoolean(value) {
if (typeof value === "boolean") {
return value;
}
if (typeof value !== "string") {
return null;
}
const normalized = value.trim().toLowerCase();
if (!normalized) {
return null;
}
if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on") {
return true;
}
if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "off") {
return false;
}
return null;
}
/**
* Détermine si la valeur `draft` représente un brouillon.
* @param {unknown} value Valeur brute de l'attribut `draft`.
* @returns {boolean} true si l'article est un brouillon.
*/
function isDraftValue(value) {
return parseBoolean(value) === true;
}
/**
* Indique si un frontmatter objet correspond à un article publié.
* @param {Record<string, unknown>|null|undefined} frontmatterData Données frontmatter sérialisées.
* @returns {boolean} true si l'article est considéré comme publié.
*/
function isEffectivelyPublished(frontmatterData) {
if (!frontmatterData || typeof frontmatterData !== "object") {
return true;
}
return isDraftValue(frontmatterData.draft) === false;
}
/**
* Indique si un document YAML frontmatter correspond à un article publié.
* @param {{ get: (key: string) => unknown }|null|undefined} doc Document YAML.
* @returns {boolean} true si l'article est considéré comme publié.
*/
function isEffectivelyPublishedDocument(doc) {
if (!doc || typeof doc.get !== "function") {
return true;
}
return isDraftValue(doc.get("draft")) === false;
}
module.exports = {
parseBoolean,
isDraftValue,
isEffectivelyPublished,
isEffectivelyPublishedDocument,
};

View File

@@ -6,6 +6,7 @@ const { Pool } = require("pg");
const { loadEnv } = require("./lib/env");
const { loadToolsConfig } = require("./lib/config");
const { readFrontmatterFile } = require("./lib/frontmatter");
const { isEffectivelyPublished } = require("./lib/publication");
const {
resolveBundlePath,
ensureBundleExists,
@@ -93,6 +94,10 @@ async function main() {
* @param {string} bundleDir Chemin du bundle après déplacement.
*/
async function updateLemmyIfNeeded(frontmatterData, bundleDir) {
if (isEffectivelyPublished(frontmatterData) === false) {
return;
}
const commentsUrl = extractCommentsUrl(frontmatterData);
if (!commentsUrl) {
return;

View File

@@ -5,14 +5,18 @@ const fs = require("node:fs");
const path = require("node:path");
const sharp = require("sharp");
const { LemmyHttp } = require("lemmy-js-client");
const { Pool } = require("pg");
const { collectBundles } = require("./lib/content");
const { loadToolsConfig } = require("./lib/config");
const { parseFrontmatterDate } = require("./lib/datetime");
const { loadEnv } = require("./lib/env");
const { readFrontmatterFile, writeFrontmatterFile } = require("./lib/frontmatter");
const { isEffectivelyPublished } = require("./lib/publication");
const CONTENT_ROOT = path.join(__dirname, "..", "content");
const FRONTMATTER_COMMENT_FIELD = "comments_url";
const FRONTMATTER_COVER_FIELD = "cover";
const DEFAULT_DATABASE_URL = "postgres:///lemmy?host=/run/postgresql&user=richard";
const MAX_COMMUNITY_NAME_LENGTH = 20;
const MIN_COMMUNITY_NAME_LENGTH = 3;
const MAX_THUMBNAIL_WIDTH = 320;
@@ -35,10 +39,12 @@ main().then(
* Point d'entrée principal : charge la configuration, collecte les articles et orchestre la synchronisation.
*/
async function main() {
loadEnv();
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);
await purgeDraftPosts(bundles, lemmyConfig, client);
console.log("Vérification des communautés Lemmy pour les fils existants…");
await ensureRestrictedCommunitiesForExistingThreads(bundles, lemmyConfig, client);
const articles = selectArticles(bundles);
@@ -128,6 +134,274 @@ async function createLemmyClient(lemmyConfig) {
return client;
}
/**
* Purge les posts Lemmy liés aux articles en brouillon.
* Règles appliquées :
* - un brouillon n'est jamais synchronisé ;
* - si un post existe déjà sur Lemmy, il est supprimé physiquement de la base ;
* - le champ comments_url est retiré du frontmatter du brouillon.
* @param {Array<object>} bundles Bundles Hugo collectés.
* @param {object} lemmyConfig Configuration Lemmy.
* @param {LemmyHttp} client Client Lemmy authentifié.
* @returns {Promise<void>} Promesse résolue une fois la purge terminée.
*/
async function purgeDraftPosts(bundles, lemmyConfig, client) {
const draftArticles = collectDraftArticles(bundles, lemmyConfig);
if (draftArticles.length === 0) {
return;
}
const postIds = new Set();
let cleanedFrontmatters = 0;
for (const article of draftArticles) {
const commentsUrl = extractCommentsUrl(article.frontmatter.data);
const postId = extractPostId(commentsUrl);
if (postId !== null) {
postIds.add(postId);
}
if (article.title) {
const matchingIds = await searchDraftPostsByTitle(
client,
article.title,
article.articleUrl,
lemmyConfig.siteUrl
);
for (const matchingId of matchingIds) {
postIds.add(matchingId);
}
} else {
console.warn(`⚠️ ${article.bundle.relativePath} : titre manquant, recherche Lemmy par titre ignorée.`);
}
if (clearDraftCommentsUrl(article)) {
cleanedFrontmatters += 1;
}
}
const targetIds = Array.from(postIds.values());
let deletedCount = 0;
if (targetIds.length > 0) {
const pool = new Pool({ connectionString: resolveDatabaseUrl() });
deletedCount = await deletePostsPermanently(pool, targetIds);
await pool.end();
}
if (deletedCount > 0 || cleanedFrontmatters > 0) {
console.log(
`🧹 Brouillons Lemmy : ${deletedCount} post(s) supprimé(s), ${cleanedFrontmatters} frontmatter(s) nettoyé(s).`
);
}
}
/**
* Construit la liste des articles en brouillon.
* @param {Array<object>} bundles Bundles Hugo collectés.
* @param {object} lemmyConfig Configuration Lemmy.
* @returns {Array<object>} Brouillons accompagnés de leur contexte Lemmy.
*/
function collectDraftArticles(bundles, lemmyConfig) {
const drafts = [];
for (const bundle of bundles) {
const frontmatter = readFrontmatterFile(bundle.indexPath);
if (!frontmatter) {
continue;
}
if (isEffectivelyPublished(frontmatter.data)) {
continue;
}
let title = "";
if (typeof frontmatter.data?.title === "string") {
title = frontmatter.data.title.trim();
}
drafts.push({
bundle,
frontmatter,
title,
articleUrl: buildArticleUrl(lemmyConfig.siteUrl, bundle.parts),
});
}
return drafts;
}
/**
* Recherche les posts Lemmy correspondant au titre et à l'URL d'un brouillon.
* @param {LemmyHttp} client Client Lemmy.
* @param {string} title Titre Hugo du brouillon.
* @param {string} articleUrl URL publique de l'article Hugo.
* @param {string} siteUrl URL racine du site Hugo.
* @returns {Promise<number[]>} Identifiants de posts correspondants.
*/
async function searchDraftPostsByTitle(client, title, articleUrl, siteUrl) {
const response = await client.search({
q: title,
type_: "Posts",
limit: 50,
});
if (!response.posts || response.posts.length === 0) {
return [];
}
const normalizedArticleUrl = normalizeUrl(articleUrl);
const normalizedSiteUrl = normalizeUrl(siteUrl);
const matches = [];
for (const postView of response.posts) {
const postTitle = readPostTitle(postView);
if (postTitle !== title) {
continue;
}
const postUrl = readPostUrl(postView);
if (!postUrl) {
continue;
}
const normalizedPostUrl = normalizeUrl(postUrl);
if (!normalizedPostUrl) {
continue;
}
if (normalizedPostUrl === normalizedArticleUrl) {
matches.push(postView.post.id);
continue;
}
if (normalizedSiteUrl && normalizedPostUrl.startsWith(`${normalizedSiteUrl}/`)) {
matches.push(postView.post.id);
}
}
return matches;
}
/**
* Supprime définitivement les posts Lemmy ciblés depuis la base.
* @param {Pool} pool Pool Postgres.
* @param {number[]} postIds Identifiants de posts à supprimer.
* @returns {Promise<number>} Nombre de posts affectés.
*/
async function deletePostsPermanently(pool, postIds) {
const query = "delete from post where id = any($1::int[])";
const result = await pool.query(query, [postIds]);
return result.rowCount;
}
/**
* Extrait un titre de post Lemmy nettoyé.
* @param {object} postView Résultat de recherche Lemmy.
* @returns {string} Titre nettoyé.
*/
function readPostTitle(postView) {
if (!postView || !postView.post) {
return "";
}
if (typeof postView.post.name !== "string") {
return "";
}
return postView.post.name.trim();
}
/**
* Extrait l'URL d'un post Lemmy nettoyée.
* @param {object} postView Résultat de recherche Lemmy.
* @returns {string} URL nettoyée.
*/
function readPostUrl(postView) {
if (!postView || !postView.post) {
return "";
}
if (typeof postView.post.url !== "string") {
return "";
}
return postView.post.url.trim();
}
/**
* Nettoie comments_url d'un brouillon pour empêcher toute synchronisation future.
* @param {object} article Brouillon collecté.
* @returns {boolean} true si le frontmatter a été modifié.
*/
function clearDraftCommentsUrl(article) {
const commentsUrl = extractCommentsUrl(article.frontmatter.data);
if (!commentsUrl) {
return false;
}
delete article.frontmatter.data[FRONTMATTER_COMMENT_FIELD];
writeFrontmatterFile(article.bundle.indexPath, article.frontmatter.data, article.frontmatter.body);
return true;
}
/**
* Extrait la valeur comments_url depuis un frontmatter sérialisé.
* @param {Record<string, unknown>|null|undefined} frontmatterData Données frontmatter.
* @returns {string} URL nettoyée ou chaîne vide.
*/
function extractCommentsUrl(frontmatterData) {
if (!frontmatterData || typeof frontmatterData !== "object") {
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) {
if (typeof url !== "string") {
return null;
}
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);
}
/**
* 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;
}
/**
* 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é.
@@ -144,10 +418,11 @@ function selectArticles(bundles) {
continue;
}
const existingComments =
typeof frontmatter.data?.[FRONTMATTER_COMMENT_FIELD] === "string"
? frontmatter.data[FRONTMATTER_COMMENT_FIELD].trim()
: "";
if (isEffectivelyPublished(frontmatter.data) === false) {
continue;
}
const existingComments = extractCommentsUrl(frontmatter.data);
if (existingComments) {
continue;
}
@@ -205,10 +480,11 @@ async function ensureRestrictedCommunitiesForExistingThreads(bundles, lemmyConfi
continue;
}
const existingComments =
typeof frontmatter.data?.[FRONTMATTER_COMMENT_FIELD] === "string"
? frontmatter.data[FRONTMATTER_COMMENT_FIELD].trim()
: "";
if (isEffectivelyPublished(frontmatter.data) === false) {
continue;
}
const existingComments = extractCommentsUrl(frontmatter.data);
if (!existingComments) {
continue;
}

View File

@@ -0,0 +1,63 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
parseBoolean,
isDraftValue,
isEffectivelyPublished,
isEffectivelyPublishedDocument,
} = require("../lib/publication");
test("parseBoolean converts common boolean representations", () => {
assert.equal(parseBoolean(true), true);
assert.equal(parseBoolean(false), false);
assert.equal(parseBoolean("true"), true);
assert.equal(parseBoolean("TRUE"), true);
assert.equal(parseBoolean("1"), true);
assert.equal(parseBoolean("on"), true);
assert.equal(parseBoolean("false"), false);
assert.equal(parseBoolean("0"), false);
assert.equal(parseBoolean("off"), false);
assert.equal(parseBoolean(""), null);
assert.equal(parseBoolean("unknown"), null);
assert.equal(parseBoolean(1), null);
});
test("isDraftValue returns true only for explicit draft values", () => {
assert.equal(isDraftValue(true), true);
assert.equal(isDraftValue("true"), true);
assert.equal(isDraftValue("yes"), true);
assert.equal(isDraftValue(false), false);
assert.equal(isDraftValue("false"), false);
assert.equal(isDraftValue(undefined), false);
});
test("isEffectivelyPublished excludes draft frontmatter", () => {
assert.equal(isEffectivelyPublished({ draft: true }), false);
assert.equal(isEffectivelyPublished({ draft: "true" }), false);
assert.equal(isEffectivelyPublished({ draft: false }), true);
assert.equal(isEffectivelyPublished({ title: "Article" }), true);
assert.equal(isEffectivelyPublished(null), true);
});
test("isEffectivelyPublishedDocument supports YAML-like get()", () => {
const docDraft = {
get(key) {
if (key === "draft") {
return true;
}
return null;
},
};
const docPublished = {
get(key) {
if (key === "draft") {
return false;
}
return null;
},
};
assert.equal(isEffectivelyPublishedDocument(docDraft), false);
assert.equal(isEffectivelyPublishedDocument(docPublished), true);
assert.equal(isEffectivelyPublishedDocument(null), true);
});

View File

@@ -24,6 +24,7 @@ const { parseFrontmatterDate } = require("./lib/datetime");
const { readFrontmatterFile } = require("./lib/frontmatter");
const { loadEnv } = require("./lib/env");
const { loadToolsConfig } = require("./lib/config");
const { isEffectivelyPublished } = require("./lib/publication");
const CONTENT_ROOT = path.join(__dirname, "..", "content");
const DEFAULT_DATABASE_URL = "postgres:///lemmy?host=/run/postgresql&user=richard";
@@ -207,6 +208,9 @@ function collectArticlesWithPostId(bundles) {
if (!frontmatter) {
continue;
}
if (isEffectivelyPublished(frontmatter.data) === false) {
continue;
}
const publication = parseFrontmatterDate(frontmatter.data?.date);
if (!publication) {
continue;