Merge pull request 'propose: exact paragraph + apply-ticket guardrails' (#33) from feat/proposer-exact-paragraph into master
Reviewed-on: #33
This commit was merged in pull request #33.
This commit is contained in:
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();
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ Ce changement de perspective implique une rupture profonde dans la manière mêm
|
|||||||
|
|
||||||
Ce qui émerge n'est pas de nouveaux principes, ni une nouvelle idéologie, mais une exigence beaucoup plus modeste, mais aussi beaucoup plus difficile à satisfaire : celle de trouver dans les relations elles-mêmes — entre groupes, entre institutions, entre individus, entre temporalités — les ressources nécessaires pour maintenir leurs mondes viables. Autrement dit : c'est *dans* les tensions, *à même* les conflits, *au sein* des alliances, *au cœur* des désaccords et des polémiques, que semble se construire la régulation. Non plus *au-dessus*, par un décret transcendant, mais *au-dedans*, par un agencement toujours révisable. C'est cela que nous voulons dire — sans technicité inutile — quand nous parlons d'un déplacement vers une *instance de régulation située de co-viabilité* : un espace commun où les forces hétérogènes, souvent antagonistes, peuvent coexister, se contredire, se confronter, s'éprouver, sans se détruire mutuellement.
|
Ce qui émerge n'est pas de nouveaux principes, ni une nouvelle idéologie, mais une exigence beaucoup plus modeste, mais aussi beaucoup plus difficile à satisfaire : celle de trouver dans les relations elles-mêmes — entre groupes, entre institutions, entre individus, entre temporalités — les ressources nécessaires pour maintenir leurs mondes viables. Autrement dit : c'est *dans* les tensions, *à même* les conflits, *au sein* des alliances, *au cœur* des désaccords et des polémiques, que semble se construire la régulation. Non plus *au-dessus*, par un décret transcendant, mais *au-dedans*, par un agencement toujours révisable. C'est cela que nous voulons dire — sans technicité inutile — quand nous parlons d'un déplacement vers une *instance de régulation située de co-viabilité* : un espace commun où les forces hétérogènes, souvent antagonistes, peuvent coexister, se contredire, se confronter, s'éprouver, sans se détruire mutuellement.
|
||||||
|
|
||||||
Penser le politique depuis cette approche, c'est renoncer à l'idée même qu'un ordre puisse se fonder définitivement, une fois pour toutes. C'est reconnaître que ce qui fait tenir une société n'est jamais un principe unique, un commandement souverain, une légitimité première, mais *un espace d'épreuve toujours rejoué* où se négocient, se recadrent, s'opposent, s'ajustent des forces hétérogènes dont l'accord est constamment partiel, toujours temporaire, perpétuellement instable.
|
Penser le politique depuis cette approche, c’est renoncer à l’idée même qu’un ordre puisse se fonder définitivement, une fois pour toutes. C’est reconnaître que ce qui fait tenir une société ne se joue jamais sur un principe unique, un commandement souverain, une légitimité première, mais sur un espace d’épreuve toujours rejoué où se négocient, se recadrent, s’opposent, s’ajustent des forces hétérogènes dont l’accord est constamment partiel, toujours temporaire, perpétuellement instable.
|
||||||
|
|
||||||
Par conséquent, un ordre durerait moins par ses fondements proclamés que par ses *capacités régulatrices effectives*. Autant dire que ce sont les dispositifs, les formats, les médiations — parfois massifs, parfois imperceptibles — par lesquels un ordre parvient à faire coexister ce qui, en droit, pourrait s'exclure : des intérêts antagonistes, des affects discordants, des récits historiques incompatibles, des régimes de valeur irréconciliables, des temporalités sociales déphasées, des exigences contradictoires en matière de justice, d\'efficacité, de mémoire ou d\'avenir.
|
Par conséquent, un ordre durerait moins par ses fondements proclamés que par ses *capacités régulatrices effectives*. Autant dire que ce sont les dispositifs, les formats, les médiations — parfois massifs, parfois imperceptibles — par lesquels un ordre parvient à faire coexister ce qui, en droit, pourrait s'exclure : des intérêts antagonistes, des affects discordants, des récits historiques incompatibles, des régimes de valeur irréconciliables, des temporalités sociales déphasées, des exigences contradictoires en matière de justice, d\'efficacité, de mémoire ou d\'avenir.
|
||||||
|
|
||||||
|
|||||||
@@ -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