Livret PDF v2, graphe des liens côté campagne, et refactor de l'import
Livret PDF (export campagne) :
- refonte lisibilité façon livre de JdR : sommaire paginé, libellés de champs
en tête de ligne, encadrés « À lire aux joueurs » / secrets MJ / combat,
ligne de contexte des scènes, en-tête courant, tables zébrées
- reflète la fusion quête/conteneur : arc SYSTEM masqué, quêtes de hub
fusionnées dans leur chapitre, partie « Quêtes » sans doublon
- contenu complété : sorties de scènes, ennemis liés au bestiaire, pièces
explorables, partie « Tables aléatoires »
- polices DejaVu embarquées (couverture Unicode, licence incluse) et harness
visuel PdfExportPreviewTest (Mockito pur, tourne sans Postgres)
Graphe des liens :
- déplacé du Lore vers la campagne (les PNJ sont des entités de campagne) ;
endpoint /api/npcs/lore/{id} supprimé
- layout retravaillé (anti-chevauchement des libellés, éventail des feuilles
autour des hubs), scènes et quêtes ajoutées, libellés sur 2 lignes,
filtres de légende mémorisés, focus au survol
- positions drag & drop persistées sur la campagne (migration V23,
PUT /api/campaigns/{id}/graph-positions, bouton « Disposition auto »)
Divers :
- ImportService découpé en classes dédiées (parser d'archive, remapper d'ids,
inserters campagne/lore/état de jeu, conversion legacy des quêtes)
- fuites d'abonnements corrigées (takeUntilDestroyed sur paramMap) sur ~15 écrans
- garde-fou i18n fragments/consolidés branché sur npm run build (check-i18n.mjs),
budget SCSS anyComponentStyle relevé à 12 ko
This commit is contained in:
127
web/scripts/check-i18n.mjs
Normal file
127
web/scripts/check-i18n.mjs
Normal file
@@ -0,0 +1,127 @@
|
||||
// Vérificateur de cohérence i18n.
|
||||
//
|
||||
// Les traductions vivent en DOUBLE : les fichiers consolidés (src/assets/i18n/fr.json,
|
||||
// en.json — ceux que charge ngx-translate) et les fragments de maintenance
|
||||
// (src/assets/i18n/fragments/*.fr.json / *.en.json). Une section peut être alimentée
|
||||
// par PLUSIEURS fragments (ex. playthroughDetail) : un fragment est donc un
|
||||
// SOUS-ENSEMBLE du consolidé, pas une copie exacte.
|
||||
//
|
||||
// ERREURS (code retour 1) :
|
||||
// 1. clé de fragment absente du consolidé, ou valeur différente (le consolidé est
|
||||
// ce que voit l'utilisateur : un fragment en avance = trad perdue en prod) ;
|
||||
// 2. deux fragments qui définissent la même clé avec des valeurs contradictoires ;
|
||||
// 3. parité structurelle fr/en rompue (fr.json vs en.json, et chaque paire de
|
||||
// fragments x.fr.json / x.en.json) ;
|
||||
// 4. JSON invalide ou paire de fragments incomplète.
|
||||
//
|
||||
// AVERTISSEMENTS (non bloquants) : clés du consolidé couvertes par aucun fragment
|
||||
// (dette de maintenance, pas un bug utilisateur).
|
||||
//
|
||||
// Usage : node scripts/check-i18n.mjs
|
||||
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const webDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
||||
const i18nDir = path.join(webDir, 'src', 'assets', 'i18n');
|
||||
const fragmentsDir = path.join(i18nDir, 'fragments');
|
||||
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
|
||||
function loadJson(file) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(file, 'utf8'));
|
||||
} catch (e) {
|
||||
errors.push(`${path.relative(webDir, file)} : JSON invalide (${e.message})`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const isObject = v => v !== null && typeof v === 'object' && !Array.isArray(v);
|
||||
|
||||
/** Feuilles d'un objet : Map "a.b.c" -> valeur (les tableaux sont des feuilles). */
|
||||
function leaves(obj, prefix = '', out = new Map()) {
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const p = prefix ? `${prefix}.${key}` : key;
|
||||
if (isObject(value)) leaves(value, p, out);
|
||||
else out.set(p, value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const sameValue = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
||||
|
||||
/** Parité structurelle : mêmes chemins de feuilles des deux côtés. */
|
||||
function structuralDiff(labelA, a, labelB, b) {
|
||||
const pathsA = leaves(a);
|
||||
const pathsB = leaves(b);
|
||||
for (const p of pathsA.keys()) if (!pathsB.has(p)) errors.push(`${labelB} : clé manquante « ${p} » (présente dans ${labelA})`);
|
||||
for (const p of pathsB.keys()) if (!pathsA.has(p)) errors.push(`${labelA} : clé manquante « ${p} » (présente dans ${labelB})`);
|
||||
}
|
||||
|
||||
// --- Chargement -------------------------------------------------------------
|
||||
const consolidated = {
|
||||
fr: loadJson(path.join(i18nDir, 'fr.json')),
|
||||
en: loadJson(path.join(i18nDir, 'en.json'))
|
||||
};
|
||||
const consolidatedLeaves = {
|
||||
fr: consolidated.fr ? leaves(consolidated.fr) : new Map(),
|
||||
en: consolidated.en ? leaves(consolidated.en) : new Map()
|
||||
};
|
||||
const fragmentFiles = readdirSync(fragmentsDir).filter(f => f.endsWith('.json')).sort();
|
||||
|
||||
// --- 1 + 2 : chaque clé de fragment doit exister À L'IDENTIQUE au consolidé ---
|
||||
const fragmentLeafOwner = { fr: new Map(), en: new Map() };
|
||||
for (const file of fragmentFiles) {
|
||||
const match = file.match(/^(.+)\.(fr|en)\.json$/);
|
||||
if (!match) { errors.push(`fragments/${file} : nom inattendu (attendu <nom>.fr.json / <nom>.en.json)`); continue; }
|
||||
const lang = match[2];
|
||||
const fragment = loadJson(path.join(fragmentsDir, file));
|
||||
if (!fragment || !consolidated[lang]) continue;
|
||||
for (const [leafPath, value] of leaves(fragment)) {
|
||||
const owner = fragmentLeafOwner[lang].get(leafPath);
|
||||
if (owner && !sameValue(owner.value, value)) {
|
||||
errors.push(`Clé « ${leafPath} » (${lang}) contradictoire entre fragments/${owner.file} et fragments/${file}`);
|
||||
}
|
||||
fragmentLeafOwner[lang].set(leafPath, { file, value });
|
||||
if (!consolidatedLeaves[lang].has(leafPath)) {
|
||||
errors.push(`${lang}.json : clé « ${leafPath} » manquante (définie par fragments/${file})`);
|
||||
} else if (!sameValue(consolidatedLeaves[lang].get(leafPath), value)) {
|
||||
errors.push(`${lang}.json ≠ fragments/${file} : valeur différente pour « ${leafPath} »`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 3 : parité structurelle fr/en -------------------------------------------
|
||||
if (consolidated.fr && consolidated.en) structuralDiff('fr.json', consolidated.fr, 'en.json', consolidated.en);
|
||||
const fragmentNames = new Set(fragmentFiles.map(f => f.replace(/\.(fr|en)\.json$/, '')));
|
||||
for (const name of fragmentNames) {
|
||||
const frFile = `${name}.fr.json`;
|
||||
const enFile = `${name}.en.json`;
|
||||
if (!fragmentFiles.includes(frFile) || !fragmentFiles.includes(enFile)) {
|
||||
errors.push(`fragments/${name} : paire fr/en incomplète`);
|
||||
continue;
|
||||
}
|
||||
const fr = loadJson(path.join(fragmentsDir, frFile));
|
||||
const en = loadJson(path.join(fragmentsDir, enFile));
|
||||
if (fr && en) structuralDiff(`fragments/${frFile}`, fr, `fragments/${enFile}`, en);
|
||||
}
|
||||
|
||||
// --- Avertissements : clés consolidées sans fragment (dette, non bloquant) ----
|
||||
for (const lang of ['fr']) { // fr suffit : la parité fr/en est déjà vérifiée
|
||||
const uncovered = [...consolidatedLeaves[lang].keys()].filter(p => !fragmentLeafOwner[lang].has(p));
|
||||
if (uncovered.length > 0) {
|
||||
warnings.push(`${uncovered.length} clé(s) de ${lang}.json sans fragment (dette de maintenance), ex. : ${uncovered.slice(0, 5).join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Rapport ------------------------------------------------------------------
|
||||
for (const w of warnings) console.warn(`i18n (avertissement) : ${w}`);
|
||||
if (errors.length > 0) {
|
||||
console.error(`\ni18n : ${errors.length} incohérence(s) bloquante(s) :\n`);
|
||||
for (const e of errors) console.error(` - ${e}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`i18n OK — ${Object.keys(consolidated.fr ?? {}).length} sections, ${consolidatedLeaves.fr.size} clés, ${fragmentFiles.length} fragments.`);
|
||||
Reference in New Issue
Block a user