Compare commits
3 Commits
chore/fix-
...
chore/appl
| Author | SHA1 | Date | |
|---|---|---|---|
| d45a8b285f | |||
| b6e04a9138 | |||
| dcf1fc2d0b |
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
@@ -1,12 +1,17 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// scripts/apply-annotation-ticket.mjs
|
// scripts/apply-annotation-ticket.mjs
|
||||||
|
//
|
||||||
// Applique un ticket Gitea "type/media | type/reference | type/comment" vers:
|
// Applique un ticket Gitea "type/media | type/reference | type/comment" vers:
|
||||||
//
|
//
|
||||||
// ✅ src/annotations/<oeuvre>/<chapitre>/<paraId>.yml (sharding par paragraphe)
|
// ✅ src/annotations/<oeuvre>/<chapitre>/<paraId>.yml (sharding par paragraphe)
|
||||||
// ✅ public/media/<oeuvre>/<chapitre>/<paraId>/<file>
|
// ✅ public/media/<oeuvre>/<chapitre>/<paraId>/<file>
|
||||||
//
|
//
|
||||||
// Robuste, idempotent, non destructif.
|
// Compat rétro : lit (si présent) l'ancien monolithe:
|
||||||
|
// src/annotations/<oeuvre>/<chapitre>.yml
|
||||||
|
// et deep-merge NON destructif dans le shard lors d'une nouvelle application,
|
||||||
|
// pour permettre une migration progressive sans perte.
|
||||||
//
|
//
|
||||||
|
// Robuste, idempotent, non destructif.
|
||||||
// DRY RUN si --dry-run
|
// DRY RUN si --dry-run
|
||||||
// Options: --dry-run --no-download --verify --strict --commit --close
|
// Options: --dry-run --no-download --verify --strict --commit --close
|
||||||
//
|
//
|
||||||
@@ -49,8 +54,8 @@ Flags:
|
|||||||
--dry-run : n'écrit rien (affiche un aperçu)
|
--dry-run : n'écrit rien (affiche un aperçu)
|
||||||
--no-download : n'essaie pas de télécharger les pièces jointes (media)
|
--no-download : n'essaie pas de télécharger les pièces jointes (media)
|
||||||
--verify : vérifie que (page, ancre) existent (dist/para-index.json si dispo, sinon baseline)
|
--verify : vérifie que (page, ancre) existent (dist/para-index.json si dispo, sinon baseline)
|
||||||
--strict : refuse si URL ref invalide (http/https) OU caption media vide
|
--strict : refuse si URL ref invalide (http/https) OU caption media vide OU verify impossible
|
||||||
--commit : git add + git commit (le script commit dans la branche courante)
|
--commit : git add + git commit (commit dans la branche courante)
|
||||||
--close : ferme le ticket (nécessite --commit)
|
--close : ferme le ticket (nécessite --commit)
|
||||||
|
|
||||||
Env requis:
|
Env requis:
|
||||||
@@ -191,6 +196,7 @@ function normalizeChemin(chemin) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizePageKeyFromChemin(chemin) {
|
function normalizePageKeyFromChemin(chemin) {
|
||||||
|
// ex: /archicrat-ia/chapitre-4/ => archicrat-ia/chapitre-4
|
||||||
return normalizeChemin(chemin).replace(/^\/+|\/+$/g, "");
|
return normalizeChemin(chemin).replace(/^\/+|\/+$/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,90 +232,156 @@ function isHttpUrl(u) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------ para-index (verify + sort) ------------------------------ */
|
function stableSortByTs(arr) {
|
||||||
|
if (!Array.isArray(arr)) return;
|
||||||
|
arr.sort((a, b) => {
|
||||||
|
const ta = Date.parse(a?.ts || "") || 0;
|
||||||
|
const tb = Date.parse(b?.ts || "") || 0;
|
||||||
|
if (ta !== tb) return ta - tb;
|
||||||
|
return JSON.stringify(a).localeCompare(JSON.stringify(b));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normPage(s) {
|
||||||
|
let x = String(s || "").trim();
|
||||||
|
if (!x) return "";
|
||||||
|
// retire origin si on a une URL complète
|
||||||
|
x = x.replace(/^https?:\/\/[^/]+/i, "");
|
||||||
|
// enlève query/hash
|
||||||
|
x = x.split("#")[0].split("?")[0];
|
||||||
|
// enlève index.html
|
||||||
|
x = x.replace(/index\.html$/i, "");
|
||||||
|
// enlève slashs de bord
|
||||||
|
x = x.replace(/^\/+/, "").replace(/\/+$/, "");
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------ para-index (verify + order) ------------------------------ */
|
||||||
|
|
||||||
async function loadParaOrderFromDist(pageKey) {
|
async function loadParaOrderFromDist(pageKey) {
|
||||||
const distIdx = path.join(CWD, "dist", "para-index.json");
|
const distIdx = path.join(CWD, "dist", "para-index.json");
|
||||||
if (!(await exists(distIdx))) return null;
|
if (!(await exists(distIdx))) return null;
|
||||||
|
|
||||||
let j;
|
let j;
|
||||||
try {
|
try {
|
||||||
j = JSON.parse(await fs.readFile(distIdx, "utf8"));
|
j = JSON.parse(await fs.readFile(distIdx, "utf8"));
|
||||||
} catch {
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const want = normPage(pageKey);
|
||||||
|
|
||||||
|
// Support A) { items:[{id,page,...}, ...] } (ou variantes)
|
||||||
|
const items = Array.isArray(j?.items)
|
||||||
|
? j.items
|
||||||
|
: Array.isArray(j?.index?.items)
|
||||||
|
? j.index.items
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (items) {
|
||||||
|
const ids = [];
|
||||||
|
for (const it of items) {
|
||||||
|
// page peut être dans plein de clés différentes
|
||||||
|
const pageCand = normPage(
|
||||||
|
it?.page ??
|
||||||
|
it?.pageKey ??
|
||||||
|
it?.path ??
|
||||||
|
it?.route ??
|
||||||
|
it?.href ??
|
||||||
|
it?.url ??
|
||||||
|
""
|
||||||
|
);
|
||||||
|
|
||||||
|
// id peut être dans plein de clés différentes
|
||||||
|
let id = String(it?.id ?? it?.paraId ?? it?.anchorId ?? it?.anchor ?? "");
|
||||||
|
if (id.startsWith("#")) id = id.slice(1);
|
||||||
|
|
||||||
|
if (pageCand === want && id) ids.push(id);
|
||||||
|
}
|
||||||
|
if (ids.length) return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Support B) { byId: { "p-...": { page:"...", ... }, ... } }
|
||||||
|
if (j?.byId && typeof j.byId === "object") {
|
||||||
|
const ids = Object.keys(j.byId)
|
||||||
|
.filter((id) => {
|
||||||
|
const meta = j.byId[id] || {};
|
||||||
|
const pageCand = normPage(meta.page ?? meta.pageKey ?? meta.path ?? meta.route ?? meta.url ?? "");
|
||||||
|
return pageCand === want;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ids.length) {
|
||||||
|
ids.sort((a, b) => {
|
||||||
|
const ia = paraIndexFromId(a);
|
||||||
|
const ib = paraIndexFromId(b);
|
||||||
|
if (Number.isFinite(ia) && Number.isFinite(ib) && ia !== ib) return ia - ib;
|
||||||
|
return String(a).localeCompare(String(b));
|
||||||
|
});
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Support C) { pages: { "archicrat-ia/chapitre-4": { ids:[...] } } } (ou variantes)
|
||||||
|
if (j?.pages && typeof j.pages === "object") {
|
||||||
|
// essaie de trouver la bonne clé même si elle est /.../ ou .../index.html
|
||||||
|
const keys = Object.keys(j.pages);
|
||||||
|
const hit = keys.find((k) => normPage(k) === want);
|
||||||
|
if (hit) {
|
||||||
|
const pg = j.pages[hit];
|
||||||
|
if (Array.isArray(pg?.ids)) return pg.ids.map(String);
|
||||||
|
if (Array.isArray(pg?.paras)) return pg.paras.map(String);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Support several shapes:
|
async function tryVerifyAnchor(pageKey, anchorId) {
|
||||||
// A) { items:[{id,page,...}, ...] }
|
// 1) dist/para-index.json : order complet si possible
|
||||||
if (Array.isArray(j?.items)) {
|
const order = await loadParaOrderFromDist(pageKey);
|
||||||
const ids = [];
|
if (order) return order.includes(anchorId);
|
||||||
for (const it of j.items) {
|
|
||||||
const p = String(it?.page || it?.pageKey || "");
|
// 1bis) dist/para-index.json : fallback “best effort” => recherche brute (IDs quasi uniques)
|
||||||
const id = String(it?.id || it?.paraId || "");
|
const distIdx = path.join(CWD, "dist", "para-index.json");
|
||||||
if (p === pageKey && id) ids.push(id);
|
if (await exists(distIdx)) {
|
||||||
}
|
try {
|
||||||
if (ids.length) return ids;
|
const raw = await fs.readFile(distIdx, "utf8");
|
||||||
}
|
if (raw.includes(`"${anchorId}"`) || raw.includes(`"#${anchorId}"`)) {
|
||||||
|
return true;
|
||||||
// B) { byId: { "p-...": { page:"archicrat-ia/chapitre-4", ... }, ... } }
|
|
||||||
if (j?.byId && typeof j.byId === "object") {
|
|
||||||
// cannot rebuild full order; but can verify existence
|
|
||||||
// return a pseudo-order map from known ids sorted by p-<n>- then alpha
|
|
||||||
const ids = Object.keys(j.byId).filter((id) => String(j.byId[id]?.page || "") === pageKey);
|
|
||||||
if (ids.length) {
|
|
||||||
ids.sort((a, b) => {
|
|
||||||
const ia = paraIndexFromId(a);
|
|
||||||
const ib = paraIndexFromId(b);
|
|
||||||
if (Number.isFinite(ia) && Number.isFinite(ib) && ia !== ib) return ia - ib;
|
|
||||||
return String(a).localeCompare(String(b));
|
|
||||||
});
|
|
||||||
return ids;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// C) { pages: { "archicrat-ia/chapitre-4": { ids:[...]} } }
|
|
||||||
if (j?.pages && typeof j.pages === "object") {
|
|
||||||
const pg = j.pages[pageKey];
|
|
||||||
if (Array.isArray(pg?.ids)) return pg.ids.map(String);
|
|
||||||
if (Array.isArray(pg?.paras)) return pg.paras.map(String);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function tryVerifyAnchor(pageKey, anchorId) {
|
|
||||||
// 1) dist/para-index.json
|
|
||||||
const order = await loadParaOrderFromDist(pageKey);
|
|
||||||
if (order) return order.includes(anchorId);
|
|
||||||
|
|
||||||
// 2) tests/anchors-baseline.json (fallback)
|
|
||||||
const base = path.join(CWD, "tests", "anchors-baseline.json");
|
|
||||||
if (await exists(base)) {
|
|
||||||
try {
|
|
||||||
const j = JSON.parse(await fs.readFile(base, "utf8"));
|
|
||||||
const candidates = [];
|
|
||||||
if (j?.pages && typeof j.pages === "object") {
|
|
||||||
for (const [k, v] of Object.entries(j.pages)) {
|
|
||||||
if (!Array.isArray(v)) continue;
|
|
||||||
if (String(k).includes(pageKey)) candidates.push(...v);
|
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
if (Array.isArray(j?.entries)) {
|
|
||||||
for (const it of j.entries) {
|
|
||||||
const p = String(it?.page || "");
|
|
||||||
const ids = it?.ids;
|
|
||||||
if (Array.isArray(ids) && p.includes(pageKey)) candidates.push(...ids);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (candidates.length) return candidates.some((x) => String(x) === anchorId);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2) tests/anchors-baseline.json (fallback)
|
||||||
|
const base = path.join(CWD, "tests", "anchors-baseline.json");
|
||||||
|
if (await exists(base)) {
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(await fs.readFile(base, "utf8"));
|
||||||
|
const candidates = [];
|
||||||
|
if (j?.pages && typeof j.pages === "object") {
|
||||||
|
for (const [k, v] of Object.entries(j.pages)) {
|
||||||
|
if (!Array.isArray(v)) continue;
|
||||||
|
if (normPage(k).includes(normPage(pageKey))) candidates.push(...v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(j?.entries)) {
|
||||||
|
for (const it of j.entries) {
|
||||||
|
const p = String(it?.page || "");
|
||||||
|
const ids = it?.ids;
|
||||||
|
if (Array.isArray(ids) && normPage(p).includes(normPage(pageKey))) candidates.push(...ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (candidates.length) return candidates.some((x) => String(x) === anchorId);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null; // cannot verify
|
||||||
}
|
}
|
||||||
|
|
||||||
return null; // cannot verify
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ----------------------------- deep merge helpers (non destructive) ----------------------------- */
|
/* ----------------------------- deep merge helpers (non destructive) ----------------------------- */
|
||||||
|
|
||||||
function keyMedia(x) {
|
function keyMedia(x) {
|
||||||
@@ -360,7 +432,6 @@ function deepMergeEntry(dst, src) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(v)) {
|
if (Array.isArray(v)) {
|
||||||
// fallback: union by JSON string
|
|
||||||
const cur = Array.isArray(dst[k]) ? dst[k] : [];
|
const cur = Array.isArray(dst[k]) ? dst[k] : [];
|
||||||
const seen = new Set(cur.map((x) => JSON.stringify(x)));
|
const seen = new Set(cur.map((x) => JSON.stringify(x)));
|
||||||
const out = [...cur];
|
const out = [...cur];
|
||||||
@@ -382,16 +453,6 @@ function deepMergeEntry(dst, src) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function stableSortByTs(arr) {
|
|
||||||
if (!Array.isArray(arr)) return;
|
|
||||||
arr.sort((a, b) => {
|
|
||||||
const ta = Date.parse(a?.ts || "") || 0;
|
|
||||||
const tb = Date.parse(b?.ts || "") || 0;
|
|
||||||
if (ta !== tb) return ta - tb;
|
|
||||||
return JSON.stringify(a).localeCompare(JSON.stringify(b));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ----------------------------- annotations I/O ----------------------------- */
|
/* ----------------------------- annotations I/O ----------------------------- */
|
||||||
|
|
||||||
async function loadAnnoDocYaml(fileAbs, pageKey) {
|
async function loadAnnoDocYaml(fileAbs, pageKey) {
|
||||||
@@ -424,9 +485,7 @@ async function loadAnnoDocYaml(fileAbs, pageKey) {
|
|||||||
function sortParasObject(paras, order) {
|
function sortParasObject(paras, order) {
|
||||||
const keys = Object.keys(paras || {});
|
const keys = Object.keys(paras || {});
|
||||||
const idx = new Map();
|
const idx = new Map();
|
||||||
if (Array.isArray(order)) {
|
if (Array.isArray(order)) order.forEach((id, i) => idx.set(String(id), i));
|
||||||
order.forEach((id, i) => idx.set(String(id), i));
|
|
||||||
}
|
|
||||||
|
|
||||||
keys.sort((a, b) => {
|
keys.sort((a, b) => {
|
||||||
const ha = idx.has(a);
|
const ha = idx.has(a);
|
||||||
@@ -448,9 +507,9 @@ function sortParasObject(paras, order) {
|
|||||||
|
|
||||||
async function saveAnnoDocYaml(fileAbs, doc, order = null) {
|
async function saveAnnoDocYaml(fileAbs, doc, order = null) {
|
||||||
await fs.mkdir(path.dirname(fileAbs), { recursive: true });
|
await fs.mkdir(path.dirname(fileAbs), { recursive: true });
|
||||||
|
|
||||||
doc.paras = sortParasObject(doc.paras, order);
|
doc.paras = sortParasObject(doc.paras, order);
|
||||||
|
|
||||||
// also sort known lists inside each para for stable diffs
|
|
||||||
for (const e of Object.values(doc.paras || {})) {
|
for (const e of Object.values(doc.paras || {})) {
|
||||||
if (!isPlainObject(e)) continue;
|
if (!isPlainObject(e)) continue;
|
||||||
stableSortByTs(e.media);
|
stableSortByTs(e.media);
|
||||||
@@ -632,7 +691,6 @@ async function main() {
|
|||||||
const pageKey = normalizePageKeyFromChemin(chemin);
|
const pageKey = normalizePageKeyFromChemin(chemin);
|
||||||
assert(pageKey, "Ticket: impossible de dériver pageKey.", 2);
|
assert(pageKey, "Ticket: impossible de dériver pageKey.", 2);
|
||||||
|
|
||||||
// para order (used for verify + sorting)
|
|
||||||
const paraOrder = DO_VERIFY ? await loadParaOrderFromDist(pageKey) : null;
|
const paraOrder = DO_VERIFY ? await loadParaOrderFromDist(pageKey) : null;
|
||||||
|
|
||||||
if (DO_VERIFY) {
|
if (DO_VERIFY) {
|
||||||
@@ -641,46 +699,43 @@ async function main() {
|
|||||||
throw Object.assign(new Error(`Ticket verify: ancre introuvable pour page "${pageKey}" => ${ancre}`), { __exitCode: 2 });
|
throw Object.assign(new Error(`Ticket verify: ancre introuvable pour page "${pageKey}" => ${ancre}`), { __exitCode: 2 });
|
||||||
}
|
}
|
||||||
if (ok === null) {
|
if (ok === null) {
|
||||||
if (STRICT) throw Object.assign(new Error(`Ticket verify (strict): impossible de vérifier (pas de dist/para-index.json ou baseline)`), { __exitCode: 2 });
|
if (STRICT) {
|
||||||
|
throw Object.assign(
|
||||||
|
new Error(`Ticket verify (strict): impossible de vérifier (pas de dist/para-index.json ou baseline)`),
|
||||||
|
{ __exitCode: 2 }
|
||||||
|
);
|
||||||
|
}
|
||||||
console.warn("⚠️ verify: impossible de vérifier (pas de dist/para-index.json ou baseline) — on continue.");
|
console.warn("⚠️ verify: impossible de vérifier (pas de dist/para-index.json ou baseline) — on continue.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ SHARD FILE: src/annotations/<pageKey>/<paraId>.yml
|
// ✅ shard path: src/annotations/<pageKey>/<paraId>.yml
|
||||||
const annoShardFileAbs = path.join(ANNO_DIR, pageKey, `${ancre}.yml`);
|
const shardAbs = path.join(ANNO_DIR, ...pageKey.split("/"), `${ancre}.yml`);
|
||||||
const annoShardFileRel = path.relative(CWD, annoShardFileAbs).replace(/\\/g, "/");
|
const shardRel = path.relative(CWD, shardAbs).replace(/\\/g, "/");
|
||||||
|
|
||||||
// legacy (read-only, used as base to avoid losing previously stored data)
|
// legacy monolith: src/annotations/<pageKey>.yml (read-only, for migration)
|
||||||
const annoLegacyFileAbs = path.join(ANNO_DIR, `${pageKey}.yml`);
|
const legacyAbs = path.join(ANNO_DIR, `${pageKey}.yml`);
|
||||||
|
|
||||||
console.log("✅ Parsed:", {
|
console.log("✅ Parsed:", { type, chemin, ancre: `#${ancre}`, pageKey, annoFile: shardRel });
|
||||||
type,
|
|
||||||
chemin,
|
|
||||||
ancre: `#${ancre}`,
|
|
||||||
pageKey,
|
|
||||||
annoFile: annoShardFileRel,
|
|
||||||
});
|
|
||||||
|
|
||||||
// load shard doc
|
// load shard doc
|
||||||
const doc = await loadAnnoDocYaml(annoShardFileAbs, pageKey);
|
const doc = await loadAnnoDocYaml(shardAbs, pageKey);
|
||||||
|
|
||||||
// merge legacy para into shard as base (non destructive)
|
|
||||||
if (await exists(annoLegacyFileAbs)) {
|
|
||||||
try {
|
|
||||||
const legacy = await loadAnnoDocYaml(annoLegacyFileAbs, pageKey);
|
|
||||||
const legacyEntry = legacy?.paras?.[ancre];
|
|
||||||
if (isPlainObject(legacyEntry)) {
|
|
||||||
if (!isPlainObject(doc.paras[ancre])) doc.paras[ancre] = {};
|
|
||||||
deepMergeEntry(doc.paras[ancre], legacyEntry);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore legacy parse issues (shard still works)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isPlainObject(doc.paras[ancre])) doc.paras[ancre] = {};
|
if (!isPlainObject(doc.paras[ancre])) doc.paras[ancre] = {};
|
||||||
const entry = doc.paras[ancre];
|
const entry = doc.paras[ancre];
|
||||||
|
|
||||||
|
// merge legacy entry into shard in-memory (non destructive) to keep compat + enable progressive migration
|
||||||
|
if (await exists(legacyAbs)) {
|
||||||
|
try {
|
||||||
|
const legacy = await loadAnnoDocYaml(legacyAbs, pageKey);
|
||||||
|
const legacyEntry = legacy?.paras?.[ancre];
|
||||||
|
if (isPlainObject(legacyEntry)) {
|
||||||
|
deepMergeEntry(entry, legacyEntry);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore legacy parse issues; shard still applies new data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const touchedFiles = [];
|
const touchedFiles = [];
|
||||||
const notes = [];
|
const notes = [];
|
||||||
let changed = false;
|
let changed = false;
|
||||||
@@ -696,10 +751,13 @@ async function main() {
|
|||||||
|
|
||||||
const before = entry.comments_editorial.length;
|
const before = entry.comments_editorial.length;
|
||||||
entry.comments_editorial = uniqUnion(entry.comments_editorial, [item], keyComment);
|
entry.comments_editorial = uniqUnion(entry.comments_editorial, [item], keyComment);
|
||||||
changed = changed || entry.comments_editorial.length !== before;
|
if (entry.comments_editorial.length !== before) {
|
||||||
|
changed = true;
|
||||||
|
notes.push(`+ comment added (len=${text.length})`);
|
||||||
|
} else {
|
||||||
|
notes.push(`~ comment already present (dedup)`);
|
||||||
|
}
|
||||||
stableSortByTs(entry.comments_editorial);
|
stableSortByTs(entry.comments_editorial);
|
||||||
notes.push(changed ? `+ comment added (len=${text.length})` : `~ comment already present (dedup)`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
else if (type === "type/reference") {
|
else if (type === "type/reference") {
|
||||||
@@ -722,15 +780,24 @@ async function main() {
|
|||||||
|
|
||||||
const before = entry.refs.length;
|
const before = entry.refs.length;
|
||||||
entry.refs = uniqUnion(entry.refs, [item], keyRef);
|
entry.refs = uniqUnion(entry.refs, [item], keyRef);
|
||||||
changed = changed || entry.refs.length !== before;
|
if (entry.refs.length !== before) {
|
||||||
|
changed = true;
|
||||||
|
notes.push(`+ reference added (${item.url ? "url" : "label"})`);
|
||||||
|
} else {
|
||||||
|
notes.push(`~ reference already present (dedup)`);
|
||||||
|
}
|
||||||
stableSortByTs(entry.refs);
|
stableSortByTs(entry.refs);
|
||||||
notes.push(changed ? `+ reference added (${item.url ? "url" : "label"})` : `~ reference already present (dedup)`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
else if (type === "type/media") {
|
else if (type === "type/media") {
|
||||||
if (!Array.isArray(entry.media)) entry.media = [];
|
if (!Array.isArray(entry.media)) entry.media = [];
|
||||||
|
|
||||||
|
const caption = (title || "").trim();
|
||||||
|
if (STRICT && !caption) {
|
||||||
|
throw Object.assign(new Error("Ticket media (strict): caption vide (titre de ticket requis)."), { __exitCode: 2 });
|
||||||
|
}
|
||||||
|
const captionFinal = caption || ".";
|
||||||
|
|
||||||
const atts = NO_DOWNLOAD ? [] : await fetchIssueAssets({ forgeApiBase, owner, repo, token, issueNum });
|
const atts = NO_DOWNLOAD ? [] : await fetchIssueAssets({ forgeApiBase, owner, repo, token, issueNum });
|
||||||
if (!atts.length) notes.push("! no assets found (nothing to download).");
|
if (!atts.length) notes.push("! no assets found (nothing to download).");
|
||||||
|
|
||||||
@@ -739,13 +806,7 @@ async function main() {
|
|||||||
const dl = a?.browser_download_url || a?.download_url || "";
|
const dl = a?.browser_download_url || a?.download_url || "";
|
||||||
if (!dl) { notes.push(`! asset missing download url: ${name}`); continue; }
|
if (!dl) { notes.push(`! asset missing download url: ${name}`); continue; }
|
||||||
|
|
||||||
const caption = (title || "").trim();
|
const mediaDirAbs = path.join(PUBLIC_DIR, "media", ...pageKey.split("/"), ancre);
|
||||||
if (STRICT && !caption) {
|
|
||||||
throw Object.assign(new Error("Ticket media (strict): caption vide (titre de ticket requis)."), { __exitCode: 2 });
|
|
||||||
}
|
|
||||||
const captionFinal = caption || ".";
|
|
||||||
|
|
||||||
const mediaDirAbs = path.join(PUBLIC_DIR, "media", pageKey, ancre);
|
|
||||||
const destAbs = path.join(mediaDirAbs, name);
|
const destAbs = path.join(mediaDirAbs, name);
|
||||||
const urlPath = `${MEDIA_URL_ROOT}/${pageKey}/${ancre}/${name}`.replace(/\/{2,}/g, "/");
|
const urlPath = `${MEDIA_URL_ROOT}/${pageKey}/${ancre}/${name}`.replace(/\/{2,}/g, "/");
|
||||||
|
|
||||||
@@ -790,7 +851,7 @@ async function main() {
|
|||||||
|
|
||||||
if (DRY_RUN) {
|
if (DRY_RUN) {
|
||||||
console.log("\n--- DRY RUN (no write) ---");
|
console.log("\n--- DRY RUN (no write) ---");
|
||||||
console.log(`Would update: ${annoShardFileRel}`);
|
console.log(`Would update: ${shardRel}`);
|
||||||
for (const n of notes) console.log(" ", n);
|
for (const n of notes) console.log(" ", n);
|
||||||
console.log("\nExcerpt (resulting entry):");
|
console.log("\nExcerpt (resulting entry):");
|
||||||
console.log(YAML.stringify({ [ancre]: doc.paras[ancre] }).trimEnd());
|
console.log(YAML.stringify({ [ancre]: doc.paras[ancre] }).trimEnd());
|
||||||
@@ -798,10 +859,10 @@ async function main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await saveAnnoDocYaml(annoShardFileAbs, doc, paraOrder);
|
await saveAnnoDocYaml(shardAbs, doc, paraOrder);
|
||||||
touchedFiles.unshift(annoShardFileRel);
|
touchedFiles.unshift(shardRel);
|
||||||
|
|
||||||
console.log(`✅ Updated: ${annoShardFileRel}`);
|
console.log(`✅ Updated: ${shardRel}`);
|
||||||
for (const n of notes) console.log(" ", n);
|
for (const n of notes) console.log(" ", n);
|
||||||
|
|
||||||
if (DO_COMMIT) {
|
if (DO_COMMIT) {
|
||||||
|
|||||||
19
src/annotations/archicrat-ia/chapitre-4/p-11-67c14c09.yml
Normal file
19
src/annotations/archicrat-ia/chapitre-4/p-11-67c14c09.yml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
schema: 1
|
||||||
|
page: archicrat-ia/chapitre-4
|
||||||
|
paras:
|
||||||
|
p-11-67c14c09:
|
||||||
|
media:
|
||||||
|
- type: image
|
||||||
|
src: /media/archicrat-ia/chapitre-4/p-11-67c14c09/Capture_d_e_cran_2026-02-16_a_13.07.35.png
|
||||||
|
caption: "[Media] p-11-67c14c09 — Chapitre 4 — Histoire archicratique des
|
||||||
|
révolutions industrielles"
|
||||||
|
credit: ""
|
||||||
|
ts: 2026-02-26T13:17:41.286Z
|
||||||
|
fromIssue: 129
|
||||||
|
- type: image
|
||||||
|
src: /media/archicrat-ia/chapitre-4/p-11-67c14c09/Capture_d_e_cran_2025-05-05_a_19.20.40.png
|
||||||
|
caption: "[Media] p-11-67c14c09 — Chapitre 4 — Histoire archicratique des
|
||||||
|
révolutions industrielles"
|
||||||
|
credit: ""
|
||||||
|
ts: 2026-02-27T09:17:04.386Z
|
||||||
|
fromIssue: 127
|
||||||
@@ -1,23 +1,81 @@
|
|||||||
|
// src/pages/annotations-index.json.ts
|
||||||
import type { APIRoute } from "astro";
|
import type { APIRoute } from "astro";
|
||||||
import * as fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import * as path from "node:path";
|
import path from "node:path";
|
||||||
import { parse as parseYAML } from "yaml";
|
import YAML from "yaml";
|
||||||
|
|
||||||
const CWD = process.cwd();
|
const CWD = process.cwd();
|
||||||
const ANNO_DIR = path.join(CWD, "src", "annotations");
|
const ANNO_ROOT = path.join(CWD, "src", "annotations");
|
||||||
|
|
||||||
// Strict en CI (ou override explicite)
|
const isObj = (x: any) => !!x && typeof x === "object" && !Array.isArray(x);
|
||||||
const STRICT =
|
const isArr = (x: any) => Array.isArray(x);
|
||||||
process.env.ANNOTATIONS_STRICT === "1" ||
|
|
||||||
process.env.CI === "1" ||
|
|
||||||
process.env.CI === "true";
|
|
||||||
|
|
||||||
async function exists(p: string): Promise<boolean> {
|
function normPath(s: string) {
|
||||||
try {
|
return String(s || "").replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
||||||
await fs.access(p);
|
}
|
||||||
return true;
|
function paraNum(pid: string) {
|
||||||
} catch {
|
const m = String(pid).match(/^p-(\d+)-/i);
|
||||||
return false;
|
return m ? Number(m[1]) : Number.POSITIVE_INFINITY;
|
||||||
|
}
|
||||||
|
function toIso(v: any) {
|
||||||
|
if (v instanceof Date) return v.toISOString();
|
||||||
|
return typeof v === "string" ? v : "";
|
||||||
|
}
|
||||||
|
function stableSortByTs(arr: any[]) {
|
||||||
|
if (!Array.isArray(arr)) return;
|
||||||
|
arr.sort((a, b) => {
|
||||||
|
const ta = Date.parse(toIso(a?.ts)) || 0;
|
||||||
|
const tb = Date.parse(toIso(b?.ts)) || 0;
|
||||||
|
if (ta !== tb) return ta - tb;
|
||||||
|
return JSON.stringify(a).localeCompare(JSON.stringify(b));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyMedia(x: any) { return String(x?.src || ""); }
|
||||||
|
function keyRef(x: any) {
|
||||||
|
return `${x?.url || ""}||${x?.label || ""}||${x?.kind || ""}||${x?.citation || ""}`;
|
||||||
|
}
|
||||||
|
function keyComment(x: any) { return String(x?.text || "").trim(); }
|
||||||
|
|
||||||
|
function uniqUnion(dst: any[], src: any[], keyFn: (x:any)=>string) {
|
||||||
|
const out = isArr(dst) ? [...dst] : [];
|
||||||
|
const seen = new Set(out.map((x) => keyFn(x)));
|
||||||
|
for (const it of (isArr(src) ? src : [])) {
|
||||||
|
const k = keyFn(it);
|
||||||
|
if (!k) continue;
|
||||||
|
if (!seen.has(k)) { seen.add(k); out.push(it); }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deepMergeEntry(dst: any, src: any) {
|
||||||
|
if (!isObj(dst) || !isObj(src)) return;
|
||||||
|
|
||||||
|
for (const [k, v] of Object.entries(src)) {
|
||||||
|
if (k === "media" && isArr(v)) { dst.media = uniqUnion(dst.media, v, keyMedia); continue; }
|
||||||
|
if (k === "refs" && isArr(v)) { dst.refs = uniqUnion(dst.refs, v, keyRef); continue; }
|
||||||
|
if (k === "comments_editorial" && isArr(v)) { dst.comments_editorial = uniqUnion(dst.comments_editorial, v, keyComment); continue; }
|
||||||
|
|
||||||
|
if (isObj(v)) {
|
||||||
|
if (!isObj(dst[k])) dst[k] = {};
|
||||||
|
deepMergeEntry(dst[k], v);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isArr(v)) {
|
||||||
|
const cur = isArr(dst[k]) ? dst[k] : [];
|
||||||
|
const seen = new Set(cur.map((x:any) => JSON.stringify(x)));
|
||||||
|
const out = [...cur];
|
||||||
|
for (const it of v) {
|
||||||
|
const s = JSON.stringify(it);
|
||||||
|
if (!seen.has(s)) { seen.add(s); out.push(it); }
|
||||||
|
}
|
||||||
|
dst[k] = out;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// scalar: set only if missing/empty
|
||||||
|
if (!(k in dst) || dst[k] == null || dst[k] === "") dst[k] = v;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,154 +84,93 @@ async function walk(dir: string): Promise<string[]> {
|
|||||||
const ents = await fs.readdir(dir, { withFileTypes: true });
|
const ents = await fs.readdir(dir, { withFileTypes: true });
|
||||||
for (const e of ents) {
|
for (const e of ents) {
|
||||||
const p = path.join(dir, e.name);
|
const p = path.join(dir, e.name);
|
||||||
if (e.isDirectory()) out.push(...(await walk(p)));
|
if (e.isDirectory()) out.push(...await walk(p));
|
||||||
else out.push(p);
|
else if (e.isFile() && /\.ya?ml$/i.test(e.name)) out.push(p);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPlainObject(x: unknown): x is Record<string, unknown> {
|
function inferExpected(relNoExt: string) {
|
||||||
return !!x && typeof x === "object" && !Array.isArray(x);
|
const parts = relNoExt.split("/").filter(Boolean);
|
||||||
}
|
const last = parts.at(-1) || "";
|
||||||
|
const isShard = /^p-\d+-/i.test(last);
|
||||||
function normalizePageKey(s: unknown): string {
|
const pageKey = isShard ? parts.slice(0, -1).join("/") : relNoExt;
|
||||||
return String(s ?? "")
|
const paraId = isShard ? last : null;
|
||||||
.replace(/^\/+/, "")
|
return { isShard, pageKey, paraId };
|
||||||
.replace(/\/+$/, "")
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferPageKeyFromFile(inDirAbs: string, fileAbs: string): string {
|
|
||||||
const rel = path.relative(inDirAbs, fileAbs).replace(/\\/g, "/");
|
|
||||||
return rel.replace(/\.(ya?ml|json)$/i, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseDoc(raw: string, fileAbs: string): unknown {
|
|
||||||
if (/\.json$/i.test(fileAbs)) return JSON.parse(raw);
|
|
||||||
return parseYAML(raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hardFailOrCollect(errors: string[], msg: string): void {
|
|
||||||
if (STRICT) throw new Error(msg);
|
|
||||||
errors.push(msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeEntry(
|
|
||||||
fileRel: string,
|
|
||||||
paraId: string,
|
|
||||||
entry: unknown,
|
|
||||||
errors: string[]
|
|
||||||
): Record<string, unknown> {
|
|
||||||
if (entry == null) return {};
|
|
||||||
|
|
||||||
if (!isPlainObject(entry)) {
|
|
||||||
hardFailOrCollect(errors, `${fileRel}: paras.${paraId} must be an object`);
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const e: Record<string, unknown> = { ...entry };
|
|
||||||
|
|
||||||
const arrayFields = [
|
|
||||||
"refs",
|
|
||||||
"authors",
|
|
||||||
"quotes",
|
|
||||||
"media",
|
|
||||||
"comments_editorial",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
for (const k of arrayFields) {
|
|
||||||
if (e[k] == null) continue;
|
|
||||||
if (!Array.isArray(e[k])) {
|
|
||||||
errors.push(`${fileRel}: paras.${paraId}.${k} must be an array (coerced to [])`);
|
|
||||||
e[k] = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return e;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GET: APIRoute = async () => {
|
export const GET: APIRoute = async () => {
|
||||||
if (!(await exists(ANNO_DIR))) {
|
const pages: Record<string, { paras: Record<string, any> }> = {};
|
||||||
const out = {
|
const errors: Array<{ file: string; error: string }> = [];
|
||||||
schema: 1,
|
|
||||||
generatedAt: new Date().toISOString(),
|
|
||||||
pages: {},
|
|
||||||
stats: { pages: 0, paras: 0, errors: 0 },
|
|
||||||
errors: [] as string[],
|
|
||||||
};
|
|
||||||
|
|
||||||
return new Response(JSON.stringify(out), {
|
let files: string[] = [];
|
||||||
headers: {
|
try {
|
||||||
"Content-Type": "application/json; charset=utf-8",
|
files = await walk(ANNO_ROOT);
|
||||||
"Cache-Control": "no-store",
|
} catch (e: any) {
|
||||||
},
|
throw new Error(`Missing annotations root: ${ANNO_ROOT} (${e?.message || e})`);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = (await walk(ANNO_DIR)).filter((p) => /\.(ya?ml|json)$/i.test(p));
|
for (const fp of files) {
|
||||||
|
const rel = normPath(path.relative(ANNO_ROOT, fp));
|
||||||
|
const relNoExt = rel.replace(/\.ya?ml$/i, "");
|
||||||
|
const { isShard, pageKey, paraId } = inferExpected(relNoExt);
|
||||||
|
|
||||||
const pages: Record<string, { paras: Record<string, Record<string, unknown>> }> =
|
|
||||||
Object.create(null);
|
|
||||||
|
|
||||||
const errors: string[] = [];
|
|
||||||
let paraCount = 0;
|
|
||||||
|
|
||||||
for (const f of files) {
|
|
||||||
const fileRel = path.relative(CWD, f).replace(/\\/g, "/");
|
|
||||||
const pageKey = normalizePageKey(inferPageKeyFromFile(ANNO_DIR, f));
|
|
||||||
|
|
||||||
if (!pageKey) {
|
|
||||||
hardFailOrCollect(errors, `${fileRel}: cannot infer page key`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let doc: unknown;
|
|
||||||
try {
|
try {
|
||||||
const raw = await fs.readFile(f, "utf8");
|
const raw = await fs.readFile(fp, "utf8");
|
||||||
doc = parseDoc(raw, f);
|
const doc = YAML.parse(raw) || {};
|
||||||
} catch (e) {
|
|
||||||
hardFailOrCollect(errors, `${fileRel}: parse failed: ${String((e as any)?.message ?? e)}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isPlainObject(doc) || (doc as any).schema !== 1) {
|
if (!isObj(doc) || doc.schema !== 1) continue;
|
||||||
hardFailOrCollect(errors, `${fileRel}: schema must be 1`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((doc as any).page != null) {
|
const docPage = normPath(doc.page || "");
|
||||||
const declared = normalizePageKey((doc as any).page);
|
if (docPage && docPage !== pageKey) {
|
||||||
if (declared !== pageKey) {
|
throw new Error(`page mismatch (page="${doc.page}" vs path="${pageKey}")`);
|
||||||
hardFailOrCollect(
|
|
||||||
errors,
|
|
||||||
`${fileRel}: page mismatch (page="${declared}" vs path="${pageKey}")`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
if (!doc.page) doc.page = pageKey;
|
||||||
|
|
||||||
const parasAny = (doc as any).paras;
|
if (!isObj(doc.paras)) throw new Error(`missing object key "paras"`);
|
||||||
if (!isPlainObject(parasAny)) {
|
|
||||||
hardFailOrCollect(errors, `${fileRel}: missing object key "paras"`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pages[pageKey]) {
|
const pg = pages[pageKey] ??= { paras: {} };
|
||||||
hardFailOrCollect(errors, `${fileRel}: duplicate page "${pageKey}" (only one file per page)`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parasOut: Record<string, Record<string, unknown>> = Object.create(null);
|
if (isShard) {
|
||||||
|
if (!paraId) throw new Error("internal: missing paraId");
|
||||||
|
if (!(paraId in doc.paras)) {
|
||||||
|
throw new Error(`shard mismatch: file must contain paras["${paraId}"]`);
|
||||||
|
}
|
||||||
|
const entry = doc.paras[paraId];
|
||||||
|
if (!isObj(pg.paras[paraId])) pg.paras[paraId] = {};
|
||||||
|
if (isObj(entry)) deepMergeEntry(pg.paras[paraId], entry);
|
||||||
|
|
||||||
for (const [paraId, entry] of Object.entries(parasAny)) {
|
stableSortByTs(pg.paras[paraId].media);
|
||||||
if (!/^p-\d+-/i.test(paraId)) {
|
stableSortByTs(pg.paras[paraId].refs);
|
||||||
hardFailOrCollect(errors, `${fileRel}: invalid para id "${paraId}"`);
|
stableSortByTs(pg.paras[paraId].comments_editorial);
|
||||||
continue;
|
} else {
|
||||||
|
for (const [pid, entry] of Object.entries(doc.paras)) {
|
||||||
|
const p = String(pid);
|
||||||
|
if (!isObj(pg.paras[p])) pg.paras[p] = {};
|
||||||
|
if (isObj(entry)) deepMergeEntry(pg.paras[p], entry);
|
||||||
|
|
||||||
|
stableSortByTs(pg.paras[p].media);
|
||||||
|
stableSortByTs(pg.paras[p].refs);
|
||||||
|
stableSortByTs(pg.paras[p].comments_editorial);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
parasOut[paraId] = sanitizeEntry(fileRel, paraId, entry, errors);
|
} catch (e: any) {
|
||||||
|
errors.push({ file: `src/annotations/${rel}`, error: String(e?.message || e) });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pages[pageKey] = { paras: parasOut };
|
// sort paras
|
||||||
paraCount += Object.keys(parasOut).length;
|
for (const [pageKey, pg] of Object.entries(pages)) {
|
||||||
|
const keys = Object.keys(pg.paras || {});
|
||||||
|
keys.sort((a, b) => {
|
||||||
|
const ia = paraNum(a);
|
||||||
|
const ib = paraNum(b);
|
||||||
|
if (Number.isFinite(ia) && Number.isFinite(ib) && ia !== ib) return ia - ib;
|
||||||
|
return String(a).localeCompare(String(b));
|
||||||
|
});
|
||||||
|
const next: Record<string, any> = {};
|
||||||
|
for (const k of keys) next[k] = pg.paras[k];
|
||||||
|
pg.paras = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
const out = {
|
const out = {
|
||||||
@@ -182,16 +179,18 @@ export const GET: APIRoute = async () => {
|
|||||||
pages,
|
pages,
|
||||||
stats: {
|
stats: {
|
||||||
pages: Object.keys(pages).length,
|
pages: Object.keys(pages).length,
|
||||||
paras: paraCount,
|
paras: Object.values(pages).reduce((n, p) => n + Object.keys(p.paras || {}).length, 0),
|
||||||
errors: errors.length,
|
errors: errors.length,
|
||||||
},
|
},
|
||||||
errors,
|
errors,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 🔥 comportement “pro CI” : si erreurs => build fail
|
||||||
|
if (errors.length) {
|
||||||
|
throw new Error(`${errors[0].file}: ${errors[0].error}`);
|
||||||
|
}
|
||||||
|
|
||||||
return new Response(JSON.stringify(out), {
|
return new Response(JSON.stringify(out), {
|
||||||
headers: {
|
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||||
"Content-Type": "application/json; charset=utf-8",
|
|
||||||
"Cache-Control": "no-store",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
Reference in New Issue
Block a user