feat: structure des classeurs Excel editable depuis la visionneuse #153
A14 : service mutate_xlsx_structure (batch ordonné d'actions
sheet_add/sheet_rename/sheet_delete/sheet_duplicate et
row/col_insert/delete, une seule réécriture verrouillée et atomique,
409 lossy sans force, backup, dernier sheet protégé) ; endpoint
PUT /api/file/{vault}/xlsx/structure (1-50 actions/requête) ;
menu « structure » dans la visionneuse : ajout/renommage/duplication/
suppression de feuilles et insertion/suppression de ligne/colonne à
partir de la cellule active, confirmations explicites pour les
destructions, re-rendu serveur après succès, reprise force après
confirmation 409.
Vérifié : test_xlsx_structure.py 11 (ops, gardes, atomicité du batch,
409 lossy + force) + contre-preuve (garde dernier sheet neutralisée ->
1 échec), xlsx-viewer.test.mjs 33/33 (3 nouveaux), ruff 0, mypy 0,
i18n parity, validate-imports.
🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
This commit is contained in:
@@ -1077,6 +1077,9 @@ export function renderXlsxViewer(area, data) {
|
||||
<button class="btn-action" id="xlsx-csv-btn" title="${escapeHtml(t("xlsx.csv_export"))}">
|
||||
<i data-lucide="file-spreadsheet" style="width:14px;height:14px"></i> CSV
|
||||
</button>
|
||||
<button class="btn-action" id="xlsx-structure-btn" title="${escapeHtml(t("xlsx.structure_btn"))}">
|
||||
<i data-lucide="table-properties" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
${lossWarning}
|
||||
@@ -1557,6 +1560,109 @@ export function renderXlsxViewer(area, data) {
|
||||
runFind();
|
||||
});
|
||||
|
||||
// ── #153 A14 — workbook structure menu (sheets, rows, columns) ─────────
|
||||
// Every action is an explicit user gesture (prompt/confirm) and goes to
|
||||
// PUT …/xlsx/structure — one locked, atomic rewrite with a backup.
|
||||
const visibleSheetIndex = () =>
|
||||
Number((visiblePanel() || panelEls[0])?.dataset.sheet) || 0;
|
||||
|
||||
const putStructure = async (actions, force = false) => {
|
||||
await api(
|
||||
`/api/file/${encodeURIComponent(data.vault)}/xlsx/structure?path=${encodeURIComponent(data.path)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ actions, force }),
|
||||
},
|
||||
);
|
||||
showToast(t("xlsx.structure_saved"), "success");
|
||||
// Re-render from the server so the viewer shows the new structure.
|
||||
const fresh = await api(
|
||||
`/api/file/${encodeURIComponent(data.vault)}?path=${encodeURIComponent(data.path)}`,
|
||||
);
|
||||
renderXlsxViewer(area, fresh);
|
||||
};
|
||||
|
||||
const structureError = (err, retryActions) => {
|
||||
if (err && err.code === "xlsx_lossy_content") {
|
||||
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 }))) {
|
||||
return putStructure(retryActions, true); // re-emitted with force
|
||||
}
|
||||
showToast(t("xlsx.lossy_cancelled"), "info");
|
||||
return null;
|
||||
}
|
||||
showToast(`${t("xlsx.structure_error")}: ${err.message || err}`, "error");
|
||||
return null;
|
||||
};
|
||||
|
||||
// Structure menu: built on demand, positioned under the button.
|
||||
area.querySelector("#xlsx-structure-btn").addEventListener("click", (e) => {
|
||||
const old = area.querySelector(".xlsx-structure-menu");
|
||||
if (old) { old.remove(); return; }
|
||||
const idx = visibleSheetIndex();
|
||||
const sheetName = sheets[idx]?.name || "";
|
||||
const activeRef = cellName(activeTd && activeTd.closest(".xlsx-panel") === visiblePanel() ? activeTd : null);
|
||||
const parsed = parseRef(activeRef);
|
||||
const menu = document.createElement("div");
|
||||
menu.className = "xlsx-structure-menu";
|
||||
const item = (label, fn) => {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "btn-action xlsx-structure-item";
|
||||
b.textContent = label;
|
||||
b.addEventListener("click", () => { menu.remove(); fn(); });
|
||||
menu.appendChild(b);
|
||||
};
|
||||
item(t("xlsx.sheet_add"), async () => {
|
||||
const name = prompt(t("xlsx.structure_prompt_add"));
|
||||
if (!name) return;
|
||||
const actions = [{ op: "sheet_add", name }];
|
||||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||
});
|
||||
item(t("xlsx.sheet_rename"), async () => {
|
||||
const to = prompt(t("xlsx.structure_prompt_rename"), sheetName);
|
||||
if (!to || to === sheetName) return;
|
||||
const actions = [{ op: "sheet_rename", from: sheetName, to }];
|
||||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||
});
|
||||
item(t("xlsx.sheet_duplicate"), async () => {
|
||||
const as = prompt(t("xlsx.structure_prompt_add"), `${sheetName} (copie)`);
|
||||
if (!as) return;
|
||||
const actions = [{ op: "sheet_duplicate", name: sheetName, as }];
|
||||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||
});
|
||||
item(t("xlsx.sheet_delete"), async () => {
|
||||
if (sheets.length <= 1) { showToast(t("xlsx.last_sheet"), "info"); return; }
|
||||
if (!confirm(t("xlsx.structure_confirm_delete_sheet", { name: sheetName }))) return;
|
||||
const actions = [{ op: "sheet_delete", name: sheetName }];
|
||||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||
});
|
||||
if (parsed) {
|
||||
menu.appendChild(Object.assign(document.createElement("div"), { className: "xlsx-structure-sep" }));
|
||||
item(t("xlsx.row_insert"), async () => {
|
||||
const actions = [{ op: "row_insert", sheet: sheetName, at: parsed.row }];
|
||||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||
});
|
||||
item(t("xlsx.row_delete"), async () => {
|
||||
if (!confirm(t("xlsx.structure_confirm_row", { n: parsed.row }))) return;
|
||||
const actions = [{ op: "row_delete", sheet: sheetName, at: parsed.row }];
|
||||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||
});
|
||||
item(t("xlsx.col_insert"), async () => {
|
||||
const actions = [{ op: "col_insert", sheet: sheetName, at: parsed.col }];
|
||||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||
});
|
||||
item(t("xlsx.col_delete"), async () => {
|
||||
if (!confirm(t("xlsx.structure_confirm_col", { n: columnName(parsed.col) }))) return;
|
||||
const actions = [{ op: "col_delete", sheet: sheetName, at: parsed.col }];
|
||||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||
});
|
||||
}
|
||||
e.target.closest(".xlsx-toolbar").appendChild(menu);
|
||||
});
|
||||
|
||||
// CSV export of the visible sheet (post-trim, pre-save data).
|
||||
area.querySelector("#xlsx-csv-btn").addEventListener("click", () => {
|
||||
const panel = visiblePanel();
|
||||
|
||||
@@ -1850,6 +1850,24 @@
|
||||
"xlsx.sort_applied": "Sort applied on {col} — display only, the workbook is unchanged",
|
||||
"xlsx.sort_reset": "Reset sort and filter",
|
||||
"xlsx.filter_placeholder": "Filter rows…",
|
||||
"xlsx.structure_btn": "Sheet structure",
|
||||
"xlsx.structure_title": "Edit the workbook structure",
|
||||
"xlsx.sheet_add": "Add a sheet",
|
||||
"xlsx.sheet_rename": "Rename the current sheet",
|
||||
"xlsx.sheet_duplicate": "Duplicate the current sheet",
|
||||
"xlsx.sheet_delete": "Delete the current sheet",
|
||||
"xlsx.row_insert": "Insert a row above",
|
||||
"xlsx.row_delete": "Delete the active cell's row",
|
||||
"xlsx.col_insert": "Insert a column to the left",
|
||||
"xlsx.col_delete": "Delete the active cell's column",
|
||||
"xlsx.structure_prompt_add": "Name of the new sheet:",
|
||||
"xlsx.structure_prompt_rename": "New name of the sheet:",
|
||||
"xlsx.structure_confirm_delete_sheet": "Permanently delete the sheet “{name}”? This changes the file (a backup is created).",
|
||||
"xlsx.structure_confirm_row": "Delete row {n}? This changes the file (a backup is created).",
|
||||
"xlsx.structure_confirm_col": "Delete column {n}? This changes the file (a backup is created).",
|
||||
"xlsx.structure_saved": "Structure updated",
|
||||
"xlsx.structure_error": "Could not change the structure",
|
||||
"xlsx.last_sheet": "The last sheet cannot be deleted",
|
||||
"xlsx.feature_cached_values": "cached values",
|
||||
"xlsx.feature_slicers": "slicers and timelines",
|
||||
"xlsx.feature_form_controls": "form controls",
|
||||
|
||||
@@ -1850,6 +1850,24 @@
|
||||
"xlsx.sort_applied": "Tri appliqué sur {col} — l'affichage seul, le classeur n'est pas modifié",
|
||||
"xlsx.sort_reset": "Réinitialiser le tri et le filtre",
|
||||
"xlsx.filter_placeholder": "Filtrer les lignes…",
|
||||
"xlsx.structure_btn": "Structure de la feuille",
|
||||
"xlsx.structure_title": "Modifier la structure du classeur",
|
||||
"xlsx.sheet_add": "Ajouter une feuille",
|
||||
"xlsx.sheet_rename": "Renommer la feuille courante",
|
||||
"xlsx.sheet_duplicate": "Dupliquer la feuille courante",
|
||||
"xlsx.sheet_delete": "Supprimer la feuille courante",
|
||||
"xlsx.row_insert": "Insérer une ligne au-dessus",
|
||||
"xlsx.row_delete": "Supprimer la ligne de la cellule active",
|
||||
"xlsx.col_insert": "Insérer une colonne à gauche",
|
||||
"xlsx.col_delete": "Supprimer la colonne de la cellule active",
|
||||
"xlsx.structure_prompt_add": "Nom de la nouvelle feuille :",
|
||||
"xlsx.structure_prompt_rename": "Nouveau nom de la feuille :",
|
||||
"xlsx.structure_confirm_delete_sheet": "Supprimer définitivement la feuille « {name} » ? Cette action modifie le fichier (un backup est créé).",
|
||||
"xlsx.structure_confirm_row": "Supprimer la ligne {n} ? Cette action modifie le fichier (un backup est créé).",
|
||||
"xlsx.structure_confirm_col": "Supprimer la colonne {n} ? Cette action modifie le fichier (un backup est créé).",
|
||||
"xlsx.structure_saved": "Structure mise à jour",
|
||||
"xlsx.structure_error": "Modification de la structure impossible",
|
||||
"xlsx.last_sheet": "Impossible de supprimer la dernière feuille",
|
||||
"xlsx.feature_cached_values": "valeurs calculées",
|
||||
"xlsx.feature_slicers": "segments et chronologies",
|
||||
"xlsx.feature_form_controls": "contrôles de formulaire",
|
||||
|
||||
@@ -10931,6 +10931,7 @@ body.desktop-mode .editor-container {
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
position: relative; /* anchors the A14 structure menu */
|
||||
}
|
||||
.xlsx-toolbar-actions {
|
||||
margin-left: auto;
|
||||
@@ -11090,6 +11091,41 @@ body.desktop-mode .editor-container {
|
||||
outline: 2px solid var(--accent, #4a90d9);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* #153 A14 — structure menu (sheets / rows / columns) */
|
||||
.xlsx-structure-menu {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 240px;
|
||||
margin-top: 4px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 8px 24px var(--shadow, rgba(0, 0, 0, 0.25));
|
||||
}
|
||||
.xlsx-structure-item {
|
||||
text-align: left;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.xlsx-structure-item:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
.xlsx-structure-sep {
|
||||
height: 1px;
|
||||
margin: 4px 0;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
/* JSDOM shims for the tests that click anchors */
|
||||
mark {
|
||||
font: inherit;
|
||||
}
|
||||
.xlsx-table td.xlsx-dirty {
|
||||
background: rgba(255, 196, 0, 0.18);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user