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:
+12
-1
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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}`;
|
||||
|
||||
Reference in New Issue
Block a user