diff --git a/scripts/apply-ticket.mjs b/scripts/apply-ticket.mjs index 7961013..af3abc3 100755 --- a/scripts/apply-ticket.mjs +++ b/scripts/apply-ticket.mjs @@ -9,18 +9,25 @@ function usage(exitCode = 0) { apply-ticket — applique une proposition de correction depuis un ticket Gitea (robuste) Usage: - node scripts/apply-ticket.mjs [--dry-run] [--no-build] + node scripts/apply-ticket.mjs [--dry-run] [--no-build] [--alias] [--commit] + +Flags: + --dry-run : ne modifie rien, affiche BEFORE/AFTER + --no-build : n'exécute pas "npm run build" (INCOMPATIBLE avec --alias) + --alias : après application, ajoute l'alias d'ancre (old -> new) dans src/anchors/anchor-aliases.json + --commit : git add + git commit automatiquement (inclut alias si --alias) Env (recommandé): - FORGE_API = base API (LAN) ex: http://192.168.1.20:3000 (évite DNS) + FORGE_API = base API (LAN) ex: http://192.168.1.20:3000 FORGE_BASE = base web ex: https://gitea.xxx.tld - FORGE_TOKEN = PAT (avec accès au repo + issues) + FORGE_TOKEN = PAT (accès repo + issues) GITEA_OWNER = owner (optionnel si auto-détecté depuis git remote) GITEA_REPO = repo (optionnel si auto-détecté depuis git remote) Notes: - Si dist//index.html est absent, le script lance "npm run build" sauf si --no-build. - Sauvegarde automatique: .bak.issue- (uniquement si on écrit) + - Avec --alias : le script build pour obtenir le NOUVEL id, puis écrit l'alias old->new. `); process.exit(exitCode); } @@ -36,10 +43,28 @@ if (!Number.isFinite(issueNum) || issueNum <= 0) { const DRY_RUN = argv.includes("--dry-run"); const NO_BUILD = argv.includes("--no-build"); +const DO_ALIAS = argv.includes("--alias"); +const DO_COMMIT = argv.includes("--commit"); + +if (DO_ALIAS && NO_BUILD) { + console.error("❌ --alias est incompatible avec --no-build (risque d'alias faux)."); + console.error("➡️ Relance sans --no-build."); + process.exit(1); +} + +if (DRY_RUN && (DO_ALIAS || DO_COMMIT)) { + console.warn("ℹ️ --dry-run : --alias/--commit sont ignorés (aucune écriture)."); +} + +if (typeof fetch !== "function") { + console.error("❌ fetch() indisponible dans ce Node. Utilise Node 18+ (ou plus)."); + process.exit(1); +} const CWD = process.cwd(); const CONTENT_ROOT = path.join(CWD, "src", "content"); const DIST_ROOT = path.join(CWD, "dist"); +const ALIASES_FILE = path.join(CWD, "src", "anchors", "anchor-aliases.json"); function normalizeText(s) { return String(s ?? "") @@ -76,9 +101,20 @@ function tokenize(s) { function run(cmd, args, opts = {}) { const r = spawnSync(cmd, args, { stdio: "inherit", ...opts }); + if (r.error) throw r.error; if (r.status !== 0) throw new Error(`Command failed: ${cmd} ${args.join(" ")}`); } +function runQuiet(cmd, args, opts = {}) { + const r = spawnSync(cmd, args, { encoding: "utf8", stdio: "pipe", ...opts }); + if (r.error) throw r.error; + if (r.status !== 0) { + const out = (r.stdout || "") + (r.stderr || ""); + throw new Error(`Command failed: ${cmd} ${args.join(" ")}\n${out}`); + } + return r.stdout || ""; +} + async function fileExists(p) { try { await fs.access(p); return true; } catch { return false; } } @@ -154,13 +190,27 @@ async function readHtmlParagraphText(htmlPath, anchorId) { const re = new RegExp(`]*\\bid=["']${escapeRegExp(anchorId)}["'][^>]*>([\\s\\S]*?)<\\/p>`, "i"); const m = html.match(re); if (!m) return ""; - let inner = m[1]; + return cleanHtmlInner(m[1]); +} - inner = inner.replace(/]*class=["'][^"']*para-tools[^"']*["'][^>]*>[\s\S]*?<\/span>/gi, " "); - inner = inner.replace(/<[^>]+>/g, " "); - inner = inner.replace(/\s+/g, " ").trim(); - inner = inner.replace(/\b(¶|Citer|Proposer|Copié)\b/gi, "").replace(/\s+/g, " ").trim(); - return inner; +function cleanHtmlInner(inner) { + let s = String(inner ?? ""); + s = s.replace(/]*class=["'][^"']*para-tools[^"']*["'][^>]*>[\s\S]*?<\/span>/gi, " "); + s = s.replace(/<[^>]+>/g, " "); + s = s.replace(/\s+/g, " ").trim(); + s = s.replace(/\b(¶|Citer|Proposer|Copié)\b/gi, "").replace(/\s+/g, " ").trim(); + return s; +} + +async function readAllHtmlParagraphs(htmlPath) { + const html = await fs.readFile(htmlPath, "utf-8"); + const out = []; + const re = /]*\sid=["'](p-\d+-[0-9a-f]{8})["'][^>]*>([\s\S]*?)<\/p>/gi; + let m; + while ((m = re.exec(html))) { + out.push({ id: m[1], text: cleanHtmlInner(m[2]) }); + } + return out; } function splitParagraphBlocks(mdxText) { @@ -173,13 +223,13 @@ function isLikelyExcerpt(s) { if (!t) return true; if (t.length < 120) return true; if (/[.…]$/.test(t)) return true; - if (t.includes("tronqu")) return true; // tronqué/tronquee etc (sans diacritiques) + if (t.includes("tronqu")) return true; return false; } -function scoreBlock(block, targetText) { +function scoreText(candidate, targetText) { const tgt = tokenize(targetText); - const blk = tokenize(block); + const blk = tokenize(candidate); if (!tgt.length || !blk.length) return 0; const tgtSet = new Set(tgt); @@ -188,13 +238,11 @@ function scoreBlock(block, targetText) { let hit = 0; for (const w of tgtSet) if (blkSet.has(w)) hit++; - // Bonus si un long préfixe ressemble (moins strict qu'un includes brut) const tgtNorm = normalizeText(stripMd(targetText)); - const blkNorm = normalizeText(stripMd(block)); + const blkNorm = normalizeText(stripMd(candidate)); const prefix = tgtNorm.slice(0, Math.min(180, tgtNorm.length)); const prefixBonus = prefix && blkNorm.includes(prefix) ? 1000 : 0; - // Ratio bonus (0..100) const ratio = hit / Math.max(1, tgtSet.size); const ratioBonus = Math.round(ratio * 100); @@ -204,15 +252,21 @@ function scoreBlock(block, targetText) { function bestBlockMatchIndex(blocks, targetText) { let best = { i: -1, score: -1 }; for (let i = 0; i < blocks.length; i++) { - const b = blocks[i]; - const sc = scoreBlock(b, targetText); + const sc = scoreText(blocks[i], targetText); if (sc > best.score) best = { i, score: sc }; } return best; } +function normalizeChemin(chemin) { + let c = String(chemin || "").trim(); + if (!c.startsWith("/")) c = "/" + c; + if (!c.endsWith("/")) c = c + "/"; + return c; +} + async function findContentFileFromChemin(chemin) { - const clean = chemin.replace(/^\/+|\/+$/g, ""); + const clean = normalizeChemin(chemin).replace(/^\/+|\/+$/g, ""); const parts = clean.split("/").filter(Boolean); if (parts.length < 2) return null; const collection = parts[0]; @@ -260,7 +314,7 @@ async function fetchIssue({ forgeApiBase, owner, repo, token, issueNum }) { headers: { "Authorization": `token ${token}`, "Accept": "application/json", - "User-Agent": "archicratie-apply-ticket/1.1", + "User-Agent": "archicratie-apply-ticket/1.2", } }); if (!res.ok) { @@ -270,6 +324,79 @@ async function fetchIssue({ forgeApiBase, owner, repo, token, issueNum }) { return await res.json(); } +async function loadAliases() { + try { + const s = await fs.readFile(ALIASES_FILE, "utf8"); + const obj = JSON.parse(s); + return obj && typeof obj === "object" ? obj : {}; + } catch { + return {}; + } +} + +function sortObjectKeys(obj) { + return Object.fromEntries(Object.keys(obj).sort().map((k) => [k, obj[k]])); +} + +async function saveAliases(obj) { + let out = obj || {}; + // tri stable + for (const k of Object.keys(out)) { + if (out[k] && typeof out[k] === "object") out[k] = sortObjectKeys(out[k]); + } + out = sortObjectKeys(out); + + await fs.mkdir(path.dirname(ALIASES_FILE), { recursive: true }); + await fs.writeFile(ALIASES_FILE, JSON.stringify(out, null, 2) + "\n", "utf8"); +} + +async function upsertAlias({ chemin, oldId, newId }) { + const route = normalizeChemin(chemin); + if (!oldId || !newId) throw new Error("Alias: oldId/newId requis"); + if (oldId === newId) return { changed: false, reason: "same" }; + + const data = await loadAliases(); + if (!data[route]) data[route] = {}; + + const prev = data[route][oldId]; + if (prev && prev !== newId) { + throw new Error(`Alias conflict: ${route}${oldId} already mapped to ${prev} (new=${newId})`); + } + if (prev === newId) return { changed: false, reason: "already" }; + + data[route][oldId] = newId; + await saveAliases(data); + return { changed: true, reason: "written" }; +} + +function gitHasStagedChanges() { + const r = spawnSync("git", ["diff", "--cached", "--quiet"]); + return r.status === 1; +} + +async function computeNewIdFromDistByContent(distHtmlPath, afterBlock) { + const paras = await readAllHtmlParagraphs(distHtmlPath); + if (!paras.length) throw new Error(`Aucun

trouvé dans ${distHtmlPath}`); + + let best = { id: null, score: -1, text: "" }; + const target = stripMd(afterBlock).slice(0, 1200); + + for (const p of paras) { + const sc = scoreText(p.text, target); + if (sc > best.score) best = { id: p.id, score: sc, text: p.text }; + } + + // seuil de sécurité (évite alias faux) + if (!best.id || best.score < 60) { + throw new Error( + `Impossible d'identifier le nouvel id dans dist (score trop faible: ${best.score}).\n` + + `➡️ Vérifie que la proposition correspond bien à UN paragraphe.` + ); + } + + return best.id; +} + async function main() { const token = getEnv("FORGE_TOKEN"); if (!token) { @@ -298,6 +425,7 @@ async function main() { let chemin = pickLine(body, "Chemin") || pickHeadingValue(body, "Chemin"); let ancre = pickLine(body, "Ancre") || pickHeadingValue(body, "Ancre paragraphe") || pickHeadingValue(body, "Ancre"); + chemin = normalizeChemin(chemin); ancre = (ancre || "").trim(); if (ancre.startsWith("#")) ancre = ancre.slice(1); @@ -344,14 +472,12 @@ async function main() { const best = bestBlockMatchIndex(blocks, targetText); - // seuil de sécurité : on veut au moins un overlap raisonnable. - // Avec le bonus prefix+ratio, un match correct dépasse très vite ~60–80. + // seuil de sécurité if (best.i < 0 || best.score < 40) { console.error("❌ Match trop faible: je refuse de remplacer automatiquement."); console.error(`➡️ Score=${best.score}. Recommandation: ticket avec 'Texte actuel (copie exacte du paragraphe)'.`); - // debug: top 5 const ranked = blocks - .map((b, i) => ({ i, score: scoreBlock(b, targetText), excerpt: stripMd(b).slice(0, 140) })) + .map((b, i) => ({ i, score: scoreText(b, targetText), excerpt: stripMd(b).slice(0, 140) })) .sort((a, b) => b.score - a.score) .slice(0, 5); @@ -388,9 +514,58 @@ async function main() { } await fs.writeFile(contentFile, updated, "utf-8"); - console.log("✅ Applied. Next:"); + console.log("✅ Applied."); + + let aliasChanged = false; + let newId = null; + + if (DO_ALIAS) { + console.log("🔁 Rebuild to compute new anchor ids (npm run build) …"); + run("npm", ["run", "build"], { cwd: CWD }); + + if (!(await fileExists(distHtmlPath))) { + throw new Error(`dist introuvable après build: ${distHtmlPath}`); + } + + newId = await computeNewIdFromDistByContent(distHtmlPath, afterBlock); + + const res = await upsertAlias({ chemin, oldId: ancre, newId }); + aliasChanged = res.changed; + + if (aliasChanged) { + console.log(`✅ Alias ajouté: ${chemin} ${ancre} -> ${newId}`); + // met à jour dist immédiatement (sans rebuild complet) + run("node", ["scripts/inject-anchor-aliases.mjs"], { cwd: CWD }); + } else { + console.log(`ℹ️ Alias déjà présent ou inutile (${ancre} -> ${newId}).`); + } + + // garde-fous rapides + run("npm", ["run", "test:anchors"], { cwd: CWD }); + run("node", ["scripts/check-inline-js.mjs"], { cwd: CWD }); + } + + if (DO_COMMIT) { + const files = [path.relative(CWD, contentFile)]; + if (DO_ALIAS && aliasChanged) files.push(path.relative(CWD, ALIASES_FILE)); + + run("git", ["add", ...files], { cwd: CWD }); + + if (!gitHasStagedChanges()) { + console.log("ℹ️ Nothing to commit (aucun changement staged)."); + return; + } + + const msg = `edit: apply ticket #${issueNum} (${chemin}#${ancre})`; + run("git", ["commit", "-m", msg], { cwd: CWD }); + console.log(`✅ Committed: ${msg}`); + return; + } + + // mode manuel (historique) + console.log("Next (manuel) :"); console.log(` git diff -- ${path.relative(CWD, contentFile)}`); - console.log(` git add ${path.relative(CWD, contentFile)}`); + console.log(` git add ${path.relative(CWD, contentFile)}${DO_ALIAS ? " src/anchors/anchor-aliases.json" : ""}`); console.log(` git commit -m "edit: apply ticket #${issueNum} (${chemin}#${ancre})"`); }