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. -[]() +[]() [](https://opensource.org/licenses/MIT) [](https://www.docker.com/) [](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. -[]() +[]() [](https://opensource.org/licenses/MIT) [](https://www.docker.com/) [](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" | {get_column_letter(c)} | " for c in range(1, n_cols + 1)] + out.append("
|---|---|
| {r} | ') + for c, val in enumerate(row, start=1): + ref = f"{get_column_letter(c)}{r}" + out.append(f'{html.escape(val)} | ') + out.append("