Compare commits

..
5 Commits
Author SHA1 Message Date
bruno 0ab402aa73 docs: roadmap priorise dette et securite #85 #87 (#77 non signe, #73 reporte)
CI / lint (push) Successful in 2m13s
CI / security (push) Successful in 1m34s
CI / test (push) Successful in 4m9s
CI / build (push) Successful in 1m31s
CI / e2e (push) Successful in 14m17s
2026-09-26 11:55:32 -04:00
bruno c36c299466 docs: #152 — aligne changelog et roadmap sur la version livrée 2.27.0
Fusionne la section 2.26.0 (jamais publiée) dans 2.27.0 et corrige la ligne
index de la roadmap ; suppression du tag local v2.26.0.
2026-09-25 20:32:14 -04:00
bruno 8611416670 feat: #152 viewer XLSX — affichage multi-feuilles, édition des cellules et téléchargement des .xlsx
- backend/xlsx_reader.py : rend openpyxl en tableaux HTML (plafond 500x40 par feuille)
- PUT /api/file/{vault}/xlsx/save : service edit_xlsx_cells (backup avant écriture, refs A1 validées)
- frontend : renderXlsxViewer (onglets, contenteditable, sauvegarde par feuille)
- docs : CHANGELOG [Unreleased], Roadmap index #152, archive, README FR/EN, exemple OpenAPI
2026-09-25 20:30:45 -04:00
bruno e20fd6bf97 fix: /api/diagnostics 500 « dictionary changed size during iteration » — snapshot des dicts avant itération BUG-079
CI / lint (push) Successful in 2m3s
CI / security (push) Successful in 1m27s
CI / test (push) Successful in 4m22s
CI / build (push) Successful in 1m22s
CI / e2e (push) Successful in 13m36s
2026-09-24 17:13:40 -04:00
bruno 943005328c feat: barre d'outils de lecture epinglee, coloration syntaxique des fichiers de code et avatars predefinis (#115, #117, BUG-078)
CI / lint (push) Successful in 2m5s
CI / security (push) Successful in 1m27s
CI / test (push) Successful in 4m10s
CI / build (push) Successful in 1m23s
CI / e2e (push) Successful in 13m55s
2026-09-24 16:21:45 -04:00
44 changed files with 1137 additions and 81 deletions
+70 -1
View File
@@ -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.24.0**.
> [Unreleased](#unreleased). La dernière version livrée est **2.27.1**.
---
@@ -14,6 +14,75 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.27.1] — 2026-09-26
### Modifié
- **Roadmap — priorisation dette & sécurité (décisions 2026-09-26).**
Items #85 (refonte architecturale) et #87 (CI/CD) détaillés et marqués
prioritaires : `backend/main.py` mesuré à ~4 827 lignes, `tools/registry.py`
à créer, persistance SQLite/Redis, verrous asyncio, audit des `except`
larges, CI sécurité bloquante (bandit/semgrep/trivy, audits pip/npm),
finition CSP nonce (BUG-034), cookies `Secure` par défaut, rotation clé
DeepSeek à confirmer (BUG-006). #73 Sync reporté (P4, hors chemin
critique) ; desktop #77 confirmé non signé + doc SmartScreen, reste les
6 tests E2E manuels. Corrections : sections livrées #83/#84 retirées du
backlog (détail dans l'archive, index inchangé), total restant recalculé
(~12-18 jours chemin critique : #77 fin + #85 + #87).
---
## [2.27.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é
- **`/api/diagnostics` — erreur 500 « dictionary changed size during iteration ».**
Le calcul des statistiques d'index itérait `inv.word_index` et `index` en
direct, pendant que l'indexeur les modifiait depuis un autre thread (build au
démarrage, hooks incrémentaux) : l'itérateur de dict levait `RuntimeError` et
l'endpoint renvoyait 500. Les deux dicts sont désormais **copiés avant
itération** (copie atomique sous le GIL), ce qui supprime la course.
---
## [2.25.0] — 2026-09-24
### Ajouté
- **#115 — barre d'outils de lecture toujours visible.** La barre d'actions d'un
document (pop-out, bookmark, Editer, Source, Copier, Partager…) est désormais
**épinglée en haut** de la zone de lecture : elle reste accessible en permanence,
même au bas d'un document long, au lieu de disparaître au défilement. Elle est
rendue comme un enfant direct du conteneur de défilement (`.file-toolbar`), masquée
en mode lecture, et alignée dans la fenêtre pop-out.
- **#117 — avatars prédéfinis dans le profil.** La section **Profil** propose une
galerie de **12 avatars** fournis avec l'application (`frontend/icons/avatar/`) :
un clic charge l'image, la recadre au format carré 256 px (même pipeline que
l'import) et l'enregistre sur le compte. L'avatar actif est surligné ; l'import
d'une photo personnalisée et la suppression restent disponibles.
### Corrigé
- **BUG-078 — coloration syntaxique des fichiers de code perdue.** Les feuilles de
style highlight.js étaient basculées à partir de la **clé de thème**
(`defaut-obsigate`, …) au lieu du **mode** (`dark`/`light`) : les deux feuilles se
retrouvaient désactivées et les blocs de code (`.py`, `.sh`, `.ps1`, `.yml`, JSON,
blocs Markdown…) s'affichaient en texte brut. Le basculement est désormais piloté
par le mode, de façon déterministe, dans le moteur de thème (`themes.js`) comme au
premier rendu (`ui.js`) ; sépia et contraste élevé réutilisent la palette claire.
---
## [2.24.0] — 2026-09-24
### Ajouté
+4 -3
View File
@@ -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.24.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.27.1-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -85,6 +85,7 @@ Les **guides d'utilisation** pas à pas se trouvent dans [`docs/GUIDES/`](docs/G
- **🖼️ Images Obsidian** : Support complet des syntaxes d'images Obsidian avec résolution intelligente
- **🎬 Audio & vidéo** : Lecteurs HTML5 intégrés (`.mp3 .wav .flac .mp4 .webm`…) avec streaming HTTP Range (lecture, déplacement, plein écran) et **lecture persistante** (mini-lecteur flottant / mini-fenêtre vidéo, retour au média ou arrêt à tout moment, contrôles écran verrouillé via Media Session), repli téléchargement si le format n'est pas lisible par le navigateur
- **🎨 Diagrammes Excalidraw** : Visualiseur/éditeur natif des fichiers `.excalidraw` et `.excalidraw.md` (iframe sandboxée, auto-save, thème clair/sombre, texte des diagrammes indexé pour la recherche)
- **📊 Tableurs Excel** : les fichiers `.xlsx` s'ouvrent dans un visualiseur dédié — un tableau par feuille avec onglets, en-têtes A1 et édition directe des cellules (`PUT /api/file/{vault}/xlsx/save`, backup automatique), plus le téléchargement du fichier d'origine
- **🎨 Syntax highlight** : Coloration syntaxique des blocs de code
- **🌓 Thème clair/sombre** : Toggle persisté en localStorage
- **📡 Synchronisation temps réel** : Surveillance automatique des fichiers via watchdog avec mise à jour incrémentale de l'index
@@ -975,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
## 📝 Changelog
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.24.0).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.27.1).
---
*Projet : ObsiGate | Version : 2.24.0 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.27.1 | Dernière mise à jour : Septembre 2026*
+4 -3
View File
@@ -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.24.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.27.1-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -84,6 +84,7 @@ Step-by-step **user guides** live in [`docs/GUIDES/`](docs/GUIDES/):
- **🖼️ Obsidian Images** : Full support for all Obsidian image syntaxes with intelligent resolution
- **🎬 Audio & video** : Built-in HTML5 players (`.mp3 .wav .flac .mp4 .webm`…) with HTTP Range streaming (play, seek, fullscreen) and **persistent playback** (floating mini-player / mini video window, return to media or stop anytime, lock-screen controls via Media Session), falling back to download when the format is not playable in the browser
- **🎨 Excalidraw Diagrams** : Native viewer/editor for `.excalidraw` and `.excalidraw.md` files (sandboxed iframe, autosave, dark/light theme, diagram text indexed for search)
- **📊 Excel Spreadsheets** : `.xlsx` files open in a dedicated viewer — one table per sheet with tabs, A1 headers and inline cell editing (`PUT /api/file/{vault}/xlsx/save`, automatic backup), plus download of the original file
- **🎨 Syntax Highlight** : Syntax highlighting for code blocks
- **🌓 Light/Dark Theme** : Toggle persisted in localStorage
- **📡 Real-time Sync** : Automatic file monitoring via watchdog with incremental index updates
@@ -1150,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
## 📝 Changelog
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.24.0).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.27.1).
---
*Project: ObsiGate | Version: 2.24.0 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.27.1 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.24.0
2.27.1
+11
View File
@@ -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()
+74 -3
View File
@@ -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
@@ -4231,7 +4299,9 @@ async def api_diagnostics(current_user=Depends(require_admin)):
vault_stats = {}
total_files = 0
total_tags = 0
for vname, vdata in index.items():
# Snapshot both dicts first: the indexer mutates them from background
# threads, and iterating a live dict raises "dictionary changed size".
for vname, vdata in list(index.items()):
file_count = len(vdata.get("files", []))
tag_count = len(vdata.get("tags", {}))
vault_stats[vname] = {"file_count": file_count, "tag_count": tag_count}
@@ -4239,7 +4309,8 @@ async def api_diagnostics(current_user=Depends(require_admin)):
total_tags += tag_count
# Memory estimate for inverted index
word_index_entries = sum(len(docs) for docs in inv.word_index.values())
word_index = inv.word_index.copy()
word_index_entries = sum(len(docs) for docs in word_index.values())
mem_estimate_mb = round(
(sys.getsizeof(inv.word_index) + word_index_entries * 80
+ len(inv.doc_info) * 200
@@ -4253,7 +4324,7 @@ async def api_diagnostics(current_user=Depends(require_admin)):
"vaults": vault_stats,
},
"inverted_index": {
"unique_tokens": len(inv.word_index),
"unique_tokens": len(word_index),
"total_postings": word_index_entries,
"documents": inv.doc_count,
"sorted_tokens": len(inv._sorted_tokens),
+4
View File
@@ -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},
+97
View File
@@ -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,
+86
View File
@@ -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 "<p><em>Feuille vide</em></p>"
n_cols = max(len(row) for row in grid)
out = [
(
'<div class="csv-table-wrapper"><table class="csv-table xlsx-table">'
'<thead><tr><th class="xlsx-corner"></th>'
)
]
out += [f"<th>{get_column_letter(c)}</th>" for c in range(1, n_cols + 1)]
out.append("</tr></thead><tbody>")
for r, row in enumerate(grid, start=1):
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>')
out.append("</tr>")
out.append("</tbody></table></div>")
return "".join(out)
def render_sheets(file_path: Path) -> list[dict[str, str]]:
"""Return ``[{"name": sheet_title, "html": table_html}, ...]``."""
wb = load_workbook(str(file_path), read_only=True, data_only=False)
try:
sheets = []
for ws in wb.worksheets:
grid = [
[_fmt(v) for v in row]
for row in ws.iter_rows(
min_row=1, max_row=MAX_ROWS, max_col=MAX_COLS, values_only=True
)
]
sheets.append({"name": ws.title, "html": _table(_trim(grid))})
return sheets
finally:
wb.close()
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.24.0"
version = "2.27.1"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.24.0"
version = "2.27.1"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.24.0",
"version": "2.27.1",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+5
View File
@@ -187,6 +187,8 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| *BUG-075* | [🟡 IMPORTANT] Assistant IA : chaque action mutatrice demande son propre « Appliquer » — aucun résumé des actions en attente ni approbation globale | 🟢 corrigé | P1 | ⚙️ backend + 📱 frontend | IA | `backend/agent/loop.py`, `backend/bookslm_routes.py`, `frontend/js/bookslm.js` | Mode agent : demander une structure de répertoires multi-fichiers → valider une action après l'autre | `backend/agent/loop.py` (`pending.actions`, lot exécuté au resume) ; `backend/bookslm_routes.py` (`confirm_all` → `ctx.confirmed`) ; `frontend/js/bookslm.js` (carte multi-actions + « Tout approuver (N) »). Tests : `tests/test_agent_loop.py`, `tests/test_bookslm.py`, `tests/frontend/ai.test.mjs` | La pause de confirmation ne capture que le **premier** appel mutateur du lot (les suivants sont `deferred`) ; carte unique sans liste ; nouveau `confirm_all` à ajouter pour autoriser la suite de l'exécution en une approbation |
| *BUG-076* | [🟡 IMPORTANT] Assistant IA : après une action de l'agent, l'arborescence et le document ouvert ne sont pas rafraîchis dynamiquement | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js` | Mode agent : créer/supprimer un fichier ou dossier, modifier le document ouvert → l'UI ne bouge pas | `frontend/js/bookslm.js` : `MUTATING_TOOLS`/`FILE_WRITE_TOOLS`, refresh d'arborescence débouncé sur event `tool`, `_notifyFileWritten` étendu (xlsx/docx/csv/pdf). Tests : `tests/frontend/ai.test.mjs`, `tests/frontend/editor-inline.test.mjs` | Aucun refresh explicite sur les événements `tool` mutateurs (repose uniquement sur le watcher SSE) ; `_notifyFileWritten` ignore les créations de documents (xlsx/docx/csv/pdf) |
| *BUG-077* | [🟡 IMPORTANT] Assistant IA : aucun bouton « Stop » pour arrêter l'exécution de l'agent à tout moment | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js` | Mode agent : lancer une longue tâche → le bouton Envoyer est désactivé, impossible d'arrêter (seule la fermeture du panneau abort) | `frontend/js/bookslm.js` + `frontend/style.css` : bouton d'envoi → Stop (`_syncSendButton`/`_stopGeneration`/`_markStopped`), i18n `ai.stop`/`ai.stopped`. Tests : `tests/frontend/ai.test.mjs` (+2) | `_abortCtrl` n'est déclenché que par `close()` ; aucun signal d'arrêt côté client pendant le stream |
| *BUG-078* | [🟡 IMPORTANT] Fichiers de code : la coloration syntaxique (highlight.js) disparaît — les feuilles de thème sont basculées à partir de la **clé** de thème au lieu du **mode** | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/themes.js`, `frontend/js/ui.js`, `tests/frontend/unit.test.mjs` | Ouvrir un fichier `.py`/`.sh`/`.ps1`/`.yml` : le code s'affiche en texte brut, sans couleurs | `frontend/js/themes.js` : `applyTheme` bascule `hljs-theme-dark`/`hljs-theme-light` selon le **mode** (`isDark`). `frontend/js/ui.js` : `initTheme`/`applyTheme` résolvent le mode persisté (`obsigate-theme-mode`) au lieu de traiter la clé (`defaut-obsigate`) comme un mode. Test : `unit.test.mjs` (+1). | Les deux feuilles étaient désactivées car `defaut-obsigate !== "dark"` et `!== "light"` ; résultat **non déterministe** selon l'ordre `UI.initTheme()` (clé) / `Sync.init()` → `themes.initThemes()` (mode). Vérifié Playwright : 5/5 chargements colorés (`.py`), sépia/contraste élevé sur la palette claire |
| *BUG-079* | `GET /api/diagnostics` → 500 « dictionary changed size during iteration » (stats d'index) | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/main.py` | Charger la page de diagnostic pendant une indexation : `GET /api/diagnostics` → 500 | `backend/main.py` (`api_diagnostics`) : snapshot avant itération — `list(index.items())` et `inv.word_index.copy()` (copie C atomique sous le GIL) ; test de non-régression `tests/test_api_main.py::TestConfig::test_diagnostics_concurrent_index_writes` | Le handler itérait les dicts en direct alors que l'indexeur les modifiait depuis un autre thread (rebuild initial dans `_search_executor`, hooks incrémentaux `add_document`/`remove_document`) → `RuntimeError` dans le générateur → 500. Test déterministe (`RaceDict` fait grossir le dict en cours d'itération) : échoue sans le correctif, passe avec. Vérifié : pytest 1305 passed / 6 skipped, ruff 0, mypy 0 |
### TODOs techniques (améliorations / nouvelles tâches)
@@ -269,6 +271,9 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 2026-09-24 | #114 | Feature | `frontend/index.html`, `frontend/js/config.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/config-mobile.test.mjs`, `tests/e2e/config-mobile.spec.js`, `docs/features/settings-mobile-114.md` (nouvelle), `docs/ROADMAP.md`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **#114 — Configuration, refonte mobile-responsive (≤768px)**. (1) **Drawer sommaire** : `#config-nav` en panneau `position: fixed` (`min(320px, 88vw)`, z-index 40) sous backdrop `#config-modal.config-toc-open::before` (z-index 35) ; bouton `#config-toc-close` ; backdrop/Échap ferment le drawer d'abord puis la modale ; `_setConfigNav` bascule la classe + conserve le `display` inline de reset. (2) **Modale plein écran** : `100dvh` + `padding: 0`, `border-radius: 0`. (3) **Tactile** : boutons/liens ≥44px, inputs/selects `16px` + `min-height: 44px` (anti-zoom iOS, scopé `#config-modal`), rangée `.config-actions-row` sticky column + safe-area, formulaires 1 colonne, MFA 1 colonne + code full-width, wrap webhook/token/share/diag/avatar/webauthn. (4) **Dettes HTML/i18n** : `.config-actions-row` replacée dans `#cfg-backend-settings` (`</section>` orphelin supprimé), id dupliqué `cfg-partages-publics` retiré du `<h2>`, `#plugins-settings-container` supprimé, doublons `.config-btn-add` + règle morte `.mfa-recovery-input` purgés, `#mt-explorer` → `data-i18n="settings.explorer"` ; i18n : clés mortes `settings.{backend,backend_hint,restart_badge,save,plugins}` supprimées, `settings.explorer` + `config.toc_close` ajoutées, `settings.tabs` FR = « Onglets ». Vérifié : `config-mobile.test.mjs` 27/27 (au CI), unit 11/11, validate-imports 40 modules, pytest 1302 passed / 6 skipped, ruff/mypy 0, E2E `chromium-mobile` 5/5. | ✅ livré (en attente vérif utilisateur) |
| 2026-09-24 | BUG-074 → BUG-077 | Correction | `backend/agent/loop.py`, `backend/bookslm_routes.py`, `frontend/js/bookslm.js`, `frontend/style.css`, `frontend/index.html`, `frontend/locales/{fr,en}.json`, `frontend/sw.js`, `tests/test_agent_loop.py`, `tests/test_bookslm.py`, `tests/frontend/ai.test.mjs`, `tests/frontend/editor-inline.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **Lot assistant IA (mode agent)** : (BUG-075) confirmation par lot — `pending.actions` regroupe toutes les mutations d'un tour LLM, un unique bouton « Tout approuver (N) » envoie `confirm_all` (`ToolContext.confirmed`) et n'interrompt plus à chaque action ; les lectures du lot s'exécutent aussitôt. (BUG-074) résumé du bloc d'étapes avec titre de la 1re action + compteur limité aux actions, reprise diffusée dans le même message (fini le « 1 étape » fragmenté). (BUG-076) refresh de l'arborescence débouncé sur les events `tool` mutateurs + `_notifyFileWritten` étendu aux documents (xlsx/docx/csv/pdf). (BUG-077) le bouton d'envoi devient « Stop » pendant le stream (abort SSE, tâche serveur annulée à la déconnexion, marqueur « Exécution arrêtée. »). Guide/i18n FR/EN + `ai.stop`/`ai.stopped`/`ai.confirm_actions`/`ai.action_apply_all` ; `SW_VERSION` v25. Vérifié : pytest 1304 passed / 6 skipped, ruff/mypy 0, validate-imports 40 modules, unit 11/11, ai 100/100, editor-inline 44/44, mobile-editor 35/35, ai-sidebar 6/6, forge 32/32, pane-manager 9/9, sw 8/8. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-24 | #115, #117, BUG-078 | Feature + correction | `frontend/js/themes.js`, `frontend/js/ui.js`, `frontend/js/viewer.js`, `frontend/js/config.js`, `frontend/index.html`, `frontend/style.css`, `frontend/popout.html`, `frontend/locales/{fr,en}.json`, `frontend/icons/avatar/*` (nouveau), `tests/frontend/unit.test.mjs`, `tests/frontend/toolbar-order.test.mjs`, `tests/frontend/settings-order-avatar.test.mjs`, `docs/features/viewer-toolbar-highlight-avatars.md` (nouvelle), `docs/ROADMAP.md`, `CHANGELOG.md` | **#115** barre d'outils de lecture épinglée : `viewer.js`/`popout.html` sortent `.file-actions` de `.file-header` dans un `.file-toolbar` enfant direct de `.content-area` (`position: sticky; top: 0`), masqué en mode lecture. **BUG-078** coloration syntaxique : le basculement des feuilles highlight.js suit le **mode** (`themes.applyTheme` + `ui.initTheme/applyTheme` lisent `obsigate-theme-mode`) au lieu de la clé de thème qui désactivait les deux feuilles. **#117** avatars prédéfinis : galerie de 12 images (`frontend/icons/avatar/`) dans `#cfg-profile`, clic → recadrage 256 px (pipeline import) + `PATCH /api/auth/me`, avatars actifs surlignés (`obsigate-avatar-preset`), import personnalisé et suppression conservés. Vérifié : Playwright (coloration 5/5 déterministe, toolbar épinglée à `barTop` constant au défilement), `unit.test.mjs` 12/12, `toolbar-order` 13/13, `settings-order-avatar` 12/12, JSDOM editor-inline/pane-manager/mobile-editor/image-viewer/pdf-viewer/config-mobile/media-viewer/excalidraw verts, pytest 1304 passed / 6 skipped, ruff/mypy 0, validate-imports 40 modules. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-24 | BUG-079 | Correction | `backend/main.py`, `tests/test_api_main.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-079** : `GET /api/diagnostics` renvoyait 500 « dictionary changed size during iteration ». Le handler itérait `inv.word_index.values()` et `index.items()` en direct alors que l'indexeur les modifiait depuis un autre thread (rebuild initial dans `_search_executor`, hooks incrémentaux `add_document`/`remove_document`) → `RuntimeError` dans le générateur. Correctif : **snapshot avant itération** (`list(index.items())`, `inv.word_index.copy()`) — copie C atomique sous le GIL, pas de verrou ajouté. Test de non-régression déterministe (`RaceDict` fait grossir le dict pendant l'itération ; échoue sans le correctif, passe avec). Vérifié : pytest 1305 passed / 6 skipped, ruff 0, mypy 0 (80 fichiers), validate-imports 40 modules, unit 12/12. | 🟢 corrigé (en attente vérif utilisateur) |
---
## 📜 Historique des bugs résolus
+25 -50
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.24.0 | **Dernière mise à jour :** 2026-09-24
> **Version :** 2.27.1 | **Dernière mise à jour :** 2026-09-26
> **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)**
@@ -37,7 +37,7 @@
- **Reste à faire :**
- [x] **Signature de l'updater Tauri** (gratuit) : paire de clés générée, `pubkey` renseignée, `createUpdaterArtifacts` activé, secrets CI câblés
- [x] **Manifeste `latest.json`** généré par `scripts/updater_manifest.py` (intégré à `publish_release.py`), endpoint updater pointé sur `main`
- [ ] **Signature de code Windows** : non retenue (pas de certificat) — alternatives : livrer non signé, SignPath.io (OSS gratuit), Certum OSS, Azure Trusted Signing, certificat EV
- [ ] **Signature de code Windows** : **non retenue — décision confirmée le 2026-09-26** : livraison non signée + documentation SmartScreen (« Exécuter quand même »). Alternatives écartées sauf retour utilisateur : SignPath.io (OSS gratuit), Certum OSS, Azure Trusted Signing, certificat EV
- [ ] Exécuter les 6 tests E2E **manuels** — protocole documenté : [DESKTOP_E2E_CHECKLIST.md](./DESKTOP_E2E_CHECKLIST.md)
---
@@ -47,6 +47,7 @@
### 73. Synchronisation multi-appareils — Obsidian Sync compatible
- **Effort :** 6-8 jours | **Impact :** 🟢
- **Décision 2026-09-26 : reporté (P4)** — axe prioritaire = dette & sécurité (#85/#87) ; #73 hors chemin critique. Si réactivé : partir d'un MVP export/hash/LWW adossé à #59 (PWA offline) + #62 (collab Yjs/CRDT) plutôt qu'un protocole parallèle.
- **Description :** Synchronisation des vaults entre plusieurs instances d'ObsiGate via un protocole de synchronisation décentralisé ou compatible Obsidian Sync. Alternative self-hosted à Obsidian Sync.
- **Sous-tâches :**
- [ ] Protocole : évaluation CRDT vs OT vs diff/patch pour fichiers markdown
@@ -60,60 +61,29 @@
---
## ⚪ Backlog — Priorité 2 (P2)
### 83. Barre d'outils d'édition mobile — style Obsidian Android
- **Effort :** 3-5 jours | **Impact :** 🟡 | **Zone :** frontend (mobile)
- **Statut :** ✅ livré — ruban horizontal défilable ancré au-dessus du clavier, commandes étendues et personnalisation persistée. Détail : [archive/COMPLETED_v1-v2.md](./archive/COMPLETED_v1-v2.md) (section #83).
- **Description :** remplacer la barre de mise en forme Markdown actuelle par un **ruban horizontal
défilable** ancré juste au-dessus du clavier virtuel, reprenant l'ergonomie de l'app Android
Obsidian : fond anthracite aux coins arrondis, insertion/enrobage de la syntaxe au curseur ou sur
la sélection, et personnalisation des commandes via une icône clé à molette.
- **Sous-tâches :**
- [x] Ruban horizontal défilable (glissement tactile gauche/droite) ancré au-dessus du clavier
- [x] Actions rapides : annuler, refaire, `[[ ]]` (lien interne), modèle/fichiers, tag `#`, pièce jointe
- [x] Formatage : H1–H6, gras, italique, barré (`~~`), surligné (`==`), code en ligne/bloc, citation (`>`)
- [x] Liens externes, listes à puces/numérotées, case à cocher (`- [ ]`), indenter / désindenter
- [x] Personnalisation (clé à molette) : ajouter / supprimer / réordonner les commandes
- [x] i18n FR/EN + tests frontend (helpers purs) + E2E mobile
---
## ⚪ Backlog — Sécurité, architecture & performance (P0/P1)
### 84. Consolidation & sécurité — revue statique 2026-09-13 (phase 1)
- **Effort :** 6-9 jours | **Impact :** 🔴 | **Zone :** backend + frontend | **Référence :** [ISSUES_TODOLIST.md](./ISSUES_TODOLIST.md) BUG-021 → BUG-034
- **Statut :** 🟢 livré (phase 1) — sanitizer XSS, rate-limit/lockout MFA, isolation vaults, ReDoS, SSRF webhooks, cycle de vie des sessions, politique de mot de passe, verrous `users.json`, audits IP, rate-limit par compte, symlinks, recherche via inverted index, token en cookie HttpOnly. Détail : [archive/COMPLETED_v1-v2.md](./archive/COMPLETED_v1-v2.md) (section #84).
- **Description :** traiter toutes les vulnérabilités critiques et importantes issues de la revue statique : XSS markdown (`escape=False`) et page publique de partage, brute-force MFA, isolation des vaults (`resolve_safe_path`), ReDoS, SSRF webhooks, cycle de vie des sessions, politique de mot de passe, races `users.json`, audits IP, rate-limit partagé, indexation symlinks.
- **Sous-tâches :**
- [x] Assainir le rendu markdown (sanitizer serveur en whitelist) et la page de partage (échappement `title`/frontmatter) — *DOMPurify client non ajouté (défense en profondeur serveur suffisante)*
- [x] Rate-limit + lockout sur les endpoints MFA (`totp/verify`, `recovery`, `webauthn/verify`)
- [x] Corriger `resolve_safe_path` (comparaison de chemin stricte par segment) + test de régression
- [x] Rotation du refresh token, révocation de l'access token au logout, persistance des JTI révoqués
- [x] Valider la politique de mot de passe à la création ; bloquer le SSRF des webhooks et externaliser les secrets
- [x] Verrous sur les mutations `users.json` ; consigner l'adresse IP réelle dans les audits
- [x] Ignorer les symlinks de l'index ; caps CPU/timeout regex (ReDoS)
- [~] Durcir la CSP — *partiel* : directives `object-src`/`base-uri`/`form-action`/`frame-ancestors` ajoutées et token retiré de `sessionStorage` ; migration **nonce** restante (nécessite la conversion des gestionnaires d'événements inline)
### 85. Refonte architecturale — découpage du monolithe & persistance d'état (phase 2)
- **Effort :** 8-12 jours | **Impact :** 🟡 | **Zone :** backend
- **Description :** extraire le monolithe `backend/main.py` (~4 260 lignes) en routers FastAPI par domaine et rendre persistant l'état qui ne l'est pas (index de recherche, JTI révoqués, compteurs de rate-limit) pour préparer le multi-nœuds.
- **Décision 2026-09-26 : prioritaire (axe Dette & sécurité).**
- **Description :** extraire le monolithe `backend/main.py` (~4 827 lignes au 2026-09-26, ~17 % du backend) en routers FastAPI par domaine et rendre persistant l'état qui ne l'est pas (index de recherche, JTI révoqués, compteurs de rate-limit) pour préparer le multi-nœuds. L'état mémoire actuel (index, inverted index, vecteurs sémantiques, `SSEManager`, collab) rend le multi-workers unsafe.
- **Sous-tâches :**
- [ ] Routers par domaine : files, search, share, webhooks, plugins, collab, admin, ai
- [ ] Centraliser le contrat d'outils IA sur `tools/registry.py` (permissions, quotas, redaction)
- [ ] Persister index, JTI révoqués et compteurs de rate-limit (SQLite/Redis)
- [ ] Verrous asyncio autour de l'index global et des stores JSON ; service de partage public (expiration, révocation, quotas)
- [ ] Routers par domaine : files, search, share, webhooks, plugins, collab, admin, ai — `main.py` conservé comme assemblage (< 500 lignes) ; dédupliquer les modèles Pydantic vers `schemas.py`
- [ ] Créer `tools/registry.py` (inexistant) : contrat central des outils IA (permissions, quotas, redaction)
- [ ] Persister index, JTI révoqués et compteurs de rate-limit (SQLite par défaut, Redis en option multi-nœuds ; le rate-limit actuel est in-memory mono-process)
- [ ] Verrous asyncio autour de l'index global et des stores JSON ; auditer les `except Exception` larges (> 100 occurrences) : best-effort (backup/audit) vs masquage d'erreur (erreurs typées 4xx/5xx + test)
- [ ] Extraire le service de partage public (expiration, révocation, quotas)
### 87. Amélioration continue — tests, CI/CD, revues de sécurité (phase 4)
- **Effort :** 3-5 jours | **Impact :** 🟡 | **Zone :** `.gitea/workflows/`, `tests/`
- **Description :** renforcer le pipeline (`.gitea/workflows/ci.yml`, `desktop-build.yml`) pour le rendre bloquant par défaut et accompagner les phases 1 → 3.
- **Décision 2026-09-26 : prioritaire (axe Dette & sécurité).**
- **Description :** renforcer le pipeline (`.gitea/workflows/ci.yml`, `desktop-build.yml`) pour le rendre bloquant par défaut et accompagner les phases 1 → 3. Constat 2026-09-26 : job `security` non bloquant (`bandit`/`pip-audit` en `|| echo`, ni semgrep ni trivy), E2E limité à `chromium-desktop`, 5 suites frontend hors CI.
- **Sous-tâches :**
- [ ] Jobs CI sécurité (bandit/semgrep/trivy, audits pip/npm) + tests E2E XSS (page de partage + lecteur markdown)
- [ ] Tests de concurrence (`users.json`), fuzzing de timing regex, couverture des composants critiques
- [ ] Jobs CI sécurité **bloquants** (bandit/semgrep/trivy, audits pip/npm) + tests E2E XSS (page de partage + lecteur markdown)
- [ ] Tests de concurrence (`users.json`), fuzzing de timing regex, couverture des composants critiques ; intégrer au CI les 5 suites frontend hors CI (`upload`, `pretty`, `media-viewer`, `mfa-settings`, `config-ai-keys`)
- [ ] Finir BUG-034 (migration CSP **nonce**, conversion des handlers inline), `Secure` cookies à `true` par défaut, politique CORS same-origin explicite ; confirmer la rotation de la clé DeepSeek (BUG-006, clé dans l'historique Git)
- [ ] Revue périodique des dépendances ; documentation utilisateur FR/EN synchronisée ; contrôle automatisé de la conformité au DoD
---
@@ -126,6 +96,7 @@
| # | Domaine / fonctionnalité | Version | Détails |
|---|---|---|---|
| 152 | Viewer XLSX — affichage multi-feuilles, édition des cellules, téléchargement | 2.27.0 | [archive](./archive/COMPLETED_v1-v2.md) |
| BUG-047 | Versionnage — source unique `VERSION` + bump SemVer automatique au commit (hooks + tag) | 2.3.0 | [DEVELOPMENT_AND_RELEASES.md](./DEVELOPMENT_AND_RELEASES.md) |
| 90 | Barre d'actions du document — regroupement fonctionnel + spacers | 2.3.0 | [archive](./archive/COMPLETED_v1-v2.md) |
| 89 | Drag & drop complet de fichiers/dossiers & intégration Assistant IA | 2.3.0 | [features/drag-and-drop-ai.md](./features/drag-and-drop-ai.md) |
@@ -189,6 +160,9 @@
| 112 | En-tête allégé & compte en sidebar — version dans le menu Options, retrait utilisateur/déconnexion du header, section compte en bas de la sidebar, pellicule d'images défilable (molette + flèches) | 2.21.0 | [features/header-user-sidebar-112.md](./features/header-user-sidebar-112.md) |
| 113 | Configuration — ordre naturel des sections (Profil 1er, À propos dernier, TOC = page) & avatar utilisateur (import PNG/JPG/WEBP, persistance serveur, cercle sidebar) | 2.22.0 | [features/settings-order-avatar-113.md](./features/settings-order-avatar-113.md) |
| 114 | Configuration — refonte mobile-responsive (modale plein écran 100dvh, sommaire en drawer coulissant, cibles tactiles ≥ 44 px, inputs 16 px anti-zoom, sauvegarde sticky, MFA 1 colonne, purge i18n/HTML) | 2.23.0 | [features/settings-mobile-114.md](./features/settings-mobile-114.md) |
| BUG-078 | Fichiers de code — coloration syntaxique restaurée (feuilles highlight.js basculées sur le mode de thème et non la clé) | 2.25.0 | [features/viewer-toolbar-highlight-avatars.md](./features/viewer-toolbar-highlight-avatars.md) |
| 115 | Viewer — barre d'outils de lecture épinglée au défilement | 2.25.0 | [features/viewer-toolbar-highlight-avatars.md](./features/viewer-toolbar-highlight-avatars.md) |
| 117 | Configuration — avatars prédéfinis dans le profil utilisateur (12 images) | 2.25.0 | [features/viewer-toolbar-highlight-avatars.md](./features/viewer-toolbar-highlight-avatars.md) |
---
@@ -196,16 +170,17 @@
| Priorité | Items | Effort total estimé |
|---|---|---|
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #86, #88–93, #94–100, #102–114, #92 | ~133 jours réalisés |
| 🔵 P2 restant | #77 Desktop : signature de code (non retenue), 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) | ~0,5-1 jour |
| ⚪ P4 restant | #73 Sync (6-8j) | 6-8 jours |
| ⚪ P0/P1 restant | #85, #87 Refonte architecturale, CI/CD (BUG-035 → BUG-040 corrigés, #86 livré) | ~11-17 jours |
| **Total restant** | **6 items + finitions** | **~23-38 jours** |
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #86, #88–93, #94–100, #102–115, #117, #92 | ~133 jours réalisés |
| 🔵 Finitions | #77 Desktop : 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) — signature Windows non retenue (décision 2026-09-26) | ~0,5-1 jour |
| ⚪ P4 reporté | #73 Sync — **reporté (décision 2026-09-26)**, hors chemin critique | 6-8 jours si réactivé |
| ⚪ P0/P1 prioritaire | #85, #87 Refonte architecturale, CI/CD (BUG-035 → BUG-040 corrigés, #86 livré) | ~11-17 jours |
| **Total chemin critique** | **#77 fin + #85 + #87** | **~12-18 jours** |
---
## Notes
- **Décisions 2026-09-26 :** axe prioritaire = dette & sécurité (#85/#87) ; #73 Sync reporté (P4, hors chemin critique) ; desktop livré non signé + doc SmartScreen.
- Les items P3/P4 ne sont pas ordonnés par priorité interne — à raffiner selon les retours utilisateurs.
- L'effort inclut le développement + tests unitaires + intégration CI, mais pas la documentation utilisateur.
- Les items marqués 🟢 (nice-to-have) sont de bons candidats pour des contributions externes.
+15
View File
@@ -404,6 +404,21 @@ Deux compléments au bouton « Ajouter » de l'assistant IA.
---
## #152 — Viewer XLSX : affichage, édition, téléchargement ✅ TERMINÉ
Les fichiers `.xlsx` s'ouvrent dans un dédié : un tableau HTML par feuille (onglets en cas de
multi-feuilles, en-têtes A1, cellules `contenteditable`), bouton **Enregistrer** actif dès la
première modification et téléchargement du fichier d'origine.
| Aspect | Détail |
|---|---|
| Lecture | `backend/xlsx_reader.py` — openpyxl `read_only`, formules affichées comme texte, plafond 500×40 cellules par feuille |
| Écriture | `PUT /api/file/{vault}/xlsx/save` → `services/mutations.edit_xlsx_cells` (backup avant écriture, refs A1 validées, `str`→`int`/`float`, 500 cellules max par requête) |
| Frontend | `renderXlsxViewer` dans `frontend/js/viewer.js` (onglets, cellules sales, Entrée/Échap, collage monoligne) |
| Limite connue | Le round-trip openpyxl conserve valeurs/formules/styles mais perd graphiques, images et tableaux croisés |
---
## Grosses fonctionnalités — fiches dédiées
| # | Feature | Version | Fiche |
@@ -0,0 +1,122 @@
# #115 / BUG-078 / #117 — Barre d'outils épinglée, coloration syntaxique des fichiers de code & avatars prédéfinis
> **Statut :** 🟢 livré (en attente vérification utilisateur)
> **Impact :** 🟡 (#115, BUG-078) · 🟢 (#117)
> **Zone :** frontend (`frontend/js/viewer.js`, `frontend/js/themes.js`,
> `frontend/js/ui.js`, `frontend/js/config.js`, `frontend/index.html`,
> `frontend/style.css`, `frontend/popout.html`, `frontend/locales/fr.json`,
> `frontend/locales/en.json`, `frontend/icons/avatar/*`)
Trois demandes traitées dans la même livraison, toutes côté frontend.
## #115 — Barre d'outils de lecture toujours visible
### Problème
La barre d'actions d'un document (pop-out, bookmark, Editer, Source, Copier, PDF, Export,
Partager…) était rendue **dans** `.file-header`, un conteneur court placé en haut de la
zone de lecture. Or un élément `position: sticky` reste borné par son parent : dès que
`.file-header` sortait de l'écran au défilement d'un document long, la barre disparaissait.
### Correctif
- `frontend/js/viewer.js` et `frontend/popout.html` sortent la barre d'actions de
`.file-header` et la placent dans un nouveau conteneur `.file-toolbar`, **enfant direct
de `.content-area`** (le conteneur de défilement). La barre peut donc se coller au haut
de la zone de lecture pour toute la hauteur du document.
- `frontend/style.css` :
```css
.file-toolbar {
position: sticky;
top: 0;
z-index: 30;
margin: 0 0 16px;
padding: 8px 0;
background: var(--bg-primary);
border-bottom: 1px solid var(--border);
}
```
Le fond est opaque pour qu'aucun texte ne transparaisse dessous.
- `body.reading-mode .file-toolbar { display: none }` : la barre reste masquée en mode
lecture, comme les autres actions.
## BUG-078 — Coloration syntaxique des fichiers de code
### Problème
Les fichiers `.py`, `.sh`, `.ps1`, `.yml`, `.json`… (et les blocs de code Markdown)
s'affichaient en **texte brut**, sans couleurs. Le code était pourtant correctement
généré côté backend (`<pre><code class="language-python">…`) et `safeHighlight()` appelait
bien `hljs.highlightElement()`.
La cause était ailleurs : les deux feuilles de style de highlight.js (`#hljs-theme-dark`
et `#hljs-theme-light`, chargées depuis le CDN dans `index.html`) étaient basculées à
partir de la **clé de thème** persistée (`obsigate-theme` = `defaut-obsigate`, …) :
```js
darkSheet.disabled = theme !== "dark"; // "defaut-obsigate" !== "dark" → true
lightSheet.disabled = theme !== "light"; // "defaut-obsigate" !== "light" → true
```
Les deux feuilles finissaient donc désactivées, privant tous les tokens de leurs couleurs.
Le résultat dépendait de l'ordre de deux initialisations concurrentes — `UI.initTheme()`
(clé de thème) au démarrage et `themes.initThemes()` (mode) via `Sync.init()` — d'où un
comportement **non déterministe** (parfois coloré, le plus souvent non).
### Correctif
- `frontend/js/themes.js` — `applyTheme(themeKey, mode)` bascule désormais les feuilles
highlight.js selon le **mode** :
```js
var isDark = mode === 'dark';
darkSheet.disabled = !isDark;
lightSheet.disabled = isDark;
```
(`sepia` et `high-contrast` réutilisent la palette claire.)
- `frontend/js/ui.js` — `initTheme()` lit le **mode** persisté (`obsigate-theme-mode`) et
`applyTheme()` résout un mode avant de fixer `data-theme` et de basculer les feuilles,
au lieu de comparer la clé de thème à `"dark"`/`"light"`.
Le basculement est ainsi **déterministe** dès le premier rendu et à chaque changement de
mode.
## #117 — Avatars prédéfinis dans le profil
### Ce qui a été livré
- **Galerie de 12 avatars** dans la section `Profil` (`#cfg-profile`), servis depuis
`frontend/icons/avatar/` (`/static/icons/avatar/<fichier>.jpg`) : Chat, chien, elephan,
hibou, koala, lapin, lion, ours, penda, pingouin, raton, tigre.
- Un clic charge l'image, la fait passer par le **même pipeline que l'import** (recadrage
carré central, redimensionnement 256 px, export JPEG 0,85) et l'enregistre via
`PATCH /api/auth/me` — aucune modification backend n'a été nécessaire, la validation
data-URL PNG/JPEG/WebP existante (BUG-113) s'applique telle quelle.
- L'avatar actif est **surligné** ; le choix est mémorisé dans `localStorage`
(`obsigate-avatar-preset`) et purgé dès qu'on importe une photo personnalisée ou qu'on
supprime l'avatar.
- L'import d'une photo et la suppression restent disponibles.
- i18n : nouvelle clé `config.avatar_presets_label` (FR/EN).
## Tests
- `tests/frontend/unit.test.mjs` — `syntax highlight theme` (BUG-078) : `themes.applyTheme`
bascule les feuilles selon le mode et `ui.js` ne compare plus la clé au mode.
- `tests/frontend/toolbar-order.test.mjs` — #115 : `.file-toolbar` dans `viewer.js` et
`popout.html`, règle CSS `position: sticky; top: 0`, masquage en mode lecture.
- `tests/frontend/settings-order-avatar.test.mjs` — #117 : 12 avatars présents dans
`#cfg-profile`, fichiers d'images existants, pipeline `config.js`, règles CSS, clé i18n
FR/EN.
- Vérification Playwright (instance locale, auth désactivée) : coloration déterministe sur
5 chargements successifs d'un `.py` ; `.file-toolbar` dont le `top` ne bouge plus après
défilement (épinglage effectif).
## Limitations
- L'avatar reste stocké en data-URL 256 px dans `data/users.json` (comportement #113
conservé).
- Le mode `high-contrast`/`sepia` utilise le thème highlight.js **clair** (pas de palette
dédiée).
Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

+21
View File
@@ -1660,6 +1660,27 @@
<span class="profile-hint" data-i18n="config.avatar_hint">PNG, JPG ou WEBP — image carrée recadrée et réduite à 256 px.</span>
</div>
</div>
<span class="profile-avatar-label" data-i18n="config.avatar_presets_label">Avatars prédéfinis</span>
<div
class="profile-avatar-presets"
id="profile-avatar-presets"
role="radiogroup"
data-i18n-attr="aria-label:config.avatar_presets_label"
aria-label="Avatars prédéfinis"
>
<button type="button" class="profile-avatar-preset" data-avatar="Chat.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/Chat.jpg" alt="" loading="lazy" /></button>
<button type="button" class="profile-avatar-preset" data-avatar="chien.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/chien.jpg" alt="" loading="lazy" /></button>
<button type="button" class="profile-avatar-preset" data-avatar="elephan.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/elephan.jpg" alt="" loading="lazy" /></button>
<button type="button" class="profile-avatar-preset" data-avatar="hibou.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/hibou.jpg" alt="" loading="lazy" /></button>
<button type="button" class="profile-avatar-preset" data-avatar="koala.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/koala.jpg" alt="" loading="lazy" /></button>
<button type="button" class="profile-avatar-preset" data-avatar="lapin.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/lapin.jpg" alt="" loading="lazy" /></button>
<button type="button" class="profile-avatar-preset" data-avatar="lion.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/lion.jpg" alt="" loading="lazy" /></button>
<button type="button" class="profile-avatar-preset" data-avatar="ours.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/ours.jpg" alt="" loading="lazy" /></button>
<button type="button" class="profile-avatar-preset" data-avatar="penda.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/penda.jpg" alt="" loading="lazy" /></button>
<button type="button" class="profile-avatar-preset" data-avatar="pingouin.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/pingouin.jpg" alt="" loading="lazy" /></button>
<button type="button" class="profile-avatar-preset" data-avatar="raton.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/raton.jpg" alt="" loading="lazy" /></button>
<button type="button" class="profile-avatar-preset" data-avatar="tigre.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/tigre.jpg" alt="" loading="lazy" /></button>
</div>
<p class="profile-avatar-error hidden" id="profile-avatar-error" role="alert"></p>
</div>
<div class="profile-field">
+56
View File
@@ -2534,6 +2534,7 @@ function initProfile() {
var overlayBtn = document.getElementById('profile-avatar-overlay');
var previewBox = document.getElementById('profile-avatar-preview');
var removeBtn = document.getElementById('profile-avatar-remove');
var presetsBox = document.getElementById('profile-avatar-presets');
if (!avatarField || !avatarInput || !chooseBtn) return;
// The avatar only exists on a real account — hide it when auth is off.
@@ -2554,6 +2555,59 @@ function initProfile() {
})
.catch(function () { /* non-bloquant */ });
// ── Preset avatars (#117) ────────────────────────────────────────
var PRESET_STORAGE_KEY = 'obsigate-avatar-preset';
function markActivePreset(name) {
if (!presetsBox) return;
presetsBox.querySelectorAll('.profile-avatar-preset').forEach(function (btn) {
var on = !!name && btn.getAttribute('data-avatar') === name;
btn.classList.toggle('active', on);
btn.setAttribute('aria-checked', on ? 'true' : 'false');
});
}
function clearActivePreset() {
try { localStorage.removeItem(PRESET_STORAGE_KEY); } catch (e) { /* ignore */ }
markActivePreset(null);
}
function rememberActivePreset(name) {
try { localStorage.setItem(PRESET_STORAGE_KEY, name); } catch (e) { /* ignore */ }
markActivePreset(name);
}
var savedPreset = null;
try { savedPreset = localStorage.getItem(PRESET_STORAGE_KEY); } catch (e) { /* ignore */ }
markActivePreset(savedPreset);
if (presetsBox) {
presetsBox.querySelectorAll('.profile-avatar-preset').forEach(function (btn) {
btn.addEventListener('click', async function () {
var preset = btn.getAttribute('data-avatar');
if (!preset) return;
_profileAvatarError(null);
var all = presetsBox.querySelectorAll('.profile-avatar-preset');
all.forEach(function (b) { b.disabled = true; });
try {
// Load the bundled image, then reuse the upload pipeline (center-crop
// + 256 px JPEG) so it stores exactly like an imported picture.
var res = await fetch('/static/icons/avatar/' + encodeURIComponent(preset), { credentials: 'include' });
if (!res.ok) throw new Error('HTTP ' + res.status);
var blob = await res.blob();
var dataUrl = await _resizeAvatarFile(blob);
var user = await _patchProfileAvatar(dataUrl);
AuthManager.updateCachedUser(user);
_renderProfileAvatar(user.avatar || dataUrl);
AuthManager.renderUserSection();
rememberActivePreset(preset);
showToast(t('config.avatar_updated'), 'success');
} catch (err) {
_profileAvatarError('config.avatar_upload_failed');
console.error('Avatar preset failed:', err);
} finally {
all.forEach(function (b) { b.disabled = false; });
}
});
});
}
function openPicker() {
_profileAvatarError(null);
avatarInput.click();
@@ -2587,6 +2641,7 @@ function initProfile() {
AuthManager.updateCachedUser(user);
_renderProfileAvatar(user.avatar || dataUrl);
AuthManager.renderUserSection();
clearActivePreset(); // an imported photo is not a preset
showToast(t('config.avatar_updated'), 'success');
} catch (err) {
_profileAvatarError('config.avatar_upload_failed');
@@ -2605,6 +2660,7 @@ function initProfile() {
AuthManager.updateCachedUser(user);
_renderProfileAvatar(null);
AuthManager.renderUserSection();
clearActivePreset();
showToast(t('config.avatar_removed'), 'success');
} catch (err) {
_profileAvatarError('config.avatar_upload_failed');
+11
View File
@@ -598,6 +598,17 @@ function applyTheme(themeKey, mode) {
// Notify other components of theme change
try {
root.setAttribute('data-theme', mode);
// Syntax highlighting follows the light/dark mode (BUG-078): the theme
// *key* is not a mode, so toggling the sheets by key used to disable both
// and strip every code block of its colours. Sepia/high-contrast reuse the
// light palette.
var darkSheet = document.getElementById('hljs-theme-dark');
var lightSheet = document.getElementById('hljs-theme-light');
if (darkSheet && lightSheet) {
var isDark = mode === 'dark';
darkSheet.disabled = !isDark;
lightSheet.disabled = isDark;
}
document.dispatchEvent(new CustomEvent('themechange', { detail: { theme: themeKey, mode: mode } }));
} catch(e) {}
}
+22 -10
View File
@@ -137,14 +137,26 @@ export const RightSidebarManager = {
// ---------------------------------------------------------------------------
// Theme
// ---------------------------------------------------------------------------
const _THEME_MODES = ["dark", "light", "high-contrast", "sepia"];
export function initTheme() {
const saved = localStorage.getItem("obsigate-theme") || "dark";
applyTheme(saved);
// The theme engine (themes.js) owns the CSS variables and the theme *key*;
// the <html data-theme> attribute must carry the *mode* (dark/light/…). Read
// the persisted mode here so the first paint and the highlight.js stylesheet
// are right before the async theme init runs (BUG-078).
let mode = "dark";
try { mode = localStorage.getItem("obsigate-theme-mode") || "dark"; } catch (e) { /* ignore */ }
applyTheme(mode);
}
export function applyTheme(theme) {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem("obsigate-theme", theme);
let mode = theme;
if (_THEME_MODES.indexOf(mode) === -1) {
// Callers may pass a theme key (legacy): fall back to the persisted mode.
try { mode = localStorage.getItem("obsigate-theme-mode") || "dark"; } catch (e) { mode = "dark"; }
}
const isDark = mode === "dark";
document.documentElement.setAttribute("data-theme", mode);
// Update theme button icon and label
const themeBtn = document.getElementById("theme-toggle");
@@ -152,23 +164,23 @@ export function applyTheme(theme) {
if (themeBtn && themeLabel) {
const icon = themeBtn.querySelector("i");
if (icon) {
icon.setAttribute("data-lucide", theme === "dark" ? "moon" : "sun");
icon.setAttribute("data-lucide", isDark ? "moon" : "sun");
}
themeLabel.textContent = theme === "dark" ? t('theme.dark') : t('theme.light');
themeLabel.textContent = isDark ? t('theme.dark') : t('theme.light');
safeCreateIcons();
}
// Swap highlight.js theme
// Swap highlight.js theme — keyed on the mode, not the theme key (BUG-078).
const darkSheet = document.getElementById("hljs-theme-dark");
const lightSheet = document.getElementById("hljs-theme-light");
if (darkSheet && lightSheet) {
darkSheet.disabled = theme !== "dark";
lightSheet.disabled = theme !== "light";
darkSheet.disabled = !isDark;
lightSheet.disabled = isDark;
}
// Update Mermaid theme for newly rendered diagrams
import('./mermaid-viewer.js').then(function(m) {
m.updateMermaidTheme(theme === 'dark');
m.updateMermaidTheme(isDark);
}).catch(function() {});
}
+116 -1
View File
@@ -995,6 +995,110 @@ export function renderVideoViewer(area, data) {
}
// ── Excel .xlsx — sheet tabs + editable cells ─────────────────────────────
// Cells are contenteditable; edits are collected per sheet and sent to
// PUT /api/file/{vault}/xlsx/save. Formula cells show their text and are
// saved back as formulas (no client-side recalculation — ceiling accepted).
function renderXlsxViewer(area, data) {
const sheets = data.xlsx_sheets || [];
const tabs = sheets.length > 1
? `<div class="xlsx-tabs">${sheets.map((s, i) =>
`<button class="xlsx-tab${i === 0 ? " active" : ""}" data-sheet="${i}">${escapeHtml(s.name)}</button>`
).join("")}</div>`
: "";
const panels = sheets.map((s, i) =>
`<div class="xlsx-panel" data-sheet="${i}"${i === 0 ? "" : ' style="display:none"'}>${s.html}</div>`
).join("");
area.innerHTML = `
<div class="xlsx-viewer">
<div class="xlsx-toolbar">
${tabs}
<span class="xlsx-toolbar-actions">
<button class="btn-action" id="xlsx-save-btn" disabled>${t("common.save")}</button>
<button class="btn-action" id="xlsx-download-btn">
<i data-lucide="download" style="width:14px;height:14px"></i> ${t("viewer.download")}
</button>
</span>
</div>
<div class="xlsx-panels">${panels}</div>
</div>`;
const saveBtn = area.querySelector("#xlsx-save-btn");
const panelEls = [...area.querySelectorAll(".xlsx-panel")];
const dirtyCount = () => area.querySelectorAll("td.xlsx-dirty").length;
const refreshSaveState = () => { saveBtn.disabled = dirtyCount() === 0; };
// Editable cells: Enter blurs, Escape reverts, paste stays single-line.
area.querySelectorAll(".xlsx-table td").forEach((td) => {
td.contentEditable = "true";
td.spellcheck = false;
td.dataset.orig = td.textContent;
td.addEventListener("input", () => {
td.classList.add("xlsx-dirty");
refreshSaveState();
});
td.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); td.blur(); }
if (e.key === "Escape") {
td.textContent = td.dataset.orig;
td.classList.remove("xlsx-dirty");
refreshSaveState();
}
});
td.addEventListener("paste", (e) => {
e.preventDefault();
const text = (e.clipboardData || window.clipboardData).getData("text").replace(/\r?\n/g, " ");
document.execCommand("insertText", false, text);
});
});
area.querySelectorAll(".xlsx-tab").forEach((tab) => {
tab.addEventListener("click", () => {
const idx = tab.dataset.sheet;
area.querySelectorAll(".xlsx-tab").forEach((x) => x.classList.toggle("active", x === tab));
panelEls.forEach((p) => { p.style.display = p.dataset.sheet === idx ? "" : "none"; });
});
});
saveBtn.addEventListener("click", async () => {
// One PUT per sheet (dirty cells can span tabs before a save).
const jobs = panelEls
.map((panel) => {
const cells = {};
panel.querySelectorAll("td.xlsx-dirty").forEach((td) => { cells[td.dataset.cell] = td.textContent; });
return { sheet: sheets[Number(panel.dataset.sheet)].name, cells };
})
.filter((job) => Object.keys(job.cells).length);
if (!jobs.length) return;
saveBtn.disabled = true;
try {
for (const job of jobs) {
await api(`/api/file/${encodeURIComponent(data.vault)}/xlsx/save?path=${encodeURIComponent(data.path)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(job),
});
}
area.querySelectorAll("td.xlsx-dirty").forEach((td) => {
td.classList.remove("xlsx-dirty");
td.dataset.orig = td.textContent;
});
refreshSaveState();
showToast(t("editor.saved"), "success");
} catch (err) {
refreshSaveState();
showToast(`${t("editor.save_error")}: ${err.message || err}`, "error");
}
});
area.querySelector("#xlsx-download-btn").addEventListener("click", () => {
window.open(`/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`, "_blank");
});
safeCreateIcons();
}
export function renderFile(data) {
// #93 — An inline edition session (#editor-container mounted in the content
// area) is destroyed by this very re-render: release it first so the editor
@@ -1058,6 +1162,12 @@ export function renderFile(data) {
return;
}
// Handle Excel .xlsx — editable table view (display / edit / download)
if (data.is_xlsx) {
renderXlsxViewer(area, data);
return;
}
// Handle Excalidraw — render in iframe editor
if (data.is_excalidraw) {
renderExcalidraw(area, data, data.vault, data.path);
@@ -1418,7 +1528,12 @@ export function renderFile(data) {
// Assemble
area.innerHTML = "";
area.appendChild(breadcrumb);
area.appendChild(el("div", { class: "file-header" }, [el("div", { class: "file-title" }, [document.createTextNode(data.title)]), tagsDiv, el("div", { class: "file-actions" }, fileActions)]));
area.appendChild(el("div", { class: "file-header" }, [el("div", { class: "file-title" }, [document.createTextNode(data.title)]), tagsDiv]));
// #115 — the action bar is a direct child of the scroll container (wrapped in
// `.file-toolbar`) so it can stay pinned while long documents scroll; nested
// inside `.file-header` it would stop sticking as soon as the header left the
// viewport.
area.appendChild(el("div", { class: "file-toolbar" }, [el("div", { class: "file-actions" }, fileActions)]));
if (fmSection) area.appendChild(fmSection);
area.appendChild(mdDiv);
area.appendChild(rawDiv);
+1
View File
@@ -386,6 +386,7 @@
"config.key_deleted": "Key deleted:",
"config.avatar_label": "Profile picture",
"config.avatar_hint": "PNG, JPG or WEBP — square image cropped and resized to 256 px. Shown in the sidebar.",
"config.avatar_presets_label": "Or pick a preset avatar",
"config.avatar_choose": "Choose an image",
"config.avatar_remove": "Remove picture",
"config.avatar_updated": "Profile picture updated",
+1
View File
@@ -386,6 +386,7 @@
"config.key_deleted": "Clé supprimée :",
"config.avatar_label": "Photo de profil",
"config.avatar_hint": "PNG, JPG ou WEBP — image carrée recadrée et réduite à 256 px. Apparaît dans la barre latérale.",
"config.avatar_presets_label": "Ou choisissez un avatar prédéfini",
"config.avatar_choose": "Choisir une image",
"config.avatar_remove": "Supprimer la photo",
"config.avatar_updated": "Photo de profil mise à jour",
+7 -2
View File
@@ -38,7 +38,7 @@
}
})();
</script>
<link rel="stylesheet" href="/static/style.css?v=2">
<link rel="stylesheet" href="/static/style.css?v=3">
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" id="hljs-theme-dark">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css" id="hljs-theme-light" disabled>
@@ -871,8 +871,13 @@ const RightSidebarManager = {
group.forEach(function (b) { actionsDiv.appendChild(b); });
});
header.appendChild(actionsDiv);
area.appendChild(header);
// #115 — pinned action bar (direct child of the scroll container) so it
// stays visible while long documents scroll, as in the main viewer.
const toolbar = document.createElement("div");
toolbar.className = "file-toolbar";
toolbar.appendChild(actionsDiv);
area.appendChild(toolbar);
// Frontmatter — Accent Card
if (data.frontmatter && Object.keys(data.frontmatter).length > 0) {
+111 -1
View File
@@ -1767,7 +1767,22 @@ select {
/* File header */
.file-header {
margin-bottom: 20px;
margin-bottom: 8px;
}
/* #115 — sticky document action bar: stays visible while long content scrolls.
A direct child of `.content-area` (the scroll container), opaque so the text
scrolling underneath never bleeds through. */
.file-toolbar {
position: sticky;
top: 0;
z-index: 30;
margin: 0 0 16px;
padding: 8px 0;
background: var(--bg-primary);
border-bottom: 1px solid var(--border);
}
.file-toolbar .file-actions {
margin-top: 0;
}
.file-title {
font-family: "JetBrains Mono", monospace;
@@ -2670,6 +2685,32 @@ select {
.profile-avatar-remove-btn { background: var(--danger-bg) !important; color: var(--danger) !important; border-color: var(--danger) !important; }
.profile-avatar-error { font-size: 0.7rem; color: var(--danger); margin-top: 8px; }
.profile-avatar-error.hidden { display: none; }
/* #117 — preset avatar gallery */
.profile-avatar-presets {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 8px;
margin-top: 10px;
max-width: 420px;
}
.profile-avatar-preset {
padding: 0;
margin: 0;
border: 2px solid var(--border);
border-radius: 50%;
overflow: hidden;
cursor: pointer;
background: var(--bg-secondary);
aspect-ratio: 1;
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.1s ease;
}
.profile-avatar-preset img { width: 100%; height: 100%; object-fit: cover; display: block; }
.profile-avatar-preset:hover { border-color: var(--accent); transform: translateY(-1px); }
.profile-avatar-preset.active {
border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent);
}
.profile-avatar-preset:disabled { opacity: 0.6; cursor: progress; }
.config-save-btn {
padding: 6px 18px; font-size: 0.75rem; font-weight: 600;
background: var(--accent); color: #fff;
@@ -4920,6 +4961,11 @@ body.resizing-v {
flex-wrap: wrap;
gap: 12px;
}
/* Preset avatars: 4 per row on narrow screens (44px touch targets). */
#config-modal .profile-avatar-presets {
grid-template-columns: repeat(4, minmax(0, 1fr));
max-width: 100%;
}
#config-modal .profile-form {
max-width: 100%;
}
@@ -10878,6 +10924,69 @@ body.desktop-mode .editor-container {
background: var(--surface);
}
/* ── XLSX Viewer ── */
.xlsx-toolbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
flex-wrap: wrap;
}
.xlsx-toolbar-actions {
margin-left: auto;
display: flex;
gap: 8px;
}
.xlsx-tabs {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.xlsx-tab {
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-secondary);
border-radius: 4px;
padding: 4px 10px;
font-size: 0.8rem;
cursor: pointer;
}
.xlsx-tab.active {
background: var(--accent, #4a90d9);
border-color: var(--accent, #4a90d9);
color: #fff;
}
.xlsx-table th.xlsx-corner,
.xlsx-table th.xlsx-rownum {
background: var(--surface);
color: var(--text-secondary);
font-weight: 400;
text-align: right;
padding: 6px 8px;
border-bottom: 2px solid var(--border);
border-right: 1px solid var(--border-light, var(--border));
position: sticky;
left: 0;
z-index: 1;
}
.xlsx-table th.xlsx-corner {
left: 0;
top: 0;
z-index: 2;
}
.xlsx-table td[contenteditable] {
cursor: text;
min-width: 40px;
white-space: pre-wrap;
}
.xlsx-table td[contenteditable]:focus {
outline: 2px solid var(--accent, #4a90d9);
outline-offset: -2px;
}
.xlsx-table td.xlsx-dirty {
background: rgba(255, 196, 0, 0.18);
}
/* ── JSON Viewer ── */
.json-viewer {
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
@@ -11853,6 +11962,7 @@ body.reading-mode .main-body {
padding-bottom: 0;
}
body.reading-mode .breadcrumb,
body.reading-mode .file-toolbar,
body.reading-mode .file-actions {
display: none;
}
+1 -1
View File
@@ -11,7 +11,7 @@
* cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is
* a separate store. Bumping SW_VERSION invalidates it on every release.
*/
const SW_VERSION = 'v25';
const SW_VERSION = 'v26';
const CODE_CACHE = `obsigate-code-${SW_VERSION}`;
const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`;
const API_CACHE = `obsigate-api-${SW_VERSION}`;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.24.0",
"version": "2.27.1",
"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": {
+36 -1
View File
@@ -16,7 +16,7 @@
*/
import { strict as assert } from "node:assert";
import { readFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -136,11 +136,46 @@ test("style.css — preview circle and sidebar image rules exist", () => {
assert.match(css, /\.sidebar-user-avatar-img\s*\{/, "no .sidebar-user-avatar-img rule");
});
// ── #117: preset avatar gallery ─────────────────────────────────────────────
test("index.html — #cfg-profile exposes 12 bundled preset avatars", () => {
const section = indexHtml.match(/<section[^>]*id="cfg-profile"[\s\S]*?<\/section>/);
assert.ok(section, "#cfg-profile section not found");
const html = section[0];
assert.match(html, /id="profile-avatar-presets"/, "preset gallery container missing");
const buttons = [...html.matchAll(/class="profile-avatar-preset"[^>]*data-avatar="([^"]+)"/g)];
assert.equal(buttons.length, 12, `expected 12 preset avatars, found ${buttons.length}`);
const names = buttons.map((m) => m[1]);
assert.equal(new Set(names).size, 12, "preset avatar names must be unique");
for (const name of names) {
const rel = path.join(ROOT, "frontend", "icons", "avatar", name);
assert.ok(existsSync(rel), `missing avatar file: ${rel}`);
assert.ok(html.includes(`/static/icons/avatar/${name}`),
`#${name} must be served from /static/icons/avatar/`);
}
});
test("config.js — preset avatars reuse the upload pipeline", () => {
assert.match(configJs, /profile-avatar-presets/, "preset container lookup missing");
assert.match(configJs, /\.profile-avatar-preset/, "preset button selector missing");
assert.match(configJs, /\/static\/icons\/avatar\//, "preset image base URL missing");
assert.match(configJs, /_resizeAvatarFile\(blob\)/, "preset must go through the resize pipeline");
assert.match(configJs, /_patchProfileAvatar\(dataUrl\)/, "preset must be persisted via PATCH");
assert.match(configJs, /obsigate-avatar-preset/, "active preset marker missing");
assert.match(configJs, /markActivePreset/, "active preset highlight missing");
});
test("style.css — preset avatar rules exist", () => {
assert.match(css, /\.profile-avatar-presets\s*\{/, "no .profile-avatar-presets rule");
assert.match(css, /\.profile-avatar-preset\s*\{/, "no .profile-avatar-preset rule");
assert.match(css, /\.profile-avatar-preset\.active\s*\{/, "no active preset rule");
});
// ── #113c: i18n + backend ───────────────────────────────────────────────────
test("i18n — avatar keys exist in FR and EN", () => {
const keys = [
"config.avatar_label",
"config.avatar_hint",
"config.avatar_presets_label",
"config.avatar_choose",
"config.avatar_remove",
"config.avatar_updated",
+24
View File
@@ -111,6 +111,30 @@ test("style.css — .action-sep rule exists", () => {
assert.match(css, /\.file-actions \.action-sep\s*\{[^}]*background:[^;]*;/);
});
// ── #115 : barre d'outils collante au défilement ─────────────────────────
test("viewer.js — action bar wrapped in a sticky .file-toolbar", () => {
assert.match(viewer, /class:\s*"file-toolbar"[\s\S]{0,120}file-actions/,
"the document action bar must live in a .file-toolbar wrapper");
});
test("popout.html — action bar wrapped in a .file-toolbar", () => {
assert.match(popout, /className\s*=\s*"file-toolbar"/,
"popout action bar must live in a .file-toolbar wrapper");
});
test("style.css — .file-toolbar is pinned to the top while scrolling", () => {
const m = css.match(/\.file-toolbar\s*\{([\s\S]*?)\}/);
assert.ok(m, ".file-toolbar rule missing");
assert.match(m[1], /position:\s*sticky/, ".file-toolbar must be sticky");
assert.match(m[1], /top:\s*0/, ".file-toolbar must pin to the top");
assert.match(m[1], /background:/, ".file-toolbar needs an opaque background");
});
test("style.css — reading mode hides the sticky toolbar", () => {
assert.match(css, /body\.reading-mode\s+\.file-toolbar\s*[,{]/,
"reading mode must hide .file-toolbar");
});
if (process.exitCode) {
console.error("\nToolbar order tests FAILED");
} else {
+23
View File
@@ -284,6 +284,28 @@ function testSidebarUserSection() {
console.log(' ✓ header trimmed + sidebar account section (#112)');
}
// ── Test syntax highlighting follows the theme mode (BUG-078) ──────────────
function testSyntaxHighlightTheme() {
const themes = readFileSync(join(JS_DIR, 'themes.js'), 'utf-8');
const ui = readFileSync(join(JS_DIR, 'ui.js'), 'utf-8');
// themes.js must swap the highlight.js stylesheets based on the *mode*.
const applyTheme = themes.match(/function applyTheme\(themeKey, mode\)\s*\{([\s\S]*?)\n\}/);
assert.ok(applyTheme, 'themes.js applyTheme not found');
assert.match(applyTheme[1], /hljs-theme-dark/, 'applyTheme must toggle the dark hljs sheet');
assert.match(applyTheme[1], /hljs-theme-light/, 'applyTheme must toggle the light hljs sheet');
assert.match(applyTheme[1], /mode === 'dark'|mode === "dark"/, 'sheet choice must key on the mode');
// ui.js must resolve the persisted *mode*, never treat the theme key as one.
assert.match(ui, /obsigate-theme-mode/, 'ui.js must read the persisted theme mode');
assert.doesNotMatch(
ui,
/darkSheet\.disabled\s*=\s*theme !== "dark"[\s\S]{0,120}lightSheet\.disabled\s*=\s*theme !== "light"/,
'ui.js must not compare the theme key to "dark"/"light" (BUG-078 regression)',
);
console.log(' ✓ syntax highlighting follows the theme mode (BUG-078)');
}
// ── Run all tests ──────────────────────────────────────────────────────────
async function main() {
let passed = 0, failed = 0;
@@ -300,6 +322,7 @@ async function main() {
['ai-fab module exports', testAIFabModuleExports],
['config TOC icons', testConfigTocIcons],
['sidebar user section', testSidebarUserSection],
['syntax highlight theme', testSyntaxHighlightTheme],
];
for (const [name, fn] of tests) {
+33
View File
@@ -460,6 +460,39 @@ class TestConfig:
# Should have some diagnostic info
assert len(data) > 0
def test_diagnostics_concurrent_index_writes(self, client):
"""Regression: word_index mutated while the handler iterates it raised
"dictionary changed size during iteration" -> 500."""
from backend.search import get_inverted_index
inv = get_inverted_index()
original = inv.word_index
class RaceDict(dict):
"""dict whose .values() grows the dict mid-iteration, exactly like
the indexer writing from another thread."""
def values(self):
it = iter(dict.values(self))
first = next(it, _SENTINEL)
if first is _SENTINEL:
return iter(())
self["__injected__"] = {"__injected_doc__": 1}
def gen():
yield first
yield from it # raises RuntimeError: dict changed size
return gen()
_SENTINEL = object()
inv.word_index = RaceDict(original)
try:
resp = client.get("/api/diagnostics")
assert resp.status_code == 200, resp.text
assert "memory_estimate_mb" in resp.json()["inverted_index"]
finally:
inv.word_index = original
original.pop("__injected__", None)
# ═══════════════════════════════════════════════════════════════════
# Dashboard
+152
View File
@@ -0,0 +1,152 @@
"""Display / edit / download for .xlsx files (viewer + PUT xlsx/save)."""
from __future__ import annotations
from pathlib import Path
import pytest
openpyxl = pytest.importorskip("openpyxl")
VAULT = "TestVault"
@pytest.fixture
def xlsx_file(test_vault_dir: str) -> str:
from openpyxl import Workbook
path = Path(test_vault_dir) / "budget.xlsx"
wb = Workbook()
ws = wb.active
ws.title = "Budget"
ws["A1"] = "Poste"
ws["B1"] = 100
ws["A2"] = "Total"
ws["B2"] = "=B1*2"
notes = wb.create_sheet("Notes")
notes["A1"] = "hello"
wb.save(path)
return str(path)
# ── Display ───────────────────────────────────────────────────────────────
class TestXlsxDisplay:
def test_renders_all_sheets(self, client, xlsx_file):
resp = client.get(f"/api/file/{VAULT}", params={"path": "budget.xlsx"})
assert resp.status_code == 200
data = resp.json()
assert data["is_xlsx"] is True
assert data["unsupported"] is False
assert [s["name"] for s in data["xlsx_sheets"]] == ["Budget", "Notes"]
first = data["xlsx_sheets"][0]["html"]
assert "Poste" in first
assert 'data-cell="B1"' in first
assert "=B1*2" in first # formula kept as text (data_only=False)
assert 'data-cell="A1"' in data["xlsx_sheets"][1]["html"]
def test_corrupt_xlsx_returns_500(self, client, test_vault_dir):
bad = Path(test_vault_dir) / "corrupt.xlsx"
bad.write_bytes(b"this is not a zip archive")
resp = client.get(f"/api/file/{VAULT}", params={"path": "corrupt.xlsx"})
assert resp.status_code == 500
# ── Index parity (tree visibility) ────────────────────────────────────────
class TestXlsxIndexing:
def test_xlsx_in_supported_extensions(self):
from backend.indexer import SUPPORTED_EXTENSIONS
assert ".xlsx" in SUPPORTED_EXTENSIONS
def test_xlsx_indexed_metadata_only(self, test_vault_dir, xlsx_file):
from backend.indexer import _index_single_file_sync
info = _index_single_file_sync(VAULT, test_vault_dir, xlsx_file)
assert info is not None
assert info["extension"] == ".xlsx"
assert info["content"] == "" # binary: never read into TF-IDF
assert info["title"] # filename-derived title
# ── Edit ──────────────────────────────────────────────────────────────────
class TestXlsxSave:
def _save(self, client, body, path="budget.xlsx"):
return client.put(
f"/api/file/{VAULT}/xlsx/save",
params={"path": path},
json=body,
)
def test_save_updates_cell_with_number_coercion(self, client, xlsx_file):
resp = self._save(client, {"sheet": "Budget", "cells": {"B1": "250"}})
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
from openpyxl import load_workbook
wb = load_workbook(xlsx_file)
assert wb["Budget"]["B1"].value == 250 # int, not "250"
def test_save_leaves_other_sheets_and_formulas(self, client, xlsx_file):
resp = self._save(client, {"sheet": "Budget", "cells": {"A1": "Titre", "B1": 42}})
assert resp.status_code == 200
from openpyxl import load_workbook
wb = load_workbook(xlsx_file)
assert wb["Budget"]["B2"].value == "=B1*2"
assert wb["Notes"]["A1"].value == "hello"
def test_save_empty_string_clears_cell(self, client, xlsx_file):
resp = self._save(client, {"sheet": "Budget", "cells": {"A1": ""}})
assert resp.status_code == 200
from openpyxl import load_workbook
assert load_workbook(xlsx_file)["Budget"]["A1"].value is None
def test_save_creates_backup(self, client, xlsx_file):
from backend.services.backups import get_backup_dir
resp = self._save(client, {"sheet": "Budget", "cells": {"A1": "backup-me"}})
assert resp.status_code == 200
backup_dir = Path(get_backup_dir(VAULT, "budget.xlsx"))
assert backup_dir.is_dir()
assert list(backup_dir.glob("*.bak"))
def test_unknown_sheet_400(self, client, xlsx_file):
resp = self._save(client, {"sheet": "Nope", "cells": {"A1": "x"}})
assert resp.status_code == 400
def test_invalid_cell_ref_400(self, client, xlsx_file):
resp = self._save(client, {"sheet": "Budget", "cells": {"A-1": "x"}})
assert resp.status_code == 400
def test_wrong_extension_400(self, client, test_vault_dir):
(Path(test_vault_dir) / "note.md").write_text("# hi\n", encoding="utf-8")
resp = self._save(client, {"sheet": "Sheet", "cells": {"A1": "x"}}, path="note.md")
assert resp.status_code == 400
def test_missing_file_404(self, client):
resp = self._save(client, {"sheet": "Budget", "cells": {"A1": "x"}}, path="absent.xlsx")
assert resp.status_code == 404
def test_too_many_cells_400(self, client, xlsx_file):
cells = {f"A{i}": i for i in range(1, 502)}
resp = self._save(client, {"sheet": "Budget", "cells": cells})
assert resp.status_code == 400
def test_nested_value_400(self, client, xlsx_file):
resp = self._save(client, {"sheet": "Budget", "cells": {"A1": {"nested": 1}}})
assert resp.status_code == 400
def test_missing_sheet_field_400(self, client, xlsx_file):
resp = self._save(client, {"cells": {"A1": "x"}})
assert resp.status_code == 400