feat: garde-fous d'écriture des classeurs Excel #153 (P0)

L'édition d'un .xlsx pouvait détruire une partie du classeur, le
concurrencer en silence, ou diffuser une injection de formule.

- BUG-085 : inspect_workbook() détecte ce qu'un round-trip openpyxl perd
  (valeurs calculées en cache, slicers, contrôles, connexions, custom
  XML, signature, commentaires enrichis, macros) → xlsx_lossy_features
  exposé en lecture, bandeau FR/EN, et 409 xlsx_lossy_content sans
  `force` (confirmation explicite puis reprise). Périmètre réel
  revalidé : graphiques, images et TCD survivent au round-trip.
- BUG-086 : écriture atomique (fichier .tmp + os.replace) : un plantage
  ne peut plus tronquer le classeur, le backup reste intact.
- BUG-087 : verrou par fichier autour du read-modify-write (timeout 15 s,
  409 conflict) ; endpoint xlsx/save devenu synchrone pour que
  l'attente s'exécute dans le threadpool.
- BUG-088 : une saisie en '=' ou '@' est stockée en texte, sauf opt-in
  `allow_formula` ou le bouton f(x) de la visionneuse. Le handler
  ServiceError expose désormais code + details, que api() propage.
- BUG-084 : la suppression d'une vault purge enfin l'index inversé
  (documents fantômes qui continuaient de matcher) et is_stale() devient
  is_ready(), le nom étant trompeur (la staleness n'existe plus).

Tests : 1390 pytest, 10 JSDOM (xlsx-viewer.test.mjs, branché au CI),
3 E2E Playwright, suite E2E complète verte, ruff/mypy 0.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
This commit is contained in:
2026-09-27 20:39:12 -04:00
parent 4c4b1222d5
commit 31d4616baf
40 changed files with 1628 additions and 87 deletions
+12 -1
View File
@@ -72,14 +72,25 @@ async function api(path, opts) {
}
if (!res.ok) {
var detail = "";
var code = "";
var details = null;
try {
var body = await res.json();
detail = body.detail || "";
// #153 A1 : the service layer exposes a stable code + details so callers
// can branch on the failure (e.g. confirm a lossy .xlsx write) instead of
// matching on the message.
code = body.code || "";
details = body.details || null;
} catch (_) {
/* no json body */
}
showToast(detail || "Erreur API : " + res.status, "error");
throw new Error(detail || "API error: " + res.status);
var apiError = new Error(detail || "API error: " + res.status);
apiError.status = res.status;
apiError.code = code;
apiError.details = details;
throw apiError;
}
return res.json();
}
+1 -1
View File
@@ -1222,7 +1222,7 @@ function renderDiagnostics(container, data) {
["Postings total", data.inverted_index.total_postings.toLocaleString()],
["Documents", data.inverted_index.documents],
["Mémoire estimée", data.inverted_index.memory_estimate_mb + " MB"],
["Stale", data.inverted_index.is_stale ? "Oui" : "Non"],
["Index prêt", data.inverted_index.is_ready ? "Oui" : "Non"],
],
},
{
+71 -8
View File
@@ -998,9 +998,21 @@ export function renderVideoViewer(area, data) {
// ── Excel .xlsx — sheet tabs + editable cells ─────────────────────────────
// Cells are contenteditable; edits are collected per sheet and sent to
// PUT /api/file/{vault}/xlsx/save. Formula cells show their text and are
// saved back as formulas (no client-side recalculation — ceiling accepted).
function renderXlsxViewer(area, data) {
// never recalculated here.
//
// #153 A1/A4 — the read response carries `xlsx_lossy_features` (parts openpyxl
// drops on save): a banner lists them and the first save asks for an explicit
// confirmation before retrying with `force: true`. A value starting with "=" or
// "@" is stored as text unless the user turns the formula toggle on, so a typed
// `=cmd|…` cannot execute when the file is later opened in Excel.
export function renderXlsxViewer(area, data) {
const sheets = data.xlsx_sheets || [];
const lossy = data.xlsx_lossy_features || [];
// Session-scoped state: once the lossy write is confirmed, the rest of the
// session saves without asking again (never persisted — a confirmation is
// per workbook, not a global preference).
let lossyConfirmed = false;
let allowFormula = false;
const tabs = sheets.length > 1
? `<div class="xlsx-tabs">${sheets.map((s, i) =>
`<button class="xlsx-tab${i === 0 ? " active" : ""}" data-sheet="${i}">${escapeHtml(s.name)}</button>`
@@ -1009,18 +1021,33 @@ function renderXlsxViewer(area, data) {
const panels = sheets.map((s, i) =>
`<div class="xlsx-panel" data-sheet="${i}"${i === 0 ? "" : ' style="display:none"'}>${s.html}</div>`
).join("");
const lossWarning = lossy.length
? `<div class="xlsx-warning" role="note">
<i data-lucide="alert-triangle" class="xlsx-warning-icon"></i>
<div class="xlsx-warning-body">
<strong>${escapeHtml(t("xlsx.lossy_title"))}</strong>
<span class="xlsx-warning-list">${lossy
.map((f) => `<span class="xlsx-warning-tag">${escapeHtml(t("xlsx.feature_" + f))}</span>`)
.join("")}</span>
<span class="xlsx-warning-hint">${escapeHtml(t("xlsx.lossy_hint"))}</span>
</div>
</div>`
: "";
area.innerHTML = `
<div class="xlsx-viewer">
<div class="xlsx-toolbar">
${tabs}
<span class="xlsx-toolbar-actions">
<button class="btn-action xlsx-formula-toggle" id="xlsx-formula-btn" type="button"
aria-pressed="false" title="${escapeHtml(t("xlsx.formula_toggle_title"))}">f(x)</button>
<button class="btn-action" id="xlsx-save-btn" disabled>${t("common.save")}</button>
<button class="btn-action" id="xlsx-download-btn">
<i data-lucide="download" style="width:14px;height:14px"></i> ${t("viewer.download")}
</button>
</span>
</div>
${lossWarning}
<div class="xlsx-panels">${panels}</div>
</div>`;
@@ -1061,6 +1088,23 @@ function renderXlsxViewer(area, data) {
});
});
// Formula toggle (#153 A4) — opt-in for this viewing session only.
const formulaBtn = area.querySelector("#xlsx-formula-btn");
formulaBtn.addEventListener("click", () => {
allowFormula = !allowFormula;
formulaBtn.setAttribute("aria-pressed", String(allowFormula));
formulaBtn.classList.toggle("active", allowFormula);
});
const putSheet = (job, force) => api(
`/api/file/${encodeURIComponent(data.vault)}/xlsx/save?path=${encodeURIComponent(data.path)}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...job, allow_formula: allowFormula, force }),
},
);
saveBtn.addEventListener("click", async () => {
// One PUT per sheet (dirty cells can span tabs before a save).
const jobs = panelEls
@@ -1074,11 +1118,25 @@ function renderXlsxViewer(area, data) {
saveBtn.disabled = true;
try {
for (const job of jobs) {
await api(`/api/file/${encodeURIComponent(data.vault)}/xlsx/save?path=${encodeURIComponent(data.path)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(job),
});
// 409 xlsx_lossy_content → confirm once, then retry with force: true.
// (Also covers a workbook that became lossy while it was open.)
let force = lossyConfirmed;
for (;;) {
try {
await putSheet(job, force);
break;
} catch (err) {
if (err && err.code === "xlsx_lossy_content" && !lossyConfirmed) {
const features = (err.details && err.details.features) || lossy;
const labels = features.map((f) => t("xlsx.feature_" + f)).join(", ");
if (!confirm(t("xlsx.lossy_confirm", { features: labels }))) throw err;
lossyConfirmed = true;
force = true;
continue;
}
throw err;
}
}
}
area.querySelectorAll("td.xlsx-dirty").forEach((td) => {
td.classList.remove("xlsx-dirty");
@@ -1088,7 +1146,12 @@ function renderXlsxViewer(area, data) {
showToast(t("editor.saved"), "success");
} catch (err) {
refreshSaveState();
showToast(`${t("editor.save_error")}: ${err.message || err}`, "error");
// A refused confirmation is a decision, not a failure: neutral toast.
if (err && err.code === "xlsx_lossy_content") {
showToast(t("xlsx.lossy_cancelled"), "info");
} else {
showToast(`${t("editor.save_error")}: ${err.message || err}`, "error");
}
}
});
+13
View File
@@ -1823,6 +1823,19 @@
"viewer.copy": "Copy",
"viewer.copy_error": "Copy error",
"viewer.download": "Download",
"xlsx.lossy_title": "Simplified save",
"xlsx.lossy_hint": "ObsiGate cannot preserve these elements: saving will ask for your confirmation.",
"xlsx.lossy_confirm": "Save anyway? The following will be lost: {features}",
"xlsx.lossy_cancelled": "Save cancelled",
"xlsx.formula_toggle_title": "Treat “=” and “@” as formulas (off by default)",
"xlsx.feature_cached_values": "cached values",
"xlsx.feature_slicers": "slicers and timelines",
"xlsx.feature_form_controls": "form controls",
"xlsx.feature_connections": "connections and queries",
"xlsx.feature_custom_xml": "custom XML",
"xlsx.feature_signature": "digital signature",
"xlsx.feature_rich_comments": "rich comments",
"xlsx.feature_macros": "macros",
"viewer.download_md": "Download as .md",
"viewer.download_file": "Download file",
"viewer.pretty": "Pretty",
+13
View File
@@ -1823,6 +1823,19 @@
"viewer.copy": "Copier",
"viewer.copy_error": "Erreur lors de la copie",
"viewer.download": "Télécharger",
"xlsx.lossy_title": "Enregistrement simplifié",
"xlsx.lossy_hint": "Ces éléments ne peuvent pas être conservés par ObsiGate : une sauvegarde vous demandera confirmation.",
"xlsx.lossy_confirm": "Enregistrer quand même ? Les éléments suivants seront perdus : {features}",
"xlsx.lossy_cancelled": "Sauvegarde annulée",
"xlsx.formula_toggle_title": "Interpréter « = » et « @ » comme des formules (désactivé par défaut)",
"xlsx.feature_cached_values": "valeurs calculées",
"xlsx.feature_slicers": "segments et chronologies",
"xlsx.feature_form_controls": "contrôles de formulaire",
"xlsx.feature_connections": "connexions et requêtes",
"xlsx.feature_custom_xml": "XML personnalisé",
"xlsx.feature_signature": "signature numérique",
"xlsx.feature_rich_comments": "commentaires enrichis",
"xlsx.feature_macros": "macros",
"viewer.download_md": "Télécharger en .md",
"viewer.download_file": "Télécharger le fichier",
"viewer.pretty": "Pretty",
+60
View File
@@ -10987,6 +10987,66 @@ body.desktop-mode .editor-container {
background: rgba(255, 196, 0, 0.18);
}
/* #153 A1/A4 — lossy-save warning + formula toggle */
.xlsx-warning {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px 10px;
margin-bottom: 8px;
border: 1px solid var(--warning, #e0a800);
border-left-width: 3px;
border-radius: 4px;
background: var(--surface);
color: var(--text-secondary);
font-size: 0.82rem;
line-height: 1.45;
}
.xlsx-warning-icon {
width: 16px;
height: 16px;
flex: 0 0 auto;
margin-top: 1px;
color: var(--warning, #e0a800);
}
.xlsx-warning-body {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.xlsx-warning-body strong {
color: var(--text-primary);
font-weight: 600;
}
.xlsx-warning-list {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.xlsx-warning-tag {
padding: 1px 6px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-secondary);
color: var(--text-secondary);
font-size: 0.75rem;
white-space: nowrap;
}
.xlsx-warning-hint {
color: var(--text-secondary);
opacity: 0.85;
}
.xlsx-formula-toggle {
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-weight: 600;
}
.xlsx-formula-toggle.active {
background: var(--accent, #4a90d9);
border-color: var(--accent, #4a90d9);
color: #fff;
}
/* ── JSON Viewer ── */
.json-viewer {
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
+1 -1
View File
@@ -11,7 +11,7 @@
* cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is
* a separate store. Bumping SW_VERSION invalidates it on every release.
*/
const SW_VERSION = 'v26';
const SW_VERSION = 'v27';
const CODE_CACHE = `obsigate-code-${SW_VERSION}`;
const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`;
const API_CACHE = `obsigate-api-${SW_VERSION}`;