Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5c528fead | ||
|
|
38f39a10ae |
+9
-1
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
|
||||
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
> **En cours de développement** : les changements à venir sont listés dans la section
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.34.0**.
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.36.0**.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,14 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [2.36.0] — 2026-09-28
|
||||
|
||||
---
|
||||
|
||||
## [2.35.0] — 2026-09-28
|
||||
|
||||
---
|
||||
|
||||
## [2.34.0] — 2026-09-28
|
||||
|
||||
---
|
||||
|
||||
+3
-3
@@ -4,7 +4,7 @@
|
||||
|
||||
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.34.0).
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.36.0).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.34.0 | Dernière mise à jour : Septembre 2026*
|
||||
*Projet : ObsiGate | Version : 2.36.0 | Dernière mise à jour : Septembre 2026*
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.34.0).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.36.0).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.34.0 | Last updated: September 2026*
|
||||
*Project: ObsiGate | Version: 2.36.0 | Last updated: September 2026*
|
||||
|
||||
@@ -64,6 +64,9 @@ from backend.services.mutations import (
|
||||
from backend.services.mutations import (
|
||||
move_path as service_move_path,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
mutate_xlsx_structure as service_mutate_xlsx_structure,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
rename_directory as service_rename_directory,
|
||||
)
|
||||
@@ -174,6 +177,61 @@ def api_file_xlsx_save(
|
||||
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]}
|
||||
|
||||
|
||||
@router.put("/api/file/{vault_name}/xlsx/structure", response_model=FileSaveResponse)
|
||||
def api_file_xlsx_structure(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to the .xlsx file"),
|
||||
body: dict = Body(
|
||||
...,
|
||||
description=(
|
||||
'{"actions": [{"op": "sheet_add", "name": "X"}, '
|
||||
'{"op": "row_insert", "sheet": "X", "at": 2, "count": 1}], '
|
||||
'"force": false}'
|
||||
),
|
||||
),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Apply structural changes to an .xlsx workbook (#153 A14).
|
||||
|
||||
``actions`` is an ordered list applied in one locked, atomic rewrite:
|
||||
``sheet_add`` (``name``, optional ``at`` 0-based), ``sheet_rename``
|
||||
(``from``/``to``), ``sheet_delete`` (refused on the last sheet),
|
||||
``sheet_duplicate`` (``name``/``as``) and ``row_insert``/``row_delete``/
|
||||
``col_insert``/``col_delete`` (``sheet``, 1-based ``at``, ``count``).
|
||||
|
||||
Without ``force`` the call fails **409** ``xlsx_lossy_content`` when the
|
||||
workbook carries features openpyxl cannot rewrite (same gate as the cell
|
||||
edits). A backup is created before the archive is replaced.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative path to the ``.xlsx`` file.
|
||||
body: JSON body with ``actions`` (1 to 50) and optional ``force``.
|
||||
|
||||
Returns:
|
||||
``FileSaveResponse`` confirming the write.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
actions = body.get("actions")
|
||||
if not isinstance(actions, list) or not actions or len(actions) > 50:
|
||||
raise HTTPException(status_code=400, detail="Actions invalides (1 à 50 par requête)")
|
||||
raw_force = body.get("force", False)
|
||||
if not isinstance(raw_force, bool):
|
||||
raise HTTPException(status_code=400, detail="Flag invalide: force")
|
||||
|
||||
result = service_mutate_xlsx_structure(
|
||||
vault_name, path, actions, force=raw_force
|
||||
)
|
||||
log_file_save(
|
||||
current_user["username"], vault_name, path,
|
||||
len(actions),
|
||||
current_user.get("_request_ip", "unknown"),
|
||||
)
|
||||
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": len(result["applied"])}
|
||||
|
||||
|
||||
@router.delete("/api/file/{vault_name}", response_model=FileDeleteResponse)
|
||||
async def api_file_delete(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
|
||||
"""Delete a file from the vault.
|
||||
|
||||
@@ -455,6 +455,193 @@ def edit_xlsx_cells(
|
||||
}
|
||||
|
||||
|
||||
def mutate_xlsx_structure(
|
||||
vault_name: str,
|
||||
path: str,
|
||||
actions: list[dict[str, Any]],
|
||||
*,
|
||||
backup: bool = True,
|
||||
force: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Apply structural changes to an ``.xlsx`` workbook (#153 A14).
|
||||
|
||||
``actions`` is an ordered list — the workbook is loaded once and every
|
||||
action is applied in sequence inside the same per-file lock and the same
|
||||
atomic replace, so a half-applied batch can never reach the disk:
|
||||
|
||||
* ``{"op": "sheet_add", "name": "X", "at": 1}`` — new sheet (at =
|
||||
optional 0-based position);
|
||||
* ``{"op": "sheet_rename", "from": "X", "to": "Y"}``;
|
||||
* ``{"op": "sheet_delete", "name": "X"}`` — refused when it is the
|
||||
last sheet (an openpyxl workbook must keep one);
|
||||
* ``{"op": "sheet_duplicate", "name": "X", "as": "Y"}`` — values,
|
||||
styles and merged ranges are copied (not the data-dependent objects);
|
||||
* ``{"op": "row_insert"|"row_delete"|"col_insert"|"col_delete",
|
||||
"sheet": "X", "at": N, "count": k}`` — 1-based position, default 1.
|
||||
|
||||
All of it rides the same guards as the cell edits (P0): per-file lock,
|
||||
``.tmp`` + ``os.replace`` atomic write and the ``force`` gate on lossy
|
||||
round-trips. The UI proposes these actions with an explicit confirmation
|
||||
— deletions are NOT recoverable from the viewer (only via the ``.bak``).
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise ServiceError(
|
||||
f"File not found: {path}",
|
||||
code="not_found",
|
||||
status=404,
|
||||
details={"vault": vault_name, "path": path},
|
||||
)
|
||||
|
||||
if not actions or len(actions) > 50:
|
||||
raise ServiceError(
|
||||
"Invalid actions (1 to 50 per request)", code="invalid", status=400
|
||||
)
|
||||
|
||||
if not force:
|
||||
from backend.xlsx_reader import inspect_workbook
|
||||
|
||||
lossy = inspect_workbook(file_path)
|
||||
if lossy:
|
||||
raise ServiceError(
|
||||
"Restructuring this workbook would drop features ObsiGate "
|
||||
"cannot preserve; retry with force=true after confirmation",
|
||||
code="xlsx_lossy_content",
|
||||
status=409,
|
||||
details={"path": path, "features": lossy},
|
||||
)
|
||||
|
||||
with _xlsx_write_lock(str(file_path)):
|
||||
from openpyxl import load_workbook
|
||||
from openpyxl.worksheet.copier import WorksheetCopy
|
||||
|
||||
try:
|
||||
wb = load_workbook(file_path)
|
||||
except Exception as exc:
|
||||
raise ServiceError(
|
||||
f"Cannot open workbook: {exc}", code="invalid", status=400
|
||||
) from exc
|
||||
|
||||
rel_path = _rel(root, file_path)
|
||||
applied: list[str] = []
|
||||
try:
|
||||
for i, action in enumerate(actions):
|
||||
op = action.get("op")
|
||||
try:
|
||||
if op == "sheet_add":
|
||||
name = str(action.get("name", "")).strip()
|
||||
if not name or name in wb.sheetnames:
|
||||
raise ServiceError(
|
||||
f"Nom de feuille invalide ou déjà pris: {name!r}",
|
||||
code="invalid", status=400,
|
||||
)
|
||||
ws = wb.create_sheet(name[:31])
|
||||
at = action.get("at")
|
||||
# create_sheet appends at the end: shift left by the
|
||||
# distance between the last index and the target.
|
||||
if isinstance(at, int) and 0 <= at < len(wb.sheetnames):
|
||||
wb.move_sheet(ws, offset=at - (len(wb.sheetnames) - 1))
|
||||
applied.append(f"sheet_add:{ws.title}")
|
||||
elif op == "sheet_rename":
|
||||
src, dst = str(action.get("from", "")), str(action.get("to", "")).strip()
|
||||
if src not in wb.sheetnames or not dst or dst in wb.sheetnames:
|
||||
raise ServiceError(
|
||||
f"Renommage invalide: {src!r} -> {dst!r}",
|
||||
code="invalid", status=400,
|
||||
)
|
||||
wb[src].title = dst[:31]
|
||||
applied.append(f"sheet_rename:{src}->{dst}")
|
||||
elif op == "sheet_delete":
|
||||
name = str(action.get("name", ""))
|
||||
if name not in wb.sheetnames:
|
||||
raise ServiceError(
|
||||
f"Feuille introuvable: {name}", code="invalid", status=400
|
||||
)
|
||||
if len(wb.sheetnames) <= 1:
|
||||
raise ServiceError(
|
||||
"Impossible de supprimer la dernière feuille",
|
||||
code="invalid", status=400,
|
||||
)
|
||||
del wb[name]
|
||||
applied.append(f"sheet_delete:{name}")
|
||||
elif op == "sheet_duplicate":
|
||||
name = str(action.get("name", ""))
|
||||
new_name = str(action.get("as", "")).strip()
|
||||
if name not in wb.sheetnames or not new_name or new_name in wb.sheetnames:
|
||||
raise ServiceError(
|
||||
f"Duplication invalide: {name!r} -> {new_name!r}",
|
||||
code="invalid", status=400,
|
||||
)
|
||||
# WorksheetCopy is the documented dup path (openpyxl
|
||||
# 3.1); it copies values, styles and merges — not
|
||||
# charts/images, which openpyxl itself cannot clone.
|
||||
copy = wb.create_sheet(new_name[:31])
|
||||
WorksheetCopy(wb[name], copy).copy_worksheet()
|
||||
applied.append(f"sheet_duplicate:{name}->{copy.title}")
|
||||
elif op in ("row_insert", "row_delete", "col_insert", "col_delete"):
|
||||
sheet = str(action.get("sheet", ""))
|
||||
if sheet not in wb.sheetnames:
|
||||
raise ServiceError(
|
||||
f"Feuille introuvable: {sheet}", code="invalid", status=400
|
||||
)
|
||||
ws = wb[sheet]
|
||||
at = action.get("at", 1)
|
||||
count = action.get("count", 1)
|
||||
if not isinstance(at, int) or at < 1 or not isinstance(count, int) or count < 1:
|
||||
raise ServiceError(
|
||||
"Position 'at' / 'count' invalides", code="invalid", status=400
|
||||
)
|
||||
if op == "row_insert":
|
||||
ws.insert_rows(at, count)
|
||||
elif op == "row_delete":
|
||||
ws.delete_rows(at, count)
|
||||
elif op == "col_insert":
|
||||
ws.insert_cols(at, count)
|
||||
else:
|
||||
ws.delete_cols(at, count)
|
||||
applied.append(f"{op}:{sheet}@{at}x{count}")
|
||||
else:
|
||||
raise ServiceError(
|
||||
f"Action inconnue: {op!r}", code="invalid", status=400
|
||||
)
|
||||
except ServiceError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise ServiceError(
|
||||
f"Action {i + 1} ({op}) a échoué: {exc}",
|
||||
code="invalid", status=400,
|
||||
) from exc
|
||||
except ServiceError:
|
||||
wb.close()
|
||||
raise
|
||||
|
||||
if backup:
|
||||
create_backup(file_path, vault_name, rel_path)
|
||||
|
||||
tmp_path = file_path.with_name(f"{file_path.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
wb.save(tmp_path)
|
||||
os.replace(tmp_path, file_path)
|
||||
except Exception:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
wb.close()
|
||||
raise
|
||||
wb.close()
|
||||
|
||||
logger.info(
|
||||
f"XLSX structure: {vault_name}/{rel_path} {applied}"
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"vault": vault_name,
|
||||
"path": rel_path,
|
||||
"applied": applied,
|
||||
}
|
||||
|
||||
|
||||
def append_to_file(
|
||||
vault_name: str,
|
||||
path: str,
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.34.0"
|
||||
version = "2.36.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.34.0"
|
||||
version = "2.36.0"
|
||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||
authors = ["Bruno Charest"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||
"productName": "ObsiGate",
|
||||
"version": "2.34.0",
|
||||
"version": "2.36.0",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.34.0 | **Dernière mise à jour :** 2026-09-28
|
||||
> **Version :** 2.36.0 | **Dernière mise à jour :** 2026-09-28
|
||||
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||
> vers les fonctionnalités livrées.
|
||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
||||
|
||||
@@ -1074,6 +1074,12 @@ export function renderXlsxViewer(area, data) {
|
||||
<button class="btn-action" id="xlsx-download-btn">
|
||||
<i data-lucide="download" style="width:14px;height:14px"></i> ${t("viewer.download")}
|
||||
</button>
|
||||
<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}
|
||||
@@ -1082,6 +1088,17 @@ export function renderXlsxViewer(area, data) {
|
||||
<i data-lucide="chevron-right" class="xlsx-formula-sep"></i>
|
||||
<input type="text" class="xlsx-formula-input" id="xlsx-formula-input"
|
||||
spellcheck="false" placeholder="${escapeHtml(t("xlsx.formula_bar_placeholder"))}" />
|
||||
<span class="xlsx-find-group">
|
||||
<input type="text" class="xlsx-find-input" id="xlsx-find-input"
|
||||
spellcheck="false" placeholder="${escapeHtml(t("xlsx.find_placeholder"))}" />
|
||||
<span class="xlsx-find-count" id="xlsx-find-count"></span>
|
||||
<button class="btn-action xlsx-find-btn" id="xlsx-find-prev" title="${escapeHtml(t("xlsx.find_prev"))}">↑</button>
|
||||
<button class="btn-action xlsx-find-btn" id="xlsx-find-next" title="${escapeHtml(t("xlsx.find_next"))}">↓</button>
|
||||
<button class="btn-action xlsx-find-case" id="xlsx-find-case" aria-pressed="false" title="${escapeHtml(t("xlsx.find_case"))}">Aa</button>
|
||||
<button class="btn-action xlsx-sort-reset" id="xlsx-sort-reset" title="${escapeHtml(t("xlsx.sort_reset"))}" style="display:none">
|
||||
<i data-lucide="rotate-ccw" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="xlsx-panels">${panels}</div>
|
||||
</div>`;
|
||||
@@ -1393,6 +1410,286 @@ export function renderXlsxViewer(area, data) {
|
||||
window.open(`/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`, "_blank");
|
||||
});
|
||||
|
||||
// ── #153 A13 — sort / filter / find in the sheet + CSV export ──────────
|
||||
// ALL of these act on the RENDERED grid only: the workbook is never
|
||||
// rewritten by a sort or a filter (the save pipeline stays the only write
|
||||
// path, and the note in xlsx.sort_applied says so).
|
||||
const visiblePanel = () =>
|
||||
panelEls.find((p) => p.style.display !== "none") || panelEls[0];
|
||||
|
||||
// Sorting reorders <tr> rows by the text of one column. Dirty cells travel
|
||||
// with their row, so a sort never loses an unsaved edit.
|
||||
let sortState = null; // { col, dir } on the visible panel
|
||||
|
||||
const applySort = (panel, col, dir) => {
|
||||
const tbody = panel.querySelector(".xlsx-table tbody");
|
||||
if (!tbody) return;
|
||||
const rows = [...tbody.querySelectorAll("tr")];
|
||||
const numeric = rows.every((tr) => {
|
||||
const td = tr.querySelector(`td[data-cell^="${columnName(col)}"]`);
|
||||
const v = td ? td.textContent.trim() : "";
|
||||
return v === "" || !isNaN(Number(v));
|
||||
});
|
||||
rows.sort((a, b) => {
|
||||
const ta = a.querySelector(`td[data-cell^="${columnName(col)}"]`);
|
||||
const tb = b.querySelector(`td[data-cell^="${columnName(col)}"]`);
|
||||
const va = ta ? ta.textContent.trim() : "";
|
||||
const vb = tb ? tb.textContent.trim() : "";
|
||||
const cmp = numeric
|
||||
? (parseFloat(va) || 0) - (parseFloat(vb) || 0)
|
||||
: va.localeCompare(vb, "fr");
|
||||
return dir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
rows.forEach((tr) => tbody.appendChild(tr));
|
||||
sortState = { col, dir };
|
||||
sortResetBtn.style.display = "";
|
||||
showToast(t("xlsx.sort_applied", { col: columnName(col) }), "info");
|
||||
};
|
||||
|
||||
// Header click cycles: asc → desc → back to the sheet order.
|
||||
const wireHeaderSort = (panel) => {
|
||||
const thead = panel.querySelector(".xlsx-table thead");
|
||||
if (!thead || thead.dataset.sortWired) return;
|
||||
thead.dataset.sortWired = "1";
|
||||
thead.querySelectorAll("th:not(.xlsx-corner)").forEach((th) => {
|
||||
th.style.cursor = "pointer";
|
||||
th.title = t("xlsx.sort_asc");
|
||||
th.addEventListener("click", () => {
|
||||
// The corner th is column 0, so the child index IS the column number.
|
||||
const col = [...th.parentElement.children].indexOf(th);
|
||||
const current = sortState && sortState.col === col ? sortState.dir : null;
|
||||
const dir = current === "asc" ? "desc" : "asc";
|
||||
applySort(panel, col, dir);
|
||||
th.title = dir === "asc" ? t("xlsx.sort_desc") : t("xlsx.sort_asc");
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Filter: rows whose cell text lacks the needle are hidden.
|
||||
const applyFilter = (panel, needle) => {
|
||||
const n = needle.trim().toLowerCase();
|
||||
panel.querySelectorAll(".xlsx-table tbody tr").forEach((tr) => {
|
||||
const text = tr.textContent.toLowerCase();
|
||||
tr.style.display = !n || text.includes(n) ? "" : "none";
|
||||
});
|
||||
if (n) sortResetBtn.style.display = "";
|
||||
};
|
||||
|
||||
panelEls.forEach(wireHeaderSort);
|
||||
|
||||
const sortResetBtn = area.querySelector("#xlsx-sort-reset");
|
||||
sortResetBtn.addEventListener("click", () => {
|
||||
// Full reset: re-render the current file (server truth), clear find too.
|
||||
sortResetBtn.style.display = "none";
|
||||
renderXlsxViewer(area, data);
|
||||
});
|
||||
|
||||
// Find-in-sheet: highlight matches, navigate with ↑/↓.
|
||||
let findMatches = [];
|
||||
let findIndex = -1;
|
||||
const findInput = area.querySelector("#xlsx-find-input");
|
||||
const findCount = area.querySelector("#xlsx-find-count");
|
||||
let findCase = false;
|
||||
|
||||
const clearFind = () => {
|
||||
findMatches.forEach(({ td, mark }) => {
|
||||
mark.replaceWith(document.createTextNode(mark.textContent));
|
||||
td.normalize();
|
||||
});
|
||||
findMatches = [];
|
||||
findIndex = -1;
|
||||
findCount.textContent = "";
|
||||
};
|
||||
|
||||
const runFind = () => {
|
||||
clearFind();
|
||||
const needle = findInput.value;
|
||||
if (!needle.trim()) return;
|
||||
const panel = visiblePanel();
|
||||
const hay = findCase ? (s) => s : (s) => s.toLowerCase();
|
||||
const n = hay(needle);
|
||||
panel.querySelectorAll(".xlsx-table td").forEach((td) => {
|
||||
const text = td.textContent;
|
||||
const pos = hay(text).indexOf(n);
|
||||
if (pos === -1 || !text) return;
|
||||
// Wrap the match in a <mark> by splitting the cell's first text node at
|
||||
// the hit (cells are text-first; the cached-value span stays untouched).
|
||||
const first = td.firstChild;
|
||||
if (first && first.nodeType === 3) {
|
||||
const mark = document.createElement("mark");
|
||||
mark.className = "xlsx-find-hit";
|
||||
mark.textContent = text.substr(pos, needle.length);
|
||||
const tail = first.splitText(pos);
|
||||
tail.splitText(needle.length);
|
||||
td.replaceChild(mark, tail);
|
||||
findMatches.push({ td, mark });
|
||||
}
|
||||
});
|
||||
findCount.textContent = findMatches.length
|
||||
? t("xlsx.find_count", { index: 1, count: findMatches.length })
|
||||
: t("xlsx.find_no_match");
|
||||
if (findMatches.length) focusMatch(0);
|
||||
};
|
||||
|
||||
const focusMatch = (i) => {
|
||||
findMatches.forEach(({ mark }) => mark.classList.remove("xlsx-find-current"));
|
||||
findIndex = (i + findMatches.length) % findMatches.length;
|
||||
const { td, mark } = findMatches[findIndex];
|
||||
mark.classList.add("xlsx-find-current");
|
||||
// scrollIntoView is missing in JSDOM; guard it (real browsers have it).
|
||||
if (typeof td.scrollIntoView === "function") td.scrollIntoView({ block: "nearest" });
|
||||
findCount.textContent = t("xlsx.find_count", { index: findIndex + 1, count: findMatches.length });
|
||||
};
|
||||
|
||||
// One input drives both the highlight (find) and the row filter (A13):
|
||||
// typing narrows the sheet to the matching rows AND highlights the hits.
|
||||
findInput.addEventListener("input", () => {
|
||||
panelEls.forEach((p) => applyFilter(p, findInput.value));
|
||||
runFind();
|
||||
});
|
||||
findInput.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") { e.preventDefault(); focusMatch(findIndex + (e.shiftKey ? -1 : 1)); }
|
||||
});
|
||||
area.querySelector("#xlsx-find-prev").addEventListener("click", () => focusMatch(findIndex - 1));
|
||||
area.querySelector("#xlsx-find-next").addEventListener("click", () => focusMatch(findIndex + 1));
|
||||
const caseBtn = area.querySelector("#xlsx-find-case");
|
||||
caseBtn.addEventListener("click", () => {
|
||||
findCase = !findCase;
|
||||
caseBtn.setAttribute("aria-pressed", String(findCase));
|
||||
caseBtn.classList.toggle("active", findCase);
|
||||
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();
|
||||
const idx = Number(panel.dataset.sheet);
|
||||
const rows = [];
|
||||
panel.querySelectorAll(".xlsx-table tbody tr").forEach((tr) => {
|
||||
rows.push(
|
||||
[...tr.querySelectorAll("td")].map((td) => {
|
||||
const clone = td.cloneNode(true);
|
||||
clone.querySelectorAll(".xlsx-cached").forEach((el) => el.remove());
|
||||
return clone.textContent;
|
||||
}),
|
||||
);
|
||||
});
|
||||
const csv = rows
|
||||
.map((r) => r.map((v) => (/[";\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v)).join(";"))
|
||||
.join("\n");
|
||||
const blob = new Blob([`\uFEFF${csv}`], { type: "text/csv;charset=utf-8" });
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `${sheets[idx]?.name || "feuille"}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(a.href);
|
||||
});
|
||||
|
||||
safeCreateIcons();
|
||||
}
|
||||
|
||||
|
||||
@@ -1838,6 +1838,36 @@
|
||||
"xlsx.load_error": "Could not load the remaining rows",
|
||||
"xlsx.formula_bar_placeholder": "Active cell content",
|
||||
"xlsx.active_cell": "Cell",
|
||||
"xlsx.find_placeholder": "Search in the sheet…",
|
||||
"xlsx.find_prev": "Previous",
|
||||
"xlsx.find_next": "Next",
|
||||
"xlsx.find_case": "Match case",
|
||||
"xlsx.find_no_match": "No match",
|
||||
"xlsx.find_count": "{index}/{count}",
|
||||
"xlsx.csv_export": "Export the sheet as CSV",
|
||||
"xlsx.sort_asc": "Sort column A→Z",
|
||||
"xlsx.sort_desc": "Sort column Z→A",
|
||||
"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",
|
||||
|
||||
@@ -1838,6 +1838,36 @@
|
||||
"xlsx.load_error": "Chargement de la suite impossible",
|
||||
"xlsx.formula_bar_placeholder": "Contenu de la cellule active",
|
||||
"xlsx.active_cell": "Cellule",
|
||||
"xlsx.find_placeholder": "Rechercher dans la feuille…",
|
||||
"xlsx.find_prev": "Précédent",
|
||||
"xlsx.find_next": "Suivant",
|
||||
"xlsx.find_case": "Respecter la casse",
|
||||
"xlsx.find_no_match": "Aucune correspondance",
|
||||
"xlsx.find_count": "{index}/{count}",
|
||||
"xlsx.csv_export": "Exporter la feuille en CSV",
|
||||
"xlsx.sort_asc": "Trier la colonne A→Z",
|
||||
"xlsx.sort_desc": "Trier la colonne Z→A",
|
||||
"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;
|
||||
@@ -11046,6 +11047,85 @@ body.desktop-mode .editor-container {
|
||||
.xlsx-formula-input:disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* #153 A13 — find-in-sheet + filter/sort controls */
|
||||
.xlsx-find-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.xlsx-find-input {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
max-width: 220px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.xlsx-find-input:focus {
|
||||
outline: 2px solid var(--accent, #4a90d9);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.xlsx-find-count {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.xlsx-find-btn,
|
||||
.xlsx-find-case,
|
||||
.xlsx-sort-reset {
|
||||
padding: 3px 8px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.xlsx-find-hit {
|
||||
background: var(--warning, #e0a800);
|
||||
color: var(--text-primary);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.xlsx-find-current {
|
||||
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);
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsigate",
|
||||
"version": "2.34.0",
|
||||
"version": "2.36.0",
|
||||
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
|
||||
"main": "patch.js",
|
||||
"directories": {
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
* editable like the initial ones.
|
||||
* - A7 : formula bar mirrors the active cell; arrows/Tab navigate; editing
|
||||
* from the bar marks the cell dirty; Escape reverts.
|
||||
* - A13 : header click sorts the rendered rows, the filter hides rows, the
|
||||
* find highlights matches, CSV export downloads the visible sheet.
|
||||
* - A14 : the structure menu sends one PUT …/xlsx/structure with the action,
|
||||
* then re-renders from the server; destructive actions confirm first.
|
||||
*
|
||||
* Usage: node tests/frontend/xlsx-viewer.test.mjs
|
||||
*/
|
||||
@@ -491,6 +495,159 @@ await test("a saved edit from the bar resets the dirty flag and orig value", asy
|
||||
assert.equal(td.dataset.orig, "200");
|
||||
});
|
||||
|
||||
// ── A13 — sort / filter / find / CSV export ─────────────────────────────────
|
||||
|
||||
const mountGrid = () => {
|
||||
const area = document.getElementById("content-area");
|
||||
area.innerHTML = "";
|
||||
const grid =
|
||||
'<div class="csv-table-wrapper"><table class="csv-table xlsx-table">' +
|
||||
'<thead><tr><th class="xlsx-corner"></th><th>A</th><th>B</th></tr></thead><tbody>' +
|
||||
'<tr><th class="xlsx-rownum">1</th><td data-cell="A1">Banane</td><td data-cell="B1">3</td></tr>' +
|
||||
'<tr><th class="xlsx-rownum">2</th><td data-cell="A2">Abricot</td><td data-cell="B2">10</td></tr>' +
|
||||
'<tr><th class="xlsx-rownum">3</th><td data-cell="A3">Cerise</td><td data-cell="B3">2</td></tr>' +
|
||||
"</tbody></table></div>";
|
||||
renderXlsxViewer(area, {
|
||||
vault: "V", path: "data.xlsx", is_xlsx: true,
|
||||
xlsx_sheets: [{ name: "Fruits", html: grid, rows: 3, cols: 2, total_rows: 3, total_cols: 2, max_rows: 500, max_cols: 40, truncated: false }],
|
||||
xlsx_lossy_features: [],
|
||||
});
|
||||
return area;
|
||||
};
|
||||
|
||||
await test("clicking a header sorts the rows numerically or lexically", () => {
|
||||
const area = mountGrid();
|
||||
// Sort by column B (numbers) ascending: 2, 3, 10.
|
||||
area.querySelector(".xlsx-table thead th:nth-child(3)").click();
|
||||
let cells = [...area.querySelectorAll("tbody td[data-cell^=\"B\"]")].map((td) => td.textContent);
|
||||
assert.deepEqual(cells, ["2", "3", "10"]);
|
||||
// Second click: descending.
|
||||
area.querySelector(".xlsx-table thead th:nth-child(3)").click();
|
||||
cells = [...area.querySelectorAll("tbody td[data-cell^=\"B\"]")].map((td) => td.textContent);
|
||||
assert.deepEqual(cells, ["10", "3", "2"]);
|
||||
});
|
||||
|
||||
await test("a dirty cell travels with its row during a sort", () => {
|
||||
const area = mountGrid();
|
||||
editCell(area, "A3", "Cerise modifiée");
|
||||
area.querySelector(".xlsx-table thead th:nth-child(3)").click();
|
||||
const aCells = [...area.querySelectorAll("tbody td[data-cell^=\"A\"]")].map((td) => td.textContent);
|
||||
assert.ok(aCells.includes("Cerise modifiée"), aCells.join("|"));
|
||||
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1);
|
||||
});
|
||||
|
||||
await test("the filter hides the rows that do not match", () => {
|
||||
const area = mountGrid();
|
||||
// The filter reuses the find input: type and the rows filter live.
|
||||
const input = area.querySelector("#xlsx-find-input");
|
||||
input.value = "abri";
|
||||
input.dispatchEvent(new w.Event("input", { bubbles: true }));
|
||||
const visible = [...area.querySelectorAll("tbody tr")].filter((tr) => tr.style.display !== "none");
|
||||
assert.equal(visible.length, 1);
|
||||
assert.ok(visible[0].textContent.includes("Abricot"));
|
||||
});
|
||||
|
||||
await test("find highlights matches and navigates with the counter", () => {
|
||||
const area = mountGrid();
|
||||
const input = area.querySelector("#xlsx-find-input");
|
||||
const count = area.querySelector("#xlsx-find-count");
|
||||
input.value = "cerise"; // lowercase: the default search ignores the case
|
||||
input.dispatchEvent(new w.Event("input", { bubbles: true }));
|
||||
assert.equal(area.querySelectorAll("mark.xlsx-find-hit").length, 1);
|
||||
assert.ok(count.textContent.includes("1/1"), count.textContent);
|
||||
// The hit is inside the matching cell.
|
||||
assert.ok(area.querySelector('td[data-cell="A3"] mark.xlsx-find-hit'));
|
||||
});
|
||||
|
||||
await test("CSV export downloads the visible sheet without the cached shadows", () => {
|
||||
const area = mountGrid();
|
||||
const clicks = [];
|
||||
const realCreate = document.createElement.bind(document);
|
||||
const anchor = realCreate("a");
|
||||
document.createElement = (tag) => {
|
||||
if (tag === "a") { clicks.push(1); return anchor; }
|
||||
return realCreate(tag);
|
||||
};
|
||||
let href = "";
|
||||
Object.defineProperty(anchor, "href", { set(v) { href = v; }, get: () => href });
|
||||
URL.createObjectURL = () => "blob:x";
|
||||
URL.revokeObjectURL = () => {};
|
||||
area.querySelector("#xlsx-csv-btn").click();
|
||||
document.createElement = realCreate;
|
||||
assert.equal(clicks.length, 1);
|
||||
assert.equal(anchor.download, "Fruits.csv");
|
||||
});
|
||||
|
||||
// ── A14 — structure menu ───────────────────────────────────────────────────
|
||||
|
||||
await test("sheet_add asks for a name, PUTs the action and re-renders", async () => {
|
||||
const area = mount();
|
||||
// The prompt is resolved through the module scope: stub it globally.
|
||||
const realPrompt = globalThis.prompt;
|
||||
globalThis.prompt = () => "Feuille 2";
|
||||
apiQueue.push({ ok: true, status: 200, body: { status: "ok" } }); // PUT
|
||||
apiQueue.push({
|
||||
ok: true, status: 200,
|
||||
body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Feuille 2", html: sheetHtml("neuf") }], xlsx_lossy_features: [] },
|
||||
}); // re-read
|
||||
area.querySelector("#xlsx-structure-btn").click();
|
||||
const items = [...area.querySelectorAll(".xlsx-structure-item")];
|
||||
const addBtn = items.find((b) => b.textContent === FR["xlsx.sheet_add"]);
|
||||
addBtn.click();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
globalThis.prompt = realPrompt;
|
||||
assert.equal(calls.length, 2);
|
||||
assert.match(calls[0].url, /\/xlsx\/structure\?path=data\.xlsx/);
|
||||
assert.deepEqual(calls[0].body.actions, [{ op: "sheet_add", name: "Feuille 2" }]);
|
||||
assert.equal(calls[0].body.force, false);
|
||||
// The viewer re-rendered from the server payload (a single sheet → no tabs).
|
||||
assert.ok(
|
||||
area.querySelector("#content-area, .xlsx-viewer") || area,
|
||||
"the viewer was rebuilt",
|
||||
);
|
||||
assert.ok(
|
||||
area.querySelector('td[data-cell="A1"]')?.textContent === "neuf",
|
||||
"the re-render shows the fresh payload",
|
||||
);
|
||||
});
|
||||
|
||||
await test("sheet_delete confirms and is refused on the last sheet", async () => {
|
||||
const area = mount();
|
||||
const delBtn = () => {
|
||||
area.querySelector("#xlsx-structure-btn").click();
|
||||
const items = [...area.querySelectorAll(".xlsx-structure-item")];
|
||||
const b = items.find((x) => x.textContent === FR["xlsx.sheet_delete"]);
|
||||
b.click();
|
||||
};
|
||||
// One sheet only → blocked before even confirming (no network call).
|
||||
delBtn();
|
||||
assert.equal(calls.length, 0, "nothing sent: last sheet");
|
||||
});
|
||||
|
||||
await test("the 409 lossy flow re-emits with force after confirmation", async () => {
|
||||
const area = mount();
|
||||
// The prompt is resolved through the module scope: stub it globally.
|
||||
const realPrompt = globalThis.prompt;
|
||||
globalThis.prompt = () => "Feuille 2";
|
||||
apiQueue.push({
|
||||
ok: false, status: 409,
|
||||
body: { detail: "…", code: "xlsx_lossy_content", details: { features: ["slicers"] } },
|
||||
});
|
||||
apiQueue.push({ ok: true, status: 200, body: { status: "ok" } }); // retry w/ force
|
||||
apiQueue.push({
|
||||
ok: true, status: 200,
|
||||
body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("1") }], xlsx_lossy_features: [] },
|
||||
});
|
||||
area.querySelector("#xlsx-structure-btn").click();
|
||||
const items = [...area.querySelectorAll(".xlsx-structure-item")];
|
||||
items.find((b) => b.textContent === FR["xlsx.sheet_add"]).click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
globalThis.prompt = realPrompt;
|
||||
assert.equal(confirmCalls, 1, "the user is asked about the lossy write");
|
||||
assert.equal(calls.length, 3);
|
||||
assert.equal(calls[1].body.force, true);
|
||||
});
|
||||
|
||||
await test("two windows in a row walk the whole sheet", async () => {
|
||||
const area = mountTruncated({ total: 1200 });
|
||||
apiQueue.push({
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Structural mutations of an .xlsx workbook (#153 A14): service + endpoint.
|
||||
|
||||
Covers sheet add/rename/delete/duplicate and row/col insert/delete, the
|
||||
atomicity of the batch (one locked rewrite) and the shared P0 guards
|
||||
(lossy 409 gate, backup, path safety).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
openpyxl = pytest.importorskip("openpyxl")
|
||||
|
||||
VAULT = "TestVault"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def book(test_vault_dir: str) -> str:
|
||||
path = Path(test_vault_dir) / "struct.xlsx"
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Data"
|
||||
ws.append(["Nom", "Valeur"])
|
||||
ws.append(["a", 1])
|
||||
ws.append(["b", 2])
|
||||
wb.create_sheet("Vide")
|
||||
wb.save(path)
|
||||
return str(path)
|
||||
|
||||
|
||||
def _put(client, path="struct.xlsx", actions=None, **extra):
|
||||
return client.put(
|
||||
f"/api/file/{VAULT}/xlsx/structure",
|
||||
params={"path": path},
|
||||
json={"actions": actions, **extra},
|
||||
)
|
||||
|
||||
|
||||
def _wb(path):
|
||||
wb = openpyxl.load_workbook(path)
|
||||
try:
|
||||
return wb
|
||||
finally:
|
||||
pass
|
||||
|
||||
|
||||
class TestSheetOps:
|
||||
def test_add_rename_delete_sheet(self, client, book):
|
||||
resp = _put(client, actions=[
|
||||
{"op": "sheet_add", "name": "Extra", "at": 0},
|
||||
{"op": "sheet_rename", "from": "Vide", "to": "Renommée"},
|
||||
])
|
||||
assert resp.status_code == 200
|
||||
wb = openpyxl.load_workbook(book)
|
||||
assert wb.sheetnames[0] == "Extra" # inserted at position 0
|
||||
assert "Renommée" in wb.sheetnames and "Vide" not in wb.sheetnames
|
||||
wb.close()
|
||||
|
||||
resp = _put(client, actions=[{"op": "sheet_delete", "name": "Extra"}])
|
||||
assert resp.status_code == 200
|
||||
wb = openpyxl.load_workbook(book)
|
||||
assert "Extra" not in wb.sheetnames
|
||||
wb.close()
|
||||
|
||||
def test_delete_last_sheet_refused(self, client, test_vault_dir):
|
||||
(Path(test_vault_dir) / "solo.xlsx").write_bytes(book_bytes("Solo"))
|
||||
resp = _put(client, path="solo.xlsx", actions=[
|
||||
{"op": "sheet_delete", "name": "Solo"},
|
||||
])
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_duplicate_copies_values(self, client, book):
|
||||
resp = _put(client, actions=[
|
||||
{"op": "sheet_duplicate", "name": "Data", "as": "Data copie"},
|
||||
])
|
||||
assert resp.status_code == 200
|
||||
wb = openpyxl.load_workbook(book)
|
||||
assert wb["Data copie"]["A1"].value == "Nom"
|
||||
assert wb["Data copie"]["B3"].value == 2
|
||||
wb.close()
|
||||
|
||||
|
||||
def book_bytes(sheet_name: str) -> bytes:
|
||||
import io
|
||||
|
||||
wb = openpyxl.Workbook()
|
||||
wb.active.title = sheet_name
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
class TestRowColOps:
|
||||
def test_row_insert_shifts_and_delete_removes(self, client, book):
|
||||
resp = _put(client, actions=[
|
||||
{"op": "row_insert", "sheet": "Data", "at": 2, "count": 1},
|
||||
])
|
||||
assert resp.status_code == 200
|
||||
wb = openpyxl.load_workbook(book)
|
||||
ws = wb["Data"]
|
||||
assert ws["A2"].value is None # the new blank row
|
||||
assert ws["A3"].value == "a" # shifted down
|
||||
wb.close()
|
||||
|
||||
resp = _put(client, actions=[
|
||||
{"op": "row_delete", "sheet": "Data", "at": 2, "count": 1},
|
||||
])
|
||||
assert resp.status_code == 200
|
||||
wb = openpyxl.load_workbook(book)
|
||||
assert wb["Data"]["A2"].value == "a"
|
||||
wb.close()
|
||||
|
||||
def test_col_insert_and_delete(self, client, book):
|
||||
assert _put(client, actions=[
|
||||
{"op": "col_insert", "sheet": "Data", "at": 2},
|
||||
]).status_code == 200
|
||||
wb = openpyxl.load_workbook(book)
|
||||
assert wb["Data"]["B1"].value is None
|
||||
assert wb["Data"]["C1"].value == "Valeur"
|
||||
wb.close()
|
||||
|
||||
assert _put(client, actions=[
|
||||
{"op": "col_delete", "sheet": "Data", "at": 2},
|
||||
]).status_code == 200
|
||||
wb = openpyxl.load_workbook(book)
|
||||
assert wb["Data"]["B1"].value == "Valeur"
|
||||
wb.close()
|
||||
|
||||
|
||||
class TestGuards:
|
||||
def test_unknown_sheet_is_400(self, client, book):
|
||||
resp = _put(client, actions=[{"op": "row_insert", "sheet": "Nope", "at": 1}])
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_unknown_op_is_400(self, client, book):
|
||||
resp = _put(client, actions=[{"op": "sheet_explode", "name": "X"}])
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_bad_position_is_400(self, client, book):
|
||||
resp = _put(client, actions=[
|
||||
{"op": "row_insert", "sheet": "Data", "at": "deux"},
|
||||
])
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_empty_actions_is_400(self, client, book):
|
||||
assert _put(client, actions=[]).status_code == 400
|
||||
|
||||
def test_lossy_workbook_refused_without_force(self, client, test_vault_dir):
|
||||
"""Same 409 gate as the cell edits (A1)."""
|
||||
path = Path(test_vault_dir) / "lossy-struct.xlsx"
|
||||
wb = openpyxl.Workbook()
|
||||
wb.active.title = "S"
|
||||
wb["S"]["A1"] = "=A2" # no cached value -> add one via the raw XML
|
||||
wb.save(path)
|
||||
with zipfile.ZipFile(path) as zf:
|
||||
items = {n: zf.read(n) for n in zf.namelist()}
|
||||
sheet = next(n for n in items if n.startswith("xl/worksheets/sheet"))
|
||||
items[sheet] = items[sheet].decode("utf-8").replace(
|
||||
"<f>A2</f>", "<f>A2</f><v>7</v>"
|
||||
).encode("utf-8")
|
||||
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for name, blob in items.items():
|
||||
zf.writestr(name, blob)
|
||||
|
||||
resp = _put(client, path="lossy-struct.xlsx", actions=[
|
||||
{"op": "sheet_add", "name": "X"},
|
||||
])
|
||||
assert resp.status_code == 409
|
||||
assert resp.json()["code"] == "xlsx_lossy_content"
|
||||
|
||||
resp = _put(client, path="lossy-struct.xlsx", actions=[
|
||||
{"op": "sheet_add", "name": "X"},
|
||||
], force=True)
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_atomicity_one_bad_action_writes_nothing(self, client, book):
|
||||
"""A batch with a valid action followed by a bad one writes nothing."""
|
||||
resp = _put(client, actions=[
|
||||
{"op": "sheet_add", "name": "Temp"},
|
||||
{"op": "sheet_delete", "name": "Inexistante"},
|
||||
])
|
||||
assert resp.status_code == 400
|
||||
wb = openpyxl.load_workbook(book)
|
||||
assert "Temp" not in wb.sheetnames
|
||||
wb.close()
|
||||
Reference in New Issue
Block a user