propose: exact paragraph + apply-ticket guardrails
This commit is contained in:
@@ -95,12 +95,14 @@
|
||||
/** @type {"correction"|"fact"|""} */
|
||||
let kind = "";
|
||||
|
||||
// Doit rester cohérent avec EditionLayout
|
||||
const URL_HARD_LIMIT = 6500;
|
||||
|
||||
const esc = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
const upsertLine = (text, key, value) => {
|
||||
const re = new RegExp(`^\\s*${esc(key)}\\s*:\\s*.*$`, "mi");
|
||||
|
||||
// value vide => supprimer la ligne si elle existe
|
||||
if (!value) {
|
||||
if (!re.test(text)) return text;
|
||||
return (
|
||||
@@ -111,10 +113,8 @@
|
||||
);
|
||||
}
|
||||
|
||||
// remplace si existe
|
||||
if (re.test(text)) return text.replace(re, `${key}: ${value}`);
|
||||
|
||||
// sinon append
|
||||
const sep = text && !text.endsWith("\n") ? "\n" : "";
|
||||
return text + sep + `${key}: ${value}\n`;
|
||||
};
|
||||
@@ -125,6 +125,40 @@
|
||||
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) => {
|
||||
pending = url;
|
||||
kind = "";
|
||||
@@ -134,18 +168,28 @@
|
||||
};
|
||||
|
||||
// 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]");
|
||||
if (!a) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
// L'URL réelle est dans data-url (préparée côté EditionLayout)
|
||||
const rawUrl = a.dataset.url || a.getAttribute("href") || "";
|
||||
if (!rawUrl || rawUrl === "#") return;
|
||||
|
||||
const full = (a.dataset.full || "").trim();
|
||||
|
||||
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 {
|
||||
window.open(rawUrl, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
@@ -157,7 +201,6 @@
|
||||
if (!btn || !pending) return;
|
||||
|
||||
kind = btn.getAttribute("data-kind") || "";
|
||||
|
||||
let body = pending.searchParams.get("body") || "";
|
||||
|
||||
if (kind === "fact") {
|
||||
@@ -190,7 +233,6 @@
|
||||
let body = pending.searchParams.get("body") || "";
|
||||
body = upsertLine(body, "Category", cat);
|
||||
|
||||
|
||||
pending.searchParams.set("body", body);
|
||||
|
||||
const u = pending.toString();
|
||||
|
||||
@@ -65,115 +65,165 @@ const GITEA_REPO = import.meta.env.PUBLIC_GITEA_REPO ?? "";
|
||||
</article>
|
||||
</main>
|
||||
|
||||
|
||||
<ProposeModal />
|
||||
<!-- IMPORTANT: define:vars injecte les constantes dans le JS sans templating fragile -->
|
||||
<ProposeModal />
|
||||
|
||||
<!-- IMPORTANT: define:vars injecte les constantes dans le JS sans templating fragile -->
|
||||
<script is:inline define:vars={{ GITEA_BASE, GITEA_OWNER, GITEA_REPO }}>
|
||||
(() => {
|
||||
// Nettoyage si un ancien bug a injecté ?body=... dans l'URL
|
||||
if (window.location.search.includes("body=")) {
|
||||
history.replaceState(null, "", window.location.pathname + window.location.hash);
|
||||
}
|
||||
try {
|
||||
// Nettoyage si un ancien bug a injecté ?body=... dans l'URL
|
||||
if (window.location.search.includes("body=")) {
|
||||
history.replaceState(null, "", window.location.pathname + window.location.hash);
|
||||
}
|
||||
|
||||
const title = document.body.dataset.docTitle || document.title;
|
||||
const version = document.body.dataset.docVersion || "";
|
||||
const docTitle = document.body.dataset.docTitle || document.title;
|
||||
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) {
|
||||
const base = String(GITEA_BASE).replace(/\/+$/, "");
|
||||
const issue = new URL(`${base}/${GITEA_OWNER}/${GITEA_REPO}/issues/new`);
|
||||
// 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;
|
||||
|
||||
// URL locale "propre" : on ignore totalement query-string (?body=...)
|
||||
const local = new URL(window.location.origin + window.location.pathname);
|
||||
// évite d’embarquer des paramètres parasites (ex: ?body=... issus de tests)
|
||||
local.searchParams.delete("body");
|
||||
local.searchParams.delete("title");
|
||||
local.hash = anchorId;
|
||||
const path = local.pathname;
|
||||
const quoteBlock = (s) =>
|
||||
String(s || "")
|
||||
.split(/\r?\n/)
|
||||
.map((l) => `> ${l}`.trimEnd())
|
||||
.join("\n");
|
||||
|
||||
const issueTitle = `[Correction] ${anchorId} — ${title}`;
|
||||
const body = [
|
||||
`Chemin: ${path}`,
|
||||
`URL locale: ${local.toString()}`,
|
||||
`Ancre: #${anchorId}`,
|
||||
`Version: ${version || "(non renseignée)"}`,
|
||||
`Type: type/correction`,
|
||||
`State: state/recevable`,
|
||||
``,
|
||||
`Texte actuel (extrait):`,
|
||||
`> ${excerpt}`,
|
||||
``,
|
||||
`Proposition (remplacer par):`,
|
||||
``,
|
||||
`Justification:`,
|
||||
``,
|
||||
`---`,
|
||||
`Note: issue générée depuis le site (pré-remplissage).`
|
||||
].join("\n");
|
||||
function buildIssueURL(anchorId, fullText, excerpt) {
|
||||
const base = String(GITEA_BASE).replace(/\/+$/, "");
|
||||
const issue = new URL(`${base}/${GITEA_OWNER}/${GITEA_REPO}/issues/new`);
|
||||
|
||||
issue.searchParams.set("title", issueTitle);
|
||||
issue.searchParams.set("body", body);
|
||||
return issue.toString();
|
||||
}
|
||||
// URL locale "propre" : on ignore totalement query-string
|
||||
const local = new URL(window.location.href);
|
||||
local.search = "";
|
||||
local.hash = anchorId;
|
||||
|
||||
const paras = Array.from(document.querySelectorAll(".reading p[id]"));
|
||||
for (const p of paras) {
|
||||
if (p.querySelector(".para-tools")) continue;
|
||||
const path = local.pathname;
|
||||
const issueTitle = `[Correction] ${anchorId} — ${docTitle}`;
|
||||
|
||||
const tools = document.createElement("span");
|
||||
tools.className = "para-tools";
|
||||
const hasFull = Boolean(fullText && fullText.length);
|
||||
const canTryFull = hasFull && fullText.length <= FULL_TEXT_SOFT_LIMIT;
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.className = "para-anchor";
|
||||
a.href = `#${p.id}`;
|
||||
a.setAttribute("aria-label", "Lien vers ce paragraphe");
|
||||
a.textContent = "¶";
|
||||
const makeBody = (embedFull) => {
|
||||
const header = [
|
||||
`Chemin: ${path}`,
|
||||
`URL locale: ${local.toString()}`,
|
||||
`Ancre: #${anchorId}`,
|
||||
`Version: ${docVersion || "(non renseignée)"}`,
|
||||
`Type: type/correction`,
|
||||
`State: state/recevable`,
|
||||
``,
|
||||
];
|
||||
|
||||
const citeBtn = document.createElement("button");
|
||||
citeBtn.type = "button";
|
||||
citeBtn.className = "para-cite";
|
||||
citeBtn.textContent = "Citer";
|
||||
const texteActuel = embedFull
|
||||
? [
|
||||
`Texte actuel (copie exacte du paragraphe):`,
|
||||
quoteBlock(fullText),
|
||||
]
|
||||
: [
|
||||
`Texte actuel (extrait):`,
|
||||
quoteBlock(excerpt || ""),
|
||||
``,
|
||||
`Note: paragraphe long → extrait (texte complet copié au clic si possible).`,
|
||||
];
|
||||
|
||||
citeBtn.addEventListener("click", async () => {
|
||||
const url = new URL(window.location.origin + window.location.pathname);
|
||||
url.hash = p.id;
|
||||
const cite = `${title}${version ? ` (v${version})` : ""} — ${url.toString()}`;
|
||||
const footer = [
|
||||
``,
|
||||
`Proposition (remplacer par):`,
|
||||
``,
|
||||
`Justification:`,
|
||||
``,
|
||||
`---`,
|
||||
`Note: issue générée depuis le site (pré-remplissage).`,
|
||||
];
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(cite);
|
||||
const prev = citeBtn.textContent;
|
||||
citeBtn.textContent = "Copié";
|
||||
setTimeout(() => (citeBtn.textContent = prev), 900);
|
||||
} catch {
|
||||
window.prompt("Copiez la citation :", cite);
|
||||
return header.concat(texteActuel, footer).join("\n");
|
||||
};
|
||||
|
||||
// 1) On tente "texte complet"
|
||||
issue.searchParams.set("title", issueTitle);
|
||||
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));
|
||||
}
|
||||
});
|
||||
|
||||
tools.appendChild(a);
|
||||
tools.appendChild(citeBtn);
|
||||
return issue.toString();
|
||||
}
|
||||
|
||||
if (giteaReady) {
|
||||
const propose = document.createElement("a");
|
||||
propose.className = "para-propose";
|
||||
propose.textContent = "Proposer";
|
||||
propose.setAttribute("aria-label", "Proposer une correction sur Gitea");
|
||||
propose.setAttribute("data-propose", "1");
|
||||
// Contrat : uniquement les paragraphes citables
|
||||
const paras = Array.from(document.querySelectorAll('.reading p[id^="p-"]'));
|
||||
|
||||
const raw = (p.textContent || "").trim().replace(/\s+/g, " ");
|
||||
const excerpt = raw.length > 420 ? (raw.slice(0, 420) + "…") : raw;
|
||||
const url = buildIssueURL(p.id, excerpt);
|
||||
for (const p of paras) {
|
||||
if (p.querySelector(".para-tools")) continue;
|
||||
|
||||
// progressive enhancement : sans JS/modal, href fonctionne.
|
||||
propose.href = url;
|
||||
const tools = document.createElement("span");
|
||||
tools.className = "para-tools";
|
||||
|
||||
// compat : la modal lit data-url en priorité (garde aussi href).
|
||||
propose.dataset.url = url;
|
||||
const a = document.createElement("a");
|
||||
a.className = "para-anchor";
|
||||
a.href = `#${p.id}`;
|
||||
a.setAttribute("aria-label", "Lien vers ce paragraphe");
|
||||
a.textContent = "¶";
|
||||
|
||||
tools.appendChild(propose);
|
||||
}
|
||||
const citeBtn = document.createElement("button");
|
||||
citeBtn.type = "button";
|
||||
citeBtn.className = "para-cite";
|
||||
citeBtn.textContent = "Citer";
|
||||
|
||||
p.appendChild(tools);
|
||||
citeBtn.addEventListener("click", async () => {
|
||||
const pageUrl = new URL(window.location.href);
|
||||
pageUrl.search = "";
|
||||
pageUrl.hash = p.id;
|
||||
|
||||
const cite = `${docTitle}${docVersion ? ` (v${docVersion})` : ""} — ${pageUrl.toString()}`;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(cite);
|
||||
const prev = citeBtn.textContent;
|
||||
citeBtn.textContent = "Copié";
|
||||
setTimeout(() => (citeBtn.textContent = prev), 900);
|
||||
} catch {
|
||||
window.prompt("Copiez la citation :", cite);
|
||||
}
|
||||
});
|
||||
|
||||
tools.appendChild(a);
|
||||
tools.appendChild(citeBtn);
|
||||
|
||||
if (giteaReady) {
|
||||
const propose = document.createElement("a");
|
||||
propose.className = "para-propose";
|
||||
propose.textContent = "Proposer";
|
||||
propose.setAttribute("aria-label", "Proposer une correction sur Gitea");
|
||||
propose.setAttribute("data-propose", "1");
|
||||
|
||||
const raw = (p.textContent || "").trim().replace(/\s+/g, " ");
|
||||
const excerpt = raw.length > 420 ? (raw.slice(0, 420) + "…") : raw;
|
||||
|
||||
const issueUrl = buildIssueURL(p.id, raw, excerpt);
|
||||
|
||||
// progressive enhancement : sans JS/modal, href fonctionne.
|
||||
propose.href = issueUrl;
|
||||
|
||||
// la modal lit data-url en priorité (garde aussi href).
|
||||
propose.dataset.url = issueUrl;
|
||||
|
||||
// Option B : texte complet disponible au clic (presse-papier + upgrade)
|
||||
propose.dataset.full = raw;
|
||||
|
||||
tools.appendChild(propose);
|
||||
}
|
||||
|
||||
p.appendChild(tools);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[EditionLayout] para-tools init failed:", err);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user