diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dd4247..25fec84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.25.1**. +> [Unreleased](#unreleased). La dernière version livrée est **2.27.0**. --- @@ -14,6 +14,20 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.27.0] — 2026-09-25 + +--- + +## [2.26.0] — 2026-09-25 + +### Ajouté + +- **#152 — Viewer XLSX** : affichage des fichiers `.xlsx` en tableaux multi-feuilles (onglets, + en-têtes A1), édition inline des cellules avec `PUT /api/file/{vault}/xlsx/save` (backup avant + écriture, coercion numérique) et téléchargement du fichier d'origine. + +--- + ## [2.25.1] — 2026-09-24 ### Corrigé diff --git a/README.fr.md b/README.fr.md index 14d7839..8eab06c 100644 --- a/README.fr.md +++ b/README.fr.md @@ -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. -[![Version](https://img.shields.io/badge/Version-2.25.1-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.27.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -85,6 +85,7 @@ Les **guides d'utilisation** pas à pas se trouvent dans [`docs/GUIDES/`](docs/G - **🖼️ Images Obsidian** : Support complet des syntaxes d'images Obsidian avec résolution intelligente - **🎬 Audio & vidéo** : Lecteurs HTML5 intégrés (`.mp3 .wav .flac .mp4 .webm`…) avec streaming HTTP Range (lecture, déplacement, plein écran) et **lecture persistante** (mini-lecteur flottant / mini-fenêtre vidéo, retour au média ou arrêt à tout moment, contrôles écran verrouillé via Media Session), repli téléchargement si le format n'est pas lisible par le navigateur - **🎨 Diagrammes Excalidraw** : Visualiseur/éditeur natif des fichiers `.excalidraw` et `.excalidraw.md` (iframe sandboxée, auto-save, thème clair/sombre, texte des diagrammes indexé pour la recherche) +- **📊 Tableurs Excel** : les fichiers `.xlsx` s'ouvrent dans un visualiseur dédié — un tableau par feuille avec onglets, en-têtes A1 et édition directe des cellules (`PUT /api/file/{vault}/xlsx/save`, backup automatique), plus le téléchargement du fichier d'origine - **🎨 Syntax highlight** : Coloration syntaxique des blocs de code - **🌓 Thème clair/sombre** : Toggle persisté en localStorage - **📡 Synchronisation temps réel** : Surveillance automatique des fichiers via watchdog avec mise à jour incrémentale de l'index @@ -975,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.25.1). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.27.0). --- -*Projet : ObsiGate | Version : 2.25.1 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.27.0 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index fc26ea7..2b02223 100644 --- a/README.md +++ b/README.md @@ -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. -[![Version](https://img.shields.io/badge/Version-2.25.1-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.27.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -84,6 +84,7 @@ Step-by-step **user guides** live in [`docs/GUIDES/`](docs/GUIDES/): - **🖼️ Obsidian Images** : Full support for all Obsidian image syntaxes with intelligent resolution - **🎬 Audio & video** : Built-in HTML5 players (`.mp3 .wav .flac .mp4 .webm`…) with HTTP Range streaming (play, seek, fullscreen) and **persistent playback** (floating mini-player / mini video window, return to media or stop anytime, lock-screen controls via Media Session), falling back to download when the format is not playable in the browser - **🎨 Excalidraw Diagrams** : Native viewer/editor for `.excalidraw` and `.excalidraw.md` files (sandboxed iframe, autosave, dark/light theme, diagram text indexed for search) +- **📊 Excel Spreadsheets** : `.xlsx` files open in a dedicated viewer — one table per sheet with tabs, A1 headers and inline cell editing (`PUT /api/file/{vault}/xlsx/save`, automatic backup), plus download of the original file - **🎨 Syntax Highlight** : Syntax highlighting for code blocks - **🌓 Light/Dark Theme** : Toggle persisted in localStorage - **📡 Real-time Sync** : Automatic file monitoring via watchdog with incremental index updates @@ -1150,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.25.1). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.27.0). --- -*Project: ObsiGate | Version: 2.25.1 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.27.0 | Last updated: September 2026* diff --git a/VERSION b/VERSION index d6af7e3..a5f3e61 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.25.1 +2.27.0 diff --git a/backend/indexer.py b/backend/indexer.py index c023489..b9df90c 100644 --- a/backend/indexer.py +++ b/backend/indexer.py @@ -65,6 +65,7 @@ SUPPORTED_EXTENSIONS = { ".sh", ".bash", ".zsh", ".fish", ".bat", ".cmd", ".ps1", ".json", ".yaml", ".yml", ".toml", ".xml", ".csv", ".cfg", ".ini", ".conf", ".env", ".pdf", + ".xlsx", ".html", ".css", ".scss", ".less", ".java", ".c", ".cpp", ".h", ".hpp", ".cs", ".go", ".rs", ".rb", ".php", ".sql", ".r", ".m", ".swift", ".kt", @@ -559,6 +560,12 @@ def _scan_vault( raw = "" title = fpath.stem.replace("-", " ").replace("_", " ") content_preview = "" + elif ext == ".xlsx": + # #152 — binary workbook: metadata only, the viewer renders + # it (parity with _index_single_file_sync). + raw = "" + title = fpath.stem.replace("-", " ").replace("_", " ") + content_preview = "" else: raw = fpath.read_text(encoding="utf-8", errors="replace") title = fpath.stem.replace("-", " ").replace("_", " ") @@ -947,6 +954,10 @@ def _index_single_file_sync(vault_name: str, vault_path: str, file_path: str, va # #108 — binary media: metadata only, never read the bytes. raw = "" content_preview = "" + elif ext == ".xlsx": + # #152 — binary workbook: metadata only (parity with _scan_vault). + raw = "" + content_preview = "" else: raw = fpath.read_text(encoding="utf-8", errors="replace") content_preview = raw[:200].strip() diff --git a/backend/main.py b/backend/main.py index aeb8b44..a40dc06 100644 --- a/backend/main.py +++ b/backend/main.py @@ -125,6 +125,9 @@ from backend.services.mutations import ( from backend.services.mutations import ( edit_file as service_edit_file, ) +from backend.services.mutations import ( + edit_xlsx_cells as service_edit_xlsx_cells, +) from backend.services.mutations import ( move_path as service_move_path, ) @@ -211,6 +214,10 @@ class FileContentResponse(BaseModel): stream_url: str | None = Field(default=None, description="Byte-range streaming URL under /api/media (audio/video)") media_mime: str | None = Field(default=None, description="MIME type for audio/video files") is_csv: bool | None = Field(default=None, description="True for CSV files") + is_xlsx: bool | None = Field(default=None, description="True for Excel .xlsx files") + xlsx_sheets: list[dict[str, Any]] | None = Field( + default=None, description="Rendered xlsx sheets [{name, html}]" + ) is_json: bool | None = Field(default=None, description="True for JSON files") is_excalidraw: bool | None = Field(default=None, description="True for Excalidraw diagram files") excalidraw_data: dict[str, Any] | None = Field(default=None, description="Excalidraw diagram data (elements, appState, files)") @@ -1843,6 +1850,41 @@ async def api_file_save( return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]} +@app.put("/api/file/{vault_name}/xlsx/save", response_model=FileSaveResponse) +async def api_file_xlsx_save( + vault_name: str, + path: str = Query(..., description="Relative path to the .xlsx file"), + body: dict = Body(..., description='{"sheet": str, "cells": {"A1": value}}'), + current_user=Depends(require_auth), +): + """Apply cell edits to an .xlsx workbook. + + Expects a JSON body with ``sheet`` and ``cells`` (A1 references to new + scalar values, max 500 per request). A backup is created before the + workbook is rewritten. + """ + if not check_vault_access(vault_name, current_user): + raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") + + sheet = body.get("sheet") + cells = body.get("cells") + if not isinstance(sheet, str) or not sheet: + raise HTTPException(status_code=400, detail="Feuille manquante") + if not isinstance(cells, dict) or not cells or len(cells) > 500: + raise HTTPException(status_code=400, detail="Cellules invalides (1 à 500 par requête)") + for ref, value in cells.items(): + if not isinstance(ref, str) or not isinstance(value, (str, int, float, bool, type(None))): + raise HTTPException(status_code=400, detail=f"Cellule invalide: {ref!r}") + + result = service_edit_xlsx_cells(vault_name, path, sheet, cells) + log_file_save( + current_user["username"], vault_name, path, + sum(len(str(v)) for v in cells.values()), + current_user.get("_request_ip", "unknown"), + ) + return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]} + + @app.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. @@ -2439,6 +2481,32 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative logger.error(f"PDF read error for {path}: {e}") raise HTTPException(status_code=500, detail=f"Error reading PDF: {e!s}") + # === Excel .xlsx: render sheets as HTML tables (binary, before read_text) === + if ext == ".xlsx": + try: + from backend.xlsx_reader import render_sheets + + sheets = render_sheets(file_path) + size = file_path.stat().st_size + return { + "vault": vault_name, + "path": path, + "title": file_path.name, + "tags": [], + "frontmatter": {}, + "html": sheets[0]["html"] if sheets else "", + "raw_length": size, + "extension": ext, + "is_markdown": False, + "is_xlsx": True, + "xlsx_sheets": sheets, + "unsupported": False, + "size_bytes": size, + } + except Exception as e: + logger.error(f"XLSX read error for {path}: {e}") + raise HTTPException(status_code=500, detail=f"Error reading XLSX: {e!s}") + # === Images: return as viewable image === if is_image(ext): size = file_path.stat().st_size diff --git a/backend/openapi_docs.py b/backend/openapi_docs.py index a7f2091..bedcdaa 100644 --- a/backend/openapi_docs.py +++ b/backend/openapi_docs.py @@ -181,6 +181,10 @@ _ENDPOINT_EXAMPLES: dict[tuple[str, str], dict[str, Any]] = { "request": {"path": "notes/Accueil.md", "content": "# Accueil\n\nMis à jour."}, "response": {"status": "ok", "vault": "TestVault", "path": "notes/Accueil.md", "size": 26}, }, + ("put", "/api/file/{vault_name}/xlsx/save"): { + "request": {"sheet": "Budget", "cells": {"B1": "250"}}, + "response": {"status": "ok", "vault": "TestVault", "path": "data/budget.xlsx", "size": 1}, + }, ("post", "/api/search/replace"): { "request": {"query": "Python", "replacement": "Python 3", "vault": "all", "dry_run": True}, "response": {"matches": [{"vault": "TestVault", "path": "note1.md", "title": "Python", "match_count": 3}], "total_matches": 3, "dry_run": True}, diff --git a/backend/services/mutations.py b/backend/services/mutations.py index f9dba68..ff3420f 100644 --- a/backend/services/mutations.py +++ b/backend/services/mutations.py @@ -14,6 +14,7 @@ from __future__ import annotations import logging import os +import re import shutil from collections.abc import Callable from pathlib import Path @@ -223,6 +224,102 @@ def edit_file( return {"success": True, "vault": vault_name, "path": rel_path, "size": len(content)} +# Cell reference like "A1" / "AB42" (Excel A1 notation, up to 3 letters / 8 digits). +_XLSX_CELL_RE = re.compile(r"^[A-Z]{1,3}[1-9][0-9]{0,7}$") +# ponytail: bare int/float coercion mirrors what Excel does when you type a +# number; dates/booleans stay text (upgrade path: parse locale dates too). +_XLSX_INT_RE = re.compile(r"^[+-]?\d+$") +_XLSX_FLOAT_RE = re.compile(r"^[+-]?(?:\d+\.\d*|\.\d+)$") + + +def _coerce_xlsx_value(value: Any) -> Any: + """Turn the string sent by the cell editor back into a scalar.""" + if not isinstance(value, str): + return value + text = value.strip() + if text == "": + return None + if _XLSX_INT_RE.match(text): + return int(text) + if _XLSX_FLOAT_RE.match(text): + return float(text) + return value + + +def edit_xlsx_cells( + vault_name: str, + path: str, + sheet: str, + cells: dict[str, Any], + *, + backup: bool = True, +) -> dict[str, Any]: + """Apply a batch of cell edits to an ``.xlsx`` workbook. + + Raises: + ServiceError: ``not_found`` (404), ``read_only`` (403) or + ``invalid`` (400) for a bad sheet, cell reference or value. + + ponytail: openpyxl round-trips values/formulas/styles but drops charts, + images and pivot tables; use the SheetJS path if a workbook needs those. + """ + 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 file_path.suffix.lower() != ".xlsx": + raise ServiceError( + f"Not an .xlsx file: {path}", code="invalid", status=400 + ) + if not cells: + raise ServiceError("No cells to update", code="invalid", status=400) + for ref in cells: + if not isinstance(ref, str) or not _XLSX_CELL_RE.match(ref): + raise ServiceError( + f"Invalid cell reference: {ref!r}", code="invalid", status=400 + ) + + from openpyxl import load_workbook + + try: + wb = load_workbook(file_path) + except Exception as exc: + raise ServiceError( + f"Cannot open workbook: {exc}", code="invalid", status=400 + ) from exc + if sheet not in wb.sheetnames: + raise ServiceError( + f"Unknown sheet: {sheet}", + code="invalid", + status=400, + details={"sheets": wb.sheetnames}, + ) + + rel_path = _rel(root, file_path) + if backup: + create_backup(file_path, vault_name, rel_path) + + ws = wb[sheet] + for ref, value in cells.items(): + ws[ref].value = _coerce_xlsx_value(value) + wb.save(file_path) + + logger.info(f"XLSX cells saved: {vault_name}/{rel_path} [{sheet}] +{len(cells)}") + return { + "success": True, + "vault": vault_name, + "path": rel_path, + "size": len(cells), + } + + def append_to_file( vault_name: str, path: str, diff --git a/backend/xlsx_reader.py b/backend/xlsx_reader.py new file mode 100644 index 0000000..250f1c7 --- /dev/null +++ b/backend/xlsx_reader.py @@ -0,0 +1,86 @@ +"""Render ``.xlsx`` workbooks as HTML tables for the viewer (#xlsx). + +Read-only: formulas are shown as their text (``data_only=False``) so a +round-trip through the viewer never depends on Excel's cached values. +Write-side lives in ``backend.services.mutations.edit_xlsx_cells``. +""" + +from __future__ import annotations + +import html +from datetime import date, datetime +from pathlib import Path +from typing import Any + +from openpyxl import load_workbook +from openpyxl.utils import get_column_letter + +# ponytail: hard caps bound the rendered grid (500 rows x 40 cols per sheet). +# Raise them, or paginate per sheet, if a real workbook needs more. +MAX_ROWS = 500 +MAX_COLS = 40 + + +def _fmt(value: Any) -> str: + if value is None: + return "" + if isinstance(value, datetime): + return value.strftime("%Y-%m-%d %H:%M") + if isinstance(value, date): + return value.isoformat() + return str(value) + + +def _trim(grid: list[list[str]]) -> list[list[str]]: + """Drop trailing empty rows and columns (openpyxl pads to max_col).""" + while grid and not any(grid[-1]): + grid.pop() + if not grid: + return grid + width = 0 + for row in grid: + for i in range(len(row) - 1, -1, -1): + if row[i]: + width = max(width, i + 1) + break + return [row[:width] for row in grid] + + +def _table(grid: list[list[str]]) -> str: + if not grid: + return "

Feuille vide

" + n_cols = max(len(row) for row in grid) + out = [ + ( + '
' + '' + ) + ] + out += [f"" for c in range(1, n_cols + 1)] + out.append("") + for r, row in enumerate(grid, start=1): + out.append(f'') + for c, val in enumerate(row, start=1): + ref = f"{get_column_letter(c)}{r}" + out.append(f'') + out.append("") + out.append("
{get_column_letter(c)}
{r}{html.escape(val)}
") + return "".join(out) + + +def render_sheets(file_path: Path) -> list[dict[str, str]]: + """Return ``[{"name": sheet_title, "html": table_html}, ...]``.""" + wb = load_workbook(str(file_path), read_only=True, data_only=False) + try: + sheets = [] + for ws in wb.worksheets: + grid = [ + [_fmt(v) for v in row] + for row in ws.iter_rows( + min_row=1, max_row=MAX_ROWS, max_col=MAX_COLS, values_only=True + ) + ] + sheets.append({"name": ws.title, "html": _table(_trim(grid))}) + return sheets + finally: + wb.close() diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 1079154..4a53627 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.25.1" +version = "2.27.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 0b1aca7..99456f0 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.25.1" +version = "2.27.0" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index f45a760..f2879e3 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.25.1", + "version": "2.27.0", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 78d2cdf..968d061 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.25.1 | **Dernière mise à jour :** 2026-09-24 +> **Version :** 2.27.0 | **Dernière mise à jour :** 2026-09-25 > **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)** @@ -126,6 +126,7 @@ | # | Domaine / fonctionnalité | Version | Détails | |---|---|---|---| +| 152 | Viewer XLSX — affichage multi-feuilles, édition des cellules, téléchargement | 2.26.0 | [archive](./archive/COMPLETED_v1-v2.md) | | BUG-047 | Versionnage — source unique `VERSION` + bump SemVer automatique au commit (hooks + tag) | 2.3.0 | [DEVELOPMENT_AND_RELEASES.md](./DEVELOPMENT_AND_RELEASES.md) | | 90 | Barre d'actions du document — regroupement fonctionnel + spacers | 2.3.0 | [archive](./archive/COMPLETED_v1-v2.md) | | 89 | Drag & drop complet de fichiers/dossiers & intégration Assistant IA | 2.3.0 | [features/drag-and-drop-ai.md](./features/drag-and-drop-ai.md) | diff --git a/docs/archive/COMPLETED_v1-v2.md b/docs/archive/COMPLETED_v1-v2.md index 9626121..efa7531 100644 --- a/docs/archive/COMPLETED_v1-v2.md +++ b/docs/archive/COMPLETED_v1-v2.md @@ -404,6 +404,21 @@ Deux compléments au bouton « Ajouter » de l'assistant IA. --- +## #152 — Viewer XLSX : affichage, édition, téléchargement ✅ TERMINÉ + +Les fichiers `.xlsx` s'ouvrent dans un dédié : un tableau HTML par feuille (onglets en cas de +multi-feuilles, en-têtes A1, cellules `contenteditable`), bouton **Enregistrer** actif dès la +première modification et téléchargement du fichier d'origine. + +| Aspect | Détail | +|---|---| +| Lecture | `backend/xlsx_reader.py` — openpyxl `read_only`, formules affichées comme texte, plafond 500×40 cellules par feuille | +| Écriture | `PUT /api/file/{vault}/xlsx/save` → `services/mutations.edit_xlsx_cells` (backup avant écriture, refs A1 validées, `str`→`int`/`float`, 500 cellules max par requête) | +| Frontend | `renderXlsxViewer` dans `frontend/js/viewer.js` (onglets, cellules sales, Entrée/Échap, collage monoligne) | +| Limite connue | Le round-trip openpyxl conserve valeurs/formules/styles mais perd graphiques, images et tableaux croisés | + +--- + ## Grosses fonctionnalités — fiches dédiées | # | Feature | Version | Fiche | diff --git a/frontend/js/viewer.js b/frontend/js/viewer.js index 4368dce..dc421e1 100644 --- a/frontend/js/viewer.js +++ b/frontend/js/viewer.js @@ -995,6 +995,110 @@ 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) { + const sheets = data.xlsx_sheets || []; + const tabs = sheets.length > 1 + ? `
${sheets.map((s, i) => + `` + ).join("")}
` + : ""; + const panels = sheets.map((s, i) => + `` + ).join(""); + + area.innerHTML = ` +
+
+ ${tabs} + + + + +
+
${panels}
+
`; + + const saveBtn = area.querySelector("#xlsx-save-btn"); + const panelEls = [...area.querySelectorAll(".xlsx-panel")]; + const dirtyCount = () => area.querySelectorAll("td.xlsx-dirty").length; + const refreshSaveState = () => { saveBtn.disabled = dirtyCount() === 0; }; + + // Editable cells: Enter blurs, Escape reverts, paste stays single-line. + area.querySelectorAll(".xlsx-table td").forEach((td) => { + td.contentEditable = "true"; + td.spellcheck = false; + td.dataset.orig = td.textContent; + td.addEventListener("input", () => { + td.classList.add("xlsx-dirty"); + refreshSaveState(); + }); + td.addEventListener("keydown", (e) => { + if (e.key === "Enter") { e.preventDefault(); td.blur(); } + if (e.key === "Escape") { + td.textContent = td.dataset.orig; + td.classList.remove("xlsx-dirty"); + refreshSaveState(); + } + }); + td.addEventListener("paste", (e) => { + e.preventDefault(); + const text = (e.clipboardData || window.clipboardData).getData("text").replace(/\r?\n/g, " "); + document.execCommand("insertText", false, text); + }); + }); + + area.querySelectorAll(".xlsx-tab").forEach((tab) => { + tab.addEventListener("click", () => { + const idx = tab.dataset.sheet; + area.querySelectorAll(".xlsx-tab").forEach((x) => x.classList.toggle("active", x === tab)); + panelEls.forEach((p) => { p.style.display = p.dataset.sheet === idx ? "" : "none"; }); + }); + }); + + saveBtn.addEventListener("click", async () => { + // One PUT per sheet (dirty cells can span tabs before a save). + const jobs = panelEls + .map((panel) => { + const cells = {}; + panel.querySelectorAll("td.xlsx-dirty").forEach((td) => { cells[td.dataset.cell] = td.textContent; }); + return { sheet: sheets[Number(panel.dataset.sheet)].name, cells }; + }) + .filter((job) => Object.keys(job.cells).length); + if (!jobs.length) return; + 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), + }); + } + area.querySelectorAll("td.xlsx-dirty").forEach((td) => { + td.classList.remove("xlsx-dirty"); + td.dataset.orig = td.textContent; + }); + refreshSaveState(); + showToast(t("editor.saved"), "success"); + } catch (err) { + refreshSaveState(); + showToast(`${t("editor.save_error")}: ${err.message || err}`, "error"); + } + }); + + area.querySelector("#xlsx-download-btn").addEventListener("click", () => { + window.open(`/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`, "_blank"); + }); + + safeCreateIcons(); +} + export function renderFile(data) { // #93 — An inline edition session (#editor-container mounted in the content // area) is destroyed by this very re-render: release it first so the editor @@ -1058,6 +1162,12 @@ export function renderFile(data) { return; } + // Handle Excel .xlsx — editable table view (display / edit / download) + if (data.is_xlsx) { + renderXlsxViewer(area, data); + return; + } + // Handle Excalidraw — render in iframe editor if (data.is_excalidraw) { renderExcalidraw(area, data, data.vault, data.path); diff --git a/frontend/style.css b/frontend/style.css index b16eebe..59cf57c 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -10924,6 +10924,69 @@ body.desktop-mode .editor-container { background: var(--surface); } +/* ── XLSX Viewer ── */ +.xlsx-toolbar { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 8px; + flex-wrap: wrap; +} +.xlsx-toolbar-actions { + margin-left: auto; + display: flex; + gap: 8px; +} +.xlsx-tabs { + display: flex; + gap: 4px; + flex-wrap: wrap; +} +.xlsx-tab { + border: 1px solid var(--border); + background: var(--surface); + color: var(--text-secondary); + border-radius: 4px; + padding: 4px 10px; + font-size: 0.8rem; + cursor: pointer; +} +.xlsx-tab.active { + background: var(--accent, #4a90d9); + border-color: var(--accent, #4a90d9); + color: #fff; +} +.xlsx-table th.xlsx-corner, +.xlsx-table th.xlsx-rownum { + background: var(--surface); + color: var(--text-secondary); + font-weight: 400; + text-align: right; + padding: 6px 8px; + border-bottom: 2px solid var(--border); + border-right: 1px solid var(--border-light, var(--border)); + position: sticky; + left: 0; + z-index: 1; +} +.xlsx-table th.xlsx-corner { + left: 0; + top: 0; + z-index: 2; +} +.xlsx-table td[contenteditable] { + cursor: text; + min-width: 40px; + white-space: pre-wrap; +} +.xlsx-table td[contenteditable]:focus { + outline: 2px solid var(--accent, #4a90d9); + outline-offset: -2px; +} +.xlsx-table td.xlsx-dirty { + background: rgba(255, 196, 0, 0.18); +} + /* ── JSON Viewer ── */ .json-viewer { font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; diff --git a/package.json b/package.json index f6ac573..bf38eb5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.25.1", + "version": "2.27.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": { diff --git a/tests/test_xlsx_viewer.py b/tests/test_xlsx_viewer.py new file mode 100644 index 0000000..d766e97 --- /dev/null +++ b/tests/test_xlsx_viewer.py @@ -0,0 +1,152 @@ +"""Display / edit / download for .xlsx files (viewer + PUT xlsx/save).""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +openpyxl = pytest.importorskip("openpyxl") + +VAULT = "TestVault" + + +@pytest.fixture +def xlsx_file(test_vault_dir: str) -> str: + from openpyxl import Workbook + + path = Path(test_vault_dir) / "budget.xlsx" + wb = Workbook() + ws = wb.active + ws.title = "Budget" + ws["A1"] = "Poste" + ws["B1"] = 100 + ws["A2"] = "Total" + ws["B2"] = "=B1*2" + notes = wb.create_sheet("Notes") + notes["A1"] = "hello" + wb.save(path) + return str(path) + + +# ── Display ─────────────────────────────────────────────────────────────── + + +class TestXlsxDisplay: + def test_renders_all_sheets(self, client, xlsx_file): + resp = client.get(f"/api/file/{VAULT}", params={"path": "budget.xlsx"}) + assert resp.status_code == 200 + data = resp.json() + assert data["is_xlsx"] is True + assert data["unsupported"] is False + assert [s["name"] for s in data["xlsx_sheets"]] == ["Budget", "Notes"] + + first = data["xlsx_sheets"][0]["html"] + assert "Poste" in first + assert 'data-cell="B1"' in first + assert "=B1*2" in first # formula kept as text (data_only=False) + assert 'data-cell="A1"' in data["xlsx_sheets"][1]["html"] + + def test_corrupt_xlsx_returns_500(self, client, test_vault_dir): + bad = Path(test_vault_dir) / "corrupt.xlsx" + bad.write_bytes(b"this is not a zip archive") + resp = client.get(f"/api/file/{VAULT}", params={"path": "corrupt.xlsx"}) + assert resp.status_code == 500 + + +# ── Index parity (tree visibility) ──────────────────────────────────────── + + +class TestXlsxIndexing: + def test_xlsx_in_supported_extensions(self): + from backend.indexer import SUPPORTED_EXTENSIONS + + assert ".xlsx" in SUPPORTED_EXTENSIONS + + def test_xlsx_indexed_metadata_only(self, test_vault_dir, xlsx_file): + from backend.indexer import _index_single_file_sync + + info = _index_single_file_sync(VAULT, test_vault_dir, xlsx_file) + assert info is not None + assert info["extension"] == ".xlsx" + assert info["content"] == "" # binary: never read into TF-IDF + assert info["title"] # filename-derived title + + +# ── Edit ────────────────────────────────────────────────────────────────── + + +class TestXlsxSave: + def _save(self, client, body, path="budget.xlsx"): + return client.put( + f"/api/file/{VAULT}/xlsx/save", + params={"path": path}, + json=body, + ) + + def test_save_updates_cell_with_number_coercion(self, client, xlsx_file): + resp = self._save(client, {"sheet": "Budget", "cells": {"B1": "250"}}) + assert resp.status_code == 200 + assert resp.json()["status"] == "ok" + + from openpyxl import load_workbook + + wb = load_workbook(xlsx_file) + assert wb["Budget"]["B1"].value == 250 # int, not "250" + + def test_save_leaves_other_sheets_and_formulas(self, client, xlsx_file): + resp = self._save(client, {"sheet": "Budget", "cells": {"A1": "Titre", "B1": 42}}) + assert resp.status_code == 200 + + from openpyxl import load_workbook + + wb = load_workbook(xlsx_file) + assert wb["Budget"]["B2"].value == "=B1*2" + assert wb["Notes"]["A1"].value == "hello" + + def test_save_empty_string_clears_cell(self, client, xlsx_file): + resp = self._save(client, {"sheet": "Budget", "cells": {"A1": ""}}) + assert resp.status_code == 200 + + from openpyxl import load_workbook + + assert load_workbook(xlsx_file)["Budget"]["A1"].value is None + + def test_save_creates_backup(self, client, xlsx_file): + from backend.services.backups import get_backup_dir + + resp = self._save(client, {"sheet": "Budget", "cells": {"A1": "backup-me"}}) + assert resp.status_code == 200 + backup_dir = Path(get_backup_dir(VAULT, "budget.xlsx")) + assert backup_dir.is_dir() + assert list(backup_dir.glob("*.bak")) + + def test_unknown_sheet_400(self, client, xlsx_file): + resp = self._save(client, {"sheet": "Nope", "cells": {"A1": "x"}}) + assert resp.status_code == 400 + + def test_invalid_cell_ref_400(self, client, xlsx_file): + resp = self._save(client, {"sheet": "Budget", "cells": {"A-1": "x"}}) + assert resp.status_code == 400 + + def test_wrong_extension_400(self, client, test_vault_dir): + (Path(test_vault_dir) / "note.md").write_text("# hi\n", encoding="utf-8") + resp = self._save(client, {"sheet": "Sheet", "cells": {"A1": "x"}}, path="note.md") + assert resp.status_code == 400 + + def test_missing_file_404(self, client): + resp = self._save(client, {"sheet": "Budget", "cells": {"A1": "x"}}, path="absent.xlsx") + assert resp.status_code == 404 + + def test_too_many_cells_400(self, client, xlsx_file): + cells = {f"A{i}": i for i in range(1, 502)} + resp = self._save(client, {"sheet": "Budget", "cells": cells}) + assert resp.status_code == 400 + + def test_nested_value_400(self, client, xlsx_file): + resp = self._save(client, {"sheet": "Budget", "cells": {"A1": {"nested": 1}}}) + assert resp.status_code == 400 + + def test_missing_sheet_field_400(self, client, xlsx_file): + resp = self._save(client, {"cells": {"A1": "x"}}) + assert resp.status_code == 400