feat: outils IA de lecture et d'edition des classeurs existants #153
A6 : list_xlsx_sheets (noms + dimensions + truncated), xlsx_to_markdown (table bornee 100x20 pour le contexte LLM), update_xlsx_cells et append_xlsx_rows (WRITE + confirmation, via edit_xlsx_cells : verrou, ecriture atomique, neutralisation des formules, 409 lossy). Les lignes ajoutees sont coercees comme dans le viewer (A10). Labels de step ai.step.xlsx_* FR/EN ; update_xlsx_cells/append_xlsx_rows branches sur MUTATING_TOOLS et FILE_WRITE_TOOLS (refresh viewer via obsigate:file-written). Tests : test_spreadsheet_tools.py 17 (risque, confirmation, persistance, garde formule heritee, coercion) + contre-preuve (cells vide -> 2 echecs). ruff 0, mypy 0, i18n parity, validate-imports. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <[email protected]>
This commit is contained in:
+5
-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).
|
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
|
> **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
|
## [2.32.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.
|
**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://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 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*
|
||||||
|
|||||||
@@ -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.
|
**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://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 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*
|
||||||
|
|||||||
@@ -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 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 documents as _documents # noqa: F401 (registers document tools)
|
||||||
from backend.tools import service as _service # noqa: F401 (registers 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 import web as _web # noqa: F401 (registers web tools)
|
||||||
from backend.tools.context import (
|
from backend.tools.context import (
|
||||||
ToolConfirmationRequired,
|
ToolConfirmationRequired,
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ _STEP_LABELS: dict[str, tuple[str, str | None]] = {
|
|||||||
"git_search_issues": ("git_issues", "query"),
|
"git_search_issues": ("git_issues", "query"),
|
||||||
"git_get_file": ("git_file", "path"),
|
"git_get_file": ("git_file", "path"),
|
||||||
"create_xlsx": ("xlsx_create", "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_docx": ("docx_create", "path"),
|
||||||
"create_csv": ("csv_create", "path"),
|
"create_csv": ("csv_create", "path"),
|
||||||
"create_pdf": ("pdf_create", "path"),
|
"create_pdf": ("pdf_create", "path"),
|
||||||
|
|||||||
@@ -315,6 +315,61 @@ class DocxInput(BaseModel):
|
|||||||
overwrite: bool = Field(True, description="Replace an existing file (with backup)")
|
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):
|
class CsvInput(BaseModel):
|
||||||
"""Create a .csv file in a vault from rows of cells."""
|
"""Create a .csv file in a vault from rows of cells."""
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.32.0"
|
version = "2.33.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.32.0"
|
version = "2.33.0"
|
||||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||||
authors = ["Bruno Charest"]
|
authors = ["Bruno Charest"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||||
"productName": "ObsiGate",
|
"productName": "ObsiGate",
|
||||||
"version": "2.32.0",
|
"version": "2.33.0",
|
||||||
"identifier": "com.obsigate.desktop",
|
"identifier": "com.obsigate.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../frontend",
|
"frontendDist": "../frontend",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# ObsiGate — Roadmap
|
# 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
|
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||||
> vers les fonctionnalités livrées.
|
> vers les fonctionnalités livrées.
|
||||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
||||||
|
|||||||
@@ -60,12 +60,14 @@ const MUTATING_TOOLS = new Set([
|
|||||||
'rename_file', 'rename_directory', 'move_path', 'replace_in_files',
|
'rename_file', 'rename_directory', 'move_path', 'replace_in_files',
|
||||||
'delete_file', 'delete_directory', 'restore_backup',
|
'delete_file', 'delete_directory', 'restore_backup',
|
||||||
'create_xlsx', 'create_docx', 'create_csv', 'create_pdf',
|
'create_xlsx', 'create_docx', 'create_csv', 'create_pdf',
|
||||||
|
'update_xlsx_cells', 'append_xlsx_rows',
|
||||||
]);
|
]);
|
||||||
// Subset carrying a concrete `vault` + `path`: the displayed document is
|
// Subset carrying a concrete `vault` + `path`: the displayed document is
|
||||||
// reloaded from disk so an open viewer/editor reflects the agent's write.
|
// reloaded from disk so an open viewer/editor reflects the agent's write.
|
||||||
const FILE_WRITE_TOOLS = new Set([
|
const FILE_WRITE_TOOLS = new Set([
|
||||||
'edit_file', 'append_to_file', 'create_file', 'restore_backup',
|
'edit_file', 'append_to_file', 'create_file', 'restore_backup',
|
||||||
'create_xlsx', 'create_docx', 'create_csv', 'create_pdf',
|
'create_xlsx', 'create_docx', 'create_csv', 'create_pdf',
|
||||||
|
'update_xlsx_cells', 'append_xlsx_rows',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2008,6 +2008,10 @@
|
|||||||
"ai.step.git_issues": "Searched issues: {value}",
|
"ai.step.git_issues": "Searched issues: {value}",
|
||||||
"ai.step.git_file": "Read a repo file: {value}",
|
"ai.step.git_file": "Read a repo file: {value}",
|
||||||
"ai.step.xlsx_create": "Spreadsheet proposed: {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.docx_create": "Word document proposed: {value}",
|
||||||
"ai.step.csv_create": "CSV file proposed: {value}",
|
"ai.step.csv_create": "CSV file proposed: {value}",
|
||||||
"ai.step.pdf_create": "PDF document proposed: {value}",
|
"ai.step.pdf_create": "PDF document proposed: {value}",
|
||||||
|
|||||||
@@ -2008,6 +2008,10 @@
|
|||||||
"ai.step.git_issues": "Issues recherchées : {value}",
|
"ai.step.git_issues": "Issues recherchées : {value}",
|
||||||
"ai.step.git_file": "Fichier de dépôt lu : {value}",
|
"ai.step.git_file": "Fichier de dépôt lu : {value}",
|
||||||
"ai.step.xlsx_create": "Tableur proposé : {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.docx_create": "Document Word proposé : {value}",
|
||||||
"ai.step.csv_create": "Fichier CSV proposé : {value}",
|
"ai.step.csv_create": "Fichier CSV proposé : {value}",
|
||||||
"ai.step.pdf_create": "Document PDF proposé : {value}",
|
"ai.step.pdf_create": "Document PDF proposé : {value}",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "obsigate",
|
"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.",
|
"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",
|
"main": "patch.js",
|
||||||
"directories": {
|
"directories": {
|
||||||
|
|||||||
@@ -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": [],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user