diff --git a/CHANGELOG.md b/CHANGELOG.md index 819f5fa..bcb04b6 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.32.0**. +> [Unreleased](#unreleased). La dernière version livrée est **2.33.0**. --- @@ -14,6 +14,10 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.33.0] — 2026-09-28 + +--- + ## [2.32.0] — 2026-09-28 --- diff --git a/README.fr.md b/README.fr.md index 5feaa70..be17edc 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.32.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.33.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/) @@ -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.32.0). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.33.0). --- -*Projet : ObsiGate | Version : 2.32.0 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.33.0 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index 8798cd3..e1f55f4 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.32.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.33.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/) @@ -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.32.0). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.33.0). --- -*Project: ObsiGate | Version: 2.32.0 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.33.0 | Last updated: September 2026* diff --git a/VERSION b/VERSION index 7cca401..3afbaeb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.32.0 +2.33.0 diff --git a/backend/tools/api.py b/backend/tools/api.py index 0471394..05c66ef 100644 --- a/backend/tools/api.py +++ b/backend/tools/api.py @@ -13,6 +13,7 @@ from backend.tools import connected as _connected # noqa: F401 (registers conn from backend.tools import crawler as _crawler # noqa: F401 (registers the site crawler) from backend.tools import documents as _documents # noqa: F401 (registers document tools) from backend.tools import service as _service # noqa: F401 (registers tools) +from backend.tools import spreadsheets as _spreadsheets # noqa: F401 (registers existing-workbook tools #153 A6) from backend.tools import web as _web # noqa: F401 (registers web tools) from backend.tools.context import ( ToolConfirmationRequired, diff --git a/backend/tools/labels.py b/backend/tools/labels.py index 1985cb8..fc22f27 100644 --- a/backend/tools/labels.py +++ b/backend/tools/labels.py @@ -52,6 +52,10 @@ _STEP_LABELS: dict[str, tuple[str, str | None]] = { "git_search_issues": ("git_issues", "query"), "git_get_file": ("git_file", "path"), "create_xlsx": ("xlsx_create", "path"), + "list_xlsx_sheets": ("xlsx_sheets", "path"), + "xlsx_to_markdown": ("xlsx_read", "path"), + "update_xlsx_cells": ("xlsx_update", "path"), + "append_xlsx_rows": ("xlsx_append", "path"), "create_docx": ("docx_create", "path"), "create_csv": ("csv_create", "path"), "create_pdf": ("pdf_create", "path"), diff --git a/backend/tools/schemas.py b/backend/tools/schemas.py index 7a0da66..a9a635c 100644 --- a/backend/tools/schemas.py +++ b/backend/tools/schemas.py @@ -315,6 +315,61 @@ class DocxInput(BaseModel): overwrite: bool = Field(True, description="Replace an existing file (with backup)") +class ListXlsxSheetsInput(BaseModel): + """List the sheets of an existing .xlsx workbook (#153 A6).""" + + vault: str = Field(..., description="Vault name") + path: str = Field(..., description="Vault-relative path of the .xlsx file") + + +class XlsxToMarkdownInput(BaseModel): + """Read one sheet of an existing .xlsx workbook as markdown (#153 A6).""" + + vault: str = Field(..., description="Vault name") + path: str = Field(..., description="Vault-relative path of the .xlsx file") + sheet: str = Field( + "", description="Sheet name (empty = the first/active sheet)" + ) + + +class UpdateXlsxCellsInput(BaseModel): + """Batch-edit cells of an existing .xlsx workbook (#153 A6).""" + + vault: str = Field(..., description="Vault name") + path: str = Field(..., description="Vault-relative path of the .xlsx file") + sheet: str = Field(..., description="Worksheet title to edit") + cells: dict[str, str | int | float | bool | None] = Field( + ..., description="A1 reference -> new value (max 500 per call)" + ) + allow_formula: bool = Field( + False, + description="Store '='/'@' values as real formulas (off by default, DDE guard)", + ) + force: bool = Field( + False, + description="Write even when features openpyxl cannot rewrite would be dropped", + ) + + +class AppendXlsxRowsInput(BaseModel): + """Append rows at the end of a sheet of an existing .xlsx (#153 A6).""" + + vault: str = Field(..., description="Vault name") + path: str = Field(..., description="Vault-relative path of the .xlsx file") + sheet: str = Field(..., description="Worksheet title to extend") + rows: list[list[str | int | float | bool | None]] = Field( + ..., description="Rows of cell values, appended below the last used row (max 500)" + ) + allow_formula: bool = Field( + False, + description="Store '='/'@' values as real formulas (off by default, DDE guard)", + ) + force: bool = Field( + False, + description="Write even when features openpyxl cannot rewrite would be dropped", + ) + + class CsvInput(BaseModel): """Create a .csv file in a vault from rows of cells.""" diff --git a/backend/tools/spreadsheets.py b/backend/tools/spreadsheets.py new file mode 100644 index 0000000..f24db42 --- /dev/null +++ b/backend/tools/spreadsheets.py @@ -0,0 +1,286 @@ +"""Spreadsheet tools (#153 A6) — read and mutate existing ``.xlsx`` workbooks. + +Complements :mod:`backend.tools.documents` (``create_xlsx`` creates a *new* +file; here the assistant can read and edit one that already exists): + +* ``list_xlsx_sheets`` — READ, sheet names + dimensions; +* ``xlsx_to_markdown`` — READ, bounded markdown table for the LLM context; +* ``update_xlsx_cells`` — WRITE, batch cell edits (wraps the guarded service); +* ``append_xlsx_rows`` — WRITE, append whole rows at the end of a sheet. + +Mutation tools go through :func:`backend.services.mutations.edit_xlsx_cells`, +which already carries the #153 P0 guards: per-file lock, atomic replace, +formula neutralisation (``allow_formula`` opt-in) and the lossy-write 409. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any + +from backend.services.errors import ServiceError +from backend.services.paths import resolve_safe_path +from backend.services.vaults import get_vault_root +from backend.tools.context import ToolContext, ToolError, ToolRisk +from backend.tools.registry import tool +from backend.tools.schemas import ( + AppendXlsxRowsInput, + ListXlsxSheetsInput, + UpdateXlsxCellsInput, + XlsxToMarkdownInput, +) + +logger = logging.getLogger("obsigate.tools.spreadsheets") + +# xlsx_to_markdown ceiling: a workbook is a data dump, not prose. The table is +# for the LLM context, so both axes are bounded (same spirit as A5's index cap). +MAX_MD_ROWS = 100 +MAX_MD_COLS = 20 +MAX_MD_CHARS = 20_000 + + +def _workbook_path(vault: str, path: str) -> Path: + """Resolve and validate a vault-relative ``.xlsx`` path.""" + path = (path or "").strip() + if not path.lower().endswith(".xlsx"): + raise ToolError("Extension attendue : .xlsx", code="invalid_arguments") + try: + root = get_vault_root(vault) + except ServiceError as e: + raise ToolError(e.message, code=e.code, details=e.details) from e + return resolve_safe_path(root, path) + + +def _map_service_error(e: ServiceError) -> ToolError: + return ToolError(e.message, code=e.code, details=e.details) + + +@tool( + name="list_xlsx_sheets", + description=( + "List the sheets of an .xlsx workbook with their dimensions " + "(rows x columns) and whether the display caps truncate them. " + "Use before editing to pick the right sheet name." + ), + input_model=ListXlsxSheetsInput, + risk=ToolRisk.READ, + requires_vault=True, +) +def list_xlsx_sheets(ctx: ToolContext, params: ListXlsxSheetsInput) -> dict[str, Any]: + """Return sheet names and extents of the workbook.""" + from backend.xlsx_reader import MAX_COLS, MAX_ROWS, _sheet_extent + + file_path = _workbook_path(params.vault, params.path) + try: + from openpyxl import load_workbook + + wb = load_workbook(str(file_path), read_only=True, data_only=True) + except ServiceError as e: + raise _map_service_error(e) from e + except Exception as e: + raise ToolError(f"Classeur illisible: {e}", code="invalid") from e + try: + sheets = [] + for ws in wb.worksheets: + total_rows, total_cols = _sheet_extent(ws) + sheets.append( + { + "name": ws.title, + "total_rows": total_rows, + "total_cols": total_cols, + "truncated": total_rows > MAX_ROWS or total_cols > MAX_COLS, + } + ) + return {"vault": params.vault, "path": params.path, "sheets": sheets} + finally: + wb.close() + + +@tool( + name="xlsx_to_markdown", + description=( + "Read a sheet of an .xlsx workbook as a bounded markdown table " + "(up to 100 rows x 20 columns). Use to inspect spreadsheet data " + "before answering or editing." + ), + input_model=XlsxToMarkdownInput, + risk=ToolRisk.READ, + requires_vault=True, +) +def xlsx_to_markdown(ctx: ToolContext, params: XlsxToMarkdownInput) -> dict[str, Any]: + """Render one sheet as a markdown table for the LLM context.""" + from openpyxl import load_workbook + + from backend.xlsx_reader import _fmt + + file_path = _workbook_path(params.vault, params.path) + try: + wb = load_workbook(str(file_path), read_only=True, data_only=True) + except ServiceError as e: + raise _map_service_error(e) from e + except Exception as e: + raise ToolError(f"Classeur illisible: {e}", code="invalid") from e + try: + if params.sheet: + if params.sheet not in wb.sheetnames: + raise ToolError( + f"Feuille introuvable: {params.sheet}", code="not_found" + ) + ws = wb[params.sheet] + else: + ws = wb.active + title = ws.title + rows: list[list[str]] = [] + truncated = False + for row in ws.iter_rows( + min_row=1, max_row=MAX_MD_ROWS, max_col=MAX_MD_COLS, values_only=True + ): + cells = [_fmt(v) for v in row] + if not any(c.strip() for c in cells): + continue + rows.append(cells) + # Real tail beyond the caps? Probe one row further. + probe = list( + ws.iter_rows( + min_row=MAX_MD_ROWS + 1, + max_row=MAX_MD_ROWS + 1, + max_col=MAX_MD_COLS, + values_only=True, + ) + ) + if any(any(str(v or "").strip() for v in r) for r in probe): + truncated = True + finally: + wb.close() + + lines: list[str] = [] + if rows: + header = rows[0] + lines.append("| " + " | ".join(header) + " |") + lines.append("|" + "|".join("---" for _ in header) + "|") + for row in rows[1:]: + lines.append("| " + " | ".join(row) + " |") + table = "\n".join(lines)[:MAX_MD_CHARS] + + return { + "vault": params.vault, + "path": params.path, + "sheet": title, + "rows": len(rows), + "cols": max((len(r) for r in rows), default=0), + "truncated": truncated, + "markdown": table, + } + + +@tool( + name="update_xlsx_cells", + description=( + "Edit cells of an existing .xlsx workbook. ``cells`` maps A1 " + "references to new values (max 500). A value starting with '=' or " + "'@' is stored as TEXT unless allow_formula is set (DDE guard). " + "Editing a workbook carrying features openpyxl cannot rewrite " + "requires force=true (cached formula results, slicers…)." + ), + input_model=UpdateXlsxCellsInput, + risk=ToolRisk.WRITE, + requires_vault=True, +) +def update_xlsx_cells(ctx: ToolContext, params: UpdateXlsxCellsInput) -> dict[str, Any]: + """Wrap the guarded cell-edit service.""" + from backend.services.mutations import edit_xlsx_cells + + if not params.cells: + raise ToolError("Aucune cellule fournie", code="invalid_arguments") + try: + result = edit_xlsx_cells( + params.vault, + params.path, + params.sheet, + dict(params.cells), + allow_formula=params.allow_formula, + force=params.force, + ) + except ServiceError as e: + raise _map_service_error(e) from e + return { + "status": "ok", + "vault": result["vault"], + "path": result["path"], + "sheet": params.sheet, + "cells": len(params.cells), + } + + +@tool( + name="append_xlsx_rows", + description=( + "Append rows at the end of a sheet of an existing .xlsx workbook. " + "Values are typed like in the viewer (numbers, TRUE/FALSE, FR dates " + "JJ/MM/AAAA). The workbook is rewritten atomically with a backup." + ), + input_model=AppendXlsxRowsInput, + risk=ToolRisk.WRITE, + requires_vault=True, +) +def append_xlsx_rows(ctx: ToolContext, params: AppendXlsxRowsInput) -> dict[str, Any]: + """Append whole rows below the last used row of the sheet.""" + from openpyxl import load_workbook + from openpyxl.utils import get_column_letter + + from backend.services.mutations import _coerce_xlsx_value, edit_xlsx_cells + + if not params.rows: + raise ToolError("Aucune ligne fournie", code="invalid_arguments") + if len(params.rows) > 500: + raise ToolError("Trop de lignes (max 500)", code="invalid_arguments") + + file_path = _workbook_path(params.vault, params.path) + try: + wb = load_workbook(str(file_path), read_only=True, data_only=True) + try: + if params.sheet not in wb.sheetnames: + raise ToolError( + f"Feuille introuvable: {params.sheet}", code="not_found" + ) + ws = wb[params.sheet] + first_free = (ws.max_row or 0) + 1 + finally: + wb.close() + except ServiceError as e: + raise _map_service_error(e) from e + except ToolError: + raise + except Exception as e: + raise ToolError(f"Classeur illisible: {e}", code="invalid") from e + + cells: dict[str, Any] = {} + for i, row in enumerate(params.rows): + for j, value in enumerate(row): + if value is None or (isinstance(value, str) and not value.strip()): + continue + ref = f"{get_column_letter(j + 1)}{first_free + i}" + cells[ref] = _coerce_xlsx_value(value) + if not cells: + raise ToolError("Aucune valeur fournie", code="invalid_arguments") + + try: + result = edit_xlsx_cells( + params.vault, + params.path, + params.sheet, + cells, + allow_formula=params.allow_formula, + force=params.force, + ) + except ServiceError as e: + raise _map_service_error(e) from e + return { + "status": "ok", + "vault": result["vault"], + "path": result["path"], + "sheet": params.sheet, + "rows": len(params.rows), + "first_row": first_free, + } diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 8aede14..6fffc16 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.32.0" +version = "2.33.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 0e15ea7..6a6386e 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.32.0" +version = "2.33.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 2173143..464c6df 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.32.0", + "version": "2.33.0", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 523a9be..6cc3aa3 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.32.0 | **Dernière mise à jour :** 2026-09-28 +> **Version :** 2.33.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)** diff --git a/frontend/js/bookslm.js b/frontend/js/bookslm.js index 9f08e25..bcf3f68 100644 --- a/frontend/js/bookslm.js +++ b/frontend/js/bookslm.js @@ -60,12 +60,14 @@ const MUTATING_TOOLS = new Set([ 'rename_file', 'rename_directory', 'move_path', 'replace_in_files', 'delete_file', 'delete_directory', 'restore_backup', 'create_xlsx', 'create_docx', 'create_csv', 'create_pdf', + 'update_xlsx_cells', 'append_xlsx_rows', ]); // Subset carrying a concrete `vault` + `path`: the displayed document is // reloaded from disk so an open viewer/editor reflects the agent's write. const FILE_WRITE_TOOLS = new Set([ 'edit_file', 'append_to_file', 'create_file', 'restore_backup', 'create_xlsx', 'create_docx', 'create_csv', 'create_pdf', + 'update_xlsx_cells', 'append_xlsx_rows', ]); /** diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 5b159cd..2fcb663 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -2008,6 +2008,10 @@ "ai.step.git_issues": "Searched issues: {value}", "ai.step.git_file": "Read a repo file: {value}", "ai.step.xlsx_create": "Spreadsheet proposed: {value}", + "ai.step.xlsx_sheets": "Workbook sheets listed: {value}", + "ai.step.xlsx_read": "Workbook read: {value}", + "ai.step.xlsx_update": "Cells edited: {value}", + "ai.step.xlsx_append": "Rows appended: {value}", "ai.step.docx_create": "Word document proposed: {value}", "ai.step.csv_create": "CSV file proposed: {value}", "ai.step.pdf_create": "PDF document proposed: {value}", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 561723b..ae8f524 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -2008,6 +2008,10 @@ "ai.step.git_issues": "Issues recherchées : {value}", "ai.step.git_file": "Fichier de dépôt lu : {value}", "ai.step.xlsx_create": "Tableur proposé : {value}", + "ai.step.xlsx_sheets": "Feuilles du classeur listées : {value}", + "ai.step.xlsx_read": "Classeur lu : {value}", + "ai.step.xlsx_update": "Cellules modifiées : {value}", + "ai.step.xlsx_append": "Lignes ajoutées : {value}", "ai.step.docx_create": "Document Word proposé : {value}", "ai.step.csv_create": "Fichier CSV proposé : {value}", "ai.step.pdf_create": "Document PDF proposé : {value}", diff --git a/package.json b/package.json index 99dd440..c6ef765 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.32.0", + "version": "2.33.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_spreadsheet_tools.py b/tests/test_spreadsheet_tools.py new file mode 100644 index 0000000..0fbed68 --- /dev/null +++ b/tests/test_spreadsheet_tools.py @@ -0,0 +1,225 @@ +"""Unit tests for the existing-workbook AI tools (#153 A6). + +Covers ``list_xlsx_sheets``, ``xlsx_to_markdown``, ``update_xlsx_cells`` and +``append_xlsx_rows`` — risk levels, confirmation gating, vault persistence and +the reuse of the guarded mutation service (P0 guards, formula neutralisation). +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from backend.tools.api import ( + ToolConfirmationRequired, + ToolContext, + ToolError, + call_tool, + get_tool, +) +from backend.tools.context import ToolRisk + +openpyxl = pytest.importorskip("openpyxl") + + +@pytest.fixture +def vault(tmp_path, monkeypatch): + """A minimal configured vault (index entry patched, no full build).""" + vault_dir = tmp_path / "Vault" + vault_dir.mkdir() + monkeypatch.setitem( + __import__("backend.indexer", fromlist=["index"]).index, + "Vault", + {"name": "Vault", "path": str(vault_dir), "config": {}}, + ) + return vault_dir + + +@pytest.fixture +def workbook(vault: Path) -> Path: + """A two-sheet workbook: Budget (values + a formula) and Notes.""" + path = vault / "classeur.xlsx" + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "Budget" + ws.append(["Poste", "Montant"]) + ws.append(["Loyer", 900]) + ws.append(["Courses", 250]) + notes = wb.create_sheet("Notes") + notes["A1"] = "bonjour" + wb.save(path) + return path + + +def _ctx() -> ToolContext: + return ToolContext( + user={"username": "tester", "role": "admin", "vaults": ["*"]}, + audit_enabled=False, + ) + + +class TestRegistry: + def test_read_tools_are_read_risk(self): + for name in ("list_xlsx_sheets", "xlsx_to_markdown"): + spec = get_tool(name) + assert spec is not None + assert spec.risk == ToolRisk.READ + + def test_mutation_tools_require_confirmation(self): + for name in ("update_xlsx_cells", "append_xlsx_rows"): + spec = get_tool(name) + assert spec is not None + assert spec.risk == ToolRisk.WRITE + assert spec.requires_confirmation is True + + def test_mutation_tools_raise_without_confirmation(self, vault, workbook): + with pytest.raises(ToolConfirmationRequired): + call_tool("update_xlsx_cells", _ctx(), { + "vault": "Vault", "path": "classeur.xlsx", + "sheet": "Budget", "cells": {"B3": "300"}, + }) + with pytest.raises(ToolConfirmationRequired): + call_tool("append_xlsx_rows", _ctx(), { + "vault": "Vault", "path": "classeur.xlsx", + "sheet": "Budget", "rows": [["Total", 1150]], + }) + + +class TestListXlsxSheets: + def test_lists_names_and_dimensions(self, vault, workbook): + out = call_tool("list_xlsx_sheets", _ctx(), { + "vault": "Vault", "path": "classeur.xlsx", + }) + assert out.ok + data = out.data + assert [s["name"] for s in data["sheets"]] == ["Budget", "Notes"] + budget = data["sheets"][0] + assert budget["total_rows"] == 3 and budget["total_cols"] == 2 + assert budget["truncated"] is False + + def test_wrong_extension_rejected(self, vault): + with pytest.raises(ToolError): + call_tool("list_xlsx_sheets", _ctx(), { + "vault": "Vault", "path": "note.md", + }) + + def test_missing_file_rejected(self, vault): + with pytest.raises(ToolError): + call_tool("list_xlsx_sheets", _ctx(), { + "vault": "Vault", "path": "absent.xlsx", + }) + + +class TestXlsxToMarkdown: + def test_renders_a_bounded_markdown_table(self, vault, workbook): + out = call_tool("xlsx_to_markdown", _ctx(), { + "vault": "Vault", "path": "classeur.xlsx", + }) + assert out.ok + data = out.data + assert data["sheet"] == "Budget" + assert data["rows"] == 3 and data["truncated"] is False + assert "| Poste | Montant |" in data["markdown"] + assert "| Loyer | 900 |" in data["markdown"] + + def test_specific_sheet(self, vault, workbook): + out = call_tool("xlsx_to_markdown", _ctx(), { + "vault": "Vault", "path": "classeur.xlsx", "sheet": "Notes", + }) + assert out.ok + data = out.data + assert data["sheet"] == "Notes" + assert "bonjour" in data["markdown"] + + def test_unknown_sheet_rejected(self, vault, workbook): + with pytest.raises(ToolError): + call_tool("xlsx_to_markdown", _ctx(), { + "vault": "Vault", "path": "classeur.xlsx", "sheet": "Nope", + }) + + def test_big_sheet_is_flagged_truncated(self, vault): + path = vault / "gros.xlsx" + wb = openpyxl.Workbook() + ws = wb.active + for i in range(150): + ws.append([f"r{i}", i]) + wb.save(path) + out = call_tool("xlsx_to_markdown", _ctx(), { + "vault": "Vault", "path": "gros.xlsx", + }) + assert out.ok + data = out.data + assert data["rows"] == 100 + assert data["truncated"] is True + + +class TestUpdateXlsxCells: + def test_edits_cells_and_survives_a_reload(self, vault, workbook): + out = call_tool("update_xlsx_cells", _ctx(), { + "vault": "Vault", "path": "classeur.xlsx", + "sheet": "Budget", "cells": {"B3": "300"}, + }, confirm=True) + assert out.ok and out.data["status"] == "ok" + wb = openpyxl.load_workbook(workbook) + assert wb["Budget"]["B3"].value == 300 # coerced like the viewer + wb.close() + + def test_formula_stays_text_by_default(self, vault, workbook): + out = call_tool("update_xlsx_cells", _ctx(), { + "vault": "Vault", "path": "classeur.xlsx", + "sheet": "Budget", "cells": {"C1": "=B2+B3"}, + }, confirm=True) + assert out.ok + wb = openpyxl.load_workbook(workbook) + assert wb["Budget"]["C1"].data_type == "s" # A4 guard inherited + wb.close() + + def test_empty_cells_rejected(self, vault, workbook): + with pytest.raises(ToolError): + call_tool("update_xlsx_cells", _ctx(), { + "vault": "Vault", "path": "classeur.xlsx", + "sheet": "Budget", "cells": {}, + }) + + +class TestAppendXlsxRows: + def test_appends_below_the_last_row(self, vault, workbook): + out = call_tool("append_xlsx_rows", _ctx(), { + "vault": "Vault", "path": "classeur.xlsx", + "sheet": "Budget", "rows": [["Total", 1150]], + }, confirm=True) + assert out.ok + assert out.data["first_row"] == 4 + wb = openpyxl.load_workbook(workbook) + ws = wb["Budget"] + assert ws["A4"].value == "Total" + assert ws["B4"].value == 1150 and isinstance(ws["B4"].value, int) + wb.close() + + def test_values_are_coerced(self, vault, workbook): + out = call_tool("append_xlsx_rows", _ctx(), { + "vault": "Vault", "path": "classeur.xlsx", + "sheet": "Budget", "rows": [["VRAI", "01/02/2026", 12.5]], + }, confirm=True) + assert out.ok + wb = openpyxl.load_workbook(workbook) + ws = wb["Budget"] + assert ws["A4"].value is True + assert (ws["B4"].value.month, ws["B4"].value.day) == (2, 1) + assert ws["C4"].value == 12.5 + wb.close() + + def test_unknown_sheet_rejected(self, vault, workbook): + with pytest.raises(ToolError): + call_tool("append_xlsx_rows", _ctx(), { + "vault": "Vault", "path": "classeur.xlsx", + "sheet": "Nope", "rows": [["x"]], + }) + + def test_empty_rows_rejected(self, vault, workbook): + with pytest.raises(ToolError): + call_tool("append_xlsx_rows", _ctx(), { + "vault": "Vault", "path": "classeur.xlsx", + "sheet": "Budget", "rows": [], + })