propose: exact paragraph + apply-ticket guardrails #33
11
.gitignore
vendored
11
.gitignore
vendored
@@ -11,18 +11,13 @@ dist/
|
|||||||
|
|
||||||
# Dossiers de travail local (à garder hors repo)
|
# Dossiers de travail local (à garder hors repo)
|
||||||
sources/
|
sources/
|
||||||
scripts/
|
|
||||||
|
|
||||||
# Backups / fichiers cassés (à garder hors repo)
|
|
||||||
|
|
||||||
# Astro generated
|
# Astro generated
|
||||||
.astro/
|
.astro/
|
||||||
|
|
||||||
# --- allow the apply-ticket tool script to be versioned ---
|
# Backups / crash copies
|
||||||
!scripts/
|
|
||||||
!scripts/apply-ticket.mjs
|
|
||||||
|
|
||||||
# Local backups / crash copies
|
|
||||||
src/**/*.bak
|
src/**/*.bak
|
||||||
|
src/**/*.bak.*
|
||||||
src/**/*.BROKEN.*
|
src/**/*.BROKEN.*
|
||||||
src/**/*.step*-fix.bak
|
src/**/*.step*-fix.bak
|
||||||
|
src/**/*.bak.issue-*
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Env (recommandé):
|
|||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- Si dist/<chemin>/index.html est absent, le script lance "npm run build" sauf si --no-build.
|
- Si dist/<chemin>/index.html est absent, le script lance "npm run build" sauf si --no-build.
|
||||||
- Sauvegarde automatique: <fichier>.bak.issue-<N>
|
- Sauvegarde automatique: <fichier>.bak.issue-<N> (uniquement si on écrit)
|
||||||
`);
|
`);
|
||||||
process.exit(exitCode);
|
process.exit(exitCode);
|
||||||
}
|
}
|
||||||
@@ -45,12 +45,16 @@ function normalizeText(s) {
|
|||||||
return String(s ?? "")
|
return String(s ?? "")
|
||||||
.normalize("NFKD")
|
.normalize("NFKD")
|
||||||
.replace(/\p{Diacritic}/gu, "")
|
.replace(/\p{Diacritic}/gu, "")
|
||||||
|
.replace(/[’‘]/g, "'")
|
||||||
|
.replace(/[“”]/g, '"')
|
||||||
|
.replace(/[–—]/g, "-")
|
||||||
|
.replace(/…/g, "...")
|
||||||
.replace(/\s+/g, " ")
|
.replace(/\s+/g, " ")
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
// stripping très pragmatique (anti-fragile > parfait)
|
// stripping très pragmatique
|
||||||
function stripMd(mdx) {
|
function stripMd(mdx) {
|
||||||
let s = String(mdx ?? "");
|
let s = String(mdx ?? "");
|
||||||
s = s.replace(/`[^`]*`/g, " "); // inline code
|
s = s.replace(/`[^`]*`/g, " "); // inline code
|
||||||
@@ -62,6 +66,14 @@ function stripMd(mdx) {
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tokenize(s) {
|
||||||
|
const n = normalizeText(stripMd(s));
|
||||||
|
return n
|
||||||
|
.replace(/[^a-z0-9'\- ]+/g, " ")
|
||||||
|
.split(" ")
|
||||||
|
.filter((w) => w.length >= 4);
|
||||||
|
}
|
||||||
|
|
||||||
function run(cmd, args, opts = {}) {
|
function run(cmd, args, opts = {}) {
|
||||||
const r = spawnSync(cmd, args, { stdio: "inherit", ...opts });
|
const r = spawnSync(cmd, args, { stdio: "inherit", ...opts });
|
||||||
if (r.status !== 0) throw new Error(`Command failed: ${cmd} ${args.join(" ")}`);
|
if (r.status !== 0) throw new Error(`Command failed: ${cmd} ${args.join(" ")}`);
|
||||||
@@ -79,25 +91,25 @@ function inferOwnerRepoFromGit() {
|
|||||||
const r = spawnSync("git", ["remote", "get-url", "origin"], { encoding: "utf-8" });
|
const r = spawnSync("git", ["remote", "get-url", "origin"], { encoding: "utf-8" });
|
||||||
if (r.status !== 0) return null;
|
if (r.status !== 0) return null;
|
||||||
const u = (r.stdout || "").trim();
|
const u = (r.stdout || "").trim();
|
||||||
// supports: https://host/owner/repo.git or ssh
|
|
||||||
const m = u.match(/[:/](?<owner>[^/]+)\/(?<repo>[^/]+?)(?:\.git)?$/);
|
const m = u.match(/[:/](?<owner>[^/]+)\/(?<repo>[^/]+?)(?:\.git)?$/);
|
||||||
if (!m?.groups) return null;
|
if (!m?.groups) return null;
|
||||||
return { owner: m.groups.owner, repo: m.groups.repo };
|
return { owner: m.groups.owner, repo: m.groups.repo };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(s) {
|
||||||
|
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
function pickLine(body, key) {
|
function pickLine(body, key) {
|
||||||
// tolère espaces/indent
|
|
||||||
const re = new RegExp(`^\\s*${escapeRegExp(key)}\\s*:\\s*([^\\n\\r]+)`, "mi");
|
const re = new RegExp(`^\\s*${escapeRegExp(key)}\\s*:\\s*([^\\n\\r]+)`, "mi");
|
||||||
const m = body.match(re);
|
const m = body.match(re);
|
||||||
return m ? m[1].trim() : "";
|
return m ? m[1].trim() : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickHeadingValue(body, headingKey) {
|
function pickHeadingValue(body, headingKey) {
|
||||||
// ex: "## Chemin ..." ligne suivante contenant /...
|
|
||||||
const re = new RegExp(`^##\\s*${escapeRegExp(headingKey)}[^\\n]*\\n([\\s\\S]*?)(?=\\n##\\s|\\n\\s*$)`, "mi");
|
const re = new RegExp(`^##\\s*${escapeRegExp(headingKey)}[^\\n]*\\n([\\s\\S]*?)(?=\\n##\\s|\\n\\s*$)`, "mi");
|
||||||
const m = body.match(re);
|
const m = body.match(re);
|
||||||
if (!m) return "";
|
if (!m) return "";
|
||||||
// première ligne non vide et non commentée
|
|
||||||
const lines = m[1].split(/\r?\n/).map(l => l.trim());
|
const lines = m[1].split(/\r?\n/).map(l => l.trim());
|
||||||
for (const l of lines) {
|
for (const l of lines) {
|
||||||
if (!l) continue;
|
if (!l) continue;
|
||||||
@@ -108,7 +120,6 @@ function pickHeadingValue(body, headingKey) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pickSection(body, markers) {
|
function pickSection(body, markers) {
|
||||||
// capture bloc après le 1er marker trouvé, jusqu'à un séparateur connu
|
|
||||||
const text = body.replace(/\r\n/g, "\n");
|
const text = body.replace(/\r\n/g, "\n");
|
||||||
const idx = markers
|
const idx = markers
|
||||||
.map(m => ({ m, i: text.toLowerCase().indexOf(m.toLowerCase()) }))
|
.map(m => ({ m, i: text.toLowerCase().indexOf(m.toLowerCase()) }))
|
||||||
@@ -118,7 +129,6 @@ function pickSection(body, markers) {
|
|||||||
const start = idx.i + idx.m.length;
|
const start = idx.i + idx.m.length;
|
||||||
const tail = text.slice(start);
|
const tail = text.slice(start);
|
||||||
|
|
||||||
// stop markers (robuste)
|
|
||||||
const stops = [
|
const stops = [
|
||||||
"\n## ", "\nJustification", "\n---", "\n## Justification", "\n## Sources",
|
"\n## ", "\nJustification", "\n---", "\n## Justification", "\n## Sources",
|
||||||
"\nProblème identifié", "\nSources proposées", "\n## Proposition", "\n## Problème"
|
"\nProblème identifié", "\nSources proposées", "\n## Proposition", "\n## Problème"
|
||||||
@@ -132,7 +142,6 @@ function pickSection(body, markers) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function unquoteBlock(s) {
|
function unquoteBlock(s) {
|
||||||
// enlève ">" de citation markdown
|
|
||||||
return String(s ?? "")
|
return String(s ?? "")
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
.map(l => l.replace(/^\s*>\s?/, ""))
|
.map(l => l.replace(/^\s*>\s?/, ""))
|
||||||
@@ -140,64 +149,66 @@ function unquoteBlock(s) {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeRegExp(s) {
|
|
||||||
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readHtmlParagraphText(htmlPath, anchorId) {
|
async function readHtmlParagraphText(htmlPath, anchorId) {
|
||||||
const html = await fs.readFile(htmlPath, "utf-8");
|
const html = await fs.readFile(htmlPath, "utf-8");
|
||||||
// cherche <p id="anchorId" ...> ... </p>
|
|
||||||
const re = new RegExp(`<p[^>]*\\bid=["']${escapeRegExp(anchorId)}["'][^>]*>([\\s\\S]*?)<\\/p>`, "i");
|
const re = new RegExp(`<p[^>]*\\bid=["']${escapeRegExp(anchorId)}["'][^>]*>([\\s\\S]*?)<\\/p>`, "i");
|
||||||
const m = html.match(re);
|
const m = html.match(re);
|
||||||
if (!m) return "";
|
if (!m) return "";
|
||||||
let inner = m[1];
|
let inner = m[1];
|
||||||
|
|
||||||
// supprime les outils "para-tools" si présents
|
|
||||||
inner = inner.replace(/<span[^>]*class=["'][^"']*para-tools[^"']*["'][^>]*>[\s\S]*?<\/span>/gi, " ");
|
inner = inner.replace(/<span[^>]*class=["'][^"']*para-tools[^"']*["'][^>]*>[\s\S]*?<\/span>/gi, " ");
|
||||||
|
|
||||||
// strip tags
|
|
||||||
inner = inner.replace(/<[^>]+>/g, " ");
|
inner = inner.replace(/<[^>]+>/g, " ");
|
||||||
inner = inner.replace(/\s+/g, " ").trim();
|
inner = inner.replace(/\s+/g, " ").trim();
|
||||||
|
|
||||||
// enlève artefacts éventuels
|
|
||||||
inner = inner.replace(/\b(¶|Citer|Proposer|Copié)\b/gi, "").replace(/\s+/g, " ").trim();
|
inner = inner.replace(/\b(¶|Citer|Proposer|Copié)\b/gi, "").replace(/\s+/g, " ").trim();
|
||||||
return inner;
|
return inner;
|
||||||
}
|
}
|
||||||
|
|
||||||
function splitParagraphBlocks(mdxText) {
|
function splitParagraphBlocks(mdxText) {
|
||||||
// bloc = séparé par 2 sauts de ligne (pragmatique)
|
|
||||||
const raw = mdxText.replace(/\r\n/g, "\n");
|
const raw = mdxText.replace(/\r\n/g, "\n");
|
||||||
const parts = raw.split(/\n{2,}/);
|
return raw.split(/\n{2,}/);
|
||||||
return parts;
|
}
|
||||||
|
|
||||||
|
function isLikelyExcerpt(s) {
|
||||||
|
const t = String(s || "").trim();
|
||||||
|
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)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreBlock(block, targetText) {
|
||||||
|
const tgt = tokenize(targetText);
|
||||||
|
const blk = tokenize(block);
|
||||||
|
if (!tgt.length || !blk.length) return 0;
|
||||||
|
|
||||||
|
const tgtSet = new Set(tgt);
|
||||||
|
const blkSet = new Set(blk);
|
||||||
|
|
||||||
|
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 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);
|
||||||
|
|
||||||
|
return prefixBonus + hit + ratioBonus;
|
||||||
}
|
}
|
||||||
|
|
||||||
function bestBlockMatchIndex(blocks, targetText) {
|
function bestBlockMatchIndex(blocks, targetText) {
|
||||||
const tgt = normalizeText(stripMd(targetText));
|
|
||||||
if (!tgt) return -1;
|
|
||||||
|
|
||||||
// on compare par inclusion de snippet + score "overlap"
|
|
||||||
const snippet = tgt.slice(0, Math.min(160, tgt.length));
|
|
||||||
let best = { i: -1, score: -1 };
|
let best = { i: -1, score: -1 };
|
||||||
|
|
||||||
for (let i = 0; i < blocks.length; i++) {
|
for (let i = 0; i < blocks.length; i++) {
|
||||||
const b = normalizeText(stripMd(blocks[i]));
|
const b = blocks[i];
|
||||||
if (!b) continue;
|
const sc = scoreBlock(b, targetText);
|
||||||
|
if (sc > best.score) best = { i, score: sc };
|
||||||
let score = 0;
|
|
||||||
if (b.includes(snippet)) score += 1000; // jackpot
|
|
||||||
|
|
||||||
// overlap par mots (cheap mais robuste)
|
|
||||||
const words = new Set(tgt.split(" ").filter(w => w.length >= 4));
|
|
||||||
let hit = 0;
|
|
||||||
for (const w of words) if (b.includes(w)) hit++;
|
|
||||||
score += hit;
|
|
||||||
|
|
||||||
if (score > best.score) best = { i, score };
|
|
||||||
}
|
}
|
||||||
|
return best;
|
||||||
// seuil minimal : évite remplacement sauvage
|
|
||||||
if (best.score < 20) return -1;
|
|
||||||
return best.i;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findContentFileFromChemin(chemin) {
|
async function findContentFileFromChemin(chemin) {
|
||||||
@@ -205,11 +216,10 @@ async function findContentFileFromChemin(chemin) {
|
|||||||
const parts = clean.split("/").filter(Boolean);
|
const parts = clean.split("/").filter(Boolean);
|
||||||
if (parts.length < 2) return null;
|
if (parts.length < 2) return null;
|
||||||
const collection = parts[0];
|
const collection = parts[0];
|
||||||
const slugPath = parts.slice(1).join("/"); // support nested
|
const slugPath = parts.slice(1).join("/");
|
||||||
const root = path.join(CONTENT_ROOT, collection);
|
const root = path.join(CONTENT_ROOT, collection);
|
||||||
if (!(await fileExists(root))) return null;
|
if (!(await fileExists(root))) return null;
|
||||||
|
|
||||||
// cherche fichier dont le path relatif (sans ext) == slugPath
|
|
||||||
const exts = [".mdx", ".md"];
|
const exts = [".mdx", ".md"];
|
||||||
async function walk(dir) {
|
async function walk(dir) {
|
||||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||||
@@ -250,7 +260,7 @@ async function fetchIssue({ forgeApiBase, owner, repo, token, issueNum }) {
|
|||||||
headers: {
|
headers: {
|
||||||
"Authorization": `token ${token}`,
|
"Authorization": `token ${token}`,
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
"User-Agent": "archicratie-apply-ticket/1.0",
|
"User-Agent": "archicratie-apply-ticket/1.1",
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -275,7 +285,6 @@ async function main() {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// API base: priorise LAN (FORGE_API), sinon FORGE_BASE
|
|
||||||
const forgeApiBase = getEnv("FORGE_API") || getEnv("FORGE_BASE");
|
const forgeApiBase = getEnv("FORGE_API") || getEnv("FORGE_BASE");
|
||||||
if (!forgeApiBase) {
|
if (!forgeApiBase) {
|
||||||
console.error("❌ FORGE_API ou FORGE_BASE manquant. Ex: export FORGE_API='http://192.168.1.20:3000'");
|
console.error("❌ FORGE_API ou FORGE_BASE manquant. Ex: export FORGE_API='http://192.168.1.20:3000'");
|
||||||
@@ -285,22 +294,17 @@ async function main() {
|
|||||||
console.log(`🔎 Fetch ticket #${issueNum} from ${owner}/${repo} …`);
|
console.log(`🔎 Fetch ticket #${issueNum} from ${owner}/${repo} …`);
|
||||||
const issue = await fetchIssue({ forgeApiBase, owner, repo, token, issueNum });
|
const issue = await fetchIssue({ forgeApiBase, owner, repo, token, issueNum });
|
||||||
|
|
||||||
const title = issue.title || "";
|
const body = String(issue.body || "").replace(/\r\n/g, "\n");
|
||||||
const bodyRaw = issue.body || "";
|
|
||||||
const body = bodyRaw.replace(/\r\n/g, "\n");
|
|
||||||
|
|
||||||
// Chemin / Ancre: support format "Chemin:" OU "## Chemin"
|
|
||||||
let chemin = pickLine(body, "Chemin") || pickHeadingValue(body, "Chemin");
|
let chemin = pickLine(body, "Chemin") || pickHeadingValue(body, "Chemin");
|
||||||
let ancre = pickLine(body, "Ancre") || pickHeadingValue(body, "Ancre paragraphe") || pickHeadingValue(body, "Ancre");
|
let ancre = pickLine(body, "Ancre") || pickHeadingValue(body, "Ancre paragraphe") || pickHeadingValue(body, "Ancre");
|
||||||
ancre = ancre.trim();
|
ancre = (ancre || "").trim();
|
||||||
if (ancre.startsWith("#")) ancre = ancre.slice(1);
|
if (ancre.startsWith("#")) ancre = ancre.slice(1);
|
||||||
|
|
||||||
// Texte actuel: support "Texte actuel (copie exacte...)" OU "Texte actuel (extrait)"
|
const currentFull = pickSection(body, ["Texte actuel (copie exacte du paragraphe)", "## Texte actuel (copie exacte du paragraphe)"]);
|
||||||
const current1 = pickSection(body, ["Texte actuel (copie exacte du paragraphe)", "## Texte actuel (copie exacte du paragraphe)"]);
|
const currentEx = pickSection(body, ["Texte actuel (extrait)", "## Assertion / passage à vérifier", "Assertion / passage à vérifier"]);
|
||||||
const current2 = pickSection(body, ["Texte actuel (extrait)", "## Assertion / passage à vérifier", "Assertion / passage à vérifier"]);
|
const texteActuel = unquoteBlock(currentFull || currentEx);
|
||||||
const texteActuel = unquoteBlock(current1 || current2);
|
|
||||||
|
|
||||||
// Proposition: support 2 modèles
|
|
||||||
const prop1 = pickSection(body, ["Proposition (texte corrigé complet)", "## Proposition (texte corrigé complet)"]);
|
const prop1 = pickSection(body, ["Proposition (texte corrigé complet)", "## Proposition (texte corrigé complet)"]);
|
||||||
const prop2 = pickSection(body, ["Proposition (remplacer par):", "## Proposition (remplacer par)"]);
|
const prop2 = pickSection(body, ["Proposition (remplacer par):", "## Proposition (remplacer par)"]);
|
||||||
const proposition = (prop1 || prop2).trim();
|
const proposition = (prop1 || prop2).trim();
|
||||||
@@ -313,56 +317,62 @@ async function main() {
|
|||||||
|
|
||||||
const contentFile = await findContentFileFromChemin(chemin);
|
const contentFile = await findContentFileFromChemin(chemin);
|
||||||
if (!contentFile) throw new Error(`Fichier contenu introuvable pour Chemin=${chemin}`);
|
if (!contentFile) throw new Error(`Fichier contenu introuvable pour Chemin=${chemin}`);
|
||||||
|
|
||||||
console.log(`📄 Target content file: ${path.relative(CWD, contentFile)}`);
|
console.log(`📄 Target content file: ${path.relative(CWD, contentFile)}`);
|
||||||
|
|
||||||
// dist html path
|
|
||||||
const distHtmlPath = path.join(DIST_ROOT, chemin.replace(/^\/+|\/+$/g,""), "index.html");
|
const distHtmlPath = path.join(DIST_ROOT, chemin.replace(/^\/+|\/+$/g,""), "index.html");
|
||||||
await ensureBuildIfNeeded(distHtmlPath);
|
await ensureBuildIfNeeded(distHtmlPath);
|
||||||
|
|
||||||
// texte cible: priorité au texte actuel du ticket, sinon récup HTML du paragraphe via ancre
|
// targetText: préférence au texte complet (ticket), sinon dist si extrait probable
|
||||||
let targetText = texteActuel;
|
let targetText = texteActuel;
|
||||||
if (!targetText) {
|
|
||||||
|
let distText = "";
|
||||||
if (await fileExists(distHtmlPath)) {
|
if (await fileExists(distHtmlPath)) {
|
||||||
const htmlText = await readHtmlParagraphText(distHtmlPath, ancre);
|
distText = await readHtmlParagraphText(distHtmlPath, ancre);
|
||||||
if (htmlText) targetText = htmlText;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!targetText && distText) targetText = distText;
|
||||||
|
if (targetText && distText && isLikelyExcerpt(targetText) && distText.length > targetText.length) {
|
||||||
|
targetText = distText;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!targetText) {
|
if (!targetText) {
|
||||||
throw new Error("Impossible de reconstruire le texte du paragraphe (ni texte actuel, ni dist html).");
|
throw new Error("Impossible de reconstruire le texte du paragraphe (ni texte actuel, ni dist html).");
|
||||||
}
|
}
|
||||||
|
|
||||||
// lecture + split blocs
|
|
||||||
const original = await fs.readFile(contentFile, "utf-8");
|
const original = await fs.readFile(contentFile, "utf-8");
|
||||||
const blocks = splitParagraphBlocks(original);
|
const blocks = splitParagraphBlocks(original);
|
||||||
|
|
||||||
const idx = bestBlockMatchIndex(blocks, targetText);
|
const best = bestBlockMatchIndex(blocks, targetText);
|
||||||
if (idx < 0) {
|
|
||||||
|
// 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.
|
||||||
|
if (best.i < 0 || best.score < 40) {
|
||||||
console.error("❌ Match trop faible: je refuse de remplacer automatiquement.");
|
console.error("❌ Match trop faible: je refuse de remplacer automatiquement.");
|
||||||
console.error("➡️ Action: mets 'Texte actuel (copie exacte du paragraphe)' dans le ticket (recommandé).");
|
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) }))
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
console.error("Top candidates:");
|
||||||
|
for (const r of ranked) {
|
||||||
|
console.error(` #${r.i + 1} score=${r.score} ${r.excerpt}${r.excerpt.length >= 140 ? "…" : ""}`);
|
||||||
|
}
|
||||||
process.exit(2);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
const beforeBlock = blocks[idx];
|
const beforeBlock = blocks[best.i];
|
||||||
const afterBlock = proposition.trim();
|
const afterBlock = proposition.trim();
|
||||||
|
|
||||||
// garde le style: 1 bloc -> 1 bloc
|
|
||||||
const nextBlocks = blocks.slice();
|
const nextBlocks = blocks.slice();
|
||||||
nextBlocks[idx] = afterBlock;
|
nextBlocks[best.i] = afterBlock;
|
||||||
|
|
||||||
const updated = nextBlocks.join("\n\n");
|
const updated = nextBlocks.join("\n\n");
|
||||||
|
|
||||||
// backup
|
console.log(`🧩 Matched block #${best.i + 1}/${blocks.length} score=${best.score}`);
|
||||||
const bakPath = `${contentFile}.bak.issue-${issueNum}`;
|
|
||||||
if (!(await fileExists(bakPath))) {
|
|
||||||
await fs.writeFile(bakPath, original, "utf-8");
|
|
||||||
}
|
|
||||||
|
|
||||||
// preview stats
|
|
||||||
console.log(`🧩 Matched block #${idx+1}/${blocks.length} (backup: ${path.relative(CWD, bakPath)})`);
|
|
||||||
|
|
||||||
if (DRY_RUN) {
|
if (DRY_RUN) {
|
||||||
console.log("\n--- DRY RUN (no write) ---\n");
|
console.log("\n--- DRY RUN (no write, no backup) ---\n");
|
||||||
console.log("=== BEFORE (excerpt) ===");
|
console.log("=== BEFORE (excerpt) ===");
|
||||||
console.log(beforeBlock.slice(0, 400) + (beforeBlock.length > 400 ? "…" : ""));
|
console.log(beforeBlock.slice(0, 400) + (beforeBlock.length > 400 ? "…" : ""));
|
||||||
console.log("\n=== AFTER (excerpt) ===");
|
console.log("\n=== AFTER (excerpt) ===");
|
||||||
@@ -371,6 +381,12 @@ async function main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// backup uniquement si on écrit
|
||||||
|
const bakPath = `${contentFile}.bak.issue-${issueNum}`;
|
||||||
|
if (!(await fileExists(bakPath))) {
|
||||||
|
await fs.writeFile(bakPath, original, "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
await fs.writeFile(contentFile, updated, "utf-8");
|
await fs.writeFile(contentFile, updated, "utf-8");
|
||||||
console.log("✅ Applied. Next:");
|
console.log("✅ Applied. Next:");
|
||||||
console.log(` git diff -- ${path.relative(CWD, contentFile)}`);
|
console.log(` git diff -- ${path.relative(CWD, contentFile)}`);
|
||||||
|
|||||||
@@ -95,12 +95,14 @@
|
|||||||
/** @type {"correction"|"fact"|""} */
|
/** @type {"correction"|"fact"|""} */
|
||||||
let kind = "";
|
let kind = "";
|
||||||
|
|
||||||
|
// Doit rester cohérent avec EditionLayout
|
||||||
|
const URL_HARD_LIMIT = 6500;
|
||||||
|
|
||||||
const esc = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
const esc = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
|
||||||
const upsertLine = (text, key, value) => {
|
const upsertLine = (text, key, value) => {
|
||||||
const re = new RegExp(`^\\s*${esc(key)}\\s*:\\s*.*$`, "mi");
|
const re = new RegExp(`^\\s*${esc(key)}\\s*:\\s*.*$`, "mi");
|
||||||
|
|
||||||
// value vide => supprimer la ligne si elle existe
|
|
||||||
if (!value) {
|
if (!value) {
|
||||||
if (!re.test(text)) return text;
|
if (!re.test(text)) return text;
|
||||||
return (
|
return (
|
||||||
@@ -111,10 +113,8 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// remplace si existe
|
|
||||||
if (re.test(text)) return text.replace(re, `${key}: ${value}`);
|
if (re.test(text)) return text.replace(re, `${key}: ${value}`);
|
||||||
|
|
||||||
// sinon append
|
|
||||||
const sep = text && !text.endsWith("\n") ? "\n" : "";
|
const sep = text && !text.endsWith("\n") ? "\n" : "";
|
||||||
return text + sep + `${key}: ${value}\n`;
|
return text + sep + `${key}: ${value}\n`;
|
||||||
};
|
};
|
||||||
@@ -125,6 +125,40 @@
|
|||||||
u.searchParams.set("title", `[${prefix}] ${cleaned}`.trim());
|
u.searchParams.set("title", `[${prefix}] ${cleaned}`.trim());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const tryUpgradeBodyWithFull = (u, full) => {
|
||||||
|
if (!full) return;
|
||||||
|
|
||||||
|
let body = u.searchParams.get("body") || "";
|
||||||
|
if (!body) return;
|
||||||
|
|
||||||
|
// Si déjà en "copie exacte", rien à faire
|
||||||
|
if (body.includes("Texte actuel (copie exacte du paragraphe)")) return;
|
||||||
|
|
||||||
|
// On ne tente que si le body est en mode extrait
|
||||||
|
if (!body.includes("Texte actuel (extrait):")) return;
|
||||||
|
|
||||||
|
const quoted = full.split(/\r?\n/).map(l => `> ${l}`.trimEnd()).join("\n");
|
||||||
|
|
||||||
|
// Remplace le bloc "Texte actuel (extrait)" jusqu'à "Proposition (remplacer par):"
|
||||||
|
const re = /Texte actuel \(extrait\):[\s\S]*?\n\nProposition \(remplacer par\):/m;
|
||||||
|
const next = body.replace(
|
||||||
|
re,
|
||||||
|
`Texte actuel (copie exacte du paragraphe):\n${quoted}\n\nProposition (remplacer par):`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (next === body) return;
|
||||||
|
|
||||||
|
const prev = u.toString();
|
||||||
|
u.searchParams.set("body", next);
|
||||||
|
|
||||||
|
// garde-fou URL
|
||||||
|
if (u.toString().length > URL_HARD_LIMIT) {
|
||||||
|
// revert
|
||||||
|
u.searchParams.set("body", body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const openWith = (url) => {
|
const openWith = (url) => {
|
||||||
pending = url;
|
pending = url;
|
||||||
kind = "";
|
kind = "";
|
||||||
@@ -134,18 +168,28 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Intercepte UNIQUEMENT les liens marqués data-propose
|
// Intercepte UNIQUEMENT les liens marqués data-propose
|
||||||
document.addEventListener("click", (e) => {
|
document.addEventListener("click", async (e) => {
|
||||||
const a = e.target?.closest?.("a[data-propose]");
|
const a = e.target?.closest?.("a[data-propose]");
|
||||||
if (!a) return;
|
if (!a) return;
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
// L'URL réelle est dans data-url (préparée côté EditionLayout)
|
|
||||||
const rawUrl = a.dataset.url || a.getAttribute("href") || "";
|
const rawUrl = a.dataset.url || a.getAttribute("href") || "";
|
||||||
if (!rawUrl || rawUrl === "#") return;
|
if (!rawUrl || rawUrl === "#") return;
|
||||||
|
|
||||||
|
const full = (a.dataset.full || "").trim();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
openWith(new URL(rawUrl));
|
const u = new URL(rawUrl);
|
||||||
|
|
||||||
|
// Option B.1 : copie presse-papier du texte complet (si dispo)
|
||||||
|
if (full) {
|
||||||
|
try { await navigator.clipboard.writeText(full); } catch {}
|
||||||
|
// Option B.2 : upgrade du body (si l'URL reste raisonnable)
|
||||||
|
tryUpgradeBodyWithFull(u, full);
|
||||||
|
}
|
||||||
|
|
||||||
|
openWith(u);
|
||||||
} catch {
|
} catch {
|
||||||
window.open(rawUrl, "_blank", "noopener,noreferrer");
|
window.open(rawUrl, "_blank", "noopener,noreferrer");
|
||||||
}
|
}
|
||||||
@@ -157,7 +201,6 @@
|
|||||||
if (!btn || !pending) return;
|
if (!btn || !pending) return;
|
||||||
|
|
||||||
kind = btn.getAttribute("data-kind") || "";
|
kind = btn.getAttribute("data-kind") || "";
|
||||||
|
|
||||||
let body = pending.searchParams.get("body") || "";
|
let body = pending.searchParams.get("body") || "";
|
||||||
|
|
||||||
if (kind === "fact") {
|
if (kind === "fact") {
|
||||||
@@ -190,7 +233,6 @@
|
|||||||
let body = pending.searchParams.get("body") || "";
|
let body = pending.searchParams.get("body") || "";
|
||||||
body = upsertLine(body, "Category", cat);
|
body = upsertLine(body, "Category", cat);
|
||||||
|
|
||||||
|
|
||||||
pending.searchParams.set("body", body);
|
pending.searchParams.set("body", body);
|
||||||
|
|
||||||
const u = pending.toString();
|
const u = pending.toString();
|
||||||
|
|||||||
@@ -65,59 +65,100 @@ const GITEA_REPO = import.meta.env.PUBLIC_GITEA_REPO ?? "";
|
|||||||
</article>
|
</article>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
||||||
<ProposeModal />
|
<ProposeModal />
|
||||||
<!-- IMPORTANT: define:vars injecte les constantes dans le JS sans templating fragile -->
|
|
||||||
|
<!-- IMPORTANT: define:vars injecte les constantes dans le JS sans templating fragile -->
|
||||||
<script is:inline define:vars={{ GITEA_BASE, GITEA_OWNER, GITEA_REPO }}>
|
<script is:inline define:vars={{ GITEA_BASE, GITEA_OWNER, GITEA_REPO }}>
|
||||||
(() => {
|
(() => {
|
||||||
|
try {
|
||||||
// Nettoyage si un ancien bug a injecté ?body=... dans l'URL
|
// Nettoyage si un ancien bug a injecté ?body=... dans l'URL
|
||||||
if (window.location.search.includes("body=")) {
|
if (window.location.search.includes("body=")) {
|
||||||
history.replaceState(null, "", window.location.pathname + window.location.hash);
|
history.replaceState(null, "", window.location.pathname + window.location.hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = document.body.dataset.docTitle || document.title;
|
const docTitle = document.body.dataset.docTitle || document.title;
|
||||||
const version = document.body.dataset.docVersion || "";
|
const docVersion = document.body.dataset.docVersion || "";
|
||||||
|
|
||||||
const giteaReady = Boolean(GITEA_BASE && GITEA_OWNER && GITEA_REPO);
|
const giteaReady = Boolean(GITEA_BASE && GITEA_OWNER && GITEA_REPO);
|
||||||
|
|
||||||
function buildIssueURL(anchorId, excerpt) {
|
// Limites pragmatiques :
|
||||||
|
// - FULL_TEXT_SOFT_LIMIT : taille max du paragraphe qu'on essaie d'embarquer tel quel
|
||||||
|
// - URL_HARD_LIMIT : taille max de l'URL finale issues/new?... (au-delà, on repasse en extrait)
|
||||||
|
const FULL_TEXT_SOFT_LIMIT = 1600;
|
||||||
|
const URL_HARD_LIMIT = 6500;
|
||||||
|
|
||||||
|
const quoteBlock = (s) =>
|
||||||
|
String(s || "")
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((l) => `> ${l}`.trimEnd())
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
function buildIssueURL(anchorId, fullText, excerpt) {
|
||||||
const base = String(GITEA_BASE).replace(/\/+$/, "");
|
const base = String(GITEA_BASE).replace(/\/+$/, "");
|
||||||
const issue = new URL(`${base}/${GITEA_OWNER}/${GITEA_REPO}/issues/new`);
|
const issue = new URL(`${base}/${GITEA_OWNER}/${GITEA_REPO}/issues/new`);
|
||||||
|
|
||||||
// URL locale "propre" : on ignore totalement query-string (?body=...)
|
// URL locale "propre" : on ignore totalement query-string
|
||||||
const local = new URL(window.location.origin + window.location.pathname);
|
const local = new URL(window.location.href);
|
||||||
// évite d’embarquer des paramètres parasites (ex: ?body=... issus de tests)
|
local.search = "";
|
||||||
local.searchParams.delete("body");
|
|
||||||
local.searchParams.delete("title");
|
|
||||||
local.hash = anchorId;
|
local.hash = anchorId;
|
||||||
const path = local.pathname;
|
|
||||||
|
|
||||||
const issueTitle = `[Correction] ${anchorId} — ${title}`;
|
const path = local.pathname;
|
||||||
const body = [
|
const issueTitle = `[Correction] ${anchorId} — ${docTitle}`;
|
||||||
|
|
||||||
|
const hasFull = Boolean(fullText && fullText.length);
|
||||||
|
const canTryFull = hasFull && fullText.length <= FULL_TEXT_SOFT_LIMIT;
|
||||||
|
|
||||||
|
const makeBody = (embedFull) => {
|
||||||
|
const header = [
|
||||||
`Chemin: ${path}`,
|
`Chemin: ${path}`,
|
||||||
`URL locale: ${local.toString()}`,
|
`URL locale: ${local.toString()}`,
|
||||||
`Ancre: #${anchorId}`,
|
`Ancre: #${anchorId}`,
|
||||||
`Version: ${version || "(non renseignée)"}`,
|
`Version: ${docVersion || "(non renseignée)"}`,
|
||||||
`Type: type/correction`,
|
`Type: type/correction`,
|
||||||
`State: state/recevable`,
|
`State: state/recevable`,
|
||||||
``,
|
``,
|
||||||
|
];
|
||||||
|
|
||||||
|
const texteActuel = embedFull
|
||||||
|
? [
|
||||||
|
`Texte actuel (copie exacte du paragraphe):`,
|
||||||
|
quoteBlock(fullText),
|
||||||
|
]
|
||||||
|
: [
|
||||||
`Texte actuel (extrait):`,
|
`Texte actuel (extrait):`,
|
||||||
`> ${excerpt}`,
|
quoteBlock(excerpt || ""),
|
||||||
|
``,
|
||||||
|
`Note: paragraphe long → extrait (texte complet copié au clic si possible).`,
|
||||||
|
];
|
||||||
|
|
||||||
|
const footer = [
|
||||||
``,
|
``,
|
||||||
`Proposition (remplacer par):`,
|
`Proposition (remplacer par):`,
|
||||||
``,
|
``,
|
||||||
`Justification:`,
|
`Justification:`,
|
||||||
``,
|
``,
|
||||||
`---`,
|
`---`,
|
||||||
`Note: issue générée depuis le site (pré-remplissage).`
|
`Note: issue générée depuis le site (pré-remplissage).`,
|
||||||
].join("\n");
|
];
|
||||||
|
|
||||||
|
return header.concat(texteActuel, footer).join("\n");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1) On tente "texte complet"
|
||||||
issue.searchParams.set("title", issueTitle);
|
issue.searchParams.set("title", issueTitle);
|
||||||
issue.searchParams.set("body", body);
|
issue.searchParams.set("body", makeBody(Boolean(canTryFull)));
|
||||||
|
|
||||||
|
// 2) Si l'URL devient trop longue, on repasse en extrait
|
||||||
|
if (issue.toString().length > URL_HARD_LIMIT) {
|
||||||
|
issue.searchParams.set("body", makeBody(false));
|
||||||
|
}
|
||||||
|
|
||||||
return issue.toString();
|
return issue.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
const paras = Array.from(document.querySelectorAll(".reading p[id]"));
|
// Contrat : uniquement les paragraphes citables
|
||||||
|
const paras = Array.from(document.querySelectorAll('.reading p[id^="p-"]'));
|
||||||
|
|
||||||
for (const p of paras) {
|
for (const p of paras) {
|
||||||
if (p.querySelector(".para-tools")) continue;
|
if (p.querySelector(".para-tools")) continue;
|
||||||
|
|
||||||
@@ -136,9 +177,11 @@ const GITEA_REPO = import.meta.env.PUBLIC_GITEA_REPO ?? "";
|
|||||||
citeBtn.textContent = "Citer";
|
citeBtn.textContent = "Citer";
|
||||||
|
|
||||||
citeBtn.addEventListener("click", async () => {
|
citeBtn.addEventListener("click", async () => {
|
||||||
const url = new URL(window.location.origin + window.location.pathname);
|
const pageUrl = new URL(window.location.href);
|
||||||
url.hash = p.id;
|
pageUrl.search = "";
|
||||||
const cite = `${title}${version ? ` (v${version})` : ""} — ${url.toString()}`;
|
pageUrl.hash = p.id;
|
||||||
|
|
||||||
|
const cite = `${docTitle}${docVersion ? ` (v${docVersion})` : ""} — ${pageUrl.toString()}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(cite);
|
await navigator.clipboard.writeText(cite);
|
||||||
@@ -162,19 +205,26 @@ const GITEA_REPO = import.meta.env.PUBLIC_GITEA_REPO ?? "";
|
|||||||
|
|
||||||
const raw = (p.textContent || "").trim().replace(/\s+/g, " ");
|
const raw = (p.textContent || "").trim().replace(/\s+/g, " ");
|
||||||
const excerpt = raw.length > 420 ? (raw.slice(0, 420) + "…") : raw;
|
const excerpt = raw.length > 420 ? (raw.slice(0, 420) + "…") : raw;
|
||||||
const url = buildIssueURL(p.id, excerpt);
|
|
||||||
|
const issueUrl = buildIssueURL(p.id, raw, excerpt);
|
||||||
|
|
||||||
// progressive enhancement : sans JS/modal, href fonctionne.
|
// progressive enhancement : sans JS/modal, href fonctionne.
|
||||||
propose.href = url;
|
propose.href = issueUrl;
|
||||||
|
|
||||||
// compat : la modal lit data-url en priorité (garde aussi href).
|
// la modal lit data-url en priorité (garde aussi href).
|
||||||
propose.dataset.url = url;
|
propose.dataset.url = issueUrl;
|
||||||
|
|
||||||
|
// Option B : texte complet disponible au clic (presse-papier + upgrade)
|
||||||
|
propose.dataset.full = raw;
|
||||||
|
|
||||||
tools.appendChild(propose);
|
tools.appendChild(propose);
|
||||||
}
|
}
|
||||||
|
|
||||||
p.appendChild(tools);
|
p.appendChild(tools);
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[EditionLayout] para-tools init failed:", err);
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user