Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ab402aa73 | ||
|
|
c36c299466 | ||
|
|
8611416670 |
+29
-1
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
|
|||||||
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
> **En cours de développement** : les changements à venir sont listés dans la section
|
> **En cours de développement** : les changements à venir sont listés dans la section
|
||||||
> [Unreleased](#unreleased). La dernière version livrée est **2.25.1**.
|
> [Unreleased](#unreleased). La dernière version livrée est **2.27.1**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -14,6 +14,34 @@ 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
|
## [2.25.1] — 2026-09-24
|
||||||
|
|
||||||
### Corrigé
|
### Corrigé
|
||||||
|
|||||||
+4
-3
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -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
|
- **🖼️ 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
|
- **🎬 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)
|
- **🎨 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
|
- **🎨 Syntax highlight** : Coloration syntaxique des blocs de code
|
||||||
- **🌓 Thème clair/sombre** : Toggle persisté en localStorage
|
- **🌓 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
|
- **📡 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
|
## 📝 Changelog
|
||||||
|
|
||||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.25.1).
|
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.27.1).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Projet : ObsiGate | Version : 2.25.1 | Dernière mise à jour : Septembre 2026*
|
*Projet : ObsiGate | Version : 2.27.1 | Dernière mise à jour : Septembre 2026*
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -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
|
- **🖼️ 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
|
- **🎬 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)
|
- **🎨 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
|
- **🎨 Syntax Highlight** : Syntax highlighting for code blocks
|
||||||
- **🌓 Light/Dark Theme** : Toggle persisted in localStorage
|
- **🌓 Light/Dark Theme** : Toggle persisted in localStorage
|
||||||
- **📡 Real-time Sync** : Automatic file monitoring via watchdog with incremental index updates
|
- **📡 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
|
## 📝 Changelog
|
||||||
|
|
||||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.25.1).
|
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.27.1).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Project: ObsiGate | Version: 2.25.1 | Last updated: September 2026*
|
*Project: ObsiGate | Version: 2.27.1 | Last updated: September 2026*
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ SUPPORTED_EXTENSIONS = {
|
|||||||
".sh", ".bash", ".zsh", ".fish", ".bat", ".cmd", ".ps1",
|
".sh", ".bash", ".zsh", ".fish", ".bat", ".cmd", ".ps1",
|
||||||
".json", ".yaml", ".yml", ".toml", ".xml", ".csv",
|
".json", ".yaml", ".yml", ".toml", ".xml", ".csv",
|
||||||
".cfg", ".ini", ".conf", ".env", ".pdf",
|
".cfg", ".ini", ".conf", ".env", ".pdf",
|
||||||
|
".xlsx",
|
||||||
".html", ".css", ".scss", ".less",
|
".html", ".css", ".scss", ".less",
|
||||||
".java", ".c", ".cpp", ".h", ".hpp", ".cs", ".go", ".rs", ".rb",
|
".java", ".c", ".cpp", ".h", ".hpp", ".cs", ".go", ".rs", ".rb",
|
||||||
".php", ".sql", ".r", ".m", ".swift", ".kt",
|
".php", ".sql", ".r", ".m", ".swift", ".kt",
|
||||||
@@ -559,6 +560,12 @@ def _scan_vault(
|
|||||||
raw = ""
|
raw = ""
|
||||||
title = fpath.stem.replace("-", " ").replace("_", " ")
|
title = fpath.stem.replace("-", " ").replace("_", " ")
|
||||||
content_preview = ""
|
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:
|
else:
|
||||||
raw = fpath.read_text(encoding="utf-8", errors="replace")
|
raw = fpath.read_text(encoding="utf-8", errors="replace")
|
||||||
title = fpath.stem.replace("-", " ").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.
|
# #108 — binary media: metadata only, never read the bytes.
|
||||||
raw = ""
|
raw = ""
|
||||||
content_preview = ""
|
content_preview = ""
|
||||||
|
elif ext == ".xlsx":
|
||||||
|
# #152 — binary workbook: metadata only (parity with _scan_vault).
|
||||||
|
raw = ""
|
||||||
|
content_preview = ""
|
||||||
else:
|
else:
|
||||||
raw = fpath.read_text(encoding="utf-8", errors="replace")
|
raw = fpath.read_text(encoding="utf-8", errors="replace")
|
||||||
content_preview = raw[:200].strip()
|
content_preview = raw[:200].strip()
|
||||||
|
|||||||
@@ -125,6 +125,9 @@ from backend.services.mutations import (
|
|||||||
from backend.services.mutations import (
|
from backend.services.mutations import (
|
||||||
edit_file as service_edit_file,
|
edit_file as service_edit_file,
|
||||||
)
|
)
|
||||||
|
from backend.services.mutations import (
|
||||||
|
edit_xlsx_cells as service_edit_xlsx_cells,
|
||||||
|
)
|
||||||
from backend.services.mutations import (
|
from backend.services.mutations import (
|
||||||
move_path as service_move_path,
|
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)")
|
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")
|
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_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_json: bool | None = Field(default=None, description="True for JSON files")
|
||||||
is_excalidraw: bool | None = Field(default=None, description="True for Excalidraw diagram 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)")
|
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"]}
|
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)
|
@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)):
|
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.
|
"""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}")
|
logger.error(f"PDF read error for {path}: {e}")
|
||||||
raise HTTPException(status_code=500, detail=f"Error reading PDF: {e!s}")
|
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 ===
|
# === Images: return as viewable image ===
|
||||||
if is_image(ext):
|
if is_image(ext):
|
||||||
size = file_path.stat().st_size
|
size = file_path.stat().st_size
|
||||||
|
|||||||
@@ -181,6 +181,10 @@ _ENDPOINT_EXAMPLES: dict[tuple[str, str], dict[str, Any]] = {
|
|||||||
"request": {"path": "notes/Accueil.md", "content": "# Accueil\n\nMis à jour."},
|
"request": {"path": "notes/Accueil.md", "content": "# Accueil\n\nMis à jour."},
|
||||||
"response": {"status": "ok", "vault": "TestVault", "path": "notes/Accueil.md", "size": 26},
|
"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"): {
|
("post", "/api/search/replace"): {
|
||||||
"request": {"query": "Python", "replacement": "Python 3", "vault": "all", "dry_run": True},
|
"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},
|
"response": {"matches": [{"vault": "TestVault", "path": "note1.md", "title": "Python", "match_count": 3}], "total_matches": 3, "dry_run": True},
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -223,6 +224,102 @@ def edit_file(
|
|||||||
return {"success": True, "vault": vault_name, "path": rel_path, "size": len(content)}
|
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(
|
def append_to_file(
|
||||||
vault_name: str,
|
vault_name: str,
|
||||||
path: str,
|
path: str,
|
||||||
|
|||||||
@@ -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()
|
||||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.25.1"
|
version = "2.27.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.25.1"
|
version = "2.27.1"
|
||||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||||
authors = ["Bruno Charest"]
|
authors = ["Bruno Charest"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||||
"productName": "ObsiGate",
|
"productName": "ObsiGate",
|
||||||
"version": "2.25.1",
|
"version": "2.27.1",
|
||||||
"identifier": "com.obsigate.desktop",
|
"identifier": "com.obsigate.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../frontend",
|
"frontendDist": "../frontend",
|
||||||
|
|||||||
+22
-50
@@ -1,6 +1,6 @@
|
|||||||
# ObsiGate — Roadmap
|
# ObsiGate — Roadmap
|
||||||
|
|
||||||
> **Version :** 2.25.1 | **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
|
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||||
> vers les fonctionnalités livrées.
|
> vers les fonctionnalités livrées.
|
||||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
- **Reste à faire :**
|
- **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] **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`
|
- [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)
|
- [ ] 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
|
### 73. Synchronisation multi-appareils — Obsidian Sync compatible
|
||||||
|
|
||||||
- **Effort :** 6-8 jours | **Impact :** 🟢
|
- **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.
|
- **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 :**
|
- **Sous-tâches :**
|
||||||
- [ ] Protocole : évaluation CRDT vs OT vs diff/patch pour fichiers markdown
|
- [ ] 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)
|
## ⚪ 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)
|
### 85. Refonte architecturale — découpage du monolithe & persistance d'état (phase 2)
|
||||||
|
|
||||||
- **Effort :** 8-12 jours | **Impact :** 🟡 | **Zone :** backend
|
- **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 :**
|
- **Sous-tâches :**
|
||||||
- [ ] Routers par domaine : files, search, share, webhooks, plugins, collab, admin, ai
|
- [ ] 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`
|
||||||
- [ ] Centraliser le contrat d'outils IA sur `tools/registry.py` (permissions, quotas, redaction)
|
- [ ] 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/Redis)
|
- [ ] 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 ; service de partage public (expiration, révocation, quotas)
|
- [ ] 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)
|
### 87. Amélioration continue — tests, CI/CD, revues de sécurité (phase 4)
|
||||||
|
|
||||||
- **Effort :** 3-5 jours | **Impact :** 🟡 | **Zone :** `.gitea/workflows/`, `tests/`
|
- **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 :**
|
- **Sous-tâches :**
|
||||||
- [ ] Jobs CI sécurité (bandit/semgrep/trivy, audits pip/npm) + tests E2E XSS (page de partage + lecteur markdown)
|
- [ ] 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
|
- [ ] 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
|
- [ ] 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 |
|
| # | 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
||||||
@@ -199,16 +170,17 @@
|
|||||||
|
|
||||||
| Priorité | Items | Effort total estimé |
|
| 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 |
|
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #86, #88–93, #94–100, #102–115, #117, #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 |
|
| 🔵 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 restant | #73 Sync (6-8j) | 6-8 jours |
|
| ⚪ P4 reporté | #73 Sync — **reporté (décision 2026-09-26)**, hors chemin critique | 6-8 jours si réactivé |
|
||||||
| ⚪ P0/P1 restant | #85, #87 Refonte architecturale, CI/CD (BUG-035 → BUG-040 corrigés, #86 livré) | ~11-17 jours |
|
| ⚪ P0/P1 prioritaire | #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** |
|
| **Total chemin critique** | **#77 fin + #85 + #87** | **~12-18 jours** |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Notes
|
## 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.
|
- 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.
|
- 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.
|
- Les items marqués 🟢 (nice-to-have) sont de bons candidats pour des contributions externes.
|
||||||
|
|||||||
@@ -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
|
## Grosses fonctionnalités — fiches dédiées
|
||||||
|
|
||||||
| # | Feature | Version | Fiche |
|
| # | Feature | Version | Fiche |
|
||||||
|
|||||||
@@ -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) {
|
export function renderFile(data) {
|
||||||
// #93 — An inline edition session (#editor-container mounted in the content
|
// #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
|
// area) is destroyed by this very re-render: release it first so the editor
|
||||||
@@ -1058,6 +1162,12 @@ export function renderFile(data) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle Excel .xlsx — editable table view (display / edit / download)
|
||||||
|
if (data.is_xlsx) {
|
||||||
|
renderXlsxViewer(area, data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle Excalidraw — render in iframe editor
|
// Handle Excalidraw — render in iframe editor
|
||||||
if (data.is_excalidraw) {
|
if (data.is_excalidraw) {
|
||||||
renderExcalidraw(area, data, data.vault, data.path);
|
renderExcalidraw(area, data, data.vault, data.path);
|
||||||
|
|||||||
@@ -10924,6 +10924,69 @@ body.desktop-mode .editor-container {
|
|||||||
background: var(--surface);
|
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 ── */
|
||||||
.json-viewer {
|
.json-viewer {
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "obsigate",
|
"name": "obsigate",
|
||||||
"version": "2.25.1",
|
"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.",
|
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
|
||||||
"main": "patch.js",
|
"main": "patch.js",
|
||||||
"directories": {
|
"directories": {
|
||||||
|
|||||||
@@ -0,0 +1,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
|
||||||
Reference in New Issue
Block a user