feat: tableurs indexables, saisie typée et valeurs calculées #153

Les tableurs étaient invisibles à la recherche, chaque saisie devenait
du texte, et une formule n'affichait que sa formule.

- A5 : extract_indexable_text() indexe les noms de feuilles et les 20
  premières lignes (plafond 5 k caractères) pour le TF-IDF et la
  recherche sémantique. Un mot tapé dans une cellule rend le fichier
  trouvable ; un classeur chiffré s'indexe par son seul nom.
- A10 : la saisie est typée comme dans Excel — booléens
  (TRUE/FAUX/OUI/NON) et dates FR JJ/MM/AAAA en ordre jour-first, donc
  01/02/2026 est le 1er février. Une saisie ressemblant à une formule
  n'est jamais convertie (BUG-088 préservé).
- A12 : la valeur calculée par Excel s'affiche sous la formule, via une
  2e lecture data_only=True faite seulement si l'archive contient un
  <v>. Info-bulle traduite FR/EN, aucun texte d'interface côté backend.
- BUG-089 : un reindex manuel ne reconstruisait pas l'index inversé, et
  backend/search.py lisait l'index via un import par valeur — après un
  rechargement du module, la recherche écrivait dans un dict périmé.

Contre-preuves vérifiées pour A5, A10 et A12 (neutralisation de chaque
fonction → échec des tests concernés). Tests : 1402 pytest, 10 JSDOM,
4 E2E, suite E2E complète verte, ruff/mypy 0.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
This commit is contained in:
2026-09-27 22:43:24 -04:00
parent 31d4616baf
commit 06f8e63d06
21 changed files with 585 additions and 61 deletions
+39 -7
View File
@@ -351,6 +351,23 @@ def _decompress_excalidraw(compressed: str) -> dict[str, Any] | None:
return data
def extract_xlsx_indexable(file_path: Path) -> str:
"""Return searchable text for a workbook (#153 A5).
Lazy wrapper: ``openpyxl`` is only imported when a spreadsheet is actually
indexed, so a vault without workbooks never pays the import. Errors are
swallowed — a corrupt or encrypted file still gets indexed by name.
"""
try:
from backend.xlsx_reader import extract_indexable_text
except Exception: # pragma: no cover - openpyxl missing
return ""
try:
return extract_indexable_text(file_path)
except Exception: # pragma: no cover - defensive
return ""
def extract_excalidraw_indexable(raw: str) -> str:
"""Return indexable text content for a raw .excalidraw / .excalidraw.md file.
@@ -561,11 +578,12 @@ def _scan_vault(
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 = ""
# #153 A5 — a workbook stays rendered by the viewer, but its
# cell values are now indexed as text so a spreadsheet is
# findable by its content (parity with _index_single_file_sync).
raw = extract_xlsx_indexable(fpath)
title = fpath.stem.replace("-", " ").replace("_", " ")
content_preview = ""
content_preview = raw[:200].strip()
else:
raw = fpath.read_text(encoding="utf-8", errors="replace")
title = fpath.stem.replace("-", " ").replace("_", " ")
@@ -807,6 +825,13 @@ async def reload_index() -> dict[str, Any]:
await build_index()
# BUG-040/#86: complete the deferred PDF + excalidraw extraction.
await enrich_pdf_texts()
# The inverted index is NOT updated by the hooks here: the rebuild above
# replaces whole vault entries, so the incremental notifications are not
# emitted for the files that only changed content. Without this, a manual
# reindex left TF-IDF search serving a stale index (BUG-089).
from backend.search import init_inverted_index
init_inverted_index()
stats = {}
for name, data in index.items():
stats[name] = {"file_count": len(data["files"]), "tag_count": len(data["tags"])}
@@ -882,6 +907,13 @@ async def reload_single_vault(vault_name: str) -> dict[str, Any]:
# BUG-040/#86: complete the deferred PDF + excalidraw extraction.
await enrich_pdf_texts(vault_name)
# Same as reload_index: the vault entry was replaced wholesale, so rebuild
# the inverted index or TF-IDF search keeps serving stale postings
# (BUG-089).
from backend.search import init_inverted_index
init_inverted_index()
stats = {"file_count": len(vault_data["files"]), "tag_count": len(vault_data["tags"])}
logger.info(f"Vault '{vault_name}' reindexed: {stats['file_count']} files, {stats['tag_count']} tags")
return stats
@@ -955,9 +987,9 @@ def _index_single_file_sync(vault_name: str, vault_path: str, file_path: str, va
raw = ""
content_preview = ""
elif ext == ".xlsx":
# #152 — binary workbook: metadata only (parity with _scan_vault).
raw = ""
content_preview = ""
# #153 A5 — index sheet names + header rows as text (see _scan_vault).
raw = extract_xlsx_indexable(fpath)
content_preview = raw[:200].strip()
else:
raw = fpath.read_text(encoding="utf-8", errors="replace")
content_preview = raw[:200].strip()
+10 -5
View File
@@ -12,7 +12,12 @@ from sortedcontainers import SortedList
from backend import indexer as _indexer
from backend import semantic_search as _semantic
from backend.indexer import index
# NOTE: the shared index is read through ``_indexer.index`` everywhere, never
# via ``from backend.indexer import index``. That import binds the dict object
# once, so a module reload of ``backend.indexer`` (tests, dev reload) rebinds
# the module-level name to a FRESH dict while this module keeps writing to the
# stale one — the inverted index then silently indexes nothing (BUG-089).
from backend.services.regex_safety import (
MAX_REGEX_MATCHES,
truncate_for_regex,
@@ -399,7 +404,7 @@ class InvertedIndex:
self.vault_docs = defaultdict(set)
self.tag_docs = defaultdict(set)
for vault_name, vault_data in index.items():
for vault_name, vault_data in _indexer.index.items():
for file_info in vault_data.get("files", []):
doc_key = f"{vault_name}::{file_info['path']}"
self.doc_count += 1
@@ -688,7 +693,7 @@ _indexer.set_index_change_hook(_on_index_change_hook)
def init_inverted_index():
"""Force initial inverted index build. Called after build_index completes on startup."""
if any(vdata.get("files") for vdata in index.values()):
if any(vdata.get("files") for vdata in _indexer.index.values()):
_inverted_index.rebuild()
logger.info("Inverted index initialized.")
@@ -784,7 +789,7 @@ def search(
else:
candidates = [
(vault_name, file_info)
for vault_name, vault_data in index.items()
for vault_name, vault_data in _indexer.index.items()
if vault_filter == "all" or vault_name == vault_filter
for file_info in vault_data["files"]
]
@@ -1613,7 +1618,7 @@ def get_all_tags(vault_filter: str | None = None) -> dict[str, int]:
Dict mapping tag names to their total occurrence count.
"""
merged: dict[str, int] = {}
for vault_name, vault_data in index.items():
for vault_name, vault_data in _indexer.index.items():
if vault_filter and vault_filter != "all" and vault_name != vault_filter:
continue
for tag, count in vault_data.get("tags", {}).items():
+50 -1
View File
@@ -19,6 +19,7 @@ import shutil
import threading
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from datetime import date, datetime
from pathlib import Path
from typing import Any
@@ -237,6 +238,14 @@ _XLSX_FLOAT_RE = re.compile(r"^[+-]?(?:\d+\.\d*|\.\d+)$")
# the legacy Lotus-style trigger. "+"/"-" are left alone: they are numbers here.
_XLSX_FORMULA_RE = re.compile(r"^[=@]")
# #153 A10 — types recognised when a user types into a cell. Excel infers them
# too; storing everything as text would make a spreadsheet unusable (a boolean
# column stays a string, a date column sorts lexicographically).
_XLSX_TRUE_LITERALS = {"true", "vrai", "oui", "yes"}
_XLSX_FALSE_LITERALS = {"false", "faux", "non", "no"}
# Shape check before strptime: keeps the hot path free of format attempts.
_XLSX_DATE_RE = re.compile(r"^\d{1,2}[-/]\d{1,2}[-/]\d{4}(?:[ T]\d{1,2}:\d{2})?$")
# #153 A3 — per-file write lock. Two concurrent saves (two tabs, the AI agent
# and the viewer, a watcher restore) would otherwise read-modify-write on the
# same archive and the last writer silently wins. Kept deliberately small: the
@@ -270,7 +279,21 @@ def _xlsx_write_lock(key: str) -> Iterator[None]:
def _coerce_xlsx_value(value: Any) -> Any:
"""Turn the string sent by the cell editor back into a scalar."""
"""Turn the string sent by the cell editor back into a scalar (#153 A10).
The coercion is symmetric with :func:`backend.xlsx_reader._fmt`: a value
typed by the user comes back as a string, and Excel would have inferred a
type when typing the same thing. Recognised here:
* an empty cell -> ``None`` (clears it)
* ``1234`` / ``-1`` -> ``int``
* ``1.5`` / ``.5`` -> ``float``
* ``TRUE``/``FAUX`` (case-insensitive) -> ``bool``
* ``31/12/2026`` / ``31/12/2026 14:30`` -> ``date``/``datetime`` (FR)
Anything else stays text. A date-looking string typed with a leading
``=`` is a formula and never reaches here as a date.
"""
if not isinstance(value, str):
return value
text = value.strip()
@@ -280,9 +303,35 @@ def _coerce_xlsx_value(value: Any) -> Any:
return int(text)
if _XLSX_FLOAT_RE.match(text):
return float(text)
lowered = text.lower()
if lowered in _XLSX_TRUE_LITERALS:
return True
if lowered in _XLSX_FALSE_LITERALS:
return False
if not _XLSX_FORMULA_RE.match(text):
parsed = _parse_fr_datetime(text)
if parsed is not None:
return parsed
return value
def _parse_fr_datetime(text: str) -> date | datetime | None:
"""Parse a FR-localised date/datetime, or return ``None``.
Accepts ``JJ/MM/AAAA`` and ``JJ/MM/AAAA HH:MM`` (also ``JJ-MM-AAAA``).
``dayfirst`` is what makes ``01/02/2026`` the 1st of February rather than
the 2nd of January — the French convention.
"""
if not _XLSX_DATE_RE.match(text):
return None
for fmt in ("%d/%m/%Y %H:%M", "%d/%m/%Y", "%d-%m-%Y %H:%M", "%d-%m-%Y"):
try:
return datetime.strptime(text, fmt)
except ValueError:
continue
return None
def _write_cell(ws: Any, ref: str, value: Any, *, allow_formula: bool) -> None:
"""Assign one cell, forcing text when it looks like a formula.
+163 -13
View File
@@ -11,6 +11,7 @@ round-trip would drop (#153 A1) so the UI can warn before saving.
from __future__ import annotations
import html
import logging
import re
import zipfile
from datetime import date, datetime
@@ -20,6 +21,8 @@ from typing import Any
from openpyxl import load_workbook
from openpyxl.utils import get_column_letter
logger = logging.getLogger("obsigate.xlsx_reader")
# 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
@@ -48,6 +51,14 @@ _CACHED_FORMULA_RE = re.compile(rb"<f[ >][^<]*</f>\s*<v>[^<]")
# Sheet XML scanned by the cached-formula probe (CPU guard, like MAX_REPLACE_FILE_BYTES).
_MAX_PROBE_BYTES = 8_000_000
# #153 A5 — ceiling on the text handed to the TF-IDF / semantic index. A workbook
# is a data dump, not prose: indexing every cell would flood the inverted index
# and bury the notes. Sheet names + the first rows are enough to make a
# spreadsheet findable by its headers.
MAX_INDEX_CHARS = 5_000
_INDEX_ROWS_PER_SHEET = 20
MAX_INDEX_SHEETS = 20
def _fmt(value: Any) -> str:
if value is None:
@@ -74,7 +85,29 @@ def _trim(grid: list[list[str]]) -> list[list[str]]:
return [row[:width] for row in grid]
def _table(grid: list[list[str]]) -> str:
def _cell_cached(cached: list[list[str]] | None, r: int, c: int) -> str:
"""Return the cached result for a 0-based cell, or ``""``.
The shadow grid is read positionally and may be narrower than the formula
grid (``_trim`` collapses the trailing empty columns of each grid
independently), so every lookup is bounds-checked rather than assumed.
"""
if not cached or r >= len(cached):
return ""
row = cached[r]
return row[c] if c < len(row) else ""
def _table(grid: list[list[str]], cached: list[list[str]] | None = None) -> str:
"""Render a grid as an HTML table.
``cached`` is the same grid read with ``data_only=True`` (#153 A12): where a
formula cell still carries its last computed result, it is shown as a
discreet second line (``<span class="xlsx-cached">``) so the user sees the
number Excel last calculated instead of only the formula text. The span
carries ``data-cached-value`` and is titled client-side from
``xlsx.cached_value_title`` — the backend never emits UI text.
"""
if not grid:
return "<p><em>Feuille vide</em></p>"
n_cols = max(len(row) for row in grid)
@@ -90,7 +123,24 @@ def _table(grid: list[list[str]]) -> str:
out.append(f'<tr><th class="xlsx-rownum">{r}</th>')
for c, val in enumerate(row, start=1):
ref = f"{get_column_letter(c)}{r}"
out.append(f'<td data-cell="{ref}">{html.escape(val)}</td>')
# The cached result only makes sense for a formula cell: on a plain
# value cell the two reads are identical and showing both would
# duplicate the text.
shadow = ""
if cached is not None and val.startswith("="):
# `c` is 1-based (A1 notation) and `r` too, while the grid is
# 0-based: translate both.
cval = _cell_cached(cached, r - 1, c - 1)
if cval and cval != val:
# The tooltip is translated client-side from
# `xlsx.cached_value_title`; never hardcode UI text here.
shadow = (
f'<span class="xlsx-cached" data-cached-value="1">'
f"{html.escape(cval)}</span>"
)
out.append(
f'<td data-cell="{ref}">{html.escape(val)}{shadow}</td>'
)
out.append("</tr>")
out.append("</tbody></table></div>")
return "".join(out)
@@ -142,18 +192,118 @@ def inspect_workbook(file_path: Path) -> list[str]:
def render_sheets(file_path: Path) -> list[dict[str, str]]:
"""Return ``[{"name": sheet_title, "html": table_html}, ...]``."""
"""Return ``[{"name": sheet_title, "html": table_html}, ...]``.
Reads the workbook twice: once with ``data_only=False`` for the formulas
(what the user must edit) and, when any formula carries a cached result
(#153 A12), once with ``data_only=True`` to show what Excel last computed.
The second pass is skipped entirely when the archive holds no cached value,
so the common case still costs a single load.
"""
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
formulas = [_sheet_grid(ws) for ws in wb.worksheets]
titles = [ws.title for ws in wb.worksheets]
finally:
wb.close()
cached: list[list[list[str]]] | None = None
if _has_cached_values(file_path):
cached = _read_cached_grids(file_path, titles)
sheets = []
for i, title in enumerate(titles):
grid = _trim(formulas[i])
# The shadow grid is NOT trimmed independently: _trim drops the
# trailing empty columns of each grid on its own width, which would
# shift every cached value left of its formula. Indexing it
# positionally against the untrimmed grid keeps the two aligned.
shadow = cached[i] if cached is not None and i < len(cached) else None
sheets.append({"name": title, "html": _table(grid, shadow)})
return sheets
def _sheet_grid(ws: Any) -> list[list[str]]:
"""Read one worksheet into a grid of formatted strings, bounded by the caps."""
return [
[_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
)
]
def _has_cached_values(file_path: Path) -> bool:
"""True when the archive holds at least one ``<f>…</f><v>…</v>``."""
try:
with zipfile.ZipFile(file_path) as zf:
return _has_cached_formulas(zf)
except (OSError, zipfile.BadZipFile):
return False
def _read_cached_grids(
file_path: Path, titles: list[str]
) -> list[list[list[str]]] | None:
"""Read every sheet with ``data_only=True`` (what Excel last computed).
Best effort: returns ``None`` on any failure so the viewer falls back to the
formula-only rendering. A workbook Excel opens but openpyxl cannot re-read
must still display.
"""
try:
wb = load_workbook(str(file_path), read_only=True, data_only=True)
except Exception:
return None
try:
grids = [_sheet_grid(ws) for ws in wb.worksheets]
if [ws.title for ws in wb.worksheets] != titles:
return None
return grids
except Exception:
logger.debug("xlsx cached values unavailable", exc_info=True)
return None
finally:
wb.close()
def extract_indexable_text(file_path: Path) -> str:
"""Return searchable text for the TF-IDF / semantic index (#153 A5).
Sheet names plus the first :data:`_INDEX_ROWS_PER_SHEET` rows of each
sheet, capped at :data:`MAX_INDEX_CHARS`. Rows are tab-joined so a search
for a header matches the sheet it belongs to.
Never raises: a corrupt, encrypted or unsupported workbook yields ``""`` so
the file still gets indexed by name (same contract as :func:`inspect_workbook`).
"""
chunks: list[str] = []
budget = MAX_INDEX_CHARS
try:
wb = load_workbook(str(file_path), read_only=True, data_only=True)
except Exception:
# Encrypted (BadZipFile) or not a real workbook: name-only indexing.
return ""
try:
for ws in wb.worksheets[:MAX_INDEX_SHEETS]:
if budget <= 0:
break
# The sheet title alone is a strong signal ("Recettes", "Budget").
block = [ws.title]
for row in ws.iter_rows(
min_row=1, max_row=_INDEX_ROWS_PER_SHEET, max_col=MAX_COLS, values_only=True
):
cells = [_fmt(v) for v in row]
# Skip blank rows instead of emitting runs of tabs.
if not any(c.strip() for c in cells):
continue
block.append("\t".join(cells).rstrip())
text = "\n".join(block)
chunks.append(text[:budget])
budget -= len(text)
except Exception:
# Truncated but still useful: keep whatever was collected.
pass
finally:
wb.close()
return "\n".join(c for c in chunks if c).strip()