propose: exact paragraph + apply-ticket guardrails

This commit is contained in:
2026-01-20 12:42:30 +01:00
parent ec42c4b2f4
commit 3b8376d6a9
4 changed files with 290 additions and 187 deletions

View File

@@ -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 dembarquer 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>