Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2be0b15bcf | ||
|
|
de6bde1613 | ||
|
|
fdc9f47a0d | ||
|
|
49c715199d | ||
|
|
fd192df036 | ||
|
|
8662d23ec8 | ||
|
|
10453d8dfe | ||
|
|
8235d632b8 | ||
|
|
f4c8504c8d | ||
|
|
4fb7c43e06 | ||
|
|
54caa48d1f | ||
|
|
f00a8bea8f | ||
|
|
55a9fcc0f4 | ||
|
|
4cdea956d7 | ||
|
|
ae06436f91 | ||
|
|
d0e10d4cc6 | ||
|
|
c94f065f80 | ||
|
|
36b4962efb | ||
|
|
e01e837a2a | ||
|
|
634ba8a272 | ||
|
|
ce1944d437 | ||
|
|
39bad990c0 | ||
|
|
431d6e9338 | ||
|
|
d453969708 | ||
|
|
778fa65b4c | ||
|
|
69c3817579 | ||
|
|
b89af917b1 | ||
|
|
d79202e698 | ||
|
|
4ce677902a | ||
|
|
fe9f7b49f7 | ||
|
|
649f965c52 | ||
|
|
d6fa8c7bb1 | ||
|
|
d2734dc8ce | ||
|
|
bab272bb5c | ||
|
|
34428ea7b3 | ||
|
|
83e6a951dd | ||
|
|
3d618eb660 | ||
|
|
2add24a9c1 | ||
|
|
a50227849d | ||
|
|
5ffc9d851a | ||
|
|
1e0e419210 | ||
|
|
2813d546a7 | ||
|
|
19e94dfa51 | ||
|
|
49f97fc26e | ||
|
|
562290d922 | ||
|
|
66a5505965 | ||
|
|
c5c225a68e | ||
|
|
1bacfd69d9 | ||
|
|
383ffa6a65 | ||
|
|
69927176df | ||
|
|
5c2ae26a74 |
+27
-9
@@ -38,6 +38,8 @@ jobs:
|
||||
- name: Frontend unit tests
|
||||
run: |
|
||||
node tests/frontend/unit.test.mjs
|
||||
node tests/frontend/navfacets.test.mjs
|
||||
node tests/frontend/desktop-roots.test.mjs
|
||||
node tests/frontend/image-viewer.test.mjs
|
||||
node tests/frontend/pdf-viewer.test.mjs
|
||||
node tests/frontend/forge-completion.test.mjs
|
||||
@@ -58,6 +60,7 @@ jobs:
|
||||
node ai.test.mjs
|
||||
node ai-sidebar.test.mjs
|
||||
node sidebar-filters.test.mjs
|
||||
node search-facets.test.mjs
|
||||
node sw.test.mjs
|
||||
node collab.test.mjs
|
||||
node mobile-editor.test.mjs
|
||||
@@ -69,6 +72,7 @@ jobs:
|
||||
node upload.test.mjs
|
||||
node config-ai-keys.test.mjs
|
||||
node xlsx-viewer.test.mjs
|
||||
node filechat.test.mjs
|
||||
else
|
||||
echo "tests/frontend/node_modules missing - installing jsdom"
|
||||
npm install --no-audit --no-fund --silent
|
||||
@@ -78,6 +82,7 @@ jobs:
|
||||
node ai.test.mjs
|
||||
node ai-sidebar.test.mjs
|
||||
node sidebar-filters.test.mjs
|
||||
node search-facets.test.mjs
|
||||
node sw.test.mjs
|
||||
node collab.test.mjs
|
||||
node mobile-editor.test.mjs
|
||||
@@ -89,6 +94,7 @@ jobs:
|
||||
node upload.test.mjs
|
||||
node config-ai-keys.test.mjs
|
||||
node xlsx-viewer.test.mjs
|
||||
node filechat.test.mjs
|
||||
fi
|
||||
|
||||
# ── Tests ─────────────────────────────────────────────────────────
|
||||
@@ -168,19 +174,31 @@ jobs:
|
||||
# Seule exception documentée : PYSEC-2026-1325 (ecdsa, Minerva) —
|
||||
# aucun correctif upstream ET ObsiGate ne signe/vérifie qu'en HS256
|
||||
# (backend/auth/jwt_handler.py), les chemins ECDSA P-256 ne
|
||||
# s'exécutent jamais. PYSEC-2026-178 (pyjwt) est, lui, corrigé par le
|
||||
# plancher pyjwt>=2.13.0 de backend/requirements.txt (BUG-091).
|
||||
# s'exécutent jamais. Les advisories pyjwt (PYSEC-2026-178 puis
|
||||
# CVE-2026-102274) sont corrigées par le plancher pyjwt>=2.14.0 de
|
||||
# backend/requirements.txt (BUG-091, BUG-095).
|
||||
# PYSEC-2026-3910 / PYSEC-2026-3911 (pypdf, DoS de ressources sur
|
||||
# l'extraction de texte et la lecture d'outlines — donc atteignables
|
||||
# via backend/pdf_reader.py) sont corrigés par le plancher
|
||||
# pypdf>=6.16.1 (BUG-093). Ces planchers doivent rester *au-dessus*
|
||||
# des versions préinstallées dans la toolcache de l'image du runner :
|
||||
# en dessous, pip répond « already satisfied » et n'aligne jamais
|
||||
# (c'est exactement ce qui a fait échouer ce job). Le garde-fou
|
||||
# tests/test_ci_workflow.py::TestDependencySecurityFloors verrouille
|
||||
# ces planchers.
|
||||
# pypdf>=6.16.1 (BUG-093).
|
||||
# CVE-2026-97687 / CVE-2026-97688 / CVE-2026-97689 (urllib3 2.7.0)
|
||||
# corrigés par le plancher urllib3>=2.8.0.
|
||||
# CVE-2026-104874 (multidict 6.7.x) corrigé par le plancher
|
||||
# multidict>=6.9.1 (transitive aiohttp/yarl ; 6.7.x est dans la
|
||||
# toolcache de l'image du runner — même piège « already satisfied »).
|
||||
# CVE-2026-85394 (python-jose ≤3.5.0, forgery HS256 par clé publique
|
||||
# DER passée comme secret HMAC) : AUCUN correctif upstream (projet
|
||||
# sans release depuis 2025). Non atteignable dans ObsiGate :
|
||||
# jwt.decode passe toujours algorithms=["HS256"] et un secret
|
||||
# symétrique serveur (backend/auth/jwt_handler.py,
|
||||
# backend/mcp/confirmations.py) — jamais une clé publique comme clé.
|
||||
# Ces planchers doivent rester *au-dessus* des versions préinstallées
|
||||
# dans la toolcache de l'image du runner : en dessous, pip répond
|
||||
# « already satisfied » et n'aligne jamais (c'est exactement ce qui a
|
||||
# fait échouer ce job). Le garde-fou tests/test_ci_workflow.py::
|
||||
# TestDependencySecurityFloors verrouille ces planchers.
|
||||
# NOTE runner Gitea Act (BUG-083) : aucun `#` dans le `run:`.
|
||||
run: pip-audit --ignore-vuln PYSEC-2026-1325
|
||||
run: pip-audit --ignore-vuln PYSEC-2026-1325 --ignore-vuln CVE-2026-85394
|
||||
|
||||
# ── Docker build ──────────────────────────────────────────────────
|
||||
build:
|
||||
|
||||
@@ -44,6 +44,7 @@ test_dir/video/
|
||||
test_vault/images/
|
||||
test_vault/markdown/
|
||||
test_vault/budget.xlsx
|
||||
test_home/
|
||||
|
||||
# Tauri updater signing keys (private key — never commit)
|
||||
desktop/*.key
|
||||
|
||||
+1125
-1
File diff suppressed because it is too large
Load Diff
@@ -41,6 +41,7 @@ COPY VERSION ./VERSION
|
||||
# Using explicit UID/GID 1000 to match common host user and docker-compose settings
|
||||
RUN groupadd -g 1000 obsigate && useradd -u 1000 -g obsigate -d /app -s /sbin/nologin obsigate \
|
||||
&& mkdir -p /app/data \
|
||||
&& chmod 777 /app/data \
|
||||
&& chown -R obsigate:obsigate /app
|
||||
USER obsigate
|
||||
|
||||
|
||||
+7
-6
@@ -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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -75,7 +75,7 @@ Les **guides d'utilisation** pas à pas se trouvent dans [`docs/GUIDES/`](docs/G
|
||||
- **📱 Éditeur mobile natif** — Édition optimisée pour le tactile : barre d'outils Markdown flottante (gras/italique/code/liste/lien), bouton « Coller » persistant (contournement iOS), zoom par pincement et hauteur ajustable, raccourcis swipe (liens entrants / table des matières) et mode lecture plein écran avec navigation entre fichiers ([détail](docs/features/mobile-editor.md))
|
||||
- **🗺️ Vue graphe interactive** — Canvas force-directed avec Barnes-Hut O(n log n), filtres (tag, type), profondeur, mode focus, historique de navigation ←→↑, export PNG, aperçu au survol (Ctrl+click)
|
||||
- **🗂️ Multi-vault** : Visualisez plusieurs vaults Obsidian simultanément
|
||||
- **🌳 Navigation arborescente** : Parcourez vos dossiers et fichiers dans la sidebar
|
||||
- **🌳 Navigation arborescente** : Parcourez vos dossiers et fichiers dans la sidebar ; chaque clic sur un répertoire de l'arbre ouvre un **onglet de navigation** — chemin, récents, sous-répertoires cliquables, facettes Vaults · Tags · Extensions, tri Pertinence/Date et enregistrement du répertoire — qui coexiste avec vos fichiers ouverts
|
||||
- **🔍 Recherche avancée** : Moteur TF-IDF avec stemming français, normalisation des accents, snippets surlignés, facettes, pagination et tri — plus une **recherche sémantique** optionnelle (embeddings `all-MiniLM-L6-v2`, fusion hybride TF-IDF + RRF) activable via le toggle `~` ([détail](docs/features/semantic-search.md))
|
||||
- **💡 Autocomplétion intelligente** : Suggestions de fichiers, tags et historique avec navigation clavier
|
||||
- **🧩 Syntaxe de requête** : Opérateurs `tag:`, `#`, `vault:`, `title:`, `path:`, `ext:` avec chips visuels
|
||||
@@ -85,7 +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` et `.xlsm` 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, écriture atomique), plus le téléchargement du fichier d'origine. Le visualiseur rend polices, couleurs, cellules fusionnées et volets figés, et offre navigation clavier, barre de formule, tri/filtre/recherche, export CSV, édition de la structure (feuilles, lignes, colonnes) et un tableau de bord du classeur (plages nommées, détection graphiques/TCD, stats par feuille) ; un `.csv` s'édite dans la même grille (RFC 4180) tandis que `.xls` et `.ods` s'ouvrent en lecture seule. Les classeurs contenant des éléments qu'ObsiGate ne peut pas conserver (valeurs calculées, segments, contrôles de formulaire, signature…) affichent un **avertissement** et demandent confirmation avant l'enregistrement ; une saisie commençant par `=` ou `@` est stockée comme texte sauf activation du bouton `f(x)`. L'assistant IA peut lister les feuilles, injecter un tableau borné dans son contexte, modifier des cellules et ajouter des lignes
|
||||
- **📊 Tableurs Excel** : les fichiers `.xlsx` et `.xlsm` 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, écriture atomique), plus le téléchargement du fichier d'origine. Le visualiseur rend polices, couleurs, cellules fusionnées et volets figés, et offre navigation et raccourcis clavier (`Ctrl+S`, `Suppr`, `F2`, `Ctrl+Origine/Fin`, `PgPréc/PgSuiv`, `Ctrl+flèches`), barre de formule avec noms de fonctions, zone Nom éditable (« Atteindre » `A1:B3`), presse-papiers de plage (copier/couper/coller un bloc, depuis ou vers Excel), un menu **Mise en forme** (gras/italique/souligné, alignements, couleurs, formats de nombre, fusions, volets figés, largeur/hauteur — `PUT /api/file/{vault}/xlsx/style`), tri/filtre/recherche sur toutes les feuilles, export CSV/Markdown/HTML et impression (sélection ou feuille), édition de la structure (feuilles, lignes, colonnes) et un tableau de bord du classeur (plages nommées, détection graphiques/TCD, stats par feuille) ; un `.csv` s'édite dans la même grille (RFC 4180) tandis que `.xls` et `.ods` s'ouvrent en lecture seule. Les classeurs contenant des éléments qu'ObsiGate ne peut pas conserver (valeurs calculées, segments, contrôles de formulaire, signature…) affichent un **avertissement** et demandent confirmation avant l'enregistrement ; une saisie commençant par `=` ou `@` est stockée comme texte sauf activation du bouton `f(x)`, et les écritures concurrentes d'un autre poste sont détectées (`If-Match` → « Réessayer »). L'assistant IA peut lister les feuilles, injecter un tableau borné dans son contexte, rechercher dans le classeur, analyser une plage, modifier des cellules et ajouter des lignes — sur `.xlsx`, `.xlsm` et `.csv`. Sur mobile (≤ 768 px), la barre de menus et le ruban sont **repliés par défaut** — un bouton ☰ les déplie — pour que la grille occupe toute la hauteur d'écran
|
||||
- **🎨 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
|
||||
@@ -222,6 +222,7 @@ Les vaults sont configurées par paires `VAULT_N_NAME` / `VAULT_N_PATH` (N = 1,
|
||||
| `VAULT_1_PATH` | Chemin dans le conteneur | `/vaults/Obsidian-RECETTES` |
|
||||
| `VAULT_1_ATTACHMENTS_PATH` | Dossier d'attachements (optionnel) | `06_Boite_a_Outils/6.2_Attachments` |
|
||||
| `VAULT_1_SCAN_ATTACHMENTS` | Scan d'images au démarrage (défaut : true) | `true` |
|
||||
| `OBSIGATE_HOME_ROOT` | Racine des dossiers personnels (un vault `home-<user>` par compte). Absente = fonctionnalité désactivée. | `/vaults/Home` |
|
||||
|
||||
**Règles de nommage :** lettres, chiffres et tirets uniquement ; pas d'espaces ; le nom doit correspondre au chemin dans le conteneur.
|
||||
|
||||
@@ -802,7 +803,7 @@ Configurables via l'interface (Settings) ou l'API `/api/config`.
|
||||
- **Rate limiting** : 10 tentatives de login max par IP sur 15 minutes + lockout par compte (5 tentatives)
|
||||
- **Audit log** : écritures/suppressions/config journalisées dans `data/audit.log` (JSON lines, rotation 10 MB)
|
||||
- **Backup automatique** : chaque modification/suppression sauvegardée dans `.obsigate-backup/` avec timestamp
|
||||
- **Secret redaction** : masquage automatique des JWT, clés API, tokens dans les aperçus
|
||||
- **Secret redaction** : masquage automatique des JWT, mots de passe, clés API (OpenAI, GitHub, Google, AWS, Slack, Stripe…), tokens dans les aperçus — cliquez sur un masque pour copier la valeur
|
||||
- **Utilisateur non-root** : conteneur Docker sous `obsigate` (UID 1000)
|
||||
- **Volumes read-only** : vaults montées en `:ro` par défaut
|
||||
- **Secrets dans `.env`** : jamais dans `docker-compose.yml`
|
||||
@@ -976,8 +977,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.43.1).
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.67.2).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.43.1 | Dernière mise à jour : Septembre 2026*
|
||||
*Projet : ObsiGate | Version : 2.67.2 | 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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -74,7 +74,7 @@ Step-by-step **user guides** live in [`docs/GUIDES/`](docs/GUIDES/):
|
||||
- **📱 Native Mobile Editor** — Touch-optimised editing: floating Markdown toolbar (bold/italic/code/list/link), persistent Paste button (iOS workaround), pinch-zoom font & adjustable height, swipe shortcuts (backlinks / table of contents) and a full-screen reading mode with page navigation ([details](docs/features/mobile-editor.md))
|
||||
- **🗺️ Interactive Graph View** — Canvas force-directed with Barnes-Hut O(n log n), filters (tag, type), depth, focus mode, navigation history ←→↑, export PNG, preview on hover (Ctrl+click)
|
||||
- **🗂️ Multi-vault** : View multiple Obsidian vaults simultaneously
|
||||
- **🌳 Tree Navigation** : Browse your folders and files in the sidebar
|
||||
- **🌳 Tree Navigation** : Browse your folders and files in the sidebar; clicking a folder in the tree opens a dedicated **navigation tab** — path, recents, clickable subfolders, Vaults · Tags · Extensions facets, Pertinence/Date sorting and save-as-search — coexisting with your open files
|
||||
- **🔍 Advanced Search** : TF-IDF search engine with French stemming, accent normalization, highlighted snippets, facets, pagination, and sorting — plus an optional **semantic search** (embeddings via `all-MiniLM-L6-v2`, hybrid TF-IDF + RRF fusion) toggled with `~` ([details](docs/features/semantic-search.md))
|
||||
- **💡 Smart Autocomplete** : Suggestions for files, tags, and history with keyboard navigation
|
||||
- **🧩 Query Syntax** : Operators `tag:`, `#`, `vault:`, `title:`, `path:`, `ext:` with visual chips
|
||||
@@ -84,7 +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` and `.xlsm` 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, atomic write), plus download of the original file. The viewer renders fonts, colors, merged cells and frozen panes, offers keyboard navigation, a formula bar, sort/filter/find, CSV export, sheet & row/column structure editing and a workbook dashboard (named ranges, charts/pivot detection, per-sheet stats); `.csv` is edited in the same grid (RFC 4180) while `.xls` and `.ods` open read-only. Workbooks holding elements ObsiGate cannot preserve (cached values, slicers, form controls, signature…) show a **warning** and ask for confirmation before saving; a value starting with `=` or `@` is stored as text unless the `f(x)` toggle is enabled. The AI assistant can list sheets, dump a bounded table to its context, update cells and append rows
|
||||
- **📊 Excel Spreadsheets** : `.xlsx` and `.xlsm` 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, atomic write), plus download of the original file. The viewer renders fonts, colors, merged cells and frozen panes, offers keyboard navigation and shortcuts (`Ctrl+S`, `Delete`, `F2`, `Ctrl+Home/End`, `PgUp/PgDn`, `Ctrl+arrows`), a formula bar with function suggestions, an editable Name Box ("go to" `A1:B3`), a range clipboard (copy/cut/paste a block, from or to Excel), a **Format** menu (bold/italic/underline, alignments, font & fill colours, number formats, merges, frozen panes, column width/row height — `PUT /api/file/{vault}/xlsx/style`), sort/filter/find across every sheet, CSV/Markdown/HTML export and printing (selection or sheet), sheet & row/column structure editing and a workbook dashboard (named ranges, charts/pivot detection, per-sheet stats); `.csv` is edited in the same grid (RFC 4180) while `.xls` and `.ods` open read-only. Workbooks holding elements ObsiGate cannot preserve (cached values, slicers, form controls, signature…) show a **warning** and ask for confirmation before saving; a value starting with `=` or `@` is stored as text unless the `f(x)` toggle is enabled, and concurrent writes from another process are detected (`If-Match` → "Retry"). The AI assistant can list sheets, dump a bounded table to its context, search the workbook, analyze a range, update cells and append rows — on `.xlsx`, `.xlsm` and `.csv`. On mobile (≤ 768 px), the menu bar and ribbon are **collapsed by default** — a single ☰ button expands them — so the grid gets the full screen height
|
||||
- **🎨 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
|
||||
@@ -238,6 +238,7 @@ Vaults are configured using pairs of `VAULT_N_NAME` / `VAULT_N_PATH` variables (
|
||||
| `VAULT_1_SCAN_ATTACHMENTS` | Enable image scanning on startup (optional, default: true) | `true` |
|
||||
| `VAULT_2_NAME` | Display name of the vault | `IT` |
|
||||
| `VAULT_2_PATH` | Path inside the container | `/vaults/Obsidian_IT` |
|
||||
| `OBSIGATE_HOME_ROOT` | Root folder for per-user home directories (one `home-<user>` vault per account). Unset = feature disabled. | `/vaults/Home` |
|
||||
|
||||
**Naming rules:**
|
||||
- Use only letters, numbers, and hyphens
|
||||
@@ -964,7 +965,7 @@ These parameters are configurable via the interface (Settings) or the `/api/conf
|
||||
- **Rate limiting** : 10 login attempts max per IP over 15 minutes + per-account lockout (5 attempts)
|
||||
- **Audit log** : All writes, deletions, and config changes are logged in `data/audit.log` (JSON lines, 10 MB rotation)
|
||||
- **Automatic backup** : Every file modification or deletion is saved in `.obsigate-backup/` with timestamp
|
||||
- **Secret redaction** : Automatic masking of JWTs, API keys, tokens, and connection strings in previews
|
||||
- **Secret redaction** : Automatic masking of JWTs, passwords, API keys (OpenAI, GitHub, Google, AWS, Slack, Stripe…), tokens and connection strings in previews — click a mask to copy the value
|
||||
- **Non-root user** : The Docker container runs under user `obsigate` (UID 1000)
|
||||
- **Read-only volumes** : Vaults are mounted as `:ro` by default in docker-compose
|
||||
- **Secrets in `.env`** : Passwords and tokens are never in `docker-compose.yml`
|
||||
@@ -1151,8 +1152,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.43.1).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.67.2).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.43.1 | Last updated: September 2026*
|
||||
*Project: ObsiGate | Version: 2.67.2 | Last updated: September 2026*
|
||||
|
||||
@@ -142,6 +142,11 @@ def require_admin(current_user=Depends(require_auth)):
|
||||
return current_user
|
||||
|
||||
|
||||
def is_home_vault(vault_name: str) -> bool:
|
||||
"""Un dossier personnel (#194) : vault « home-<user> »."""
|
||||
return vault_name.startswith("home-")
|
||||
|
||||
|
||||
def check_vault_access(vault_name: str, user: dict) -> bool:
|
||||
"""Check if a user has access to a specific vault.
|
||||
|
||||
@@ -149,8 +154,13 @@ def check_vault_access(vault_name: str, user: dict) -> bool:
|
||||
- vaults == ["*"] → full access (admin default)
|
||||
- vault_name in vaults → access granted
|
||||
- otherwise → denied
|
||||
|
||||
#194 : un dossier personnel n'est **jamais** couvert par ``*`` — sinon
|
||||
un admin (``vaults: ["*"]``) verrait le dossier de chaque utilisateur.
|
||||
"""
|
||||
vaults = user.get("_token_vaults") or user.get("vaults", [])
|
||||
if is_home_vault(vault_name):
|
||||
return vault_name in vaults
|
||||
if "*" in vaults:
|
||||
return True
|
||||
return vault_name in vaults
|
||||
|
||||
+17
-2
@@ -946,9 +946,20 @@ async def create_user_endpoint(
|
||||
user = create_user(
|
||||
req.username, req.password, req.role, req.vaults, req.display_name
|
||||
)
|
||||
return user
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
# #194 : dossier perso — best effort, réparé au démarrage si le disque
|
||||
# (NFS) est indisponible (ensure_user_home journalise l'erreur).
|
||||
from backend.auth.user_store import get_user
|
||||
from backend.user_home import ensure_user_home
|
||||
|
||||
await ensure_user_home(req.username)
|
||||
# La réponse doit refléter l'octroi du vault perso (#194) : create_user a
|
||||
# renvoyé un instantané construit avant l'octroi.
|
||||
fresh = get_user(req.username)
|
||||
if fresh:
|
||||
user["vaults"] = fresh.get("vaults", [])
|
||||
return user
|
||||
|
||||
|
||||
@router.patch("/admin/users/{username}")
|
||||
@@ -975,9 +986,13 @@ async def delete_user_endpoint(
|
||||
raise HTTPException(400, "Impossible de supprimer son propre compte")
|
||||
try:
|
||||
delete_user(username)
|
||||
return {"message": f"Utilisateur '{username}' supprimé"}
|
||||
except ValueError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
# #194 : fermer le vault du dossier perso (le dossier est conservé).
|
||||
from backend.user_home import release_user_home
|
||||
|
||||
await release_user_home(username)
|
||||
return {"message": f"Utilisateur '{username}' supprimé"}
|
||||
|
||||
|
||||
# ── API / MCP tokens (feature #107) ──────────────────────────────────
|
||||
|
||||
+17
-12
@@ -536,18 +536,6 @@ async def api_bookslm_agent(
|
||||
# run no longer pauses on every subsequent mutating call.
|
||||
ctx.confirmed = True
|
||||
|
||||
async def _llm(msgs, tool_schemas):
|
||||
return await chat_completion(
|
||||
msgs,
|
||||
tools=tool_schemas,
|
||||
provider=req.provider,
|
||||
model=req.model,
|
||||
temperature=0.3,
|
||||
# Tool-call arguments can carry a whole file body (e.g. a generated
|
||||
# table): leave more room than the plain-chat default.
|
||||
max_tokens=8192,
|
||||
)
|
||||
|
||||
async def generate_sse():
|
||||
import asyncio
|
||||
|
||||
@@ -561,6 +549,23 @@ async def api_bookslm_agent(
|
||||
yield f"event: error\ndata: {error_data}\n\n"
|
||||
return
|
||||
|
||||
# #187: resolve the provider like /chat does — the agent must use
|
||||
# the same engine the SSE "provider" tag reports (the raw
|
||||
# req.provider could name an unavailable provider and silently
|
||||
# fall back to another one via _get_provider_config).
|
||||
async def _llm(msgs, tool_schemas):
|
||||
return await chat_completion(
|
||||
msgs,
|
||||
tools=tool_schemas,
|
||||
provider=cfg_name,
|
||||
model=req.model,
|
||||
temperature=0.3,
|
||||
# Tool-call arguments can carry a whole file body (e.g. a
|
||||
# generated table): leave more room than the plain-chat
|
||||
# default.
|
||||
max_tokens=8192,
|
||||
)
|
||||
|
||||
# Stream tool events live: each executed step is pushed on the
|
||||
# queue by the loop callback and emitted as soon as it happens,
|
||||
# so the UI can grow its « N steps » block while thinking.
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
# backend/file_chat.py — historique de discussion (#169, #190)
|
||||
"""Chat history persisted under ``data/chats/``.
|
||||
|
||||
One JSON document per ``(vault, path)`` pair, keyed by a SHA-256 of both so
|
||||
the filename never carries user-controlled path separators. Writes are
|
||||
atomic (tmp + move) and the message list is capped at
|
||||
:data:`MAX_MESSAGES` to bound growth.
|
||||
|
||||
#190 adds the **general chat**: the same store addressed with the reserved
|
||||
sentinels (:data:`GLOBAL_VAULT` / :data:`GLOBAL_PATH`), so no second
|
||||
implementation. Messages may carry an ``attachment`` (image/video/url)
|
||||
uploaded under ``data/chat_uploads/``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from backend.render import _render_markdown
|
||||
|
||||
logger = logging.getLogger("obsigate.file_chat")
|
||||
|
||||
CHAT_DIR = Path("data/chats")
|
||||
MAX_MESSAGES = 500 # retention ceiling per file (oldest dropped first)
|
||||
MAX_TEXT = 4000 # characters per message
|
||||
|
||||
# ponytail: global lock over read-modify-write — chat writes are HTTP-only and
|
||||
# serialized anyway, this just makes losing a message to a future thread (or a
|
||||
# watcher hook) impossible; per-file locks if it ever becomes contended.
|
||||
_LOCK = threading.RLock()
|
||||
|
||||
# #190 — general (non file-bound) conversation, stored like any other one.
|
||||
GLOBAL_VAULT = "__global__"
|
||||
GLOBAL_PATH = "general"
|
||||
|
||||
# #191 — private (2 users) conversations reuse the same store: the vault is
|
||||
# the reserved sentinel and the path is the sorted username pair, so the
|
||||
# storage key never depends on who asks.
|
||||
DM_VAULT = "__dm__"
|
||||
|
||||
# #190 — attachments (image/video) live outside the vaults.
|
||||
UPLOAD_DIR = Path("data/chat_uploads")
|
||||
MAX_UPLOAD_BYTES = 25 * 1024 * 1024 # 25 MB per attachment
|
||||
ALLOWED_ATTACH_EXT = {
|
||||
".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg",
|
||||
".mp4", ".webm", ".ogg", ".mov", ".m4v",
|
||||
}
|
||||
|
||||
# #191 — link preview fetch budget
|
||||
PREVIEW_TIMEOUT = 5.0 # seconds
|
||||
PREVIEW_MAX_BYTES = 512 * 1024 # only the head of the page is parsed
|
||||
PREVIEW_IMAGE_MAX = 2 * 1024 * 1024 # ponytail: 2 MB ceiling on a thumbnail
|
||||
|
||||
|
||||
def _chat_file(vault: str, path: str) -> Path:
|
||||
"""Return the chat file for *(vault, path)* (hashed, traversal-proof)."""
|
||||
CHAT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
key = hashlib.sha256(f"{vault}\0{path}".encode()).hexdigest()[:32]
|
||||
return CHAT_DIR / f"{key}.json"
|
||||
|
||||
|
||||
def _read(vault: str, path: str) -> dict[str, Any]:
|
||||
"""Load the raw chat document (empty structure when missing/corrupt)."""
|
||||
file = _chat_file(vault, path)
|
||||
if not file.exists():
|
||||
return {"vault": vault, "path": path, "messages": []}
|
||||
try:
|
||||
doc = json.loads(file.read_text(encoding="utf-8"))
|
||||
if not isinstance(doc.get("messages"), list):
|
||||
raise TypeError("messages is not a list") # caught by the handler below
|
||||
return doc
|
||||
except Exception as e:
|
||||
logger.error("Failed to read chat for %s/%s: %s", vault, path, e)
|
||||
return {"vault": vault, "path": path, "messages": []}
|
||||
|
||||
|
||||
def _write(file: Path, doc: dict[str, Any]) -> None:
|
||||
"""Atomically persist *doc* (tmp file + rename)."""
|
||||
try:
|
||||
tmp = file.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(doc, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
shutil.move(str(tmp), str(file))
|
||||
except Exception as e:
|
||||
logger.error("Failed to write chat %s: %s", file.name, e)
|
||||
|
||||
|
||||
def get_messages(vault: str, path: str) -> list[dict[str, Any]]:
|
||||
"""Return the chat history for *(vault, path)* (chronological).
|
||||
|
||||
Every message carries its rendered ``html`` (#193): same markdown pipeline
|
||||
as a document (mistune + sanitizer), computed on read so a template change
|
||||
applies to the whole history without rewriting the JSON store.
|
||||
"""
|
||||
return [_decorate(m, vault) for m in _read(vault, path).get("messages", [])]
|
||||
|
||||
|
||||
def _decorate(msg: dict[str, Any], vault: str) -> dict[str, Any]:
|
||||
"""Return a copy of *msg* with its sanitized markdown ``html`` (#193).
|
||||
|
||||
The stored message is left untouched (``html`` is never persisted). A
|
||||
rendering failure must never break the chat: the message goes out with an
|
||||
empty ``html`` and the client falls back to plain text.
|
||||
"""
|
||||
try:
|
||||
html = _render_markdown(msg.get("text", ""), vault)
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
logger.warning("chat markdown rendering failed: %s", e)
|
||||
html = ""
|
||||
return {**msg, "html": html}
|
||||
|
||||
|
||||
def add_message(
|
||||
vault: str,
|
||||
path: str,
|
||||
user: str,
|
||||
text: str,
|
||||
attachment: dict[str, Any] | None = None,
|
||||
preview: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Append a message and persist it. Returns the stored message.
|
||||
|
||||
The list is capped at :data:`MAX_MESSAGES` (oldest dropped first).
|
||||
*attachment* (#190) is ``{name, url, mime, kind}`` for image/video/url;
|
||||
*preview* (#191) is the OpenGraph card of the first URL in *text*.
|
||||
"""
|
||||
text = (text or "").strip()[:MAX_TEXT]
|
||||
msg: dict[str, Any] = {
|
||||
"id": uuid.uuid4().hex[:12],
|
||||
"user": user or "anonyme",
|
||||
"text": text,
|
||||
"ts": time.time(),
|
||||
}
|
||||
if attachment:
|
||||
msg["attachment"] = attachment
|
||||
if preview:
|
||||
msg["preview"] = preview
|
||||
return _append(vault, path, msg)
|
||||
|
||||
|
||||
def _append(
|
||||
vault: str,
|
||||
path: str,
|
||||
msg: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Cap, persist and return *msg* (shared by file, global and DM chats).
|
||||
|
||||
The read-modify-write of the whole document happens under ``_LOCK`` so a
|
||||
concurrent writer can never drop a message (same class of bug as
|
||||
BUG-029 on ``users.json``).
|
||||
"""
|
||||
with _LOCK:
|
||||
doc = _read(vault, path)
|
||||
messages = list(doc.get("messages", []))
|
||||
messages.append(msg)
|
||||
if len(messages) > MAX_MESSAGES:
|
||||
messages = messages[-MAX_MESSAGES:]
|
||||
doc["messages"] = messages
|
||||
_write(_chat_file(vault, path), doc)
|
||||
return _decorate(msg, vault) # #193 — le html part avec l'écho SSE
|
||||
|
||||
|
||||
# --- #192 : accusé de réception ---------------------------------------------
|
||||
|
||||
def get_read(vault: str, path: str) -> dict[str, float]:
|
||||
"""Return ``{username: last_read_ts}`` for a conversation (#192)."""
|
||||
return {str(u): float(ts) for u, ts in (_read(vault, path).get("read") or {}).items()}
|
||||
|
||||
|
||||
def mark_read(vault: str, path: str, user: str) -> dict[str, float]:
|
||||
"""Record that *user* has seen the conversation (#192).
|
||||
|
||||
Returns the whole read map so the caller can broadcast it on SSE: a
|
||||
sender learns their messages were received as soon as the recipient
|
||||
displays the conversation.
|
||||
"""
|
||||
if not user:
|
||||
return get_read(vault, path)
|
||||
with _LOCK:
|
||||
doc = _read(vault, path)
|
||||
read = {str(u): float(ts) for u, ts in (doc.get("read") or {}).items()}
|
||||
read[user] = time.time()
|
||||
doc["read"] = read
|
||||
_write(_chat_file(vault, path), doc)
|
||||
return read
|
||||
|
||||
|
||||
# --- #191 : messages privés (2 utilisateurs) -------------------------------
|
||||
|
||||
def dm_path(user_a: str, user_b: str) -> str:
|
||||
"""Storage path for the private conversation between two users.
|
||||
|
||||
The pair is sorted so both participants address the same document.
|
||||
"""
|
||||
return "|".join(sorted([user_a, user_b]))
|
||||
|
||||
|
||||
def get_dm_messages(user_a: str, user_b: str) -> list[dict[str, Any]]:
|
||||
"""Return the private history between two users (chronological)."""
|
||||
return get_messages(DM_VAULT, dm_path(user_a, user_b))
|
||||
|
||||
|
||||
def add_dm_message(
|
||||
user_a: str,
|
||||
user_b: str,
|
||||
author: str,
|
||||
text: str,
|
||||
attachment: dict[str, Any] | None = None,
|
||||
preview: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Append a private message. Returns the stored message."""
|
||||
return add_message(DM_VAULT, dm_path(user_a, user_b), author, text, attachment, preview)
|
||||
|
||||
|
||||
# --- #191 : suppression -----------------------------------------------------
|
||||
|
||||
def delete_message(vault: str, path: str, message_id: str) -> bool:
|
||||
"""Remove one message from a conversation. True when it existed."""
|
||||
with _LOCK:
|
||||
doc = _read(vault, path)
|
||||
messages = list(doc.get("messages", []))
|
||||
kept = [m for m in messages if m.get("id") != message_id]
|
||||
if len(kept) == len(messages):
|
||||
return False
|
||||
doc["messages"] = kept
|
||||
_write(_chat_file(vault, path), doc)
|
||||
return True
|
||||
|
||||
|
||||
# --- #190 : chat général (conversation centrale, hors fichier) -------------
|
||||
|
||||
def get_global_messages() -> list[dict[str, Any]]:
|
||||
"""Return the general-chat history (chronological)."""
|
||||
return get_messages(GLOBAL_VAULT, GLOBAL_PATH)
|
||||
|
||||
|
||||
def add_global_message(
|
||||
user: str,
|
||||
text: str,
|
||||
attachment: dict[str, Any] | None = None,
|
||||
preview: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Append a message to the general chat. Returns the stored message."""
|
||||
return add_message(GLOBAL_VAULT, GLOBAL_PATH, user, text, attachment, preview)
|
||||
|
||||
|
||||
def save_attachment(filename: str, data: bytes) -> dict[str, Any]:
|
||||
"""Persist an uploaded attachment under :data:`UPLOAD_DIR`.
|
||||
|
||||
Returns ``{name, url, mime, kind}``. The stored name is a fresh UUID
|
||||
(never the client name), the extension must be in
|
||||
:data:`ALLOWED_ATTACH_EXT` and the size is capped at
|
||||
:data:`MAX_UPLOAD_BYTES`.
|
||||
|
||||
Raises:
|
||||
ValueError: extension refused, empty file or size exceeded.
|
||||
"""
|
||||
ext = Path(filename or "").suffix.lower()
|
||||
if ext not in ALLOWED_ATTACH_EXT:
|
||||
raise ValueError(f"extension refusée : {ext or '(aucune)'}")
|
||||
if not data:
|
||||
raise ValueError("fichier vide")
|
||||
if len(data) > MAX_UPLOAD_BYTES:
|
||||
raise ValueError(f"fichier trop lourd (max {MAX_UPLOAD_BYTES // (1024 * 1024)} MB)")
|
||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
name = f"{uuid.uuid4().hex}{ext}"
|
||||
(UPLOAD_DIR / name).write_bytes(data)
|
||||
kind = "video" if ext in {".mp4", ".webm", ".ogg", ".mov", ".m4v"} else "image"
|
||||
return {
|
||||
"name": name,
|
||||
"url": f"/api/chat/attachment/{name}",
|
||||
"mime": _MIME_BY_EXT.get(ext, "application/octet-stream"),
|
||||
"kind": kind,
|
||||
}
|
||||
|
||||
|
||||
# --- #191 : link preview ----------------------------------------------------
|
||||
|
||||
_URL_RE = re.compile(r"https?://[^\s<>\"']+")
|
||||
_PREVIEW_CACHE: dict[str, dict[str, Any] | None] = {}
|
||||
PREVIEW_CACHE_MAX = 200
|
||||
|
||||
|
||||
def _og(content: str, prop: str) -> str:
|
||||
"""Extract one OpenGraph/``<title>`` value from an HTML head (regex)."""
|
||||
for pattern in (
|
||||
rf'<meta[^>]+(?:property|name)="{prop}"[^>]+content="([^"]*)"',
|
||||
rf'<meta[^>]+content="([^"]*)"[^>]+(?:property|name)="{prop}"',
|
||||
):
|
||||
m = re.search(pattern, content, re.IGNORECASE)
|
||||
if m:
|
||||
return html.unescape(m.group(1)).strip()[:300]
|
||||
if prop == "og:title":
|
||||
m = re.search(r"<title[^>]*>([^<]*)</title>", content, re.IGNORECASE)
|
||||
if m:
|
||||
return html.unescape(m.group(1)).strip()[:300]
|
||||
return ""
|
||||
|
||||
|
||||
# BUG-109 — content-type → extension (the attachment allow-list decides).
|
||||
_IMG_EXT_BY_MIME = {
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
"image/svg+xml": ".svg",
|
||||
}
|
||||
|
||||
|
||||
def _proxy_image(img_url: str, page_url: str) -> str:
|
||||
"""Download *img_url* into ``chat_uploads`` and return a same-origin URL.
|
||||
|
||||
The response CSP is ``img-src 'self' data: blob:``: a remote ``og:image``
|
||||
would be blocked by the browser (BUG-109). Relative and protocol-relative
|
||||
values are resolved against *page_url* first. Raises ``ValueError`` /
|
||||
``SSRFError`` on any failure — the caller keeps the card and drops only
|
||||
the thumbnail.
|
||||
"""
|
||||
from backend.tools.web import USER_AGENT, _assert_public_http_url
|
||||
|
||||
full = urljoin(page_url, img_url)
|
||||
_assert_public_http_url(full)
|
||||
resp = httpx.get(
|
||||
full,
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
timeout=PREVIEW_TIMEOUT,
|
||||
follow_redirects=True,
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
raise ValueError(f"HTTP {resp.status_code}")
|
||||
data = resp.content
|
||||
if not data or len(data) > PREVIEW_IMAGE_MAX:
|
||||
raise ValueError("image vide ou trop lourde")
|
||||
mime = (resp.headers.get("content-type") or "").split(";")[0].strip().lower()
|
||||
ext = _IMG_EXT_BY_MIME.get(mime) or Path(urlparse(full).path).suffix.lower()
|
||||
# save_attachment(): allow-list d'extensions + nom UUID (jamais le nom distant)
|
||||
return str(save_attachment(f"preview{ext}", data)["url"])
|
||||
|
||||
|
||||
def build_preview(text: str) -> dict[str, Any] | None:
|
||||
"""Fetch OpenGraph metadata for the first URL in *text* (#191).
|
||||
|
||||
SSRF-guarded (reuses the web-tool guard), size/time capped, cached in a
|
||||
bounded dict. Returns ``{url, title, description, image, site}`` or
|
||||
``None`` when there is no URL / the fetch fails (never raises: a dead
|
||||
link must not block the message). ``image`` is a **same-origin**
|
||||
``/api/chat/attachment/...`` URL (BUG-109), empty when the thumbnail
|
||||
could not be fetched.
|
||||
"""
|
||||
m = _URL_RE.search(text or "")
|
||||
if not m:
|
||||
return None
|
||||
url = m.group(0).rstrip(".,;:!?)")
|
||||
if url in _PREVIEW_CACHE:
|
||||
cached = _PREVIEW_CACHE[url]
|
||||
return dict(cached) if cached else None
|
||||
try:
|
||||
from backend.tools.web import USER_AGENT, _assert_public_http_url
|
||||
|
||||
_assert_public_http_url(url)
|
||||
resp = httpx.get(
|
||||
url,
|
||||
headers={"User-Agent": USER_AGENT, "Accept": "text/html,*/*"},
|
||||
timeout=PREVIEW_TIMEOUT,
|
||||
follow_redirects=True,
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
raise ValueError(f"HTTP {resp.status_code}")
|
||||
body = resp.text[:PREVIEW_MAX_BYTES]
|
||||
# BUG-109 : vignette téléchargée côté serveur — une image distante
|
||||
# échouerait à la CSP. Échec isolé = carte sans vignette.
|
||||
image = _og(body, "og:image")
|
||||
try:
|
||||
image = _proxy_image(image, url) if image else ""
|
||||
except Exception as ie:
|
||||
logger.debug("preview image failed for %s: %s", url, ie)
|
||||
image = ""
|
||||
preview = {
|
||||
"url": url,
|
||||
"title": _og(body, "og:title") or _og(body, "og:site_name"),
|
||||
"description": _og(body, "og:description"),
|
||||
"image": image,
|
||||
"site": _og(body, "og:site_name") or (url.split("/")[2] if "/" in url[8:] else url),
|
||||
}
|
||||
if not preview["title"]:
|
||||
raise ValueError("pas de titre")
|
||||
except Exception as e:
|
||||
logger.debug("link preview failed for %s: %s", url, e)
|
||||
preview = None
|
||||
if len(_PREVIEW_CACHE) >= PREVIEW_CACHE_MAX:
|
||||
_PREVIEW_CACHE.pop(next(iter(_PREVIEW_CACHE))) # oldest first (dict order)
|
||||
_PREVIEW_CACHE[url] = preview
|
||||
return dict(preview) if preview else None
|
||||
|
||||
|
||||
def attachment_path(name: str) -> Path | None:
|
||||
"""Resolve an attachment by its stored name (UUID+ext only, no traversal)."""
|
||||
p = Path(name)
|
||||
if p.name != name or p.suffix.lower() not in ALLOWED_ATTACH_EXT:
|
||||
return None
|
||||
file = UPLOAD_DIR / p.name
|
||||
return file if file.exists() else None
|
||||
|
||||
|
||||
# Extension → MIME (literals only; ``mimetypes`` guesses poorly for a few).
|
||||
_MIME_BY_EXT = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".svg": "image/svg+xml",
|
||||
".mp4": "video/mp4",
|
||||
".webm": "video/webm",
|
||||
".ogg": "video/ogg",
|
||||
".mov": "video/quicktime",
|
||||
".m4v": "video/x-m4v",
|
||||
}
|
||||
+68
-1
@@ -37,6 +37,12 @@ _last_full_index_ts: str = ""
|
||||
# Hook for incremental inverted index updates: called as (action, vault, path, file_info)
|
||||
_on_index_change: Callable[..., None] | None = None
|
||||
|
||||
# Registre des vaults ajoutés à la volée (#194) : les env VAULT_N_*/DIR_N_*
|
||||
# ne couvrent que le déploiement, tout ce qui est créé à runtime
|
||||
# (/api/vaults/add, dossiers perso) vivrait uniquement en mémoire sinon et
|
||||
# disparaîtrait au prochain rebuild ou redémarrage.
|
||||
DYNAMIC_VAULTS_FILE = Path("data/vaults.json")
|
||||
|
||||
|
||||
def set_index_change_hook(hook):
|
||||
"""Register a callback for incremental inverted index updates.
|
||||
@@ -134,7 +140,64 @@ def load_vault_config() -> dict[str, dict[str, Any]]:
|
||||
}
|
||||
n += 1
|
||||
|
||||
return vaults
|
||||
# Registre dynamique (#194) : les vaults créés à runtime (dossiers
|
||||
# persos, /api/vaults/add) sont chargés en premier, les env gagnent en
|
||||
# cas de collision de nom (vérité du déploiement).
|
||||
merged = _load_dynamic_vaults()
|
||||
merged.update(vaults)
|
||||
return merged
|
||||
|
||||
|
||||
def _load_dynamic_vaults() -> dict[str, dict[str, Any]]:
|
||||
"""Read vaults registered at runtime from ``data/vaults.json`` (#194)."""
|
||||
if not DYNAMIC_VAULTS_FILE.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(DYNAMIC_VAULTS_FILE.read_text(encoding="utf-8"))
|
||||
vaults = data.get("vaults", {})
|
||||
return vaults if isinstance(vaults, dict) else {}
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.error(f"Failed to read {DYNAMIC_VAULTS_FILE}: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def _save_dynamic_vaults(vaults: dict[str, dict[str, Any]]) -> None:
|
||||
"""Atomic write of the dynamic vault registry (tmp + rename, as users.json)."""
|
||||
try:
|
||||
DYNAMIC_VAULTS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = DYNAMIC_VAULTS_FILE.with_suffix(".tmp")
|
||||
tmp.write_text(
|
||||
json.dumps({"version": 1, "vaults": vaults}, indent=2, default=str),
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(tmp, DYNAMIC_VAULTS_FILE)
|
||||
except OSError as e:
|
||||
# Le registre est un filet : vault_config mémoire reste valable
|
||||
# jusqu'au prochain rebuild, qui se reparera du dossier manquant.
|
||||
logger.error(f"Failed to write {DYNAMIC_VAULTS_FILE}: {e}")
|
||||
|
||||
|
||||
def persist_vault(vault_name: str) -> None:
|
||||
"""Persist *vault_name* so it survives rebuild/restart (#194).
|
||||
|
||||
Idempotent. Env-declared vaults are re-added by :func:`load_vault_config`
|
||||
anyway; persisting them too is harmless (single source after merge).
|
||||
"""
|
||||
cfg = vault_config.get(vault_name)
|
||||
if not cfg:
|
||||
return
|
||||
vaults = _load_dynamic_vaults()
|
||||
vaults[vault_name] = cfg
|
||||
_save_dynamic_vaults(vaults)
|
||||
|
||||
|
||||
def unpersist_vault(vault_name: str) -> None:
|
||||
"""Drop *vault_name* from the dynamic registry (#194). No-op if absent."""
|
||||
vaults = _load_dynamic_vaults()
|
||||
if vault_name not in vaults:
|
||||
return
|
||||
vaults.pop(vault_name, None)
|
||||
_save_dynamic_vaults(vaults)
|
||||
|
||||
|
||||
|
||||
@@ -867,6 +930,10 @@ async def reload_single_vault(vault_name: str) -> dict[str, Any]:
|
||||
|
||||
# Remove old vault data from index structures
|
||||
await remove_vault_from_index(vault_name)
|
||||
# remove_vault_from_index a poppé la config : la remettre, sinon le vault
|
||||
# disparaît de vault_config jusqu'au prochain reload complet (#194 — les
|
||||
# vaults dynamiques n'y reviennent que par data/vaults.json).
|
||||
vault_config[vault_name] = config
|
||||
|
||||
# Re-add the vault with updated configuration
|
||||
import functools
|
||||
|
||||
+57
-1
@@ -167,7 +167,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
"""Add security headers to all HTTP responses."""
|
||||
|
||||
async def dispatch(self, request, call_next):
|
||||
from backend.csp import new_nonce
|
||||
from backend.csp import inject_csp_nonce, new_nonce
|
||||
|
||||
# Nonce CSP frais par réponse (#87 T5b) : injecté dans script-src et
|
||||
# dans le balisage HTML par les routes (backend.csp.inject_csp_nonce).
|
||||
@@ -200,6 +200,30 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
"form-action 'self'; "
|
||||
"frame-ancestors 'self';"
|
||||
)
|
||||
# BUG-106 : /docs et /redoc sont générés par FastAPI, hors de nos
|
||||
# routes qui appellent inject_csp_nonce — leur balisage inline
|
||||
# restait sans nonce et était bloqué par script-src (page blanche).
|
||||
# On injecte ici avec le même helper, seul le HTML est retouché.
|
||||
if request.url.path.startswith(("/docs", "/redoc")) and "text/html" in (
|
||||
response.headers.get("Content-Type") or ""
|
||||
):
|
||||
import gzip
|
||||
|
||||
# La compression (GZipMiddleware) est plus proche de la route :
|
||||
# le corps arrive déjà gunzippé, on recomprime à l'identique.
|
||||
raw = b"".join([chunk async for chunk in response.body_iterator])
|
||||
is_gz = "gzip" in (response.headers.get("Content-Encoding") or "")
|
||||
if is_gz:
|
||||
raw = gzip.decompress(raw)
|
||||
patched = inject_csp_nonce(raw.decode("utf-8", "replace"), nonce).encode("utf-8")
|
||||
if is_gz:
|
||||
patched = gzip.compress(patched)
|
||||
response.headers["Content-Length"] = str(len(patched))
|
||||
|
||||
async def _docs_body():
|
||||
yield patched
|
||||
|
||||
response.body_iterator = _docs_body()
|
||||
# Static assets are NOT content-hashed, so they must revalidate:
|
||||
# ``immutable``/long max-age made Cloudflare and mobile browsers serve
|
||||
# a stale build for a year (the service worker cache compounded it).
|
||||
@@ -340,10 +364,34 @@ async def lifespan(app: FastAPI):
|
||||
else:
|
||||
logger.info("File watcher disabled by configuration.")
|
||||
|
||||
# #194 : dossier personnel par utilisateur — réparation/migration
|
||||
# (mkdir manquant, registre perdu, user créé hors API). Après le
|
||||
# watcher pour que les vaults ajoutés soient surveillés.
|
||||
from backend.user_home import ensure_all_user_homes
|
||||
await ensure_all_user_homes()
|
||||
|
||||
logger.info("Background startup complete.")
|
||||
|
||||
asyncio.create_task(_background_startup())
|
||||
|
||||
async def _scheduler_loop():
|
||||
"""Background tick for scheduled tasks (#170) — every 60 s, best effort."""
|
||||
from backend.scheduler import tick
|
||||
|
||||
await asyncio.sleep(60)
|
||||
while True:
|
||||
try:
|
||||
outcomes = await asyncio.to_thread(tick)
|
||||
if outcomes:
|
||||
logger.info(f"Scheduler tick: {len(outcomes)} task(s) executed")
|
||||
except Exception as e:
|
||||
logger.warning(f"Scheduler tick failed: {e}")
|
||||
await asyncio.sleep(60)
|
||||
|
||||
if os.environ.get("OBSIGATE_SCHEDULER", "1") != "0":
|
||||
asyncio.create_task(_scheduler_loop())
|
||||
logger.info("Scheduler loop started (#170, 60 s tick).")
|
||||
|
||||
logger.info("ObsiGate ready (listening for requests while indexing).")
|
||||
yield
|
||||
|
||||
@@ -466,12 +514,16 @@ from backend.routers.backups import router as backups_router
|
||||
from backend.routers.config import _load_config
|
||||
from backend.routers.config import router as config_router
|
||||
from backend.routers.conflicts import router as conflicts_router
|
||||
from backend.routers.duplicates import router as duplicates_router
|
||||
from backend.routers.file_chat import router as file_chat_router
|
||||
from backend.routers.files_media import router as files_media_router
|
||||
from backend.routers.files_read import router as files_read_router
|
||||
from backend.routers.files_write import router as files_write_router
|
||||
from backend.routers.health import router as health_router
|
||||
from backend.routers.history import router as history_router
|
||||
from backend.routers.notify import router as notify_router
|
||||
from backend.routers.realtime import router as realtime_router
|
||||
from backend.routers.scheduler import router as scheduler_router
|
||||
from backend.routers.search import router as search_router
|
||||
from backend.routers.sharing import router as sharing_router
|
||||
from backend.routers.vaults import router as vaults_router
|
||||
@@ -489,12 +541,16 @@ app.include_router(search_router) # ROADMAP #85 T5 — Search
|
||||
app.include_router(backups_router) # ROADMAP #85 T4 — Backups
|
||||
app.include_router(conflicts_router) # ROADMAP #85 T8 — Conflicts
|
||||
app.include_router(config_router) # ROADMAP #85 T7 — Config
|
||||
app.include_router(file_chat_router) # ROADMAP #169 — Chat par fichier
|
||||
app.include_router(files_read_router) # ROADMAP #85 T6a — Files read
|
||||
app.include_router(files_media_router) # ROADMAP #85 T6c — Media/export
|
||||
app.include_router(files_write_router) # ROADMAP #85 T6b — Files write
|
||||
app.include_router(webhooks_router) # ROADMAP #85 T2 — Webhooks
|
||||
app.include_router(sharing_router) # ROADMAP #85 T3 — Sharing
|
||||
app.include_router(vaults_router) # ROADMAP #85 T8 — Vaults
|
||||
app.include_router(duplicates_router) # ROADMAP #166 — Doublons
|
||||
app.include_router(notify_router) # ROADMAP #168 — Notifications externes
|
||||
app.include_router(scheduler_router) # ROADMAP #170 — Tâches planifiées
|
||||
|
||||
# Admin Dashboard endpoints (system stats, audit logs, backups, stream)
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
"""External notifications — Discord, Telegram, SMTP, generic webhook (#168).
|
||||
|
||||
Configuration is persisted in ``data/notify_channels.json``; secrets live in
|
||||
``data/notify_secrets.json`` (0600) or in ``OBSIGATE_NOTIFY_SECRET_<ID>``
|
||||
environment variables — never in the public config file (same pattern as
|
||||
``backend/webhooks.py``, BUG-026).
|
||||
|
||||
Supported channel types:
|
||||
|
||||
* ``discord`` — Discord webhook URL (``POST {"content": ...}``).
|
||||
* ``telegram`` — Bot API (``POST https://api.telegram.org/bot<token>/sendMessage``).
|
||||
* ``smtp`` — Email via stdlib ``smtplib`` (STARTTLS, auth login).
|
||||
* ``webhook`` — generic JSON ``POST`` (SSRF-safe, same policy as #9).
|
||||
|
||||
Each channel declares ``triggers`` chosen among :data:`VALID_TRIGGERS`.
|
||||
The scheduler (#170) broadcasts on ``schedule_failure``; file-event fan-out
|
||||
stays on the historical ``backend/webhooks.py`` path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import smtplib
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from email.message import EmailMessage
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("obsigate.notify")
|
||||
|
||||
DATA_DIR = Path(os.environ.get("OBSIGATE_DATA_DIR", "data"))
|
||||
CHANNELS_FILE = DATA_DIR / "notify_channels.json"
|
||||
SECRETS_FILE = DATA_DIR / "notify_secrets.json"
|
||||
|
||||
CHANNEL_TYPES = ("discord", "telegram", "smtp", "webhook")
|
||||
VALID_TRIGGERS = ("manual", "schedule_failure", "schedule_success", "duplicate_found")
|
||||
|
||||
_lock = threading.RLock()
|
||||
|
||||
|
||||
# ── Store helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _read_channels() -> list[dict[str, Any]]:
|
||||
if not CHANNELS_FILE.exists():
|
||||
return []
|
||||
try:
|
||||
data = json.loads(CHANNELS_FILE.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, list) else []
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return []
|
||||
|
||||
|
||||
def _write_channels(channels: list[dict[str, Any]]) -> None:
|
||||
CHANNELS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = CHANNELS_FILE.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(channels, indent=2, default=str), encoding="utf-8")
|
||||
tmp.replace(CHANNELS_FILE)
|
||||
|
||||
|
||||
def _read_secrets() -> dict[str, str]:
|
||||
if not SECRETS_FILE.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(SECRETS_FILE.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
|
||||
|
||||
def _write_secrets(secrets: dict[str, str]) -> None:
|
||||
SECRETS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = SECRETS_FILE.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(secrets, indent=2), encoding="utf-8")
|
||||
tmp.replace(SECRETS_FILE)
|
||||
try:
|
||||
SECRETS_FILE.chmod(0o600)
|
||||
except OSError:
|
||||
pass # Windows: pas de permissions Unix
|
||||
|
||||
|
||||
def _secret_key(channel_id: str) -> str:
|
||||
return "OBSIGATE_NOTIFY_SECRET_" + channel_id.replace("-", "_").upper()
|
||||
|
||||
|
||||
def _get_secret(channel_id: str) -> str | None:
|
||||
"""Resolve a channel secret: env > dedicated store > legacy inline config."""
|
||||
env_val = os.environ.get(_secret_key(channel_id))
|
||||
if env_val:
|
||||
return env_val
|
||||
stored = _read_secrets().get(channel_id)
|
||||
if stored:
|
||||
return stored
|
||||
for ch in _read_channels():
|
||||
if ch.get("id") == channel_id:
|
||||
cfg = ch.get("config", {})
|
||||
for key in ("webhook_url", "bot_token", "password"):
|
||||
if cfg.get(key):
|
||||
return str(cfg[key])
|
||||
return None
|
||||
|
||||
|
||||
def _public_view(channel: dict[str, Any]) -> dict[str, Any]:
|
||||
clean = {k: v for k, v in channel.items() if k != "config"}
|
||||
cfg = dict(channel.get("config", {}))
|
||||
for secret_field in ("webhook_url", "bot_token", "password"):
|
||||
if cfg.get(secret_field):
|
||||
cfg[secret_field] = "***"
|
||||
clean["config"] = cfg
|
||||
clean["has_secret"] = bool(_get_secret(channel["id"]))
|
||||
return clean
|
||||
|
||||
|
||||
# ── CRUD ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _validate_config(channel_type: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Validate (sans secret) and normalize a channel config. Raises ValueError."""
|
||||
config = dict(config or {})
|
||||
if channel_type == "discord":
|
||||
url = str(config.get("webhook_url") or config.get("url") or "").strip()
|
||||
if not url.startswith(("https://discord.com/api/webhooks/", "https://discordapp.com/api/webhooks/")):
|
||||
# Laisse passer les URLs de test locales quand le mode privé est ouvert.
|
||||
from backend.webhooks import validate_webhook_url
|
||||
|
||||
validate_webhook_url(url)
|
||||
if "discord" not in url and not os.environ.get("OBSIGATE_WEBHOOK_ALLOW_PRIVATE"):
|
||||
raise ValueError("URL Discord invalide (webhook discord.com attendu)")
|
||||
config["webhook_url"] = url
|
||||
elif channel_type == "telegram":
|
||||
if not str(config.get("chat_id") or "").strip():
|
||||
raise ValueError("chat_id Telegram requis")
|
||||
config["chat_id"] = str(config["chat_id"]).strip()
|
||||
if config.get("bot_token"):
|
||||
config["bot_token"] = str(config["bot_token"]).strip()
|
||||
elif channel_type == "smtp":
|
||||
for field in ("host", "from_addr", "to_addr"):
|
||||
if not str(config.get(field) or "").strip():
|
||||
raise ValueError(f"Champ SMTP requis : {field}")
|
||||
config["port"] = int(config.get("port") or 587)
|
||||
config["use_tls"] = bool(config.get("use_tls", True))
|
||||
config["username"] = str(config.get("username") or "").strip()
|
||||
elif channel_type == "webhook":
|
||||
from backend.webhooks import validate_webhook_url
|
||||
|
||||
url = str(config.get("url") or "").strip()
|
||||
validate_webhook_url(url)
|
||||
config["url"] = url
|
||||
else:
|
||||
raise ValueError(f"Type de canal inconnu : {channel_type}")
|
||||
triggers = [t for t in (config.get("triggers") or ["manual"]) if t in VALID_TRIGGERS]
|
||||
config["triggers"] = triggers or ["manual"]
|
||||
return config
|
||||
|
||||
|
||||
def list_channels() -> list[dict[str, Any]]:
|
||||
"""Return public views of all notification channels."""
|
||||
return [_public_view(ch) for ch in _read_channels()]
|
||||
|
||||
|
||||
def create_channel(name: str, channel_type: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Create a notification channel. Secrets are split into the secret store."""
|
||||
if channel_type not in CHANNEL_TYPES:
|
||||
raise ValueError(f"Type de canal inconnu : {channel_type}")
|
||||
with _lock:
|
||||
channels = _read_channels()
|
||||
channel_id = str(uuid.uuid4())
|
||||
normalized = _validate_config(channel_type, config)
|
||||
secrets = _read_secrets()
|
||||
for field in ("webhook_url", "bot_token", "password"):
|
||||
if normalized.get(field) and len(str(normalized[field])) > 8:
|
||||
secrets[channel_id] = str(normalized[field])
|
||||
normalized[field] = "***" # placeholder : le secret vit dans le store dédié
|
||||
_write_secrets(secrets)
|
||||
channel = {
|
||||
"id": channel_id,
|
||||
"name": (name or channel_type).strip() or channel_type,
|
||||
"type": channel_type,
|
||||
"enabled": True,
|
||||
"config": normalized,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"last_sent_at": None,
|
||||
"last_error": None,
|
||||
}
|
||||
channels.append(channel)
|
||||
_write_channels(channels)
|
||||
logger.info(f"Created notify channel '{name}' ({channel_type})")
|
||||
return _public_view(channel)
|
||||
|
||||
|
||||
def update_channel(channel_id: str, updates: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Update a channel (name/enabled/config). Returns None when unknown."""
|
||||
with _lock:
|
||||
channels = _read_channels()
|
||||
for channel in channels:
|
||||
if channel.get("id") != channel_id:
|
||||
continue
|
||||
if updates.get("name"):
|
||||
channel["name"] = str(updates["name"])
|
||||
if "enabled" in updates:
|
||||
channel["enabled"] = bool(updates["enabled"])
|
||||
if "config" in updates and isinstance(updates["config"], dict):
|
||||
merged = {**channel.get("config", {}), **updates["config"]}
|
||||
normalized = _validate_config(channel["type"], merged)
|
||||
secrets = _read_secrets()
|
||||
for field in ("webhook_url", "bot_token", "password"):
|
||||
if updates["config"].get(field):
|
||||
secrets[channel_id] = str(updates["config"][field])
|
||||
normalized[field] = "***"
|
||||
_write_secrets(secrets)
|
||||
channel["config"] = normalized
|
||||
_write_channels(channels)
|
||||
return _public_view(channel)
|
||||
return None
|
||||
|
||||
|
||||
def delete_channel(channel_id: str) -> bool:
|
||||
"""Delete a channel and its secret. Returns False when unknown."""
|
||||
with _lock:
|
||||
channels = _read_channels()
|
||||
remaining = [c for c in channels if c.get("id") != channel_id]
|
||||
if len(remaining) == len(channels):
|
||||
return False
|
||||
_write_channels(remaining)
|
||||
secrets = _read_secrets()
|
||||
if secrets.pop(channel_id, None) is not None:
|
||||
_write_secrets(secrets)
|
||||
return True
|
||||
|
||||
|
||||
# ── Dispatch ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _send_discord(webhook_url: str, title: str, message: str) -> None:
|
||||
import httpx
|
||||
|
||||
content = f"**{title}**\n{message}"[:2000]
|
||||
resp = httpx.post(webhook_url, json={"content": content}, timeout=10.0)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
def _send_telegram(bot_token: str, chat_id: str, title: str, message: str) -> None:
|
||||
import httpx
|
||||
|
||||
from backend.webhooks import is_safe_target
|
||||
|
||||
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
||||
if not is_safe_target(url):
|
||||
raise RuntimeError("Cible Telegram bloquée par la politique SSRF")
|
||||
text = f"*{title}*\n{message}"[:4000]
|
||||
resp = httpx.post(
|
||||
url,
|
||||
json={"chat_id": chat_id, "text": text, "parse_mode": "Markdown"},
|
||||
timeout=10.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
def _send_smtp(config: dict[str, Any], password: str | None, title: str, message: str) -> None:
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = f"[ObsiGate] {title}"
|
||||
msg["From"] = config["from_addr"]
|
||||
msg["To"] = config["to_addr"]
|
||||
msg.set_content(message)
|
||||
with smtplib.SMTP(str(config["host"]), int(config.get("port", 587)), timeout=10) as client:
|
||||
if config.get("use_tls", True):
|
||||
client.starttls()
|
||||
if config.get("username") and password:
|
||||
client.login(str(config["username"]), password)
|
||||
client.send_message(msg)
|
||||
|
||||
|
||||
def _send_webhook(url: str, title: str, message: str, trigger: str) -> None:
|
||||
import httpx
|
||||
|
||||
from backend.webhooks import is_safe_target
|
||||
|
||||
if not is_safe_target(url):
|
||||
raise RuntimeError("Cible webhook bloquée par la politique SSRF")
|
||||
resp = httpx.post(
|
||||
url,
|
||||
json={
|
||||
"event": trigger,
|
||||
"title": title,
|
||||
"message": message,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"source": "obsigate-notify",
|
||||
},
|
||||
timeout=10.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
def send_via_channel(channel: dict[str, Any], title: str, message: str, trigger: str = "manual") -> None:
|
||||
"""Send a notification through one raw channel record. Raises on failure."""
|
||||
channel_type = channel.get("type")
|
||||
cfg = dict(channel.get("config", {}))
|
||||
secret = _get_secret(channel["id"])
|
||||
if channel_type == "discord":
|
||||
url = secret or cfg.get("webhook_url") or ""
|
||||
if not url or url == "***":
|
||||
raise RuntimeError("URL webhook Discord manquante")
|
||||
_send_discord(url, title, message)
|
||||
elif channel_type == "telegram":
|
||||
token = secret or cfg.get("bot_token") or os.environ.get("OBSIGATE_TELEGRAM_BOT_TOKEN") or ""
|
||||
if not token or token == "***":
|
||||
raise RuntimeError("Token bot Telegram manquant")
|
||||
_send_telegram(token, str(cfg.get("chat_id", "")), title, message)
|
||||
elif channel_type == "smtp":
|
||||
_send_smtp(cfg, secret, title, message)
|
||||
elif channel_type == "webhook":
|
||||
url = str(cfg.get("url") or "").strip()
|
||||
if not url:
|
||||
raise RuntimeError("URL webhook manquante")
|
||||
_send_webhook(url, title, message, trigger)
|
||||
else:
|
||||
raise RuntimeError(f"Type de canal inconnu : {channel_type}")
|
||||
|
||||
|
||||
def broadcast(trigger: str, title: str, message: str) -> list[dict[str, Any]]:
|
||||
"""Send to every enabled channel subscribed to *trigger*. Never raises."""
|
||||
results: list[dict[str, Any]] = []
|
||||
for channel in _read_channels():
|
||||
if not channel.get("enabled", True):
|
||||
continue
|
||||
if trigger not in channel.get("config", {}).get("triggers", ["manual"]):
|
||||
continue
|
||||
try:
|
||||
send_via_channel(channel, title, message, trigger)
|
||||
results.append({"channel_id": channel["id"], "ok": True})
|
||||
_mark_sent(channel["id"], None)
|
||||
except Exception as e:
|
||||
logger.warning(f"Notify channel '{channel.get('name')}' failed: {e}")
|
||||
results.append({"channel_id": channel["id"], "ok": False, "error": str(e)})
|
||||
_mark_sent(channel["id"], str(e))
|
||||
return results
|
||||
|
||||
|
||||
def _mark_sent(channel_id: str, error: str | None) -> None:
|
||||
with _lock:
|
||||
channels = _read_channels()
|
||||
for channel in channels:
|
||||
if channel.get("id") == channel_id:
|
||||
channel["last_sent_at"] = datetime.now(timezone.utc).isoformat()
|
||||
channel["last_error"] = error
|
||||
_write_channels(channels)
|
||||
@@ -41,6 +41,9 @@ TAGS_METADATA: list[dict[str, str]] = [
|
||||
{"name": "Admin", "description": "Admin-only system monitoring: stats, audit log, backup stats and live stream."},
|
||||
{"name": "Plugins", "description": "Install, enable and manage user plugins."},
|
||||
{"name": "Push", "description": "Web Push (VAPID) subscription management and test notifications."},
|
||||
{"name": "Duplicates", "description": "Duplicate-note detection and confirmed merge (#166)."},
|
||||
{"name": "Notify", "description": "External notifications: Discord, Telegram, SMTP and generic webhooks (#168)."},
|
||||
{"name": "Scheduler", "description": "Scheduled automatic tasks reusing the vault mutation services (#170)."},
|
||||
{"name": "Frontend", "description": "Static assets and SPA fallback routes."},
|
||||
]
|
||||
|
||||
@@ -95,6 +98,9 @@ _TAG_RULES: list[tuple[re.Pattern[str], str]] = [
|
||||
(re.compile(r"^/api/shares"), "Sharing"),
|
||||
(re.compile(r"^/s/"), "Sharing"),
|
||||
(re.compile(r"^/api/webhooks"), "Webhooks"),
|
||||
(re.compile(r"^/api/duplicates"), "Duplicates"),
|
||||
(re.compile(r"^/api/notify"), "Notify"),
|
||||
(re.compile(r"^/api/scheduler"), "Scheduler"),
|
||||
(re.compile(r"^/api/conflicts"), "Conflicts"),
|
||||
(re.compile(r"^/api/backups"), "Backups"),
|
||||
(re.compile(r"^/api/file/[^/]+/(backups|diff|restore)"), "Backups"),
|
||||
@@ -148,6 +154,9 @@ _TAG_ALIASES: dict[str, str] = {
|
||||
"export": "Export",
|
||||
"sharing": "Sharing",
|
||||
"webhooks": "Webhooks",
|
||||
"duplicates": "Duplicates",
|
||||
"notify": "Notify",
|
||||
"scheduler": "Scheduler",
|
||||
"conflicts": "Conflicts",
|
||||
"system": "System",
|
||||
"frontend": "Frontend",
|
||||
@@ -185,6 +194,17 @@ _ENDPOINT_EXAMPLES: dict[tuple[str, str], dict[str, Any]] = {
|
||||
"request": {"sheet": "Budget", "cells": {"B1": "250"}, "allow_formula": False, "force": False},
|
||||
"response": {"status": "ok", "vault": "TestVault", "path": "data/budget.xlsx", "size": 1},
|
||||
},
|
||||
("put", "/api/file/{vault_name}/xlsx/style"): {
|
||||
"request": {
|
||||
"ops": [
|
||||
{"op": "cell", "sheet": "Budget", "range": "A1:B1", "style": {"bold": True, "fill_color": "#ffe08a"}},
|
||||
{"op": "col_width", "sheet": "Budget", "col": "A", "width": 24},
|
||||
],
|
||||
"force": False,
|
||||
"if_match": "18f2c0ab-1f4",
|
||||
},
|
||||
"response": {"status": "ok", "vault": "TestVault", "path": "data/budget.xlsx", "size": 2, "revision": "18f2c0ab-1f6"},
|
||||
},
|
||||
# GET : pas d'exemple de requête (un requestBody sur un GET serait un OpenAPI
|
||||
# invalide) — les paramètres sont documentés par leurs Query().
|
||||
("get", "/api/file/{vault_name}/xlsx/sheet"): {
|
||||
|
||||
+4
-4
@@ -13,7 +13,7 @@ from typing import Any
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.auth.middleware import require_auth
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
|
||||
logger = logging.getLogger("obsigate.push")
|
||||
|
||||
@@ -167,9 +167,9 @@ async def subscribe_push(
|
||||
"""Subscribe to push notifications for a vault."""
|
||||
username = current_user.get("username", "unknown")
|
||||
|
||||
# Check if user has access to this vault
|
||||
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
|
||||
if "*" not in user_vaults and request.vault not in user_vaults:
|
||||
# Check if user has access to this vault (#194 : "*" n'inclut pas les
|
||||
# dossiers persos, check_vault_access est la source unique).
|
||||
if not check_vault_access(request.vault, current_user):
|
||||
raise HTTPException(status_code=403, detail="No access to this vault")
|
||||
|
||||
# Check if subscription already exists
|
||||
|
||||
+23
-4
@@ -21,7 +21,7 @@ import mistune
|
||||
|
||||
from backend.image_processor import preprocess_images
|
||||
from backend.indexer import find_file_in_index, get_vault_data
|
||||
from backend.secret_redactor import redact_file_content
|
||||
from backend.secret_redactor import redact_with_placeholders, restore_masks
|
||||
from backend.services.sanitizer import sanitize_html
|
||||
|
||||
|
||||
@@ -166,7 +166,13 @@ def _normalize_line_breaks(text: str) -> str:
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _render_markdown(raw_md: str, vault_name: str, current_file_path: Path | None = None) -> str:
|
||||
def _render_markdown(
|
||||
raw_md: str,
|
||||
vault_name: str,
|
||||
current_file_path: Path | None = None,
|
||||
*,
|
||||
click_to_copy: bool = False,
|
||||
) -> str:
|
||||
"""Render a markdown string to HTML with wikilink and image support.
|
||||
|
||||
Uses the cached singleton mistune renderer for performance.
|
||||
@@ -175,6 +181,10 @@ def _render_markdown(raw_md: str, vault_name: str, current_file_path: Path | Non
|
||||
raw_md: Raw markdown text (frontmatter already stripped).
|
||||
vault_name: Current vault for wikilink resolution context.
|
||||
current_file_path: Absolute path to the current markdown file.
|
||||
click_to_copy: Restore masked secrets as clickable badges carrying
|
||||
the real value (authenticated app preview, feature #188).
|
||||
Public shares and PDF exports keep plain labels: the secret
|
||||
never reaches their HTML.
|
||||
|
||||
Returns:
|
||||
HTML string.
|
||||
@@ -184,8 +194,13 @@ def _render_markdown(raw_md: str, vault_name: str, current_file_path: Path | Non
|
||||
vault_root = Path(vault_data["path"]) if vault_data else None
|
||||
attachments_path = vault_data.get("config", {}).get("attachmentsPath") if vault_data else None
|
||||
|
||||
# Redact secrets before rendering (P0 security)
|
||||
raw_md = redact_file_content(raw_md, str(current_file_path) if current_file_path else "")
|
||||
# Redact secrets before rendering (P0 security). Placeholders survive
|
||||
# the markdown conversion (fenced code blocks included) and are turned
|
||||
# back into visible masks — clickable badges when click_to_copy — right
|
||||
# after the HTML is produced (feature #188).
|
||||
raw_md, secret_entries = redact_with_placeholders(
|
||||
raw_md, str(current_file_path) if current_file_path else ""
|
||||
)
|
||||
|
||||
# Preprocess images first
|
||||
if vault_root:
|
||||
@@ -201,6 +216,10 @@ def _render_markdown(raw_md: str, vault_name: str, current_file_path: Path | Non
|
||||
# renderers HTML renvoient toujours `str` à l'exécution).
|
||||
rendered = cast(str, _markdown_renderer(converted))
|
||||
|
||||
# Restore secret masks (plain labels, or clickable badges carrying the
|
||||
# real value on the authenticated app preview — feature #188).
|
||||
rendered = restore_masks(rendered, secret_entries, click_to_copy=click_to_copy)
|
||||
|
||||
# Add heading IDs for TOC navigation
|
||||
rendered = _add_heading_ids(rendered)
|
||||
|
||||
|
||||
@@ -26,9 +26,10 @@ webauthn==2.6.0
|
||||
psutil>=5.9
|
||||
pywebpush>=2.3.0
|
||||
mcp==1.28.1
|
||||
# Plancher de sécurité (BUG-091) : pyjwt est une dépendance transitive (mcp) ;
|
||||
# 2.12.x est vulnérable (PYSEC-2026-178, fix 2.13.0) et pip-audit bloque sinon.
|
||||
pyjwt[crypto]>=2.13.0
|
||||
# Plancher de sécurité (BUG-091, BUG-095) : pyjwt est une dépendance transitive
|
||||
# (mcp). 2.12.x → PYSEC-2026-178 (fix 2.13.0) ; 2.13.0 → CVE-2026-102274
|
||||
# (fix 2.14.0). pip-audit étant bloquant, on reste au-dessus du dernier correctif.
|
||||
pyjwt[crypto]>=2.14.0
|
||||
sse-starlette==2.1.3
|
||||
openpyxl>=3.1
|
||||
xlrd==2.0.2
|
||||
@@ -36,3 +37,10 @@ odfpy==1.4.1
|
||||
python-docx>=1.1
|
||||
reportlab>=4.0
|
||||
pillow>=10.0
|
||||
# Plancher urllib3 >= 2.8.0 (CVE-2026-97687, CVE-2026-97688, CVE-2026-97689)
|
||||
urllib3>=2.8.0
|
||||
# Plancher de sécurité (CVE-2026-104874, fix 6.9.1) : multidict est transitive
|
||||
# (aiohttp/yarl). 6.7.x est la version pré-installée dans la toolcache de
|
||||
# l'image du runner — sans plancher, pip répond « already satisfied » et
|
||||
# n'aligne jamais (même piège que pypdf, BUG-093).
|
||||
multidict>=6.9.1
|
||||
|
||||
@@ -24,7 +24,7 @@ from pathlib import Path
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
|
||||
from backend.ai import PROVIDERS, _read_ai_keys, get_ai_key
|
||||
from backend.auth.middleware import require_admin, require_auth
|
||||
from backend.auth.middleware import check_vault_access, require_admin, require_auth
|
||||
from backend.indexer import index
|
||||
from backend.media_types import IMAGE_EXTENSIONS
|
||||
from backend.schemas import (
|
||||
@@ -495,14 +495,13 @@ async def api_diagnostics(current_user=Depends(require_admin)):
|
||||
@router.get("/api/dashboard", response_model=DashboardResponse)
|
||||
async def api_dashboard(current_user=Depends(require_auth)):
|
||||
"""Aggregated dashboard statistics across all accessible vaults."""
|
||||
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
|
||||
vault_stats = []
|
||||
total_files = 0
|
||||
total_tags = set()
|
||||
total_size = 0
|
||||
total_images = 0
|
||||
for vname, vdata in index.items():
|
||||
if "*" not in user_vaults and vname not in user_vaults:
|
||||
if not check_vault_access(vname, current_user):
|
||||
continue
|
||||
files = vdata.get("files", [])
|
||||
fc = len(files)
|
||||
|
||||
@@ -31,10 +31,9 @@ router = APIRouter(tags=["conflicts"])
|
||||
@router.get("/api/conflicts", response_model=ConflictsResponse)
|
||||
async def api_conflicts(current_user=Depends(require_auth)):
|
||||
"""List sync-conflict files across accessible vaults."""
|
||||
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
|
||||
all_conflicts = get_conflicts()
|
||||
if "*" not in user_vaults:
|
||||
all_conflicts = [c for c in all_conflicts if c["vault"] in user_vaults]
|
||||
# #194 : filtrage via check_vault_access ("*" n'inclut pas les homes).
|
||||
all_conflicts = [c for c in all_conflicts if check_vault_access(c["vault"], current_user)]
|
||||
return {"conflicts": all_conflicts, "total": len(all_conflicts)}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Duplicate detection & merge endpoints (#166).
|
||||
|
||||
Read endpoints require vault access; the merge endpoint is destructive
|
||||
(backup first in the service layer) and additionally requires the
|
||||
confirmation token pattern used by mutating routes — here enforced by an
|
||||
explicit ``confirm=true`` body flag, mirroring the agent two-step flow.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
from backend.services import duplicates as _duplicates
|
||||
from backend.services.errors import ServiceError
|
||||
|
||||
router = APIRouter(prefix="/api/duplicates", tags=["duplicates"])
|
||||
|
||||
|
||||
class DuplicatePair(BaseModel):
|
||||
"""One candidate duplicate pair."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
file_a: str = Field(description="First file (vault-relative)")
|
||||
file_b: str = Field(description="Second file (vault-relative)")
|
||||
score: float = Field(description="Blended similarity in [0, 1]")
|
||||
|
||||
|
||||
class DuplicatesResponse(BaseModel):
|
||||
"""Response for GET /api/duplicates."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
vault: str = Field(description="Vault name")
|
||||
threshold: float = Field(description="Applied threshold")
|
||||
files_scanned: int = Field(description="Markdown files compared")
|
||||
truncated: bool = Field(description="True when the scan hit the file cap")
|
||||
pairs: list[DuplicatePair] = Field(description="Candidate pairs, best score first")
|
||||
|
||||
|
||||
class MergeResponse(BaseModel):
|
||||
"""Response for POST /api/duplicates/merge."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
strategy: str = Field(description="Applied merge strategy")
|
||||
target: str = Field(description="Surviving note")
|
||||
deleted: str = Field(description="Absorbed note (deleted after merge)")
|
||||
|
||||
|
||||
@router.get("", response_model=DuplicatesResponse)
|
||||
async def api_duplicates_list(
|
||||
vault: str = Query(..., description="Vault name"),
|
||||
threshold: float = Query(0.75, ge=0.3, le=1.0, description="Minimum similarity"),
|
||||
limit: int = Query(20, ge=1, le=200, description="Max pairs"),
|
||||
subdir: str = Query("", description="Directory scope"),
|
||||
current_user: dict[str, Any] = Depends(require_auth),
|
||||
):
|
||||
"""List candidate duplicate notes ordered by descending score."""
|
||||
if not check_vault_access(vault, current_user):
|
||||
raise HTTPException(403, f"No access to vault '{vault}'")
|
||||
try:
|
||||
return _duplicates.find_duplicate_pairs(vault, threshold=threshold, limit=limit, subdir=subdir)
|
||||
except ServiceError as e:
|
||||
raise HTTPException(e.status or 400, e.message) from e
|
||||
|
||||
|
||||
@router.post("/merge", response_model=MergeResponse)
|
||||
async def api_duplicates_merge(
|
||||
body: dict[str, Any] = Body(...),
|
||||
current_user: dict[str, Any] = Depends(require_auth),
|
||||
):
|
||||
"""Merge *source_path* into *target_path* (``confirm: true`` required)."""
|
||||
vault = str(body.get("vault") or "")
|
||||
if not check_vault_access(vault, current_user):
|
||||
raise HTTPException(403, f"No access to vault '{vault}'")
|
||||
if body.get("confirm") is not True:
|
||||
raise HTTPException(400, "Fusion destructive : confirmez avec {confirm: true}")
|
||||
try:
|
||||
return _duplicates.merge_duplicates(
|
||||
vault,
|
||||
str(body.get("source_path") or ""),
|
||||
str(body.get("target_path") or ""),
|
||||
strategy=str(body.get("strategy") or "append"),
|
||||
)
|
||||
except ServiceError as e:
|
||||
raise HTTPException(e.status or 400, e.message) from e
|
||||
@@ -0,0 +1,290 @@
|
||||
# backend/routers/file_chat.py — chat (#169, #190)
|
||||
"""Chat endpoints: history read + message post with SSE fan-out.
|
||||
|
||||
- ``GET/POST /api/file/{vault_name}/chat`` — per-file chat: auth + vault
|
||||
access + path traversal check (``resolve_safe_path`` raises
|
||||
``ServiceError`` mapped by the app-level handler).
|
||||
- ``GET/POST /api/chat`` — the **general chat** (#190), not bound to a file.
|
||||
- ``POST /api/chat/upload`` / ``GET /api/chat/attachment/{name}`` (#190):
|
||||
image/video attachments (extension allow-list, size cap, UUID name).
|
||||
|
||||
Every post is broadcast on the existing SSE channel (``chat_message``) so
|
||||
all connected clients update live without a second WebSocket.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, File, HTTPException, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from backend import file_chat as _store
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
from backend.auth.user_store import get_all_users, get_user
|
||||
from backend.indexer import get_vault_data
|
||||
from backend.schemas import ChatHistoryResponse, ChatMessageResponse, ChatReadResponse, StatusResponse
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.sse import sse_manager
|
||||
|
||||
router = APIRouter() # tags dérivés de `tag_for_path` → « Files »
|
||||
|
||||
|
||||
def _check(vault_name: str, path: str, current_user: dict[str, Any]) -> None:
|
||||
"""Authz + traversal guard shared by both verbs."""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(404, "Vault not found")
|
||||
resolve_safe_path(Path(vault_data["path"]), path) # ServiceError → 403/500
|
||||
|
||||
|
||||
@router.get("/api/file/{vault_name}/chat", response_model=ChatHistoryResponse)
|
||||
async def api_file_chat_history(
|
||||
vault_name: str,
|
||||
path: str,
|
||||
current_user: dict[str, Any] = Depends(require_auth),
|
||||
):
|
||||
"""Return the chat history for a file (chronological)."""
|
||||
_check(vault_name, path, current_user)
|
||||
return {"messages": _store.get_messages(vault_name, path), "read": _store.get_read(vault_name, path)}
|
||||
|
||||
|
||||
@router.post("/api/file/{vault_name}/chat", response_model=ChatMessageResponse)
|
||||
async def api_file_chat_post(
|
||||
vault_name: str,
|
||||
body: dict[str, Any] = Body(...),
|
||||
current_user: dict[str, Any] = Depends(require_auth),
|
||||
):
|
||||
"""Post a chat message and broadcast it on SSE (``chat_message``)."""
|
||||
path = str(body.get("path") or "")
|
||||
text = str(body.get("text") or "")
|
||||
if not path:
|
||||
raise HTTPException(400, "path is required")
|
||||
if not text.strip():
|
||||
raise HTTPException(400, "text is required")
|
||||
_check(vault_name, path, current_user)
|
||||
msg = _store.add_message(vault_name, path, current_user.get("username", ""), text)
|
||||
await sse_manager.broadcast("chat_message", {"vault": vault_name, "path": path, "message": msg})
|
||||
return {"message": msg, "status": "ok"}
|
||||
|
||||
|
||||
# --- #190 : chat général ----------------------------------------------------
|
||||
|
||||
def _attachment(body: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Validate the optional ``attachment`` object sent by the client."""
|
||||
raw = body.get("attachment")
|
||||
if not raw or not isinstance(raw, dict):
|
||||
return None
|
||||
name = str(raw.get("name") or "")
|
||||
# Only an already-uploaded file (or an http(s) URL) may travel along.
|
||||
if not _store.attachment_path(name) and not str(raw.get("url", "")).startswith(("http://", "https://", "/api/")):
|
||||
raise HTTPException(400, "attachment inconnu")
|
||||
return {
|
||||
"name": name,
|
||||
"url": str(raw.get("url") or ""),
|
||||
"mime": str(raw.get("mime") or ""),
|
||||
"kind": str(raw.get("kind") or "file"),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/chat", response_model=ChatHistoryResponse)
|
||||
async def api_chat_history(current_user: dict[str, Any] = Depends(require_auth)):
|
||||
"""Return the general chat history (#190, chronological)."""
|
||||
return {
|
||||
"messages": _store.get_global_messages(),
|
||||
"read": _store.get_read(_store.GLOBAL_VAULT, _store.GLOBAL_PATH),
|
||||
}
|
||||
|
||||
|
||||
# --- #192 : accusé de réception ---------------------------------------------
|
||||
|
||||
def _check_read(vault: str, path: str, current_user: dict[str, Any]) -> None:
|
||||
"""Authorization for marking a conversation read (#192).
|
||||
|
||||
General chat is open to any member, a DM only to the two participants,
|
||||
a file chat follows the vault ACL.
|
||||
"""
|
||||
if vault == _store.GLOBAL_VAULT:
|
||||
return
|
||||
if vault == _store.DM_VAULT:
|
||||
if current_user.get("username") not in str(path).split("|"):
|
||||
raise HTTPException(403, "Accès refusé à cette conversation privée")
|
||||
return
|
||||
_check(vault, path, current_user)
|
||||
|
||||
|
||||
@router.post("/api/chat/read", response_model=ChatReadResponse)
|
||||
async def api_chat_read(
|
||||
body: dict[str, Any] = Body(...),
|
||||
current_user: dict[str, Any] = Depends(require_auth),
|
||||
):
|
||||
"""Record that the caller has seen a conversation (#192).
|
||||
|
||||
Broadcast on SSE (``chat_read``) so the sender's own messages flip to
|
||||
✓✓ live on every connected client.
|
||||
"""
|
||||
vault = str(body.get("vault") or "")
|
||||
path = str(body.get("path") or "")
|
||||
if not vault or not path:
|
||||
raise HTTPException(400, "vault and path are required")
|
||||
_check_read(vault, path, current_user)
|
||||
username = current_user.get("username", "")
|
||||
read = _store.mark_read(vault, path, username)
|
||||
await sse_manager.broadcast(
|
||||
"chat_read",
|
||||
{"vault": vault, "path": path, "user": username, "ts": read.get(username, 0.0), "read": read},
|
||||
)
|
||||
return {"read": read, "status": "ok"}
|
||||
|
||||
|
||||
@router.post("/api/chat", response_model=ChatMessageResponse)
|
||||
async def api_chat_post(
|
||||
body: dict[str, Any] = Body(...),
|
||||
current_user: dict[str, Any] = Depends(require_auth),
|
||||
):
|
||||
"""Post to the general chat and broadcast it on SSE (``chat_message``)."""
|
||||
text = str(body.get("text") or "")
|
||||
if not text.strip():
|
||||
raise HTTPException(400, "text is required")
|
||||
# #191 — best-effort link preview: a dead/slow URL never blocks the post.
|
||||
msg = _store.add_global_message(
|
||||
current_user.get("username", ""), text, _attachment(body),
|
||||
_store.build_preview(text),
|
||||
)
|
||||
await sse_manager.broadcast(
|
||||
"chat_message",
|
||||
{"vault": _store.GLOBAL_VAULT, "path": _store.GLOBAL_PATH, "message": msg},
|
||||
)
|
||||
return {"message": msg, "status": "ok"}
|
||||
|
||||
|
||||
@router.post("/api/chat/upload")
|
||||
async def api_chat_upload(
|
||||
file: UploadFile = File(...),
|
||||
current_user: dict[str, Any] = Depends(require_auth),
|
||||
):
|
||||
"""Store an image/video attachment (#190). Returns ``{attachment}``."""
|
||||
data = await file.read()
|
||||
try:
|
||||
info = _store.save_attachment(file.filename or "", data)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
return {"attachment": info}
|
||||
|
||||
|
||||
@router.get("/api/chat/attachment/{name}")
|
||||
async def api_chat_attachment(name: str, current_user: dict[str, Any] = Depends(require_auth)):
|
||||
"""Serve an uploaded attachment (name validated against the allow-list)."""
|
||||
path = _store.attachment_path(name)
|
||||
if not path:
|
||||
raise HTTPException(404, "Attachment not found")
|
||||
info = _store._MIME_BY_EXT.get(path.suffix.lower(), "application/octet-stream")
|
||||
return FileResponse(str(path), media_type=info)
|
||||
|
||||
|
||||
# --- #191 : suppression + messages privés -----------------------------------
|
||||
|
||||
def _owner_or_admin(msg_user: str, current_user: dict[str, Any]) -> None:
|
||||
"""A post may be deleted by its author or by an admin."""
|
||||
if current_user.get("role") != "admin" and current_user.get("username") != msg_user:
|
||||
raise HTTPException(403, "Seul l'auteur ou un administrateur peut supprimer ce message")
|
||||
|
||||
|
||||
def _find_and_authorize(vault: str, path: str, message_id: str, current_user: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Locate *message_id* in the conversation and check the delete right."""
|
||||
for m in _store.get_messages(vault, path):
|
||||
if m.get("id") == message_id:
|
||||
_owner_or_admin(m.get("user", ""), current_user)
|
||||
return m
|
||||
raise HTTPException(404, "Message not found")
|
||||
|
||||
|
||||
@router.delete("/api/chat/{message_id}", response_model=StatusResponse)
|
||||
async def api_chat_delete(message_id: str, current_user: dict[str, Any] = Depends(require_auth)):
|
||||
"""Delete a message from the general chat (author or admin, #191)."""
|
||||
_find_and_authorize(_store.GLOBAL_VAULT, _store.GLOBAL_PATH, message_id, current_user)
|
||||
if not _store.delete_message(_store.GLOBAL_VAULT, _store.GLOBAL_PATH, message_id):
|
||||
raise HTTPException(404, "Message not found")
|
||||
await sse_manager.broadcast(
|
||||
"chat_deleted",
|
||||
{"vault": _store.GLOBAL_VAULT, "path": _store.GLOBAL_PATH, "id": message_id},
|
||||
)
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.get("/api/chat/users", response_model=list[dict[str, Any]])
|
||||
async def api_chat_users(current_user: dict[str, Any] = Depends(require_auth)):
|
||||
"""Usernames available for a private conversation (chat DM picker, #191).
|
||||
|
||||
Every authenticated member may see who else is around — this is a
|
||||
self-hosted portal, not a directory that needs hiding.
|
||||
"""
|
||||
me = current_user.get("username", "")
|
||||
return [
|
||||
{"username": u.get("username", ""), "display_name": u.get("display_name") or u.get("username", "")}
|
||||
for u in get_all_users()
|
||||
if u.get("username") and u.get("username") != me
|
||||
]
|
||||
|
||||
|
||||
def _dm_peer(username: str, current_user: dict[str, Any]) -> str:
|
||||
"""Validate the DM peer exists and is not ourselves."""
|
||||
if not username or username == current_user.get("username"):
|
||||
raise HTTPException(400, "Destinataire invalide")
|
||||
if not get_user(username):
|
||||
raise HTTPException(404, "Utilisateur inconnu")
|
||||
return username
|
||||
|
||||
|
||||
@router.get("/api/chat/dm/{username}", response_model=ChatHistoryResponse)
|
||||
async def api_chat_dm_history(username: str, current_user: dict[str, Any] = Depends(require_auth)):
|
||||
"""Private history with *username* (#191)."""
|
||||
peer = _dm_peer(username, current_user)
|
||||
return {
|
||||
"messages": _store.get_dm_messages(current_user["username"], peer),
|
||||
"read": _store.get_read(_store.DM_VAULT, _store.dm_path(current_user["username"], peer)),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/chat/dm/{username}", response_model=ChatMessageResponse)
|
||||
async def api_chat_dm_post(
|
||||
username: str,
|
||||
body: dict[str, Any] = Body(...),
|
||||
current_user: dict[str, Any] = Depends(require_auth),
|
||||
):
|
||||
"""Post a private message and broadcast it to both participants (#191)."""
|
||||
peer = _dm_peer(username, current_user)
|
||||
text = str(body.get("text") or "")
|
||||
if not text.strip():
|
||||
raise HTTPException(400, "text is required")
|
||||
msg = _store.add_dm_message(
|
||||
current_user["username"], peer, current_user.get("username", ""), text,
|
||||
_attachment(body), _store.build_preview(text),
|
||||
)
|
||||
# Same shape as the general chat so the client routes on vault/path.
|
||||
await sse_manager.broadcast(
|
||||
"chat_message",
|
||||
{"vault": _store.DM_VAULT, "path": _store.dm_path(current_user["username"], peer), "message": msg},
|
||||
)
|
||||
return {"message": msg, "status": "ok"}
|
||||
|
||||
|
||||
@router.delete("/api/chat/dm/{username}/{message_id}", response_model=StatusResponse)
|
||||
async def api_chat_dm_delete(
|
||||
username: str,
|
||||
message_id: str,
|
||||
current_user: dict[str, Any] = Depends(require_auth),
|
||||
):
|
||||
"""Delete one private message (author or admin, #191)."""
|
||||
peer = _dm_peer(username, current_user)
|
||||
vault, path = _store.DM_VAULT, _store.dm_path(current_user["username"], peer)
|
||||
_find_and_authorize(vault, path, message_id, current_user)
|
||||
if not _store.delete_message(vault, path, message_id):
|
||||
raise HTTPException(404, "Message not found")
|
||||
await sse_manager.broadcast(
|
||||
"chat_deleted", {"vault": vault, "path": path, "id": message_id}
|
||||
)
|
||||
return {"status": "deleted"}
|
||||
@@ -42,11 +42,49 @@ from backend.schemas import (
|
||||
XlsxSheetWindowResponse,
|
||||
)
|
||||
from backend.services.files import read_raw_file
|
||||
from backend.services.mutations import file_revision
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.services.vaults import browse_directory, get_vault_root
|
||||
from backend.share import list_shares
|
||||
|
||||
logger = logging.getLogger("obsigate")
|
||||
|
||||
|
||||
def _resolve_shared_file(vault_name: str, path: str, username: str | None):
|
||||
"""#196 — resolve ``home-<user>/Partage/<file>`` to the real source file.
|
||||
|
||||
Returns ``(real_vault, real_path)`` when *path* is a received share
|
||||
mounted in the user's personal folder, else ``None``. Read-only: only a
|
||||
recipient (or the share creator, whose own file is already accessible)
|
||||
gets a mapping — a share directed to someone else never resolves here.
|
||||
|
||||
Chemin canonique : ``Partage/<token>/<nom>`` (token = lève l'ambiguïté
|
||||
de deux partages au même nom). Fallback par nom seul pour les liens
|
||||
ne transportant pas le token.
|
||||
"""
|
||||
if not username or not vault_name.startswith("home-") or not path.startswith("Partage/"):
|
||||
return None
|
||||
owner = vault_name[len("home-"):]
|
||||
if owner != username:
|
||||
return None
|
||||
# Chemin virtuel canonique : Partage/<token>/<nom> — le token lève
|
||||
# l'ambiguïté (deux partages, même nom de base). Fallback : match par
|
||||
# nom pour les liens ne portant pas le token.
|
||||
rest = path.split("/", 1)[1]
|
||||
parts = rest.split("/", 1)
|
||||
if len(parts) == 2 and len(parts[0]) >= 20: # token (64 hex) vs nom de fichier
|
||||
token, _name = parts
|
||||
for s in list_shares(user=username):
|
||||
if s.get("token") == token and s.get("created_by") != username:
|
||||
return s["vault"], s["path"]
|
||||
return None
|
||||
for s in list_shares(user=username):
|
||||
if s.get("created_by") == username:
|
||||
continue
|
||||
if (s.get("path") or "").split("/")[-1] == rest:
|
||||
return s["vault"], s["path"]
|
||||
return None
|
||||
|
||||
# Map file extensions to highlight.js language hints
|
||||
EXT_TO_LANG = {
|
||||
".py": "python", ".js": "javascript", ".ts": "typescript",
|
||||
@@ -103,7 +141,11 @@ async def api_file_raw(vault_name: str, path: str = Query(..., description="Rela
|
||||
Returns:
|
||||
``FileRawResponse`` with vault, path, and raw text content.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
shared = _resolve_shared_file(vault_name, path, current_user.get("username"))
|
||||
if shared:
|
||||
# Partage dirigé = autorisation (cf. api_file).
|
||||
vault_name, path = shared
|
||||
elif not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
return read_raw_file(vault_name, path)
|
||||
|
||||
@@ -119,7 +161,11 @@ async def api_file_download(vault_name: str, path: str = Query(..., description=
|
||||
Returns:
|
||||
``FileResponse`` with ``application/octet-stream`` content-type.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
shared = _resolve_shared_file(vault_name, path, current_user.get("username"))
|
||||
if shared:
|
||||
# Partage dirigé = autorisation (cf. api_file).
|
||||
vault_name, path = shared
|
||||
elif not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
@@ -165,12 +211,11 @@ async def api_file_backlinks(
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
|
||||
backlinks = get_backlinks(vault_name, path)
|
||||
|
||||
# Filter by user-accessible vaults
|
||||
if "*" not in user_vaults:
|
||||
backlinks = [b for b in backlinks if b["vault"] in user_vaults]
|
||||
# Filter by user-accessible vaults (#194 : check_vault_access, "*" sans
|
||||
# les dossiers persos).
|
||||
backlinks = [b for b in backlinks if check_vault_access(b["vault"], current_user)]
|
||||
|
||||
return {
|
||||
"vault": vault_name,
|
||||
@@ -226,7 +271,7 @@ def api_file_xlsx_dashboard(
|
||||
)
|
||||
def api_file_xlsx_sheet(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to the .xlsx file"),
|
||||
path: str = Query(..., description="Relative path to the .xlsx/.xlsm file"),
|
||||
sheet: str = Query(..., description="Sheet name (as shown in the viewer tab)"),
|
||||
offset: int = Query(0, ge=0, description="0-based index of the first row to return"),
|
||||
limit: int = Query(
|
||||
@@ -234,7 +279,7 @@ def api_file_xlsx_sheet(
|
||||
),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Return a window of rows of one sheet of an .xlsx workbook (#153 A9).
|
||||
"""Return a window of rows of one sheet of an .xlsx/.xlsm workbook (#153 A9).
|
||||
|
||||
Backs the viewer's lazy loading: instead of every sheet in a single JSON
|
||||
payload, the client asks for the block it is about to display. The row
|
||||
@@ -258,7 +303,7 @@ def api_file_xlsx_sheet(
|
||||
|
||||
Raises:
|
||||
HTTPException: 403 (vault access), 404 (vault, file or sheet unknown),
|
||||
415 (not an .xlsx file), 500 (unreadable workbook).
|
||||
415 (not an .xlsx/.xlsm file), 500 (unreadable workbook).
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
@@ -269,8 +314,12 @@ def api_file_xlsx_sheet(
|
||||
file_path = resolve_safe_path(Path(vault_data["path"]), path)
|
||||
if not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
if file_path.suffix.lower() != ".xlsx":
|
||||
raise HTTPException(status_code=415, detail="Le fichier n'est pas un classeur .xlsx")
|
||||
# BUG-097 — a .xlsm rides the same editable viewer (and its lazy loading),
|
||||
# so its row windows must be servable too; other formats stay refused.
|
||||
if file_path.suffix.lower() not in (".xlsx", ".xlsm"):
|
||||
raise HTTPException(
|
||||
status_code=415, detail="Le fichier n'est pas un classeur .xlsx/.xlsm"
|
||||
)
|
||||
|
||||
# Import tardif : openpyxl n'est chargé que si un .xlsx est réellement demandé.
|
||||
from backend.xlsx_reader import read_sheet_window
|
||||
@@ -301,8 +350,18 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
||||
Returns:
|
||||
``FileContentResponse`` with HTML, metadata, and tags.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
# #196 — fichier reçu par partage dirigé : home-<user>/Partage/<fichier>
|
||||
# est résolu vers le fichier source (lecture seule, viewer standard).
|
||||
# Résolu AVANT l'ACL vault : le dossier est virtuel, l'autorisation réelle
|
||||
# est l'appartenance au partage (vérifiée dans le resolver).
|
||||
shared = _resolve_shared_file(vault_name, path, current_user.get("username"))
|
||||
if shared:
|
||||
# Le partage dirigé EST l'autorisation : le destinataire n'a par
|
||||
# définition pas accès au vault source — on ne passe PAS par l'ACL.
|
||||
vault_name, path = shared
|
||||
elif not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
@@ -368,6 +427,10 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
||||
"is_markdown": False,
|
||||
"is_xlsx": True,
|
||||
"xlsx_sheets": sheets,
|
||||
# #156-A12 — optimistic-concurrency token: the viewer sends it
|
||||
# back as `if_match` so another writer cannot be overwritten in
|
||||
# silence (409 `conflict` instead).
|
||||
"xlsx_revision": file_revision(file_path),
|
||||
# #153 A1 — parts a save would drop; the viewer warns and asks
|
||||
# for an explicit confirmation before forcing the write.
|
||||
"xlsx_lossy_features": inspect_workbook(file_path),
|
||||
@@ -507,6 +570,7 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
||||
"is_markdown": False,
|
||||
"is_xlsx": True,
|
||||
"xlsx_sheets": sheets,
|
||||
"xlsx_revision": file_revision(file_path),
|
||||
# Macros are NOT lossy for .xlsm: keep_vba re-serializes them
|
||||
# (an empty LOSSY probe is what makes the save gate pass).
|
||||
"xlsx_lossy_features": [],
|
||||
@@ -554,6 +618,8 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
||||
"title": file_path.name, "tags": [], "frontmatter": {},
|
||||
"html": html, "raw_length": len(raw), "extension": ext,
|
||||
"is_markdown": False, "is_csv": True,
|
||||
# #156-A12 — same stale-write guard as the workbooks.
|
||||
"xlsx_revision": file_revision(file_path),
|
||||
}
|
||||
|
||||
# === JSON: syntax-highlighted display ===
|
||||
@@ -636,7 +702,7 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
||||
tags = _extract_tags(post)
|
||||
|
||||
title = post.metadata.get("title", file_path.stem.replace("-", " ").replace("_", " "))
|
||||
html_content = _render_markdown(post.content, vault_name, file_path)
|
||||
html_content = _render_markdown(post.content, vault_name, file_path, click_to_copy=True)
|
||||
|
||||
return {
|
||||
"vault": vault_name,
|
||||
|
||||
+117
-10
@@ -67,6 +67,9 @@ from backend.services.mutations import (
|
||||
from backend.services.mutations import (
|
||||
mutate_xlsx_structure as service_mutate_xlsx_structure,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
mutate_xlsx_style as service_mutate_xlsx_style,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
rename_directory as service_rename_directory,
|
||||
)
|
||||
@@ -127,7 +130,7 @@ def api_file_xlsx_save(
|
||||
...,
|
||||
description=(
|
||||
'{"sheet": str, "cells": {"A1": value}, '
|
||||
'"allow_formula": false, "force": false}'
|
||||
'"allow_formula": false, "force": false, "if_match": str}'
|
||||
),
|
||||
),
|
||||
current_user=Depends(require_auth),
|
||||
@@ -144,6 +147,10 @@ def api_file_xlsx_save(
|
||||
(slicers, form controls, connections, custom XML, signature, cached formula
|
||||
results). Without it the call fails **409** ``xlsx_lossy_content`` and the
|
||||
client asks the user to confirm (#153 A1).
|
||||
* ``if_match`` — revision token returned by the read (#156-A12). When it no
|
||||
longer matches the file on disk the write is refused with **409**
|
||||
``conflict`` (``reason=stale_revision``) instead of overwriting a change
|
||||
made by another writer. Omitted: last writer wins (curl, AI tools).
|
||||
|
||||
A backup is created before the workbook is rewritten, and the new archive
|
||||
swaps in atomically. Declared as a sync endpoint on purpose: the openpyxl
|
||||
@@ -168,16 +175,22 @@ def api_file_xlsx_save(
|
||||
if not isinstance(raw, bool):
|
||||
raise HTTPException(status_code=400, detail=f"Flag invalide: {name}")
|
||||
flags[name] = raw
|
||||
if_match = body.get("if_match")
|
||||
if if_match is not None and not isinstance(if_match, str):
|
||||
raise HTTPException(status_code=400, detail="Jeton invalide: if_match")
|
||||
|
||||
result = service_edit_xlsx_cells(
|
||||
vault_name, path, sheet, cells, **flags
|
||||
vault_name, path, sheet, cells, expected_revision=if_match, **flags
|
||||
)
|
||||
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"]}
|
||||
return {
|
||||
"status": "ok", "vault": result["vault"], "path": result["path"],
|
||||
"size": result["size"], "revision": result.get("revision"),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/api/file/{vault_name}/csv/save", response_model=FileSaveResponse)
|
||||
@@ -186,7 +199,11 @@ def api_file_csv_save(
|
||||
path: str = Query(..., description="Relative path to the .csv file"),
|
||||
body: dict = Body(
|
||||
...,
|
||||
description='{"cells": {"A1": value}} — A1-addressed text edits (#153 A16)',
|
||||
description=(
|
||||
'{"cells": {"A1": value}, "if_match": str} — A1-addressed text '
|
||||
'edits (#153 A16). `if_match` is the revision token of the read '
|
||||
'(#156-A12): a stale token fails with 409 `conflict`.'
|
||||
),
|
||||
),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
@@ -195,6 +212,9 @@ def api_file_csv_save(
|
||||
The grid is re-parsed with :mod:`csv`, patched and re-serialized
|
||||
(RFC 4180 quoting). References beyond the extent grow the grid. Values
|
||||
are stored verbatim as text — a CSV has no formula engine.
|
||||
|
||||
``if_match`` (optional, #156-A12) is the revision the client read: when the
|
||||
file changed in the meantime the write is refused with **409** ``conflict``.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
@@ -206,13 +226,20 @@ def api_file_csv_save(
|
||||
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_save_csv_cells(vault_name, path, cells)
|
||||
if_match = body.get("if_match")
|
||||
if if_match is not None and not isinstance(if_match, str):
|
||||
raise HTTPException(status_code=400, detail="Jeton invalide: if_match")
|
||||
|
||||
result = service_save_csv_cells(vault_name, path, cells, expected_revision=if_match)
|
||||
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"]}
|
||||
return {
|
||||
"status": "ok", "vault": result["vault"], "path": result["path"],
|
||||
"size": result["size"], "revision": result.get("revision"),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/api/file/{vault_name}/xlsx/structure", response_model=FileSaveResponse)
|
||||
@@ -224,7 +251,7 @@ def api_file_xlsx_structure(
|
||||
description=(
|
||||
'{"actions": [{"op": "sheet_add", "name": "X"}, '
|
||||
'{"op": "row_insert", "sheet": "X", "at": 2, "count": 1}], '
|
||||
'"force": false}'
|
||||
'"force": false, "if_match": str}'
|
||||
),
|
||||
),
|
||||
current_user=Depends(require_auth),
|
||||
@@ -239,7 +266,9 @@ def api_file_xlsx_structure(
|
||||
|
||||
Without ``force`` the call fails **409** ``xlsx_lossy_content`` when the
|
||||
workbook carries features openpyxl cannot rewrite (same gate as the cell
|
||||
edits). A backup is created before the archive is replaced.
|
||||
edits). A backup is created before the archive is replaced. The optional
|
||||
``if_match`` revision (#156-A12) refuses a structural rewrite on a file that
|
||||
changed since it was read (**409** ``conflict``).
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
@@ -258,16 +287,90 @@ def api_file_xlsx_structure(
|
||||
raw_force = body.get("force", False)
|
||||
if not isinstance(raw_force, bool):
|
||||
raise HTTPException(status_code=400, detail="Flag invalide: force")
|
||||
if_match = body.get("if_match")
|
||||
if if_match is not None and not isinstance(if_match, str):
|
||||
raise HTTPException(status_code=400, detail="Jeton invalide: if_match")
|
||||
|
||||
result = service_mutate_xlsx_structure(
|
||||
vault_name, path, actions, force=raw_force
|
||||
vault_name, path, actions, force=raw_force, expected_revision=if_match
|
||||
)
|
||||
log_file_save(
|
||||
current_user["username"], vault_name, path,
|
||||
len(actions),
|
||||
current_user.get("_request_ip", "unknown"),
|
||||
)
|
||||
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": len(result["applied"])}
|
||||
return {
|
||||
"status": "ok", "vault": result["vault"], "path": result["path"],
|
||||
"size": len(result["applied"]), "revision": result.get("revision"),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/api/file/{vault_name}/xlsx/style", response_model=FileSaveResponse)
|
||||
def api_file_xlsx_style(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to the .xlsx/.xlsm file"),
|
||||
body: dict = Body(
|
||||
...,
|
||||
description=(
|
||||
'{"ops": [{"op": "cell", "sheet": "X", "range": "A1:B2", '
|
||||
'"style": {"bold": true, "fill_color": "#ffe08a"}}], '
|
||||
'"force": false, "if_match": str}'
|
||||
),
|
||||
),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Write formatting on an .xlsx/.xlsm workbook (#156-A8).
|
||||
|
||||
``ops`` is an ordered list applied in one locked, atomic rewrite:
|
||||
``cell`` (``sheet``, ``range``/``cell``, ``style`` with ``bold``,
|
||||
``italic``, ``underline``, ``strike``, ``font_size`` (6-72),
|
||||
``font_color``/``fill_color`` as ``#rrggbb``, ``align``
|
||||
(left/center/right/justify), ``valign`` (top/middle/bottom), ``wrap``,
|
||||
``rotation`` (-90..90), ``border`` (all/outer/none) + ``border_style`` /
|
||||
``border_color`` and ``number_format``), ``merge``/``unmerge`` (``range``),
|
||||
``col_width`` (``col``, ``width``), ``row_height`` (``row``, ``height``),
|
||||
``comment_set`` (``ref``, ``text``) / ``comment_clear`` (``ref``)
|
||||
and ``freeze`` (``cell``, empty to release).
|
||||
|
||||
Without ``force`` the call fails **409** ``xlsx_lossy_content`` when the
|
||||
workbook carries features openpyxl cannot rewrite (same gate as the cell
|
||||
edits). A backup is created before the archive is replaced; the optional
|
||||
``if_match`` revision (#156-A12) refuses a rewrite on a file that changed
|
||||
since it was read (**409** ``conflict``).
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative path to the ``.xlsx``/``.xlsm`` file.
|
||||
body: JSON body with ``ops`` (1 to 50) and optional ``force``.
|
||||
|
||||
Returns:
|
||||
``FileSaveResponse`` confirming the write.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
ops = body.get("ops")
|
||||
if not isinstance(ops, list) or not ops or len(ops) > 50:
|
||||
raise HTTPException(status_code=400, detail="Ops invalides (1 à 50 par requête)")
|
||||
raw_force = body.get("force", False)
|
||||
if not isinstance(raw_force, bool):
|
||||
raise HTTPException(status_code=400, detail="Flag invalide: force")
|
||||
if_match = body.get("if_match")
|
||||
if if_match is not None and not isinstance(if_match, str):
|
||||
raise HTTPException(status_code=400, detail="Jeton invalide: if_match")
|
||||
|
||||
result = service_mutate_xlsx_style(
|
||||
vault_name, path, ops, force=raw_force, expected_revision=if_match
|
||||
)
|
||||
log_file_save(
|
||||
current_user["username"], vault_name, path,
|
||||
len(ops),
|
||||
current_user.get("_request_ip", "unknown"),
|
||||
)
|
||||
return {
|
||||
"status": "ok", "vault": result["vault"], "path": result["path"],
|
||||
"size": len(result["applied"]), "revision": result.get("revision"),
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/api/file/{vault_name}", response_model=FileDeleteResponse)
|
||||
@@ -449,6 +552,10 @@ async def api_file_create(
|
||||
|
||||
Returns:
|
||||
FileCreateResponse confirming creation.
|
||||
|
||||
Note:
|
||||
A ``.xlsx`` path creates an empty workbook (one ``Feuille1`` sheet) built
|
||||
with openpyxl: the payload is binary, so ``content`` is ignored (#186).
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
@@ -59,7 +59,6 @@ async def api_recent(limit: int | None = Query(None), vault: str | None = Query(
|
||||
@router.get("/api/bookmarks", response_model=BookmarksResponse)
|
||||
async def api_bookmarks(vault: str | None = Query(None), current_user=Depends(require_auth)):
|
||||
username = current_user.get("username")
|
||||
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
|
||||
|
||||
if not username:
|
||||
return {"files": []}
|
||||
@@ -68,7 +67,8 @@ async def api_bookmarks(vault: str | None = Query(None), current_user=Depends(re
|
||||
files_resp = []
|
||||
for item in history:
|
||||
v_name = item["vault"]
|
||||
if "*" not in user_vaults and v_name not in user_vaults:
|
||||
# #194 : check_vault_access — "*" n'inclut pas les dossiers persos.
|
||||
if not check_vault_access(v_name, current_user):
|
||||
continue
|
||||
|
||||
# Find in index to get metadata
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""External notification channels endpoints (#168).
|
||||
|
||||
Channel CRUD is admin-only (secrets involved); sending a test notification
|
||||
requires authentication. Responses mask secrets (``***`` + ``has_secret``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from backend import notify as _notify
|
||||
from backend.auth.middleware import require_admin, require_auth
|
||||
from backend.schemas import StatusResponse
|
||||
|
||||
router = APIRouter(prefix="/api/notify", tags=["notify"])
|
||||
|
||||
|
||||
class NotifyChannel(BaseModel):
|
||||
"""Public view of a notification channel (secrets masked)."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
id: str = Field(description="Channel id")
|
||||
name: str = Field(description="Display name")
|
||||
type: str = Field(description="discord | telegram | smtp | webhook")
|
||||
enabled: bool = Field(description="Whether the channel receives broadcasts")
|
||||
config: dict[str, Any] = Field(description="Channel config (secrets masked)")
|
||||
has_secret: bool = Field(description="True when a secret is configured")
|
||||
|
||||
|
||||
class NotifySendResult(BaseModel):
|
||||
"""Outcome of a test send / broadcast."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
ok: bool = Field(description="True when every delivery succeeded")
|
||||
deliveries: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
@router.get("/channels", response_model=list[NotifyChannel])
|
||||
async def api_notify_list(current_user=Depends(require_admin)):
|
||||
"""List notification channels (admin)."""
|
||||
return _notify.list_channels()
|
||||
|
||||
|
||||
@router.post("/channels", response_model=NotifyChannel)
|
||||
async def api_notify_create(body: dict = Body(...), current_user=Depends(require_admin)):
|
||||
"""Create a channel (``{name, type, config}``). Secrets go to the secret store."""
|
||||
try:
|
||||
return _notify.create_channel(
|
||||
str(body.get("name") or ""),
|
||||
str(body.get("type") or ""),
|
||||
dict(body.get("config") or {}),
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
|
||||
@router.patch("/channels/{channel_id}", response_model=NotifyChannel)
|
||||
async def api_notify_update(channel_id: str, body: dict = Body(...), current_user=Depends(require_admin)):
|
||||
"""Update a channel (name / enabled / config)."""
|
||||
try:
|
||||
result = _notify.update_channel(channel_id, body)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
if result is None:
|
||||
raise HTTPException(404, "Channel not found")
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/channels/{channel_id}", response_model=StatusResponse)
|
||||
async def api_notify_delete(channel_id: str, current_user=Depends(require_admin)):
|
||||
"""Delete a channel and its secret."""
|
||||
if not _notify.delete_channel(channel_id):
|
||||
raise HTTPException(404, "Channel not found")
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/test", response_model=NotifySendResult)
|
||||
async def api_notify_test(body: dict = Body(...), current_user=Depends(require_auth)):
|
||||
"""Send a test notification (broadcast or single ``channel_id``)."""
|
||||
title = str(body.get("title") or "Test ObsiGate")
|
||||
message = str(body.get("message") or "Notification de test.")
|
||||
channel_id = str(body.get("channel_id") or "")
|
||||
if channel_id:
|
||||
channel = next((c for c in _notify._read_channels() if c.get("id") == channel_id), None)
|
||||
if channel is None:
|
||||
raise HTTPException(404, "Channel not found")
|
||||
try:
|
||||
_notify.send_via_channel(channel, title, message, "manual")
|
||||
except Exception as e:
|
||||
raise HTTPException(502, f"Envoi échoué : {e}") from e
|
||||
return {"ok": True, "deliveries": [{"channel_id": channel_id, "ok": True}]}
|
||||
deliveries = _notify.broadcast("manual", title, message)
|
||||
return {"ok": all(d.get("ok") for d in deliveries), "deliveries": deliveries}
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Scheduled tasks endpoints (#170).
|
||||
|
||||
Tasks reuse the existing mutation/notification services — this router only
|
||||
validates, persists and triggers. File-writing actions check vault access
|
||||
at creation time; the background tick re-checks nothing (system context) but
|
||||
records failures and notifies on ``schedule_failure`` (#168).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from backend import scheduler as _scheduler
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
from backend.schemas import StatusResponse
|
||||
|
||||
router = APIRouter(prefix="/api/scheduler", tags=["scheduler"])
|
||||
|
||||
|
||||
class ScheduledTask(BaseModel):
|
||||
"""A programmed automatic task."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
id: str = Field(description="Task id")
|
||||
name: str = Field(description="Display name")
|
||||
action: dict[str, Any] = Field(description="{kind, params}")
|
||||
schedule: dict[str, Any] = Field(description="{kind, ...}")
|
||||
enabled: bool = Field(description="Whether the tick executes it")
|
||||
created_by: str = Field(description="Owner username")
|
||||
created_at: str = Field(description="ISO-8601 creation time")
|
||||
last_run_at: str | None = Field(default=None)
|
||||
last_status: str | None = Field(default=None)
|
||||
last_error: str | None = Field(default=None)
|
||||
run_count: int = Field(default=0)
|
||||
next_run_at: str = Field(description="ISO-8601 next due time")
|
||||
|
||||
|
||||
class TaskRunResult(BaseModel):
|
||||
"""Outcome of a manual or due run."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
task_id: str = Field(description="Task id")
|
||||
ok: bool = Field(description="True on success")
|
||||
result: dict[str, Any] | None = Field(default=None)
|
||||
error: str | None = Field(default=None)
|
||||
|
||||
|
||||
def _check_action_vault(action: dict[str, Any], user: dict[str, Any]) -> None:
|
||||
from backend.services.errors import ServiceError
|
||||
from backend.services.vaults import get_vault_root
|
||||
|
||||
kind = (action or {}).get("kind")
|
||||
params = (action or {}).get("params") or {}
|
||||
if kind in ("create_file", "append_to_file"):
|
||||
vault = str(params.get("vault") or "")
|
||||
if not check_vault_access(vault, user):
|
||||
raise HTTPException(403, f"No access to vault '{vault}'")
|
||||
try:
|
||||
get_vault_root(vault)
|
||||
except ServiceError as e:
|
||||
raise HTTPException(404, f"Unknown vault '{vault}'") from e
|
||||
|
||||
|
||||
@router.get("/tasks", response_model=list[ScheduledTask])
|
||||
async def api_scheduler_list(current_user: dict[str, Any] = Depends(require_auth)):
|
||||
"""List scheduled tasks (newest first)."""
|
||||
return _scheduler.list_tasks()
|
||||
|
||||
|
||||
@router.post("/tasks", response_model=ScheduledTask)
|
||||
async def api_scheduler_create(body: dict = Body(...), current_user: dict[str, Any] = Depends(require_auth)):
|
||||
"""Create a task (``{name, action, schedule, enabled?}``)."""
|
||||
action = dict(body.get("action") or {})
|
||||
_check_action_vault(action, current_user)
|
||||
try:
|
||||
return _scheduler.create_task(
|
||||
str(body.get("name") or ""),
|
||||
action,
|
||||
dict(body.get("schedule") or {}),
|
||||
created_by=str(current_user.get("username", "api")),
|
||||
enabled=bool(body.get("enabled", True)),
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
|
||||
@router.patch("/tasks/{task_id}", response_model=ScheduledTask)
|
||||
async def api_scheduler_update(task_id: str, body: dict = Body(...), current_user: dict[str, Any] = Depends(require_auth)):
|
||||
"""Update a task (name / enabled / action / schedule)."""
|
||||
if "action" in body:
|
||||
_check_action_vault(dict(body["action"] or {}), current_user)
|
||||
try:
|
||||
result = _scheduler.update_task(task_id, body)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
if result is None:
|
||||
raise HTTPException(404, "Task not found")
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/tasks/{task_id}", response_model=StatusResponse)
|
||||
async def api_scheduler_delete(task_id: str, current_user: dict[str, Any] = Depends(require_auth)):
|
||||
"""Delete a task."""
|
||||
if not _scheduler.delete_task(task_id):
|
||||
raise HTTPException(404, "Task not found")
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/run", response_model=TaskRunResult)
|
||||
async def api_scheduler_run(task_id: str, current_user: dict[str, Any] = Depends(require_auth)):
|
||||
"""Execute a task immediately (manual run)."""
|
||||
try:
|
||||
return _scheduler.run_task(task_id, manual=True)
|
||||
except KeyError:
|
||||
raise HTTPException(404, "Task not found") from None
|
||||
@@ -82,7 +82,15 @@ async def api_search(
|
||||
# paginate in the shared service so routes and tools share the same logic.
|
||||
return await loop.run_in_executor(
|
||||
get_search_executor(),
|
||||
partial(search_vaults, q, vault, tag, limit, offset),
|
||||
# #194 : filtre par vault accessible AVANT pagination ("*" sans les
|
||||
# dossiers persos) — sinon un user voyait les notes des autres.
|
||||
# #196 : les documents reçus par partage dirigé sont ajoutés aux
|
||||
# résultats (vault virtuel "home-<user>/Partage").
|
||||
partial(
|
||||
search_vaults, q, vault, tag, limit, offset,
|
||||
is_allowed=lambda v: check_vault_access(v, current_user),
|
||||
username=current_user["username"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
+54
-15
@@ -24,7 +24,8 @@ import frontmatter
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import FileResponse, HTMLResponse, Response
|
||||
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
from backend.auth.middleware import check_vault_access, get_current_user, require_auth
|
||||
from backend.auth.user_store import get_user
|
||||
from backend.indexer import get_vault_data, parse_markdown_file, update_single_file
|
||||
from backend.render import _render_markdown
|
||||
from backend.schemas import ShareModel, StatusResponse
|
||||
@@ -53,6 +54,27 @@ except Exception: # pragma: no cover - WeasyPrint/GTK missing
|
||||
router = APIRouter(tags=["sharing"])
|
||||
|
||||
|
||||
def _gate_share(share: dict | None, user: dict | None) -> dict:
|
||||
"""#196 — directed shares: auth + membership check for ``/s/*`` pages.
|
||||
|
||||
Public share (``shared_with`` empty) → pass-through, no auth required.
|
||||
Directed share → requires an authenticated user who is the creator, an
|
||||
admin, or listed in ``shared_with``. Anything else answers **404** (never
|
||||
403) so an outsider cannot learn that a token exists.
|
||||
"""
|
||||
if share is None:
|
||||
raise HTTPException(404, "Share not found or expired")
|
||||
recipients = share.get("shared_with") or []
|
||||
if not recipients:
|
||||
return share
|
||||
if user is None:
|
||||
raise HTTPException(404, "Share not found or expired")
|
||||
if user["username"] == share.get("created_by") or user.get("role") == "admin" \
|
||||
or user["username"] in recipients:
|
||||
return share
|
||||
raise HTTPException(404, "Share not found or expired")
|
||||
|
||||
|
||||
@router.post("/api/share/{vault_name}", response_model=ShareModel)
|
||||
async def api_share_create(
|
||||
vault_name: str,
|
||||
@@ -66,9 +88,18 @@ async def api_share_create(
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'")
|
||||
path = body.get("path", "")
|
||||
path = body.get("path") or ""
|
||||
if not path:
|
||||
raise HTTPException(400, "Chemin de fichier requis")
|
||||
expires = body.get("expires_in_hours")
|
||||
share = create_share(vault_name, path, current_user["username"], expires)
|
||||
# #196 — directed share: validate recipients before creating anything.
|
||||
recipients = body.get("shared_with") or []
|
||||
if not isinstance(recipients, list):
|
||||
raise HTTPException(400, "shared_with doit être une liste d'utilisateurs")
|
||||
for name in recipients:
|
||||
if not isinstance(name, str) or not get_user(name):
|
||||
raise HTTPException(400, f"Utilisateur inconnu : {name}")
|
||||
share = create_share(vault_name, path, current_user["username"], expires, recipients)
|
||||
share["url"] = f"/s/{share['token']}"
|
||||
|
||||
# Set publish: true in the file's frontmatter
|
||||
@@ -94,8 +125,13 @@ async def api_share_create(
|
||||
|
||||
@router.get("/api/shares", response_model=list[ShareModel])
|
||||
async def api_shares_list(vault: str | None = Query(None), current_user=Depends(require_auth)):
|
||||
"""List all shares (optionally filtered by vault)."""
|
||||
shares = list_shares(vault)
|
||||
"""List shares (optionally filtered by vault).
|
||||
|
||||
#196 : un non-admin ne voit que SES partages (créés ou reçus) ; un admin
|
||||
voit tout (comportement historique).
|
||||
"""
|
||||
username = None if current_user.get("role") == "admin" else current_user["username"]
|
||||
shares = list_shares(vault, username)
|
||||
for s in shares:
|
||||
s["url"] = f"/s/{s['token']}"
|
||||
return shares
|
||||
@@ -103,6 +139,12 @@ async def api_shares_list(vault: str | None = Query(None), current_user=Depends(
|
||||
|
||||
@router.delete("/api/share/{share_id}", response_model=StatusResponse)
|
||||
async def api_share_revoke(share_id: str, current_user=Depends(require_auth)):
|
||||
# #196 — only the creator or an admin may revoke; a recipient cannot.
|
||||
from backend.share import _read
|
||||
share = _read()["shares"].get(share_id)
|
||||
if share and current_user.get("role") != "admin" \
|
||||
and share.get("created_by") != current_user["username"]:
|
||||
raise HTTPException(403, "Seul le créateur du partage peut le révoquer")
|
||||
if not revoke_share(share_id):
|
||||
raise HTTPException(404, "Share not found")
|
||||
return {"status": "revoked"}
|
||||
@@ -113,13 +155,12 @@ async def api_share_revoke(share_id: str, current_user=Depends(require_auth)):
|
||||
response_class=Response,
|
||||
responses={200: {"content": {"application/pdf": {}}, "description": "Shared document as PDF"}},
|
||||
)
|
||||
async def public_share_pdf_download(token: str):
|
||||
async def public_share_pdf_download(token: str, current_user=Depends(get_current_user)):
|
||||
"""Download shared document as real PDF via WeasyPrint."""
|
||||
if generate_pdf is None:
|
||||
raise HTTPException(501, "PDF export unavailable (WeasyPrint/GTK not available)")
|
||||
share = get_share_by_token(token)
|
||||
if not share:
|
||||
raise HTTPException(404, "Share not found or expired")
|
||||
share = _gate_share(share, current_user)
|
||||
vault_data = get_vault_data(share["vault"])
|
||||
if not vault_data:
|
||||
raise HTTPException(404, "Vault not found")
|
||||
@@ -147,11 +188,10 @@ async def public_share_pdf_download(token: str):
|
||||
|
||||
|
||||
@router.get("/s/{token}/raw", response_class=FileResponse)
|
||||
async def public_share_raw(token: str):
|
||||
async def public_share_raw(token: str, current_user=Depends(get_current_user)):
|
||||
"""Download the raw (original) shared document."""
|
||||
share = get_share_by_token(token)
|
||||
if not share:
|
||||
raise HTTPException(404, "Share not found or expired")
|
||||
share = _gate_share(share, current_user)
|
||||
vault_data = get_vault_data(share["vault"])
|
||||
if not vault_data:
|
||||
raise HTTPException(404, "Vault not found")
|
||||
@@ -164,13 +204,12 @@ async def public_share_raw(token: str):
|
||||
|
||||
|
||||
@router.get("/s/{token}", response_class=HTMLResponse)
|
||||
async def public_share_view(request: Request, token: str):
|
||||
"""Public share view — no authentication required."""
|
||||
async def public_share_view(request: Request, token: str, current_user=Depends(get_current_user)):
|
||||
"""Public share view — no authentication required (unless directed #196)."""
|
||||
from backend.csp import inject_csp_nonce
|
||||
|
||||
share = get_share_by_token(token)
|
||||
if not share:
|
||||
raise HTTPException(404, "Share not found or expired")
|
||||
share = _gate_share(share, current_user)
|
||||
vault_data = get_vault_data(share["vault"])
|
||||
if not vault_data:
|
||||
raise HTTPException(404, "Vault not found")
|
||||
|
||||
@@ -55,6 +55,11 @@ async def api_add_vault(body: dict = Body(...), current_user=Depends(require_adm
|
||||
|
||||
stats = await add_vault_to_index(name, vault_path)
|
||||
|
||||
# #194 : persister, sinon ce vault disparaît au prochain rebuild/redémarrage.
|
||||
from backend.indexer import persist_vault
|
||||
|
||||
persist_vault(name)
|
||||
|
||||
# Start watching the new vault
|
||||
watcher = get_watcher()
|
||||
if watcher:
|
||||
@@ -80,6 +85,10 @@ async def api_remove_vault(vault_name: str, current_user=Depends(require_admin))
|
||||
await watcher.remove_vault(vault_name)
|
||||
|
||||
await remove_vault_from_index(vault_name)
|
||||
# #194 : plus de trace au redémarrage (les vaults d'env, eux, reviennent).
|
||||
from backend.indexer import unpersist_vault
|
||||
|
||||
unpersist_vault(vault_name)
|
||||
await sse_manager.broadcast("vault_removed", {"vault": vault_name})
|
||||
return {"status": "ok", "vault": vault_name}
|
||||
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
"""Scheduled tasks — automatic agent actions, type cron (#170).
|
||||
|
||||
Tasks are persisted in ``data/scheduled_tasks.json`` (guarded by an RLock,
|
||||
same pattern as the other JSON stores). Supported actions reuse the existing
|
||||
mutation/notification services — no new write path:
|
||||
|
||||
* ``create_file`` → ``backend.services.mutations.create_file``;
|
||||
* ``append_to_file`` → ``backend.services.mutations.append_to_file``;
|
||||
* ``notify`` → ``backend.notify.broadcast`` (trigger ``manual``).
|
||||
|
||||
Supported schedules:
|
||||
|
||||
* ``interval_hours`` — every N hours (N >= 0.25);
|
||||
* ``daily_time`` — once a day at ``HH:MM`` (local server time);
|
||||
* ``once_at`` — one shot at an ISO-8601 datetime (past = due immediately).
|
||||
|
||||
On failure the task records ``last_error`` and a ``schedule_failure``
|
||||
broadcast is emitted to the notification channels (#168) — best effort,
|
||||
never recursive (a failing ``notify`` action does not rebroadcast).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("obsigate.scheduler")
|
||||
|
||||
DATA_DIR = Path(os.environ.get("OBSIGATE_DATA_DIR", "data"))
|
||||
TASKS_FILE = DATA_DIR / "scheduled_tasks.json"
|
||||
|
||||
ACTION_KINDS = ("create_file", "append_to_file", "notify")
|
||||
SCHEDULE_KINDS = ("interval_hours", "daily_time", "once_at")
|
||||
|
||||
_lock = threading.RLock()
|
||||
|
||||
|
||||
# ── Store ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _read_tasks() -> list[dict[str, Any]]:
|
||||
if not TASKS_FILE.exists():
|
||||
return []
|
||||
try:
|
||||
data = json.loads(TASKS_FILE.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, list) else []
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return []
|
||||
|
||||
|
||||
def _write_tasks(tasks: list[dict[str, Any]]) -> None:
|
||||
TASKS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = TASKS_FILE.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(tasks, indent=2, default=str), encoding="utf-8")
|
||||
tmp.replace(TASKS_FILE)
|
||||
|
||||
|
||||
def list_tasks() -> list[dict[str, Any]]:
|
||||
"""Return all scheduled tasks (newest first)."""
|
||||
return sorted(_read_tasks(), key=lambda t: t.get("created_at", ""), reverse=True)
|
||||
|
||||
|
||||
def get_task(task_id: str) -> dict[str, Any] | None:
|
||||
"""Return one task by id, or None."""
|
||||
for task in _read_tasks():
|
||||
if task.get("id") == task_id:
|
||||
return task
|
||||
return None
|
||||
|
||||
|
||||
# ── Validation ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _validate_action(action: dict[str, Any]) -> dict[str, Any]:
|
||||
kind = action.get("kind")
|
||||
if kind not in ACTION_KINDS:
|
||||
raise ValueError(f"Action inconnue : {kind} (attendu : {', '.join(ACTION_KINDS)})")
|
||||
params = dict(action.get("params") or {})
|
||||
if kind in ("create_file", "append_to_file"):
|
||||
if not str(params.get("vault") or "").strip():
|
||||
raise ValueError("params.vault requis pour create_file/append_to_file")
|
||||
if not str(params.get("path") or "").strip():
|
||||
raise ValueError("params.path requis pour create_file/append_to_file")
|
||||
if kind == "append_to_file" and not str(params.get("content") or ""):
|
||||
raise ValueError("params.content requis pour append_to_file")
|
||||
elif kind == "notify":
|
||||
if not str(params.get("title") or "").strip():
|
||||
raise ValueError("params.title requis pour notify")
|
||||
if not str(params.get("message") or "").strip():
|
||||
raise ValueError("params.message requis pour notify")
|
||||
return {"kind": kind, "params": params}
|
||||
|
||||
|
||||
def _validate_schedule(schedule: dict[str, Any]) -> dict[str, Any]:
|
||||
kind = schedule.get("kind")
|
||||
if kind not in SCHEDULE_KINDS:
|
||||
raise ValueError(f"Planification inconnue : {kind} (attendu : {', '.join(SCHEDULE_KINDS)})")
|
||||
if kind == "interval_hours":
|
||||
hours = float(schedule.get("hours") or 0)
|
||||
if hours < 0.25:
|
||||
raise ValueError("hours doit être >= 0.25")
|
||||
return {"kind": kind, "hours": hours}
|
||||
if kind == "daily_time":
|
||||
at = str(schedule.get("at") or "").strip()
|
||||
try:
|
||||
datetime.strptime(at, "%H:%M")
|
||||
except ValueError:
|
||||
raise ValueError("at doit être au format HH:MM (ex. 08:30)") from None
|
||||
return {"kind": kind, "at": at}
|
||||
# once_at
|
||||
at = str(schedule.get("at") or "").strip()
|
||||
try:
|
||||
parsed = datetime.fromisoformat(at)
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
raise ValueError("at doit être une date ISO-8601 (ex. 2026-10-05T08:30:00)") from None
|
||||
return {"kind": kind, "at": parsed.isoformat()}
|
||||
|
||||
|
||||
# ── CRUD ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def create_task(
|
||||
name: str,
|
||||
action: dict[str, Any],
|
||||
schedule: dict[str, Any],
|
||||
*,
|
||||
created_by: str = "api",
|
||||
enabled: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a scheduled task. Raises ValueError on invalid action/schedule."""
|
||||
validated_action = _validate_action(action)
|
||||
validated_schedule = _validate_schedule(schedule)
|
||||
now = datetime.now(timezone.utc)
|
||||
with _lock:
|
||||
tasks = _read_tasks()
|
||||
task = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"name": (name or validated_action["kind"]).strip() or validated_action["kind"],
|
||||
"action": validated_action,
|
||||
"schedule": validated_schedule,
|
||||
"enabled": bool(enabled),
|
||||
"created_by": created_by,
|
||||
"created_at": now.isoformat(),
|
||||
"last_run_at": None,
|
||||
"last_status": None,
|
||||
"last_error": None,
|
||||
"run_count": 0,
|
||||
"next_run_at": compute_next_run(
|
||||
{"schedule": validated_schedule, "last_run_at": None}, now
|
||||
).isoformat(),
|
||||
}
|
||||
tasks.append(task)
|
||||
_write_tasks(tasks)
|
||||
logger.info(f"Scheduled task created: '{task['name']}' ({validated_schedule['kind']})")
|
||||
return task
|
||||
|
||||
|
||||
def update_task(task_id: str, updates: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Update name/enabled/action/schedule. Returns None when unknown."""
|
||||
with _lock:
|
||||
tasks = _read_tasks()
|
||||
for task in tasks:
|
||||
if task.get("id") != task_id:
|
||||
continue
|
||||
if updates.get("name"):
|
||||
task["name"] = str(updates["name"])
|
||||
if "enabled" in updates:
|
||||
task["enabled"] = bool(updates["enabled"])
|
||||
if "action" in updates:
|
||||
task["action"] = _validate_action(updates["action"])
|
||||
if "schedule" in updates:
|
||||
task["schedule"] = _validate_schedule(updates["schedule"])
|
||||
task["next_run_at"] = compute_next_run(task).isoformat()
|
||||
_write_tasks(tasks)
|
||||
return task
|
||||
return None
|
||||
|
||||
|
||||
def delete_task(task_id: str) -> bool:
|
||||
"""Delete a task. Returns False when unknown."""
|
||||
with _lock:
|
||||
tasks = _read_tasks()
|
||||
remaining = [t for t in tasks if t.get("id") != task_id]
|
||||
if len(remaining) == len(tasks):
|
||||
return False
|
||||
_write_tasks(remaining)
|
||||
return True
|
||||
|
||||
|
||||
# ── Scheduling ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def compute_next_run(task: dict[str, Any], now: datetime | None = None) -> datetime:
|
||||
"""Compute the next due datetime for *task*."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
if now.tzinfo is None:
|
||||
now = now.replace(tzinfo=timezone.utc)
|
||||
schedule = task.get("schedule", {})
|
||||
kind = schedule.get("kind")
|
||||
last_run_at = task.get("last_run_at")
|
||||
last = None
|
||||
if last_run_at:
|
||||
try:
|
||||
last = datetime.fromisoformat(str(last_run_at))
|
||||
if last.tzinfo is None:
|
||||
last = last.replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
last = None
|
||||
if kind == "interval_hours":
|
||||
hours = float(schedule.get("hours", 24))
|
||||
base = last or now
|
||||
nxt = base + timedelta(hours=hours)
|
||||
# Première planification : due dès maintenant + intervalle ? Non —
|
||||
# la tâche démarre au prochain intervalle, sauf retard déjà accumulé.
|
||||
if last is None:
|
||||
nxt = now + timedelta(hours=hours)
|
||||
return max(now, nxt)
|
||||
if kind == "daily_time":
|
||||
hour, minute = (str(schedule.get("at", "08:00")) + ":00").split(":")[:2]
|
||||
candidate = now.replace(hour=int(hour), minute=int(minute), second=0, microsecond=0)
|
||||
if candidate <= now:
|
||||
candidate += timedelta(days=1)
|
||||
return candidate
|
||||
if kind == "once_at":
|
||||
try:
|
||||
at = datetime.fromisoformat(str(schedule.get("at")))
|
||||
if at.tzinfo is None:
|
||||
at = at.replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
return now
|
||||
if task.get("last_run_at"):
|
||||
return datetime.max.replace(tzinfo=timezone.utc) # déjà exécutée
|
||||
return at
|
||||
return now + timedelta(hours=24)
|
||||
|
||||
|
||||
def _execute_action(task: dict[str, Any]) -> dict[str, Any]:
|
||||
action = task["action"]
|
||||
kind = action["kind"]
|
||||
params = action["params"]
|
||||
if kind == "create_file":
|
||||
from backend.services.mutations import create_file
|
||||
|
||||
return create_file(
|
||||
params["vault"],
|
||||
params["path"],
|
||||
params.get("content", ""),
|
||||
overwrite=bool(params.get("overwrite", False)),
|
||||
)
|
||||
if kind == "append_to_file":
|
||||
from backend.services.mutations import append_to_file
|
||||
|
||||
return append_to_file(params["vault"], params["path"], params.get("content", ""))
|
||||
if kind == "notify":
|
||||
from backend.notify import broadcast
|
||||
|
||||
results = broadcast("manual", str(params["title"]), str(params.get("message", "")))
|
||||
return {"broadcast": results}
|
||||
raise ValueError(f"Action inconnue : {kind}")
|
||||
|
||||
|
||||
def run_task(task_id: str, *, manual: bool = False) -> dict[str, Any]:
|
||||
"""Execute one task now (manual or due). Records status; notifies on failure."""
|
||||
with _lock:
|
||||
tasks = _read_tasks()
|
||||
task = next((t for t in tasks if t.get("id") == task_id), None)
|
||||
if task is None:
|
||||
raise KeyError(task_id)
|
||||
if not task.get("enabled", True) and not manual:
|
||||
return {"task_id": task_id, "skipped": True, "reason": "disabled"}
|
||||
try:
|
||||
result = _execute_action(task)
|
||||
task["last_run_at"] = datetime.now(timezone.utc).isoformat()
|
||||
task["last_status"] = "ok"
|
||||
task["last_error"] = None
|
||||
task["run_count"] = int(task.get("run_count", 0)) + 1
|
||||
if task.get("schedule", {}).get("kind") == "once_at":
|
||||
task["enabled"] = False # one-shot consommé
|
||||
task["next_run_at"] = compute_next_run(task).isoformat()
|
||||
_write_tasks(tasks)
|
||||
if manual:
|
||||
from backend.notify import broadcast
|
||||
|
||||
broadcast("schedule_success", f"Tâche « {task['name']} » OK", "Exécution manuelle réussie.")
|
||||
return {"task_id": task_id, "ok": True, "result": result}
|
||||
except Exception as e:
|
||||
task["last_run_at"] = datetime.now(timezone.utc).isoformat()
|
||||
task["last_status"] = "error"
|
||||
task["last_error"] = str(e)
|
||||
task["run_count"] = int(task.get("run_count", 0)) + 1
|
||||
task["next_run_at"] = compute_next_run(task).isoformat()
|
||||
_write_tasks(tasks)
|
||||
logger.warning(f"Scheduled task '{task.get('name')}' failed: {e}")
|
||||
if task["action"]["kind"] != "notify":
|
||||
try:
|
||||
from backend.notify import broadcast
|
||||
|
||||
broadcast(
|
||||
"schedule_failure",
|
||||
f"Échec tâche « {task.get('name')} »",
|
||||
f"{e}",
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failure notification broadcast failed", exc_info=True)
|
||||
return {"task_id": task_id, "ok": False, "error": str(e)}
|
||||
|
||||
|
||||
def tick(now: datetime | None = None) -> list[dict[str, Any]]:
|
||||
"""Run every due task. Returns per-task outcomes (empty when idle)."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
outcomes: list[dict[str, Any]] = []
|
||||
for task in _read_tasks():
|
||||
if not task.get("enabled", True):
|
||||
continue
|
||||
try:
|
||||
next_run = datetime.fromisoformat(str(task.get("next_run_at") or ""))
|
||||
if next_run.tzinfo is None:
|
||||
next_run = next_run.replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
next_run = compute_next_run(task, now)
|
||||
if next_run <= now:
|
||||
outcomes.append(run_task(task["id"]))
|
||||
return outcomes
|
||||
+70
-2
@@ -151,6 +151,54 @@ class BacklinksResponse(BaseModel):
|
||||
total: int
|
||||
|
||||
|
||||
class ChatMessageItem(BaseModel):
|
||||
"""One chat message (``GET/POST /api/file/{vault}/chat`` + ``/api/chat``)."""
|
||||
|
||||
id: str = Field(description="Message id")
|
||||
user: str = Field(description="Author username")
|
||||
text: str = Field(description="Message body")
|
||||
ts: float = Field(description="Unix timestamp")
|
||||
attachment: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="Optional image/video/url attachment {name, url, mime, kind}",
|
||||
)
|
||||
preview: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="Link preview card {url, title, description, image, site} (#191)",
|
||||
)
|
||||
html: str = Field(
|
||||
default="",
|
||||
description=(
|
||||
"Rendered (mistune + sanitized) HTML of ``text``, like a document "
|
||||
"preview — tables, lists, fenced code blocks (#193)"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ChatHistoryResponse(BaseModel):
|
||||
"""Response for ``GET /api/file/{vault}/chat``."""
|
||||
|
||||
messages: list[ChatMessageItem] = Field(description="Messages, chronological")
|
||||
read: dict[str, float] = Field(
|
||||
default_factory=dict,
|
||||
description="{username: last read unix ts} per participant (#192)",
|
||||
)
|
||||
|
||||
|
||||
class ChatReadResponse(BaseModel):
|
||||
"""Response for ``POST /api/chat/read`` (#192 read receipts)."""
|
||||
|
||||
read: dict[str, float] = Field(description="{username: last read unix ts}")
|
||||
status: str = Field(description="'ok'")
|
||||
|
||||
|
||||
class ChatMessageResponse(BaseModel):
|
||||
"""Response for ``POST /api/file/{vault}/chat``."""
|
||||
|
||||
message: ChatMessageItem
|
||||
status: str = Field(description="'ok'")
|
||||
|
||||
|
||||
class BackupsListResponse(BaseModel):
|
||||
"""Response for ``GET /api/backups``."""
|
||||
|
||||
@@ -301,6 +349,14 @@ class FileContentResponse(BaseModel):
|
||||
"when the sheet exceeds the 500x40 render caps (#153 A8)"
|
||||
),
|
||||
)
|
||||
xlsx_revision: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Optimistic-concurrency token of the spreadsheet (#156-A12): the "
|
||||
"client sends it back as the `if_match` of a write so a change made "
|
||||
"elsewhere is refused (409 `conflict`) instead of overwritten"
|
||||
),
|
||||
)
|
||||
xlsx_lossy_features: list[str] | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
@@ -401,6 +457,12 @@ class FileSaveResponse(BaseModel):
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path within the vault")
|
||||
size: int = Field(description="Size of saved content in characters")
|
||||
# #156-A12 — optimistic-concurrency token of the file AFTER the write, so a
|
||||
# client can chain writes without re-reading (absent on non-spreadsheets).
|
||||
revision: str | None = Field(
|
||||
default=None,
|
||||
description="Opaque revision of the saved spreadsheet (send it back as `if_match`)",
|
||||
)
|
||||
|
||||
|
||||
class FileDeleteResponse(BaseModel):
|
||||
@@ -549,13 +611,15 @@ class BookmarkToggleRequest(BaseModel):
|
||||
class SearchResultItem(BaseModel):
|
||||
"""A single search result."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path")
|
||||
title: str = Field(description="File title")
|
||||
tags: list[str] = Field(description="File tags")
|
||||
score: int = Field(description="Relevance score")
|
||||
snippet: str = Field(description="Content excerpt with highlights")
|
||||
modified: str = Field(description="ISO 8601 modification timestamp")
|
||||
modified: str | None = Field(default=None, description="ISO 8601 modification timestamp")
|
||||
share_token: str | None = Field(default=None, description="#196 token de partage dirigé (document reçu)")
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
@@ -632,6 +696,7 @@ class SearchFacets(BaseModel):
|
||||
|
||||
tags: dict[str, int] = Field(default_factory=dict)
|
||||
vaults: dict[str, int] = Field(default_factory=dict)
|
||||
extensions: dict[str, int] = Field(default_factory=dict, description="Counts per file extension, dotless and lowercase (query-ready for the ext: operator)")
|
||||
|
||||
|
||||
class AdvancedSearchResponse(BaseModel):
|
||||
@@ -641,7 +706,7 @@ class AdvancedSearchResponse(BaseModel):
|
||||
total: int = Field(description="Total number of matching results")
|
||||
offset: int = Field(description="Current pagination offset")
|
||||
limit: int = Field(description="Page size")
|
||||
facets: SearchFacets = Field(description="Faceted counts by tag and vault")
|
||||
facets: SearchFacets = Field(description="Faceted counts by tag, vault and file extension")
|
||||
query_time_ms: float = Field(default=0, description="Server-side query time in milliseconds")
|
||||
semantic_available: bool = Field(default=False, description="True when the semantic (embedding) index is ready")
|
||||
|
||||
@@ -652,6 +717,7 @@ class TitleSuggestion(BaseModel):
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path")
|
||||
title: str = Field(description="File title")
|
||||
tags: list[str] = Field(default_factory=list, description="File tags")
|
||||
|
||||
|
||||
class SuggestResponse(BaseModel):
|
||||
@@ -847,6 +913,7 @@ class VaultFileEntry(BaseModel):
|
||||
modified_iso: str | None = None
|
||||
extension: str = ""
|
||||
rel_dir: str | None = None
|
||||
tags: list[str] = Field(default_factory=list, description="Tags de l'index (#158)")
|
||||
|
||||
|
||||
class VaultFilesResponse(BaseModel):
|
||||
@@ -991,6 +1058,7 @@ class ShareModel(BaseModel):
|
||||
expires_at: str | None = None
|
||||
access_count: int = 0
|
||||
last_accessed: str | None = None
|
||||
shared_with: list[str] = Field(default_factory=list, description="#196 destinataires (partage dirigé)")
|
||||
|
||||
|
||||
class ConflictEntry(BaseModel):
|
||||
|
||||
+16
-3
@@ -373,6 +373,7 @@ class InvertedIndex:
|
||||
self.doc_vault: dict[str, str] = {}
|
||||
self.vault_docs: dict[str, set] = defaultdict(set)
|
||||
self.tag_docs: dict[str, set] = defaultdict(set)
|
||||
self.doc_tags: dict[str, set] = defaultdict(set)
|
||||
self._sorted_tokens: SortedList = SortedList()
|
||||
self._ready: bool = False # True after initial build
|
||||
|
||||
@@ -403,6 +404,7 @@ class InvertedIndex:
|
||||
self.doc_vault = {}
|
||||
self.vault_docs = defaultdict(set)
|
||||
self.tag_docs = defaultdict(set)
|
||||
self.doc_tags = defaultdict(set)
|
||||
|
||||
for vault_name, vault_data in _indexer.index.items():
|
||||
for file_info in vault_data.get("files", []):
|
||||
@@ -417,6 +419,7 @@ class InvertedIndex:
|
||||
# --- Per-document tag index ---
|
||||
for tag in file_info.get("tags", []):
|
||||
self.tag_docs[tag.lower()].add(doc_key)
|
||||
self.doc_tags[file_info['path']].add(tag.lower())
|
||||
|
||||
# --- Title tokens ---
|
||||
title_tokens = tokenize(file_info.get("title", ""))
|
||||
@@ -1324,6 +1327,7 @@ def advanced_search(
|
||||
scored_results: list[tuple[float, dict[str, Any]]] = []
|
||||
facet_tags: dict[str, int] = defaultdict(int)
|
||||
facet_vaults: dict[str, int] = defaultdict(int)
|
||||
facet_extensions: dict[str, int] = defaultdict(int)
|
||||
|
||||
# Pre-compute prefix expansions once per term (avoid repeated binary search)
|
||||
prefix_expansions: dict[str, list[str]] = {}
|
||||
@@ -1423,6 +1427,11 @@ def advanced_search(
|
||||
facet_vaults[result["vault"]] = facet_vaults.get(result["vault"], 0) + 1
|
||||
for tag in result.get("tags", []):
|
||||
facet_tags[tag] = facet_tags.get(tag, 0) + 1
|
||||
# Extension normalised without leading dot ("md", not ".md") so it can be
|
||||
# fed back directly as the `ext:` query operator.
|
||||
ext = str(result.get("extension") or "").lower().lstrip(".")
|
||||
if ext:
|
||||
facet_extensions[ext] = facet_extensions.get(ext, 0) + 1
|
||||
|
||||
total = len(scored_results)
|
||||
page = scored_results[offset: offset + limit]
|
||||
@@ -1436,6 +1445,7 @@ def advanced_search(
|
||||
"facets": {
|
||||
"tags": dict(sorted(facet_tags.items(), key=lambda x: -x[1])[:20]),
|
||||
"vaults": dict(sorted(facet_vaults.items(), key=lambda x: -x[1])),
|
||||
"extensions": dict(sorted(facet_extensions.items(), key=lambda x: -x[1])),
|
||||
},
|
||||
"query_time_ms": elapsed_ms,
|
||||
"semantic_available": semantic_available,
|
||||
@@ -1538,7 +1548,7 @@ def suggest_titles(
|
||||
prefix: str,
|
||||
vault_filter: str = "all",
|
||||
limit: int = SUGGEST_LIMIT,
|
||||
) -> list[dict[str, str]]:
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Suggest file titles matching a prefix (accent-insensitive).
|
||||
|
||||
Args:
|
||||
@@ -1547,7 +1557,7 @@ def suggest_titles(
|
||||
limit: Maximum suggestions.
|
||||
|
||||
Returns:
|
||||
List of ``{"vault", "path", "title"}`` dicts.
|
||||
List of ``{"vault", "path", "title", "tags"}`` dicts.
|
||||
"""
|
||||
if not prefix or len(prefix) < MIN_PREFIX_LENGTH:
|
||||
return []
|
||||
@@ -1565,7 +1575,10 @@ def suggest_titles(
|
||||
key = f"{entry['vault']}::{entry['path']}"
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
results.append(entry)
|
||||
# Add tags from the index
|
||||
entry_with_tags: dict[str, Any] = dict(entry)
|
||||
entry_with_tags["tags"] = list(inv.doc_tags.get(entry["path"], set()))
|
||||
results.append(entry_with_tags)
|
||||
if len(results) >= limit:
|
||||
return results
|
||||
|
||||
|
||||
+191
-38
@@ -1,48 +1,104 @@
|
||||
"""
|
||||
Secret redactor: masks sensitive patterns in rendered text.
|
||||
|
||||
Scans for common secret patterns and replaces them with [MASQUÉ]
|
||||
before content is served to the frontend. Prevents accidental
|
||||
exposure of API keys, tokens, and passwords in previews.
|
||||
Scans for common secret patterns and replaces them with a French mask
|
||||
label (``[CLÉ API MASQUÉE]``, ``[MOT DE PASSE MASQUÉ]``, …) before content
|
||||
is served to the frontend. Prevents accidental exposure of API keys,
|
||||
tokens, and passwords in previews.
|
||||
|
||||
Patterns detected:
|
||||
- Generic API keys (long alphanumeric strings with key/secret/token prefix)
|
||||
- Generic API keys (``api_key=…``, ``token: …`` — values of 8+ chars)
|
||||
- Passwords (``password=…``, ``"passwd": "…"`` — any length)
|
||||
- JWT tokens (eyJ... base64url)
|
||||
- AWS-style keys (AKIA..., sk-..., etc.)
|
||||
- Provider key formats: OpenAI/Anthropic/OpenRouter (``sk-``), Stripe,
|
||||
GitLab, Google (``AIza…`` / ``ya29.``), AWS, GitHub, Slack, SendGrid,
|
||||
Hugging Face, npm, Docker, Resend, Square, Atlassian, Discord,
|
||||
Telegram, ``Bearer …`` tokens
|
||||
- Private key blocks (-----BEGIN ... PRIVATE KEY-----)
|
||||
- Connection strings with passwords
|
||||
- Bare hex secrets next to a secret keyword (BUG-035)
|
||||
|
||||
Interactive masking (feature #188): :func:`redact_with_placeholders`
|
||||
returns the text with every mask replaced by an opaque placeholder plus
|
||||
the list of ``(label, secret)`` entries; :func:`restore_masks` turns the
|
||||
placeholders back into plain labels (public shares, PDF exports, AI
|
||||
context) or into clickable ``<span class="secret-mask" data-secret="…">``
|
||||
badges (authenticated app preview) so a click copies the real value to
|
||||
the clipboard.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html as _html
|
||||
import logging
|
||||
import re
|
||||
|
||||
logger = logging.getLogger("obsigate.redactor")
|
||||
|
||||
# --- Patterns ---
|
||||
# Order matters: more specific patterns first
|
||||
_PATTERNS = [
|
||||
# Each entry is ``(pattern, replacement, secret_group)``:
|
||||
# * ``replacement``: a literal label, a ``\\1``-style template, or a
|
||||
# callable receiving the match and returning the visible label;
|
||||
# * ``secret_group``: index of the group holding the value that a click
|
||||
# copies to the clipboard (feature #188).
|
||||
_PATTERNS: list[tuple[re.Pattern[str], object, int]] = [
|
||||
# Private key blocks
|
||||
(re.compile(r'-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----.*?-----END (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----', re.DOTALL), '[CLÉ PRIVÉE MASQUÉE]'),
|
||||
|
||||
(re.compile(r'-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----.*?-----END (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----', re.DOTALL), '[CLÉ PRIVÉE MASQUÉE]', 0),
|
||||
|
||||
# JWT tokens (base64url encoded, starts with eyJ)
|
||||
(re.compile(r'eyJ[a-zA-Z0-9_-]{20,}\.[a-zA-Z0-9_-]{20,}\.[a-zA-Z0-9_-]{20,}'), '[JWT MASQUÉ]'),
|
||||
|
||||
(re.compile(r'eyJ[a-zA-Z0-9_-]{20,}\.[a-zA-Z0-9_-]{20,}\.[a-zA-Z0-9_-]{20,}'), '[JWT MASQUÉ]', 0),
|
||||
|
||||
# Connection strings with passwords
|
||||
(re.compile(r'(?:mongodb|mysql|postgres(?:ql)?|redis|sqlite)://[^:]+:[^@\s]+@'), '[CONNECTION_STRING MASQUÉE]'),
|
||||
|
||||
# Generic API key patterns: key=... or token=... or secret=...
|
||||
(re.compile(r'(?:api[_-]?key|apikey|secret|token|password|passwd|auth[_-]?token)\s*[:=]\s*[\'"]?([^\s\'"]{20,})[\'"]?', re.IGNORECASE),
|
||||
lambda m: f'{m.group(0).split("=")[0].split(":")[0]}=[MASQUÉ]' if "=" in m.group(0) or ":" in m.group(0) else '[MASQUÉ]'),
|
||||
|
||||
# Prefixed API keys (sk-..., pk-..., rk-...)
|
||||
(re.compile(r'(?:sk|pk|rk)-[a-zA-Z0-9]{20,}'), '[CLÉ API MASQUÉE]'),
|
||||
|
||||
# AWS access keys
|
||||
(re.compile(r'AKIA[0-9A-Z]{16}'), '[AWS_KEY MASQUÉ]'),
|
||||
|
||||
(re.compile(r'(?:mongodb|mysql|postgres(?:ql)?|redis|sqlite)://[^:]+:[^@\s]+@'), '[CONNECTION_STRING MASQUÉE]', 0),
|
||||
|
||||
# Passwords — any length, bare or quoted (``password=…``,
|
||||
# ``"passwd": "…"``). The left side may carry a qualifier
|
||||
# (``db_password``, ``DATABASE.PASSWORD``, ``user_pwd``); a *bare*
|
||||
# ``PWD=`` (shell working directory) must NOT match, hence ``pwd``
|
||||
# only in its qualified branch.
|
||||
(re.compile(
|
||||
r'(?i)((?:[A-Za-z0-9_.-]*(?:password|passwd|passphrase|mot\s+de\s+passe)'
|
||||
r'|[A-Za-z0-9_.-]+pwd)["\']?\s*[:=]\s*["\']?)([^\s"\',;]{4,})'),
|
||||
r'\1[MOT DE PASSE MASQUÉ]', 2),
|
||||
|
||||
# Generic API key assignments: api_key=…, token=…, secret=… — values
|
||||
# of 8+ characters (short enough to catch real keys, long enough to
|
||||
# skip plain words).
|
||||
(re.compile(r'(?i)([A-Za-z0-9_.-]*(?:api[_-]?key|apikey|secret|token|auth[_-]?token)["\']?\s*[:=]\s*["\']?)([^\s\'"]{8,})'),
|
||||
lambda m: f'{m.group(1)}[MASQUÉ]' if ("=" in m.group(0) or ":" in m.group(0)) else '[MASQUÉ]', 2),
|
||||
|
||||
# GitHub tokens (ghp_, gho_, ghu_, ghs_, ghr_)
|
||||
(re.compile(r'gh[pousr]_[a-zA-Z0-9]{36,}'), '[GITHUB_TOKEN MASQUÉ]'),
|
||||
|
||||
(re.compile(r'gh[pousr]_[a-zA-Z0-9]{36,}'), '[GITHUB_TOKEN MASQUÉ]', 0),
|
||||
|
||||
# AWS access keys
|
||||
(re.compile(r'(?:AKIA|ASIA)[0-9A-Z]{16}'), '[AWS_KEY MASQUÉ]', 0),
|
||||
|
||||
# Provider key formats (feature #188) — one alternation covering the
|
||||
# large majority of token shapes in the wild.
|
||||
(re.compile(
|
||||
r'(?<![A-Za-z0-9])(?:'
|
||||
r'sk-[A-Za-z0-9_\-]{16,}' # OpenAI / Anthropic / OpenRouter
|
||||
r'|sk_(?:live|test)_[A-Za-z0-9]{10,}' # Stripe secret key
|
||||
r'|pk_(?:live|test)_[A-Za-z0-9]{10,}' # Stripe publishable key
|
||||
r'|whsec_[A-Za-z0-9]{16,}' # Stripe / Svix webhook secret
|
||||
r'|glpat-[A-Za-z0-9_\-]{20,}' # GitLab personal access token
|
||||
r'|github_pat_[A-Za-z0-9_]{22,}' # GitHub fine-grained PAT
|
||||
r'|npm_[A-Za-z0-9]{36}' # npm automation token
|
||||
r'|dckr_pat_[A-Za-z0-9_\-]{20,}' # Docker Hub token
|
||||
r'|hf_[A-Za-z0-9]{30,}' # Hugging Face token
|
||||
r'|AIza[0-9A-Za-z_\-]{35}' # Google API key
|
||||
r'|ya29\.[0-9A-Za-z_\-]{20,}' # Google OAuth access token
|
||||
r'|xox[baprs]-[0-9A-Za-z\-]{10,}' # Slack token
|
||||
r'|SG\.[A-Za-z0-9_\-]{16,}' # SendGrid API key
|
||||
r'|re_[A-Za-z0-9]{40}' # Resend API key
|
||||
r'|sq0[a-z]{3}-[A-Za-z0-9_\-]{16,}' # Square access token
|
||||
r'|ATATT[A-Za-z0-9_\-]{20,}' # Atlassian access token
|
||||
r'|[NOP][A-Za-z0-9_\-]{23,28}\.[A-Za-z0-9_\-]{6}\.[A-Za-z0-9_\-]{27,}' # Discord bot token
|
||||
r'|\d{8,10}:[A-Za-z0-9_\-]{35}' # Telegram bot token
|
||||
r'|Bearer\s+[A-Za-z0-9._~+/=\-]{20,}' # Authorization: Bearer …
|
||||
r')'),
|
||||
'[CLÉ API MASQUÉE]', 0),
|
||||
|
||||
]
|
||||
|
||||
# BUG-035: bare 40–64 char hex strings used to be redacted unconditionally,
|
||||
@@ -61,14 +117,23 @@ _HASH_CONTEXT_RE = re.compile(
|
||||
#: How far before the hex string a keyword may appear to count as context.
|
||||
_HEX_CONTEXT_WINDOW = 60
|
||||
|
||||
# --- Interactive masking (feature #188) ---
|
||||
# Private-use-area sentinels: they survive markdown rendering (mistune
|
||||
# treats them as plain text, fenced code blocks included) and are
|
||||
# stripped by ``backend.render._heading_slugify``.
|
||||
_PLACEHOLDER_OPEN = "\uE000"
|
||||
_PLACEHOLDER_CLOSE = "\uE001"
|
||||
_PLACEHOLDER_RE = re.compile("\uE000(\\d+)\uE001")
|
||||
|
||||
def _redact_bare_hex_secrets(text: str) -> tuple:
|
||||
|
||||
def _redact_bare_hex_secrets(text: str, mask) -> tuple[str, int]:
|
||||
"""Redact 40–64 char hex strings only when a secret keyword is nearby.
|
||||
|
||||
Git/SHA/checksum contexts are left untouched (BUG-035).
|
||||
|
||||
Args:
|
||||
text: Text to scan.
|
||||
mask: ``mask(original, label) -> str`` replacement builder.
|
||||
|
||||
Returns:
|
||||
(redacted_text, redaction_count) tuple.
|
||||
@@ -82,12 +147,38 @@ def _redact_bare_hex_secrets(text: str) -> tuple:
|
||||
return match.group(0)
|
||||
if _SECRET_CONTEXT_RE.search(window):
|
||||
count += 1
|
||||
return '[HEX_KEY MASQUÉ]'
|
||||
return mask(match.group(0), '[HEX_KEY MASQUÉ]')
|
||||
return match.group(0)
|
||||
|
||||
return _HEX_RE.sub(_replace, text), count
|
||||
|
||||
|
||||
def _redact(text: str, mask) -> tuple[str, int]:
|
||||
"""Apply every pattern; ``mask(original, label) -> str`` builds the
|
||||
replacement (plain label, or placeholder for the interactive mode)."""
|
||||
count = 0
|
||||
result = text
|
||||
for pattern, replacement, secret_group in _PATTERNS:
|
||||
|
||||
def _sub(match: re.Match, replacement=replacement, secret_group=secret_group) -> str:
|
||||
if callable(replacement):
|
||||
label = replacement(match)
|
||||
else:
|
||||
label = match.expand(str(replacement))
|
||||
return mask(match.group(secret_group), label)
|
||||
|
||||
new_result, n = pattern.subn(_sub, result)
|
||||
count += n
|
||||
result = new_result
|
||||
result, hex_count = _redact_bare_hex_secrets(result, mask)
|
||||
return result, count + hex_count
|
||||
|
||||
|
||||
def _plain_mask(original: str, label: str) -> str:
|
||||
"""Plain masking: only the visible label survives."""
|
||||
return label
|
||||
|
||||
|
||||
def redact(text: str) -> tuple:
|
||||
"""Redact sensitive patterns from text.
|
||||
|
||||
@@ -97,22 +188,84 @@ def redact(text: str) -> tuple:
|
||||
Returns:
|
||||
(redacted_text, redaction_count) tuple.
|
||||
"""
|
||||
count = 0
|
||||
result = text
|
||||
for pattern, replacement in _PATTERNS:
|
||||
if callable(replacement):
|
||||
new_result, n = pattern.subn(replacement, result)
|
||||
else:
|
||||
new_result, n = pattern.subn(str(replacement), result)
|
||||
count += n
|
||||
result = new_result
|
||||
result, hex_count = _redact_bare_hex_secrets(result)
|
||||
count += hex_count
|
||||
result, count = _redact(text, _plain_mask)
|
||||
if count > 0:
|
||||
logger.info(f"Redacted {count} secret(s) from content")
|
||||
return result, count
|
||||
|
||||
|
||||
def redact_with_placeholders(text: str, file_path: str = "") -> tuple[str, list[tuple[str, str]]]:
|
||||
"""Redact *text*, replacing every mask with an opaque placeholder.
|
||||
|
||||
Used by the markdown rendering pipeline: placeholders survive the
|
||||
markdown → HTML conversion (fenced code blocks included, where a
|
||||
literal ``<span>`` would be shown as text), then
|
||||
:func:`restore_masks` turns them back into labels or clickable badges.
|
||||
|
||||
Args:
|
||||
text: The raw text content to scan.
|
||||
file_path: Optional file path for logging context.
|
||||
|
||||
Returns:
|
||||
(text_with_placeholders, entries) where *entries* is the list of
|
||||
``(label, secret)`` tuples referenced by the placeholders, in
|
||||
order of appearance.
|
||||
"""
|
||||
entries: list[tuple[str, str]] = []
|
||||
|
||||
def mask(original: str, label: str) -> str:
|
||||
entries.append((label, original))
|
||||
return f"{_PLACEHOLDER_OPEN}{len(entries) - 1}{_PLACEHOLDER_CLOSE}"
|
||||
|
||||
result, count = _redact(text, mask)
|
||||
if count > 0:
|
||||
logger.warning(f"Redacted {count} potential secret(s) from {file_path or '<unknown>'}")
|
||||
return result, entries
|
||||
|
||||
|
||||
def restore_masks(
|
||||
text: str,
|
||||
entries: list[tuple[str, str]],
|
||||
*,
|
||||
click_to_copy: bool = False,
|
||||
) -> str:
|
||||
"""Turn placeholders produced by :func:`redact_with_placeholders` back
|
||||
into visible masks.
|
||||
|
||||
Args:
|
||||
text: Rendered HTML still containing placeholders.
|
||||
entries: The ``(label, secret)`` list returned alongside.
|
||||
click_to_copy: When True (authenticated app preview), each mask
|
||||
becomes ``<span class="secret-mask" data-secret="…">label</span>``
|
||||
so a click copies the real value. When False (public shares,
|
||||
PDF exports), only the plain label is restored — the secret
|
||||
never reaches the page.
|
||||
|
||||
Returns:
|
||||
The text with every placeholder replaced.
|
||||
"""
|
||||
if not entries:
|
||||
return text
|
||||
|
||||
def _sub(match: re.Match) -> str:
|
||||
idx = int(match.group(1))
|
||||
if idx >= len(entries):
|
||||
return ""
|
||||
label, original = entries[idx]
|
||||
label_esc = _html.escape(str(label), quote=False)
|
||||
if not click_to_copy:
|
||||
return label_esc
|
||||
return (
|
||||
'<span class="secret-mask" data-secret="'
|
||||
+ _html.escape(str(original), quote=True)
|
||||
+ '">'
|
||||
+ label_esc
|
||||
+ "</span>"
|
||||
)
|
||||
|
||||
return _PLACEHOLDER_RE.sub(_sub, text)
|
||||
|
||||
|
||||
def redact_file_content(content: str, file_path: str = "") -> str:
|
||||
"""Redact a file's content for preview rendering.
|
||||
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Duplicate detection & merge services (#166).
|
||||
|
||||
Single source of truth consumed by the REST routes
|
||||
(``/api/duplicates``) and the AI tool layer (``find_duplicates``,
|
||||
``merge_duplicate_notes``).
|
||||
|
||||
Method is deterministic stdlib-only: frontmatter stripped, token-set
|
||||
Jaccard blended with a title similarity. No embedding dependency —
|
||||
the semantic index (#70) stays an optional refinement, not a requirement.
|
||||
|
||||
Fusion never runs without an explicit confirmation: the tool layer
|
||||
registers the merge as ``DANGEROUS`` (two-step propose/apply) and this
|
||||
service takes an automatic backup before any destructive write.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from difflib import SequenceMatcher
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from backend.services.backups import create_backup
|
||||
from backend.services.errors import ServiceError
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.services.vaults import get_vault_root
|
||||
|
||||
logger = logging.getLogger("obsigate.services.duplicates")
|
||||
|
||||
MAX_FILES_SCANNED = 500
|
||||
MAX_FILE_BYTES = 200_000
|
||||
MAX_CONTENT_CHARS = 50_000
|
||||
|
||||
_WORD_RE = re.compile(r"[\w]+", re.UNICODE)
|
||||
_FRONTMATTER_RE = re.compile(r"\A---\s*\n.*?\n---\s*\n", re.DOTALL)
|
||||
|
||||
|
||||
def _strip_frontmatter(text: str) -> str:
|
||||
"""Remove a leading YAML frontmatter block, if present."""
|
||||
return _FRONTMATTER_RE.sub("", text, count=1)
|
||||
|
||||
|
||||
def _tokens(text: str) -> set[str]:
|
||||
"""Lowercase word tokens (keeps accents), stop-words free but tiny tokens dropped."""
|
||||
return {t for t in _WORD_RE.findall(text.lower()) if len(t) > 2}
|
||||
|
||||
|
||||
def similarity_score(a: str, b: str) -> float:
|
||||
"""Blend Jaccard (0.7) + title/first-line similarity (0.3) in [0, 1].
|
||||
|
||||
Pure function — unit-tested directly.
|
||||
"""
|
||||
ta, tb = _tokens(_strip_frontmatter(a)), _tokens(_strip_frontmatter(b))
|
||||
if not ta or not tb:
|
||||
return 0.0
|
||||
jaccard = len(ta & tb) / len(ta | tb)
|
||||
head_a = (a.strip().splitlines() or [""])[:1][0][:200].lower()
|
||||
head_b = (b.strip().splitlines() or [""])[:1][0][:200].lower()
|
||||
title_sim = SequenceMatcher(None, head_a, head_b).ratio() if head_a and head_b else 0.0
|
||||
return round(0.7 * jaccard + 0.3 * title_sim, 4)
|
||||
|
||||
|
||||
def _iter_markdown_files(root: Path, subdir: str = "") -> list[Path]:
|
||||
base = resolve_safe_path(root, subdir) if subdir else root.resolve()
|
||||
if not base.exists() or not base.is_dir():
|
||||
raise ServiceError(
|
||||
f"Directory not found: {subdir or '.'}",
|
||||
code="not_found",
|
||||
status=404,
|
||||
details={"path": subdir},
|
||||
)
|
||||
files = sorted(
|
||||
(p for p in base.rglob("*.md") if p.is_file() and not p.is_symlink()),
|
||||
key=lambda p: str(p),
|
||||
)
|
||||
return files[:MAX_FILES_SCANNED]
|
||||
|
||||
|
||||
def _read_capped(path: Path) -> str:
|
||||
try:
|
||||
if path.stat().st_size > MAX_FILE_BYTES:
|
||||
return ""
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return ""
|
||||
return text[:MAX_CONTENT_CHARS]
|
||||
|
||||
|
||||
def find_duplicate_pairs(
|
||||
vault: str,
|
||||
threshold: float = 0.75,
|
||||
limit: int = 50,
|
||||
subdir: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Return candidate duplicate pairs ordered by descending score.
|
||||
|
||||
Args:
|
||||
vault: Vault name.
|
||||
threshold: Minimum blended score in [0.3, 1.0].
|
||||
limit: Max pairs returned (1-200).
|
||||
subdir: Optional vault-relative directory scope.
|
||||
"""
|
||||
if not 0.3 <= threshold <= 1.0:
|
||||
raise ServiceError(
|
||||
"threshold must be between 0.3 and 1.0",
|
||||
code="invalid_arguments",
|
||||
status=400,
|
||||
)
|
||||
limit = max(1, min(limit, 200))
|
||||
root = get_vault_root(vault)
|
||||
files = _iter_markdown_files(root, subdir)
|
||||
contents: dict[str, str] = {}
|
||||
token_sets: dict[str, set[str]] = {}
|
||||
for path in files:
|
||||
rel = str(path.relative_to(root)).replace("\\", "/")
|
||||
text = _read_capped(path)
|
||||
if not text.strip():
|
||||
continue
|
||||
contents[rel] = text
|
||||
token_sets[rel] = _tokens(_strip_frontmatter(text))
|
||||
|
||||
rels = sorted(contents)
|
||||
pairs: list[dict[str, Any]] = []
|
||||
for i in range(len(rels)):
|
||||
for j in range(i + 1, len(rels)):
|
||||
a, b = rels[i], rels[j]
|
||||
ta, tb = token_sets[a], token_sets[b]
|
||||
if not ta or not tb:
|
||||
continue
|
||||
# Cheap pre-filter: Jaccard lower bound before the full score.
|
||||
inter = len(ta & tb)
|
||||
union = len(ta | tb)
|
||||
if union == 0 or inter / union < threshold * 0.6:
|
||||
continue
|
||||
score = similarity_score(contents[a], contents[b])
|
||||
if score >= threshold:
|
||||
pairs.append({"file_a": a, "file_b": b, "score": score})
|
||||
pairs.sort(key=lambda p: p["score"], reverse=True)
|
||||
return {
|
||||
"vault": vault,
|
||||
"threshold": threshold,
|
||||
"files_scanned": len(contents),
|
||||
"truncated": len(files) >= MAX_FILES_SCANNED,
|
||||
"pairs": pairs[:limit],
|
||||
}
|
||||
|
||||
|
||||
def merge_duplicates(
|
||||
vault: str,
|
||||
source_path: str,
|
||||
target_path: str,
|
||||
strategy: str = "append",
|
||||
) -> dict[str, Any]:
|
||||
"""Merge *source_path* into *target_path*, then delete the source.
|
||||
|
||||
Strategies:
|
||||
``append`` — source content appended after target (separator + origin
|
||||
marker), source deleted.
|
||||
``prefer_target`` — source deleted, target untouched (dedupe only).
|
||||
``prefer_source`` — target overwritten with source content, source deleted.
|
||||
|
||||
A backup of both files is taken first; the source deletion also goes
|
||||
through the backup-aware mutation service.
|
||||
"""
|
||||
from backend.services import mutations as _mutations
|
||||
|
||||
if strategy not in ("append", "prefer_target", "prefer_source"):
|
||||
raise ServiceError(
|
||||
f"Unknown strategy: {strategy}",
|
||||
code="invalid_arguments",
|
||||
status=400,
|
||||
)
|
||||
if source_path == target_path:
|
||||
raise ServiceError(
|
||||
"source_path and target_path must differ",
|
||||
code="invalid_arguments",
|
||||
status=400,
|
||||
)
|
||||
root = get_vault_root(vault)
|
||||
src = resolve_safe_path(root, source_path)
|
||||
dst = resolve_safe_path(root, target_path)
|
||||
if not src.is_file() or src.suffix.lower() != ".md":
|
||||
raise ServiceError(
|
||||
f"Source not found: {source_path}",
|
||||
code="not_found",
|
||||
status=404,
|
||||
details={"path": source_path},
|
||||
)
|
||||
if not dst.is_file() or dst.suffix.lower() != ".md":
|
||||
raise ServiceError(
|
||||
f"Target not found: {target_path}",
|
||||
code="not_found",
|
||||
status=404,
|
||||
details={"path": target_path},
|
||||
)
|
||||
# Backup préalable (jamais de fusion sans filet — critère #166).
|
||||
create_backup(src, vault, source_path)
|
||||
create_backup(dst, vault, target_path)
|
||||
|
||||
if strategy == "prefer_target":
|
||||
result = _mutations.delete_file(vault, source_path)
|
||||
return {"strategy": strategy, "target": target_path, "deleted": source_path, "delete": result}
|
||||
if strategy == "prefer_source":
|
||||
content = src.read_text(encoding="utf-8", errors="replace")
|
||||
result = _mutations.edit_file(vault, target_path, content)
|
||||
deleted = _mutations.delete_file(vault, source_path)
|
||||
return {"strategy": strategy, "target": target_path, "edit": result, "deleted": source_path, "delete": deleted}
|
||||
# append
|
||||
target_text = dst.read_text(encoding="utf-8", errors="replace")
|
||||
source_text = src.read_text(encoding="utf-8", errors="replace")
|
||||
merged = target_text.rstrip() + f"\n\n---\n\n_Fusionné depuis `{source_path}` (#166)_\n\n" + source_text.lstrip()
|
||||
result = _mutations.edit_file(vault, target_path, merged)
|
||||
deleted = _mutations.delete_file(vault, source_path)
|
||||
return {"strategy": strategy, "target": target_path, "edit": result, "deleted": source_path, "delete": deleted}
|
||||
+603
-14
@@ -41,6 +41,28 @@ _EXCALIDRAW_SKELETON = (
|
||||
'"appState":{"viewBackgroundColor":"#ffffff"},"files":{}}'
|
||||
)
|
||||
|
||||
# Title of the single sheet of a workbook created from scratch (#186).
|
||||
_XLSX_DEFAULT_SHEET = "Feuille1"
|
||||
|
||||
|
||||
def _empty_xlsx_bytes() -> bytes:
|
||||
"""Return the bytes of a valid, empty single-sheet workbook.
|
||||
|
||||
An ``.xlsx`` is a ZIP archive (OPC), not a text file: writing ``""`` to it
|
||||
produces a file no reader (and no spreadsheet application) can open. The
|
||||
"Create a file" modal posts an empty text payload, so the archive itself is
|
||||
built here with openpyxl.
|
||||
"""
|
||||
import io
|
||||
|
||||
from openpyxl import Workbook
|
||||
|
||||
wb = Workbook()
|
||||
wb.active.title = _XLSX_DEFAULT_SHEET
|
||||
buffer = io.BytesIO()
|
||||
wb.save(buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _rel(root: Path, path: Path) -> str:
|
||||
"""Return *path* relative to *root* as a POSIX-style string."""
|
||||
@@ -98,13 +120,14 @@ def create_file(
|
||||
*,
|
||||
overwrite: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a text file in a vault.
|
||||
"""Create a file in a vault.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Vault-relative path of the new file.
|
||||
content: Initial content (an Excalidraw skeleton is injected for empty
|
||||
``.excalidraw`` files).
|
||||
``.excalidraw`` files). ``.xlsx`` is binary: the workbook is always
|
||||
built from scratch and *content* is ignored for that extension.
|
||||
overwrite: When True, replace an existing file (with a backup) instead
|
||||
of raising ``already_exists``.
|
||||
|
||||
@@ -130,17 +153,25 @@ def create_file(
|
||||
)
|
||||
create_backup(file_path, vault_name, _rel(root, file_path))
|
||||
|
||||
suffix = file_path.suffix.lower()
|
||||
try:
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if file_path.suffix.lower() == ".excalidraw" and not content.strip():
|
||||
content = _EXCALIDRAW_SKELETON
|
||||
file_path.write_text(content, encoding="utf-8")
|
||||
if suffix == ".xlsx":
|
||||
# Binary OPC archive: a text write would leave an unopenable file
|
||||
# (#186). Written as bytes, so the size below is the byte count.
|
||||
payload = _empty_xlsx_bytes()
|
||||
file_path.write_bytes(payload)
|
||||
else:
|
||||
if suffix == ".excalidraw" and not content.strip():
|
||||
content = _EXCALIDRAW_SKELETON
|
||||
payload = content.encode("utf-8")
|
||||
file_path.write_text(content, encoding="utf-8")
|
||||
except PermissionError as e:
|
||||
raise ServiceError("Permission denied: cannot create file", code="permission_denied", status=403) from e
|
||||
|
||||
rel_path = _rel(root, file_path)
|
||||
logger.info(f"File created: {vault_name}/{rel_path}")
|
||||
return {"success": True, "vault": vault_name, "path": rel_path, "size": len(content)}
|
||||
return {"success": True, "vault": vault_name, "path": rel_path, "size": len(payload)}
|
||||
|
||||
|
||||
def create_directory(vault_name: str, path: str, *, exist_ok: bool = False) -> dict[str, Any]:
|
||||
@@ -278,6 +309,67 @@ def _xlsx_write_lock(key: str) -> Iterator[None]:
|
||||
lock.release()
|
||||
|
||||
|
||||
def _invalidate_meta(file_path: Path) -> None:
|
||||
"""Drop the cached workbook metadata after a write (#156-A13).
|
||||
|
||||
Imported lazily so the module stays importable when the spreadsheet reader
|
||||
is not needed (it pulls openpyxl in).
|
||||
"""
|
||||
try:
|
||||
from backend.xlsx_reader import invalidate_workbook_meta
|
||||
|
||||
invalidate_workbook_meta(file_path)
|
||||
except Exception: # pragma: no cover - cache invalidation is best-effort
|
||||
logger.debug("workbook meta invalidation skipped", exc_info=True)
|
||||
|
||||
|
||||
def file_revision(file_path: Path) -> str:
|
||||
"""Opaque revision token of a file — ``mtime_ns:size`` in hex (#156-A12).
|
||||
|
||||
Cheap by design (one ``stat``) and enough for optimistic concurrency: any
|
||||
writer that replaces the file changes at least one of the two fields. The
|
||||
viewer reads it with the file and sends it back as the ``if_match`` of a
|
||||
write, so an external editor (Excel, the watcher, another worker) can no
|
||||
longer be silently overwritten.
|
||||
|
||||
Returns:
|
||||
The token, or ``""`` when the file cannot be stat'ed.
|
||||
"""
|
||||
try:
|
||||
st = file_path.stat()
|
||||
except OSError:
|
||||
return ""
|
||||
return f"{st.st_mtime_ns:x}-{st.st_size:x}"
|
||||
|
||||
|
||||
def _check_revision(file_path: Path, expected: str | None) -> None:
|
||||
"""Refuse a write on a file that changed since it was read (#156-A12).
|
||||
|
||||
``expected`` comes from the read payload (``if_match``): when it is absent
|
||||
the write keeps its pre-A12 behaviour (last writer wins), so curl, the AI
|
||||
tools and the batch uploader are unaffected.
|
||||
|
||||
Raises:
|
||||
ServiceError: ``conflict`` (409, ``reason=stale_revision``) when the
|
||||
file on disk is not the revision the caller read.
|
||||
"""
|
||||
if not expected:
|
||||
return
|
||||
current = file_revision(file_path)
|
||||
if current and current != expected:
|
||||
raise ServiceError(
|
||||
"The file changed on disk since it was read; reload before saving",
|
||||
code="conflict",
|
||||
status=409,
|
||||
details={
|
||||
"reason": "stale_revision",
|
||||
"path": str(file_path),
|
||||
"expected_revision": expected,
|
||||
"current_revision": current,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _coerce_xlsx_value(value: Any) -> Any:
|
||||
"""Turn the string sent by the cell editor back into a scalar (#153 A10).
|
||||
|
||||
@@ -354,6 +446,7 @@ def edit_xlsx_cells(
|
||||
backup: bool = True,
|
||||
allow_formula: bool = False,
|
||||
force: bool = False,
|
||||
expected_revision: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Apply a batch of cell edits to an ``.xlsx`` workbook.
|
||||
|
||||
@@ -416,6 +509,9 @@ def edit_xlsx_cells(
|
||||
)
|
||||
|
||||
with _xlsx_write_lock(str(file_path)):
|
||||
# #156-A12 — inside the lock: no writer can slip in between the check
|
||||
# and the load.
|
||||
_check_revision(file_path, expected_revision)
|
||||
from openpyxl import load_workbook
|
||||
|
||||
# #153 A16 — .xlsm round-trips with keep_vba=True so the macro
|
||||
@@ -451,6 +547,9 @@ def edit_xlsx_cells(
|
||||
except Exception:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
# #156-A13 — the metadata cache is keyed on (mtime, size); drop it too
|
||||
# so a rewrite that lands on the same tick can never serve stale maps.
|
||||
_invalidate_meta(file_path)
|
||||
|
||||
logger.info(f"XLSX cells saved: {vault_name}/{rel_path} [{sheet}] +{len(cells)}")
|
||||
return {
|
||||
@@ -458,6 +557,7 @@ def edit_xlsx_cells(
|
||||
"vault": vault_name,
|
||||
"path": rel_path,
|
||||
"size": len(cells),
|
||||
"revision": file_revision(file_path),
|
||||
}
|
||||
|
||||
|
||||
@@ -468,6 +568,7 @@ def mutate_xlsx_structure(
|
||||
*,
|
||||
backup: bool = True,
|
||||
force: bool = False,
|
||||
expected_revision: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Apply structural changes to an ``.xlsx`` workbook (#153 A14).
|
||||
|
||||
@@ -521,6 +622,8 @@ def mutate_xlsx_structure(
|
||||
)
|
||||
|
||||
with _xlsx_write_lock(str(file_path)):
|
||||
# #156-A12 — stale-write guard (see edit_xlsx_cells).
|
||||
_check_revision(file_path, expected_revision)
|
||||
from openpyxl import load_workbook
|
||||
from openpyxl.worksheet.copier import WorksheetCopy
|
||||
|
||||
@@ -601,11 +704,42 @@ def mutate_xlsx_structure(
|
||||
"Position 'at' / 'count' invalides", code="invalid", status=400
|
||||
)
|
||||
if op == "row_insert":
|
||||
ws.insert_rows(at, count)
|
||||
# Le modèle sparse d'openpyxl ignore les inserts
|
||||
# au-delà de la dernière ligne utilisée (rien à
|
||||
# décaler), et l'écrivain abandonne les cellules
|
||||
# vides (None) au cycle suivant : on matérialise
|
||||
# avec " " (un blanc qui persiste, relu tel quel,
|
||||
# affiché vide, ignoré par la recherche) pour
|
||||
# qu'une insertion « sous la dernière ligne »
|
||||
# crée bien des lignes blanches, comme Excel.
|
||||
if at > ws.max_row:
|
||||
if at - ws.max_row > _INSERT_GAP_LIMIT:
|
||||
raise ServiceError(
|
||||
"Position trop éloignée de la fin de la feuille",
|
||||
code="invalid", status=400,
|
||||
)
|
||||
for r in range(at, at + count):
|
||||
cell = ws.cell(row=r, column=1)
|
||||
if cell.value is None:
|
||||
cell.value = " "
|
||||
else:
|
||||
ws.insert_rows(at, count)
|
||||
elif op == "row_delete":
|
||||
ws.delete_rows(at, count)
|
||||
elif op == "col_insert":
|
||||
ws.insert_cols(at, count)
|
||||
# Idem côté colonnes : « à droite de la dernière ».
|
||||
if at > ws.max_column:
|
||||
if at - ws.max_column > _INSERT_GAP_LIMIT:
|
||||
raise ServiceError(
|
||||
"Position trop éloignée de la fin de la feuille",
|
||||
code="invalid", status=400,
|
||||
)
|
||||
for c in range(at, at + count):
|
||||
cell = ws.cell(row=1, column=c)
|
||||
if cell.value is None:
|
||||
cell.value = " "
|
||||
else:
|
||||
ws.insert_cols(at, count)
|
||||
else:
|
||||
ws.delete_cols(at, count)
|
||||
applied.append(f"{op}:{sheet}@{at}x{count}")
|
||||
@@ -636,6 +770,7 @@ def mutate_xlsx_structure(
|
||||
wb.close()
|
||||
raise
|
||||
wb.close()
|
||||
_invalidate_meta(file_path)
|
||||
|
||||
logger.info(
|
||||
f"XLSX structure: {vault_name}/{rel_path} {applied}"
|
||||
@@ -645,6 +780,449 @@ def mutate_xlsx_structure(
|
||||
"vault": vault_name,
|
||||
"path": rel_path,
|
||||
"applied": applied,
|
||||
"revision": file_revision(file_path),
|
||||
}
|
||||
|
||||
|
||||
# #156-A8 — write-side formatting. Every value is data-driven (the colours
|
||||
# come from the caller, never from a hardcoded palette) and the whole batch
|
||||
# rides the same lock, backup and atomic swap as the cell edits.
|
||||
# Cap anti-DoS : une insertion au-delà de la fin matérialise des cellules,
|
||||
# jamais un gouffre de lignes/colonnes.
|
||||
_INSERT_GAP_LIMIT = 1000
|
||||
_STYLE_KEYS = {
|
||||
"bold",
|
||||
"italic",
|
||||
"underline",
|
||||
"strike",
|
||||
"font_size",
|
||||
"font_color",
|
||||
"fill_color",
|
||||
"align",
|
||||
"valign",
|
||||
"wrap",
|
||||
"rotation",
|
||||
"border",
|
||||
"border_style",
|
||||
"border_color",
|
||||
"number_format",
|
||||
}
|
||||
_STYLE_ALIGNS = {"left", "center", "right", "justify"}
|
||||
_STYLE_VALIGNS = {"top": "top", "middle": "center", "bottom": "bottom"}
|
||||
_STYLE_BORDERS = {"all", "outer", "none"}
|
||||
_STYLE_BORDER_STYLES = {
|
||||
"thin", "medium", "thick", "dashed", "dotted", "double", "hair",
|
||||
"mediumDashed", "dashDot", "mediumDashDot", "dashDotDot",
|
||||
"mediumDashDotDot", "slantDashDot",
|
||||
}
|
||||
MAX_STYLE_CELLS = 10_000
|
||||
_HEX_COLOR_RE = re.compile(r"^#?[0-9a-fA-F]{6}$")
|
||||
|
||||
|
||||
def _style_color(value: Any) -> str | None:
|
||||
"""Normalise ``#rrggbb`` to openpyxl's ARGB, or ``None`` to clear."""
|
||||
if value is None or value == "":
|
||||
return None
|
||||
if not isinstance(value, str) or not _HEX_COLOR_RE.match(value.strip()):
|
||||
raise ServiceError(
|
||||
f"Couleur invalide: {value!r}", code="invalid", status=400
|
||||
)
|
||||
return "FF" + value.strip().lstrip("#").upper()
|
||||
|
||||
|
||||
def _apply_cell_style(cell: Any, style: dict[str, Any]) -> None:
|
||||
"""Apply the data-driven style fragment of one ``cell`` operation.
|
||||
|
||||
Only the keys listed in :data:`_STYLE_KEYS` are accepted — an unknown one
|
||||
is a client bug, not something to ignore silently. Font attributes are
|
||||
copied before mutation so the shared style of the other cells is left
|
||||
untouched.
|
||||
"""
|
||||
import copy as copy_mod
|
||||
|
||||
from openpyxl.styles import Alignment, Border, Color, PatternFill, Side
|
||||
|
||||
unknown = set(style) - _STYLE_KEYS
|
||||
if unknown:
|
||||
raise ServiceError(
|
||||
f"Style inconnu: {sorted(unknown)}", code="invalid", status=400
|
||||
)
|
||||
if {"bold", "italic", "underline", "strike", "font_size", "font_color"} & set(style):
|
||||
font = copy_mod.copy(cell.font)
|
||||
if "bold" in style:
|
||||
font.bold = bool(style["bold"])
|
||||
if "italic" in style:
|
||||
font.italic = bool(style["italic"])
|
||||
if "underline" in style:
|
||||
font.underline = "single" if style["underline"] else None
|
||||
if "strike" in style:
|
||||
font.strike = bool(style["strike"])
|
||||
if "font_size" in style:
|
||||
try:
|
||||
size = float(style["font_size"])
|
||||
except (TypeError, ValueError):
|
||||
raise ServiceError(
|
||||
f"Taille de police invalide: {style['font_size']!r}",
|
||||
code="invalid", status=400,
|
||||
) from None
|
||||
if not 6 <= size <= 72:
|
||||
raise ServiceError(
|
||||
f"Taille de police hors bornes (6-72): {style['font_size']!r}",
|
||||
code="invalid", status=400,
|
||||
)
|
||||
font.size = size
|
||||
if "font_color" in style:
|
||||
rgb = _style_color(style["font_color"])
|
||||
font.color = Color(rgb=rgb) if rgb else None
|
||||
cell.font = font
|
||||
if "fill_color" in style:
|
||||
rgb = _style_color(style["fill_color"])
|
||||
cell.fill = (
|
||||
PatternFill(start_color=rgb, end_color=rgb, fill_type="solid")
|
||||
if rgb
|
||||
else PatternFill(fill_type=None)
|
||||
)
|
||||
if "align" in style:
|
||||
align = style["align"]
|
||||
if align not in _STYLE_ALIGNS:
|
||||
raise ServiceError(
|
||||
f"Alignement invalide: {align!r}", code="invalid", status=400
|
||||
)
|
||||
current = cell.alignment
|
||||
cell.alignment = Alignment(
|
||||
horizontal=align,
|
||||
vertical=getattr(current, "vertical", None),
|
||||
wrap_text=getattr(current, "wrap_text", None),
|
||||
text_rotation=getattr(current, "text_rotation", None),
|
||||
)
|
||||
if "valign" in style or "wrap" in style or "rotation" in style:
|
||||
current = cell.alignment
|
||||
vertical = getattr(current, "vertical", None)
|
||||
wrap_text = getattr(current, "wrap_text", None)
|
||||
text_rotation = getattr(current, "text_rotation", None) or 0
|
||||
if "valign" in style:
|
||||
valign = style["valign"]
|
||||
if valign not in _STYLE_VALIGNS:
|
||||
raise ServiceError(
|
||||
f"Alignement vertical invalide: {valign!r}", code="invalid", status=400
|
||||
)
|
||||
vertical = _STYLE_VALIGNS[valign]
|
||||
if "wrap" in style:
|
||||
wrap_text = bool(style["wrap"])
|
||||
if "rotation" in style:
|
||||
try:
|
||||
rotation = int(style["rotation"])
|
||||
except (TypeError, ValueError):
|
||||
raise ServiceError(
|
||||
f"Rotation invalide: {style['rotation']!r}", code="invalid", status=400
|
||||
) from None
|
||||
if not (-90 <= rotation <= 90 or rotation == 180):
|
||||
raise ServiceError(
|
||||
f"Rotation hors bornes (-90..90, 180): {rotation!r}", code="invalid", status=400
|
||||
)
|
||||
# OOXML stocke les angles horaires sur 91..180 (-45° → 135).
|
||||
# 180 = texte vertical retourné (le lecteur le relit tel quel).
|
||||
text_rotation = rotation if rotation >= 0 else 180 + rotation
|
||||
cell.alignment = Alignment(
|
||||
horizontal=getattr(current, "horizontal", None),
|
||||
vertical=vertical,
|
||||
wrap_text=wrap_text,
|
||||
text_rotation=text_rotation,
|
||||
)
|
||||
if {"border", "border_style", "border_color"} & set(style):
|
||||
which = style.get("border", "all")
|
||||
if which not in _STYLE_BORDERS:
|
||||
raise ServiceError(
|
||||
f"Bordure invalide: {which!r}", code="invalid", status=400
|
||||
)
|
||||
bstyle = style.get("border_style", "thin")
|
||||
if bstyle not in _STYLE_BORDER_STYLES:
|
||||
raise ServiceError(
|
||||
f"Style de bordure invalide: {bstyle!r}", code="invalid", status=400
|
||||
)
|
||||
raw_color = style.get("border_color", "#000000")
|
||||
if raw_color in (None, ""):
|
||||
raw_color = "FF000000"
|
||||
rgb = _style_color(raw_color)
|
||||
if rgb is None:
|
||||
raise ServiceError(
|
||||
f"Couleur de bordure invalide: {style.get('border_color')!r}",
|
||||
code="invalid", status=400,
|
||||
)
|
||||
if which == "none":
|
||||
cell.border = Border()
|
||||
else:
|
||||
# `all` et `outer` se rendent pareil cellule par cellule : les
|
||||
# bords partagés coïncident, le contour de la plage reste net.
|
||||
side = Side(style=bstyle, color=rgb)
|
||||
cell.border = Border(left=side, right=side, top=side, bottom=side)
|
||||
if "number_format" in style:
|
||||
fmt = style["number_format"] or "General"
|
||||
if not isinstance(fmt, str) or len(fmt) > 120:
|
||||
raise ServiceError(
|
||||
"Format de nombre invalide", code="invalid", status=400
|
||||
)
|
||||
cell.number_format = fmt
|
||||
|
||||
|
||||
def mutate_xlsx_style(
|
||||
vault_name: str,
|
||||
path: str,
|
||||
ops: list[dict[str, Any]],
|
||||
*,
|
||||
backup: bool = True,
|
||||
force: bool = False,
|
||||
expected_revision: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Write formatting on an ``.xlsx``/``.xlsm`` workbook (#156-A8).
|
||||
|
||||
``ops`` is an ordered list (1 to 50) applied in one locked, atomic rewrite:
|
||||
|
||||
* ``{"op": "cell", "sheet": "X", "range": "A1:B2", "style": {…}}`` with
|
||||
``bold``, ``italic``, ``underline``, ``strike``, ``font_size`` (6-72),
|
||||
``font_color`` / ``fill_color`` (``#rrggbb``, ``""`` clears),
|
||||
``align`` (left/center/right/justify), ``valign`` (top/middle/bottom),
|
||||
``wrap`` (bool), ``rotation`` (-90..90), ``border``
|
||||
(all/outer/none) + ``border_style`` / ``border_color`` and
|
||||
``number_format`` ;
|
||||
* ``{"op": "merge"|"unmerge", "sheet": "X", "range": "A1:B2"}`` ;
|
||||
* ``{"op": "col_width", "sheet": "X", "col": "A", "width": 24}`` ;
|
||||
* ``{"op": "row_height", "sheet": "X", "row": 3, "height": 30}`` ;
|
||||
* ``{"op": "comment_set", "sheet": "X", "ref": "B2", "text": "…"}``
|
||||
(``author`` optional) / ``{"op": "comment_clear", ...}`` ;
|
||||
* ``{"op": "freeze", "sheet": "X", "cell": "B2"}`` (``""`` releases).
|
||||
|
||||
Same guards as the cell edits: per-file lock, ``.bak`` backup,
|
||||
``.tmp`` + ``os.replace`` atomic swap, lossy-write 409 and the optional
|
||||
``expected_revision`` concurrency check (#156-A12).
|
||||
"""
|
||||
from openpyxl.utils import get_column_letter
|
||||
from openpyxl.utils.cell import range_boundaries
|
||||
|
||||
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() not in (".xlsx", ".xlsm"):
|
||||
raise ServiceError(
|
||||
f"Not an .xlsx/.xlsm file: {path}", code="invalid", status=400
|
||||
)
|
||||
if not ops or len(ops) > 50:
|
||||
raise ServiceError(
|
||||
"Invalid ops (1 to 50 per request)", code="invalid", status=400
|
||||
)
|
||||
|
||||
if not force and file_path.suffix.lower() != ".xlsm":
|
||||
from backend.xlsx_reader import inspect_workbook
|
||||
|
||||
lossy = inspect_workbook(file_path)
|
||||
if lossy:
|
||||
raise ServiceError(
|
||||
"Restyling this workbook would drop features ObsiGate cannot "
|
||||
"preserve; retry with force=true after confirmation",
|
||||
code="xlsx_lossy_content",
|
||||
status=409,
|
||||
details={"path": path, "features": lossy},
|
||||
)
|
||||
|
||||
def _range(ref: str) -> tuple[int, int, int, int]:
|
||||
try:
|
||||
min_col, min_row, max_col, max_row = range_boundaries(str(ref).upper())
|
||||
except Exception as exc:
|
||||
raise ServiceError(
|
||||
f"Plage invalide: {ref!r}", code="invalid", status=400
|
||||
) from exc
|
||||
if (max_row - min_row + 1) * (max_col - min_col + 1) > MAX_STYLE_CELLS:
|
||||
raise ServiceError(
|
||||
f"Plage trop grande (max {MAX_STYLE_CELLS} cellules)",
|
||||
code="invalid",
|
||||
status=400,
|
||||
)
|
||||
return min_col, min_row, max_col, max_row
|
||||
|
||||
def _sheet(wb: Any, action: dict[str, Any]) -> Any:
|
||||
name = str(action.get("sheet", ""))
|
||||
if name not in wb.sheetnames:
|
||||
raise ServiceError(
|
||||
f"Feuille introuvable: {name}",
|
||||
code="invalid",
|
||||
status=400,
|
||||
details={"sheets": wb.sheetnames},
|
||||
)
|
||||
return wb[name]
|
||||
|
||||
applied: list[str] = []
|
||||
with _xlsx_write_lock(str(file_path)):
|
||||
_check_revision(file_path, expected_revision)
|
||||
from openpyxl import load_workbook
|
||||
|
||||
try:
|
||||
wb = load_workbook(
|
||||
file_path, keep_vba=file_path.suffix.lower() == ".xlsm"
|
||||
)
|
||||
except Exception as exc:
|
||||
raise ServiceError(
|
||||
f"Cannot open workbook: {exc}", code="invalid", status=400
|
||||
) from exc
|
||||
rel_path = _rel(root, file_path)
|
||||
try:
|
||||
for i, action in enumerate(ops):
|
||||
if not isinstance(action, dict):
|
||||
raise ServiceError(
|
||||
f"Action {i + 1} invalide", code="invalid", status=400
|
||||
)
|
||||
op = action.get("op")
|
||||
try:
|
||||
if op == "cell":
|
||||
ws = _sheet(wb, action)
|
||||
ref = str(action.get("range") or action.get("cell") or "")
|
||||
min_col, min_row, max_col, max_row = _range(ref)
|
||||
style = action.get("style") or {}
|
||||
if not isinstance(style, dict) or not style:
|
||||
raise ServiceError(
|
||||
"Style vide", code="invalid", status=400
|
||||
)
|
||||
for row in ws.iter_rows(
|
||||
min_row=min_row,
|
||||
max_row=max_row,
|
||||
min_col=min_col,
|
||||
max_col=max_col,
|
||||
):
|
||||
for cell in row:
|
||||
_apply_cell_style(cell, style)
|
||||
applied.append(
|
||||
f"cell:{ws.title}!{ref}:{','.join(sorted(style))}"
|
||||
)
|
||||
elif op in ("merge", "unmerge"):
|
||||
ws = _sheet(wb, action)
|
||||
ref = str(action.get("range", ""))
|
||||
_range(ref)
|
||||
if op == "merge":
|
||||
ws.merge_cells(ref)
|
||||
else:
|
||||
ws.unmerge_cells(ref)
|
||||
applied.append(f"{op}:{ws.title}!{ref}")
|
||||
elif op == "col_width":
|
||||
ws = _sheet(wb, action)
|
||||
col = action.get("col")
|
||||
width = action.get("width")
|
||||
if isinstance(col, int):
|
||||
col = get_column_letter(col)
|
||||
if not isinstance(col, str) or not col.strip():
|
||||
raise ServiceError(
|
||||
"Colonne invalide", code="invalid", status=400
|
||||
)
|
||||
if not isinstance(width, (int, float)) or not 0 <= float(width) <= 255:
|
||||
raise ServiceError(
|
||||
"Largeur invalide (0 à 255)", code="invalid", status=400
|
||||
)
|
||||
width = float(width)
|
||||
if width == 0:
|
||||
# 0 = largeur par défaut (équivalent « Default
|
||||
# Width » d'Excel / « Reset » de Sheets) : on
|
||||
# retire la mesure explicite au lieu d'écrire une
|
||||
# largeur de 0, qu'Excel lirait comme une colonne
|
||||
# quasi masquée pendant que l'affichage reste en
|
||||
# largeur automatique.
|
||||
ws.column_dimensions.pop(col.strip().upper(), None)
|
||||
else:
|
||||
ws.column_dimensions[col.strip().upper()].width = width
|
||||
applied.append(f"col_width:{ws.title}!{col}={width}")
|
||||
elif op == "row_height":
|
||||
ws = _sheet(wb, action)
|
||||
row = action.get("row")
|
||||
height = action.get("height")
|
||||
if not isinstance(row, int) or row < 1:
|
||||
raise ServiceError("Ligne invalide", code="invalid", status=400)
|
||||
if not isinstance(height, (int, float)) or not 0 <= float(height) <= 409:
|
||||
raise ServiceError(
|
||||
"Hauteur invalide (0 à 409)", code="invalid", status=400
|
||||
)
|
||||
height = float(height)
|
||||
if height == 0:
|
||||
# 0 = hauteur par défaut (même logique que la
|
||||
# largeur ci-dessus) : la mesure explicite est
|
||||
# retirée, la ligne redevient automatique.
|
||||
ws.row_dimensions.pop(row, None)
|
||||
else:
|
||||
ws.row_dimensions[row].height = height
|
||||
applied.append(f"row_height:{ws.title}!{row}={height}")
|
||||
elif op == "freeze":
|
||||
ws = _sheet(wb, action)
|
||||
cell_ref = str(action.get("cell", ""))
|
||||
if cell_ref:
|
||||
_range(cell_ref)
|
||||
ws.freeze_panes = cell_ref.upper() or None
|
||||
applied.append(f"freeze:{ws.title}!{cell_ref or '-'}")
|
||||
elif op in ("comment_set", "comment_clear"):
|
||||
from openpyxl.comments import Comment
|
||||
|
||||
ws = _sheet(wb, action)
|
||||
ref = str(action.get("ref") or action.get("cell") or "")
|
||||
min_col, min_row, max_col, max_row = _range(ref)
|
||||
if (min_col, min_row) != (max_col, max_row):
|
||||
raise ServiceError(
|
||||
"Un commentaire vise une cellule (A1), pas une plage",
|
||||
code="invalid", status=400,
|
||||
)
|
||||
if op == "comment_clear":
|
||||
ws.cell(row=min_row, column=min_col).comment = None
|
||||
else:
|
||||
text = str(action.get("text") or "")
|
||||
if not text.strip() or len(text) > 2000:
|
||||
raise ServiceError(
|
||||
"Texte de commentaire invalide (1 à 2000 caractères)",
|
||||
code="invalid", status=400,
|
||||
)
|
||||
author = str(action.get("author") or "ObsiGate")[:64]
|
||||
ws.cell(row=min_row, column=min_col).comment = Comment(
|
||||
text, author
|
||||
)
|
||||
applied.append(f"{op}:{ws.title}!{ref}")
|
||||
else:
|
||||
raise ServiceError(
|
||||
f"Action inconnue: {op!r}", code="invalid", status=400
|
||||
)
|
||||
except ServiceError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise ServiceError(
|
||||
f"Action {i + 1} ({op}) a échoué: {exc}",
|
||||
code="invalid",
|
||||
status=400,
|
||||
) from exc
|
||||
except ServiceError:
|
||||
wb.close()
|
||||
raise
|
||||
|
||||
if backup:
|
||||
create_backup(file_path, vault_name, rel_path)
|
||||
|
||||
tmp_path = file_path.with_name(f"{file_path.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
wb.save(tmp_path)
|
||||
os.replace(tmp_path, file_path)
|
||||
except Exception:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
wb.close()
|
||||
raise
|
||||
wb.close()
|
||||
_invalidate_meta(file_path)
|
||||
|
||||
logger.info(f"XLSX style: {vault_name}/{rel_path} {applied}")
|
||||
return {
|
||||
"success": True,
|
||||
"vault": vault_name,
|
||||
"path": rel_path,
|
||||
"applied": applied,
|
||||
"revision": file_revision(file_path),
|
||||
}
|
||||
|
||||
|
||||
@@ -654,14 +1232,18 @@ def save_csv_cells(
|
||||
cells: dict[str, Any],
|
||||
*,
|
||||
backup: bool = True,
|
||||
expected_revision: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Apply A1-addressed cell edits to a ``.csv`` file (#153 A16).
|
||||
|
||||
The file is re-parsed, patched and re-serialized with :mod:`csv` so
|
||||
quoting follows RFC 4180. References beyond the current extent grow the
|
||||
grid (missing rows/cells are filled with empty strings). Values are
|
||||
stored as text: a CSV has no formula engine, so any string — including
|
||||
ones starting with ``=`` — is written verbatim (the render escapes it).
|
||||
quoting follows RFC 4180. BUG-098 — the delimiter is **sniffed** and the
|
||||
same one is reused on write-back: a `;`-separated French CSV used to be
|
||||
parsed as a single column and rewritten with `,`. References beyond the
|
||||
current extent grow the grid (missing rows/cells are filled with empty
|
||||
strings). Values are stored as text: a CSV has no formula engine, so any
|
||||
string — including ones starting with ``=`` — is written verbatim (the
|
||||
render escapes it).
|
||||
|
||||
Raises:
|
||||
ServiceError: ``not_found`` (404), ``read_only`` (403), ``conflict``
|
||||
@@ -670,6 +1252,8 @@ def save_csv_cells(
|
||||
import csv as csv_mod
|
||||
import io as io_mod
|
||||
|
||||
from backend.xlsx_reader import sniff_csv_delimiter
|
||||
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
@@ -691,9 +1275,13 @@ def save_csv_cells(
|
||||
f"Invalid cell reference: {ref!r}", code="invalid", status=400
|
||||
)
|
||||
|
||||
# #156-A12 — same stale-write guard as the workbook path.
|
||||
_check_revision(file_path, expected_revision)
|
||||
raw = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
# BUG-098 — parse AND rewrite with the file's own delimiter.
|
||||
delimiter = sniff_csv_delimiter(raw)
|
||||
try:
|
||||
rows = list(csv_mod.reader(io_mod.StringIO(raw)))
|
||||
rows = list(csv_mod.reader(io_mod.StringIO(raw), delimiter=delimiter))
|
||||
except csv_mod.Error:
|
||||
rows = [[line] for line in raw.splitlines()]
|
||||
|
||||
@@ -721,7 +1309,7 @@ def save_csv_cells(
|
||||
create_backup(file_path, vault_name, rel_path)
|
||||
|
||||
buf = io_mod.StringIO()
|
||||
csv_mod.writer(buf, lineterminator="\n").writerows(rows)
|
||||
csv_mod.writer(buf, delimiter=delimiter, lineterminator="\n").writerows(rows)
|
||||
tmp_path = file_path.with_name(f"{file_path.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
tmp_path.write_text(buf.getvalue(), encoding="utf-8")
|
||||
@@ -736,6 +1324,7 @@ def save_csv_cells(
|
||||
"vault": vault_name,
|
||||
"path": rel_path,
|
||||
"size": len(cells),
|
||||
"revision": file_revision(file_path),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import time
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from backend.auth.middleware import is_home_vault
|
||||
from backend.history import get_recent_opened, is_bookmarked
|
||||
from backend.indexer import find_file_in_index, index
|
||||
|
||||
@@ -31,6 +32,10 @@ def humanize_mtime(mtime: float) -> str:
|
||||
|
||||
|
||||
def _can_access(vault: str, user_vaults: list[str]) -> bool:
|
||||
# #194 : un dossier perso ne bénéficie jamais de "*" (même règle que
|
||||
# backend.auth.middleware.check_vault_access).
|
||||
if is_home_vault(vault):
|
||||
return vault in user_vaults
|
||||
return "*" in user_vaults or vault in user_vaults
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
|
||||
@@ -11,15 +12,29 @@ def search_vaults(
|
||||
tag: str | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
is_allowed: Callable[[str], bool] | None = None,
|
||||
username: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Full-text search with pagination, returned as the API response payload.
|
||||
|
||||
No permission filtering is applied here: callers that need it (the tool
|
||||
layer) filter the ``results`` list themselves.
|
||||
``is_allowed`` (#194) filters the raw hits **before** pagination, so a
|
||||
restricted vault set neither distorts ``total`` nor the returned page.
|
||||
The tool layer filters on its own and leaves it as ``None``.
|
||||
|
||||
``username`` (#196) : les documents **reçus** par cet utilisateur via un
|
||||
partage dirigé sont ajoutés aux résultats (lecture seule, sans indexer
|
||||
les fichiers d'autrui — le contenu est lu à la volée et mis en cache
|
||||
TF-IDF côté index, pas sur disque).
|
||||
"""
|
||||
from backend.search import search
|
||||
|
||||
all_results = search(q, vault_filter=vault, tag_filter=tag)
|
||||
if is_allowed is not None:
|
||||
all_results = [r for r in all_results if is_allowed(r.get("vault", ""))]
|
||||
|
||||
if username is not None:
|
||||
all_results = all_results + _shared_results(username, q, vault)
|
||||
|
||||
total = len(all_results)
|
||||
page = all_results[offset: offset + limit]
|
||||
return {
|
||||
@@ -34,6 +49,67 @@ def search_vaults(
|
||||
}
|
||||
|
||||
|
||||
def _shared_results(username: str, q: str, vault: str) -> list[dict[str, Any]]:
|
||||
"""Search results for documents shared TO *username* (#196).
|
||||
|
||||
Reads the shared file content on the fly (read-only, source vault on
|
||||
disk) and returns hits shaped exactly like ordinary search results so
|
||||
the frontend can open them via the normal share page.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from backend.indexer import get_vault_data
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.share import list_shares
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
if not q:
|
||||
return out
|
||||
q_lower = q.lower()
|
||||
|
||||
for s in list_shares(user=username):
|
||||
if s.get("created_by") == username:
|
||||
continue # own share — already indexed in its own vault
|
||||
if vault not in ("all", f"home-{username}"):
|
||||
continue
|
||||
data = get_vault_data(s["vault"])
|
||||
if not data:
|
||||
continue
|
||||
try:
|
||||
fp = resolve_safe_path(Path(data["path"]), s["path"])
|
||||
if not fp.exists() or fp.suffix.lower() != ".md":
|
||||
continue
|
||||
raw = fp.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
continue
|
||||
title = fp.stem
|
||||
occurrences = raw.lower().count(q_lower)
|
||||
if q_lower in title.lower():
|
||||
occurrences += 1
|
||||
if occurrences == 0:
|
||||
continue
|
||||
out.append({
|
||||
"vault": f"home-{username}",
|
||||
"path": f"Partage/{fp.name}",
|
||||
"title": title,
|
||||
"tags": [],
|
||||
"score": min(occurrences, 10),
|
||||
"snippet": _snippet(raw, q_lower),
|
||||
"modified": None,
|
||||
"share_token": s["token"],
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _snippet(text: str, q_lower: str, width: int = 160) -> str:
|
||||
"""Small excerpt around the first match (#196, shared-file search)."""
|
||||
idx = text.lower().find(q_lower)
|
||||
if idx < 0:
|
||||
return text[:width]
|
||||
start = max(0, idx - width // 2)
|
||||
return "…" + text[start:start + width].replace("\n", " ") + "…"
|
||||
|
||||
|
||||
def list_tags(vault: str | None = None) -> dict[str, int]:
|
||||
"""Return tag → count, optionally restricted to a single vault."""
|
||||
from backend.search import get_all_tags
|
||||
|
||||
@@ -102,6 +102,29 @@ def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
|
||||
return {"vault": vault_name, "path": path, "items": items}
|
||||
|
||||
|
||||
def _indexed_tags(vault_name: str, rel_path: str) -> list[str]:
|
||||
"""Tags of a file as stored in the search index (#158).
|
||||
|
||||
Empty list when the file is not indexed yet (binary formats, index still
|
||||
building) — the listing itself comes from the filesystem, tags are a
|
||||
decoration used by the navigation page facets/filters.
|
||||
|
||||
Args:
|
||||
vault_name: Vault name.
|
||||
rel_path: Path of the file relative to the vault root (``/`` separated).
|
||||
|
||||
Returns:
|
||||
The file's tags, or an empty list when unknown.
|
||||
"""
|
||||
try:
|
||||
from backend.search import get_inverted_index
|
||||
|
||||
info = get_inverted_index().doc_info.get(f"{vault_name}::{rel_path}")
|
||||
except Exception:
|
||||
return []
|
||||
return list((info or {}).get("tags") or [])
|
||||
|
||||
|
||||
def list_all_files(
|
||||
vault_name: str,
|
||||
dir: str = "",
|
||||
@@ -189,6 +212,7 @@ def list_all_files(
|
||||
"modified": stat.st_mtime,
|
||||
"modified_iso": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
|
||||
"extension": ext.lstrip(".") if ext else "",
|
||||
"tags": _indexed_tags(vault_name, rel_path),
|
||||
}
|
||||
if rel_to_dir and rel_to_dir != ".":
|
||||
file_entry["rel_dir"] = rel_to_dir
|
||||
|
||||
+17
-3
@@ -44,8 +44,14 @@ def create_share(
|
||||
path: str,
|
||||
created_by: str,
|
||||
expires_in_hours: int | None = None,
|
||||
shared_with: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Create a new share token for a document."""
|
||||
"""Create a new share token for a document.
|
||||
|
||||
``shared_with`` non vide (#196) : partage **dirigé** — la page ``/s/…``
|
||||
exige alors une session et n'accepte que les destinataires listés (plus
|
||||
le créateur et les admins). Vide/absent : comportement public inchangé.
|
||||
"""
|
||||
with _lock:
|
||||
data = _read()
|
||||
token = secrets.token_hex(32) # 64-char hex token
|
||||
@@ -64,6 +70,7 @@ def create_share(
|
||||
"expires_at": expires_at,
|
||||
"access_count": 0,
|
||||
"last_accessed": None,
|
||||
"shared_with": list(shared_with) if shared_with else [],
|
||||
}
|
||||
data["shares"][token] = share
|
||||
_write(data)
|
||||
@@ -107,10 +114,17 @@ def revoke_share(share_id: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def list_shares(vault_filter: str | None = None) -> list:
|
||||
"""List all shares, optionally filtered by vault."""
|
||||
def list_shares(vault_filter: str | None = None, user: str | None = None) -> list:
|
||||
"""List shares, optionally filtered by vault and/or requesting user.
|
||||
|
||||
#196 : un non-admin ne voit que les partages qu'il a créés **ou** qui lui
|
||||
sont dirigés. Un admin voit tout. ``user=None`` (anciens appels, tests
|
||||
unitaires) conserve l'ancien comportement : tout lister.
|
||||
"""
|
||||
data = _read()
|
||||
shares = list(data["shares"].values())
|
||||
if user is not None:
|
||||
shares = [s for s in shares if user in (s.get("created_by"), *(s.get("shared_with") or []))]
|
||||
if vault_filter:
|
||||
shares = [s for s in shares if s["vault"] == vault_filter]
|
||||
# Most recent first
|
||||
|
||||
@@ -12,6 +12,9 @@ which ``.gitignore`` excludes via ``_*.py``), hence this explicit facade.
|
||||
from backend.tools import connected as _connected # noqa: F401 (registers connected-source tools)
|
||||
from backend.tools import crawler as _crawler # noqa: F401 (registers the site crawler)
|
||||
from backend.tools import documents as _documents # noqa: F401 (registers document tools)
|
||||
from backend.tools import duplicates as _duplicates # noqa: F401 (registers duplicate tools #166)
|
||||
from backend.tools import notify as _notify_tools # noqa: F401 (registers notify tool #168)
|
||||
from backend.tools import scheduled as _scheduled # noqa: F401 (registers scheduler tools #170)
|
||||
from backend.tools import service as _service # noqa: F401 (registers tools)
|
||||
from backend.tools import spreadsheets as _spreadsheets # noqa: F401 (registers existing-workbook tools #153 A6)
|
||||
from backend.tools import web as _web # noqa: F401 (registers web tools)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Duplicate detection & merge tools (#166).
|
||||
|
||||
* ``find_duplicates`` — READ, vault-scoped: candidate pairs with scores.
|
||||
* ``merge_duplicate_notes`` — DANGEROUS: confirmed fusion with automatic
|
||||
backup (service layer), never without an explicit approval.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from backend.services import duplicates as _duplicates
|
||||
from backend.services.errors import ServiceError
|
||||
from backend.tools.context import ToolContext, ToolError, ToolRisk
|
||||
from backend.tools.registry import tool
|
||||
from backend.tools.schemas import FindDuplicatesInput, MergeDuplicatesInput
|
||||
|
||||
|
||||
@tool(
|
||||
name="find_duplicates",
|
||||
description="Find candidate duplicate markdown notes in a vault (similarity scores).",
|
||||
input_model=FindDuplicatesInput,
|
||||
risk=ToolRisk.READ,
|
||||
requires_vault=True,
|
||||
)
|
||||
def find_duplicates(ctx: ToolContext, params: FindDuplicatesInput) -> dict[str, Any]:
|
||||
"""List duplicate candidates ordered by descending score."""
|
||||
try:
|
||||
return _duplicates.find_duplicate_pairs(
|
||||
params.vault,
|
||||
threshold=params.threshold,
|
||||
limit=params.limit,
|
||||
subdir=params.subdir,
|
||||
)
|
||||
except ServiceError as e:
|
||||
raise ToolError(e.message, code=e.code, details=e.details) from e
|
||||
|
||||
|
||||
@tool(
|
||||
name="merge_duplicate_notes",
|
||||
description=(
|
||||
"Merge one note into another and delete the source (backup first). "
|
||||
"Destructive: requires confirmation."
|
||||
),
|
||||
input_model=MergeDuplicatesInput,
|
||||
risk=ToolRisk.DANGEROUS,
|
||||
requires_vault=True,
|
||||
)
|
||||
def merge_duplicate_notes(ctx: ToolContext, params: MergeDuplicatesInput) -> dict[str, Any]:
|
||||
"""Fuse *source_path* into *target_path* using the chosen strategy."""
|
||||
try:
|
||||
return _duplicates.merge_duplicates(
|
||||
params.vault,
|
||||
params.source_path,
|
||||
params.target_path,
|
||||
strategy=params.strategy,
|
||||
)
|
||||
except ServiceError as e:
|
||||
raise ToolError(e.message, code=e.code, details=e.details) from e
|
||||
@@ -56,9 +56,19 @@ _STEP_LABELS: dict[str, tuple[str, str | None]] = {
|
||||
"xlsx_to_markdown": ("xlsx_read", "path"),
|
||||
"update_xlsx_cells": ("xlsx_update", "path"),
|
||||
"append_xlsx_rows": ("xlsx_append", "path"),
|
||||
"search_workbook": ("xlsx_search", "query"),
|
||||
"analyze_range": ("xlsx_analyze", "path"),
|
||||
"edit_xlsx_structure": ("xlsx_structure", "path"),
|
||||
"create_docx": ("docx_create", "path"),
|
||||
"create_csv": ("csv_create", "path"),
|
||||
"create_pdf": ("pdf_create", "path"),
|
||||
"find_duplicates": ("duplicates", "vault"),
|
||||
"merge_duplicate_notes": ("duplicates_merge", "source_path"),
|
||||
"notify_external": ("notify", "title"),
|
||||
"create_scheduled_task": ("schedule_create", "name"),
|
||||
"list_scheduled_tasks": ("schedule_list", None),
|
||||
"delete_scheduled_task": ("schedule_delete", "task_id"),
|
||||
"run_scheduled_task_now": ("schedule_run", "task_id"),
|
||||
}
|
||||
|
||||
GENERIC_KEY = "generic"
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""External notification tool (#168) — Discord, Telegram, SMTP, webhook.
|
||||
|
||||
``notify_external`` is WRITE (external side effect → confirmation card in the
|
||||
UI, propose/apply over MCP). Delivery itself lives in :mod:`backend.notify`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from backend import notify as _notify
|
||||
from backend.tools.context import ToolContext, ToolError, ToolRisk
|
||||
from backend.tools.registry import tool
|
||||
from backend.tools.schemas import NotifyExternalInput
|
||||
|
||||
|
||||
@tool(
|
||||
name="notify_external",
|
||||
description="Send a notification through external channels (Discord, Telegram, SMTP, webhook).",
|
||||
input_model=NotifyExternalInput,
|
||||
risk=ToolRisk.WRITE,
|
||||
)
|
||||
def notify_external(ctx: ToolContext, params: NotifyExternalInput) -> dict[str, Any]:
|
||||
"""Broadcast to the trigger scope, or target a single channel id."""
|
||||
try:
|
||||
if params.channel_id:
|
||||
channel = next(
|
||||
(c for c in _notify._read_channels() if c.get("id") == params.channel_id),
|
||||
None,
|
||||
)
|
||||
if channel is None:
|
||||
raise ToolError(f"Unknown channel: {params.channel_id}", code="not_found")
|
||||
if not channel.get("enabled", True):
|
||||
raise ToolError(f"Channel disabled: {params.channel_id}", code="invalid_arguments")
|
||||
_notify.send_via_channel(channel, params.title, params.message, params.trigger)
|
||||
return {"ok": True, "channel_id": params.channel_id}
|
||||
results = _notify.broadcast(params.trigger, params.title, params.message)
|
||||
return {"ok": True, "deliveries": results}
|
||||
except ToolError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise ToolError(f"Notification failed: {e}", code="notify_failed") from e
|
||||
@@ -117,9 +117,22 @@ def list_tools(*, scope: ToolScope | None = None) -> list[ToolSpec]:
|
||||
return specs
|
||||
|
||||
|
||||
# ponytail: tool schemas are static after import (registration is decorator
|
||||
# only); keying the cache on len(_REGISTRY) invalidates it if a tool is ever
|
||||
# registered at runtime. Rebuilding 50 pydantic JSON schemas cost ~27 ms per
|
||||
# agent/MCP request.
|
||||
_SCHEMAS_CACHE: dict[Any, list[dict[str, Any]]] = {}
|
||||
|
||||
|
||||
def get_tool_schemas(*, scope: ToolScope | None = None) -> list[dict[str, Any]]:
|
||||
"""Return OpenAI-compatible schemas for registered tools."""
|
||||
return [spec.openai_schema() for spec in list_tools(scope=scope)]
|
||||
"""Return OpenAI-compatible schemas for registered tools (cached)."""
|
||||
key = (scope, len(_REGISTRY))
|
||||
cached = _SCHEMAS_CACHE.get(key)
|
||||
if cached is None:
|
||||
cached = [spec.openai_schema() for spec in list_tools(scope=scope)]
|
||||
_SCHEMAS_CACHE.clear()
|
||||
_SCHEMAS_CACHE[key] = cached
|
||||
return [dict(s) for s in cached]
|
||||
|
||||
|
||||
def _audit(ctx: ToolContext, spec: ToolSpec, arguments: dict[str, Any], *, ok: bool, error: str | None = None) -> None:
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Scheduled-task tools (#170) — the agent programs its own cron.
|
||||
|
||||
* ``create_scheduled_task`` — WRITE (a future write, confirmed once now).
|
||||
* ``list_scheduled_tasks`` — READ.
|
||||
* ``delete_scheduled_task`` — WRITE (removes a future side effect).
|
||||
* ``run_scheduled_task_now`` — WRITE (immediate side effect).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from backend import scheduler as _scheduler
|
||||
from backend.tools.context import ToolContext, ToolError, ToolRisk
|
||||
from backend.tools.registry import tool
|
||||
from backend.tools.schemas import (
|
||||
CreateScheduledTaskInput,
|
||||
DeleteScheduledTaskInput,
|
||||
ListVaultsInput,
|
||||
RunScheduledTaskInput,
|
||||
)
|
||||
|
||||
|
||||
@tool(
|
||||
name="create_scheduled_task",
|
||||
description="Program an automatic task (create_file, append_to_file, notify) on a cron-like schedule.",
|
||||
input_model=CreateScheduledTaskInput,
|
||||
risk=ToolRisk.WRITE,
|
||||
)
|
||||
def create_scheduled_task(ctx: ToolContext, params: CreateScheduledTaskInput) -> dict[str, Any]:
|
||||
"""Create a task owned by the requesting user."""
|
||||
try:
|
||||
return _scheduler.create_task(
|
||||
params.name,
|
||||
params.action,
|
||||
params.schedule,
|
||||
created_by=ctx.username,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise ToolError(str(e), code="invalid_arguments") from e
|
||||
|
||||
|
||||
@tool(
|
||||
name="list_scheduled_tasks",
|
||||
description="List automatic tasks programmed in ObsiGate.",
|
||||
input_model=ListVaultsInput,
|
||||
risk=ToolRisk.READ,
|
||||
)
|
||||
def list_scheduled_tasks(ctx: ToolContext, _params: ListVaultsInput) -> list[dict[str, Any]]:
|
||||
"""Return tasks newest first."""
|
||||
return _scheduler.list_tasks()
|
||||
|
||||
|
||||
@tool(
|
||||
name="delete_scheduled_task",
|
||||
description="Delete a programmed automatic task.",
|
||||
input_model=DeleteScheduledTaskInput,
|
||||
risk=ToolRisk.WRITE,
|
||||
)
|
||||
def delete_scheduled_task(ctx: ToolContext, params: DeleteScheduledTaskInput) -> dict[str, Any]:
|
||||
"""Delete by id; unknown id is a not_found tool error."""
|
||||
if not _scheduler.delete_task(params.task_id):
|
||||
raise ToolError(f"Unknown task: {params.task_id}", code="not_found")
|
||||
return {"ok": True, "task_id": params.task_id}
|
||||
|
||||
|
||||
@tool(
|
||||
name="run_scheduled_task_now",
|
||||
description="Execute a programmed task immediately (manual run).",
|
||||
input_model=RunScheduledTaskInput,
|
||||
risk=ToolRisk.WRITE,
|
||||
)
|
||||
def run_scheduled_task_now(ctx: ToolContext, params: RunScheduledTaskInput) -> dict[str, Any]:
|
||||
"""Run now and return the outcome (failures are recorded + notified)."""
|
||||
try:
|
||||
return _scheduler.run_task(params.task_id, manual=True)
|
||||
except KeyError as e:
|
||||
raise ToolError(f"Unknown task: {params.task_id}", code="not_found") from e
|
||||
@@ -332,12 +332,42 @@ class XlsxToMarkdownInput(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class SearchWorkbookInput(BaseModel):
|
||||
"""Find a text across the sheets of a spreadsheet (#156 A14)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(
|
||||
..., description="Vault-relative path of the file (.xlsx, .xlsm or .csv)"
|
||||
)
|
||||
query: str = Field(..., description="Text to look for")
|
||||
sheet: str = Field("", description="Restrict to one sheet (empty = all sheets)")
|
||||
case_sensitive: bool = Field(False, description="Match case")
|
||||
|
||||
|
||||
class AnalyzeRangeInput(BaseModel):
|
||||
"""Aggregate the values of an A1 range (#156 A14)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(
|
||||
..., description="Vault-relative path of the file (.xlsx, .xlsm or .csv)"
|
||||
)
|
||||
sheet: str = Field(
|
||||
"", description="Sheet name (empty = the first/active sheet)"
|
||||
)
|
||||
range: str = Field(
|
||||
"",
|
||||
description="A1 range to analyse (e.g. 'B2:B50'); empty = the whole sheet",
|
||||
)
|
||||
|
||||
|
||||
class UpdateXlsxCellsInput(BaseModel):
|
||||
"""Batch-edit cells of an existing .xlsx workbook (#153 A6)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(..., description="Vault-relative path of the .xlsx file")
|
||||
sheet: str = Field(..., description="Worksheet title to edit")
|
||||
sheet: str = Field(
|
||||
"", description="Worksheet title to edit (ignored for a .csv)"
|
||||
)
|
||||
cells: dict[str, str | int | float | bool | None] = Field(
|
||||
..., description="A1 reference -> new value (max 500 per call)"
|
||||
)
|
||||
@@ -370,6 +400,25 @@ class AppendXlsxRowsInput(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class EditXlsxStructureInput(BaseModel):
|
||||
"""Structural CRUD on an existing workbook (#156 A14)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(..., description="Vault-relative path of the .xlsx/.xlsm file")
|
||||
actions: list[dict[str, Any]] = Field(
|
||||
...,
|
||||
description=(
|
||||
"Ordered structural actions (1-50): sheet_add/sheet_rename/"
|
||||
"sheet_duplicate/sheet_delete, row_insert/row_delete/"
|
||||
"col_insert/col_delete"
|
||||
),
|
||||
)
|
||||
force: bool = Field(
|
||||
False,
|
||||
description="Write even when features openpyxl cannot rewrite would be dropped",
|
||||
)
|
||||
|
||||
|
||||
class CsvInput(BaseModel):
|
||||
"""Create a .csv file in a vault from rows of cells."""
|
||||
|
||||
@@ -392,6 +441,53 @@ class PdfInput(BaseModel):
|
||||
overwrite: bool = Field(True, description="Replace an existing file (with backup)")
|
||||
|
||||
|
||||
class FindDuplicatesInput(BaseModel):
|
||||
"""Find candidate duplicate notes in a vault (#166)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
threshold: float = Field(0.75, ge=0.3, le=1.0, description="Minimum similarity score")
|
||||
limit: int = Field(20, ge=1, le=200, description="Maximum number of pairs")
|
||||
subdir: str = Field("", description="Vault-relative directory scope (empty = whole vault)")
|
||||
|
||||
|
||||
class MergeDuplicatesInput(BaseModel):
|
||||
"""Merge one note into another, then delete the source (#166, destructive)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
source_path: str = Field(..., description="Vault-relative path of the note to absorb")
|
||||
target_path: str = Field(..., description="Vault-relative path of the surviving note")
|
||||
strategy: str = Field("append", description="'append', 'prefer_target' or 'prefer_source'")
|
||||
|
||||
|
||||
class NotifyExternalInput(BaseModel):
|
||||
"""Send a notification through external channels (#168)."""
|
||||
|
||||
title: str = Field(..., min_length=1, description="Notification title")
|
||||
message: str = Field(..., min_length=1, description="Notification body")
|
||||
trigger: str = Field("manual", description="Trigger scope: manual, schedule_failure, schedule_success")
|
||||
channel_id: str = Field("", description="Single channel id (empty = broadcast to trigger)")
|
||||
|
||||
|
||||
class CreateScheduledTaskInput(BaseModel):
|
||||
"""Create an automatic task executed by the scheduler (#170)."""
|
||||
|
||||
name: str = Field(..., min_length=1, description="Task display name")
|
||||
action: dict[str, Any] = Field(..., description="{kind, params} (create_file, append_to_file, notify)")
|
||||
schedule: dict[str, Any] = Field(..., description="{kind, ...} (interval_hours, daily_time, once_at)")
|
||||
|
||||
|
||||
class DeleteScheduledTaskInput(BaseModel):
|
||||
"""Delete a scheduled task by id (#170)."""
|
||||
|
||||
task_id: str = Field(..., min_length=1, description="Task id")
|
||||
|
||||
|
||||
class RunScheduledTaskInput(BaseModel):
|
||||
"""Execute a scheduled task immediately (#170)."""
|
||||
|
||||
task_id: str = Field(..., min_length=1, description="Task id")
|
||||
|
||||
|
||||
class ToolResult(BaseModel):
|
||||
"""Uniform result returned by :func:`backend.tools.registry.call_tool`."""
|
||||
|
||||
|
||||
+392
-105
@@ -1,21 +1,30 @@
|
||||
"""Spreadsheet tools (#153 A6) — read and mutate existing ``.xlsx`` workbooks.
|
||||
"""Spreadsheet tools (#153 A6, #156 A14) — read and mutate existing workbooks.
|
||||
|
||||
Complements :mod:`backend.tools.documents` (``create_xlsx`` creates a *new*
|
||||
file; here the assistant can read and edit one that already exists):
|
||||
file; here the assistant can read and edit one that already exists). The same
|
||||
three formats the viewer edits are supported — ``.xlsx``, ``.xlsm`` and
|
||||
``.csv`` (#156-A14 used to be ``.xlsx`` only, which made a workbook the UI
|
||||
edits invisible to the assistant):
|
||||
|
||||
* ``list_xlsx_sheets`` — READ, sheet names + dimensions;
|
||||
* ``xlsx_to_markdown`` — READ, bounded markdown table for the LLM context;
|
||||
* ``update_xlsx_cells`` — WRITE, batch cell edits (wraps the guarded service);
|
||||
* ``append_xlsx_rows`` — WRITE, append whole rows at the end of a sheet.
|
||||
* ``list_xlsx_sheets`` — READ, sheet names + dimensions;
|
||||
* ``xlsx_to_markdown`` — READ, bounded markdown table for the LLM context;
|
||||
* ``search_workbook`` — READ, find text across every sheet (#156-A14);
|
||||
* ``analyze_range`` — READ, aggregate stats over an A1 range (#156-A14);
|
||||
* ``update_xlsx_cells`` — WRITE, batch cell edits (guarded service);
|
||||
* ``append_xlsx_rows`` — WRITE, append whole rows at the end of a sheet;
|
||||
* ``edit_xlsx_structure`` — WRITE, structural CRUD (sheets/rows/columns).
|
||||
|
||||
Mutation tools go through :func:`backend.services.mutations.edit_xlsx_cells`,
|
||||
which already carries the #153 P0 guards: per-file lock, atomic replace,
|
||||
formula neutralisation (``allow_formula`` opt-in) and the lossy-write 409.
|
||||
Mutation tools go through :func:`backend.services.mutations.edit_xlsx_cells`
|
||||
(or ``save_csv_cells`` / ``mutate_xlsx_structure``), which already carry the
|
||||
#153 P0 guards: per-file lock, atomic replace, formula neutralisation
|
||||
(``allow_formula`` opt-in) and the lossy-write 409. Every write is a WRITE-risk
|
||||
tool, so the registry keeps asking for an explicit confirmation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -25,8 +34,11 @@ from backend.services.vaults import get_vault_root
|
||||
from backend.tools.context import ToolContext, ToolError, ToolRisk
|
||||
from backend.tools.registry import tool
|
||||
from backend.tools.schemas import (
|
||||
AnalyzeRangeInput,
|
||||
AppendXlsxRowsInput,
|
||||
EditXlsxStructureInput,
|
||||
ListXlsxSheetsInput,
|
||||
SearchWorkbookInput,
|
||||
UpdateXlsxCellsInput,
|
||||
XlsxToMarkdownInput,
|
||||
)
|
||||
@@ -39,12 +51,28 @@ MAX_MD_ROWS = 100
|
||||
MAX_MD_COLS = 20
|
||||
MAX_MD_CHARS = 20_000
|
||||
|
||||
# #156-A14 — the newer read tools scan more than the markdown table (a search
|
||||
# or an aggregate must not stop at row 100) but stay bounded all the same:
|
||||
# a runaway scan would load a whole ledger into the model's context.
|
||||
MAX_SCAN_ROWS = 5_000
|
||||
MAX_SCAN_COLS = 100
|
||||
MAX_SEARCH_RESULTS = 100
|
||||
MAX_RANGE_CELLS = 10_000
|
||||
MAX_RANGE_VALUES = 200
|
||||
|
||||
def _workbook_path(vault: str, path: str) -> Path:
|
||||
"""Resolve and validate a vault-relative ``.xlsx`` path."""
|
||||
# The formats the spreadsheet editor (and now the assistant) can handle.
|
||||
SPREADSHEET_EXTENSIONS = (".xlsx", ".xlsm", ".csv")
|
||||
|
||||
_NUMBER_RE = re.compile(r"^-?\d+(?:[.,]\d+)?$")
|
||||
|
||||
|
||||
def _spreadsheet_path(vault: str, path: str) -> Path:
|
||||
"""Resolve and validate a vault-relative ``.xlsx``/``.xlsm``/``.csv`` path."""
|
||||
path = (path or "").strip()
|
||||
if not path.lower().endswith(".xlsx"):
|
||||
raise ToolError("Extension attendue : .xlsx", code="invalid_arguments")
|
||||
if not path.lower().endswith(SPREADSHEET_EXTENSIONS):
|
||||
raise ToolError(
|
||||
"Extension attendue : .xlsx, .xlsm ou .csv", code="invalid_arguments"
|
||||
)
|
||||
try:
|
||||
root = get_vault_root(vault)
|
||||
except ServiceError as e:
|
||||
@@ -52,57 +80,175 @@ def _workbook_path(vault: str, path: str) -> Path:
|
||||
return resolve_safe_path(root, path)
|
||||
|
||||
|
||||
def _is_csv(file_path: Path) -> bool:
|
||||
return file_path.suffix.lower() == ".csv"
|
||||
|
||||
|
||||
def _map_service_error(e: ServiceError) -> ToolError:
|
||||
return ToolError(e.message, code=e.code, details=e.details)
|
||||
|
||||
|
||||
@tool(
|
||||
name="list_xlsx_sheets",
|
||||
description=(
|
||||
"List the sheets of an .xlsx workbook with their dimensions "
|
||||
"(rows x columns) and whether the display caps truncate them. "
|
||||
"Use before editing to pick the right sheet name."
|
||||
),
|
||||
input_model=ListXlsxSheetsInput,
|
||||
risk=ToolRisk.READ,
|
||||
requires_vault=True,
|
||||
)
|
||||
def list_xlsx_sheets(ctx: ToolContext, params: ListXlsxSheetsInput) -> dict[str, Any]:
|
||||
"""Return sheet names and extents of the workbook."""
|
||||
from backend.xlsx_reader import MAX_COLS, MAX_ROWS, _sheet_extent
|
||||
def _sheet_titles(file_path: Path) -> list[str]:
|
||||
"""Sheet names of a workbook; a CSV has a single, unnamed “sheet”."""
|
||||
if _is_csv(file_path):
|
||||
return [""]
|
||||
from openpyxl import load_workbook
|
||||
|
||||
file_path = _workbook_path(params.vault, params.path)
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=True)
|
||||
try:
|
||||
from openpyxl import load_workbook
|
||||
return list(wb.sheetnames)
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
|
||||
def _read_grid(
|
||||
file_path: Path, sheet: str, max_rows: int, max_cols: int
|
||||
) -> tuple[str, list[list[str]], bool]:
|
||||
"""Read one sheet (or a CSV) as bounded, formatted, trimmed rows.
|
||||
|
||||
Returns ``(title, rows, truncated)``; ``truncated`` is True when real data
|
||||
sits just beyond the row cap (probed one row further) so the caller can say
|
||||
so instead of silently dropping it.
|
||||
"""
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from backend.xlsx_reader import _fmt
|
||||
|
||||
if _is_csv(file_path):
|
||||
import csv as csv_mod
|
||||
import io as io_mod
|
||||
|
||||
from backend.xlsx_reader import sniff_csv_delimiter
|
||||
|
||||
stem = file_path.stem
|
||||
if sheet and sheet != stem:
|
||||
raise ToolError(f"Feuille introuvable: {sheet}", code="not_found")
|
||||
raw = file_path.read_text(encoding="utf-8-sig", errors="replace")
|
||||
reader = csv_mod.reader(io_mod.StringIO(raw), delimiter=sniff_csv_delimiter(raw))
|
||||
grid: list[list[str]] = []
|
||||
truncated = False
|
||||
for i, row in enumerate(reader):
|
||||
if i >= max_rows:
|
||||
truncated = any(str(c).strip() for c in row)
|
||||
break
|
||||
grid.append([str(c) for c in row][:max_cols])
|
||||
while grid and not any(c.strip() for c in grid[-1]):
|
||||
grid.pop()
|
||||
return stem, grid, truncated
|
||||
|
||||
try:
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=True)
|
||||
except ServiceError as e:
|
||||
raise _map_service_error(e) from e
|
||||
except Exception as e:
|
||||
raise ToolError(f"Classeur illisible: {e}", code="invalid") from e
|
||||
try:
|
||||
sheets = []
|
||||
for ws in wb.worksheets:
|
||||
total_rows, total_cols = _sheet_extent(ws)
|
||||
sheets.append(
|
||||
{
|
||||
"name": ws.title,
|
||||
"total_rows": total_rows,
|
||||
"total_cols": total_cols,
|
||||
"truncated": total_rows > MAX_ROWS or total_cols > MAX_COLS,
|
||||
}
|
||||
if sheet:
|
||||
if sheet not in wb.sheetnames:
|
||||
raise ToolError(f"Feuille introuvable: {sheet}", code="not_found")
|
||||
ws = wb[sheet]
|
||||
else:
|
||||
ws = wb.active
|
||||
title = ws.title
|
||||
grid = []
|
||||
for row in ws.iter_rows(
|
||||
min_row=1, max_row=max_rows, max_col=max_cols, values_only=True
|
||||
):
|
||||
grid.append([_fmt(v) for v in row])
|
||||
probe = list(
|
||||
ws.iter_rows(
|
||||
min_row=max_rows + 1,
|
||||
max_row=max_rows + 1,
|
||||
max_col=max_cols,
|
||||
values_only=True,
|
||||
)
|
||||
return {"vault": params.vault, "path": params.path, "sheets": sheets}
|
||||
)
|
||||
truncated = any(any(str(v or "").strip() for v in r) for r in probe)
|
||||
finally:
|
||||
wb.close()
|
||||
while grid and not any(c.strip() for c in grid[-1]):
|
||||
grid.pop()
|
||||
return title, grid, truncated
|
||||
|
||||
|
||||
def _to_number(text: str) -> float | None:
|
||||
"""Coerce a displayed cell to a float, or ``None`` when it is not one."""
|
||||
t = text.strip().replace("\u00a0", "").replace(" ", "")
|
||||
if not _NUMBER_RE.match(t):
|
||||
return None
|
||||
try:
|
||||
return float(t.replace(",", "."))
|
||||
except ValueError: # pragma: no cover - regex already guarantees the shape
|
||||
return None
|
||||
|
||||
|
||||
@tool(
|
||||
name="list_xlsx_sheets",
|
||||
description=(
|
||||
"List the sheets of a spreadsheet (.xlsx, .xlsm or .csv) with their "
|
||||
"dimensions (rows x columns) and whether the display caps truncate "
|
||||
"them. Use before editing to pick the right sheet name."
|
||||
),
|
||||
input_model=ListXlsxSheetsInput,
|
||||
risk=ToolRisk.READ,
|
||||
requires_vault=True,
|
||||
)
|
||||
def list_xlsx_sheets(ctx: ToolContext, params: ListXlsxSheetsInput) -> dict[str, Any]:
|
||||
"""Return sheet names and extents of the workbook (or of the CSV)."""
|
||||
from backend.xlsx_reader import MAX_COLS, MAX_ROWS
|
||||
|
||||
file_path = _spreadsheet_path(params.vault, params.path)
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise ToolError(f"Fichier introuvable: {params.path}", code="not_found")
|
||||
|
||||
extents: list[tuple[str, int, int]] = []
|
||||
if _is_csv(file_path):
|
||||
_, rows, _ = _read_grid(file_path, "", MAX_SCAN_ROWS, MAX_SCAN_COLS)
|
||||
extents.append(
|
||||
(
|
||||
file_path.stem,
|
||||
len(rows),
|
||||
max((len(r) for r in rows), default=0),
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Declared dimensions are enough here (and far cheaper than scanning
|
||||
# every row): the caller just needs a size to decide what to read.
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from backend.xlsx_reader import _sheet_extent
|
||||
|
||||
try:
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=True)
|
||||
except ServiceError as e:
|
||||
raise _map_service_error(e) from e
|
||||
except Exception as e:
|
||||
raise ToolError(f"Classeur illisible: {e}", code="invalid") from e
|
||||
try:
|
||||
extents = [
|
||||
(ws.title, *_sheet_extent(ws)) for ws in wb.worksheets
|
||||
]
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
sheets = [
|
||||
{
|
||||
"name": name,
|
||||
"total_rows": total_rows,
|
||||
"total_cols": total_cols,
|
||||
"truncated": total_rows > MAX_ROWS or total_cols > MAX_COLS,
|
||||
}
|
||||
for name, total_rows, total_cols in extents
|
||||
]
|
||||
return {"vault": params.vault, "path": params.path, "sheets": sheets}
|
||||
|
||||
|
||||
@tool(
|
||||
name="xlsx_to_markdown",
|
||||
description=(
|
||||
"Read a sheet of an .xlsx workbook as a bounded markdown table "
|
||||
"(up to 100 rows x 20 columns). Use to inspect spreadsheet data "
|
||||
"before answering or editing."
|
||||
"Read a sheet of a spreadsheet (.xlsx, .xlsm or .csv) as a bounded "
|
||||
"markdown table (up to 100 rows x 20 columns). Use to inspect "
|
||||
"spreadsheet data before answering or editing."
|
||||
),
|
||||
input_model=XlsxToMarkdownInput,
|
||||
risk=ToolRisk.READ,
|
||||
@@ -110,49 +256,8 @@ def list_xlsx_sheets(ctx: ToolContext, params: ListXlsxSheetsInput) -> dict[str,
|
||||
)
|
||||
def xlsx_to_markdown(ctx: ToolContext, params: XlsxToMarkdownInput) -> dict[str, Any]:
|
||||
"""Render one sheet as a markdown table for the LLM context."""
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from backend.xlsx_reader import _fmt
|
||||
|
||||
file_path = _workbook_path(params.vault, params.path)
|
||||
try:
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=True)
|
||||
except ServiceError as e:
|
||||
raise _map_service_error(e) from e
|
||||
except Exception as e:
|
||||
raise ToolError(f"Classeur illisible: {e}", code="invalid") from e
|
||||
try:
|
||||
if params.sheet:
|
||||
if params.sheet not in wb.sheetnames:
|
||||
raise ToolError(
|
||||
f"Feuille introuvable: {params.sheet}", code="not_found"
|
||||
)
|
||||
ws = wb[params.sheet]
|
||||
else:
|
||||
ws = wb.active
|
||||
title = ws.title
|
||||
rows: list[list[str]] = []
|
||||
truncated = False
|
||||
for row in ws.iter_rows(
|
||||
min_row=1, max_row=MAX_MD_ROWS, max_col=MAX_MD_COLS, values_only=True
|
||||
):
|
||||
cells = [_fmt(v) for v in row]
|
||||
if not any(c.strip() for c in cells):
|
||||
continue
|
||||
rows.append(cells)
|
||||
# Real tail beyond the caps? Probe one row further.
|
||||
probe = list(
|
||||
ws.iter_rows(
|
||||
min_row=MAX_MD_ROWS + 1,
|
||||
max_row=MAX_MD_ROWS + 1,
|
||||
max_col=MAX_MD_COLS,
|
||||
values_only=True,
|
||||
)
|
||||
)
|
||||
if any(any(str(v or "").strip() for v in r) for r in probe):
|
||||
truncated = True
|
||||
finally:
|
||||
wb.close()
|
||||
file_path = _spreadsheet_path(params.vault, params.path)
|
||||
title, rows, truncated = _read_grid(file_path, params.sheet, MAX_MD_ROWS, MAX_MD_COLS)
|
||||
|
||||
lines: list[str] = []
|
||||
if rows:
|
||||
@@ -174,34 +279,166 @@ def xlsx_to_markdown(ctx: ToolContext, params: XlsxToMarkdownInput) -> dict[str,
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
name="search_workbook",
|
||||
description=(
|
||||
"Search a text across every sheet of a spreadsheet (.xlsx, .xlsm or "
|
||||
".csv) and return the matching cells with their sheet and A1 "
|
||||
"reference (max 100 matches). Use it to find where a value lives "
|
||||
"without dumping whole sheets into the context."
|
||||
),
|
||||
input_model=SearchWorkbookInput,
|
||||
risk=ToolRisk.READ,
|
||||
requires_vault=True,
|
||||
)
|
||||
def search_workbook(ctx: ToolContext, params: SearchWorkbookInput) -> dict[str, Any]:
|
||||
"""Find a needle across all sheets, bounded and counted per sheet."""
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
file_path = _spreadsheet_path(params.vault, params.path)
|
||||
needle = (params.query or "").strip()
|
||||
if not needle:
|
||||
raise ToolError("Requête vide", code="invalid_arguments")
|
||||
|
||||
titles = _sheet_titles(file_path)
|
||||
if params.sheet:
|
||||
if params.sheet not in titles:
|
||||
raise ToolError(f"Feuille introuvable: {params.sheet}", code="not_found")
|
||||
titles = [params.sheet]
|
||||
|
||||
hay = needle if params.case_sensitive else needle.lower()
|
||||
matches: list[dict[str, Any]] = []
|
||||
by_sheet: dict[str, int] = {}
|
||||
total = 0
|
||||
for title in titles:
|
||||
sheet_title, rows, _ = _read_grid(
|
||||
file_path, title, MAX_SCAN_ROWS, MAX_SCAN_COLS
|
||||
)
|
||||
label = sheet_title or file_path.stem
|
||||
for r_i, row in enumerate(rows, start=1):
|
||||
for c_i, value in enumerate(row, start=1):
|
||||
if not value:
|
||||
continue
|
||||
haystack = value if params.case_sensitive else value.lower()
|
||||
if hay not in haystack:
|
||||
continue
|
||||
total += 1
|
||||
by_sheet[label] = by_sheet.get(label, 0) + 1
|
||||
if len(matches) < MAX_SEARCH_RESULTS:
|
||||
matches.append(
|
||||
{
|
||||
"sheet": label,
|
||||
"cell": f"{get_column_letter(c_i)}{r_i}",
|
||||
"value": value,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"vault": params.vault,
|
||||
"path": params.path,
|
||||
"query": needle,
|
||||
"total": total,
|
||||
"truncated": total > MAX_SEARCH_RESULTS,
|
||||
"by_sheet": by_sheet,
|
||||
"matches": matches,
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
name="analyze_range",
|
||||
description=(
|
||||
"Aggregate an A1 range of a sheet (.xlsx, .xlsm or .csv): count, sum, "
|
||||
"mean, min and max of the numeric cells, plus a bounded sample of the "
|
||||
"values. Use it to answer a question about a column without reading "
|
||||
"the whole sheet."
|
||||
),
|
||||
input_model=AnalyzeRangeInput,
|
||||
risk=ToolRisk.READ,
|
||||
requires_vault=True,
|
||||
)
|
||||
def analyze_range(ctx: ToolContext, params: AnalyzeRangeInput) -> dict[str, Any]:
|
||||
"""Numeric aggregates + value sample over an A1 range of the sheet."""
|
||||
from openpyxl.utils.cell import range_boundaries
|
||||
|
||||
file_path = _spreadsheet_path(params.vault, params.path)
|
||||
title, rows, _ = _read_grid(file_path, params.sheet, MAX_SCAN_ROWS, MAX_SCAN_COLS)
|
||||
|
||||
label = (params.range or "").strip()
|
||||
if label:
|
||||
try:
|
||||
min_col, min_row, max_col, max_row = range_boundaries(label.upper())
|
||||
except Exception as e:
|
||||
raise ToolError(f"Plage invalide: {label}", code="invalid_arguments") from e
|
||||
if (max_row - min_row + 1) * (max_col - min_col + 1) > MAX_RANGE_CELLS:
|
||||
raise ToolError(
|
||||
f"Plage trop grande (max {MAX_RANGE_CELLS} cellules)",
|
||||
code="invalid_arguments",
|
||||
)
|
||||
selected = [row[min_col - 1 : max_col] for row in rows[min_row - 1 : max_row]]
|
||||
else:
|
||||
selected = rows
|
||||
|
||||
values = [v for row in selected for v in row if isinstance(v, str) and v.strip()]
|
||||
numbers = [n for n in (_to_number(v) for v in values) if n is not None]
|
||||
|
||||
stats: dict[str, Any] = {"count": len(numbers)}
|
||||
if numbers:
|
||||
stats.update(
|
||||
{
|
||||
"sum": round(sum(numbers), 6),
|
||||
"mean": round(sum(numbers) / len(numbers), 6),
|
||||
"min": min(numbers),
|
||||
"max": max(numbers),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"vault": params.vault,
|
||||
"path": params.path,
|
||||
"sheet": title,
|
||||
"range": params.range or "",
|
||||
"rows": len(selected),
|
||||
"cols": max((len(r) for r in selected), default=0),
|
||||
"cells": len(values),
|
||||
"numeric": stats,
|
||||
"values": values[:MAX_RANGE_VALUES],
|
||||
"truncated": len(values) > MAX_RANGE_VALUES,
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
name="update_xlsx_cells",
|
||||
description=(
|
||||
"Edit cells of an existing .xlsx workbook. ``cells`` maps A1 "
|
||||
"references to new values (max 500). A value starting with '=' or "
|
||||
"'@' is stored as TEXT unless allow_formula is set (DDE guard). "
|
||||
"Editing a workbook carrying features openpyxl cannot rewrite "
|
||||
"requires force=true (cached formula results, slicers…)."
|
||||
"Edit cells of an existing spreadsheet (.xlsx, .xlsm or .csv). "
|
||||
"``cells`` maps A1 references to new values (max 500). A value "
|
||||
"starting with '=' or '@' is stored as TEXT unless allow_formula is "
|
||||
"set (DDE guard). Editing a workbook carrying features openpyxl "
|
||||
"cannot rewrite requires force=true (cached formula results, "
|
||||
"slicers…). A .csv has no sheet: any ``sheet`` value is ignored."
|
||||
),
|
||||
input_model=UpdateXlsxCellsInput,
|
||||
risk=ToolRisk.WRITE,
|
||||
requires_vault=True,
|
||||
)
|
||||
def update_xlsx_cells(ctx: ToolContext, params: UpdateXlsxCellsInput) -> dict[str, Any]:
|
||||
"""Wrap the guarded cell-edit service."""
|
||||
from backend.services.mutations import edit_xlsx_cells
|
||||
"""Wrap the guarded cell-edit service (``.xlsx``/``.xlsm`` or ``.csv``)."""
|
||||
from backend.services.mutations import edit_xlsx_cells, save_csv_cells
|
||||
|
||||
file_path = _spreadsheet_path(params.vault, params.path)
|
||||
if not params.cells:
|
||||
raise ToolError("Aucune cellule fournie", code="invalid_arguments")
|
||||
if not _is_csv(file_path) and not params.sheet:
|
||||
raise ToolError("Feuille requise pour un classeur", code="invalid_arguments")
|
||||
try:
|
||||
result = edit_xlsx_cells(
|
||||
params.vault,
|
||||
params.path,
|
||||
params.sheet,
|
||||
dict(params.cells),
|
||||
allow_formula=params.allow_formula,
|
||||
force=params.force,
|
||||
)
|
||||
if _is_csv(file_path):
|
||||
result = save_csv_cells(params.vault, params.path, dict(params.cells))
|
||||
else:
|
||||
result = edit_xlsx_cells(
|
||||
params.vault,
|
||||
params.path,
|
||||
params.sheet,
|
||||
dict(params.cells),
|
||||
allow_formula=params.allow_formula,
|
||||
force=params.force,
|
||||
)
|
||||
except ServiceError as e:
|
||||
raise _map_service_error(e) from e
|
||||
return {
|
||||
@@ -216,9 +453,10 @@ def update_xlsx_cells(ctx: ToolContext, params: UpdateXlsxCellsInput) -> dict[st
|
||||
@tool(
|
||||
name="append_xlsx_rows",
|
||||
description=(
|
||||
"Append rows at the end of a sheet of an existing .xlsx workbook. "
|
||||
"Values are typed like in the viewer (numbers, TRUE/FALSE, FR dates "
|
||||
"JJ/MM/AAAA). The workbook is rewritten atomically with a backup."
|
||||
"Append rows at the end of a sheet of an existing spreadsheet "
|
||||
"(.xlsx or .xlsm). Values are typed like in the viewer (numbers, "
|
||||
"TRUE/FALSE, FR dates JJ/MM/AAAA). The workbook is rewritten "
|
||||
"atomically with a backup. Not available for .csv."
|
||||
),
|
||||
input_model=AppendXlsxRowsInput,
|
||||
risk=ToolRisk.WRITE,
|
||||
@@ -236,7 +474,13 @@ def append_xlsx_rows(ctx: ToolContext, params: AppendXlsxRowsInput) -> dict[str,
|
||||
if len(params.rows) > 500:
|
||||
raise ToolError("Trop de lignes (max 500)", code="invalid_arguments")
|
||||
|
||||
file_path = _workbook_path(params.vault, params.path)
|
||||
file_path = _spreadsheet_path(params.vault, params.path)
|
||||
if _is_csv(file_path):
|
||||
raise ToolError(
|
||||
"Un .csv n'a pas de notion de fin de feuille : utilisez "
|
||||
"update_xlsx_cells avec des références A1",
|
||||
code="invalid_arguments",
|
||||
)
|
||||
try:
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=True)
|
||||
try:
|
||||
@@ -284,3 +528,46 @@ def append_xlsx_rows(ctx: ToolContext, params: AppendXlsxRowsInput) -> dict[str,
|
||||
"rows": len(params.rows),
|
||||
"first_row": first_free,
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
name="edit_xlsx_structure",
|
||||
description=(
|
||||
"Change the structure of an existing .xlsx/.xlsm workbook: add, "
|
||||
"rename, duplicate or delete a sheet, or insert/delete rows and "
|
||||
"columns. ``actions`` is an ordered list of "
|
||||
'{"op": "sheet_add"|"sheet_rename"|"sheet_duplicate"|"sheet_delete"|'
|
||||
'"row_insert"|"row_delete"|"col_insert"|"col_delete", …} '
|
||||
"(1 to 50). Deletions drop data and cannot be undone from the "
|
||||
"assistant — confirm with the user first."
|
||||
),
|
||||
input_model=EditXlsxStructureInput,
|
||||
risk=ToolRisk.WRITE,
|
||||
requires_vault=True,
|
||||
)
|
||||
def edit_xlsx_structure(
|
||||
ctx: ToolContext, params: EditXlsxStructureInput
|
||||
) -> dict[str, Any]:
|
||||
"""Apply a batch of structural changes through the guarded service."""
|
||||
from backend.services.mutations import mutate_xlsx_structure
|
||||
|
||||
if not params.actions:
|
||||
raise ToolError("Aucune action fournie", code="invalid_arguments")
|
||||
if len(params.actions) > 50:
|
||||
raise ToolError("Trop d'actions (max 50)", code="invalid_arguments")
|
||||
if str(params.path or "").lower().endswith(".csv"):
|
||||
raise ToolError(
|
||||
"Un .csv n'a pas de structure modifiable", code="invalid_arguments"
|
||||
)
|
||||
try:
|
||||
result = mutate_xlsx_structure(
|
||||
params.vault, params.path, [dict(a) for a in params.actions], force=params.force
|
||||
)
|
||||
except ServiceError as e:
|
||||
raise _map_service_error(e) from e
|
||||
return {
|
||||
"status": "ok",
|
||||
"vault": result["vault"],
|
||||
"path": result["path"],
|
||||
"actions": len(params.actions),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Dossier personnel par utilisateur (#194).
|
||||
|
||||
Chaque utilisateur reçoit ``<OBSIGATE_HOME_ROOT>/<username>`` monté comme un
|
||||
vault propre ``home-<username>`` : l'isolation profite de l'ACL par vault
|
||||
déjà en place (``check_vault_access``), aucune ACL par chemin à inventer.
|
||||
|
||||
Tout est idempotent (``ensure_user_home``) pour être rappelé à la création
|
||||
d'un compte ET au démarrage : un dossier supprimé, un registre perdu ou un
|
||||
user créé hors API se réparent au boot.
|
||||
|
||||
La fonctionnalité est inactive tant que ``OBSIGATE_HOME_ROOT`` n'est pas
|
||||
défini (dev, tests, desktop) — comportement inchangé.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger("obsigate.user_home")
|
||||
|
||||
# Même grammaire que CreateUserRequest.username_valid (backend/auth/router.py) :
|
||||
# segment de chemin sûr, déjà passé par la validation de l'API. Re-vérifié ici
|
||||
# car le nom sert à construire un chemin (défense en profondeur).
|
||||
_USERNAME_RE = re.compile(r"^[a-zA-Z0-9_-]{2,32}$")
|
||||
|
||||
|
||||
def home_root() -> Path | None:
|
||||
"""Racine des dossiers persos, ou ``None`` si la fonctionnalité est inactive."""
|
||||
root = os.environ.get("OBSIGATE_HOME_ROOT", "").strip()
|
||||
return Path(root) if root else None
|
||||
|
||||
|
||||
def home_vault_name(username: str) -> str:
|
||||
"""Nom de vault du dossier perso — pas de ``/`` ni ``::`` (segment d'URL, clé d'index)."""
|
||||
return f"home-{username}"
|
||||
|
||||
|
||||
async def ensure_user_home(username: str) -> str | None:
|
||||
"""Crée (si besoin) le dossier perso de *username*, son vault et son octroi.
|
||||
|
||||
Retourne le nom de vault, ou ``None`` si désactivé / nom invalide /
|
||||
erreur disque (journalisée, réparée au prochain démarrage).
|
||||
"""
|
||||
root = home_root()
|
||||
if root is None:
|
||||
return None
|
||||
if not _USERNAME_RE.match(username):
|
||||
logger.warning(f"Home folder skipped: invalid username {username!r}")
|
||||
return None
|
||||
|
||||
home = root / username
|
||||
try:
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
except OSError:
|
||||
logger.exception(f"Cannot create home folder {home} for user '{username}'")
|
||||
return None
|
||||
|
||||
name = home_vault_name(username)
|
||||
try:
|
||||
from backend.indexer import add_vault_to_index, index, persist_vault, vault_config
|
||||
from backend.sse import sse_manager
|
||||
|
||||
vault_path = str(home)
|
||||
if name not in index:
|
||||
await add_vault_to_index(name, vault_path)
|
||||
persist_vault(name)
|
||||
from backend.watcher_state import get_watcher
|
||||
|
||||
watcher = get_watcher()
|
||||
if watcher:
|
||||
await watcher.add_vault(name, vault_path)
|
||||
await sse_manager.broadcast("vault_added", {"vault": name})
|
||||
logger.info(f"Home vault '{name}' registered at {vault_path}")
|
||||
elif name not in vault_config:
|
||||
vault_config[name] = {"path": vault_path, "attachmentsPath": None,
|
||||
"scanAttachmentsOnStartup": True}
|
||||
except Exception:
|
||||
logger.exception(f"Cannot register home vault '{name}' for user '{username}'")
|
||||
return None
|
||||
|
||||
_grant(username, name)
|
||||
return name
|
||||
|
||||
|
||||
async def release_user_home(username: str) -> None:
|
||||
"""Retire le vault du dossier perso à la suppression du compte (#194).
|
||||
|
||||
Le dossier sur disque est **conservé** (décision produit : pas de perte
|
||||
de données) ; seul l'index, le watcher et le registre le referment.
|
||||
"""
|
||||
name = home_vault_name(username)
|
||||
try:
|
||||
from backend.indexer import index, remove_vault_from_index, unpersist_vault
|
||||
|
||||
unpersist_vault(name)
|
||||
if name not in index:
|
||||
return
|
||||
await remove_vault_from_index(name)
|
||||
from backend.sse import sse_manager
|
||||
from backend.watcher_state import get_watcher
|
||||
|
||||
watcher = get_watcher()
|
||||
if watcher:
|
||||
await watcher.remove_vault(name)
|
||||
await sse_manager.broadcast("vault_removed", {"vault": name})
|
||||
logger.info(f"Home vault '{name}' released (folder kept)")
|
||||
except Exception:
|
||||
logger.exception(f"Cannot release home vault '{name}'")
|
||||
|
||||
|
||||
def _grant(username: str, vault_name: str) -> None:
|
||||
"""Ajoute le vault à ``user.vaults`` s'il n'y est pas déjà (#194).
|
||||
|
||||
L'octroi est explicite même pour un admin (``vaults: ["*"]``) : ``*`` ne
|
||||
couvre jamais un dossier perso (voir ``check_vault_access``).
|
||||
"""
|
||||
from backend.auth.user_store import get_user, update_user
|
||||
|
||||
user = get_user(username)
|
||||
if not user:
|
||||
return # créé hors API (bootstrap avant users.json) → réparé au boot suivant
|
||||
vaults = user.get("vaults") or []
|
||||
if vault_name in vaults:
|
||||
return
|
||||
update_user(username, {"vaults": [*vaults, vault_name]})
|
||||
|
||||
|
||||
async def ensure_all_user_homes() -> int:
|
||||
"""Passe de réparation/migration au démarrage : un home par user existant.
|
||||
|
||||
Balaye aussi les vaults orphelins (compte supprimé hors route, ex.
|
||||
``create_admin.py delete``) : ils sont refermés, dossier conservé.
|
||||
"""
|
||||
from backend.auth.user_store import get_all_users
|
||||
from backend.indexer import index, vault_config
|
||||
|
||||
root = home_root()
|
||||
if root is None:
|
||||
return 0
|
||||
users = get_all_users()
|
||||
usernames = {u.get("username") for u in users}
|
||||
created = 0
|
||||
for user in users:
|
||||
username = user.get("username")
|
||||
if not username:
|
||||
continue
|
||||
if await ensure_user_home(username):
|
||||
created += 1
|
||||
if created:
|
||||
logger.info(f"User home folders ensured for {created} user(s)")
|
||||
|
||||
# Orphelins : vault home-<x> toujours indexé mais <x> n'existe plus.
|
||||
# Path.parent == root → on ne touche qu'aux dossiers sous la racine Home,
|
||||
# jamais à un vault admin nommé « home-… » par ailleurs.
|
||||
for name in list(index):
|
||||
if not name.startswith("home-"):
|
||||
continue
|
||||
owner = name[len("home-"):]
|
||||
if owner in usernames:
|
||||
continue
|
||||
cfg_path = Path((vault_config.get(name) or {}).get("path", ""))
|
||||
if cfg_path.parent != root:
|
||||
continue
|
||||
logger.warning(f"Orphan home vault '{name}' released (user deleted?)")
|
||||
await release_user_home(owner)
|
||||
return created
|
||||
+295
-23
@@ -14,9 +14,11 @@ read-only; :func:`render_csv_table` turns a CSV into the same table shape.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import html
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
import zipfile
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
@@ -32,6 +34,13 @@ logger = logging.getLogger("obsigate.xlsx_reader")
|
||||
MAX_ROWS = 500
|
||||
MAX_COLS = 40
|
||||
|
||||
# BUG-094 — an empty sheet used to render as a bare "Feuille vide" paragraph
|
||||
# with no cell at all, so a freshly added sheet had nothing to click and no way
|
||||
# to insert a row/column. Render a small blank grid instead (Excel-like), with
|
||||
# real A1 coordinates, so the cells are editable and the structure actions work.
|
||||
EMPTY_SHEET_ROWS = 20
|
||||
EMPTY_SHEET_COLS = 8
|
||||
|
||||
# #153 A9 — window size served by ``read_sheet_window()`` (lazy per-sheet
|
||||
# loading). The endpoint is bounded so a single request can never ask for the
|
||||
# whole workbook back in one JSON payload; the UI pages through the rest.
|
||||
@@ -58,8 +67,22 @@ LOSSY_PARTS: dict[str, tuple[str, ...]] = {
|
||||
# Excel recalculates.
|
||||
_CACHED_FORMULA_RE = re.compile(rb"<f[ >][^<]*</f>\s*<v>[^<]")
|
||||
|
||||
# Sheet XML scanned by the cached-formula probe (CPU guard, like MAX_REPLACE_FILE_BYTES).
|
||||
_MAX_PROBE_BYTES = 8_000_000
|
||||
# Sheet XML scanned by the cached-formula probe (CPU guard, like
|
||||
# MAX_REPLACE_FILE_BYTES). BUG-099 — the allowance is PER SHEET: a single huge
|
||||
# first sheet used to eat the whole budget and hide a cached formula sitting in
|
||||
# the next one. The total ceiling still bounds the work on a many-sheet archive.
|
||||
# Running out of budget is reported as *unverified* (never as "nothing to lose")
|
||||
# so the write guard stays cautious instead of silently dropping the values.
|
||||
_MAX_PROBE_BYTES_PER_SHEET = 4_000_000
|
||||
_MAX_PROBE_BYTES_TOTAL = 32_000_000
|
||||
|
||||
# #156-A13 — metadata cache. `read_sheet_window()` serves one window at a time
|
||||
# and used to reload the whole workbook (normal mode, data_only=False) for every
|
||||
# window, just to read the style/merge/freeze maps of one sheet. The result is
|
||||
# keyed by (path, mtime_ns, size): any write replaces the file, hence the key.
|
||||
_META_CACHE_MAX = 8
|
||||
_meta_cache: dict[str, tuple[tuple[int, int], dict[str, dict[str, Any]]]] = {}
|
||||
_meta_cache_lock = threading.Lock()
|
||||
|
||||
# #153 A17 — OPC parts of chart / pivot objects, matched against the archive
|
||||
# name list (xl/charts/chart1.xml, xl/pivotTables/pivotTable1.xml, …).
|
||||
@@ -104,6 +127,18 @@ def _cell_fragments(cell: Any) -> tuple[list[str], str | None]:
|
||||
fragments.append("font-weight:600")
|
||||
if font and font.italic:
|
||||
fragments.append("font-style:italic")
|
||||
# #156-A8 — underline is written from the viewer too, so it is read back
|
||||
# (the toggle in the formatting menu needs to see its own effect).
|
||||
decorations = []
|
||||
if font and font.underline:
|
||||
decorations.append("underline")
|
||||
if font and getattr(font, "strike", False):
|
||||
decorations.append("line-through")
|
||||
if decorations:
|
||||
fragments.append("text-decoration:" + " ".join(decorations))
|
||||
size = getattr(font, "size", None) if font else None
|
||||
if isinstance(size, (int, float)) and size != 11:
|
||||
fragments.append(f"font-size:{size:g}pt")
|
||||
fmt = cell.number_format
|
||||
if fmt and fmt not in ("General", "@"):
|
||||
# A custom number format is signalled typographically (mono font)
|
||||
@@ -113,29 +148,129 @@ def _cell_fragments(cell: Any) -> tuple[list[str], str | None]:
|
||||
fragments.append("font-family:'JetBrains Mono',monospace")
|
||||
alignment = cell.alignment
|
||||
align = alignment.horizontal if alignment else None
|
||||
return fragments, (align if align in ("left", "right", "center") else None)
|
||||
if alignment is not None:
|
||||
vertical = getattr(alignment, "vertical", None)
|
||||
if vertical == "center":
|
||||
fragments.append("vertical-align:middle")
|
||||
elif vertical in ("top", "bottom"):
|
||||
fragments.append(f"vertical-align:{vertical}")
|
||||
if getattr(alignment, "wrap_text", None) is True:
|
||||
fragments.append("white-space:normal")
|
||||
elif getattr(alignment, "wrap_text", None) is False:
|
||||
fragments.append("white-space:nowrap")
|
||||
rotation = getattr(alignment, "text_rotation", None) or 0
|
||||
if rotation == 90 or rotation == 255:
|
||||
fragments.append("writing-mode:vertical-rl")
|
||||
elif rotation == 180:
|
||||
fragments.append("writing-mode:vertical-rl;transform:scale(-1)")
|
||||
else:
|
||||
# Angles diagonaux (ex. ±45° du menu Format › Rotation) : OOXML
|
||||
# stocke l'anti-horaire sur 0..90 et l'horaire sur 91..180 (-45°
|
||||
# → 135, cf. mutations). Le CSS tourne en sens horaire : signe
|
||||
# inversé pour un rendu fidèle.
|
||||
excel = rotation if rotation <= 90 else -(180 - rotation)
|
||||
if excel:
|
||||
fragments.append(f"transform:rotate({-excel}deg)")
|
||||
border = getattr(cell, "border", None)
|
||||
if border is not None:
|
||||
sides = []
|
||||
for side_name in ("left", "right", "top", "bottom"):
|
||||
side = getattr(border, side_name, None)
|
||||
style = getattr(side, "style", None)
|
||||
if not style:
|
||||
continue
|
||||
color = getattr(getattr(side, "color", None), "rgb", None)
|
||||
hexpart = ""
|
||||
if isinstance(color, str) and len(color) == 8:
|
||||
hexpart = f" #{color[2:].lower()}"
|
||||
width = {"medium": "2px", "thick": "3px"}.get(style, "1px")
|
||||
css_style = {"dashed": "dashed", "dotted": "dotted", "double": "double"}.get(
|
||||
style, "solid"
|
||||
)
|
||||
sides.append(f"border-{side_name}:{width} {css_style}{hexpart}")
|
||||
fragments.extend(sides)
|
||||
return fragments, (align if align in ("left", "right", "center", "justify") else None)
|
||||
|
||||
|
||||
def _sheet_style_maps(ws: Any) -> tuple[dict[str, str], dict[str, str]]:
|
||||
"""Flat ``{ref: css}`` and ``{ref: align}`` maps of one worksheet.
|
||||
def _sheet_style_maps(
|
||||
ws: Any,
|
||||
) -> tuple[dict[str, str], dict[str, str], dict[str, str], dict[str, str]]:
|
||||
"""Flat maps of one worksheet: css, align, comment text, number format.
|
||||
|
||||
The flat string is what the API serves and what the viewer applies
|
||||
verbatim to ``td.style``; a plain cell is simply absent from the map.
|
||||
``left`` is the table default and never included. Bounded by
|
||||
``MAX_ROWS x MAX_COLS`` like the render itself.
|
||||
``MAX_ROWS x MAX_COLS`` like the render itself. Comment texts are capped
|
||||
(tooltip use) and formats only list non-General number formats.
|
||||
"""
|
||||
styles: dict[str, str] = {}
|
||||
aligns: dict[str, str] = {}
|
||||
comments: dict[str, str] = {}
|
||||
formats: dict[str, str] = {}
|
||||
for row in ws.iter_rows(min_row=1, max_row=MAX_ROWS, max_col=MAX_COLS):
|
||||
for cell in row:
|
||||
if cell.value is None and cell.number_format == "General":
|
||||
comment = getattr(cell, "comment", None)
|
||||
comment_text = getattr(comment, "text", None) if comment is not None else None
|
||||
# Une cellule vide mais stylée (gras, diagonale, commentaire…)
|
||||
# doit survivre à la relecture : `has_style` (et le commentaire)
|
||||
# sont les seuls tests rapides qui la distinguent d'une cellule
|
||||
# vraiment vierge — sinon le style semble « ne pas fonctionner »
|
||||
# dès qu'on recharge le classeur.
|
||||
if cell.value is None and cell.number_format == "General" and not cell.has_style:
|
||||
if comment_text:
|
||||
comments[cell.coordinate] = str(comment_text)[:500]
|
||||
continue
|
||||
fragments, align = _cell_fragments(cell)
|
||||
if fragments:
|
||||
styles[cell.coordinate] = ";".join(fragments)
|
||||
if align and align != "left":
|
||||
aligns[cell.coordinate] = align
|
||||
return styles, aligns
|
||||
if comment_text:
|
||||
comments[cell.coordinate] = str(comment_text)[:500]
|
||||
fmt = getattr(cell, "number_format", None)
|
||||
if fmt and fmt not in ("General", "@", None):
|
||||
formats[cell.coordinate] = str(fmt)
|
||||
return styles, aligns, comments, formats
|
||||
|
||||
|
||||
def _sheet_dim_maps(ws: Any) -> tuple[dict[str, float], dict[str, float]]:
|
||||
"""Explicit column widths (units) and row heights (pt), by letter/number.
|
||||
|
||||
Only dimensions the file sets explicitly are listed (defaults are a
|
||||
rendering concern of the client). Bounded like the render itself.
|
||||
"""
|
||||
from openpyxl.utils import column_index_from_string
|
||||
|
||||
colwidths: dict[str, float] = {}
|
||||
try:
|
||||
for key, dim in (getattr(ws, "column_dimensions", {}) or {}).items():
|
||||
width = getattr(dim, "width", None)
|
||||
if not isinstance(width, (int, float)):
|
||||
continue
|
||||
letters = str(key).split(":")
|
||||
try:
|
||||
cols = [column_index_from_string(a.strip()) for a in letters]
|
||||
except ValueError:
|
||||
continue
|
||||
lo, hi = min(cols), max(cols)
|
||||
for c in range(lo, min(hi, MAX_COLS) + 1):
|
||||
if c >= 1:
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
colwidths[get_column_letter(c)] = float(width)
|
||||
except Exception:
|
||||
logger.debug("xlsx column widths unavailable", exc_info=True)
|
||||
rowheights: dict[str, float] = {}
|
||||
try:
|
||||
for r, dim in (getattr(ws, "row_dimensions", {}) or {}).items():
|
||||
if not isinstance(r, int) or not 1 <= r <= MAX_ROWS:
|
||||
continue
|
||||
height = getattr(dim, "height", None)
|
||||
if isinstance(height, (int, float)):
|
||||
rowheights[str(r)] = float(height)
|
||||
except Exception:
|
||||
logger.debug("xlsx row heights unavailable", exc_info=True)
|
||||
return colwidths, rowheights
|
||||
|
||||
|
||||
def read_sheet_styles(file_path: Path, sheet: str) -> dict[str, dict[str, Any]]:
|
||||
@@ -313,24 +448,65 @@ def _table(
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _has_cached_formulas(zf: zipfile.ZipFile) -> bool:
|
||||
"""True when at least one formula cell still carries its computed value."""
|
||||
budget = _MAX_PROBE_BYTES
|
||||
def _is_sheet_xml(name: str) -> bool:
|
||||
"""True for the worksheet XML parts the cached-formula probe scans."""
|
||||
return name.startswith("xl/worksheets/sheet") and name.endswith(".xml")
|
||||
|
||||
|
||||
def _scan_cached_formulas(zf: zipfile.ZipFile) -> tuple[bool, bool]:
|
||||
"""Scan the sheet XML for a formula carrying a non-empty cached result.
|
||||
|
||||
Returns ``(found, unverified)``. ``unverified`` is True when the byte
|
||||
budget stopped the scan before every sheet could be read to the end: a
|
||||
negative result is then **not** proof that the workbook holds no cached
|
||||
value (BUG-099), so callers must not treat it as a licence to write.
|
||||
|
||||
Each sheet gets its own :data:`_MAX_PROBE_BYTES_PER_SHEET` allowance (a
|
||||
single huge sheet can no longer starve the others) while
|
||||
:data:`_MAX_PROBE_BYTES_TOTAL` bounds the whole archive. A hit short-
|
||||
circuits the scan: the answer is already known.
|
||||
"""
|
||||
budget = _MAX_PROBE_BYTES_TOTAL
|
||||
unverified = False
|
||||
for name in zf.namelist():
|
||||
if not name.startswith("xl/worksheets/sheet") or not name.endswith(".xml"):
|
||||
if not _is_sheet_xml(name):
|
||||
continue
|
||||
sheet_budget = min(_MAX_PROBE_BYTES_PER_SHEET, budget)
|
||||
exhausted = False
|
||||
try:
|
||||
with zf.open(name) as fh:
|
||||
while budget > 0:
|
||||
chunk = fh.read(65536)
|
||||
while sheet_budget > 0:
|
||||
# Read at most what the sheet's allowance has left, so one
|
||||
# large chunk can never consume the whole total budget.
|
||||
chunk = fh.read(min(65536, sheet_budget))
|
||||
if not chunk:
|
||||
break
|
||||
break # read to the end: this sheet is verified clean
|
||||
sheet_budget -= len(chunk)
|
||||
budget -= len(chunk)
|
||||
if _CACHED_FORMULA_RE.search(chunk):
|
||||
return True
|
||||
return True, False
|
||||
else:
|
||||
# Left the loop on the budget, not on EOF.
|
||||
exhausted = True
|
||||
except (KeyError, OSError, zipfile.BadZipFile):
|
||||
continue
|
||||
return False
|
||||
if exhausted:
|
||||
unverified = True
|
||||
if budget <= 0:
|
||||
unverified = True
|
||||
break
|
||||
return False, unverified
|
||||
|
||||
|
||||
def _has_cached_formulas(zf: zipfile.ZipFile) -> bool:
|
||||
"""True when at least one formula cell still carries its computed value.
|
||||
|
||||
Boolean view of :func:`_scan_cached_formulas` for the display path (the
|
||||
second ``data_only=True`` read): a truncated scan simply skips the shadow
|
||||
grid, it never claims the workbook is lossless.
|
||||
"""
|
||||
found, _ = _scan_cached_formulas(zf)
|
||||
return found
|
||||
|
||||
|
||||
def inspect_workbook(file_path: Path) -> list[str]:
|
||||
@@ -342,6 +518,10 @@ def inspect_workbook(file_path: Path) -> list[str]:
|
||||
|
||||
``cached_values`` is a synthetic key: openpyxl keeps the formula but drops
|
||||
the cached result, so the workbook stays correct once Excel recalculates it.
|
||||
``cached_values_unverified`` (BUG-099) is the other synthetic key: the
|
||||
cached-value probe ran out of budget, so a negative result is not proof —
|
||||
the entry keeps the write guard cautious (409 + confirmation) rather than
|
||||
promising a lossless round-trip it cannot vouch for.
|
||||
"""
|
||||
try:
|
||||
with zipfile.ZipFile(file_path) as zf:
|
||||
@@ -351,21 +531,60 @@ def inspect_workbook(file_path: Path) -> list[str]:
|
||||
for key, prefixes in LOSSY_PARTS.items()
|
||||
if any(name.startswith(prefix) for name in names for prefix in prefixes)
|
||||
}
|
||||
if _has_cached_formulas(zf):
|
||||
cached_found, cached_unverified = _scan_cached_formulas(zf)
|
||||
if cached_found:
|
||||
found.add("cached_values")
|
||||
elif cached_unverified:
|
||||
found.add("cached_values_unverified")
|
||||
return sorted(found)
|
||||
except (OSError, zipfile.BadZipFile):
|
||||
return []
|
||||
|
||||
|
||||
def invalidate_workbook_meta(file_path: Path | str) -> None:
|
||||
"""Drop the cached metadata of one workbook (#156-A13).
|
||||
|
||||
Called by the write path right after the atomic replace: the (mtime, size)
|
||||
key already changes on a rewrite, this only closes the theoretical window
|
||||
where a same-size write lands on the same timestamp tick.
|
||||
"""
|
||||
with _meta_cache_lock:
|
||||
_meta_cache.pop(str(file_path), None)
|
||||
|
||||
|
||||
def read_workbook_meta(file_path: Path) -> dict[str, dict[str, Any]]:
|
||||
"""Return ``{sheet: {styles, aligns, merges, freeze}}`` for every sheet.
|
||||
"""Return ``{sheet: {styles, aligns, comments, formats, colwidths,
|
||||
rowheights, merges, freeze}}``.
|
||||
|
||||
One normal (non-streaming) load serves the three A15 metadata maps: the
|
||||
fragments are the workbook's own values, a failure yields ``{}`` per sheet
|
||||
so the viewer keeps its plain rendering. Styles are read with
|
||||
``data_only=False`` — the edited value is the formula, not its result.
|
||||
|
||||
#156-A13 — the result is cached on ``(path, mtime_ns, size)``: a truncated
|
||||
sheet is fetched window by window, and each window used to pay a full
|
||||
workbook load for these maps alone. Callers only read from the mapping.
|
||||
"""
|
||||
try:
|
||||
st = file_path.stat()
|
||||
except OSError:
|
||||
return {}
|
||||
key = str(file_path)
|
||||
stamp = (st.st_mtime_ns, st.st_size)
|
||||
with _meta_cache_lock:
|
||||
hit = _meta_cache.get(key)
|
||||
if hit and hit[0] == stamp:
|
||||
return hit[1]
|
||||
out = _read_workbook_meta_uncached(file_path)
|
||||
with _meta_cache_lock:
|
||||
_meta_cache[key] = (stamp, out)
|
||||
while len(_meta_cache) > _META_CACHE_MAX:
|
||||
_meta_cache.pop(next(iter(_meta_cache)))
|
||||
return out
|
||||
|
||||
|
||||
def _read_workbook_meta_uncached(file_path: Path) -> dict[str, dict[str, Any]]:
|
||||
"""Load the workbook once and build the per-sheet metadata maps."""
|
||||
try:
|
||||
wb = load_workbook(str(file_path), data_only=False)
|
||||
except Exception:
|
||||
@@ -373,12 +592,17 @@ def read_workbook_meta(file_path: Path) -> dict[str, dict[str, Any]]:
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
try:
|
||||
for ws in wb.worksheets:
|
||||
styles, aligns = _sheet_style_maps(ws)
|
||||
styles, aligns, comments, formats = _sheet_style_maps(ws)
|
||||
colwidths, rowheights = _sheet_dim_maps(ws)
|
||||
merged = getattr(ws, "merged_cells", None)
|
||||
ranges = getattr(merged, "ranges", None) or []
|
||||
out[ws.title] = {
|
||||
"styles": styles,
|
||||
"aligns": aligns,
|
||||
"comments": comments,
|
||||
"formats": formats,
|
||||
"colwidths": colwidths,
|
||||
"rowheights": rowheights,
|
||||
"merges": [str(r) for r in ranges],
|
||||
"freeze": str(getattr(ws, "freeze_panes", None) or ""),
|
||||
}
|
||||
@@ -386,7 +610,14 @@ def read_workbook_meta(file_path: Path) -> dict[str, dict[str, Any]]:
|
||||
except Exception:
|
||||
logger.debug("xlsx meta unavailable", exc_info=True)
|
||||
for t in wb.sheetnames:
|
||||
out.setdefault(t, {"styles": {}, "aligns": {}, "merges": [], "freeze": ""})
|
||||
out.setdefault(
|
||||
t,
|
||||
{
|
||||
"styles": {}, "aligns": {}, "comments": {}, "formats": {},
|
||||
"colwidths": {}, "rowheights": {},
|
||||
"merges": [], "freeze": "",
|
||||
},
|
||||
)
|
||||
return out
|
||||
finally:
|
||||
wb.close()
|
||||
@@ -424,6 +655,10 @@ def render_sheets(file_path: Path) -> list[dict[str, Any]]:
|
||||
sheets = []
|
||||
for i, title in enumerate(titles):
|
||||
grid = _trim(formulas[i])
|
||||
# BUG-094 — a blank sheet still needs an editable grid (see constants):
|
||||
# the viewer's cell editing and structure actions all hang off a cell.
|
||||
if not grid:
|
||||
grid = [[""] * EMPTY_SHEET_COLS for _ in range(EMPTY_SHEET_ROWS)]
|
||||
# The shadow grid is NOT trimmed independently: _trim drops the
|
||||
# trailing empty columns of each grid on its own width, which would
|
||||
# shift every cached value left of its formula. Indexing it
|
||||
@@ -451,6 +686,10 @@ def render_sheets(file_path: Path) -> list[dict[str, Any]]:
|
||||
"truncated": total_rows > MAX_ROWS or total_cols > MAX_COLS,
|
||||
"styles": sheet_meta.get("styles", {}),
|
||||
"aligns": sheet_meta.get("aligns", {}),
|
||||
"comments": sheet_meta.get("comments", {}),
|
||||
"formats": sheet_meta.get("formats", {}),
|
||||
"colwidths": sheet_meta.get("colwidths", {}),
|
||||
"rowheights": sheet_meta.get("rowheights", {}),
|
||||
"merges": sheet_meta.get("merges", []),
|
||||
"freeze": sheet_meta.get("freeze", ""),
|
||||
}
|
||||
@@ -515,6 +754,10 @@ def read_sheet_window(
|
||||
"html": _table(grid, shadow, row_offset=offset, styles=meta.get("styles")),
|
||||
"styles": meta.get("styles", {}),
|
||||
"aligns": meta.get("aligns", {}),
|
||||
"comments": meta.get("comments", {}),
|
||||
"formats": meta.get("formats", {}),
|
||||
"colwidths": meta.get("colwidths", {}),
|
||||
"rowheights": meta.get("rowheights", {}),
|
||||
"merges": meta.get("merges", []),
|
||||
"freeze": meta.get("freeze", ""),
|
||||
}
|
||||
@@ -770,17 +1013,46 @@ def read_workbook_dashboard(file_path: Path) -> dict[str, Any]:
|
||||
# ── #153 A16 — additional spreadsheet formats ───────────────────────────────
|
||||
|
||||
|
||||
def render_csv_table(raw: str, *, delimiter: str = ",") -> str:
|
||||
# BUG-098 — a CSV written by a French office suite is `;`-separated, and the
|
||||
# delimiter must be *detected* (then reused on write-back), not assumed.
|
||||
CSV_SNIFF_BYTES = 4096
|
||||
_CSV_DELIMITERS = (",", ";", "\t", "|")
|
||||
|
||||
|
||||
def sniff_csv_delimiter(raw: str) -> str:
|
||||
"""Return the most likely field delimiter of *raw* (``,`` as fallback).
|
||||
|
||||
:class:`csv.Sniffer` handles quoted fields and multi-line values; it is
|
||||
unreliable on short or single-column samples, so the first non-empty line
|
||||
is counted as a tie-breaker and a comma remains the last resort.
|
||||
"""
|
||||
sample = raw[:CSV_SNIFF_BYTES]
|
||||
try:
|
||||
return csv.Sniffer().sniff(sample, delimiters="".join(_CSV_DELIMITERS)).delimiter
|
||||
except csv.Error:
|
||||
pass
|
||||
first = next((line for line in sample.splitlines() if line.strip()), "")
|
||||
counts = {delim: first.count(delim) for delim in _CSV_DELIMITERS}
|
||||
best = max(counts, key=lambda delim: counts[delim])
|
||||
return best if counts[best] else ","
|
||||
|
||||
|
||||
def render_csv_table(raw: str, *, delimiter: str | None = None) -> str:
|
||||
"""Render CSV text as the same HTML table shape the xlsx viewer consumes.
|
||||
|
||||
Row numbers replace the A1 column: a CSV has no fixed column count, so
|
||||
the first row is a plain data row like the others (the viewer offers the
|
||||
toolbar either way). Every cell is HTML-escaped at render time.
|
||||
|
||||
``delimiter`` defaults to the sniffed one (:func:`sniff_csv_delimiter`,
|
||||
BUG-098): a `;`-separated file used to render as a single column.
|
||||
"""
|
||||
import csv as csv_mod
|
||||
import io as io_mod
|
||||
|
||||
reader = csv_mod.reader(io_mod.StringIO(raw), delimiter=delimiter)
|
||||
reader = csv_mod.reader(
|
||||
io_mod.StringIO(raw), delimiter=delimiter or sniff_csv_delimiter(raw)
|
||||
)
|
||||
try:
|
||||
rows = [row for row in reader]
|
||||
except csv_mod.Error:
|
||||
|
||||
@@ -115,7 +115,9 @@ $env:VERSION = $Version
|
||||
Write-Info "Version : $Version"
|
||||
|
||||
# ----- Build the image -----
|
||||
$BuildArgs = @("-f", $ComposeFile)
|
||||
# NOTE : `--no-cache` est un flag de `docker compose build` (après `build`),
|
||||
# pas un flag global (avant) — sinon `unknown flag: --no-cache`.
|
||||
$BuildArgs = @("-f", $ComposeFile, "build")
|
||||
if (-not $UseCache) {
|
||||
$BuildArgs += "--no-cache"
|
||||
Write-Info "Construction de l'image Docker (sans cache)..."
|
||||
@@ -123,7 +125,7 @@ if (-not $UseCache) {
|
||||
Write-Info "Construction de l'image Docker (avec cache)..."
|
||||
}
|
||||
|
||||
docker compose @BuildArgs build
|
||||
docker compose @BuildArgs
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Échec de la construction de l'image Docker."
|
||||
exit $LASTEXITCODE
|
||||
|
||||
@@ -223,4 +223,4 @@ $COMPOSE_CMD -f "$COMPOSE_FILE" ps
|
||||
|
||||
echo ""
|
||||
info "Logs récents (Ctrl+C pour quitter) :"
|
||||
$COMPOSE_CMD -f "$COMPOSE_FILE" logs --tail=20 -f
|
||||
$COMPOSE_CMD -f "$COMPOSE_FILE" logs --tail=20
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.43.1"
|
||||
version = "2.67.2"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.43.1"
|
||||
version = "2.67.2"
|
||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||
authors = ["Bruno Charest"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -38,5 +38,7 @@ fn main() {
|
||||
println!("cargo:rerun-if-changed=.git/refs/heads/main");
|
||||
println!("cargo:rerun-if-changed=.git/refs/tags");
|
||||
|
||||
println!("cargo:rerun-if-changed=permissions");
|
||||
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
},
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"allow-app-commands",
|
||||
"shell:allow-open",
|
||||
"shell:allow-execute",
|
||||
"dialog:default",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capabilities for ObsiGate Desktop","remote":{"urls":["http://127.0.0.1:*","http://localhost:*"]},"local":true,"windows":["main"],"permissions":["core:default","shell:allow-open","shell:allow-execute","dialog:default","notification:default","fs:default","process:default","store:default"]}}
|
||||
{"default":{"identifier":"default","description":"Default capabilities for ObsiGate Desktop","remote":{"urls":["http://127.0.0.1:*","http://localhost:*"]},"local":true,"windows":["main"],"permissions":["core:default","allow-app-commands","shell:allow-open","shell:allow-execute","dialog:default","notification:default","fs:default","process:default","store:default"]}}
|
||||
@@ -2210,6 +2210,12 @@
|
||||
"Identifier": {
|
||||
"description": "Permission identifier",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Commandes de l'application ObsiGate appelées depuis la page backend (http://127.0.0.1).",
|
||||
"type": "string",
|
||||
"const": "allow-app-commands",
|
||||
"markdownDescription": "Commandes de l'application ObsiGate appelées depuis la page backend (http://127.0.0.1)."
|
||||
},
|
||||
{
|
||||
"description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
|
||||
"type": "string",
|
||||
|
||||
@@ -2210,6 +2210,12 @@
|
||||
"Identifier": {
|
||||
"description": "Permission identifier",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Commandes de l'application ObsiGate appelées depuis la page backend (http://127.0.0.1).",
|
||||
"type": "string",
|
||||
"const": "allow-app-commands",
|
||||
"markdownDescription": "Commandes de l'application ObsiGate appelées depuis la page backend (http://127.0.0.1)."
|
||||
},
|
||||
{
|
||||
"description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
|
||||
"type": "string",
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Commandes applicatives ObsiGate — autorisées depuis l'origine distante.
|
||||
#
|
||||
# La fenêtre Tauri redirige vers http://127.0.0.1:<port> (page servie par le
|
||||
# backend Python). Pour Tauri v2 c'est une origine *remote* : sans entrée ACL
|
||||
# explicite, TOUTE commande de l'app (invoke_handler) est rejetée avec
|
||||
# "Command ... not allowed by ACL" — le bouton « Choisir mon dossier » et le
|
||||
# wizard ne faisaient alors rien (erreurs avalées par desktop.js:invoke()).
|
||||
#
|
||||
# Auto-généré en… non : édité à la main. Liste = tauri::generate_handler![…]
|
||||
# dans src/main.rs — ajouter toute nouvelle commande ici.
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-app-commands"
|
||||
description = "Commandes de l'application ObsiGate appelées depuis la page backend (http://127.0.0.1)."
|
||||
commands.allow = [
|
||||
"get_backend_url",
|
||||
"get_version",
|
||||
"get_config",
|
||||
"save_vault_path",
|
||||
"get_vault_path",
|
||||
"get_wizard_state",
|
||||
"complete_wizard",
|
||||
"pick_vault_folder",
|
||||
"pick_folder",
|
||||
"get_system_theme",
|
||||
"restart_backend",
|
||||
"check_backend_health",
|
||||
"save_window_state",
|
||||
"get_window_state",
|
||||
"add_vault",
|
||||
"remove_vault",
|
||||
"list_vaults",
|
||||
"add_dir",
|
||||
"remove_dir",
|
||||
"list_dirs",
|
||||
]
|
||||
+19
-43
@@ -10,48 +10,23 @@ use log::{info, warn};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod imp {
|
||||
use super::*;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows::core::{Interface, HSTRING, PCWSTR};
|
||||
use windows::Win32::Foundation::PROPERTYKEY;
|
||||
use windows::Win32::System::Com::{
|
||||
CoCreateInstance, CoInitializeEx, CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED,
|
||||
};
|
||||
use windows::Win32::System::Com::StructuredStorage::PROPVARIANT;
|
||||
use windows::Win32::System::Variant::VT_LPWSTR;
|
||||
use windows::Win32::UI::Shell::{
|
||||
ICustomDestinationList, IShellLinkW, SetCurrentProcessExplicitAppUserModelID,
|
||||
DestinationList, EnumerableObjectCollection, ShellLink,
|
||||
};
|
||||
use windows::Win32::UI::Shell::Common::IObjectCollection;
|
||||
use windows::Win32::UI::Shell::PropertiesSystem::IPropertyStore;
|
||||
use super::*;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows::core::{Interface, HSTRING, PCWSTR};
|
||||
use windows::Win32::System::Com::{
|
||||
CoCreateInstance, CoInitializeEx, CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED,
|
||||
};
|
||||
use windows::Win32::UI::Shell::{
|
||||
ICustomDestinationList, IShellLinkW, SetCurrentProcessExplicitAppUserModelID,
|
||||
DestinationList, EnumerableObjectCollection, ShellLink,
|
||||
};
|
||||
use windows::Win32::UI::Shell::Common::IObjectCollection;
|
||||
|
||||
const APP_ID: &str = "com.obsigate.desktop";
|
||||
const APP_ID: &str = "com.obsigate.desktop";
|
||||
|
||||
// PKEY_Title (System.Title): {F29F85E0-4FF9-1068-AB91-08002B27B3D9}, pid 2
|
||||
const PKEY_TITLE: PROPERTYKEY = PROPERTYKEY {
|
||||
fmtid: windows::core::GUID::from_u128(0xF29F85E0_4FF9_1068_AB91_08002B27B3D9),
|
||||
pid: 2,
|
||||
};
|
||||
|
||||
fn wide_null_terminated(s: &str) -> Vec<u16> {
|
||||
std::ffi::OsStr::new(s).encode_wide().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
/// Set the jump-list display title via the shell link's property store.
|
||||
fn set_link_title(link: &IShellLinkW, title: &str) -> windows::core::Result<()> {
|
||||
let store: IPropertyStore = link.cast()?;
|
||||
let mut wide = wide_null_terminated(title);
|
||||
let mut pv: PROPVARIANT = unsafe { std::mem::zeroed() };
|
||||
unsafe {
|
||||
let inner = &mut *pv.Anonymous.Anonymous;
|
||||
inner.vt = VT_LPWSTR;
|
||||
inner.Anonymous.pwszVal = windows::core::PWSTR(wide.as_mut_ptr());
|
||||
store.SetValue(&PKEY_TITLE, &pv)?;
|
||||
store.Commit()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn wide_null_terminated(s: &str) -> Vec<u16> {
|
||||
std::ffi::OsStr::new(s).encode_wide().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
/// Build one shell link for a vault. `arg` is the command-line argument the
|
||||
/// app expects to open that vault (see file associations / single-instance).
|
||||
@@ -70,9 +45,10 @@ mod imp {
|
||||
}
|
||||
link.SetDescription(&HSTRING::from(name))?;
|
||||
}
|
||||
// Title (display name) is best-effort — a missing title only means the
|
||||
// jump-list entry falls back to the executable name.
|
||||
let _ = set_link_title(&link, name);
|
||||
// Title (display name): the raw PROPVARIANT setter that used to live
|
||||
// here corrupted the process heap (STATUS_HEAP_CORRUPTION c0000374 —
|
||||
// the shell property store freed our Rust-allocated VT_LPWSTR buffer).
|
||||
// SetDescription below already gives jump-list entries their label.
|
||||
Ok(link)
|
||||
}
|
||||
|
||||
|
||||
+202
-18
@@ -48,6 +48,7 @@ fn pick_free_port() -> u16 {
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::os::windows::process::CommandExt;
|
||||
use tauri::Manager;
|
||||
use tauri::webview::WebviewWindowBuilder;
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -160,6 +161,39 @@ fn save_config(config: &AppConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Premier lancement (#160) ───────────────────────────────────────────────
|
||||
|
||||
/// Répertoire de démarrage monté comme vault au premier lancement.
|
||||
const STARTER_VAULT_NAME: &str = "ObsiGate";
|
||||
/// Document d'accueil écrit (si absent) dans le répertoire de démarrage.
|
||||
const WELCOME_DOC_NAME: &str = "Prise en main.md";
|
||||
const WELCOME_DOC: &str = include_str!("prise_en_main.md");
|
||||
|
||||
/// Config par défaut au premier lancement — pure, sans I/O (testable).
|
||||
/// `<home>/ObsiGate` est monté comme vault ET comme contexte initial ; le
|
||||
/// dossier home reste une racine, nommée d'après son dernier segment
|
||||
/// (fin du « bruno » codé en dur, #160).
|
||||
fn default_first_run_config(home: &str) -> AppConfig {
|
||||
let home_path = std::path::Path::new(home);
|
||||
let vault_dir = home_path.join("ObsiGate");
|
||||
let vault_path = vault_dir.to_string_lossy().to_string();
|
||||
AppConfig {
|
||||
vault_path: Some(vault_path.clone()),
|
||||
vaults: vec![VaultConfig {
|
||||
name: STARTER_VAULT_NAME.to_string(),
|
||||
path: vault_path,
|
||||
}],
|
||||
dirs: vec![DirConfig {
|
||||
name: home_path
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| home.to_string()),
|
||||
path: home.to_string(),
|
||||
}],
|
||||
..AppConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Backend lifecycle ───────────────────────────────────────────────────────
|
||||
|
||||
fn spawn_backend(exe_dir: &PathBuf) -> Result<Child, String> {
|
||||
@@ -320,6 +354,18 @@ async fn pick_vault_folder(app: tauri::AppHandle) -> Result<String, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sélecteur de dossier générique, sans effet de bord (contrairement à
|
||||
/// `pick_vault_folder` qui écrit `vault_path` + `wizard_done` pour le wizard).
|
||||
/// Sert à l'ajout d'un dossier racine depuis la section Configuration (#159).
|
||||
#[tauri::command]
|
||||
async fn pick_folder(app: tauri::AppHandle) -> Result<String, String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
match app.dialog().file().blocking_pick_folder() {
|
||||
Some(p) => Ok(p.to_string()),
|
||||
None => Err("No folder selected".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_wizard_state() -> bool {
|
||||
load_config().wizard_done
|
||||
@@ -343,6 +389,13 @@ fn get_system_theme(app: tauri::AppHandle) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// La jump list des raccourcis suit les vaults : à rafraîchir après tout
|
||||
/// ajout/retrait de vault (#159). Best-effort — `update_jumplist` logge et n'échoue jamais.
|
||||
fn refresh_jumplist() {
|
||||
let vaults = load_config().vaults;
|
||||
jumplist::update_jumplist(&jumplist::build_vault_args(&vaults));
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn add_vault(name: String, path: String) -> Result<(), String> {
|
||||
let mut config = load_config();
|
||||
@@ -350,6 +403,7 @@ fn add_vault(name: String, path: String) -> Result<(), String> {
|
||||
config.vaults.retain(|v| v.name != name);
|
||||
config.vaults.push(VaultConfig { name: name.clone(), path });
|
||||
save_config(&config);
|
||||
refresh_jumplist();
|
||||
info!("Vault added: {} — restart backend to apply", name);
|
||||
Ok(())
|
||||
}
|
||||
@@ -359,6 +413,7 @@ fn remove_vault(name: String) -> Result<(), String> {
|
||||
let mut config = load_config();
|
||||
config.vaults.retain(|v| v.name != name);
|
||||
save_config(&config);
|
||||
refresh_jumplist();
|
||||
info!("Vault removed: {} — restart backend to apply", name);
|
||||
Ok(())
|
||||
}
|
||||
@@ -513,7 +568,7 @@ fn main() {
|
||||
// Load config for window state
|
||||
let mut config = load_config();
|
||||
|
||||
// First run: create sensible defaults per platform
|
||||
// First run: create sensible defaults per platform (#160)
|
||||
if config.vaults.is_empty() {
|
||||
info!("First run — creating default vault configuration");
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -521,25 +576,28 @@ fn main() {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/home/bruno".to_string());
|
||||
|
||||
let vault_dir = std::path::PathBuf::from(&home).join("voute_obsidian");
|
||||
// Create the vault directory if it doesn't exist
|
||||
if !vault_dir.exists() {
|
||||
if let Err(e) = fs::create_dir_all(&vault_dir) {
|
||||
error!("Failed to create default vault dir {}: {}", vault_dir.display(), e);
|
||||
} else {
|
||||
info!("Created default vault dir: {}", vault_dir.display());
|
||||
// Montage du répertoire de démarrage (vault + contexte initial) sans
|
||||
// toucher aux autres champs (taille/position de fenêtre) de la config.
|
||||
let defaults = default_first_run_config(&home);
|
||||
config.dirs = defaults.dirs;
|
||||
config.vaults = defaults.vaults;
|
||||
config.vault_path = defaults.vault_path;
|
||||
|
||||
let vault_dir = std::path::PathBuf::from(config.vaults[0].path.clone());
|
||||
if let Err(e) = fs::create_dir_all(&vault_dir) {
|
||||
error!("Failed to create default vault dir {}: {}", vault_dir.display(), e);
|
||||
} else {
|
||||
info!("Created default vault dir: {}", vault_dir.display());
|
||||
}
|
||||
// Document d'accueil : écrit une seule fois, jamais écrasé (#160).
|
||||
let welcome = vault_dir.join(WELCOME_DOC_NAME);
|
||||
if !welcome.exists() {
|
||||
match fs::write(&welcome, WELCOME_DOC) {
|
||||
Ok(()) => info!("Welcome document written: {}", welcome.display()),
|
||||
Err(e) => error!("Failed to write {}: {}", welcome.display(), e),
|
||||
}
|
||||
}
|
||||
config.dirs = vec![
|
||||
DirConfig { name: "bruno".to_string(), path: home.clone() },
|
||||
];
|
||||
config.vaults = vec![
|
||||
VaultConfig {
|
||||
name: "Obsidian".to_string(),
|
||||
path: vault_dir.to_string_lossy().to_string(),
|
||||
},
|
||||
];
|
||||
config.vault_path = Some(home);
|
||||
|
||||
save_config(&config);
|
||||
info!("Default vault config saved");
|
||||
}
|
||||
@@ -589,6 +647,7 @@ fn main() {
|
||||
get_wizard_state,
|
||||
complete_wizard,
|
||||
pick_vault_folder,
|
||||
pick_folder,
|
||||
get_system_theme,
|
||||
restart_backend,
|
||||
check_backend_health,
|
||||
@@ -602,6 +661,19 @@ fn main() {
|
||||
list_dirs,
|
||||
])
|
||||
.setup(move |app| {
|
||||
// BUG-107 / #89 — HTML5 drag & drop des fichiers : Tauri installe
|
||||
// son propre IDropTarget par-dessus celui du WebView2 (wry :
|
||||
// « find the WebView2 window and override! ») et aucun événement
|
||||
// natif n'est écouté ici → les glisser-déposer depuis
|
||||
// l'Explorateur n'atteignaient jamais la page. La fenêtre est donc
|
||||
// créée manuellement (`create: false` dans tauri.conf.json) avec
|
||||
// le handler désactivé : la page reçoit les drops comme sur le web.
|
||||
let window_config = app.config().app.windows[0].clone();
|
||||
WebviewWindowBuilder::from_config(app, &window_config)
|
||||
.expect("fenêtre principale (tauri.conf.json)")
|
||||
.disable_drag_drop_handler()
|
||||
.build()?;
|
||||
|
||||
// Build native menu bar (File / Edit / Help)
|
||||
let _ = build_native_menu(app);
|
||||
|
||||
@@ -1122,4 +1194,116 @@ mod tests {
|
||||
let dbg = format!("{:?}", d);
|
||||
assert!(dbg.contains("x"));
|
||||
}
|
||||
|
||||
/// Guardrail ACL : toute commande Tauri invoquée par le frontend doit
|
||||
/// figurer dans desktop/permissions/commands.toml. La page servie par le
|
||||
/// backend (http://127.0.0.1) est une origine *remote* pour Tauri v2 :
|
||||
/// sans cette entrée, la commande est rejetée « not allowed by ACL » et
|
||||
/// desktop.js:invoke() avale l'erreur → bouton silencieusement mort
|
||||
/// (bouton « Choisir mon dossier » du wizard, BUG-104).
|
||||
#[test]
|
||||
fn test_frontend_invokes_are_acl_allowed() {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let perms = fs::read_to_string(dir.join("permissions/commands.toml"))
|
||||
.expect("desktop/permissions/commands.toml manquant");
|
||||
let main_src = fs::read_to_string(dir.join("src/main.rs")).unwrap();
|
||||
|
||||
// Commandes déclarées dans le manifeste de permissions.
|
||||
let allow_list = perms
|
||||
.split("commands.allow")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split(']').next())
|
||||
.unwrap_or("");
|
||||
let allowed: Vec<String> = allow_list
|
||||
.split('"')
|
||||
.skip(1)
|
||||
.step_by(2)
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
assert!(!allowed.is_empty(), "commands.allow vide dans commands.toml");
|
||||
|
||||
// Chaque permission doit correspondre à une commande réellement
|
||||
// enregistrée (faute de frappe → permission morte).
|
||||
for cmd in &allowed {
|
||||
assert!(
|
||||
main_src.contains(&format!("fn {cmd}(")),
|
||||
"permission allow pour la commande inconnue {cmd}"
|
||||
);
|
||||
}
|
||||
|
||||
// Toute commande invoke('…') du frontend doit être autorisée.
|
||||
let js_dir = dir.join("../frontend/js");
|
||||
let mut seen = 0usize;
|
||||
for entry in fs::read_dir(&js_dir).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("js") { continue; }
|
||||
let src = fs::read_to_string(&path).unwrap();
|
||||
let mut rest = src.as_str();
|
||||
while let Some(pos) = rest.find("invoke('") {
|
||||
rest = &rest[pos + "invoke('".len()..];
|
||||
let name: String = rest.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect();
|
||||
if !name.is_empty() {
|
||||
seen += 1;
|
||||
assert!(
|
||||
allowed.contains(&name),
|
||||
"commande {name} invoquée par {} absente de permissions/commands.toml",
|
||||
path.file_name().unwrap().to_string_lossy()
|
||||
);
|
||||
}
|
||||
rest = &rest[rest.find('\'').map(|i| i + 1).unwrap_or(rest.len())..];
|
||||
}
|
||||
}
|
||||
assert!(seen > 0, "aucun invoke('…') trouvé dans frontend/js");
|
||||
}
|
||||
|
||||
/// BUG-107 : la fenêtre est créée en code (`create: false`) avec le
|
||||
/// handler de drag & drop de Tauri désactivé. Si l'un des deux manque,
|
||||
/// wry réinstalle son IDropTarget par-dessus celui du WebView2 et les
|
||||
/// glisser-déposer de fichiers depuis l'Explorateur n'atteignent plus la
|
||||
/// page (#89 fonctionnait uniquement sur le web).
|
||||
#[test]
|
||||
fn test_window_created_without_tauri_drag_drop_handler() {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let conf = fs::read_to_string(dir.join("tauri.conf.json")).unwrap();
|
||||
assert!(
|
||||
conf.contains("\"create\": false"),
|
||||
"tauri.conf.json : la fenêtre doit être créée en code (create: false)"
|
||||
);
|
||||
let main_src = fs::read_to_string(dir.join("src/main.rs")).unwrap();
|
||||
assert!(
|
||||
main_src.contains("disable_drag_drop_handler()"),
|
||||
"main.rs : disable_drag_drop_handler() manquant — drag & drop bloqué"
|
||||
);
|
||||
}
|
||||
|
||||
/// #160 — premier lancement : `<home>/ObsiGate` monté comme vault et
|
||||
/// comme contexte initial, racine home nommée d'après son chemin.
|
||||
#[test]
|
||||
fn test_default_first_run_config() {
|
||||
#[cfg(target_os = "windows")]
|
||||
let home = r"C:\Users\alice";
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let home = "/home/alice";
|
||||
|
||||
let c = default_first_run_config(home);
|
||||
assert_eq!(c.vaults.len(), 1);
|
||||
assert_eq!(c.vaults[0].name, STARTER_VAULT_NAME);
|
||||
assert!(c.vaults[0].path.ends_with("ObsiGate"), "{}", c.vaults[0].path);
|
||||
assert_eq!(c.vault_path.as_deref(), Some(c.vaults[0].path.as_str()));
|
||||
assert_eq!(c.dirs.len(), 1);
|
||||
assert_eq!(c.dirs[0].name, "alice", "nom de racine dérivé du chemin");
|
||||
assert_eq!(c.dirs[0].path, home);
|
||||
assert!(!c.wizard_done);
|
||||
}
|
||||
|
||||
/// #160 — le document d'accueil est embarqué dans le binaire : non vide,
|
||||
/// Markdown, et nom de fichier stable (écrit une seule fois sur le disque).
|
||||
#[test]
|
||||
fn test_welcome_doc_embedded() {
|
||||
assert!(WELCOME_DOC.len() > 500, "document d'accueil trop court");
|
||||
assert!(WELCOME_DOC.starts_with('#'), "attendu : titre Markdown");
|
||||
assert!(WELCOME_DOC.contains("ObsiGate"));
|
||||
assert!(WELCOME_DOC.contains("Ctrl+Espace"));
|
||||
assert_eq!(WELCOME_DOC_NAME, "Prise en main.md");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# 🟢 Bienvenue sur ObsiGate — Prise en main
|
||||
|
||||
Ce dossier `%USERPROFILE%\ObsiGate` est votre **espace de départ** : il est
|
||||
monté automatiquement comme vault **ObsiGate** au premier lancement. Vous
|
||||
pouvez y écrire librement, puis brancher vos vrais vaults Obsidian quand vous
|
||||
êtes prêt.
|
||||
|
||||
## ObsiGate en 30 secondes
|
||||
|
||||
ObsiGate est une porte d'entrée locale vers vos notes : index en mémoire,
|
||||
recherche instantanée, lecture Markdown avec liens `[[wikilinks]]`, images,
|
||||
PDF, tableurs Excel, Excalidraw, Mermaid et assistant IA — sans base de
|
||||
données, sans modification de vos fichiers, tout reste sur votre machine.
|
||||
|
||||
## Premiers pas (application desktop)
|
||||
|
||||
1. **Ce document** s'affiche dans la visionneuse : c'est votre première note,
|
||||
modifiez-la ou supprimez-la quand vous n'en avez plus besoin.
|
||||
2. **Ajouter vos vaults** : Configuration → « 🖥️ Vaults & dossiers (Desktop) »
|
||||
→ *Ajouter un vault* (sélecteur de dossier natif). Depuis la sidebar,
|
||||
clic droit sur une racine → « Retirer de l'application » la désiste
|
||||
(aucun fichier n'est supprimé).
|
||||
3. **Naviguer** : la sidebar liste vos racines et leur arbre ; l'onglet
|
||||
Navigation parcourt un dossier sous forme de vues répertoire.
|
||||
4. **Rechercher** : `Ctrl+Espace` (palette de fichiers),
|
||||
`Ctrl+Alt+Espace` (palette de commandes), `Ctrl+F` dans un document.
|
||||
5. **Onglets** : `Ctrl+W` ferme l'onglet, `Ctrl+Tab` bascule ; le mode split
|
||||
divise l'écran en panneaux.
|
||||
6. **Guide complet** : bouton d'aide de l'en-tête — sommaire, raccourcis,
|
||||
architecture, et téléchargement du guide en Markdown/PDF.
|
||||
|
||||
## Le saviez-vous ?
|
||||
|
||||
- Le **glisser-déposer** de fichiers depuis l'Explorateur est pris en charge :
|
||||
déposez-les sur la fenêtre pour les importer.
|
||||
- Le **clic droit** est partout : menus contextuels de fichier, dossier et
|
||||
vault (avec renommage, création, suppression, vue graphique).
|
||||
- Le **watcher** surveille vos vaults : une note modifiée dans Obsidian
|
||||
apparaît immédiatement ici.
|
||||
|
||||
— *Ce fichier vous appartient.*
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||
"productName": "ObsiGate",
|
||||
"version": "2.43.1",
|
||||
"version": "2.67.2",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
@@ -13,6 +13,7 @@
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"create": false,
|
||||
"title": "ObsiGate",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
|
||||
@@ -13,14 +13,14 @@ services:
|
||||
context: .
|
||||
image: obsigate:latest
|
||||
container_name: obsigate-test
|
||||
user: "1000:1000"
|
||||
user: "197609:197609"
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "2021:8080"
|
||||
volumes:
|
||||
- /tmp/obsigate-test-vault:/vaults/TestVault
|
||||
- /tmp/obsigate-test-dir:/vaults/TestDir
|
||||
- /tmp/obsigate-test-data:/app/data
|
||||
- ./test_vault:/vaults/TestVault
|
||||
- ./test_dir:/vaults/TestDir
|
||||
- obsigate_data:/app/data
|
||||
environment:
|
||||
- VAULT_1_NAME=TestVault
|
||||
- VAULT_1_PATH=/vaults/TestVault
|
||||
@@ -51,3 +51,4 @@ services:
|
||||
|
||||
volumes:
|
||||
ollama_data:
|
||||
obsigate_data:
|
||||
|
||||
@@ -21,12 +21,17 @@ services:
|
||||
volumes:
|
||||
- /c/dev/git/python/ObsiGate/test_vault:/vaults/TestVault
|
||||
- /c/dev/git/python/ObsiGate/test_dir:/vaults/TestDir
|
||||
# #194 : racine des dossiers persos — UN seul montage pour tous les
|
||||
# utilisateurs (dossier hôte : mkdir test_home à côté du dépôt).
|
||||
- /c/dev/git/python/ObsiGate/test_home:/vaults/Home
|
||||
- obsigate_data:/app/data
|
||||
environment:
|
||||
- VAULT_1_NAME=TestVault
|
||||
- VAULT_1_PATH=/vaults/TestVault
|
||||
- DIR_1_NAME=TestDir
|
||||
- DIR_1_PATH=/vaults/TestDir
|
||||
# #194 : dossier personnel par utilisateur = <racine>/home-<user>
|
||||
- OBSIGATE_HOME_ROOT=/vaults/Home
|
||||
- OBSIGATE_AUTH_ENABLED=true
|
||||
- OBSIGATE_ADMIN_USER=admin
|
||||
- OBSIGATE_ADMIN_PASSWORD=test123
|
||||
|
||||
@@ -36,6 +36,9 @@ services:
|
||||
- /NFS/OBSIDIAN_DOC/Obsidian_WORKOUT:/vaults/Obsidian_WORKOUT
|
||||
- /NFS/OBSIDIAN_DOC/SessionsManager:/vaults/SessionsManager
|
||||
- /home/bruno:/vaults/bruno
|
||||
# #194 : racine des dossiers perso — UN seul montage pour tous les
|
||||
# utilisateurs (un montage par user demanderait un redémarrage).
|
||||
- /NFS/OBSIDIAN_DOC/Home:/vaults/Home
|
||||
- /DOCKER_CONFIG/ObsiGate/data:/app/data
|
||||
environment:
|
||||
- VAULT_1_NAME=Recettes
|
||||
@@ -50,6 +53,8 @@ services:
|
||||
- VAULT_5_PATH=/vaults/SessionsManager
|
||||
- DIR_1_NAME=Bruno
|
||||
- DIR_1_PATH=/vaults/bruno
|
||||
# #194 : dossier personnel par utilisateur = <racine>/home-<user>
|
||||
- OBSIGATE_HOME_ROOT=/vaults/Home
|
||||
- OBSIGATE_AUTH_ENABLED=true
|
||||
- OBSIGATE_ADMIN_USER=admin
|
||||
# OBSIGATE_ADMIN_PASSWORD → .env
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
# Assistant V2 — ObsiGate comme second cerveau & assistant personnel
|
||||
|
||||
> **Statut :** 📐 Spécification de conception (non implémentée)
|
||||
> **Dernière mise à jour :** 2026-10-08
|
||||
> **IDs proposés :** ObsiGate **#191 → #201** (liste « Assistant V2 »)
|
||||
> **Portée :** évolution de l'assistant IA existant en assistant à mémoire long terme,
|
||||
> outillé (connecteurs externes), planifié (rappels, briefing) et proactif (insights).
|
||||
> **Hors portée :** toute intégration avec l'infrastructure externe du homelab
|
||||
> (Honcho, Hermes, Ollama partagé, ntfy homelab, cron système). ObsiGate est **autonome** :
|
||||
> un seul conteneur, ses propres stores, ses propres clés.
|
||||
|
||||
---
|
||||
|
||||
## 1. Vision
|
||||
|
||||
Transformer ObsiGate d'un portail de notes avec assistant vers un **second cerveau
|
||||
conversationnel et autonome** :
|
||||
|
||||
1. **Il se souvient** — mémoire long terme structurée (faits, préférences, décisions,
|
||||
entités), consolidée chaque nuit, **visible et éditable en markdown dans le vault**
|
||||
(pas de boîte noire).
|
||||
2. **Il agit** — lit/écrit le vault, mais aussi les services externes connectés :
|
||||
Gmail, Google Agenda, Teams, Discord, Notion, Drive… (cadre de connecteurs unifié).
|
||||
3. **Il anticipe** — rappels conversationnels, briefing quotidien généré par l'agent,
|
||||
détection de boucles ouvertes et d'engagements non tenus.
|
||||
4. **Il centralise** — ObsiGate devient le point d'entrée unique prise de notes +
|
||||
assistant ; tout le reste (desktop Tauri, MCP externe, PWA mobile) n'est qu'un
|
||||
accès à *ce même* cerveau.
|
||||
|
||||
Le modèle économique de la conception : **Muse/Snow/ChatGPT-Memory rendent l'effet
|
||||
« l'assistant me connaît » ; ici la mémoire est auditable (markdown dans le vault,
|
||||
audit log, dédupli, réversibilité) et les données ne quittent jamais le stack ObsiGate
|
||||
sauf appel explicite aux API des services que Bruno a connectés lui-même.**
|
||||
|
||||
---
|
||||
|
||||
## 2. Principes directeurs (non négociables)
|
||||
|
||||
| # | Principe | Conséquence technique |
|
||||
|---|---|---|
|
||||
| P1 | **Autonomie du stack** | Tout vit dans le conteneur `obsigate` + son volume `data/`. Aucun nouveau conteneur runtime (Ollama est retiré — voir §9). |
|
||||
| P2 | **Zéro framework d'agent externe** | Décision déjà prise en #92 : boucle maison `backend/agent/loop.py`. L'Assistant V2 l'étend, ne la remplace pas. |
|
||||
| P3 | **Un outil = un `@tool`** | Toute nouvelle capacité passe par le registre typé (`backend/tools/registry.py`) : risque, scope, rate-limit, confirmation, audit, labels + i18n. |
|
||||
| P4 | **Mémoire = markdown d'abord** | Les artefacts durables (profil, insights, briefings, rappels) sont des notes du vault ; la base SQLite n'est que l'index reconstructible. |
|
||||
| P5 | **Humain dans la boucle** | `WRITE`/`DANGEROUS` exigent confirmation (#91/#92) ; un fait de confiance faible reste `pending` tant que l'utilisateur ne le valide pas. |
|
||||
| P6 | **Secrets jamais committés** | Pattern existant : `data/api_keys.json` / `data/connector_secrets.json` (0600) ou variables `OBSIGATE_*` dans `.env` (`env_file`, jamais dans le compose). |
|
||||
| P7 | **i18n FR/EN systématique** | Toute clé d'interface (`ai.step.*`, config connecteurs, cartes mémoire) existe dans les deux locales. |
|
||||
| P8 | **Le desktop Tauri reste fonctionnel** | Les connecteurs se dégradent silencieusement hors ligne (capability = absente si pas de token) ; aucune dépendance à un service réseau au démarrage. |
|
||||
| P9 | **Sécurité existante réutilisée** | `_resolve_safe_path()`, `require_auth`/`require_vault_access`, SSRF guards (`validate_webhook_url`, `is_safe_target`), `secret_redactor` avant tout appel LLM. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Inventaire de l'existant (socle sur lequel on bâtit)
|
||||
|
||||
Constat clé : **l'ossature « agent » existe déjà**. Ce qui manque, c'est la mémoire
|
||||
long terme, les connecteurs, la couche proactive et l'ordonnancement de haut niveau.
|
||||
|
||||
| Brique | Fichier(s) | État | Rôle dans la V2 |
|
||||
|---|---|---|---|
|
||||
| Boucle agent multi-étapes + tool calling natif | `backend/agent/loop.py` (`run_agent`), `backend/ai_chat.py` (payload `tools`/`tool_choice`, conversion Gemini, fallback si refus) | ✅ livré (#91/#92) | Moteur de l'assistant V2 — inchangé |
|
||||
| Registre d'outils typé | `backend/tools/registry.py` (`@tool`, `ToolRisk` READ/WRITE/DANGEROUS, `ToolScope` IN_APP/MCP, confirmation auto si non-READ, rate-limit, audit, schéma OpenAI) | ✅ | Cadre d'insertion de **tous** les nouveaux outils |
|
||||
| Outils vault (~26) | `backend/tools/service.py`, `backend/services/*` (files, mutations, search, vaults, duplicates…) | ✅ | Outils natifs de l'assistant |
|
||||
| Recherche TF-IDF + sémantique embeddings (RRF) | `backend/search.py`, `backend/semantic_search.py`, `requirements-semantic.txt` (sentence-transformers **local, optionnel**, FAISS) | ✅ (#70) | Retrieval de la mémoire épisodique ; remplace le besoin Ollama embeddings |
|
||||
| Historique de conversation persistant | `backend/ai_history.py` (`data/sessions`, par utilisateur) | ✅ (#95) | **Mémoire épisodique** brute — source de la consolidation |
|
||||
| Serveur MCP externe | `backend/mcp/server.py` (Streamable HTTP `/mcp`, propose/apply) | ✅ (#79) | Même registre exposé — les outils V2 en profitent gratis |
|
||||
| Scheduler type cron | `backend/scheduler.py` (tick asyncio 60 s, `data/scheduled_tasks.json`, `interval_hours`/`daily_time`/`once_at`, actions `create_file`/`append_to_file`/`notify`) + outils `create/list/delete/run_scheduled_task` (`backend/tools/scheduled.py`) | ✅ (#170) | Socle des rappels & briefing — à étendre (§8) |
|
||||
| Notifications externes | `backend/notify.py` (canaux **Discord webhook, Telegram, SMTP, webhook générique**, triggers, secrets 0600) + outil `notify_external` | ✅ (#168) | Bras de sortie des rappels/briefing |
|
||||
| Web Push PWA | `backend/push.py` (VAPID) + `frontend/sw.js` | ✅ (#67) | Deuxième canal de rappel, sans dépendance externe |
|
||||
| Sources connectées (premier précédent) | `backend/tools/connected.py` (Gitea/GitHub, READ), page Configurations #103, `backend/tools/secrets.py` | ✅ (#92/#103) | Patron à généraliser en cadre connecteurs (§7) |
|
||||
| Web : recherche + fetch + crawl | `backend/tools/web.py` (Tavily/Brave/SerpAPI/Exa/SearXNG, `fetch_url`), `backend/tools/crawler.py` (`crawl_site`) | ✅ | Outils « connaissances hors vault » |
|
||||
| Production de documents | `backend/tools/documents.py` (`create_xlsx/docx/csv/pdf`) | ✅ | Actions de l'assistant |
|
||||
| Doublons | `backend/tools/duplicates.py` (`find_duplicates`, `merge_duplicate_notes` DANGEROUS) | ✅ (#166) | Hygiène de la mémoire |
|
||||
| Skills / slash-commandes | `backend/skills.py` (`/cmd`, `data/skills.json`) | ✅ | Vecteur naturel des prompts de consolidation & briefing |
|
||||
| Cache SQLite | `backend/tools/webcache.py` (précédent `sqlite3` stdlib dans `data/`) | ✅ | Patron du store mémoire (§5) |
|
||||
| Fournisseurs LLM | `backend/ai.py` (`PROVIDERS` : deepseek, openrouter, gemini, nvidia, qwencloud, xiaomi, mistral, ollama), `reload_ai_config()`, clés `data/api_keys.json` + fallback env | ✅ | Garder le multi-provider ; **retirer le conteneur ollama** (§9) |
|
||||
| Audit + redaction | `backend/audit.py`, `backend/tools/audit.py`, `backend/secret_redactor.py` | ✅ | Traçabilité mémoire/connecteurs |
|
||||
|
||||
**Gaps** (→ §5–§8) : mémoire sémantique (faits) · miroir markdown · consolidation ·
|
||||
cadre connecteurs générique + OAuth2 · Gmail/Calendar/Teams/Discord(in)/Notion/Drive ·
|
||||
rappels conversationnels · agent programmé (briefing) · insights proactifs.
|
||||
|
||||
---
|
||||
|
||||
## 4. Architecture cible
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────┐
|
||||
│ FRONTEND (SPA + PWA + Tauri) │
|
||||
│ Assistant panel · Cartes mémoire · Config │
|
||||
│ « Sources connectées » · Briefings │
|
||||
└───────────────┬────────────────────────────┘
|
||||
│ REST + SSE (existant)
|
||||
┌──────────────────────────────────────▼─────────────────────────────────────┐
|
||||
│ BACKEND obsigate (1 conteneur) │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────────┐ ┌────────────────────────────┐ │
|
||||
│ │ agent/loop.py│◄──│ tools/registry.py│◄──│ TOUS les outils V2 (@tool) │ │
|
||||
│ │ (run_agent) │ │ risque/scope/ │ │ vault · web · docs (exist.)│ │
|
||||
│ └──────┬───────┘ │ audit/limit │ │ memory.* (nouveau §5) │ │
|
||||
│ │ └──────────────────┘ │ connector.* (§7) │ │
|
||||
│ ┌──────┴────────┐ │ reminder.* (§8) │ │
|
||||
│ │ ai_chat.py │ │ scheduled.* (étendu) │ │
|
||||
│ │ providers API │ └────────────────────────────┘ │
|
||||
│ └──────┬────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────▼───────────┐ ┌────────────────────┐ ┌───────────────────────┐ │
|
||||
│ │ scheduler.py │ │ memory/ │ │ connectors/ │ │
|
||||
│ │ tick 60 s │ │ store SQLite │ │ base OAuth2+PKCE │ │
|
||||
│ │ + agent_prompt │─►│ (faits) │ │ gmail/gcal/teams/ │ │
|
||||
│ │ (nouv. action) │ │ consolidator │ │ discord/notion/drive │ │
|
||||
│ └──────┬───────────┘ └─────────┬──────────┘ └───────────┬───────────┘ │
|
||||
│ │ notify.broadcast │ miroir markdown │ HTTPS API │
|
||||
│ │ + Web Push ▼ ▼ │
|
||||
│ ┌──────▼──────────┐ ┌────────────────────┐ ┌─────────────────────┐ │
|
||||
│ │ Vault Obsidian │ │ Vault Obsidian │ │ Services externes │ │
|
||||
│ │ (5 volumes NFS) │ │ 99_Assistant/*.md │ │ (Google/MS/Discord) │ │
|
||||
│ └─────────────────┘ └────────────────────┘ └─────────────────────┘ │
|
||||
└────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Nouveaux modules backend (alignés sur l'organisation existante) :
|
||||
|
||||
```
|
||||
backend/
|
||||
memory/
|
||||
store.py # SQLite data/memory.sqlite3 — CRUD faits + runs
|
||||
recall.py # fusion recall: facts + sémantique (#70) + épisodes (ai_history)
|
||||
consolidator.py # job « dreaming » nocturne (prompt + parsing + dédup)
|
||||
mirror.py # rendu markdown 99_Assistant/ + parse des edits humains
|
||||
connectors/
|
||||
base.py # ConnectorInterface: id, scopes, authorize_url, exchange,
|
||||
# refresh, capabilities(), call() (retry + rate-limit + audit)
|
||||
oauth.py # flux code + PKCE, redirect /api/connectors/{id}/callback
|
||||
registry.py # registre des connecteurs installés (comme tools/registry)
|
||||
gmail.py gcal.py teams.py discord.py notion.py drive.py
|
||||
routers/
|
||||
connectors.py # CRUD admin: installation, statut, test de connexion
|
||||
memory.py # API faits (liste/édit/suppression/validation pending)
|
||||
tools/
|
||||
memory.py # @tool memory.* (§5.4)
|
||||
reminders.py # @tool reminder.* (§8.1)
|
||||
connectors.py # @tool par capability connecteur (§7.4)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Mémoire long terme — #192 (socle), #193 (miroir), #194 (consolidation)
|
||||
|
||||
### 5.1 Modèle de données (SQLite, `data/memory.sqlite3`)
|
||||
|
||||
Précédent : `webcache.py`. Une seule table applicative + une table d'audit des runs.
|
||||
|
||||
```sql
|
||||
CREATE TABLE facts (
|
||||
id TEXT PRIMARY KEY, -- f-<uuid4>
|
||||
user_id TEXT NOT NULL,
|
||||
subject TEXT NOT NULL, -- ex: "santé", "Projet FlowDeck", "bruno"
|
||||
predicate TEXT NOT NULL, -- ex: "rdv", "préférence", "décision"
|
||||
value TEXT NOT NULL, -- contenu FR court (< 500 car.)
|
||||
kind TEXT NOT NULL CHECK (kind IN
|
||||
('preference','decision','entity','event','habit','goal','open_loop')),
|
||||
confidence REAL NOT NULL DEFAULT 0.5, -- 0..1 ; < 0.7 → status pending
|
||||
status TEXT NOT NULL DEFAULT 'confirmed'
|
||||
CHECK (status IN ('pending','confirmed','rejected','expired')),
|
||||
source TEXT NOT NULL, -- 'consolidation' | 'user' | 'chat'
|
||||
source_ref TEXT, -- session id ou chemin de note
|
||||
supersedes TEXT, -- id du fait remplacé (dédup temporel)
|
||||
created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
||||
last_seen_at TEXT NOT NULL, -- reinforcement par usage
|
||||
expires_at TEXT -- nullable (ex: rdv passé)
|
||||
);
|
||||
CREATE INDEX idx_facts_subject ON facts(user_id, subject);
|
||||
CREATE INDEX idx_facts_status ON facts(user_id, status);
|
||||
|
||||
CREATE TABLE consolidation_runs (
|
||||
id TEXT PRIMARY KEY, started_at TEXT, finished_at TEXT,
|
||||
sessions_consumed INTEGER, notes_changed INTEGER,
|
||||
facts_added INTEGER, facts_updated INTEGER, error TEXT
|
||||
);
|
||||
```
|
||||
|
||||
Règles :
|
||||
- **Dédup temporel** : un nouveau fait `(subject, predicate)` avec valeur différente
|
||||
crée une ligne et marque l'ancienne `superseded` (via `supersedes`, status `expired`).
|
||||
Historique conservé, recall = dernier `confirmed` non expiré.
|
||||
- **Renforcement** : `last_seen_at` bump quand le fait est réaffirmé en chat ;
|
||||
les facts non revus depuis N jours (180) et non `decision` deviennent `expired`.
|
||||
- **PII santé/famille** : `consolidator` force `status='pending'` (validation humaine
|
||||
via cartes) si la note source appartient aux dossiers sensibles configurables
|
||||
(`OBSIGATE_MEMORY_REVIEW_FOLDERS`, ex. `Santé,Famille`).
|
||||
- Backup : `data/` étant déjà dans le cycle de backups existant, rien de neuf.
|
||||
|
||||
### 5.2 Miroir markdown dans le vault — #193
|
||||
|
||||
Dossier **`99_Assistant/`** (créé à la première activation, dans le vault principal
|
||||
configurable `OBSIGATE_MEMORY_VAULT=Main`, dossier racine configurable) :
|
||||
|
||||
| Fichier | Contenu | Écrit par | Éditable par Bruno |
|
||||
|---|---|---|---|
|
||||
| `Profil.md` | Préférences/habitus/goals confirmés, groupés par sujet (H2) | mirror (consolidation) | ✅ — les edits sont respectés (§5.3 règles de merge) |
|
||||
| `Insights.md` | Détections proactes #201 (boucles ouvertes, engagements, patterns), horodatées | mirror | ✅ (suppression = `rejected` en base) |
|
||||
| `Rappels.md` | Table des rappels actifs (liens vers tâches planifiées) | mirror scheduler | lecture seule (UI dédiée) |
|
||||
| `Briefings/YYYY-MM-DD.md` | Briefing quotidien #200 | agent programmé | ✅ (archivage, jamais régénéré) |
|
||||
| `Journal.md` | Dernières consolidations (nb faits ajoutés/validés) | mirror | lecture seule |
|
||||
|
||||
Le miroir utilise des **balises HTML de section** :
|
||||
|
||||
```markdown
|
||||
<!-- obsigate:managed:preferences -->
|
||||
…bloc régénéré…
|
||||
<!-- /obsigate:managed:preferences -->
|
||||
```
|
||||
|
||||
Règle de merge : à chaque écriture, le mirror compare le contenu précédent généré
|
||||
(hash stocké en base) avec le contenu actuel du fichier. Si l'utilisateur a modifié
|
||||
l'intérieur d'une section gérée → la modification est **re-parse en faits `source='user'`**
|
||||
(confiance 1.0, status confirmed) puis la section est régénérée à partir de la base.
|
||||
Tout ce qui est hors balises est laissé intact. C'est le mécanisme qui rend la mémoire
|
||||
*auditable et contestable* — l'anti-boîte-noire.
|
||||
|
||||
### 5.3 Consolidation nocturne (« dreaming ») — #194
|
||||
|
||||
Job planifié via le scheduler existant (§8.3), action `agent_prompt` dédiée :
|
||||
|
||||
1. **Collecte incrémentale** : sessions `ai_history` créées/modifiées depuis le dernier
|
||||
run + fichiers `.md` modifiés depuis le dernier run (via l'index/watcher mtime) —
|
||||
plafonds : 200 fichiers × 30 Ko (comme bookslm), contenu `redact_file_content()` avant LLM.
|
||||
2. **Extraction** (1 appel LLM, provider par défaut = cheap model) : prompt système
|
||||
d'extraction → JSON `[{subject, predicate, value, kind, confidence}]` validé Pydantic ;
|
||||
rejet des valeurs non sourcées.
|
||||
3. **Réconciliation** (déterministe, stdlib) : dédup/supersede par `(subject,predicate)`,
|
||||
plancher de confiance, dossiers sensibles → pending.
|
||||
4. **Écriture** : `facts` + `mirror.py` → sections gérées de `Profil.md`.
|
||||
5. **Cartes de validation** : les faits `pending` deviennent des cartes dans le panneau
|
||||
Assistant (« Valider / Modifier / Oublier »), persistées jusqu'à action.
|
||||
|
||||
Coût : 1–3 appels API/nuit (deepseek-chat ≈ quelques centimes). Budget max :
|
||||
`OBSIGATE_CONSOLIDATION_MAX_TOKENS` (défaut 30 000/jour) pour bornes dures.
|
||||
|
||||
### 5.4 Outils mémoire (registés `@tool`) — #192
|
||||
|
||||
| Outil | Risque | Description (schéma) |
|
||||
|---|---|---|
|
||||
| `memory_recall` | READ | `{query, subject?, kind?}` → faits confirmed pertinents + fusion RRF avec sémantique #70 sur le vault. Injecté **automatiquement** dans le prompt système de chaque chat (voir §5.5). |
|
||||
| `memory_save` | WRITE | `{subject, predicate, value, kind, confidence=0.9}` — le modèle propose, la carte de confirmation affiche le fait ; Bruno valide → `source='chat'`. |
|
||||
| `memory_update` | WRITE | `{fact_id, value | status}` — correction/revalidation. |
|
||||
| `memory_forget` | DANGEROUS | `{fact_id}` — status `rejected`, supprimé du miroir, tracé à l'audit. |
|
||||
| `profile_get` | READ | `{section?}` → contenu courant de `Profil.md` (plus riche que le recall brut). |
|
||||
|
||||
### 5.5 Injection dans le contexte (le « ça me connaît » quotidien)
|
||||
|
||||
Au démarrage de chaque session agent (`bookslm_routes` → futur endpoint `/agent`),
|
||||
construction d'un bloc mémoire coiffant le prompt système, budget ≤ 2 500 tokens :
|
||||
|
||||
```
|
||||
<memoried-context>
|
||||
Profil (résumé géré) : préférences, habitudes, objectifs actifs…
|
||||
Faits saillants liés au sujet : <memory_recall(query=requête utilisateur)>
|
||||
Rappels actifs : <n lignes de Rappels.md>
|
||||
Dernier briefing : <3 puces>
|
||||
</memoried-context>
|
||||
```
|
||||
|
||||
Ce bloc est **visible** dans la section « étapes » du panneau (label « Mémoire »),
|
||||
comme les autres étapes d'outils — pas de magie cachée.
|
||||
|
||||
---
|
||||
|
||||
## 6. Rattachement MCP / Desktop / Skills
|
||||
|
||||
- Tous les nouveaux outils `@tool` sont automatiquement exposés au **serveur MCP**
|
||||
(`/mcp`, scopes IN_APP+MCP par défaut, propose/apply déjà gérés) → Claude Desktop /
|
||||
Cursor bénéficient gratis de `memory.*`, `reminder.*`, `connector.*`.
|
||||
- **Skills `/`** : nouvelles built-in `/profil` (montre Profil.md), `/rappel <texte>`
|
||||
(crée un rappel), `/brief` (régénère le briefing du jour), `/consolider`
|
||||
(déclenche le run de consolidation à la demande), `/connecteurs` (état des sources).
|
||||
- **Desktop Tauri** : les connecteurs sans token enregistré n'apparaissent pas dans le
|
||||
registre de schémas envoyés au LLM (`list_tools` filtré par capabilities actives) ;
|
||||
zéro appel réseau au démarrage.
|
||||
|
||||
---
|
||||
|
||||
## 7. Connecteurs externes — #195 (cadre), #196–#198 (implémentations)
|
||||
|
||||
### 7.1 Cadre (`backend/connectors/`)
|
||||
|
||||
Décision : **remplacer la logique ad hoc de `connected.py` (Gitea/GitHub par variables
|
||||
d'env) par un cadre général** — tout en gardant ces deux sources fonctionnelles
|
||||
(migration interne, même UX « Sources connectées » #103 étendue).
|
||||
|
||||
Concepts :
|
||||
|
||||
- **Connector** = classe (id, label, docs URL, scopes OAuth, capabilities nommées).
|
||||
- **Installation** = entrée `data/connectors.json` `{id, type, status, config (public),
|
||||
created_at}` ; secrets dans `data/connector_secrets.json` (0600) ou
|
||||
`OBSIGATE_CONNECTOR_SECRET_<ID>` — **même pattern strict que #168/#103**.
|
||||
- **Auth** : soit *token statique* (Discord webhook, Notion key, GitHub), soit
|
||||
**OAuth2 authorization-code + PKCE** (Google, Microsoft) :
|
||||
`GET /api/connectors/{id}/authorize` → redirection → `GET /api/connectors/{id}/callback`
|
||||
(échange + stockage `refresh_token` chiffré avec `data/secret.key` existant) →
|
||||
`POST /api/connectors/{id}/test` (validation par un appel READ bon marché).
|
||||
- **Refresh** transparent dans `call()` (anticipation du expiry, 1 retry sur 401).
|
||||
- **Capabilities → tools** : chaque capability expose un `@tool` (dynamiquement
|
||||
enregistré si le connecteur est installé), préfixé :
|
||||
`gmail.search_messages`, `gcal.list_events`, `teams.list_chats`…
|
||||
Risque par capability (lecture READ / écriture WRITE / envoi DANGEROUS).
|
||||
- **Budget & limites** : rate-limit par connecteur via `tools/ratelimit.py`,
|
||||
timeout httpx 15 s, retry backoff (pattern #92), cache SQLite des réponses GET
|
||||
bornées (pattern `webcache.py`, TTL 5 min pour le contexte de chat).
|
||||
- **Audit** : chaque `call()` journalisé (connecteur, capability, args tronqués,
|
||||
latence, statut) via `tools/audit.py`.
|
||||
|
||||
### 7.2 Matrice des connecteurs cibles
|
||||
|
||||
| Connecteur | Auth | Capabilités (→ outil) | Risque | Phase |
|
||||
|---|---|---|---|---|
|
||||
| **Google — Calendar** | OAuth2 PKCE | `gcal.list_events` (période, requête), `gcal.get_event`, `gcal.create_event`, `gcal.update_event` | READ, READ, WRITE, WRITE | #196 |
|
||||
| **Google — Gmail** | OAuth2 PKCE | `gmail.search_messages` (query `is:unread…`), `gmail.read_message`, `gmail.create_draft`, ~~`gmail.send`~~ | READ, READ, WRITE, 🔴 non exposé v1 | #196 |
|
||||
| **Google — Drive** | OAuth2 PKCE | `drive.search`, `drive.read_file` (import md/docx → vault) | READ | #198 (rattache backlog #167) |
|
||||
| **Microsoft — Teams / Graph** | Entra ID client, OAuth2 | `teams.list_chats`, `teams.read_messages`, `teams.send_message` | READ, READ, WRITE | #197 |
|
||||
| **Microsoft — Outlook** | idem Graph | `outlook.search_messages`, `outlook.list_events` (calendriers persos MS) | READ | #197 (optionnel, même jeton) |
|
||||
| **Discord** | webhook (sortie, ✅ #168) + **bot token lecture** (entrée, scopes `read`) | `discord.send` (existant via notify), `discord.list_messages`, `discord.search` (REST, pas de gateway websocket en v1) | WRITE, READ, READ | #198 |
|
||||
| **Notion** | API key (user token) | `notion.search`, `notion.read_page`, `notion.append_blocks` | READ, READ, WRITE | #198 |
|
||||
| **Slack** | webhook sortie + bot token lecture REST | `slack.send`, `slack.search_messages` | WRITE, READ | option future |
|
||||
| **Gitea / GitHub** | token (existant) | migrés sous le cadre | READ | #195 (migration) |
|
||||
| **Todoist / tasks externes** | token | — | — | hors v1 ; les tâches restent **dans le vault** (checklists) |
|
||||
|
||||
Règle transversale : **la prise de notes reste le vault**. Les connecteurs *nourrissent*
|
||||
l'assistant (contexte) et reçoivent des *actions* (événement, brouillon, message) ; ils
|
||||
ne remplacent jamais la note. C'est l'inverse des apps chat généralistes.
|
||||
|
||||
### 7.3 Utilisation des connecteurs par l'assistant
|
||||
|
||||
- L'agent choisit les outils selon les demandes (tool-calling existant, `tool_choice=auto`).
|
||||
- **Contexte briefing** (§8.2) : agrège `gcal.list_events(jour)` + rappels + mails
|
||||
non lus (si Gmail connecté) + notes modifiées → note de briefing.
|
||||
- **Demande type** : « est-ce que j'ai une réponse du dentiste ? » →
|
||||
`gmail.search_messages(from:...)` → cite le mail → propose la note.
|
||||
- **Confidentialité** : le contenu des connecteurs entre dans la conversation mais
|
||||
**jamais** dans la consolidation mémoire par défaut (seuls les faits *explicitement*
|
||||
confirmés par l'utilisateur via `memory_save` entrent en base).
|
||||
|
||||
### 7.4 UI « Sources connectées » (#103 étendue)
|
||||
|
||||
Page Configurations : carte par connecteur (statut, scopes, bouton Connect/Test/
|
||||
Déconnecter), journal des derniers appels (audit filtré), budget tokens. Textes FR/EN.
|
||||
|
||||
---
|
||||
|
||||
## 8. Rappels, briefing, agent programmé — #199 (rappels), #200 (agent+briefing)
|
||||
|
||||
### 8.1 Rappels conversationnels — #199
|
||||
|
||||
Le scheduler #170 sait déjà planifier `notify`/`create_file`. Les rappels = **action
|
||||
typée de haut niveau**, pour que l'assistant puisse les gérer naturellement :
|
||||
|
||||
- Nouvelle action scheduler `reminder` : `{user_id, text, deliver: [channels…],
|
||||
recurring: none|daily|weekly|cron}` → au due : `notify.broadcast` + Web Push +
|
||||
append à `99_Assistant/Rappels.md` (historique `Rappels.md` → section « tenus »).
|
||||
- Outils : `reminder_create` (WRITE, `{texte, quand (ISO ou relatif « in 3 weeks »
|
||||
résolu côté serveur par la LLM→date), récurrence, canaux}`), `reminder_list` (READ),
|
||||
`reminder_snooze` (WRITE, +`{task_id, delta}` — le snooze bump `last_snoozed_count`,
|
||||
signal pour #201), `reminder_cancel` (WRITE).
|
||||
- UX : panneau Assistant « Cloche » + section latérale dédiée ; les rappels dus sont
|
||||
injectés en contexte mémoire (§5.5) pour que l'assistant puisse en parler de lui-même.
|
||||
|
||||
### 8.2 Agent programmé — nouveau type d'action `agent_prompt` — #200
|
||||
|
||||
Brique générique manquante : le scheduler doit pouvoir **lancer le run_agent** avec un
|
||||
prompt, pas seulement des actions primitives.
|
||||
|
||||
- Sécurité de la tâche non interactive (pas de confirmation humaine possible) :
|
||||
**whitelist de capacités** — READ sans limite ; WRITE restreint aux chemins
|
||||
`99_Assistant/**` (le prompt d'un scheduled-agent ne peut écrire ailleurs) ;
|
||||
DANGEROUS et `connectors.*` en écriture **interdits**. Paramètre
|
||||
`allowed_extra_tools: []` pour élargir explicitement à la création.
|
||||
- Plafonds : max 8 itérations d'outils, budget tokens par tâche, `last_error` +
|
||||
broadcast `schedule_failure` (déjà en place), `last_run_at` persisté.
|
||||
- Le **briefing du matin** = built-in : skill `/brief` + tâche `daily_time 07:00`
|
||||
(fuseau `OBSIGATE_TZ`) → prompt agrégateur (§7.3) → écrit `Briefings/YYYY-MM-DD.md`
|
||||
+ notification (canaux de l'utilisateur + Web Push).
|
||||
- Autres prompts programmatifs naturels : « résumé hebdo des notes modifiées »,
|
||||
« veille » (crawl d'une page + diff dans une note) — mais chacun reste une tâche
|
||||
créée par l'utilisateur ou l'assistant, pas de magie par défaut.
|
||||
|
||||
### 8.3 Le job de consolidation passe par `agent_prompt`
|
||||
|
||||
#194 s'implémente comme tâche built-in `daily_time 23:30` + whitelist
|
||||
`memory.*`/`profile_get` — l'infrastructure d'ordonnancement de #200 sert la mémoire.
|
||||
(Ordre de livraison : #200 avant #194, cf. §10.)
|
||||
|
||||
---
|
||||
|
||||
## 9. Retrait d'Ollama — #191
|
||||
|
||||
Objectif utilisateur : ObsiGate ne doit pas hériter de l'infra (ni Ollama, ni Honcho) ;
|
||||
et le conteneur Ollama (1,4 Go+ de poids, 2 modèles, RAM) ne sert plus.
|
||||
|
||||
1. `docker-compose.yml` : supprimer le service `ollama`, le volume `ollama_data`,
|
||||
les vars `OLLAMA_BASE_URL`/`OLLAMA_MODEL`, et la mention « port 11434 » de l'en-tête.
|
||||
Idem `docker-compose.test-linux.yml` / `docker-compose.test-win.yml`.
|
||||
2. `backend/ai.py` / `model_capabilities.py` : **conserver** le provider `ollama`
|
||||
(option de configuration, utile au desktop hors-ligne qui pointe vers un ollama
|
||||
local de l'utilisateur) mais retirer tout défaut pointant vers le réseau interne.
|
||||
Les tests d'isolation : `OBSIGATE_*` ne référence plus `http://ollama:11434`.
|
||||
3. Embeddings : par **sentence-transformers local optionnel** (déjà #70) — le mode
|
||||
par défaut de l'Assistant V2 fonctionne **sans embeddings** (TF-IDF + RRF + faits
|
||||
SQLite), les embeddings étant un raffinement auto-détecté (`SemanticIndex` existe,
|
||||
avec fallback API provider si configurée).
|
||||
4. Migration : `docker compose down && up -d` (règle .env connue P17/P29) ; supprimer
|
||||
le volume `ollama_data` (procédure documentée dans le CHANGELOG + ROADMAP de la tache #191).
|
||||
|
||||
---
|
||||
|
||||
## 10. Phasage & IDs
|
||||
|
||||
> Chaque tranche est livrable indépendamment, CI verte + DoD `DELIVERY_WORKFLOW.md`,
|
||||
> i18n FR/EN, tests, guide utilisateur `docs/GUIDES/`.
|
||||
|
||||
| ID | Tranche | Contenu | Effort | Dépend | Critères d'acceptation |
|
||||
|---|---|---|---|---|---|
|
||||
| #191 | **Purge Ollama** | compose ×3, docs, notes migration | 0,5 j | — | plus aucun conteneur ollama ; AI editor + agent OK sur deepseek/openrouter/gemini |
|
||||
| #192 | **Mémoire socle** | `memory/store.py`, `recall.py`, 5 outils `@tool`, tests | 3–4 j | #191 | recall/save/update/forget fonctionnent en chat ; audit + confirmation OK |
|
||||
| #193 | **Miroir markdown** | `mirror.py`, `99_Assistant/`, balises gérées, re-parse edits | 2–3 j | #192 | édition manuelle de Profil.md → faits `source='user'` ; réécritures idempotentes |
|
||||
| #200 | **Agent programmé + briefing** | action `agent_prompt` (whitelist), skill `/brief`, tâches built-in | 3–4 j | #192 | briefing 07:00 → note + notif ; WRITE limité `99_Assistant/**` prouvé par tests |
|
||||
| #194 | **Consolidation nocturne** | `consolidator.py`, incrémental, pending/cartes, budget tokens | 3–4 j | #200, #193 | 1 nuit test → ≥1 fait utile extrait ; aucun secret extrait (redaction test) ; budget respecté |
|
||||
| #199 | **Rappels** | action `reminder`, 4 outils, UI cloche, snooze-count | 2–3 j | #200 | « rappelle-moi dans 3 semaines » → rappel réel ; snooze ≥3 → signal visible #201 |
|
||||
| #195 | **Cadre connecteurs** | `connectors/` base+oauth+registry, UI Sources, migration Gitea/GitHub | 4–5 j | — | OAuth2 PKCE de bout en bout avec un provider de test ; secrets 0600 |
|
||||
| #196 | **Google Calendar + Gmail** | capabilities §7.2, context briefing étendu | 3–4 j | #195 | agenda du jour cité dans briefing ; recherche mail via chat avec confirmation WRITE nulle |
|
||||
| #197 | **Microsoft Teams/Outlook** | Graph client min, capabilities READ + send | 3–4 j | #195 | lire un chat Teams et y répondre via carte de confirmation |
|
||||
| #198 | **Discord read + Notion + Drive** | capacités restantes §7.2 | 3–5 j | #195 | import page Notion → note vault ; `drive.read_file` OK |
|
||||
| #201 | **Proactivité / insights** | détecteurs stdlib (open loops, snoozes, pourrissement TODO, récurrences) → `Insights.md` + suggestions en contexte | 3–4 j | #194, #199 | un scenario « note à faire > 21 jours » et « snooze ×3 » génèrent des insights réels et acceptables/refusables |
|
||||
|
||||
Ordre recommandé : `#191 → #192 → #193 → #200 → #194 → #199` (la mémoire et le
|
||||
briefing rendent l'assistant utile **sans** connecteur) puis connecteurs `#195 → #196…`
|
||||
et enfin `#201` (le coaching a besoin de la mémoire garnie).
|
||||
|
||||
---
|
||||
|
||||
## 11. Sécurité & confidentialité (rappel explicite)
|
||||
|
||||
- **Secrets** : uniquement `data/connector_secrets.json` (0600, chiffrés `secret.key`)
|
||||
ou env `OBSIGATE_CONNECTOR_SECRET_*` — jamais le compose, jamais git (#168/#103).
|
||||
- **SSRF** : toutes les URLs configurées par l'utilisateur passent
|
||||
`validate_webhook_url`/`is_safe_target` ; les endpoints des providers OAuth sont
|
||||
une allowlist codée (accounts.google.com, login.microsoftonline.com…).
|
||||
- **Chiffrement au repos** optionnel : même pattern que #172 (backlog existant) —
|
||||
si activé, s'applique à `connector_secrets.json` + `memory.sqlite3`.
|
||||
- **Redaction** : `redact_file_content()` systématique avant LLM pour toute note
|
||||
consolidée ; jamais le contenu brut des connecteurs dans les prompts hors
|
||||
appel d'outil explicite.
|
||||
- **Consentement par tâche planifiée** : les tâches `agent_prompt` listent leurs
|
||||
capacités autorisées dans l'UI, avec journal `last_run` dépliable.
|
||||
- **Effacement** : `memory_forget` + purge des sessions #95 + export de la base
|
||||
(`GET /api/memory/export` JSON) → réversibilité complète RGPD-like.
|
||||
|
||||
---
|
||||
|
||||
## 12. Risques & arbitrages assumés
|
||||
|
||||
| Risque | Mitigation |
|
||||
|---|---|
|
||||
| Qualité variable de l'extraction de faits → mémoire « bruitée » | seuil confiance + pending/cartes ; dossiers sensibles → validation manuelle ; `last_seen` decay ; effacement facile |
|
||||
| Coût LLM (consolidation + briefing quotidiens) | cheap model dédié par var `OBSIGATE_AGENT_BACKGROUND_MODEL` ; budgets durs en tokens ; aggregation déterministe (stdlib) avant LLM |
|
||||
| Dérive du scheduler qui exécute des prompts non surveillés | whitelist capacités + budget itérations + plafond chemins ; journal d'audit par tâche |
|
||||
| Élargissement du scope backend (main.py déjà volumineux) | nouveaux modules dédiés (`memory/`, `connectors/`) ; aucune logique dans `main.py` ; routers séparés |
|
||||
| OAuth app Google/Microsoft à créer manuellement (client id) | guide `docs/GUIDES/CONNECTEURS_GOOGLE_MS.md` pas-à-pas FR/EN ; possible de rester sur tokens statiques Notion/GitHub sans projet cloud |
|
||||
| Éditions humaines du miroir non re-parsables (markdown libre) | balises `managed` strictes ; sections hors-balises intouchables ; re-parse = faits, pas diff sémantique |
|
||||
| Tauri desktop + connecteurs = surface réseau | capabilities absentes du registre tant que non installées ; offline-first global inchangé |
|
||||
|
||||
---
|
||||
|
||||
## 13. Définition de terminé (par tranche, conforme DoD dépôt)
|
||||
|
||||
Pour chaque ID : tests unitaires backend (+tests de non-régression pour tout bug),
|
||||
tests JSDOM si UI, E2E Playwright si flux UI touché, ruff/mypy 0 erreur, i18n FR/EN,
|
||||
labels `ai.step.*` pour tout nouvel outil, mise à jour `CHANGELOG.md` `[Unreleased]`,
|
||||
`docs/ROADMAP.md` (statut + fiche `docs/features/assistant-v2-<slug>.md` par tranche),
|
||||
guide utilisateur `docs/GUIDES/` si exposé à l'utilisateur, CI verte, commit conventionnel
|
||||
référençant l'ID.
|
||||
|
||||
---
|
||||
|
||||
## Annexe A — Variables d'environnement nouvelles
|
||||
|
||||
```
|
||||
# Mémoire
|
||||
OBSIGATE_MEMORY_ENABLED=true
|
||||
OBSIGATE_MEMORY_VAULT=Main # vault où vit 99_Assistant/
|
||||
OBSIGATE_MEMORY_REVIEW_FOLDERS=Santé,Famille # forcent status=pending
|
||||
OBSIGATE_CONSOLIDATION_MODEL= # cheap model, défaut = provider courant
|
||||
OBSIGATE_CONSOLIDATION_MAX_TOKENS=30000
|
||||
# Briefing / agent programmé
|
||||
OBSIGATE_TZ=America/Toronto
|
||||
OBSIGATE_BRIEFING_TIME=07:00
|
||||
# Connecteurs (ou UI Sources connectées — stockage prioritaire)
|
||||
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET # app Google Cloud (consent screen)
|
||||
AZURE_CLIENT_ID / AZURE_CLIENT_SECRET # app Entra ID
|
||||
OBSIGATE_NOTION_API_KEY=
|
||||
OBSIGATE_DISCORD_BOT_TOKEN= # lecture REST uniquement
|
||||
```
|
||||
|
||||
## Annexe B — Prompt de consolidation (extrait)
|
||||
|
||||
```
|
||||
Tu extrais des faits durables à partir de notes et conversations récentes d'un
|
||||
journal personnel. Pour chaque fait : subject, predicate, value (une phrase,
|
||||
français), kind ∈ preference|decision|entity|event|habit|goal|open_loop,
|
||||
confidence 0..1. Règles :
|
||||
- Uniquement ce qui est explicitement affirmé ou fortement implicite ;
|
||||
- Rien de sensible sans appui textuel direct (santé, finances, familles) ;
|
||||
- Jamais de mot de passe, token, numéro personnel (signalés comme refusés) ;
|
||||
- Si un fait contredit un fait existant, produit le nouveau avec note de supersede.
|
||||
Sortie : JSON strict {facts: [...]} — aucun commentaire.
|
||||
```
|
||||
|
||||
## Annexe C — Prompt du briefing (extrait)
|
||||
|
||||
```
|
||||
Contexte fourni : agenda du jour (gcal), rappels dus, mails non lus importants
|
||||
(gmail si connecté), notes modifiées hier, faits pending à valider.
|
||||
Produis une note markdown Briefings/YYYY-MM-DD.md :
|
||||
- Aujourd'hui : événements + rappels (liens vault si applicable)
|
||||
- À trancher : 2–4 questions ouvertes détectées dans les notes récentes
|
||||
- Mémoire : 1–3 faits nouveaux à valider (citez la source note)
|
||||
Pas d'invention. Chaque affirmation cite sa source (chemin ou sujet connecteur).
|
||||
```
|
||||
@@ -235,6 +235,9 @@ Gestion :
|
||||
| `/api/conflicts` · `/api/conflicts/resolve` | Conflits Syncthing | GET/POST |
|
||||
| `/api/plugins` | Installer / activer / désactiver | GET/POST/DELETE |
|
||||
| `/api/push/*` | Abonnement Web Push (VAPID) | GET/POST/DELETE |
|
||||
| `/api/duplicates` · `/api/duplicates/merge` | Doublons : paires candidates, fusion (`confirm: true`) | GET/POST |
|
||||
| `/api/notify/channels` · `/api/notify/test` | Notifications Discord/Telegram/SMTP/webhook (CRUD admin + test) | GET/POST/PATCH/DELETE |
|
||||
| `/api/scheduler/tasks` · `/api/scheduler/tasks/{id}/run` | Tâches planifiées (CRUD + exécution manuelle) | GET/POST/PATCH/DELETE |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -182,10 +182,23 @@ partagée par l'assistant in-app et le serveur MCP.
|
||||
| Écriture (propose/apply) | `create_file`, `create_directory`, `edit_file`, `append_to_file`, `restore_backup` |
|
||||
| Destructif (propose/apply) | `rename_file`, `rename_directory`, `move_path`, `replace_in_files`, `delete_file`, `delete_directory` |
|
||||
| Web / sources connectées | `web_search`, `fetch_url`, sources Gitea/GitHub… |
|
||||
| Doublons (#166) | `find_duplicates` (lecture), `merge_duplicate_notes` (destructif) |
|
||||
| Notifications (#168) | `notify_external` (Discord, Telegram, SMTP, webhook) |
|
||||
| Tâches planifiées (#170) | `create/list/delete/run_scheduled_task*` |
|
||||
|
||||
Les mutations suivent un flux **two-step** : `propose_<tool>` renvoie un aperçu
|
||||
et un **jeton signé à usage unique**, puis `apply_<tool>` exécute.
|
||||
|
||||
Exemples de demandes à l'assistant (mode agent) :
|
||||
|
||||
- « Trouve les notes en double dans ce vault » → `find_duplicates`, puis
|
||||
« fusionne `brouillon.md` dans `rapport.md` » → `merge_duplicate_notes`
|
||||
(backup automatique, approbation requise).
|
||||
- « Préviens-moi sur Discord quand la tâche échoue » → `notify_external`
|
||||
(canaux configurés via `POST /api/notify/channels`, déclencheurs au choix).
|
||||
- « Crée une note de veille chaque matin à 8h » → `create_scheduled_task`
|
||||
(`daily_time 08:00`, action `create_file` ou `append_to_file`).
|
||||
|
||||
---
|
||||
|
||||
## 7. Sécurité
|
||||
|
||||
@@ -174,7 +174,7 @@ et [`features/api-mcp-tokens-107.md`](../features/api-mcp-tokens-107.md).
|
||||
| **Rate limiting MFA** | Appliqué aux endpoints TOTP/WebAuthn/recovery |
|
||||
| **Audit log** | Écritures, suppressions, config dans `data/audit.log` (JSON lines, rotation 10 Mo) |
|
||||
| **Backup automatique** | Avant chaque modification/suppression dans `.obsigate-backup/` |
|
||||
| **Redaction** | Masquage des JWT, clés API, tokens dans les aperçus et retours d'outils |
|
||||
| **Redaction** | Masquage des JWT, mots de passe, clés API (OpenAI, GitHub, Google, AWS, Slack, Stripe…), tokens et connection strings dans les aperçus markdown et les retours d'outils — clic sur un masque = copie de la valeur |
|
||||
| **CSP** | `object-src`, `base-uri`, `form-action`, `frame-ancestors` restreints |
|
||||
| **Cookie HttpOnly** | Jeton retiré de `sessionStorage`, porté par cookie HTTP-only |
|
||||
| **Utilisateur non-root** | Conteneur sous `obsigate` (UID 1000) |
|
||||
@@ -187,6 +187,30 @@ et [`features/api-mcp-tokens-107.md`](../features/api-mcp-tokens-107.md).
|
||||
Une politique minimale est validée à la création d'un compte. Choisissez des mots
|
||||
de passe longs et uniques ; activez le MFA pour les comptes admin.
|
||||
|
||||
### Secrets masqués dans les aperçus
|
||||
|
||||
Quand une note contient un secret, l'aperçu markdown le remplace par un masque
|
||||
— `[CLÉ API MASQUÉE]`, `[MOT DE PASSE MASQUÉ]`, `[JWT MASQUÉ]`,
|
||||
`[CONNECTION_STRING MASQUÉE]` — au lieu de la valeur :
|
||||
|
||||
- **Détectés automatiquement** : mots de passe (`password=`, `"passwd": "…"`,
|
||||
`db_password=…`, toute longueur), affectations `api_key=` / `token=` /
|
||||
`secret=`, JWT, clés privées, connection strings, hex en contexte secret,
|
||||
et les formats de clés les plus répandus — OpenAI/Anthropic/OpenRouter
|
||||
(`sk-`), GitHub (`ghp_`, `github_pat_`), Google (`AIza…`, `ya29.`), AWS
|
||||
(`AKIA…`/`ASIA…`), Slack (`xoxb-`), Stripe (`sk_live_`, `whsec_`), GitLab
|
||||
(`glpat-`), Hugging Face (`hf_`), npm, Docker, SendGrid, Resend, Square,
|
||||
Atlassian, Discord, Telegram, jetons `Bearer …`.
|
||||
- **Clic = copie** : dans l'application (aperçu authentifié), cliquer sur un
|
||||
masque copie la valeur réelle dans le presse-papiers (infobulle « Cliquer
|
||||
pour copier la valeur »). La valeur n'apparaît jamais en clair à l'écran.
|
||||
- **Jamais exposé à l'extérieur** : partages publics (`/s/{token}`), exports
|
||||
PDF et contexte envoyé à l'IA / au serveur MCP ne reçoivent que le libellé,
|
||||
jamais la valeur derrière le masque.
|
||||
- **Hors périmètre** : la vue « source » (fichier brut) et les aperçus de
|
||||
fichiers non-markdown ne sont pas masqués — c'est le fichier lui-même qui
|
||||
est affiché.
|
||||
|
||||
---
|
||||
|
||||
## 7. Variables d'environnement
|
||||
|
||||
@@ -23,6 +23,32 @@ commande**.
|
||||
| Icône de barre des tâches (tray) | ❌ | ✅ |
|
||||
| Auto-update | ❌ | ✅ (vérifie les releases Gitea) |
|
||||
| Mode hors-ligne | Limité | Complet (backend local) |
|
||||
| Gestion vaults & dossiers | Fichiers de config serveur | ✅ UI dédiée + menu contextuel |
|
||||
|
||||
### Gestion des vaults et dossiers (#159)
|
||||
|
||||
Deux surfaces, réservées à l'application desktop :
|
||||
|
||||
- **Menu contextuel** : clic droit sur un vault ou un dossier racine dans la
|
||||
sidebar → « Retirer de l'application » (confirmation, déregistration de la
|
||||
configuration locale — **aucun fichier n'est supprimé**), puis
|
||||
redémarrage automatique du backend.
|
||||
- **Configuration** : section « 🖥️ Vaults & dossiers (Desktop) » listant les
|
||||
vaults et dossiers chargés, avec boutons « Ajouter un vault » (sélecteur de
|
||||
dossier natif) et « Ajouter un dossier ».
|
||||
|
||||
### Premier lancement (#160)
|
||||
|
||||
Au tout premier démarrage (aucun vault configuré), l'application :
|
||||
|
||||
1. crée et monte **`%USERPROFILE%\ObsiGate`** comme vault de démarrage
|
||||
(« ObsiGate ») et ouvre l'interface dessus ;
|
||||
2. y écrit le document **`Prise en main.md`** — présentation, premières
|
||||
étapes, raccourcis (`Ctrl+Espace`, `Ctrl+Alt+Espace`, `Ctrl+F`,
|
||||
`Ctrl+W`/`Ctrl+Tab`) et accès au guide complet (bouton d'aide de l'en-tête).
|
||||
Le fichier n'est jamais réécrit : modifiez-le ou supprimez-le librement ;
|
||||
3. ajoute votre dossier personnel comme seconde racine (nommée d'après son
|
||||
chemin).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -178,6 +178,31 @@ des **onglets** (avec possibilité de vue multi-panneaux / split view).
|
||||
> Selon le mode, la lecture et l'édition se remplacent : `Editer` et `Forge`
|
||||
> prennent la place de la vue lecture ; revenez avec `✓` / `×` ou `Échap`.
|
||||
|
||||
### Discuter d'un fichier
|
||||
|
||||
- **Bouton « Chat »** (💬, barre d'actions du document) : ouvre un panneau
|
||||
latéral avec l'historique de la discussion et la saisie d'un message.
|
||||
- Les nouveaux messages s'affichent en direct ; si le panneau est fermé, une
|
||||
notification annonce l'envoi. L'historique est conservé par fichier.
|
||||
- Le panneau **suit le document** : changer de fichier re-cible la discussion.
|
||||
|
||||
### Chat général
|
||||
|
||||
- Onglet **💬 Chat** (dernier de la sidebar, sous les onglets) : conversation
|
||||
centrale, valable pour toute l'application, avec **pastille rouge** quand
|
||||
de nouveaux messages arrivent.
|
||||
- **📎** attache une image ou une vidéo (25 MB max) ; les URL sont cliquables.
|
||||
- La **barre de filtre** sert à chercher dans le chat (texte ou auteur) quand
|
||||
cet onglet est actif.
|
||||
- **🗑** (survol d'un message) : supprime votre message — un administrateur
|
||||
peut supprimer celui de n'importe qui. Confirmation demandée, le retrait
|
||||
est immédiat pour tout le monde.
|
||||
- **Messages privés** : la rangée de pastilles au-dessus du fil propose
|
||||
**Général** puis chaque utilisateur ; choisissez un destinataire pour une
|
||||
conversation à deux, avec son propre compteur de non-lus.
|
||||
- **Link preview** : coller une URL suffit — titre, description, image et
|
||||
site s'affichent en carte cliquable sous le message (si le site répond).
|
||||
|
||||
---
|
||||
|
||||
## 7. Rechercher
|
||||
|
||||
@@ -19,7 +19,8 @@ avec :
|
||||
- **Normalisation des accents** — `resume` trouve `résumé`, `elephant` trouve `éléphant`.
|
||||
- **Stemming français** — les variantes des mots sont rapprochées.
|
||||
- **Snippets surlignés** — les termes trouvés sont mis en `<mark>` dans l'extrait.
|
||||
- **Facettes** — compteurs par vault et par tag sur les résultats.
|
||||
- **Facettes** — compteurs par vault, par tag et par extension sur les résultats, panneau
|
||||
repliable d'un clic (chevron, état mémorisé).
|
||||
- **Pagination** — 50 résultats par page.
|
||||
- **Tri** — par pertinence (TF-IDF) ou par date de modification.
|
||||
- **Chips de filtres** — les filtres actifs apparaissent sous forme de puces retirables.
|
||||
@@ -133,24 +134,87 @@ curl "http://localhost:2020/api/file/Recettes/pdf/info?path=menu.pdf"
|
||||
|
||||
## 6. Tableurs Excel (XLSX)
|
||||
|
||||
### Créer un classeur
|
||||
|
||||
Pour démarrer un tableur, clic droit sur un vault ou un dossier →
|
||||
**Nouveau fichier** (ou **Nouveau fichier ici**), choisissez le type **Excel
|
||||
(.xlsx)** et saisissez un nom : le classeur est créé avec une feuille
|
||||
**Feuille1** et s'ouvre directement dans l'éditeur, prêt à être saisi. La même
|
||||
liste de types est accessible depuis la palette de commandes (`Ctrl+Alt+Space`).
|
||||
|
||||
### Affichage et édition
|
||||
|
||||
Un fichier `.xlsx` s'ouvre dans une visionneuse dédiée : un tableau par
|
||||
feuille, des onglets pour naviguer entre elles (toujours visibles, même à
|
||||
une seule feuille), les en-têtes A1/B1 et les numéros de ligne. La barre de
|
||||
commandes regroupe les actions en sections (Formules · Insertion · Vue ·
|
||||
Fichier) autour d'un bouton **Enregistrer** principal. Chaque cellule est
|
||||
modifiable directement (clic), `Entrée` valide, `Échap` annule la saisie.
|
||||
**Enregistrer** envoie les cellules modifiées à
|
||||
commandes reprend l'ordre de Google Sheets (recherche, annuler/rétablir,
|
||||
impression, peinture, zoom, formats de nombre, police, couleurs, bordures,
|
||||
fusion, alignements, retour ligne, rotation, options) et le bouton **⋮**
|
||||
regroupe figeage, tri, filtre, dimensions, lien, commentaire ; les menus
|
||||
**Mise en forme** / **Structure** restent disponibles. Elle est **épinglée en
|
||||
haut** de la page et l'éditeur occupe toute la largeur du contenu, pour que
|
||||
les commandes restent atteignables pendant le défilement d'un grand tableau.
|
||||
|
||||
**Mise en page mobile** — sur un écran étroit (≤ 768 px), la barre de menus et
|
||||
le ruban de formatage sont **repliés par défaut** afin que la grille occupe
|
||||
toute la hauteur disponible : seuls les onglets de feuilles, **Enregistrer** et
|
||||
la **barre de formule (fx)** restent affichés. Le bouton **☰** (à gauche des
|
||||
onglets) déplie/replie l'ensemble ; les commandes tactiles font au moins
|
||||
44 px, les onglets défilent horizontalement et les champs de saisie restent en
|
||||
16 px pour éviter le zoom automatique d'iOS.
|
||||
|
||||
Les largeurs de colonnes et hauteurs de lignes s'ajustent en **glissant le
|
||||
bord des en-têtes** (souris et tactile) : en plaçant le pointeur sur le bord
|
||||
d'un en-tête de colonne (`A`, `B`, `C`…) ou de ligne (`1`, `2`, `3`…), le
|
||||
curseur change pour indiquer l'ajustement.
|
||||
|
||||
### Sélectionner et saisir
|
||||
|
||||
Comme dans Google Sheets et Excel, une cellule est **sélectionnée** ou **en cours
|
||||
de saisie** — jamais les deux à la fois.
|
||||
|
||||
- **Sélectionner** — un clic ou les flèches : la cellule s'entoure, sans curseur
|
||||
clignotant.
|
||||
- **Saisir** — un **double-clic** (le curseur se place sous le pointeur, la saisie
|
||||
se fait en place), la **première frappe** (qui *remplace* le contenu au lieu
|
||||
de s'y ajouter), ou `F2`.
|
||||
- **Valider et descendre** — `Entrée` pendant la saisie enregistre et place le
|
||||
focus **sur la cellule du dessous**. `Entrée` sur une cellule simplement
|
||||
sélectionnée la bascule en saisie. `Tab` fait de même vers la droite. Les
|
||||
**flèches** valident aussi et déplacent la sélection.
|
||||
- **Quitter la saisie** — `Échap` restaure la valeur d'origine ; un clic ailleurs
|
||||
conserve ce qui a été tapé.
|
||||
- `Alt`+flèche insère un saut de ligne **dans** la cellule.
|
||||
|
||||
**Sélection multiple** — `Maj`+clic ou `Maj`+flèches étendent la plage depuis la
|
||||
cellule d'origine ; glisser à la souris fait de même. Un **clic simple** réduit
|
||||
toujours la sélection à la cellule cliquée. La plage est **contourée** comme dans
|
||||
Excel, et les en-têtes couverts (lettres et numéros de ligne) sont mis en
|
||||
évidence. Depuis les **marges**, un clic sur un numéro de ligne ou une lettre de
|
||||
colonne sélectionne la **ligne ou la colonne entière**, et glisser — ou
|
||||
`Maj`+clic — étend à **plusieurs lignes ou colonnes** (`A1:Z5`). Une sélection
|
||||
de plusieurs cellules affiche une mini-barre (lien, commentaire,
|
||||
réinitialiser le filtre, somme). **Enregistrer**, placé à droite des onglets
|
||||
de feuilles, envoie les cellules modifiées à
|
||||
`PUT /api/file/{vault}/xlsx/save` : une sauvegarde par feuille, avec
|
||||
**backup automatique** du fichier avant écriture, et une écriture
|
||||
**atomique** (le classeur n'est jamais laissé à moitié écrit).
|
||||
|
||||
Le bouton **« + »** à côté des onglets ajoute une nouvelle feuille. Deux
|
||||
pastilles d'état rappellent les limites de la vue : **« Lecture seule »**
|
||||
pour les formats `.xls`/`.ods`, et **« Formules non recalculées »** — ObsiGate
|
||||
affiche la formule telle qu'elle est enregistrée, Excel la recalcule à
|
||||
l'ouverture et les cellules dépendantes ne se rafraîchissent pas à l'écran.
|
||||
Le bouton **« + »** à côté des onglets ajoute une nouvelle feuille. Une
|
||||
pastille **« Lecture seule »** rappelle la seule limite de la vue : les
|
||||
formats `.xls`/`.ods`. ObsiGate calcule par ailleurs les formules courantes
|
||||
à l'écran (arithmétique, `SOMME`, `MOYENNE`, `SI`…) ; les fonctions non
|
||||
prises en charge affichent la formule telle qu'enregistrée, Excel la
|
||||
recalcule à l'ouverture.
|
||||
|
||||
Le tableau suit le **thème de l'application** : fond, en-têtes, bordures et
|
||||
lignes alternées sont dérivés du thème actif (tous les thèmes, y compris
|
||||
contrasté élevé et sépia).
|
||||
|
||||
La grille montre toujours les colonnes **A à Z** et **1000 lignes**, comme un
|
||||
tableur : les lignes et colonnes inutilisées restent affichées et leurs
|
||||
cellules vides sont **éditables** comme les autres. Seule la feuille affichée
|
||||
est étendue, à son ouverture.
|
||||
|
||||
### Avertissement avant enregistrement
|
||||
|
||||
@@ -164,9 +228,11 @@ l'application. Si vous refusez, rien n'est écrit.
|
||||
|
||||
> Les **graphiques, images et tableaux croisés** sont, eux, bien conservés.
|
||||
|
||||
Si le classeur est modifié ailleurs entre-temps (verrou concurrent), ObsiGate
|
||||
n'interrompt pas votre travail : un bandeau vous propose de **réessayer**
|
||||
l'enregistrement, vos modifications restant en place.
|
||||
Si le classeur est modifié ailleurs entre-temps (autre poste, Excel,
|
||||
synchronisation…), ObsiGate n'interrompt pas votre travail : un bandeau vous
|
||||
propose de **réessayer**. Le bouton **Réessayer** relit d'abord le fichier pour
|
||||
récupérer la version courante, puis rejoue l'enregistrement — vos
|
||||
modifications restent en place pendant tout ce temps.
|
||||
|
||||
### Formules
|
||||
|
||||
@@ -182,8 +248,14 @@ curl -X PUT "http://localhost:2020/api/file/Recettes/xlsx/save?path=budget.xlsx"
|
||||
- `allow_formula` : `true` pour écrire une vraie formule (`=B1*2`).
|
||||
- `force` : `true` pour enregistrer malgré les éléments non préservés
|
||||
(sinon l'API répond **409** `xlsx_lossy_content`).
|
||||
- Deux sauvegardes simultanées sur le même fichier : la seconde reçoit
|
||||
**409** `conflict` au lieu d'écraser la première.
|
||||
- `if_match` (facultatif) : la **version** du fichier attendue, telle que la
|
||||
lecture la renvoie (`xlsx_revision` ou `revision`). Si le fichier a changé
|
||||
depuis, l'écriture est refusée (**409** `conflict`, `details.reason =
|
||||
"stale_revision"`) au lieu d'écraser le travail de l'autre écrivain ;
|
||||
relisez le fichier puis renvoyez la nouvelle version. Les trois routes
|
||||
d'écriture (`xlsx/save`, `xlsx/structure`, `csv/save`) acceptent l'en-tête
|
||||
`If-Match` ou le champ `if_match` et renvoient la version à jour dans
|
||||
`revision`.
|
||||
|
||||
### Feuilles volumineuses et lecture par fenêtres
|
||||
|
||||
@@ -206,38 +278,121 @@ curl "http://localhost:2020/api/file/Recettes/xlsx/sheet?path=budget.xlsx&sheet=
|
||||
lignes (1 à 1 000 par requête).
|
||||
- La réponse porte `total_rows`, `truncated` et `has_more` pour paginer.
|
||||
- Erreurs : **404** si la feuille n'existe pas, **415** si le fichier
|
||||
n'est pas un `.xlsx`.
|
||||
n'est ni un `.xlsx` ni un `.xlsm`.
|
||||
|
||||
### Fonctions avancées
|
||||
|
||||
**Barre de formule et navigation clavier** — la cellule active est nommée en
|
||||
A1 au-dessus du tableau ; `Tab`/`Maj+Tab` et les flèches circulent entre les
|
||||
cellules, `Entrée` valide, `Maj+Entrée` insère un retour à la ligne, une
|
||||
plage se copie telle quelle vers un tableur.
|
||||
**Barre de formule, zone Nom et navigation clavier** — au-dessus du tableau, la
|
||||
**zone Nom** affiche l'adresse de la cellule active (`B12`) ou de la plage
|
||||
sélectionnée (`A1:B3`) et elle est **éditable** (« Atteindre ») : saisissez une
|
||||
référence puis `Entrée` pour y aller (`B12`, `A1:B3`, `$A$1`, ou `Feuille2!A1`
|
||||
pour changer d'onglet) ; une référence inconnue est refusée avec un message et
|
||||
l'adresse précédente est restaurée. La barre de formule reflète la cellule
|
||||
active et propose les **noms de fonctions** courants pendant la saisie.
|
||||
|
||||
`Tab`/`Maj+Tab` et les flèches circulent entre les cellules, `Maj+flèches` étend
|
||||
la sélection, `Entrée` valide, `Maj+Entrée` insère un saut de ligne **dans** la
|
||||
cellule, `F2` ouvre la cellule en édition, `Suppr` vide la sélection,
|
||||
`Échap` restaure la valeur d'origine ; `Origine`/`Fin` vont au bord de la ligne,
|
||||
`Ctrl+Origine`/`Ctrl+Fin` aux coins de la feuille affichée,
|
||||
`PgPréc`/`PgSuiv` font défiler d'un écran, `Ctrl+flèches` saute au bout de la
|
||||
plage de données, `Ctrl+A` sélectionne toute la feuille affichée et `Ctrl+S`
|
||||
enregistre. Tant que la cellule n'est pas en cours d'édition, `Suppr`
|
||||
efface la sélection plutôt qu'un caractère.
|
||||
|
||||
**Presse-papiers de plage** — copier, couper et coller un **bloc** de cellules
|
||||
(`Ctrl+C`, `Ctrl+X`, `Ctrl+V`, ou les entrées correspondantes du menu
|
||||
contextuel) : coller un bloc copié ici **ou depuis Excel** remplit la plage à
|
||||
partir de la cellule active et la laisse sélectionnée. Le collage est du
|
||||
**texte** (formules et valeurs recopiées telles quelles) et reste **annulable** ;
|
||||
« couper » efface la source après le collage (un collage sur place ne l'efface
|
||||
pas). Un bloc plus large que la grille affichée est tronqué, avec un message.
|
||||
|
||||
**Annuler / rétablir** — `Ctrl+Z` (ou le bouton **Annuler** du ruban) revient
|
||||
sur les dernières éditions de cellules, `Ctrl+Maj+Z` / `Ctrl+Y` les rétablit.
|
||||
|
||||
**Tri, filtre, recherche, export** — chaque colonne se trie (ascendant /
|
||||
descendant, info-bulle : le tri s'applique à l'affichage seul), les lignes
|
||||
se filtrent, la recherche (`Ctrl+F` du panneau) va de correspondance en
|
||||
correspondance, et la feuille s'exporte en CSV. Rien de tout cela ne
|
||||
modifie le classeur.
|
||||
**Sélection et menu contextuel** — cliquer une cellule l'active, **glisser**
|
||||
ou `Maj+clic` sélectionne une plage (affichée dans la zone Nom, ex. `A1:B3`),
|
||||
et cliquer un **en-tête** sélectionne toute la ligne ou colonne. Un **clic
|
||||
droit** (ou un **appui long** sur mobile) ouvre un menu : copier, couper,
|
||||
coller, insérer/supprimer une ligne ou une colonne, trier A→Z / Z→A, effacer le
|
||||
contenu. Chaque entrée porte une **icône** ; le menu se referme dès qu'il perd
|
||||
le focus (clic ailleurs, `Échap`) et se parcourt au clavier (`↑`/`↓`).
|
||||
|
||||
**Tri, filtre, recherche, export** — le tri (ascendant / descendant) s'applique
|
||||
depuis le menu contextuel et n'affecte que l'affichage ; les lignes se filtrent et
|
||||
la recherche (`Ctrl+F` du panneau) parcourt **toutes les feuilles** : le compteur
|
||||
indique le nombre de feuilles concernées et passer sur une correspondance
|
||||
**active l'onglet** qui la contient.
|
||||
|
||||
La sortie est rassemblée dans un seul menu **Fichier**, placé à droite des
|
||||
onglets de feuilles, juste avant le bouton **Enregistrer**. Il propose cinq
|
||||
actions, toujours sur le **contenu affiché** (et jamais sur les valeurs
|
||||
calculées en cache) :
|
||||
|
||||
- **Télécharger le fichier** — le classeur d'origine, tel qu'il est sur le
|
||||
disque ;
|
||||
- **CSV** — exporte la **sélection** quand une plage de plusieurs cellules est
|
||||
active (le nom du fichier reprend la plage, ex. `Fruits-A1B2.csv`), sinon la
|
||||
feuille entière ;
|
||||
- **Markdown** et **HTML** — tableau markdown ou document HTML autonome,
|
||||
mêmes règles de sélection ;
|
||||
- **Imprimer** — imprime la feuille ou la sélection seule, sans le ruban ni
|
||||
les panneaux de l'application.
|
||||
|
||||
Le menu se ferme au clic extérieur, à la perte de focus, avec `Échap` ou au
|
||||
défilement, et se parcourt aux flèches. Rien de tout cela ne modifie le
|
||||
classeur.
|
||||
|
||||
**Structure** — le menu **Structure** de la barre d'outils ajoute,
|
||||
renomme, duplique ou supprime une feuille, et insère/supprime des lignes ou
|
||||
colonnes autour de la cellule active (`PUT …/xlsx/structure`, backup
|
||||
automatique et confirmation, comme pour l'édition des cellules).
|
||||
|
||||
**Styles et mise en page** — la lecture restitue couleurs de police et de
|
||||
fond, gras/italique/souligné, alignements, cellules fusionnées et volets
|
||||
figés ; l'ancrage de la zone figée est conservé au défilement.
|
||||
**Mise en forme** — le bouton **Mise en forme** ouvre un menu qui agit sur la
|
||||
**sélection courante** (une cellule ou une plage), repris aussi dans la barre
|
||||
d'outils (gras, italique, barré, souligné, taille, couleurs, bordures,
|
||||
alignements, retour ligne, rotation) :
|
||||
|
||||
- **caractère** — gras, italique, souligné, barré, taille de police,
|
||||
effacer la mise en forme ;
|
||||
- **alignement** — gauche, centré, droite, justifié (+ vertical, retour à la
|
||||
ligne, rotation) ;
|
||||
- **bordures** — toutes, extérieures, aucune ;
|
||||
- **couleurs** — couleur de police et couleur de fond, via un **panneau façon
|
||||
Google Sheets** : *Réinitialiser*, une palette de 80 pastilles (une ligne de
|
||||
gris du noir au blanc, puis huit teintes du clair au foncé), une section
|
||||
**Standard** de huit couleurs prédéfinies et une section **Personnalisé**.
|
||||
Un clic sur une pastille referme le panneau et applique la couleur à la
|
||||
cellule ou à la plage sélectionnée. Le bouton **+** ouvre le sélecteur du
|
||||
système pour une teinte libre. « Mise en forme conditionnelle » et
|
||||
« Couleurs en alternance » sont annoncées mais pas encore disponibles ;
|
||||
- **format de nombre** — général, nombre, pourcentage, devise, date, texte ;
|
||||
- **structure** — fusionner / défusionner les cellules, figer / libérer les
|
||||
volets, largeur de colonne, hauteur de ligne (fusionner exige une vraie
|
||||
plage).
|
||||
|
||||
L'écriture passe par `PUT …/xlsx/style`, avec les mêmes garanties que l'édition
|
||||
des cellules : backup automatique, écriture atomique, confirmation si
|
||||
l'opération détruirait des éléments non préservables (graphiques, valeurs
|
||||
calculées en cache…) et **détection d'un écrivain externe** (`If-Match` →
|
||||
message « Réessayer »). Un `.csv` ne portant pas de mise en forme, le bouton
|
||||
n'y est pas proposé (il est également absent d'une grille en lecture seule).
|
||||
La lecture restitue par ailleurs les couleurs, polices, cellules fusionnées et
|
||||
volets figés du fichier ; l'ancrage de la zone figée est conservé au défilement.
|
||||
Les **commentaires** se lisent (pastille) et s'éditent (mini-barre ou menu ⋮) ;
|
||||
les liens s'insèrent comme formules `=LIEN(url;libellé)` (ouvr. `Ctrl+clic`).
|
||||
Restent hors périmètre : validation de données, mise en forme conditionnelle,
|
||||
graphiques et tableaux croisés dynamiques.
|
||||
|
||||
**Formats de fichiers** — `.xlsm` s'édite comme un `.xlsx` et ses
|
||||
**macros sont préservées** à l'enregistrement ; `.xls` et `.ods` s'affichent
|
||||
en **lecture seule** ; un `.csv` s'ouvre dans la même grille et se réécrit
|
||||
conformément à la RFC 4180 (les guillemets et séparateurs sont
|
||||
échappés).
|
||||
**macros sont préservées** à l'enregistrement (y compris le chargement des
|
||||
lignes au-delà du plafond) ; `.xls` et `.ods` s'affichent en **lecture
|
||||
seule** ; un `.csv` s'ouvre dans la même grille et se réécrit conformément à
|
||||
la RFC 4180 (les guillemets et séparateurs sont échappés). Le **séparateur
|
||||
du CSV est détecté** (`;`, `,` ou tabulation) à la lecture et **réutilisé à
|
||||
l'enregistrement** : un fichier exporté par Excel en français (point-virgule)
|
||||
s'affiche donc en colonnes distinctes et le reste après édition.
|
||||
|
||||
**Tableau de bord** — le bouton **Tableau de bord** ouvre un **inspecteur
|
||||
latéral droit** (la grille reste visible à côté) qui liste les plages
|
||||
@@ -248,8 +403,13 @@ numériques) avec quelques chiffres clés. Cliquer une **plage nommée**
|
||||
sélectionne sa première cellule dans la grille, et le panneau est
|
||||
**redimensionnable**. L'en-tête de l'inspecteur offre
|
||||
aussi un accès direct à l'**assistant IA**, qui peut ensuite exploiter ces
|
||||
plages (outils `list_xlsx_sheets`, `xlsx_to_markdown`, `update_xlsx_cells`,
|
||||
`append_xlsx_rows`).
|
||||
plages. Ses outils couvrent désormais les **trois formats édités**
|
||||
(`.xlsx`, `.xlsm`, `.csv`) : `list_xlsx_sheets` et `xlsx_to_markdown` pour lire,
|
||||
`search_workbook` (recherche dans toutes les feuilles, comptée par feuille),
|
||||
`analyze_range` (agrégats — nombre, somme, moyenne, min, max — d'une plage A1),
|
||||
`update_xlsx_cells` et `append_xlsx_rows` pour modifier, et
|
||||
`edit_xlsx_structure` pour la structure (ajouter/renommer/dupliquer/supprimer
|
||||
une feuille, insérer/supprimer des lignes ou des colonnes).
|
||||
|
||||
### Limites
|
||||
|
||||
@@ -257,14 +417,35 @@ plages (outils `list_xlsx_sheets`, `xlsx_to_markdown`, `update_xlsx_cells`,
|
||||
sous une feuille plus grande, le bouton **« Charger la suite »** (ou le
|
||||
défilement vers le bas du tableau) ajoute les lignes suivantes par
|
||||
fenêtres de 500 — elles deviennent aussitôt éditables et
|
||||
sauvegardables.
|
||||
sauvegardables. Le chargement paresseux est **vertical uniquement** :
|
||||
l'axe des colonnes reste tronqué à 40 (les colonnes au-delà ne sont ni
|
||||
affichées ni exportées).
|
||||
- Un **format de nombre personnalisé** (devise, pourcentage…) est signalé
|
||||
par une police à chasse fixe, mais la valeur reste affichée brute.
|
||||
- L'application de **styles** depuis la visionneuse (mettre en gras,
|
||||
colorer) n'est pas proposée — seuls les styles existants sont rendus.
|
||||
par une police à chasse fixe à la lecture ; depuis le bouton **Mise en
|
||||
forme**, appliquer un format ne change que le format de la cellule, **pas**
|
||||
la valeur affichée (aucun recalcul n'est fait, cf. les limites d'export
|
||||
ci-dessous).
|
||||
- `.xls` et `.ods` restent en lecture seule (convertir vers `.xlsx` pour
|
||||
éditer) ; les macros d'un `.xlsm` sont conservées mais ne s'exécutent
|
||||
pas dans ObsiGate.
|
||||
- La **poignée de recopie** (fill), la multi-sélection `Ctrl+clic` et le
|
||||
glisser-déposer de lignes/colonnes ne sont pas proposés ; un collage de
|
||||
plusieurs cellules s'annule **cellule par cellule** (`Ctrl+Z` répété).
|
||||
- Les exports (CSV, Markdown, HTML, impression) reflètent ce qui est **affiché** :
|
||||
une feuille tronquée s'exporte tronquée, et les formules sortent telles
|
||||
qu'enregistrées (aucune valeur calculée n'est recalculée). Un classeur reste
|
||||
la source de vérité : utilisez **Charger la suite** pour exporter au-delà du
|
||||
plafond.
|
||||
- **Moteur de formule local** : ObsiGate calcule à l'écran l'arithmétique, les
|
||||
références, les plages et une trentaine de fonctions en français et en
|
||||
anglais (`SOMME`, `MOYENNE`, `SI`, `LIEN`…). Une saisie commençant par `=`
|
||||
ou `@` reste stockée comme **texte** (garde anti-DDE) tant que le bouton
|
||||
`f(x)` n'est pas activé ; l'enregistrement vous le signale par un message.
|
||||
Les fonctions non prises en charge affichent la valeur mise en cache par
|
||||
Excel quand elle existe. Excel reste la référence pour les valeurs calculées.
|
||||
- L'**annulation** couvre l'édition, l'effacement, le tri/filtre et les
|
||||
actions de structure ; en revanche une **suppression** (feuille, ligne,
|
||||
colonne) n'est pas annulable, faute d'inverse.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+51
-1
@@ -201,6 +201,25 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
|
||||
| *BUG-092* | Les tests réseau dépendent du DNS réel du runner : `test_worker_failure_maps_to_tool_error` échoue en `dns_error` au lieu d'atteindre le worker Playwright mocké, et le job CI `test` rougit de façon intermittente | 🟢 corrigé | P1 | CI / tests | IA | `tests/test_webrender.py`, `tests/test_web_tools.py` | Sur un runner au DNS instable : `pytest tests/test_webrender.py -k test_worker_failure_maps_to_tool_error` → `assert 'dns_error' == 'render_unavailable'` | Fixture `no_dns` mockant les **deux** références du garde SSRF `_assert_public_http_url` (celle de `backend/tools/web.py` et celle importée dans le namespace de `backend/tools/webrender.py`, ligne 30 — la seconde avait d'abord échappé au correctif). Les tests de garde SSRF n'utilisent pas la fixture et continuent de traverser le vrai garde | Le garde est appelé par `fetch_url` **avant** le traitement ; seule la couche httpx était mockée. Contre-preuve : DNS coupé globalement (`socket.getaddrinfo` → `gaierror`) → avant 1 échec, après **1474 passed / 6 skipped** |
|
||||
| *BUG-093* | Le job CI `security` échoue : `pip-audit` bloque sur deux DoS de ressources dans `pypdf` 6.16.0 (PYSEC-2026-3910, PYSEC-2026-3911) — et le plancher `pypdf>=4.0` ne les corrigeait pas, car l'image Act du runner embarque 6.16.0 *préinstallé* dans sa toolcache Python (`Requirement already satisfied` ⇒ jamais mis à niveau) | 🟢 corrigé | P0 | CI / sécurité | IA | `backend/requirements.txt`, `.gitea/workflows/ci.yml`, `tests/test_ci_workflow.py` | Run Gitea #1660, job `security` : `Found 2 known vulnerabilities, ignored 2 in 1 package` → `pypdf 6.16.0 PYSEC-2026-3910 6.16.1` / `PYSEC-2026-3911 6.16.1` | Plancher `pypdf>=6.16.1` (correctif des deux advisories), commenté pour expliquer la contrainte de la toolcache. Ajout de `tests/test_ci_workflow.py::TestDependencySecurityFloors`, qui verrouille les planchers de sécurité (`pypdf`, `pyjwt`) et interdit qu'ils retombent sous le correctif | Les deux advisories sont des **consommations de ressources non contrôlées** (PDF à outlines multiples ou à nombreux XForm réutilisés) et sont donc **atteignables** par ObsiGate, dont `backend/pdf_reader.py` extrait le texte et parcourt les outlines de PDF fournis par l'utilisateur. Contre-preuve : plancher remis à `>=4.0` → le garde-fou échoue. pip-audit local : 6.16.1, 6.16.2 et 6.19.0 sans vulnérabilité connue. Correction découverte en lisant le log du job (`/actions/runs/1660/jobs/5541/logs`, accessible sans token) — le log de l'étape Semgrep collé précédemment datait d'un run antérieur |
|
||||
| *BUG-094* | Feuille `.xlsx` vide ou nouvellement ajoutée : impossible d'y saisir une valeur et d'y insérer une ligne/colonne — la feuille s'affiche « Feuille vide » sans aucune cellule | 🟢 corrigé | P1 | tableur Excel / UX | IA | `backend/xlsx_reader.py::render_sheets`, `frontend/js/viewer.js::renderXlsxViewer` | Ajouter une feuille (`PUT …/xlsx/structure` `sheet_add`) puis tenter de saisir A1 ou d'insérer une ligne/colonne | `render_sheets()` remplace une grille vide par un quadrillage vierge 20×8 (constantes `EMPTY_SHEET_ROWS`/`EMPTY_SHEET_COLS`) aux vraies coordonnées A1 ; la visionneuse retombe sur `parseRef(activeRef) || {row:1,col:1}` pour que le menu Structure propose toujours insérer/supprimer ligne et colonne. Contre-preuve : `TestXlsxDisplay::test_empty_sheet_renders_an_editable_blank_grid` (sans le correctif : « Feuille vide » sans `data-cell`) | Le classeur n'était pas en cause : seule la **représentation HTML** était vide, donc aucun `td` à sélectionner → aucune cellule active → aucune action de structure possible. Vérifié : `test_xlsx_viewer.py` 59 passed, `xlsx-viewer.test.mjs` 52/52 |
|
||||
| *BUG-095* | Le job CI `security` échoue : `pip-audit` bloque sur CVE-2026-102274 dans `pyjwt` 2.13.0 (correctif 2.14.0) — le plancher `>=2.13.0` (BUG-091) est désormais sous le dernier correctif | 🟢 corrigé | P1 | CI / sécurité | IA | `backend/requirements.txt`, `.gitea/workflows/ci.yml`, `tests/test_ci_workflow.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | Run Gitea du push v2.44.0, job `security` : `Found 1 known vulnerability, ignored 2 in 1 package` → `pyjwt 2.13.0 CVE-2026-102274 2.14.0` | Plancher `pyjwt[crypto]>=2.14.0` (au-dessus de la version préinstallée de la toolcache du runner, sinon `pip` répond « already satisfied »). Garde-fou `TestDependencySecurityFloors` : `FLOORS["pyjwt"]` porté à `(2, 14, 0)` — contre-preuve : plancher remis à `2.13.0` → test rouge. Commentaire de la job `security` mis à jour | Le plancher de BUG-091 (2.13.0) corrigeait PYSEC-2026-178 mais est lui-même vulnérable depuis. Même mécanisme que BUG-093 (pypdf) : un plancher de sécurité doit rester au-dessus du dernier correctif. Vérifié : `test_ci_workflow.py` 9 passed ; pip-audit local OK (pyjwt 2.15.1 ≥ 2.14.0) |
|
||||
| *BUG-096* | [🟡 IMPORTANT] Enregistrer un `.csv` depuis la visionneuse échoue : `TypeError` sur `sheets[…].name` (aucun `PUT …/csv/save` émis) | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/viewer.js` (`renderXlsxViewer`, construction des jobs de sauvegarde) | Ouvrir un `.csv` dans ObsiGate, modifier une cellule, cliquer **Enregistrer** | `frontend/js/viewer.js` : le job de sauvegarde ne lit plus le nom de feuille dans `xlsx_sheets` (absent du payload CSV — l'origine du `TypeError`) — repli feuille → `data.title`/`data.path`. Test JSDOM « saving an edited csv PUTs /csv/save (payload without xlsx_sheets) ». Vérifié : suite 1490 passed / 6 skipped, `xlsx-viewer.test.mjs` 61/61 | Défaut #156 A1. En mode CSV la réponse de lecture n'a pas de `xlsx_sheets` (`backend/routers/files_read.py`) : `sheets` est vide et `sheets[Number(panel.dataset.sheet)].name` lève un `TypeError` **avant** le `try`, donc aucun `PUT /api/file/{vault}/csv/save` n'est émis (le service `save_csv_cells()` et l'endpoint sont, eux, corrects). **Reproduit en JSDOM le 2026-09-29** (harnais de `tests/frontend/xlsx-viewer.test.mjs`) : `Cannot read properties of undefined (reading 'name')`, 0 requête d'API enregistrée. Analyse et critères : `docs/features/xlsx-editor-completeness.md` |
|
||||
| *BUG-097* | [🟡 IMPORTANT] Feuille `.xlsm` tronquée : « Charger la suite » échoue en **415** (`GET …/xlsx/sheet` n'accepte que `.xlsx`) alors que le `.xlsm` est éditable | 🟢 corrigé | P1 | ⚙️ backend + 🔌 api | IA | `backend/routers/files_read.py` (`api_file_xlsx_sheet`), `frontend/js/viewer.js` (`wireLazyRows`) | Ouvrir un `.xlsm` de plus de 500 lignes puis cliquer le pied « Charger la suite » (ou approcher du bas du tableau) | `backend/routers/files_read.py` : `GET …/xlsx/sheet` accepte `.xlsx` **et** `.xlsm` (autres formats → 415 inchangé). Tests `TestXlsmEditable::test_sheet_window_is_served_for_xlsm` + `test_sheet_window_still_refuses_other_formats` | Défaut #156 A2. `.xlsm` est servi éditable (`files_read.py:491-512`, pas de `xlsx_readonly`) donc la visionneuse câble le chargement paresseux, mais `api_file_xlsx_sheet` refuse tout ce qui n'est pas `.xlsx` (`files_read.py:272`). Analyse et critères : `docs/features/xlsx-editor-completeness.md` |
|
||||
| *BUG-098* | [🟡 IMPORTANT] Délimiteur CSV figé `,` : un `.csv` français (`;`) s'affiche en une seule colonne et se réécrit dans un autre format | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/xlsx_reader.py` (`render_csv_table`, `delimiter=","` par défaut), `backend/routers/files_read.py:551`, `backend/services/mutations.py` (`save_csv_cells`) | Ouvrir un CSV `;` (export Excel FR) dans ObsiGate : une seule colonne ; éditer une cellule puis enregistrer : le fichier est réécrit en `,` | `backend/xlsx_reader.py` (`sniff_csv_delimiter`) + `backend/services/mutations.py::save_csv_cells` : délimiteur détecté et **réutilisé**. Tests `TestCsvDelimiter` + lecture/écriture/guillemets point-virgule | Défaut #156 A3. `render_csv_table(raw)` est appelé sans délimiteur et `save_csv_cells()` re-parse en `,`, alors que l'export CSV de la visionneuse écrit en `;`. Traitement prévu : détection `;`/`,`/tab partagée lecture/écriture/export. Analyse : `docs/features/xlsx-editor-completeness.md` |
|
||||
| *BUG-099* | [🔵 MINEUR] Sonde de perte plafonnée (8 Mo, budget global) : risque de perte **silencieuse** des valeurs calculées en cache au-delà du budget | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/xlsx_reader.py` (`_MAX_PROBE_BYTES`, `_has_cached_formulas`, `inspect_workbook`) | Constituer un classeur dont le XML de feuille dépasse 8 Mo **avant** la première formule cachée, puis l'éditer : la garde 409 `xlsx_lossy_content` ne se déclenche pas | `backend/xlsx_reader.py` : budget **par feuille** (4 Mo) + plafond global (32 Mo) ; `_scan_cached_formulas()` renvoie `(found, unverified)` et `inspect_workbook()` ajoute `cached_values_unverified` (libellé i18n FR/EN). Contre-preuve : budget épuisé → signalé au lieu de `[]`. Tests `TestXlsxCachedValueProbe` (3) | Défaut #156 A4, **analyse statique (non reproduit)**. Le budget est partagé entre toutes les feuilles : au-delà, `cached_values` n'est pas détecté et l'écriture détruit ces valeurs sans avertissement (risque n°1 de #153). Traitement prévu : budget par feuille + signal d'incertitude pour que la garde reste prudente. Analyse : `docs/features/xlsx-editor-completeness.md` |
|
||||
| *BUG-100* | Retour à l'accueil incomplet : le clic sur le titre perd la section « Raccourcis & Astuces » et laisse la recherche globale ainsi que le filtre de la sidebar actifs | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/viewer.js` (`showWelcome`), `frontend/js/legacy.js` (`goHome`) | Ouvrir un fichier (le dashboard est détruit), puis cliquer le titre ObsiGate | `viewer.js` : `#quick-help` capturé au chargement du module et réinjecté dans le template reconstruit de `showWelcome()` ; `legacy.js::goHome` clique `#search-clear-btn` (chips + barre de résultats) et `#sidebar-filter-clear-btn` avant d'afficher l'accueil | **Cause racine :** `showWelcome()` reconstruit `#dashboard-home` depuis un template qui n'embarque pas le bloc `#quick-help` présent dans `index.html` — il ne disparaissait qu'après la première reconstruction (ouverture de fichier, puis retour). Livré avec #158 A3 |
|
||||
| *BUG-101* | Raccourcis d'onglets déclarés deux fois : un seul `Ctrl+W` fermait deux onglets (le second fermait celui ré-activé par la fermeture précédente) et `Ctrl+Tab` sautait un onglet | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/ui.js` (bloc « Keyboard shortcuts for tabs » dupliqué en fin de fichier) | Ouvrir deux onglets puis `Ctrl+W` une fois | Suppression du second bloc `document.addEventListener("keydown", …)` dupliqué (fin de `ui.js`) — un seul listener reste | Exposé par #158 : le second onglet n'était plus jamais vide auparavant (la 2ᵉ fermeture tombait sur `_activeTabId === null` et devenait sans effet). Vérifié : repro Playwright (`closes: ["fichier", "nav"]` avant → `["fichier"]` après) + suite E2E |
|
||||
| *BUG-102* | Menu contextuel qui se referme ~10 ms après son ouverture : la reflow des icônes (remplacement `<i>` → `<svg>` par lucide) déclenche un événement `scroll` capturé par `ContextMenuManager` qui referme le menu | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/js/ui.js` (`ContextMenuManager.init/show`) | Clic droit sur un fichier de la page de navigation (ou de l'arbre) juste après un rendu d'icônes | `show()` mémorise `_shownAt` ; le listener `scroll` (capture) ignore un déclenchement dans les 250 ms suivant l'ouverture | Exposé par #158 A5 (test E2E `nav-tab.spec.js` : menu résolu « hidden » alors que 6 items étaient construits). Vérifié : instrumentation Playwright (display block conservé après le scroll) + suite E2E |
|
||||
| *BUG-103* | Mobile : le bas du sidebar de navigation est masqué par la barre d'outils du bas (`z-index` 200 < 900) | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/style.css` (règle `.sidebar` du bloc `@media (max-width: 768px)`) | Vue mobile (≤768 px), sidebar ouvert depuis « Explorateur » | Passé à `z-index: 950` (au-dessus de `.mobile-toolbar`, 900) | Exposé après #158 (retour utilisateur). Vérifié : `elementFromPoint` Playwright (393×851) + test source `mobile sidebar over toolbar` (`unit.test.mjs`) |
|
||||
| *BUG-104* | Desktop : toutes les commandes Tauri invoquées depuis la page backend (`http://127.0.0.1`) rejetées « not allowed by ACL » — le bouton « Choisir mon dossier » du wizard ne fait rien | 🟢 corrigé | P1 | 🖥️ desktop | IA | `desktop/permissions/commands.toml` (nouveau), `desktop/capabilities/default.json`, `desktop/build.rs` | Installeur 2.49.2 : clic sur « Choisir mon dossier » → aucune fenêtre ; console `[desktop] invoke(pick_vault_folder) failed: Command … not allowed by ACL` (capturée via CDP, WebView2 `--remote-debugging-port`) | Manifeste de permissions applicatives créé (`allow-app-commands`, 19 commandes) et référencé par la capability : Tauri v2 ACL gate toute commande invoquée depuis une origine *remote*, y compris celles de l'`invoke_handler` | `cargo test` 25 passed (nouveau garde-fou `test_frontend_invokes_are_acl_allowed`) + vérif live CDP : `get_version`→2.49.2, clic → fenêtre native « Select Folder », parcours complet ajout de vault terminé |
|
||||
| *BUG-105* | Desktop : crash 2-15 s après l'ouverture de la fenêtre (`APPCRASH c0000374`, heap corruption détectée dans ntdll) — absent de v2.0.0, apparu en 2.49.2 | 🟢 corrigé | P1 | 🖥️ desktop | IA | `desktop/src/jumplist.rs` (`set_link_title`) | Installeur 2.49.2 : fenêtre visible quelques secondes puis fermeture (3/3), Event Log Windows `APPCRASH obsigate-desktop.exe … c0000374`, rien dans stderr | `set_link_title` (#77) construisait un `PROPVARIANT VT_LPWSTR` pointant un buffer heap Rust et le confiait au property store du shell, qui le libère avec l'allocateur Windows → corruption du tas ; fonction supprimée (`SetDescription` fournit déjà le libellé) | Bisect 3 états (jumplist off = stable, titre off = stable, d'origine = crash) ; run 9 min sans crash + sortie propre `EXIT=0`, zéro événement WER (avant : 3 crashes ≤20 s) |
|
||||
| *BUG-106* | Page blanche sur `/docs` (Swagger UI) : la CSP `script-src` sans `unsafe-inline` (#87 T5c) refuse le script inline d'init de FastAPI, jamais noncé | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/main.py` (`SecurityHeadersMiddleware`) | Ouvrir `https://og.dracodev.net/docs` : HTML servi (200) mais aucune UI, console `Refused to execute inline script … Content Security Policy` | Injection du nonce dans le HTML de `/docs` et `/redoc` depuis le middleware, via le helper existant `inject_csp_nonce` ; corps décompressé/recompressé (GZipMiddleware est plus proche de la route) | `pytest tests/test_csp_nonce.py` (3 tests ajoutés : nonce sur `/docs`, `/redoc` sans injection, regex sur bundle externe) + 45 passed sur les 3 suites sécurité ; ruff/mypy 0 |
|
||||
| *BUG-107* | Drag & drop de fichiers depuis l'Explorateur inutilisable sur desktop : Tauri/wry pose son propre `IDropTarget` par-dessus de celui du WebView2 et aucun événement natif n'est écouté — #89 ne fonctionnait que sur le web | 🟢 corrigé | P1 | 🖥️ desktop | IA | `desktop/tauri.conf.json` (`create: false`), `desktop/src/main.rs` (`WebviewWindowBuilder::…disable_drag_drop_handler`) | App desktop : glisser un fichier de l'Explorateur sur la fenêtre → aucun survol, aucun dépôt — les gestionnaires HTML5 de `dragdrop.js` ne sont jamais déclenchés | Création manuelle de la fenêtre avec `disable_drag_drop_handler()` (doc Tauri : « required to use HTML5 drag and drop APIs on the frontend on Windows ») ; `create: false` évite la double création par la boucle Tauri | `cargo test` 26 passed (nouveau garde-fou `test_window_created_without_tauri_drag_drop_handler` : les deux marqueurs obligatoires) + lancement OK (fenêtre unique, boot normal) ; dépôt réel à valider par l'utilisateur |
|
||||
| *BUG-108* | Tableur : le « Coller » du menu contextuel ne fait rien (copier-coller par menu muet) | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/xlsx/context-menu.js` (`closeContextMenu`) | Ouvrir un `.xlsx`, clic droit « Copier » sur une plage, clic droit cible puis « Coller » → rien n'est collé (le menu se ferme sans action) | `closeContextMenu()` retirait le nœud du DOM **avant** de remettre `_menu` à `null` : retirer le menu qui tient le focus émet `focusout` en synchrone, qui rappelle `closeContextMenu()` → second `remove()` sur un nœud déjà démonté → `NotFoundError` qui avorte le handler de l'action « Coller » du viewer. Introduit par #179 (fermeture au focus). Correctif : `_menu = null` avant `menu.remove()` | Reproduit en local sur HEAD (e2e « coller une plage » : attendu `Date`, obtenu la valeur datée) puis vert après fix : E2E `-g "coller une plage"` 1/1, `xlsx-menus` 11/11, `ai-quick-actions` 13/13 |
|
||||
| *BUG-109* | Chat : dans la tuile d'une URL (link preview) l'image ne s'affiche jamais | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/file_chat.py` (`build_preview`, `_proxy_image`) | Poster une URL avec `og:image` dans le chat général → la carte s'affiche mais sans vignette (console : `Refused to load the image … Content Security Policy`) | La CSP de réponse (`img-src 'self' data: blob:`) interdit toute image externe, et les `og:image` **relatives** étaient résolues contre l'origine d'ObsiGate (404). La vignette est désormais **téléversée à l'envoi** dans `chat_uploads` et servie same-origin (`/api/chat/attachment/<uuid>`) : résolution `urljoin` contre la page, garde SSRF réutilisée, plafond 2 Mo, allow-list d'extensions ; échec du téléchargement → carte conservée sans vignette (jamais de message perdu) | Tests `test_preview_image_is_proxied_same_origin` + `test_preview_image_failure_keeps_the_card` (contre-preuve : proxy neutralisé → `image == ""`). Cartes antérieures inchangées : leur URL distante était déjà bloquée |
|
||||
| *BUG-111* | Admin : modale « Administration — Utilisateurs » sans icône de fermeture (lucide absent de admin.html) et tableau de bord admin affichant toutes les sections au lieu de la seule section sélectionnée | 2026-10-10 | Utilisateur | `frontend/admin.html` (ajout du script lucide UMD) + `frontend/js/admin.js` (`_wireSectionNav` : show/hide au lieu du scrollspy) ; suites frontend + backend vertes, vérifié sur instance locale 2020 | lucide n'était chargé que dans index.html ; le scrollspy laissait les 4 sections visibles |
|
||||
| *BUG-110* | Chat : refonte visuelle des posts — boutons discrets sous l'image, image ouverte dans un onglet de l'app, point de non-lus sur l'icône chat, notification OS hors focus, annulation **réelle** d'une suppression | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/js/filechat.js`, `frontend/js/ui.js`, `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/filechat.test.mjs` | Clic sur une image du chat → ouverture dans un onglet du navigateur au lieu du visualiseur de l'app ; boutons de pièce jointe bavards (icône + libellé) ; aucun repère visuel persistant sur l'icône chat ; aucune alerte OS quand l'app est en arrière-plan ; le bouton « Annuler » d'une suppression ne restaurait rien (copie DOM d'un message déjà supprimé côté serveur) | `TabManager.openChatImage()` (+ branche `chatImage` de `activate()`, `renderImageViewer()` accepte une `url` directe) ouvre la pièce jointe dans un **onglet** avec le visualiseur des images de vault (zoom/pan/plein écran) ; boutons **icônes seules** (24 px, `aria-label`) sous l'image, alignés à droite ; **point rouge pulsant** (`.chat-has-unread`) tant qu'il reste des non-lus, en plus du badge chiffré ; `_notifyNewMessage()` — notification OS si `document.hidden`/hors focus, permission demandée à l'ouverture du chat ; suppression **différée de 10 s** (ligne « Ce message a été supprimé » + « Annuler », DELETE envoyé à l'échéance seulement, échec réseau → le message revient) | Vérifié : `filechat.test.mjs` 25/25 (dont 6 tests #110), `validate-imports` 42 modules / 372 exports 0 erreur, `unit.test.mjs` 13/13 |
|
||||
| | | | | | | | | | |
|
||||
### TODOs techniques (améliorations / nouvelles tâches)
|
||||
|
||||
| # | Titre | Statut | Priorité | Scope | Assigné | Zone (fichier) | Cmd de repro | Correctif / Commit | Notes |
|
||||
@@ -277,7 +296,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| 2026-09-17 | BUG-065, #78 (complément) | Correction + feature | `frontend/js/excalidraw-viewer.js`, `frontend/js/utils.js`, `frontend/excalidraw-editor.html`, `tests/frontend/excalidraw-viewer.test.mjs`, `docs/features/excalidraw.md`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-065** : l'auto-save Excalidraw (débounce 2 s) déclenchait `PUT save` → SSE `index_updated` → `reloadExternalWrite` → `openFile` → recréation de l'iframe = refresh visible pendant le dessin. Auto-save retirée (`excalidraw-viewer.js` : plus de `requestSave`/`saveTimer`), sauvegarde explicite (bouton 💾 / Ctrl+S) ; `reloadExternalWrite` (utils.js) court-circuite le re-rendu si un iframe Excalidraw est ouvert sur ce fichier (attributs `data-excalidraw-vault`/`data-excalidraw-path`) ; le badge « Modified » suit désormais une signature des éléments (`id:versionNonce`) au lieu de tout `onChange` — resize/zoom/plein écran ne marquent plus le fichier modifié. **#78 (complément)** : bouton **plein écran** `#btn-fullscreen` dans la barre d'outils de l'éditeur (`requestFullscreen` sur le document de l'iframe) + iframe créée avec `allow="fullscreen" allowfullscreen`. Vérifié Playwright : bascule plein écran OK (`document.fullscreenElement` true→false), badge non modifié après bascule ; tests statiques `excalidraw-viewer.test.mjs` 12/12, validate-imports 38 modules, unit 9/9. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-17 | #78 (complément) | UI | `frontend/excalidraw-editor.html`, `docs/features/excalidraw.md`, `CHANGELOG.md` | **#78 (complément)** : la barre d'outils de l'éditeur Excalidraw passe en **colonne d'icônes** (34×34 px, SVG seuls), **collée au bord droit** (`right: 0` ; `top: 45%` ; empilement vertical), avec `title`/`aria-label`. L'icône du bouton Save est remplacée par une coche pendant 1,2 s après une sauvegarde réussie. Badge « Modifié » réduit à une pastille. Vérifié Playwright : bord droit au bord de l'iframe, haut 45 %, 4 boutons empilés ; bascule plein écran OK, cycle d'icône Save + `PUT save` observés. | 🟢 livré (en attente vérif utilisateur) |
|
||||
| 2026-09-18 | BUG-066 | Correction | `frontend/locales/fr.json`, `frontend/locales/en.json`, `tests/frontend/unit.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-066** : la table des matières de la page de configuration n'affichait aucune icône pour « Fichiers cachés » et « Partages publics ». Les libellés du sommaire proviennent de clés i18n (`config.section_hidden`, `config.section_shares`) distinctes des titres de section qui, eux, portaient déjà l'icône. Alignement : 🗂️ / 📤 en FR **et** EN. Test de non-régression : `unit.test.mjs` vérifie que **toutes** les entrées `.help-nav-link` du sommaire portent une icône dans les deux langues (17/17). Vérifié : `unit.test.mjs` 10/10, `validate-imports` 38 modules. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-18 | #105, BUG-067 | Documentation + correction | `frontend/index.html`, `frontend/js/config.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `backend/guide_export.py`, `backend/main.py`, `tests/test_guide.py`, `docs/features/guide-coverage-105.md`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **#105** : audit complet de couverture du Guide d'utilisation — 8 nouvelles sections (Architecture + diagramme Mermaid, API & intégrations, Diagrammes Mermaid & Excalidraw, Hors-ligne & synchronisation, Collaboration temps réel, Application desktop, Bibliothèque & signets, Multilingue) et compléments (recherche sémantique, MFA/WebAuthn, notifications push, exports HTML/ePub/ZIP, PDF, vue multi-panneaux, admin). Téléchargement du guide en Markdown et PDF (`GET /api/guide/download?format=md|pdf`, FR/EN, rendu par le moteur d'export existant). Guide plus large en desktop. **BUG-067** : ancre morte `#help-mobile-editor` → section dédiée créée. | 🟢 corrigé (en attente vérif utilisateur)
|
||||
| 2026-09-18 | #105, BUG-067 | Documentation + correction | `frontend/index.html`, `frontend/js/config.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `backend/guide_export.py`, `backend/main.py`, `tests/test_guide.py`, `docs/features/guide-coverage-105.md`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **#105** : audit complet de couverture du Guide d'utilisation — 8 nouvelles sections (Architecture + diagramme Mermaid, API & intégrations, Diagrammes Mermaid & Excalidraw, Hors-ligne & synchronisation, Collaboration temps réel, Application desktop, Bibliothèque & signets, Multilingue) et compléments (recherche sémantique, MFA/WebAuthn, notifications push, exports HTML/ePub/ZIP, PDF, vue multi-panneaux, admin). Téléchargement du guide en Markdown et PDF (`GET /api/guide/download?format=md|pdf`, FR/EN, rendu par le moteur d'export existant). Guide plus large en desktop. **BUG-067** : ancre morte `#help-mobile-editor` → section dédiée créée. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-18 | #105 (ajustements) | Amélioration | `frontend/index.html`, `frontend/js/config.js`, `frontend/sw.js`, `frontend/locales/{fr,en}.json`, `backend/guide_export.py`, `backend/pdf_export.py`, `Dockerfile`, `scripts/build_guide_diagrams.py`, `scripts/render_guide_diagram.mjs`, `scripts/guide_content.py`, `backend/assets/guide_diagrams/df7366a40db6a5a2.png`, `tests/test_guide.py`, `docs/features/guide-coverage-105.md`, `CHANGELOG.md` | **#105 (retour utilisateur)** : 1) boutons de téléchargement du guide passés en icônes seules (tooltips i18n conservés) ; 2) le diagramme Mermaid de la section Architecture est désormais rendu en **vraie image** dans le PDF (pipeline de pré-rendu PNG Chromium+mermaid v11, PNG commité sous `backend/assets/guide_diagrams/<sha1>.png`, résolu par `diagram_png_for()` ; le Markdown garde le fenced mermaid) ; 3) emoji du PDF rendus **en couleur** au lieu de rectangles : `fonts-noto-color-emoji` ajouté au Dockerfile + `"Noto Color Emoji"` en fin de pile de polices PDF. Vérifié : pytest 1218 (test_guide ×13), ruff/mypy 0, validate-imports 38, unit 10/10 ; PDF live conteneur 2020 : 24 pages, 0 glyphes tofu, diagramme 3568x1174 embarqué. | 🟢 livré
|
||||
| 2026-09-22 | BUG-068 | Correction | `backend/auth/router.py`, `backend/requirements.txt`, `frontend/js/auth.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_mfa.py`, `tests/frontend/mfa-settings.test.mjs` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-068** : section « 🔒 Sécurité du compte » finalisée. (1) Boutons hors thème : `config-btn-primary`/`config-btn-danger` n'existaient pas en CSS → définis depuis les variables du thème (+ états disabled). (2) QR invisible : l'image tierce était bloquée par la CSP (`img-src 'self' data: blob:`) et exposait le secret TOTP → QR SVG `data:` généré en local par le backend (`qr_data_url`, segno) avec repli saisie manuelle. (3) Codes de récupération perdus à la 1re activation WebAuthn → `_showRecoveryCodes(codes, targetId)` avec repli `webauthn-flow-area`. (4) Carte « Mot de passe » ajoutée (endpoint `change-password` existant, jusque-là sans UI) + échappement des libellés de clés WebAuthn. Vérifié : pytest 1241 passed / 6 skipped, ruff 0, mypy 0 (78 fichiers), `mfa-settings.test.mjs` 9/9, unit 10/10, validate-imports 39 modules. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-23 | BUG-069 | Correction | `frontend/js/auth.js`, `frontend/locales/{fr,en}.json`, `tests/frontend/mfa-settings.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-069** : login 2FA bloqué sans erreur — après user+pwd corrects, `showMfaChallenge` cherchait `.login-box` (inexistant dans `index.html`, marquage réel `#login-screen > .login-card`) et faisait un `return` silencieux : page de login figée, aucune erreur. Correctif : montage dans `.login-card` (repli `#login-screen`) + erreur visible `mfa.challenge_unavailable` (FR/EN) si le point de montage manque. **Reproduit au navigateur** (Playwright, instance Docker `obsigate-test`, compte jetable avec TOTP) : avant → challenge jamais affiché ; après → challenge affiché, code erroné → erreur, code valide (verify 200) → app. Tests : `mfa-settings.test.mjs` 11/11 (+2 ancrage DOM), unit 10/10, validate-imports 39 modules. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
@@ -296,6 +315,37 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| 2026-09-27 | BUG-083 | Correction CI | `.gitea/workflows/ci.yml`, `tests/test_ci_workflow.py` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-083** : job `security` rouge — le runner Gitea Act tronque naïvement au premier `#` (même entre guillemets) : `echo "... see #87)"` devenait une citation non fermée (`unexpected EOF while looking for matching '"'"`, `/var/run/act/workflow/4` ligne 2). Seul `run:` du workflow avec un `#` (les `#` des noms d'étapes Bandit/Npm audit sont inoffensifs, ces étapes passent). Correctif : echo sans `#` (réf `#87` en commentaire YAML). Garde-fou `test_ci_workflow.py` (aucun `#` dans le code des `run:`, `upload.test.mjs` verrouillé en étape JSDOM — BUG-082) + contre-preuve sur l'ancien `ci.yml`. Vérifié : 56 passed. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-27 | BUG-081 | Correction | `backend/auth/router.py`, `tests/test_mfa.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-081** : `GET /api/auth/mfa/status` répondait 500 quand l'auth est désactivée — le pseudo-user `anonymous` n'a aucune entrée en store (`get_user` → `None`, `AttributeError` sur `user.get`). Garde `user is None` → payload « MFA désactivé ». Test `TestMfaStatusAuthDisabled` (échoue en 500 sans le correctif). Vérifié : `test_mfa.py` 32 passed, ruff/mypy 0. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-27 | #87 T6, T7, T8 | Sécurité (fin #87) | `backend/requirements.txt`, `backend/{render,export}.py`, `backend/tools/documents.py`, `backend/auth/router.py`, `backend/main.py`, `semgrep-rules/` (nouveau), `.gitea/workflows/ci.yml`, `tests/test_i18n_parity.py` (nouveau), `tests/test_auth_api.py`, `tests/test_security_headers.py`, `docker-compose.yml`, `.env.example`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **T6** : dépendances qualifiées (mistune 3.3.3, multipart 0.0.31, weasyprint 70, mcp 1.28.1, fastapi 0.141.1 + starlette 1.7.0, setuptools 84 ; `cast` mistune 3 sites) — suite 1359 passed, ruff/mypy 0, **`pip-audit` bloquant 0 vuln** (exception ecdsa/Minerva documentée : sans fix, HS256 only). **T7** : **semgrep bloquant** local 8 règles, 0 finding (trivy écarté : réseau). **T8** : Secure auto + `X-Forwarded-Proto` (`TRUST_PROXY`), warning affiné, CORS same-origin explicite, `style-src` résiduel assumé (189+343 sites) ; TODO exemple purgé, locales FR/EN 2213 parité testée, `npm audit` 0. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-29 | BUG-094 | Correction | `backend/xlsx_reader.py`, `frontend/js/viewer.js`, `tests/test_xlsx_viewer.py`, `tests/frontend/xlsx-viewer.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-094 — une feuille vide ou nouvellement ajoutée devient éditable et manipulable.** `render_sheets()` substitue une grille vierge 20×8 (`EMPTY_SHEET_ROWS`/`EMPTY_SHEET_COLS`) quand la feuille ne porte aucune cellule, avec de vraies coordonnées A1 ; la visionneuse retombe sur `parseRef(activeRef) || {row:1,col:1}` pour que le menu Structure propose toujours insérer/supprimer ligne et colonne. Contre-preuve : `TestXlsxDisplay::test_empty_sheet_renders_an_editable_blank_grid` (sans le correctif : « Feuille vide » sans `data-cell`). Vérifié : `test_xlsx_viewer.py` 59 passed, `xlsx-viewer.test.mjs` 52/52. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-29 | BUG-095 | Correction CI / sécurité | `backend/requirements.txt`, `.gitea/workflows/ci.yml`, `tests/test_ci_workflow.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **Le job `security` repasse au vert** : `pyjwt` 2.13.0 est vulnérable (CVE-2026-102274, correctif 2.14.0) et le plancher de BUG-091 (`>=2.13.0`) était donc sous le dernier correctif. Plancher porté à `pyjwt[crypto]>=2.14.0` (au-dessus de la toolcache du runner, sinon `pip` répond « already satisfied »), garde-fou `TestDependencySecurityFloors` mis à jour (contre-preuve : plancher remis à 2.13.0 → test rouge), commentaire de la job `security` aligné. Vérifié : `test_ci_workflow.py` 9 passed, pip-audit local OK (pyjwt 2.15.1). | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-29 | BUG-096 → BUG-099 (#156 A1-A4) | Correction | `frontend/js/viewer.js`, `frontend/locales/{fr,en}.json`, `backend/xlsx_reader.py`, `backend/services/mutations.py`, `backend/routers/files_read.py`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/test_xlsx_viewer.py`, `tests/test_xlsx_formats.py`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/features/xlsx-editor-completeness.md`, `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md`, `docs/ISSUES_TODOLIST.md` | **Les 4 défauts P0 du tableur sont corrigés.** (BUG-096) l'enregistrement d'un `.csv` depuis la visionneuse ne lit plus le nom de feuille dans `xlsx_sheets` (absent du payload CSV) : le `TypeError` partait **avant** tout appel réseau, aucun `PUT …/csv/save` n'était émis. (BUG-097) `GET …/xlsx/sheet` accepte `.xlsx` **et** `.xlsm` — le chargement des lignes au-delà du plafond fonctionne pour les classeurs macro. (BUG-098) `sniff_csv_delimiter()` détecte `;`/`,`/tabulation et `save_csv_cells()` réutilise le délimiteur : un CSV français s'affiche en colonnes distinctes et **le reste** après édition. (BUG-099) sonde de perte **par feuille** (4 Mo) + plafond global (32 Mo) et clé `cached_values_unverified` quand le budget est épuisé — plus de perte silencieuse possible des valeurs calculées. Tests : `TestXlsxCachedValueProbe` (3, contre-preuve : budget épuisé signalé au lieu de `[]`), `TestCsvDelimiter` + 3 tests CSV, fenêtre `.xlsm` (+ refus `.csv`), 1 test JSDOM CSV. Vérifié : suite **1490 passed / 6 skipped**, ruff 0, mypy 0 (102 fichiers), `xlsx-viewer.test.mjs` 61/61, validate-imports 40 modules, unit 12/12, i18n parity. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-29 | #156 A5-A7 (P1) | Fonctionnalité (sans nouveau défaut) | `frontend/js/viewer.js`, `frontend/js/xlsx/command-bar.js`, `frontend/locales/{fr,en}.json`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/features/xlsx-editor-completeness.md`, `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md` | **P1 livré** — (A5) presse-papiers de plage : copier/couper/coller un bloc TSV au clavier et au menu contextuel, presse-papiers interne + miroir système, remplissage multi-cellules, insertion **texte** échappée, débordement signalé, annulable ; (A6) clavier complet (`Ctrl+S`/`Ctrl+A`/`Suppr`/`F2`/`Ctrl+Home|End`/`Home|End`/`PgUp|PgDn`/`Ctrl+flèches`/`Maj+Entrée`) ; (A7) zone Nom éditable (« Atteindre ») + liste de fonctions. Vérifié : JSDOM `xlsx-viewer.test.mjs` 84/84 (20 nouveaux), E2E Playwright. | ✅ livré |
|
||||
| 2026-09-30 | #156 A8-A14 (P2 + P3) — clôture du backlog | Fonctionnalité (sans nouveau défaut) | `backend/services/mutations.py`, `backend/routers/files_read.py`, `backend/routers/files_write.py`, `backend/schemas.py`, `backend/xlsx_reader.py`, `backend/openapi_docs.py`, `backend/tools/{spreadsheets,schemas,labels}.py`, `frontend/js/viewer.js`, `frontend/js/xlsx/command-bar.js`, `frontend/locales/{fr,en}.json`, `frontend/style.css`, `tests/test_xlsx_styles.py`, `tests/test_xlsx_viewer.py`, `tests/test_spreadsheet_tools.py`, `tests/test_xlsx_formats.py`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/features/xlsx-editor-completeness.md`, `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md`, `README.md`, `README.fr.md` | **P2 + P3 livrés, backlog #156 clôturé** — (A8) **mise en forme en écriture** : bouton Mise en forme (gras/italique/souligné, alignements, couleurs via sélecteur natif, formats de nombre, fusion/défusion, volets figés, largeur/hauteur) et nouvelle route `PUT …/xlsx/style` (`mutate_xlsx_style` : verrou, backup, swap atomique, garde de perte, `If-Match`) ; (A9) décision « pas de moteur de formule » **annoncée dans l'UI** ; (A10) undo/redo unifié avec piles par fichier conservées au re-rendu ; (A11) export de la sélection + Markdown + HTML + impression et recherche sur **toutes** les feuilles (`n/m · k feuilles`) ; (A12) concurrence optimiste `If-Match` → **409** `conflict`/`stale_revision`, retry qui relit ; (A13) cache de `read_workbook_meta()` par `(chemin, mtime_ns, taille)` (LRU 8) ; (A14) outils IA `.xlsm`/`.csv` + `search_workbook`, `analyze_range`, `edit_xlsx_structure` (confirmation conservée). Vérifié : suite **1525 passed / 6 skipped**, ruff 0, mypy 0 (102 fichiers), JSDOM `xlsx-viewer.test.mjs` 107/107, validate-imports + unit, i18n parity 2355/2355, E2E `xlsx` 10/10 + `Split View` 37 + `XSS` 2. | ✅ livré |
|
||||
| 2026-09-29 | #156 (audit), BUG-096 → BUG-099 | Enregistrement (audit statique + 1 repro JSDOM) | `docs/ROADMAP.md`, `docs/features/xlsx-editor-completeness.md` (nouveau), `docs/ISSUES_TODOLIST.md`, `CHANGELOG.md` | **Audit de complétude de l'éditeur Excel → ouverture de l'item #156** (P0 défauts · P1 presse-papiers/clavier · P2 mise en forme/calcul/undo · P3 sortie/robustesse/perf) avec fiche dédiée. **4 défauts** enregistrés : BUG-096 (enregistrement `.csv` en `TypeError`, **reproduit en JSDOM** — `Cannot read properties of undefined (reading 'name')`, aucun `PUT …/csv/save`), BUG-097 (lazy-load `.xlsm` → 415), BUG-098 (délimiteur CSV `,` figé vs export `;`), BUG-099 (sonde de perte plafonnée à 8 Mo, risque théorique). Manques fonctionnels recensés : presse-papiers de plage, clavier complet, zone Nom éditable, mise en forme en écriture, calcul, undo/redo unifié, export/impression, concurrence optimiste, cache des métadonnées, outils IA `.xlsm`/`.csv`. **Aucun code modifié** (documentation seule). | 🔴 ouvert (à traiter) |
|
||||
| 2026-10-02 | #158 (A1-A3), BUG-100 | Fonctionnalité + correction | `frontend/js/navfacets.js` (nouveau), `frontend/js/vaulthome.js`, `frontend/js/ui.js`, `frontend/js/sidebar.js`, `frontend/js/viewer.js`, `frontend/js/legacy.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `backend/services/vaults.py`, `backend/schemas.py`, `tests/test_nav_files.py` (nouveau), `tests/frontend/navfacets.test.mjs` (nouveau), `tests/frontend/unit.test.mjs`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **A1** : `TabManager.openNav(vault, dir)` — un onglet de navigation par vault, ouvert à chaque clic répertoire de l'arbre (mode focus et mode All) sans fermer les onglets de fichiers ; la ligne vault reste un expandeur. **A2** : la vue liste les sous-répertoires cliquables et embarque le panneau de facettes **Vaults · Tags · Extensions** (mêmes classes que la page de recherche), les tris **Pertinence / Date** et le bouton **Sauver** ; `GET /api/vault/{v}/files` expose désormais les `tags` indexés (facettes calculées côté client). **A3 / BUG-100** : `showWelcome()` réinjecte `#quick-help` et `goHome()` efface recherche globale + filtre sidebar. Tests : `test_nav_files.py` 3 passed, `navfacets.test.mjs` 10/10, `unit.test.mjs` 12/12, `validate-imports` 41 modules, ruff/mypy 0. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-10-02 | #158 (A4-A6) | Fonctionnalité | `frontend/js/vaulthome.js`, `frontend/js/ui.js`, `frontend/js/pane-manager.js`, `frontend/js/legacy.js`, `frontend/js/sidebar.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/e2e/nav-tab.spec.js` (nouveau), `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/features/navigation-tab-158.md` | **A4** : `TabManager.openHome()` — le clic sur le titre ouvre la page d'accueil dans un **onglet Accueil** (icône Maison, `common.home` FR/EN) remonté en position 0 à chaque fois ; `deactivate()` conserve cet onglet actif, `pane-manager.js` a son propre `openHome` pour le split. **A5** : menus contextuels répertoires **et** fichiers de la page de navigation via `ContextMenuManager.show()` (avec `stopPropagation()`, le handler global referme sinon le menu) — listes vérifiées identiques à la sidebar ; la ligne d'un vault rouvre la page de navigation. **A6** : icône des répertoires à `var(--accent)` (couleur de l'arbre). **Correctif annexé** : l'effacement du filtre sidebar au retour à l'accueil n'est déclenché que s'il était actif (sinon `restoreSidebarTree()` repliait l'arbre). Vérifié : Playwright jetable 16/16, `nav-tab.spec.js` 4 tests, validate-imports/unit/i18n parity verts. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-10-02 | BUG-101 | Correction | `frontend/js/ui.js` | **BUG-101 — le bloc « Keyboard shortcuts for tabs » existait en double dans `ui.js`** (fin de fichier) : un seul `Ctrl+W` déclenchait **deux** `close()`, le second refermant l'onglet ré-activé par le premier — observé avec #158 quand un onglet navigation survit à la fermeture du fichier ; `Ctrl+Tab` sautait également un onglet. Bloc dupliqué supprimé (un seul listener). Vérifié : repro Playwright (`close()` appelé 2× puis 1×) et suite E2E. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-10-02 | BUG-102 | Correction | `frontend/js/ui.js` | **BUG-102 — menu contextuel refermé par sa propre ouverture** : la reflow des icônes lucide à l'affichage déclenche un `scroll` capturé qui refermait le menu ~10 ms après son apparition (constaté sur les fichiers de la page de navigation, #158 A5). `show()` mémorise `_shownAt` et le listener `scroll` ignore un déclenchement dans les 250 ms. Vérifié : instrumentation Playwright + suite E2E (`nav-tab.spec.js`). | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-10-02 | BUG-103 | Correction | `frontend/style.css` | **BUG-103 — sidebar mobile sous la barre d'outils du bas** : en mobile le `.sidebar` (`z-index: 200`) passait sous `.mobile-toolbar` (`z-index: 900`, 64 px de haut), masquant les dernières entrées de l'arbre ; passé à `950`. Vérifié : `elementFromPoint` Playwright (393×851) + test source `mobile sidebar over toolbar` (`unit.test.mjs`). | 🟢 corrigé |
|
||||
| 2026-10-02 | BUG-104 | Correction | `desktop/permissions/commands.toml` (nouveau), `desktop/capabilities/default.json`, `desktop/build.rs` | **BUG-104 — commandes Tauri rejetées par l'ACL depuis la page backend** : la fenêtre redirige vers `http://127.0.0.1:<port>`, origine *remote* pour Tauri v2, et aucune commande applicative n'était déclarée → `Command … not allowed by ACL` sur `pick_vault_folder`, `get_wizard_state`, etc. ; les erreurs sont avalées par `desktop.js:invoke()` → bouton du wizard silencieusement inopérant depuis #77. Correctif : manifeste `desktop/permissions/commands.toml` (`allow-app-commands`, 19 commandes) référencé par la capability + `rerun-if-changed=permissions` dans `build.rs`. Vérifié : `cargo test` 25 passed (nouveau garde-fou ACL), CDP live : `get_version` OK, clic → fenêtre native « Select Folder », ajout de vault complété de bout en bout. | 🟢 corrigé |
|
||||
| 2026-10-02 | BUG-105 | Correction | `desktop/src/jumplist.rs` | **BUG-105 — crash heap (c0000374) à l'ouverture du desktop** : `set_link_title` (#77) passait un `PROPVARIANT VT_LPWSTR` pointant un buffer heap Rust au property store du shell, qui le libère avec l'allocateur Windows → `STATUS_HEAP_CORRUPTION` détectée dans ntdll ≤20 s après le lancement (3/3, Event Log WER, même empreinte de pile). Fonction supprimée : le libellé des entrées vient de `SetDescription`. Vérifié : bisect sur 3 états de build, run 9 min `EXIT=0`, zéro événement WER. | 🟢 corrigé |
|
||||
| 2026-10-02 | BUG-106 | Correction | `backend/main.py`, `tests/test_csp_nonce.py` | **BUG-106 — page blanche sur /docs** : la CSP `#87 T5c` (`script-src` sans `unsafe-inline`, nonce frais par réponse) s'applique aussi aux pages générées par FastAPI (`/docs`, `/redoc`) dont le balisage n'est jamais passé par `inject_csp_nonce` → le navigateur refusait le script inline `SwaggerUIBundle(…)`, la div restait vide. `SecurityHeadersMiddleware` injecte désormais le nonce dans ce HTML avec le helper existant ; le corps est décompressé/recompressé car `SSESafeGZipMiddleware` est plus proche de la route (les réponses arrivent gunzippées). `/redoc` n'a aucun script inline (bundle jsdelivr seul, couvert par `script-src`) — vérifié tel quel. Vérifié : `test_docs_inline_script_nonced`, `test_redoc_served_with_csp`, `test_docs_external_bundle_not_nonced` + 45 passed sur les 3 suites sécurité, ruff/mypy 0. | 🟢 corrigé |
|
||||
| 2026-10-02 | BUG-107 | Correction | `desktop/tauri.conf.json`, `desktop/src/main.rs` | **BUG-107 — drag & drop de fichiers inutilisable sur desktop** : Tauri/wry installe son propre `IDropTarget` par-dessus de celui du WebView2 (source wry : « Enumerate child windows to find the WebView2 window and override! ») et aucun événement natif n'était écouté → les glisser-déposer depuis l'Explorateur n'atteignaient jamais les gestionnaires HTML5 de `dragdrop.js` (#89 marchait donc uniquement sur le web). Correctif : la doc Tauri est explicite — `disable_drag_drop_handler()` « is required to use HTML5 drag and drop APIs on the frontend on Windows » ; la fenêtre est créée en code (`create: false` dans `tauri.conf.json`, boucle Tauri qui saute les fenêtres non auto-créées) via `WebviewWindowBuilder::from_config(...).disable_drag_drop_handler()`. Vérifié : `cargo test` 26 passed (nouveau garde-fou `test_window_created_without_tauri_drag_drop_handler` : les deux marqueurs obligatoires), lancement de l'app OK (fenêtre unique, boot log normal) ; glisser-déposer réel à valider par l'utilisateur. | 🟢 corrigé |
|
||||
| 2026-10-07 | BUG-108 | Correction | `frontend/js/xlsx/context-menu.js` | **BUG-108 — « Coller » du menu contextuel du tableur muet** : `closeContextMenu()` faisait `menu.remove()` **avant** `_menu = null` ; retirer le menu portant le focus émet `focusout` synchrone qui rappelle `closeContextMenu()`, le second `remove()` sur un nœud démonté lève `NotFoundError` et avorte le handler de l'action avant son exécution (introduit par la fermeture au focus #179). Reproduit par l'E2E `xlsx-viewer.spec.js` « coller une plage » (CI #850→#854). Correctif : déférencer avant de retirer. | 🟢 corrigé |
|
||||
| 2026-10-08 | #189 | Fonctionnalité | `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js`, `docs/ROADMAP.md`, `docs/features/xlsx-mobile-layout-189.md`, `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md`, `README.md`, `README.fr.md`, `CHANGELOG.md` | **#189 — mise en page mobile du tableur** : sur ≤ 768 px les barres menus/ruban/Recherche sont repliées par défaut (la barre des feuilles + barre formule fx restent visibles, la grille récupère la hauteur perdue), bascule unique `#xlsx-tools-toggle` (☰, `aria-expanded`), cibles tactiles ≥ 44 px, groupes du ruban qui s'enroulent, onglets de feuilles défilants, inputs 16 px (anti-zoom iOS). E2E mobile ajouté + helper `openXlsx()` (déplie le ruban). 10 échecs E2E mobile **préexistants** prouvés par `git stash` (hors périmètre, CI ne lance que desktop). Vérifié : xlsx-viewer.test.mjs 165/165, E2E desktop 19 passed/1 skipped, suite 1628 passed, ruff/mypy 0, CI run #1937 success | ✅ livré (en attente vérif utilisateur) |
|
||||
| 2026-10-08 | #191 | Fonctionnalité | `backend/file_chat.py`, `backend/routers/file_chat.py`, `backend/schemas.py`, `frontend/js/filechat.js`, `frontend/js/sync.js`, `frontend/index.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py`, `tests/frontend/filechat.test.mjs`, `docs/ROADMAP.md`, `docs/features/file-chat-169.md`, `CHANGELOG.md` | **#191 — chat : suppression de post, messages privés, boîte compacte, link preview** : (A) `DELETE /api/chat/{id}` + `/api/chat/dm/{peer}/{id}` (auteur ou admin, 403 sinon) avec diffusion SSE `chat_deleted` et bouton 🗑 au survol côté client (`confirm()` natif) ; (B) DM 2 utilisateurs : vault `__dm__` + `dm_path(a,b)` trié, `GET /api/chat/users` (destinataires sans soi-même, aucun hash exposé), routes `GET/POST /api/chat/dm/{user}` (404 inconnu, 400 DM à soi), rangée de canaux `.chat-channels` dans la sidebar, non-lus par canal (compteurs localStorage, anciens timestamps/compteurs mélangés unifiés) ; (C) bouton d'envoi réduit à une icône dans les deux formulaires ; (D) `build_preview(text)` : 1ʳᵉ URL → garde SSRF `_assert_public_http_url` → `httpx.get` 5 s/512 Ko → OG (titre/desc/image/site) en carte cliquable, best-effort (échec → message sans carte), cache 200 entrées. Bug trouvé en cours de route : `max_redirects` n'existe pas sur `httpx.get` → TypeError avalé par le `try` = previews jamais générées en prod, invisible des tests (ils mockaient `build_preview`) ; arg retiré + `test_build_preview_happy_path_parses_og` (httpx mocké) ajouté. Tests : pytest 45 file_chat (44→45, +13 sur #191), JSDOM filechat 16 (+5) |
|
||||
| 2026-10-08 | #190 | Fonctionnalité | `backend/file_chat.py`, `backend/routers/file_chat.py`, `backend/schemas.py`, `frontend/js/filechat.js` (réécrit), `frontend/js/viewer.js`, `frontend/js/config.js`, `frontend/js/sidebar.js`, `frontend/js/app.js`, `frontend/index.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py`, `tests/frontend/filechat.test.mjs`, `docs/ROADMAP.md`, `CHANGELOG.md` | **#190 — chat : suivi du document, chat général en onglet sidebar, onglets au-dessus du filtre** : (A) l'en-tête du panneau affiche le document ciblé (titre + chemin) et `followFileChat()` appelé par `renderFile()` re-cible le panneau ouvert à chaque changement de document ; (B) onglet **Chat** (dernier) → conversation générale stockée dans le même store #169 via les sentinelles `__global__/general` (`GET/POST /api/chat`), **pastille de messages non lus** (`localStorage` + badge sur l'onglet), **pièces jointes image/vidéo** (`POST /api/chat/upload` : allow-list d'extensions, 25 MB, nom UUID — jamais le nom client ; `GET /api/chat/attachment/{name}` résolu contre l'allow-list), **URL cliquables** dans le rendu (linkification `http(s)://`), date/heure d'envoi ; (C) barre de filtre **déplacée sous les onglets** et routée vers `filterChatMessages()` (texte + auteur) quand l'onglet Chat est actif. Transport : broadcast SSE `chat_message` réutilisé (vault `__global__` route vers le panneau sidebar, sinon panneau fichier) — pas de second WebSocket. Contre-preuves : `__global__` non routé → test « routes __global__ » échoue ; panneau sans classe `active` → le message part en toast au lieu du rendu (révélé par le stub JSDOM). Vérifié : pytest **1655 passed / 2 skipped** (27 tests chat dont 12 nouveaux), ruff 0, mypy 0 (113 fichiers), validate-imports 42 modules/368 exports, unit 13/13, filechat.test.mjs **11/11** (5 nouveaux), toolbar-order, sidebar-filters 8/8 | ✅ livré (en attente vérif utilisateur) |
|
||||
| 2026-10-08 | #169 | Fonctionnalité | `backend/file_chat.py` (nouveau), `backend/routers/file_chat.py` (nouveau), `backend/schemas.py`, `backend/main.py`, `frontend/js/filechat.js` (nouveau), `frontend/js/viewer.js`, `frontend/js/sync.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py` (nouveau), `tests/frontend/filechat.test.mjs` (nouveau), `tests/frontend/toolbar-order.test.mjs`, `.gitea/workflows/ci.yml`, `docs/ROADMAP.md`, `docs/features/file-chat-169.md` (nouveau), `docs/GUIDES/PRISE_EN_MAIN.md`, `CHANGELOG.md` | **#169 — chat intégré par fichier** : store JSON par (vault, path) sous `data/chats/` (nom SHA-256 → traversal impossible, plafond 500 msgs, texte 4000 car., écriture atomique, fichier corrompu → vide) ; `GET/POST /api/file/{vault}/chat` (auth + `check_vault_access` + `resolve_safe_path`, 400/404/403, `response_model`) + broadcast SSE `chat_message` sur le transport #62 (**pas de 2ᵉ WebSocket**) ; panneau latéral `filechat.js` (bouton 💬 toolbar, rendu chronologique `textContent`, envoi optimiste + dédup par id, toast si panneau fermé/autre fichier, plein écran ≤ 768 px) ; relais SSE en import dynamique dans `sync.js` ; i18n FR/EN 10 clés `chat.*`. En route : tag OpenAPI maison `file-chat` non déclaré → `test_used_tags_are_declared` rouge (retiré, dérivé « Files »), regex navBtns de `toolbar-order.test.mjs` (échappements → `includes`), ruff UP012/TRY004/I001. Vérifié : `test_file_chat.py` 15 passed, `filechat.test.mjs` 6/6, suite 1643 passed, ruff/mypy 0, validate-imports 42 modules, CI run #1938 5/5 success (v2.57.0) | ✅ livré (en attente vérif utilisateur) |
|
||||
| 2026-10-08 | #192 + BUG-109 | Fonctionnalité + correction | `backend/file_chat.py`, `backend/routers/file_chat.py`, `backend/schemas.py`, `frontend/js/filechat.js`, `frontend/js/sync.js`, `frontend/index.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py`, `tests/frontend/filechat.test.mjs`, `docs/ROADMAP.md`, `docs/features/file-chat-169.md`, `CHANGELOG.md` | **#192 — chat : saisie auto-agrandissante + accusé de réception.** La boîte d'envoi (panneau document **et** sidebar) devient un `textarea` dont la hauteur suit le contenu (plafond 160 px puis défilement, repasse à 1 ligne après l'envoi), `Entrée` envoie / `Maj+Entrée` saute une ligne ; placeholder sidebar désormais traduit à l'init. **Accusé de réception** : chaque document de conversation porte `read = {utilisateur: ts}` (verrou global sur le read-modify-write — une lecture ne peut plus faire disparaître un message), `GET` d'historique renvoie la carte, nouveau `POST /api/chat/read` (400 sans vault/path, 403 DM hors pair, ACL vault pour un chat de fichier) marque la lecture et diffuse `chat_read` en SSE ; côté client `✓` envoyé / `✓✓` lu sur **mes** messages (exclut ma propre carte de lecture), bascule en direct via `onChatRead()` + toast « {user} a lu votre message », accusé émis à l'ouverture d'une conversation et à la réception d'un message déjà affiché. **BUG-109** : la vignette des tuiles de lien ne s'affichait jamais (CSP `img-src 'self'` + `og:image` relative résolue contre ObsiGate) → `_proxy_image()` téléverse l'image dans `chat_uploads` (same-origin, `urljoin` contre la page, garde SSRF, 2 Mo max, allow-list) et la carte reste affichée si le téléchargement échoue. Tests : pytest `test_file_chat.py` 45→**54** (`TestReadReceipts` 7, proxy image 2, 2 assertions de réponse adaptées), JSDOM `filechat.test.mjs` 16→**19** ; ruff 0, mypy 0 (113 fichiers), validate-imports 42/371, unit 13/13 | ✅ livré (en attente vérif utilisateur) |
|
||||
| 2026-10-10 | BUG-111 | Correction UI admin | `frontend/admin.html`, `frontend/js/admin.js`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-111** : (1) la modale « Administration — Utilisateurs » ouverte depuis `/admin` n'affichait pas l'icône du bouton fermer — `safeCreateIcons()` retourne immédiatement car le global `lucide` n'existe pas sur `admin.html` (script UMD absent — seul index.html le chargeait) ; ajout du même `<script defer>` lucide@0.344.0. (2) Le tableau de bord admin affichait les 4 sections d'un coup (scrollspy) — `_wireSectionNav` masque désormais toutes les sections sauf celle cliquée (état initial : hash de l'URL ou section Stats). Vérifié : 1705 passed, suites frontend vertes, instance docker locale 2020. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-10-09 | BUG-110 | Correction + refonte UI chat | `frontend/js/filechat.js`, `frontend/js/ui.js`, `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/filechat.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-110 — refonte visuelle du chat** : (1) les boutons de pièce jointe deviennent des **icônes seules** (24 px, `aria-label`) rangées **sous l'image, alignées à droite** — plus de gros boutons à libellé ; (2) le clic sur une image ouvre le **même visualiseur que pour une image de vault** (zoom/pan/plein écran) mais dans un **onglet de l'app** (`TabManager.openChatImage()`, branche `chatImage` de `activate()`, `renderImageViewer()` accepte désormais une `url` directe : une pièce jointe n'est pas dans un vault) au lieu d'un onglet du navigateur ; (3) **point rouge pulsant sur l'icône chat** de la sidebar tant qu'il reste des non-lus (classe `chat-has-unread`, en plus du badge chiffré) ; (4) **notification OS** (`Notification`) quand l'app n'est pas au premier plan (`document.hidden`/hors focus), permission demandée au premier clic d'ouverture du chat ; (5) la suppression n'est plus mensongère : le `DELETE` est **différé de 10 s**, la ligne passe à « Ce message a été supprimé » avec « Annuler » — annuler réarme le message (l'ancien code restaurait une copie DOM d'un message déjà détruit côté serveur en affichant « Message restauré ») ; échec réseau → le message revient. Vérifié : `filechat.test.mjs` 25/25 (dont 6 tests #110 : icônes seules, clic image → `openChatImage`, aucun `DELETE` pendant la fenêtre d'annulation, notification OS), `validate-imports` 42 modules / 372 exports 0 erreur, `unit.test.mjs` 13/13 | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
|
||||
| 2026-10-09 | #193 | Fonctionnalité | `backend/file_chat.py`, `backend/schemas.py`, `frontend/js/filechat.js`, `frontend/style.css`, `tests/test_file_chat.py`, `tests/frontend/filechat.test.mjs`, `docs/features/file-chat-169.md`, `CHANGELOG.md` | **#193 — un post du chat s'affiche comme un document markdown** : le serveur rend le texte de chaque message avec le **pipeline des documents** (`backend/render.py::_render_markdown` : mistune — tableaux, listes de tâches, notes —, wikilinks, masquage des secrets #188, **sanitizer BUG-021**) et le renvoie dans un champ `html` (ajouté à `ChatMessageItem`, sinon `response_model` le filtrait). Le rendu est calculé **à la lecture** (`get_messages`) et à l'ajout (`_append`) — il n'est donc **jamais persisté** dans `data/chats/*.json` — et l'écho SSE part avec : les autres clients voient le rendu sans refresh. Côté front, `_fillBody()` injecte le HTML dans un `<div class="md-content">` puis appelle `safeHighlight()` sur chaque `pre code` (highlight.js + alias de langages, exactement comme le viewer ; mermaid non déclenché). Repli intact si `html` manque (écho optimiste → texte brut + URL cliquable) et un échec de rendu n'emporte jamais le message. CSS : `.file-chat-md` annule le `pre-wrap` hérité de la bulle (les retours à la ligne du HTML source produisaient des lignes vides) et compacte `.md-content` (`pre`/`table` en `overflow-x: auto`). Vérifié : `test_file_chat.py` 54→**61** (`TestMarkdownRendering` ×7 : html à l'ajout et à la lecture, **absent du JSON**, classe `language-xxx`, listes, `<script>` neutralisé, routes POST/GET, broadcast SSE), `filechat.test.mjs` 25→**27**, suite 1689 passed, ruff 0, mypy 0 (113 fichiers), validate-imports 42 modules / 372 exports | ✅ livré (en attente vérif utilisateur) |
|
||||
|
||||
| 2026-10-09 | #194 | Fonctionnalité | `backend/user_home.py` (nouveau), `backend/indexer.py`, `backend/auth/router.py`, `backend/routers/vaults.py`, `backend/main.py`, `docker-compose.yml`, `tests/test_user_home.py` (nouveau), `tests/test_auth_api.py`, `docs/features/user-home-194.md` (nouveau), `docs/ROADMAP.md`, `CHANGELOG.md`, `README.md`, `README.fr.md` | **#194 — dossier personnel par utilisateur** : chaque compte reçoit `<OBSIGATE_HOME_ROOT>/<user>` monté comme vault propre `home-<user>` (option A validée) — l'isolation s'appuie sur l'ACL par vault **existante** (`check_vault_access`), aucune ACL par chemin inventée. `backend/user_home.py` : `ensure_user_home()` idempotent (mkdir → `add_vault_to_index` → `persist_vault` → watcher → SSE → octroi dans `user.vaults`) appelé à `POST /api/auth/admin/users` **et** au démarrage (`ensure_all_user_homes()` : migration des users existants, réparation des dossiers/registres manquants, sweep des vaults `home-*` orphelins dont l'utilisateur a disparu hors route — garde `Path.parent == racine` pour ne jamais toucher à un vault admin homonyme) ; `release_user_home()` à la suppression (index/watcher/registre refermés, **dossier conservé**). **Blocage levé** : registre persistant `data/vaults.json` fusionné par `load_vault_config()` — avant, tout vault ajouté à la volée (`/api/vaults/add`) disparaissait au premier reindex complet et au redémarrage. Correctif au passage : `reload_single_vault()` remet `vault_config[name]` après `remove_vault_from_index()`. Réponse du POST = vaults réactualisés (l'instantané de `create_user` était antérieur à l'octroi). Déploiement : montage unique `/NFS/OBSIDIAN_DOC/Home:/vaults/Home` + `OBSIGATE_HOME_ROOT` (absente = feature inactive, dev/tests/desktop inchangés). Vérifié : `test_user_home.py` 12/12 (création, idempotence, username invalide, persistance après `load_vault_config`, retrait, isolation `check_vault_access`, orphelins), `test_auth_api.py::TestUserHome` 2/2, suite **1703 passed / 2 skipped**, ruff backend 0, mypy 114 fichiers 0, `validate-imports` 42 modules / 372 exports, `unit.test.mjs` 13/13 | ✅ livré (en attente vérif utilisateur) |
|
||||
|
||||
| 2026-10-09 | #194 | Fonctionnalité (UI) | `frontend/js/sidebar.js`, `frontend/js/viewer.js`, `docker-compose.test-win.yml`, `.gitignore`, `tests/frontend/sidebar-filters.test.mjs`, `docs/ROADMAP.md`, `docs/features/user-home-194.md`, `CHANGELOG.md` | **#194 (incrément UI)** : les vaults `home-<user>` s'affichent **en premier** dans l'arborescence de la sidebar — tri stable appliqué à la source (`_homeFirst` dans `loadVaults()` et dans le soft-refresh, donc `state.allVaults`, l'arborescence et les sélecteurs restent cohérents) — et portent l'icône **maison** lucide (`data-lucide="home"` via `getVaultIcon`, même `safeCreateIcons`/`stroke: currentColor` que les autres icônes, thème-aware). Compose de test : `test_home:/vaults/Home` + `OBSIGATE_HOME_ROOT=/vaults/Home` (dossier `test_home/` ignoré par git). Vérifié : `sidebar-filters.test.mjs` 8→**10/10**, `validate-imports` 42 modules / 372 exports, `unit.test.mjs` 13/13, suites voisines (ai-sidebar, toolbar-order, search-facets, pane-manager, editor-inline) vertes, **live Playwright sur l'instance 2020** : ordre `home-admin, home-bruno, TestDir, TestVault` + `svg.lucide-home` rendu (placeholder `<i>` remplacé) | ✅ livré (en attente vérif utilisateur) |
|
||||
|
||||
| 2026-10-09 | #194 | Correction (isolation) | `backend/auth/middleware.py`, `backend/services/recent.py`, `backend/services/search.py`, `backend/routers/search.py`, `backend/routers/config.py`, `backend/routers/conflicts.py`, `backend/routers/files_read.py`, `backend/routers/history.py`, `backend/push.py`, `backend/user_home.py`, `tests/test_user_home.py`, `tests/test_auth_api.py`, `docs/features/user-home-194.md`, `CHANGELOG.md` | **#194 — l'admin voyait le home de chaque utilisateur** (retour utilisateur) : `vaults: ["*"]` ouvrait **tous** les vaults, donc `home-<user>` compris. Correctif à la racine : `check_vault_access` exige un octroi **explicite** pour tout vault `home-*` (nouveau `is_home_vault()`), et les filtres `« * »` écrits en dur dans les routes ont été remplacés par cet appel unique — dashboard (`routers/config.py`), conflits, liens retour, favoris, abonnements push (`/api/search/replace` utilisait déjà `is_vault_allowed`). `/api/search` filtre désormais les bruts **avant** pagination via `search_vaults(is_allowed=…)` (sinon notes personnelles d'un autre compte dans les résultats). `_grant` n'écarte plus les comptes `« * »` → l'admin reçoit son propre `home-admin` (auto-réparé au démarrage). Vérifié : `test_user_home.py` 12→**14/14** (dont « * » sans homes + filtrage recherche), `test_auth_api.py` 2/2 (admin ne voit **plus** `home-alice`, alice voit le sien), suite **1705 passed / 2 skipped**, ruff backend 0, mypy 114 fichiers 0, **live Playwright instance 2020** : arborescence admin = `home-admin, TestDir, TestVault` (plus de `home-bruno`), `svg.lucide-home` rendu. Reste ouvert (fuite pré-existante, hors périmètre) : `/api/search/advanced`, `/api/tags`, `/api/tags/suggest`, `/api/tree-search`, `/api/suggest` ne filtrent pas par vault accessible | ✅ livré (en attente vérif utilisateur) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+665
-6
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.43.1 | **Dernière mise à jour :** 2026-09-29
|
||||
> **Version :** 2.67.2 | **Dernière mise à jour :** 2026-10-10
|
||||
> **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)**
|
||||
@@ -30,6 +30,76 @@
|
||||
|
||||
## 🔵 En cours
|
||||
|
||||
### 196. Partage dirigé entre utilisateurs (document → utilisateur)
|
||||
|
||||
- **Effort :** 0,5-1 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-10 | **Statut :** ✅ livré le 2026-10-10
|
||||
- **Fiche :** [features/directed-shares-196.md](./features/directed-shares-196.md)
|
||||
- **Description :** un utilisateur peut partager un document à un ou plusieurs
|
||||
autres comptes (pas un lien public) : les pages `/s/{token}` exigent alors
|
||||
une session et n'acceptent que créateur + admin + destinataires (404 sinon,
|
||||
pas de fuite d'existence). Vue « partagé avec moi » dans le dashboard,
|
||||
destinataires listés dans le dialogue de partage, révocabilité réservée au
|
||||
créateur/admin, `GET /api/shares` scopé (un non-admin ne voit que ses
|
||||
partages créés/reçus — corrige une fuite préexistante). Rétrocompatible :
|
||||
les partages publics existants restent anonymes.
|
||||
- **Sous-tâches :**
|
||||
- [x] Store `shared_with` + validation des destinataires à la création
|
||||
- [x] Gate auth+appartenance sur `/s/{token}` (+`/pdf`, `/raw`) — 404 opaque
|
||||
- [x] Scope de `GET /api/shares` ; révocation créateur/admin uniquement
|
||||
- [x] UI : dialogue dirigé/public + destinataires, dashboard « partagé par X »
|
||||
- [x] i18n FR/EN, tests `test_directed_shares.py` (9), fiche feature
|
||||
- [x] **Itération 2 (2026-10-11)** : icône « partagé » (share-2 bleu) sur les
|
||||
fichiers porteurs d'un partage actif dans l'arborescence ; dossier
|
||||
virtuel **Partage** à la racine du home du destinataire (fichiers reçus,
|
||||
ouverture lecture seule `/s/{token}`) ; les documents reçus sont
|
||||
**cherchables** (merge dans `search_vaults`, résultats
|
||||
`home-<user>/Partage/…` avec `share_token`, clic → page de partage —
|
||||
jamais l'éditeur du vault d'autrui) ; cache front invalidé à la
|
||||
création/révocation. +2 tests (recherche trouvée par le destinataire,
|
||||
aucune fuite vers un non-destinataire).
|
||||
- [x] **Itération 3 (2026-10-11)** : le dossier Partage ne déclenche plus la
|
||||
navigation (dossier virtuel — corrige « Directory not found: Partage ») ;
|
||||
un fichier reçu s'ouvre **dans l'application en onglet** (arbre,
|
||||
recherche, dashboard) via la résolution serveur
|
||||
`home-<user>/Partage/<fichier>` → fichier source dans `/api/file`
|
||||
(+ raw, download) ; validation `path` non-nulle à la création d'un
|
||||
partage. +2 tests.
|
||||
- [x] **Itération 4 (2026-10-11)** : le partage dirigé EST l'autorisation —
|
||||
la résolution `Partage/…` court-circuite l'ACL du vault source (fix
|
||||
« Accès refusé à la vault … » : le destinataire n'a par définition
|
||||
pas accès au vault de l'émetteur) ; chemin virtuel canonique
|
||||
`Partage/<token>/<nom>` (lève l'ambiguïté de fichiers homonymes,
|
||||
fallback par nom conservé) ; « reçu » = adressé à moi (un admin ne
|
||||
voit plus les partages d'autres comptes comme reçus — fix 404
|
||||
« File not found: Partage/… »). +3 tests.
|
||||
|
||||
### 194. Dossier personnel par utilisateur (`<racine>/Home/<user>`)
|
||||
|
||||
- **Effort :** 1-2 jours | **Impact :** 🟡 | **Ouvert :** 2026-10-09 | **Statut :** 🔵 en cours
|
||||
- **Cadrage :** chaque utilisateur créé reçoit un dossier personnel monté en vault propre
|
||||
(`home-<user>`), isolé par l'ACL par vault existante — pas d'ACL par chemin à inventer.
|
||||
- **Décisions validées :** option A (un vault par utilisateur), nom `home-<user>`,
|
||||
dossier **conservé** à la suppression du compte, racine `/NFS/OBSIDIAN_DOC/Home`
|
||||
(montée `/vaults/Home`, exposée par `OBSIGATE_HOME_ROOT`).
|
||||
- **Sous-tâches :**
|
||||
- [x] ID stable créé (avant codage, AGENTS.md)
|
||||
- [x] Registre persistant des vaults dynamiques `data/vaults.json` (les vaults ajoutés à la
|
||||
volée sont aujourd'hui perdus au redémarrage / reindex — `load_vault_config` ne lit
|
||||
que les env `VAULT_N_*`)
|
||||
- [x] Service `ensure_user_home()` idempotent : mkdir + vault + octroi, appelé à la création
|
||||
d'un user et au démarrage (réparation/migration)
|
||||
- [x] Retrait au suppression de compte (index/watcher/registre) sans supprimer le dossier
|
||||
- [x] Montage Docker + `OBSIGATE_HOME_ROOT` (prod **et** compose de test
|
||||
`docker-compose.test-win.yml` → `test_home:/vaults/Home`)
|
||||
- [x] UI : vaults `home-*` en **premier** dans l'arborescence + icône maison
|
||||
lucide (`getVaultIcon`), vérifié live sur l'instance 2020
|
||||
- [x] Isolation : « * » (admin) ne couvre **plus** les dossiers persos —
|
||||
`check_vault_access` + filtres des routes + `/api/search` (avant
|
||||
pagination) ; octroi du home aussi aux comptes « * »
|
||||
- [x] Tests (création, persistance après rebuild, isolation 403 entre users) + docs
|
||||
|
||||
---
|
||||
|
||||
### 77. Application Desktop native — Tauri (Windows / Linux / macOS)
|
||||
|
||||
- **Effort :** 8-12 jours | **Impact :** 🟡 | **Framework :** Tauri v2 (Rust + Webview)
|
||||
@@ -42,7 +112,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 🔵 En cours — Visionneuse & édition Excel (P0/P1/P2)
|
||||
## ✅ Terminé — Visionneuse & édition Excel (P0/P1/P2)
|
||||
|
||||
### 153. Visionneuse & édition XLSX — complétude (fidélité, recherche, IA, UX, formats)
|
||||
|
||||
@@ -92,7 +162,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 🔵 En cours — Refonte UI/UX tableur (P2)
|
||||
## ✅ Terminé — Refonte UI/UX tableur (P2)
|
||||
|
||||
### 154. Refonte UI/UX de la visionneuse & éditeur XLSX (ruban, grille, inspecteur)
|
||||
|
||||
@@ -119,12 +189,435 @@
|
||||
|
||||
---
|
||||
|
||||
## ✅ Terminé — Ergonomie tableur
|
||||
|
||||
### 155. Ergonomie tableur — menu contextuel & sélection type Excel
|
||||
|
||||
- **Effort :** 2-4 jours | **Impact :** 🟡
|
||||
- **Statut :** ✅ **livré** — 2026-09-29 (menu contextuel clic droit + appui long, sélection type Excel)
|
||||
- **Analyse, conception et critères :** [features/xlsx-context-menu.md](./features/xlsx-context-menu.md)
|
||||
- **Description :** retours utilisateur après #154 — ajouter un **menu contextuel** (clic droit +
|
||||
appui long tactile) sur les cellules et en-têtes, et aligner la **sélection/curseur** sur le
|
||||
comportement d'Excel (plage par glisser, `Maj`, sélection de ligne/colonne par en-tête).
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1** Menu contextuel thémé (clic droit + appui long) : insérer/supprimer ligne & colonne,
|
||||
trier, effacer le contenu
|
||||
- [x] **A2** Sélection type Excel : plage (glisser / `Maj+clic` / `Maj+flèches`), en-têtes
|
||||
ligne/colonne, curseur croix, zone Nom affichant la plage
|
||||
|
||||
---
|
||||
|
||||
## ✅ Terminé — Polish menus tableur
|
||||
|
||||
### 179. Éditeur tableur — icônes des menus & boutons, fermeture au focus, polish mobile
|
||||
|
||||
- **Effort :** 1-2 jours | **Impact :** 🟡
|
||||
- **Statut :** ✅ **livré le 2026-10-04** (v2.53.0) — ouvert le 2026-10-04
|
||||
- **Fiche :** [features/xlsx-menus-179.md](./features/xlsx-menus-179.md)
|
||||
- **Description :** la grille (#155), les menus Structure/Mise en forme/Export
|
||||
(#156-A8/A11/A14) et les boutons du ruban (#154) sont textuels et restent
|
||||
ouverts quand le focus quitte le menu. Ajouter des icônes Lucide
|
||||
représentatives, fermer tout menu à la perte de focus (clic extérieur,
|
||||
`focusout`, `Échap`, défilement), naviguer au clavier dans le menu grille, et
|
||||
garantir des cibles tactiles ≥ 44 px + menus dans le viewport en mobile.
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1** Icônes des entrées du menu contextuel de la grille (+ `item.icon`)
|
||||
- [x] **A2** Icônes des menus Structure / Mise en forme / Export + boutons
|
||||
Enregistrer / « + » onglet
|
||||
- [x] **A3** Fermeture à la perte de focus (helper partageable : extérieur,
|
||||
`focusout`, `Échap`, scroll/resize) + navigation clavier du menu grille
|
||||
- [x] **A4** Polish mobile (44 px tactile, menus bornés au viewport) + tests
|
||||
JSDOM + E2E `xlsx-viewer.spec.js`
|
||||
|
||||
---
|
||||
|
||||
## ✅ Terminé — Ergonomie & grille du tableur
|
||||
|
||||
### 186. Création d'un fichier Excel depuis « Créer un fichier »
|
||||
|
||||
- **Effort :** 0,5 jour | **Impact :** 🟢
|
||||
- **Statut :** ✅ **livré le 2026-10-05** - ouvert le 2026-10-05
|
||||
- **Fiche :** [features/create-file-xlsx-186.md](./features/create-file-xlsx-186.md)
|
||||
- **Description :** l'éditeur Excel étant disponible, la modale « Créer un fichier » ne
|
||||
proposait que des formats texte (`.md`, `.txt`, `.py`, `.js`, `.json`, `.yaml`, `.sh`,
|
||||
`.ps1`) + Excalidraw : impossible de démarrer un classeur depuis l'UI. Il fallait
|
||||
passer par l'assistant IA (`create_xlsx`) ou déposer un fichier existant.
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1** Option **Excel (.xlsx)** dans la liste des types, et les 10 libellés du
|
||||
`<select>` passés en i18n FR/EN (`modal.ext_*`) — ils étaient codés en dur
|
||||
- [x] **A2** `create_file()` backend : un `.xlsx` est une archive OPC, pas du texte —
|
||||
le classeur est **construit avec openpyxl** (une feuille `Feuille1`) au lieu
|
||||
d'écrire la charge utile vide, sinon le fichier est illisible par la visionneuse
|
||||
- [x] **A3** Tests : non-régression pytest (`test_api_main.py` — classeur relu par
|
||||
openpyxl et servi en `is_xlsx`), E2E `xlsx-viewer.spec.js` (option présente,
|
||||
création depuis le menu contextuel, ouverture dans la grille)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Terminé — Assistant IA
|
||||
|
||||
### 187. Assistant IA — mode agent toujours actif, retrait du bouton toggle
|
||||
|
||||
- **Effort :** 0,5 jour | **Impact :** 🟢
|
||||
- **Statut :** ✅ **livré le 2026-10-07** (v2.53.2) — ouvert le 2026-10-06
|
||||
- **Description :** le bouton « mode agent » du panneau (toggle persisté en
|
||||
localStorage, défaut OFF) n'était plus protecteur : Deep Research et les
|
||||
quick actions `agent: true` basculaient déjà le mode en silence. Un
|
||||
utilisateur pouvait donc voir l'assistant agir sans jamais avoir activé le
|
||||
bouton. Retiré au profit d'un agent toujours actif : toute requête texte
|
||||
part sur `/api/ai/bookslm/agent` (outils lire/lister/chercher/modifier ; les
|
||||
mutations gardent la confirmation two-step) ; les images seules restent sur
|
||||
`/api/ai/bookslm/chat` (endpoint multimodal, la boucle agent étant
|
||||
textuelle).
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1** Frontend : bouton header, `_agentMode`, toggle/persistance et les
|
||||
auto-bascules (Deep Research, quick actions) supprimés ; `_postChat`
|
||||
route texte→`/agent`, images→`/chat` ; aide et i18n FR/EN mises à jour
|
||||
- [x] **A2** Optimisations requêtes AI : `/agent` résout désormais le
|
||||
provider comme `/chat` (avant : `req.provider` brut transmis au
|
||||
provider adapter → moteur réel ≠ étiquette SSE) ; schémas d'outils
|
||||
mis en cache (~27 ms de pydantic économisés par requête agent/MCP)
|
||||
- [x] **A3** Tests : `ai.test.mjs` adapté (100/100) ; non-régression pytest
|
||||
(`test_bookslm.py` provider résolu, `test_tools.py` cache sûr) ;
|
||||
ruff/mypy 0 erreur ; build + instance Docker locale à jour
|
||||
|
||||
---
|
||||
|
||||
## ✅ Terminé — Barre de menus & grille du tableur
|
||||
|
||||
### 185. Tableur — barre de menus Google Sheets (10 menus), ruban Sheets, grille unie
|
||||
|
||||
- **Effort :** 1-2 jours | **Impact :** 🟡
|
||||
- **Statut :** ✅ **livré le 2026-10-08** (v2.54.0) — ouvert le 2026-10-05
|
||||
- **Fiche :** [features/xlsx-menus-185.md](./features/xlsx-menus-185.md)
|
||||
- **Description :** l'éditeur n'avait **aucune barre de menus** (ni Fichier, ni
|
||||
Modifier, ni Affichage, ni Insertion, ni Format, ni Données, ni Outils, ni
|
||||
Gemini, ni Extensions, ni Aide) : beaucoup de fonctions n'étaient atteignables
|
||||
que par clic droit. Le ruban gardait un chromer bordersé qui ne ressemblait à
|
||||
rien et la grille n'était bordée qu'en bas avec une couleur quasi identique au
|
||||
fond des cellules — le quadrillage se lisait comme une tache.
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1** `frontend/js/xlsx/menus.js` : les **dix menus de Google Sheets**,
|
||||
dans l'ordre et avec les libellés exacts (161 entrées) ; sections en
|
||||
accordéon ; le menu **Fichier** quitte la barre d'onglets pour la barre de
|
||||
menus en **conservant ses identifiants**, seul *Enregistrer* reste
|
||||
épinglé
|
||||
- [x] **A2** **Majorité des fonctions câblées** : Partager, Renommer,
|
||||
Corbeille, Historique, Détails ; Annuler/Rétablir/Couper/Copier/Coller,
|
||||
**Collage spécial (valeurs)**, Supprimer, Tout sélectionner,
|
||||
Rechercher-et-remplacer ; Figer, Afficher (barre de formule / grille /
|
||||
en-têtes / tableau de bord), Zoom, Plein écran ; Lignes / Colonnes /
|
||||
Cellules, Lien, Commentaire, Fonction ; Nombre, Thème, B/I/U/S, Taille,
|
||||
Alignement, Retour ligne, Rotation, **Couleurs alternées**, Effacer ;
|
||||
Trier, **Filtre**, Plages nommées, **Nettoyage (doublons / espaces)**,
|
||||
**Diviser le texte en colonnes** ; Autocomplétion, Accessibilité ;
|
||||
Gemini ; Aide, Mises à jour, Raccourcis, Signalement. Les 36 options
|
||||
absentes d'ObsiGate **sont listées et le disent** au clic
|
||||
- [x] **A3** **Ruban façon Sheets** : boutons plats 30 × 30 sans chromer,
|
||||
survol discret, groupes par filets ; nouveau bouton grille piloté par le
|
||||
menu Affichage
|
||||
- [x] **A4** **Grille unie et visible** : 1 px **sur les quatre côtés** de
|
||||
chaque cellule (en-têtes compris) et un seul `--grid-border` dérivé du
|
||||
thème (`color-mix` texte → surface) au lieu de `--border` invisible
|
||||
- [x] Tests JSDOM (`xlsx-menus` + `xlsx-viewer`), E2E `xlsx-viewer.spec.js`,
|
||||
i18n FR/EN (134 clés `xlsx.m*` / `xlsx.mi_*`)
|
||||
|
||||
---
|
||||
|
||||
### 184. Tableur — peinture de format, multi-lignes/colonnes, grille A→Z × 1000
|
||||
|
||||
- **Effort :** 1 jour | **Impact :** 🟡
|
||||
- **Statut :** ✅ **livré le 2026-10-08** (v2.54.0) — ouvert le 2026-10-05
|
||||
- **Fiche :** [features/xlsx-sheet-input-181.md](./features/xlsx-sheet-input-181.md) (§8)
|
||||
- **Description :** « Peindre le format » échouait quand la source n'avait pas de
|
||||
format propre ; il manquait la sélection de plusieurs lignes/colonnes depuis
|
||||
les marges ; la grille s'arrêtait aux dimensions réelles du fichier au lieu
|
||||
d'aller jusqu'à A → Z et 1000 lignes.
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1** Peinture de format **complète** (toutes propriétés) + source sans
|
||||
format ⇒ **réinitialisation** de la cible ; fuite du handler annulé
|
||||
- [x] **A2** Sélection multi-lignes / multi-colonnes par glissement sur les
|
||||
marges et `Maj`+clic ; le geste de redimensionnement garde la priorité
|
||||
- [x] **A3** Grille étendue à **A → Z × 1000 lignes**, cellules vides
|
||||
éditables, padding à l'activation de l'onglet, pas de doublons
|
||||
- [x] **A4** Performance : index de cellules `findTd`, court-circuits
|
||||
formule/styles, marqueur `data-wired`
|
||||
|
||||
---
|
||||
|
||||
## ✅ Terminé — Panneau de couleurs du tableur
|
||||
|
||||
### 183. Panneau de sélection de couleurs façon Google Sheets (texte et fond)
|
||||
|
||||
- **Effort :** 0,5-1 jour | **Impact :** 🟡
|
||||
- **Statut :** ✅ **livré le 2026-10-08** (v2.54.0) — ouvert le 2026-10-05
|
||||
- **Fiche :** [features/xlsx-sheet-input-181.md](./features/xlsx-sheet-input-181.md) (§7)
|
||||
- **Description :** le sélecteur natif `<input type="color">` du ruban et du menu
|
||||
Mise en forme est remplacé par un panneau conforme aux maquettes : *Réinitialiser*,
|
||||
palette 8 × 10, STANDARD, PERSONNALISÉ, pied de page. Un clic sur une pastille
|
||||
referme le panneau et applique la couleur.
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1** Module `frontend/js/xlsx/color-picker.js` : palette 8 × 10
|
||||
(8 gris puis 8 teintes × 9 nuances), **sortie en `#rrggbb`**
|
||||
- [x] **A2** Coche de sélection blanche/noire selon la luminance
|
||||
- [x] **A3** Câblage ruban + menu Mise en forme, fermeture au clic, application
|
||||
sur cellule **ou plage**, réinitialisation, « + » / crayons vers le
|
||||
sélecteur natif
|
||||
- [x] **A4** Chrome du panneau thémé (variables CSS), i18n FR/EN (21 clés),
|
||||
pied de page honnête (message explicite pour les fonctions absentes)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Terminé — Correctifs éditeur tableur
|
||||
|
||||
### 182. Tableur — désélection fiable & couleurs sur une plage
|
||||
|
||||
- **Effort :** 0,5 jour | **Impact :** 🟡
|
||||
- **Statut :** ✅ **livré le 2026-10-08** (v2.54.0) — ouvert le 2026-10-05
|
||||
- **Fiche :** [features/xlsx-sheet-input-181.md](./features/xlsx-sheet-input-181.md) (§6)
|
||||
- **Description :** suite de #181 — la sélection multiple se dessinait mais ne
|
||||
s'effaçait plus, et l'application d'une couleur de texte / de fond sur une
|
||||
plage paraissait inerte.
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1** Le clic simple efface toujours la plage, sans dépendre de
|
||||
l'événement `focus` (cause : `Maj`+clic + `preventDefault()` figeait le
|
||||
focus, donc aucun `focus` ne partait au clic suivant)
|
||||
- [x] **A2** Après `Maj`+clic, le focus suit la cellule cliquée ; la zone Nom
|
||||
affiche la plage après un glissement
|
||||
- [x] **A3** Couleurs de texte et de fond vérifiées sur les 3 chemins
|
||||
(menu Mise en forme, nuanciers du ruban, `Maj`+flèches et glissement)
|
||||
- [x] **A4** Pastille de couleur sur les nuanciers du ruban (retour visuel)
|
||||
- [x] **A5** **Contours de plage qui s'empilaient** : le sélecteur de
|
||||
nettoyage oubliait le `.` des classes (`querySelectorAll("xlsx-sel-top")`
|
||||
cherche une balise) — rien n'était effacé, les encadrés restaient et
|
||||
s'ajoutaient à chaque changement de cellule. Même famille de faute sur
|
||||
les en-têtes reflétés
|
||||
- [x] **A6** Pendant la saisie, les flèches **valident et déplacent** la
|
||||
sélection (au lieu de déplacer le curseur), comme Google Sheets ;
|
||||
`Alt`+flèche garde le saut de ligne
|
||||
- [x] Tests JSDOM `xlsx-viewer` **152/152** (+6), suites frontend vertes
|
||||
|
||||
---
|
||||
|
||||
## ✅ Terminé — Polish éditeur tableur (mise en page & thèmes)
|
||||
|
||||
### 181. Éditeur tableur — modèle de saisie Google Sheets (sélection ≠ édition) & écarts fonctionnels
|
||||
|
||||
- **Effort :** 1 jour (A1-A3) + inventaire des écarts (A4) | **Impact :** 🟡
|
||||
- **Statut :** ✅ **livré le 2026-10-08** (v2.54.0) — ouvert le 2026-10-05
|
||||
- **Fiche :** [features/xlsx-sheet-input-181.md](./features/xlsx-sheet-input-181.md)
|
||||
- **Description :** l'éditeur ne distingue pas **sélectionner** une cellule et
|
||||
**l'éditer** : le curseur de texte est visible en permanence, `Entrée` valide
|
||||
puis perd le focus, et l'indicateur de plage est discret. Aligner le modèle de
|
||||
saisie sur Google Sheets / Excel (A1-A3) et **inventorier** précisément ce qui
|
||||
manque encore face à un tableur de référence (A4) — le rapprochement exhaustif
|
||||
n'est pas réalisable en une tranche.
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1** Deux états distincts : *sélection* (clic / flèches, pas de
|
||||
curseur via `caret-color: transparent`, contour visible) et *édition*
|
||||
(double-clic en place, première frappe qui **remplace**, `F2`,
|
||||
`Alt`+flèche pour un saut de ligne, `Échap` qui restaure)
|
||||
- [x] **A2** Sélection multiple : glisser, `Maj`+clic, `Maj`+flèches, avec
|
||||
contour de plage (`.xlsx-sel-*` sur les 4 bords réels) + en-têtes
|
||||
`A`/`B`… et `1`/`2`… reflétés
|
||||
- [x] **A3** `Entrée` contextuelle : en édition → valide et passe **à la
|
||||
cellule du dessous** ; en sélection → bascule en **mode édition**
|
||||
- [x] **A4** Inventaire des écarts vs Google Sheets / Excel et priorisation
|
||||
(P1 poignée de recopie / remplacer / validation de données / filtre
|
||||
auto / tri multi-critères ; P2 mise en forme conditionnelle / création
|
||||
de graphiques / TCD éditables / protection ; P3 analyse de données,
|
||||
historique, macros) — voir [fiche](./features/xlsx-sheet-input-181.md)
|
||||
- [x] Tests JSDOM (`xlsx-viewer` **146/146**, +10 cas #181), suites frontend
|
||||
vertes, build + déploiement de l'instance Docker locale
|
||||
|
||||
---
|
||||
|
||||
### 180. Éditeur tableur — redimensionnement au curseur, grille thémée, menu Fichier & barre épinglée
|
||||
|
||||
- **Effort :** 1-2 jours | **Impact :** 🟡
|
||||
- **Statut :** ✅ **livré le 2026-10-08** (v2.54.0) — ouvert le 2026-10-05
|
||||
- **Fiche :** [features/xlsx-sheetbar-180.md](./features/xlsx-sheetbar-180.md)
|
||||
- **Description :** retours utilisateur après #179 — (1) le redimensionnement des
|
||||
colonnes/lignes par les marges (`A, B, C…` / `1, 2, 3…`) doit afficher un
|
||||
curseur iconique (`col-resize` / `row-resize`) quand le pointeur est sur le
|
||||
bord ; (2) la grille doit suivre le thème de l'utilisateur (15 thèmes ×
|
||||
4 modes + importés : tokens dérivés, plus de fond figé) ; (3) regrouper
|
||||
Télécharger / CSV / Exporter dans un **menu Fichier** en haut à droite, au
|
||||
niveau des onglets de feuilles, avec **Enregistrer** juste à côté ;
|
||||
(4) retirer l'indicateur « Formules calculées localement » ; (5) épingler la
|
||||
barre de boutons en haut, au bord de la marge, et étendre l'éditeur à la
|
||||
largeur de la page.
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1** Curseur de redimensionnement sur les marges d'en-têtes
|
||||
(en-tête de bord miroité en ligne + classe de repaint `.xlsx-resize-col/row`)
|
||||
- [x] **A2** Grille compatible tous thèmes (`--grid-*` dérivés dans
|
||||
`themes.js`, replis CSS, modes contraste élevé / sépia, menu Export sur `--surface`)
|
||||
- [x] **A3** Menu Fichier + Enregistrer au niveau des onglets (`.xlsx-sheetbar`,
|
||||
identifiants des contrôles conservés)
|
||||
- [x] **A4** Retrait de l'indicateur de formules + barre épinglée pleine largeur
|
||||
(`position: sticky`, `:has(.xlsx-viewer)` en plein cadre)
|
||||
- [x] Tests JSDOM (`xlsx-viewer` 136/136, `xlsx-menus` 12/12), E2E `xlsx-viewer.spec.js`,
|
||||
i18n FR/EN, 8 tests réalignés sur l'application locale des écritures
|
||||
|
||||
---
|
||||
|
||||
## ✅ Terminé — Navigation
|
||||
|
||||
### 158. Navigation — vue répertoire en onglet, filtres & tris, retour Home complet
|
||||
|
||||
- **Effort :** 1-2 jours | **Impact :** 🟡
|
||||
- **Statut :** ✅ **livré** — 2026-10-02 (A1 onglet, A2 contenu/filtres/tris, A3 retour Home +
|
||||
**BUG-100**)
|
||||
- **Détail de conception :** [features/navigation-tab-158.md](./features/navigation-tab-158.md)
|
||||
- **Description :** la vue « chemin + Récents + fichiers du répertoire » (vault home) n'apparaît
|
||||
qu'en mode focus vault, disparaît dès qu'un fichier est ouvert, n'expose ni sous-répertoires ni
|
||||
filtres, et le clic sur le titre ne redonne pas la vraie page d'accueil (section
|
||||
**Raccourcis & Astuces** manquante, recherche/filtres non effacés).
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1 — onglet de navigation.** Tout clic sur un **répertoire** de l'arbre (en mode focus
|
||||
vault **et** en mode All) ouvre/actualise un **onglet de navigation** dédié, qui
|
||||
coexiste avec les onglets de fichiers ouverts. La ligne d'un vault dans l'arbre garde son
|
||||
rôle d'expansion ; la racine du vault s'atteint depuis la vue (fil d'Ariane, facette
|
||||
Vaults) ou le sélecteur de vault.
|
||||
- [x] **A2 — contenu de la vue.** Liste des **sous-répertoires cliquables**, panneau de
|
||||
**facettes Vaults · Tags · Extensions** (même mécanisme/visuel que la page de recherche),
|
||||
boutons de tri **Pertinence / Date** et bouton **Sauver**.
|
||||
- [x] **A3 — retour Home complet.** Clic sur le titre = page d'accueil d'un refresh
|
||||
(section **Raccourcis & Astuces** restaurée) + effacement de la recherche globale et de
|
||||
la barre de filtre de la sidebar gauche.
|
||||
- [x] **A4 — onglet Accueil.** Le clic sur le titre affiche la page d'accueil dans un onglet
|
||||
(icône Maison) épinglé **en tête de barre**, réactivable à tout moment.
|
||||
- [x] **A5 — menus contextuels de la page de navigation.** Clic droit identique à la sidebar
|
||||
sur les répertoires **et** les fichiers de la vue ; la ligne d'un vault dans l'arbre
|
||||
ouvre la page de navigation (racine du vault).
|
||||
- [x] **A6 — icône des répertoires** de la section Répertoires à la couleur de l'arbre
|
||||
(`var(--accent)`).
|
||||
|
||||
---
|
||||
|
||||
## ✅ Terminé — Tableur Excel (complétude)
|
||||
|
||||
### 156. Éditeur Excel — complétude fonctionnelle (presse-papiers, mise en forme, calcul, robustesse)
|
||||
|
||||
- **Effort :** 15-22 jours (P0 2-3 j ✅ · P1 4-6 j ✅ · P2 5-7 j ✅ · P3 4-6 j ✅) | **Impact :** 🟡
|
||||
- **Statut :** ✅ **livré le 2026-09-30** — **P0** (4 défauts **BUG-096 → BUG-099** corrigés),
|
||||
**P1** (**A5-A7** : presse-papiers de plage, clavier complet, zone Nom éditable), **P2**
|
||||
(**A8** mise en forme en écriture, **A9** décision « pas de moteur de formule, annoncée dans
|
||||
l'UI », **A10** undo/redo unifié conservé au re-rendu) et **P3** (**A11** export sélection/
|
||||
Markdown/HTML/impression + recherche multi-feuilles, **A12** concurrence optimiste `If-Match`,
|
||||
**A13** cache des métadonnées, **A14** outils IA `.xlsm`/`.csv` + recherche/analyse/structure)
|
||||
- **Analyse, défauts, risques et critères d'acceptation :** [features/xlsx-editor-completeness.md](./features/xlsx-editor-completeness.md)
|
||||
- **Description :** #153 a rendu l'éditeur **correct** sur la grille de valeurs, #154/#155 l'ont
|
||||
rendu **convivial** (ruban, inspecteur, undo/redo des cellules, sélection et menu contextuel
|
||||
type Excel). Il reste l'écart avec un vrai éditeur tableur : coller une **plage**, écrire la
|
||||
**mise en forme**, **calculer**, **sortir** le résultat, et détecter un écrivain concurrent.
|
||||
La fiche porte l'audit ; les cases ci-dessous sont le **suivi de référence**.
|
||||
- **Défauts corrigés le 2026-09-29 (cf. [ISSUES_TODOLIST.md](./ISSUES_TODOLIST.md)) :**
|
||||
- ✅ BUG-096 — enregistrer un `.csv` depuis la visionneuse levait un `TypeError`
|
||||
(`sheet: sheets[…].name` alors qu'un CSV n'a pas de `xlsx_sheets`) : aucun `PUT …/csv/save`
|
||||
n'était émis (reproduit en JSDOM, corrigé + test) ;
|
||||
- ✅ BUG-097 — feuille `.xlsm` tronquée : « Charger la suite » échouait en **415** —
|
||||
`GET …/xlsx/sheet` accepte désormais `.xlsx` **et** `.xlsm` ;
|
||||
- ✅ BUG-098 — délimiteur CSV détecté (`;`/`,`/tabulation) et réutilisé à l'écriture :
|
||||
un CSV français s'affiche en colonnes et le reste après édition ;
|
||||
- ✅ BUG-099 — sonde de perte **par feuille** + signal `cached_values_unverified` quand le
|
||||
budget est épuisé : plus de perte silencieuse possible des valeurs calculées.
|
||||
- **Sous-tâches :**
|
||||
- **P0 — défauts (🔴, 2-3 j) — ✅ livré le 2026-09-29**
|
||||
- [x] **A1** Sauvegarde `.csv` depuis la visionneuse — BUG-096 (test JSDOM : `PUT …/csv/save` émis)
|
||||
- [x] **A2** Chargement paresseux des `.xlsm` — BUG-097 (`GET …/xlsx/sheet` accepte `.xlsx`/`.xlsm`)
|
||||
- [x] **A3** Délimiteur CSV détecté et réutilisé à l'écriture — BUG-098 (`sniff_csv_delimiter`)
|
||||
- [x] **A4** Sonde de perte par feuille + signal `cached_values_unverified` — BUG-099
|
||||
- **P1 — presse-papiers & clavier (🟡, 4-6 j) — ✅ livré le 2026-09-29**
|
||||
- [x] **A5** Presse-papiers de plage — copier/couper/coller un bloc TSV (`Ctrl+C`/`Ctrl+X`/`Ctrl+V`, menu contextuel), presse-papiers interne + système, remplissage multi-cellules (tests JSDOM : `A1:B2` → `D5:E6`)
|
||||
- [x] **A6** Clavier complet — `Ctrl+S`, `Ctrl+A`, `Suppr`, `F2`, `Ctrl+Home/End`, `Home`/`End`, `PgUp/PgDn`, `Ctrl+flèches`, `Maj+Entrée` (saut de ligne en cellule)
|
||||
- [x] **A7** Zone Nom éditable (« Atteindre » : `B12`, `A1:B3`, `Feuille!A1`) + aide à la saisie des fonctions
|
||||
- **P2 — mise en forme, calcul, undo (🟡, 5-7 j) — ✅ livré le 2026-09-30**
|
||||
- [x] **A8** Mise en forme en écriture — bouton **Mise en forme** (gras/italique/souligné, alignements, couleurs, formats de nombre, fusions, volets figés, largeur/hauteur) via `PUT …/xlsx/style` (verrou, backup, garde de perte, `If-Match`)
|
||||
- [x] **A9** Calcul — **décision documentée** : pas de moteur de formule, l'enregistrement annonce « enregistrée comme texte » (la garde anti-DDE reste)
|
||||
- [x] **A10** Undo/redo unifié (cellules, effacement, tri/filtre, structure) et conservé au re-rendu (`_xlsxHistory` par fichier)
|
||||
- **P3 — sortie, robustesse, performances (🟢, 4-6 j) — ✅ livré le 2026-09-30**
|
||||
- [x] **A11** Sortie & recherche : export de la sélection / Markdown / HTML / impression, recherche sur toutes les feuilles (compteur `n/m · k feuilles`)
|
||||
- [x] **A12** Concurrence optimiste inter-processus (`ETag`/`If-Match`, **409** réparable, bouton « Réessayer » qui relit)
|
||||
- [x] **A13** Performances : cache des métadonnées par `(chemin, mtime, taille)` (LRU 8), invalidé à chaque écriture (axe des colonnes > `MAX_COLS` : hors périmètre)
|
||||
- [x] **A14** Outils IA étendus — `.xlsm`/`.csv` acceptés, `search_workbook`, `analyze_range`, `edit_xlsx_structure`
|
||||
|
||||
---
|
||||
|
||||
## ✅ Terminé — Secrets masqués
|
||||
|
||||
### 188. Secrets masqués — couverture universelle des clés API & mots de passe + clic pour copier
|
||||
|
||||
- **Effort :** 1 jour | **Impact :** 🟡
|
||||
- **Statut :** ✅ **livré le 2026-10-08** — ouvert le 2026-10-08
|
||||
- **Fiche :** [features/secret-mask-188.md](./features/secret-mask-188.md)
|
||||
- **Description :** le redacteur (`secret_redactor.py`) ne couvrait que les
|
||||
JWT, clés `sk-`/`AKIA`/`ghp_`, connection strings et affectations
|
||||
`key=value` de 20+ caractères — la plupart des clés API réelles et **tous
|
||||
les mots de passe courts** passaient en clair dans l'aperçu markdown, et
|
||||
un masque affiché n'était pas récupérable sans ouvrir la source. #188
|
||||
couvre la majorité des formats de clés, masque les mots de passe quelle
|
||||
que soit leur longueur et rend chaque masque **cliquable** dans l'aperçu
|
||||
authentifié (copie dans le presse-papiers) sans jamais exposer la valeur
|
||||
à l'extérieur (partages, PDF, IA/MCP).
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1** Détection universelle : table de formats fournisseur (OpenAI,
|
||||
Stripe, GitLab, GitHub, Google, AWS, Slack, SendGrid, Hugging Face,
|
||||
npm, Docker, Resend, Square, Atlassian, Discord, Telegram,
|
||||
`Bearer …`) + plancher des affectations génériques 20 → 8 caractères
|
||||
- [x] **A2** Mots de passe : `[MOT DE PASSE MASQUÉ]` toute longueur /
|
||||
quote (`password=`, `"passwd":`, `db_password=`, `mot de passe=`) ;
|
||||
`PWD=` shell épargné
|
||||
- [x] **A3** Clic → copie : placeholders markdown-safe (blocs de code
|
||||
compris) réécrits en `<span class="secret-mask" data-secret>`
|
||||
côté aperçu authentifié (listener délégué, toast + infobulle
|
||||
i18n FR/EN) ; libellé seul pour partages / PDF / IA / MCP ;
|
||||
code masqué non re-coloré (highlight.js perdrait le badge)
|
||||
- [x] **A4** Guide in-app FR/EN, `GUIDES/AUTHENTIFICATION_SECURITE.md`,
|
||||
README FR/EN, tests `TestSecretRedactor` (+33)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Terminé — Mise en page mobile du tableur
|
||||
|
||||
### 189. Tableur — mise en page mobile (barres repliées, grille prioritaire)
|
||||
|
||||
- **Effort :** 0,5-1 jour | **Impact :** 🟡 | **Framework :** CSS vanilla + `frontend/js/viewer.js`
|
||||
- **Statut :** ✅ **livré le 2026-10-08** — ouvert le 2026-10-08
|
||||
- **Fiche :** [features/xlsx-mobile-layout-189.md](./features/xlsx-mobile-layout-189.md)
|
||||
- **Description :** sur mobile, la barre de menus + le ruban + la barre de
|
||||
formule s'empilaient et occupaient plus de 50 % de la hauteur d'écran.
|
||||
Sous 768 px, `.xlsx-menubar`, `.xlsx-cmdbar` et le groupe Recherche sont
|
||||
**repliés par défaut** (la barre de formule fx et les onglets restent
|
||||
visibles) et un **bouton unique** `#xlsx-tools-toggle` dans la barre des
|
||||
feuilles déplie/replie l'ensemble (`aria-expanded`, état actif) — desktop
|
||||
inchangé (bouton masqué).
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1** Repli CSS par défaut ≤ 768 px + révélation via
|
||||
`.xlsx-toolbar.xlsx-tools-open` (menubar, ruban, Recherche)
|
||||
- [x] **A2** Bascule `#xlsx-tools-toggle` dans `.xlsx-sheetbar`
|
||||
(`aria-expanded` + classe `active` + i18n `xlsx.tools_toggle` FR/EN)
|
||||
- [x] **A3** Tactile : cibles ≥ 44 px, onglets en défilement horizontal,
|
||||
champs de la barre de formule en 16 px (anti-zoom iOS), barre de
|
||||
formule en 2 lignes quand les outils sont ouverts
|
||||
- [x] **A4** Tests : JSDOM `xlsx-viewer.test.mjs` (+2, 165/165) et E2E
|
||||
mobile (repli par défaut, dépliage, no-overflow ruban, 44 px) ;
|
||||
`openXlsx()` déplie automatiquement ≤ 768 px pour les tests desktop
|
||||
|
||||
---
|
||||
|
||||
## ⚪ Backlog — Priorité 4 (P4)
|
||||
|
||||
### 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.
|
||||
- **Périmètre élargi le 2026-10-04 (liste v1.2 Ph.3) :** la **synchronisation des favoris**
|
||||
(répertoires compris, cf. #161) et la **synchronisation des préférences utilisateur** (thèmes
|
||||
#65/#174, langue, historique de recherche et de modifications, recherches sauvegardées #162) sont
|
||||
**fusionnées dans cet item** plutôt que portées par des items séparés — même API de sync, même
|
||||
résolution de conflits. Store local actuel : JSON par utilisateur dans `data/` (pas de table
|
||||
`user_preferences` distincte ; un addendum de schéma suffirait si #73 est réactivé).
|
||||
- **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
|
||||
@@ -135,6 +628,9 @@
|
||||
- [ ] Conflits : UI de résolution manuelle (diff côte à côte entre version locale et distante)
|
||||
- [ ] Chiffrement : optionnel, chiffrement AES-256-GCM avant transmission
|
||||
- [ ] Pairing : échange de clé publique + code QR pour appairage des appareils
|
||||
- [ ] Périmètre v1.2 : sync des **favoris** (#161) et des **préférences** (thème, langue,
|
||||
historique, recherches sauvegardées) avec résolution de conflits (priorité à la dernière
|
||||
modification) + indicateur « préférences synchronisées »
|
||||
|
||||
---
|
||||
|
||||
@@ -158,6 +654,130 @@
|
||||
|
||||
---
|
||||
|
||||
## ⚪ Backlog — Améliorations produit & évolutions agent IA (liste v1.2) — P1/P2
|
||||
|
||||
> **Ouvert le 2026-10-04** à partir de la liste « Roadmap ObsiGate – Développement des
|
||||
> Fonctionnalités » v1.2 (phases 1-3 + suggestions agent IA numérotées 161-180 dans la liste
|
||||
> source). Évaluation croisée avec le code réel (2026-10-04) : **7 propositions sont déjà
|
||||
> livrées** et n'ouvrent donc **pas** d'ID ; seul le périmètre manquant reçoit un ID ObsiGate
|
||||
> stable `#161` → `#178`. Les numéros de la liste source ne sont **pas** repris tels quels
|
||||
> (règle du dépôt : un ID n'est jamais attribué à du déjà-fait ni réutilisé).
|
||||
> Chaque item doit passer par [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md) (DoD) à son ouverture.
|
||||
|
||||
### Table de correspondance (liste v1.2 → ObsiGate)
|
||||
|
||||
| Proposition v1.2 | Verdict 2026-10-04 (vérifié dans le code) | ID ObsiGate |
|
||||
|---|---|---|
|
||||
| Ph.1 — Sauvegarde des recherches fréquentes | Sauvegarde **déjà livrée** (`backend/saved_searches.py`, store `data/{user}_saved_searches.json`, bouton « Sauver » #158) ; fréquence & rappels manquants | ⚪ #162 |
|
||||
| Ph.1 — Liens de répertoires aux favoris | Marque-page existant sur **fichiers** uniquement (dashboard) ; répertoires absents | ⚪ #161 |
|
||||
| Ph.1 — Recherche dans la page | **Déjà livrée** en vue lecture (`FindInPageManager`, Ctrl+F + navigation + regex, tableur via #153-A13) ; reste l'intégration **Forge** | ⚪ #163 |
|
||||
| Ph.2 — Auto-complétion intelligente | **Déjà livrée** (`frontend/js/autocomplete.js` : Mermaid, code fence, frontmatter, wikilinks, table — partagé Forge/CodeMirror) | ✅ |
|
||||
| Ph.2 — Suggestions contextuelles (historique & préférences) | Non livré | ⚪ #164 |
|
||||
| Ph.3 — Synchronisation favoris + préférences multiplateforme | Fusionné dans le périmètre de **#73** (reporté P4, décision 2026-09-26) | ⚪ #73 |
|
||||
| 161 — Synchronisation Git automatique des vaults | Non livré (les outils `git_*` #92 ciblent Gitea/GitHub pour l'IA, pas la sync de vault) | ⚪ #165 |
|
||||
| 162 — Génération de résumés automatiques | **Déjà livrée** (`POST /api/ai/summarize`, actions instantanées #106, skills `backend/skills.py`) | ✅ |
|
||||
| 163 — Détection & fusion de doublons | Non livré (brique embeddings/TF-IDF #70 disponible) | ⚪ #166 |
|
||||
| 164 — Traduction automatique | **Déjà livrée** (toolbar IA « Traduire », #106) | ✅ |
|
||||
| 165 — Analyse de ton/style | **Déjà livrée** (toolbar IA « Ton », #106) | ✅ |
|
||||
| 166 — APIs tierces (Google Drive, Notion) | Partiel : sources connectées **Gitea/GitHub** livrées (#103, `backend/tools/connected.py`) ; Drive/Notion manquants | ⚪ #167 |
|
||||
| 167 — Notifications externes (Slack, Discord, Email) | Partiel : webhooks #9 + Push API #67 livrés ; canaux Slack/Discord/SMTP et choix des déclencheurs à câbler | ⚪ #168 |
|
||||
| 168 — Édition collaborative temps réel | **Déjà livrée** (#62 — Yjs/CRDT, WebSocket, awareness, v2.3.0) | ✅ |
|
||||
| 169 — Scripts personnalisés sandboxés | **Déjà livrée** (#61 — plugins en sandbox Web Worker, hooks, 9 endpoints, v2.2.0) | ✅ |
|
||||
| 170 — Planification de tâches automatiques (cron) | Non livré | ⚪ #170 |
|
||||
| 171 — Audit des accès | Partiel : `backend/audit.py` + `/api/admin/audit` + dashboard admin #71 existent ; historique **par fichier**, filtres avancés et export CSV manquants | ⚪ #171 |
|
||||
| 172 — Chiffrement/déchiffrement de fichiers | Non livré (AES-GCM interne réservé aux tokens) | ⚪ #172 |
|
||||
| 173 — Vues personnalisées (Kanban, Calendrier) | Non livré | ⚪ #173 |
|
||||
| 174 — Optimisation mobile | Base **largement livrée** (#69 éditeur mobile, #83 ruban mobile, #114 config responsive 44 px) ; reste un audit du solde | ⚪ #178 |
|
||||
| 175 — Chat intégré | **Livré le 2026-10-08** (SSE, panneau latéral, historique, toast) | ✅ #169 |
|
||||
| 176 — Thèmes dynamiques (règles heure/date) | Base livrée (#65 thèmes + import/export) ; règles automatiques manquantes | ⚪ #174 |
|
||||
| 177 — Export formats propriétaires (OneNote, Evernote) | Base livrée (#66 HTML/MD bundle/ePub + PDF #74) ; formats tiers manquants | ⚪ #175 |
|
||||
| 178 — Webhooks pour événements | **Déjà livrée** (#9 — `backend/webhooks.py`, événements + secrets + validation d'URL SSRF-safe) | ✅ |
|
||||
| 179 — Gestion fine des permissions | Non livré (ACLs par vault/dossier racine seulement) | ⚪ #176 |
|
||||
| 180 — Historique des révisions visuel | Partiel : gestion des backups **avec diff** livrée (#40-46, outil IA `diff_backup`) ; UI côte à côte manquante | ⚪ #177 |
|
||||
|
||||
---
|
||||
|
||||
### 161. Favoris — répertoires et liens de vault (menu contextuel)
|
||||
|
||||
- **Effort :** 0,5-1 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-04 | **Statut :** ⚪ non commencé
|
||||
- **Description :** le marque-page actuel ne couvre que les fichiers ; ajouter les **répertoires**
|
||||
aux favoris (proposition « liens de répertoires », liste v1.2 Ph.1).
|
||||
- **Sous-tâches :**
|
||||
- [ ] Étendre le modèle de favoris aux répertoires (vérification d'accès backend via
|
||||
`_resolve_safe_path()`, permissions)
|
||||
- [ ] Entrée « Ajouter aux favoris » dans le menu contextuel de l'arbre **et** de la page de
|
||||
navigation (#158-A5)
|
||||
- [ ] Section « Favoris » dans la sidebar (répertoires + fichiers)
|
||||
- [ ] i18n FR/EN, tests (unit + E2E si UI), fiche `docs/features/`
|
||||
|
||||
### 162. Recherches fréquentes — fréquence d'utilisation & rappels
|
||||
|
||||
- **Effort :** 1-2 jours | **Impact :** 🟢 | **Ouvert :** 2026-10-04 | **Statut :** ⚪ non commencé
|
||||
- **Cadrage :** la **sauvegarde** des recherches existe déjà (`backend/saved_searches.py`, bouton
|
||||
« Sauver » de #158). Cet item n'ouvre que l'incrément manquant de la liste v1.2 Ph.1.
|
||||
- **Sous-tâches :**
|
||||
- [ ] Compteur d'utilisation par recherche sauvegardée (horodatage + fréquence)
|
||||
- [ ] Section dédiée « Recherches fréquentes » (page de résultats + sidebar) et épinglage
|
||||
- [ ] Rappel optionnel (« Vous avez souvent cherché X ») via notifications #67, interruptible
|
||||
en Configuration
|
||||
- [ ] i18n FR/EN + tests
|
||||
|
||||
### 163. Recherche dans la page — éditeur Forge
|
||||
|
||||
- **Effort :** 0,5-1 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-04 | **Statut :** ⚪ non commencé
|
||||
- **Cadrage :** `FindInPageManager` (Ctrl+F, surbrillance, navigation ↑/↓, casse / mot entier /
|
||||
regex) est livré pour la vue lecture ; le tableur a sa propre recherche (#153-A13). Reste à
|
||||
raccorder la recherche à l'éditeur **Forge** et à l'exposer en commande `/`.
|
||||
- **Sous-tâches :**
|
||||
- [ ] Raccorder `FindInPageManager` à Forge (textarea **et** CodeMirror) : surbrillance dans le
|
||||
contenu édité, navigation avec boucle, conservation du curseur
|
||||
- [ ] Commande `/recherche-dans-page` (mécanisme de commandes #81)
|
||||
- [ ] Non-régression des raccourcis existants (Ctrl+F vs recherche globale, Échap) ; i18n ;
|
||||
tests JSDOM + E2E
|
||||
|
||||
### 164. Suggestions contextuelles dans l'éditeur (historique & préférences)
|
||||
|
||||
- **Effort :** 3-5 jours | **Impact :** 🟢 | **Ouvert :** 2026-10-04 | **Statut :** ⚪ non commencé
|
||||
- **Cadrage :** l'auto-complétion contextuelle (Mermaid, code, Markdown, wikilinks, frontmatter)
|
||||
est **déjà livrée** (`frontend/js/autocomplete.js`) — la proposition v1.2 Ph.2
|
||||
« Auto-complétion intelligente » est couverte. Cet item n'ouvre que les suggestions apprises
|
||||
du contexte utilisateur.
|
||||
- **Sous-tâches :**
|
||||
- [ ] Mesure locale des snippets les plus utilisés par l'utilisateur (fréquence, sans cloud)
|
||||
- [ ] Suggestions basées sur l'historique de modification (termes récurrents, patterns du type
|
||||
« souvent inséré après … ») — option locale, IA légère si disponible
|
||||
- [ ] Bascule on/off en Configuration ; intégration à la complétion existante
|
||||
- [ ] i18n FR/EN + tests
|
||||
|
||||
---
|
||||
|
||||
### ⚪ Évolutions agent IA (liste v1.2 « Phase 4 », IDs ObsiGate #165 → #178)
|
||||
|
||||
| ID | Fonctionnalité | Effort | Impact | Dépendances / socle à étendre |
|
||||
|---|---|---|---|---|
|
||||
| 165 | Synchronisation automatique des vaults avec Git (add/commit/push/pull, résolution de conflits, auth SSH/token, option planification X heures) | 4-6 j | 🟡 | subprocess/`gitpython` ; UI dans Configuration (#160) ; distinct des outils `git_*` #92 (IA, Gitea/GitHub) |
|
||||
| 166 | Détection & fusion de doublons (score de similarité, liste des paires potentielles, outil de fusion) — ✅ **livré le 2026-10-04** ([fiche](./features/agent-phase4-166-168-170.md)) | 3-4 j | 🟢 | embeddings/TF-IDF #70 ; **jamais de fusion sans confirmation explicite** (+ backup préalable) |
|
||||
| 167 | Connexions tierces — Google Drive & Notion (import/export, OAuth2, respect des quotas) | 5-7 j | 🟡 | page « sources connectées » #103 ; rate-limit existant |
|
||||
| 168 | Notifications externes — canaux Slack/Discord (webhooks) et Email (SMTP) + choix des déclencheurs — ✅ **livré le 2026-10-04** ([fiche](./features/agent-phase4-166-168-170.md) ; périmètre : Discord, Telegram, SMTP, webhook générique — Slack via webhook générique) | 3-4 j | 🟢 | `backend/webhooks.py` #9 ; Push API #67 |
|
||||
| 169 | Chat intégré par fichier (panneau latéral, historique, notifications de nouveaux messages) — ✅ **livré le 2026-10-08** ([fiche](./features/file-chat-169.md) ; transport SSE #62, pas de 2ᵉ WebSocket) | 3-4 j | 🟢 | transport SSE #62 |
|
||||
| 170 | Planification de tâches automatiques (type cron ; actions = outils existants `create_file`/`append_to_file` ; notifications d'échec) — ✅ **livré le 2026-10-04** ([fiche](./features/agent-phase4-166-168-170.md) ; tick asyncio 60 s, API + outils, sans UI dédiée) | 3-4 j | 🟢 | scheduler backend (APScheduler ou asyncio) ; UI de création de tâches |
|
||||
| 171 | Audit des accès — extension : historique lecture/édition par fichier, filtres (utilisateur/fichier/date), export CSV | 2-3 j | 🟡 | `backend/audit.py` + `/api/admin/audit` (#71) |
|
||||
| 172 | Chiffrement/déchiffrement de fichiers sensibles (AES-256-GCM, gestion de clés, confirmation) | 3-4 j | 🟡 | **backup automatique préalable** ; clés hors dépôt (jamais de secret committé) |
|
||||
| 173 | Vues personnalisées Kanban & Calendrier (glisser-déposer, colonnes/date par tags & métadonnées) | 5-6 j | 🟢 | facettes/tags #157-#158 ; store JSON par utilisateur (pattern `data/*.json`) |
|
||||
| 174 | Thèmes dynamiques (règles heure/date, ex. mode sombre 18h-8h, règles personnalisables) | 2-3 j | 🟢 | thèmes #65 ; préférence persistée (candidate à la sync #73) |
|
||||
| 175 | Export vers formats propriétaires (OneNote, Evernote) avec conservation des métadonnées (tags, liens, images) | 3-4 j | 🟢 | `export.py` #66 (HTML/MD bundle/ePub) + PDF #74 |
|
||||
| 176 | Permissions fines par fichier/dossier (utilisateur/groupe + actions, héritage, journal des changements) | 4-5 j | 🟡 | ACLs vaults/dossiers existantes ; passerelle obligatoire par `_resolve_safe_path()` |
|
||||
| 177 | Historique des révisions visuel (diff côte à côte, surlignage ajouts/suppressions, navigation Précédent/Suivant) | 3-4 j | 🟢 | backups + diff #40-46 (`diff_backup`) ; `difflib` backend |
|
||||
| 178 | Optimisation mobile — solde (audit cibles tactiles ≥ 44 px, lazy-loading images/médias, raccourcis clavier virtuel) | 4-5 j | 🟡 | base livrée #69/#83/#114 ; lazy-loading déjà partiel (#153-A9) |
|
||||
|
||||
- **Critères d'acceptation transverses (liste v1.2 harmonisée avec la DoD du dépôt) :** test
|
||||
desktop **et** mobile ; documentation utilisateur FR/EN dans `docs/GUIDES/` (l'appel
|
||||
« Guide_MCP_ObsiGate.md » de la liste source est ramené à la cartographie documentaire du
|
||||
dépôt) ; **backup automatique avant toute modification destructive** ; i18n FR/EN systématique ;
|
||||
passage de l'ID à « en cours » avant codage (AGENTS.md, « Avant de commencer »).
|
||||
|
||||
---
|
||||
|
||||
## ✅ Complété — index
|
||||
|
||||
> Détail complet dans [docs/archive/COMPLETED_v1-v2.md](./archive/COMPLETED_v1-v2.md) et
|
||||
@@ -167,7 +787,25 @@
|
||||
| # | 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) |
|
||||
| 154 | Tableur — Refonte UI/UX (ruban groupé, onglets permanents, badges d'état, inspecteur droit, undo/redo) | 2.40.0→2.44.0 | [features/xlsx-ui-redesign.md](./features/xlsx-ui-redesign.md) |
|
||||
| 154 | Tableur — Refonte UI/UX (ruban groupé, onglets permanents, badges d'état, inspecteur droit, undo/redo) | 2.40.0→2.43.1 | [features/xlsx-ui-redesign.md](./features/xlsx-ui-redesign.md) |
|
||||
| 155 | Tableur — Menu contextuel (clic droit / appui long) & sélection type Excel | 2.44.0 | [features/xlsx-context-menu.md](./features/xlsx-context-menu.md) |
|
||||
| 156 | Tableur — Complétude éditeur : presse-papiers/clavier (A5-A7), mise en forme (A8), décision calcul (A9), undo unifié (A10), export + recherche multi-feuilles (A11), concurrence optimiste (A12), cache méta (A13), outils IA `.xlsm`/`.csv` (A14) + BUG-096 → BUG-099 | 2.45.0 | [features/xlsx-editor-completeness.md](./features/xlsx-editor-completeness.md) |
|
||||
| 179 | Tableur — icônes des menus & boutons, fermeture au focus, polish mobile | 2.53.0 | [features/xlsx-menus-179.md](./features/xlsx-menus-179.md) |
|
||||
| 180 | Tableur — redimensionnement au curseur, grille thémée, menu Fichier, barre épinglée | 2.54.0 | [features/xlsx-sheetbar-180.md](./features/xlsx-sheetbar-180.md) |
|
||||
| 181 | Tableur — modèle de saisie Google Sheets (sélection ≠ édition) & inventaire des écarts | 2.54.0 | [features/xlsx-sheet-input-181.md](./features/xlsx-sheet-input-181.md) |
|
||||
| 182 | Tableur — désélection fiable, contours non empilés, couleurs sur une plage | 2.54.0 | [features/xlsx-sheet-input-181.md](./features/xlsx-sheet-input-181.md) |
|
||||
| 183 | Tableur — panneau de couleurs façon Google Sheets (texte et fond) | 2.54.0 | [features/xlsx-sheet-input-181.md](./features/xlsx-sheet-input-181.md) |
|
||||
| 184 | Tableur — peinture de format complète, multi-lignes/colonnes, grille A → Z × 1000 (croissance par blocs) | 2.54.0 | [features/xlsx-sheet-input-181.md](./features/xlsx-sheet-input-181.md) |
|
||||
| 185 | Tableur — barre de menus Google Sheets (10 menus), ruban Sheets, grille unie | 2.54.0 | [features/xlsx-menus-185.md](./features/xlsx-menus-185.md) |
|
||||
| 186 | Création d'un fichier Excel (`.xlsx`) depuis la modale « Créer un fichier » | 2.54.0 | [features/create-file-xlsx-186.md](./features/create-file-xlsx-186.md) |
|
||||
| 188 | Secrets — détection universelle des clés API & mots de passe, masque cliquable (clic = copie) | Unreleased | [features/secret-mask-188.md](./features/secret-mask-188.md) |
|
||||
| 189 | Tableur — mise en page mobile : barres repliées par défaut, bascule unique, cibles tactiles 44 px | 2.56.0 | [features/xlsx-mobile-layout-189.md](./features/xlsx-mobile-layout-189.md) |
|
||||
| 169 | Chat intégré par fichier (panneau latéral, historique, temps réel SSE, toast de notification) | 2.57.0 | [features/file-chat-169.md](./features/file-chat-169.md) |
|
||||
| 190 | Chat — suivi du document, chat général en onglet sidebar (pièces jointes, non lus, recherche), onglets au-dessus du filtre | 2.58.0 | [features/file-chat-169.md](./features/file-chat-169.md) |
|
||||
| 191 | Chat — suppression de post, messages privés 2 utilisateurs (canaux), boîte d'édition compacte, link preview OG | 2.60.0 | [features/file-chat-169.md](./features/file-chat-169.md) |
|
||||
| 192 | Chat — boîte de saisie auto-agrandissante + accusé de réception ✓✓ (retour live SSE, notification de lecture) · BUG-109 (image de tuile de lien) | 2.61.0 | [features/file-chat-169.md](./features/file-chat-169.md) |
|
||||
| 159 | Desktop — gestion des vaults & dossiers : retrait par menu contextuel + section Configuration (ajout vault/dossier racine) | 2.50.0 | [features/desktop-tauri.md](./features/desktop-tauri.md) |
|
||||
| 160 | Desktop — premier lancement professionnel (répertoire `%USERPROFILE%\ObsiGate` + `Prise en main.md`) et section Configuration harmonisée | 2.51.0 | [features/desktop-tauri.md](./features/desktop-tauri.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) |
|
||||
@@ -235,6 +873,11 @@
|
||||
| 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) |
|
||||
| 85 | Refonte architecturale — découpage du monolithe (14 routers, `main.py` 4 827 → ~750 lignes), stores JSON verrouillés, rate-limit SQLite optionnel | 2.27.2→2.27.13 | [features/archi-refonte-85.md](./features/archi-refonte-85.md) |
|
||||
| 157 | Recherche — facette « Extensions » dans les résultats (3ᵉ filtre avec Vaults et Tags) + panneau repliable | 2.46.0→2.47.0 | [archive](./archive/COMPLETED_v1-v2.md) |
|
||||
| 158 | Navigation — clic répertoire → **onglet de navigation** (sous-répertoires, facettes Vaults/Tags/Extensions, tri Pertinence/Date, Sauver), **onglet Accueil**, menus contextuels + retour Home complet (**BUG-100** → **BUG-102**) | 2.48.0→2.49.0 | [features/navigation-tab-158.md](./features/navigation-tab-158.md) |
|
||||
| 166 | Assistant IA — détection & fusion de doublons (score déterministe, paires candidates, fusion backup + confirmation) | Unreleased | [features/agent-phase4-166-168-170.md](./features/agent-phase4-166-168-170.md) |
|
||||
| 168 | Assistant IA — notifications externes Discord / Telegram / SMTP / webhook (déclencheurs, secrets hors config, SSRF-safe) | Unreleased | [features/agent-phase4-166-168-170.md](./features/agent-phase4-166-168-170.md) |
|
||||
| 170 | Assistant IA — tâches planifiées type cron (create/append/notify, interval/daily/once, tick 60 s, échec notifié) | Unreleased | [features/agent-phase4-166-168-170.md](./features/agent-phase4-166-168-170.md) |
|
||||
|
||||
---
|
||||
|
||||
@@ -244,10 +887,14 @@
|
||||
|---|---|---|
|
||||
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–86, #88–93, #94–100, #102–115, #117, #92 | ~141 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é |
|
||||
| ⚪ P4 reporté | #73 Sync — **reporté (décision 2026-09-26)**, hors chemin critique ; périmètre élargi 2026-10-04 (favoris #161 + préférences) | 6-8 jours si réactivé |
|
||||
| ⚪ P1/P2 nouvelles (liste v1.2) | #161 favoris répertoires · #162 recherches fréquentes · #163 recherche Forge · #164 suggestions contextuelles | ~5-9 jours |
|
||||
| ⚪ Évolutions agent IA (#165 → #178) | Git sync, doublons, Drive/Notion, notifications, chat, cron, audit, chiffrement, Kanban, thèmes dynamiques, OneNote/Evernote, permissions, diff visuel, mobile | ~45-63 jours si tout activé (à prioriser par tranches) |
|
||||
| ⚪ P0/P1 prioritaire | #87 CI/CD (BUG-035 → BUG-040 corrigés, #86 livré) | ~3-5 jours |
|
||||
| ✅ Terminé | #153 Visionneuse & édition XLSX — complétude (A1-A17 **toutes livrées**, v2.27.0 → v2.39.0) | 0 jour restant |
|
||||
| ✅ Terminé | #154 Refonte UI/UX tableur (A1-A5 **toutes livrées**, v2.40.0 → v2.44.0) | 0 jour restant |
|
||||
| ✅ Terminé | #154 Refonte UI/UX tableur (A1-A5 **toutes livrées**, v2.40.0 → v2.43.1) | 0 jour restant |
|
||||
| ✅ Terminé | #155 Ergonomie tableur — menu contextuel & sélection type Excel | 0 jour restant |
|
||||
| ✅ Terminé | #156 Éditeur Excel — complétude — **P0-P3 ✅ livrés le 2026-09-30** (BUG-096 → BUG-099, A5-A14) | 0 jour restant |
|
||||
| **Total chemin critique** | **#77 fin + #87** | **~4-6 jours** |
|
||||
|
||||
---
|
||||
@@ -257,6 +904,18 @@
|
||||
- **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.
|
||||
- **Ajout 2026-09-27 :** #153 ouvert à la suite de l'audit de la visionneuse XLSX (limitations, risques de perte de données, périmètre IA/recherche) — détail et critères dans [features/xlsx-viewer.md](./features/xlsx-viewer.md).
|
||||
- **Ajout 2026-09-29 :** #154 ouvert — refonte UI/UX de la visionneuse/éditeur XLSX (audit UX, architecture cible, plan par lots) dans [features/xlsx-ui-redesign.md](./features/xlsx-ui-redesign.md) ; **livré en 5 lots** (ruban groupé, onglets permanents + « + », badges d'état, tokens de grille, dialogues thémés, inspecteur droit, undo/redo, extraction `frontend/js/xlsx/*`).
|
||||
- **Ajout 2026-09-29 :** #155 livré — menu contextuel (clic droit / appui long) et sélection type Excel sur la grille ([features/xlsx-context-menu.md](./features/xlsx-context-menu.md)) ; au passage **BUG-094** corrigé (feuille vide/nouvelle désormais éditable, quadrillage vierge 20×8).
|
||||
- **Clôture 2026-09-30 :** #156 **livré (P0 → P3)** — **A8** mise en forme en écriture (bouton **Mise en forme** : gras/italique/souligné, alignements, couleurs, formats de nombre, fusions, volets figés, largeur/hauteur — via `PUT …/xlsx/style`), **A9** décision « pas de moteur de formule, annoncée dans l'UI », **A10** undo/redo unifié conservé au re-rendu, **A11** export de la sélection / Markdown / HTML / impression + recherche sur toutes les feuilles, **A12** concurrence optimiste (`If-Match`, **409** réparable), **A13** cache des métadonnées par `mtime`, **A14** outils IA `.xlsm`/`.csv` + `search_workbook`/`analyze_range`/`edit_xlsx_structure` — détail dans [features/xlsx-editor-completeness.md](./features/xlsx-editor-completeness.md).
|
||||
- **Ajout 2026-09-29 :** #156 **P1 livré** — **A5** presse-papiers de plage (copier/couper/coller un bloc, presse-papiers interne + système, entrées du menu contextuel, remplissage multi-cellules), **A6** clavier complet (`Ctrl+S`/`Ctrl+A`/`Suppr`/`F2`/`Ctrl+Home|End`/`PgUp|PgDn`/`Ctrl+flèches`/`Maj+Entrée`) et **A7** zone Nom éditable + aide à la saisie ; restent P2 (mise en forme, calcul, undo unifié) et P3 (sortie, concurrence optimiste, performances, outils IA).
|
||||
- **Ajout 2026-09-29 :** #156 ouvert — audit de complétude de l'éditeur Excel : **4 défauts recensés** (BUG-096 enregistrement `.csv`, BUG-097 lazy-load `.xlsm`, BUG-098 délimiteur CSV, BUG-099 sonde de perte), **corrigés le jour même (P0 ✅)** avec tests de non-régression ; restent P1-P3 (presse-papiers, clavier, mise en forme, calcul, export, concurrence optimiste) puis presse-papiers de plage, clavier complet, mise en forme en écriture, calcul, undo/redo unifié, export/impression, concurrence optimiste — détail dans [features/xlsx-editor-completeness.md](./features/xlsx-editor-completeness.md).
|
||||
- **Ajout 2026-10-04 :** intégration de la liste « Roadmap – Développement des Fonctionnalités »
|
||||
v1.2 (phases 1-3 + suggestions agent IA). Évaluation croisée avec le code : **7 propositions déjà
|
||||
livrées** (auto-complétion `autocomplete.js`, résumés/traduction/ton #106, édition collaborative
|
||||
#62, scripts/plugins #61, webhooks #9, recherche dans la page en vue lecture) → pas d'ID ;
|
||||
**périmètre manquant** ouvert en backlog **#161 → #178** avec **table de correspondance** en tête
|
||||
de la nouvelle section ; sync favoris/préférences **fusionnée dans #73** (reporté P4). Les numéros
|
||||
161-180 de la liste source ne sont pas repris tels quels (règle dépôt : ID jamais attribué au
|
||||
déjà-fait) ; la correspondance figure item par item.
|
||||
- **Clôture #85 (v2.27.13) :** monolithe découpé (T1→T9), stores verrouillés + rate-limit SQLite (T10), fiche `docs/features/archi-refonte-85.md`.
|
||||
- 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.
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// ObsiGate AI Agent — flux de traitement d'une demande (b89af91)
|
||||
digraph ObsiGateAgent {
|
||||
rankdir=TB;
|
||||
splines=polyline;
|
||||
nodesep=0.35; ranksep=0.45;
|
||||
graph [fontname="DejaVu Sans", fontsize=13, bgcolor="white", pad=0.3];
|
||||
node [fontname="DejaVu Sans", fontsize=12, style=filled, shape=box, color="#333333", margin="0.16,0.09"];
|
||||
edge [fontname="DejaVu Sans", fontsize=11, color="#555555", arrowsize=0.8];
|
||||
|
||||
// ── Entrée ──
|
||||
user [label="Demande utilisateur\n(frontend bookslm.js)", fillcolor="#dbeafe"];
|
||||
deep [label="Chip « Deep Research »\n= agent mode + prompt injecté :\n« décomposer les étapes, chercher\ndans le vault, croiser,\nsynthétiser avec sources »",
|
||||
shape=note, fillcolor="#fef3c7"];
|
||||
api [label="POST /api/bookslm/agent\n→ réponse SSE (thought · tool ·\nconfirmation · message · done)", fillcolor="#e0f2fe"];
|
||||
prompt [label="Construction du system prompt\nmode directory / documents / general\ncontexte vault pré-chargé (BooksLM :\n≤200 fichiers, ≤200k chars, cache 5 min)\nprotocole agent = function calling natif", fillcolor="#f1f5f9"];
|
||||
|
||||
user -> api; deep -> user [style=dashed, label="active", fontsize=10];
|
||||
api -> prompt;
|
||||
|
||||
// ── Boucle agent ──
|
||||
subgraph cluster_loop {
|
||||
label="BOUCLE AGENT — backend/agent/loop.py · run_agent()";
|
||||
style=rounded; color="#e11d48"; penwidth=2; fontsize=13; fontcolor="#e11d48";
|
||||
|
||||
llm [label="Appel LLM — chat_completion()\nfournisseur OpenAI-compatible / Gemini\ntemp=0.3 · max_tokens 8192\nschemas des 50 outils exposés\n(si modèle sans tools → retombe chat simple)", fillcolor="#fee2e2"];
|
||||
decide [label="Le LLM demande des\noutils (tool_calls) ?", shape=diamond, fillcolor="#fff7ed"];
|
||||
thought [label="note de raisonnement\nintermédiaire → step visible « pensée »", fillcolor="#fef9c3"];
|
||||
tools [label="Exécution de chaque tool call\nvia call_tool() (voir pipeline)", fillcolor="#dcfce7"];
|
||||
back [label="résultat (JSON, tronqué ≤100k chars)\najouté à la conversation →\nitération suivante", shape=plaintext];
|
||||
|
||||
llm -> decide;
|
||||
decide -> thought [label="oui", color="#e11d48"];
|
||||
thought -> tools;
|
||||
tools -> llm [label="itération ≤ 10\nquota tools ≤ 25\n(BOOKSLM_MAX_TOOL_CALLS)", color="#e11d48"];
|
||||
}
|
||||
prompt -> llm;
|
||||
|
||||
// ── Pipeline call_tool ──
|
||||
subgraph cluster_pipe {
|
||||
label="PIPELINE call_tool() — backend/tools/registry.py · chaque appel passe par là";
|
||||
style=rounded; color="#7c3aed"; fontsize=13; fontcolor="#7c3aed";
|
||||
|
||||
p1 [label="1 · Schéma Pydantic\n+ validation arguments", fillcolor="#ede9fe"];
|
||||
p2 [label="2 · Rate limit\npar identité + outil", fillcolor="#ede9fe"];
|
||||
p3 [label="3 · Permission vault\n(+ déstructif si risque\nDANGEROUS)", fillcolor="#ede9fe"];
|
||||
p4 [label="4 · Risque READ ?\n→ exécution directe", shape=diamond, fillcolor="#f5f3ff"];
|
||||
p5 [label="PAUSE CONFIRMATION\noutil WRITE/DANGEROUS → event SSE\n« confirmation » avec le plan complet\nbatché (BUG-075) : une seule\napprobation applique toutes les mutations\n(resume via confirm / confirm_all)", fillcolor="#fde68a"];
|
||||
p6 [label="5 · Handler exécuté\n6 · Audit JSON-lines\n7 · Redaction secrets (redact_payload)", fillcolor="#dcfce7"];
|
||||
p1 -> p2 -> p3 -> p4;
|
||||
p4 -> p6 [label="read"];
|
||||
p4 -> p5 [label="mutation", color="#d97706"];
|
||||
p5 -> p6 [label="approuvé", style=dashed];
|
||||
}
|
||||
tools -> p1;
|
||||
p6 -> back [label="tool result", style=invis];
|
||||
p6 -> tools [style=invis];
|
||||
back -> p1 [style=invis];
|
||||
|
||||
// ── Sorties ──
|
||||
done [label="Réponse finale\nréponse sans tool_calls\n→ SSE « message »\n(Markdown + sources citées)", fillcolor="#bbf7d0"];
|
||||
final [label="Budget épuisé (10 itérations\nou 25 appels) → _finalize_answer :\ndernier appel SANS outils pour forcer\nune synthèse, sinon liste des sources\ndéterministe (jamais de réponse vide)", fillcolor="#fed7aa"];
|
||||
decide -> done [label="non", color="#059669", penwidth=2];
|
||||
decide -> final [label="budget atteint", color="#ea580c"];
|
||||
answer [label="Affichage UI : bloc\n« N étapes » Notion-style\n(pensées + outils dépliables)", fillcolor="#dbeafe"];
|
||||
done -> answer; final -> answer;
|
||||
|
||||
// ── Boîte à outils ──
|
||||
subgraph cluster_tools {
|
||||
label="50 OUTILS — backend/tools/*.py · risque READ / WRITE / DANGEROUS";
|
||||
style=rounded; color="#0369a1"; fontsize=13; fontcolor="#0369a1";
|
||||
node [fillcolor="#f0f9ff", fontsize=10];
|
||||
|
||||
fam_vault [label="VAULT · service.py (22)\nlecture : list_vaults, list_directory,\nlist_all_files, read_file, read_file_raw,\nsearch_fulltext, search_advanced,\nsearch_paths, get_graph, get_backlinks,\nlist_tags, suggest_tags, list_recent,\nlist_backups, diff_backup\nécriture : create_file, create_directory,\nappend_to_file, edit_file, restore_backup\nDANGEROUS : delete_file, delete_directory,\nmove_path, rename_file, rename_directory,\nreplace_in_files"];
|
||||
fam_web [label="WEB · web.py + crawler.py\nweb_search · fetch_url\ncrawl_site (≤20 pages, même hôte)"];
|
||||
fam_doc [label="DOCUMENTS · documents.py\ncreate_pdf · create_docx\ncreate_xlsx · create_csv"];
|
||||
fam_xlsx [label="TABLEURS · spreadsheets.py\nlist_xlsx_sheets · xlsx_to_markdown\nsearch_workbook · analyze_range\nupdate_xlsx_cells · append_xlsx_rows\nedit_xlsx_structure"];
|
||||
fam_dup [label="DOUBLONS · duplicates.py\nfind_duplicates\nmerge_duplicate_notes (DANG.)"];
|
||||
fam_git [label="GIT CONNECTÉ · connected.py\ngit_list_repos · git_get_file\ngit_search_issues (Gitea/GitHub)"];
|
||||
fam_sched [label="SCHEDULER · scheduled.py\nlist/create/delete/run_scheduled_task"];
|
||||
fam_notify [label="NOTIFICATIONS · notify.py\nnotify_external (Discord,\nTelegram, SMTP, webhook)"];
|
||||
}
|
||||
p6 -> cluster_tools [style=invis];
|
||||
fam_vault -> fam_web [style=invis]; fam_web -> fam_doc [style=invis];
|
||||
fam_doc -> fam_xlsx [style=invis]; fam_xlsx -> fam_dup [style=invis];
|
||||
fam_dup -> fam_git [style=invis]; fam_git -> fam_sched [style=invis];
|
||||
fam_sched -> fam_notify [style=invis];
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Generated by graphviz version 2.42.4 (0)
|
||||
-->
|
||||
<!-- Title: ObsiGateAgent Pages: 1 -->
|
||||
<svg width="2130pt" height="1034pt"
|
||||
viewBox="0.00 0.00 2130.20 1033.70" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(21.6 1012.1)">
|
||||
<title>ObsiGateAgent</title>
|
||||
<polygon fill="white" stroke="transparent" points="-21.6,21.6 -21.6,-1012.1 2108.6,-1012.1 2108.6,21.6 -21.6,21.6"/>
|
||||
<g id="clust1" class="cluster">
|
||||
<title>cluster_loop</title>
|
||||
<path fill="white" stroke="#e11d48" stroke-width="2" d="M493,-11.5C493,-11.5 1052,-11.5 1052,-11.5 1058,-11.5 1064,-17.5 1064,-23.5 1064,-23.5 1064,-471 1064,-471 1064,-477 1058,-483 1052,-483 1052,-483 493,-483 493,-483 487,-483 481,-477 481,-471 481,-471 481,-23.5 481,-23.5 481,-17.5 487,-11.5 493,-11.5"/>
|
||||
<text text-anchor="middle" x="772.5" y="-468.6" font-family="DejaVu Sans" font-size="13.00" fill="#e11d48">BOUCLE AGENT — backend/agent/loop.py · run_agent()</text>
|
||||
</g>
|
||||
<g id="clust2" class="cluster">
|
||||
<title>cluster_pipe</title>
|
||||
<path fill="white" stroke="#7c3aed" d="M1084,-102C1084,-102 1580,-102 1580,-102 1586,-102 1592,-108 1592,-114 1592,-114 1592,-761.5 1592,-761.5 1592,-767.5 1586,-773.5 1580,-773.5 1580,-773.5 1084,-773.5 1084,-773.5 1078,-773.5 1072,-767.5 1072,-761.5 1072,-761.5 1072,-114 1072,-114 1072,-108 1078,-102 1084,-102"/>
|
||||
<text text-anchor="middle" x="1332" y="-759.1" font-family="DejaVu Sans" font-size="13.00" fill="#7c3aed">PIPELINE call_tool() — backend/tools/registry.py · chaque appel passe par là</text>
|
||||
</g>
|
||||
<g id="clust3" class="cluster">
|
||||
<title>cluster_tools</title>
|
||||
<path fill="white" stroke="#0369a1" d="M1612,-8C1612,-8 2067,-8 2067,-8 2073,-8 2079,-14 2079,-20 2079,-20 2079,-970.5 2079,-970.5 2079,-976.5 2073,-982.5 2067,-982.5 2067,-982.5 1612,-982.5 1612,-982.5 1606,-982.5 1600,-976.5 1600,-970.5 1600,-970.5 1600,-20 1600,-20 1600,-14 1606,-8 1612,-8"/>
|
||||
<text text-anchor="middle" x="1839.5" y="-968.1" font-family="DejaVu Sans" font-size="13.00" fill="#0369a1">50 OUTILS — backend/tools/*.py · risque READ / WRITE / DANGEROUS</text>
|
||||
</g>
|
||||
<!-- user -->
|
||||
<g id="node1" class="node">
|
||||
<title>user</title>
|
||||
<polygon fill="#dbeafe" stroke="#333333" points="712.5,-743.5 561.5,-743.5 561.5,-705.5 712.5,-705.5 712.5,-743.5"/>
|
||||
<text text-anchor="middle" x="637" y="-727.9" font-family="DejaVu Sans" font-size="12.00">Demande utilisateur</text>
|
||||
<text text-anchor="middle" x="637" y="-714.9" font-family="DejaVu Sans" font-size="12.00">(frontend bookslm.js)</text>
|
||||
</g>
|
||||
<!-- api -->
|
||||
<g id="node3" class="node">
|
||||
<title>api</title>
|
||||
<polygon fill="#e0f2fe" stroke="#333333" points="742.5,-669 531.5,-669 531.5,-618 742.5,-618 742.5,-669"/>
|
||||
<text text-anchor="middle" x="637" y="-653.4" font-family="DejaVu Sans" font-size="12.00">POST /api/bookslm/agent</text>
|
||||
<text text-anchor="middle" x="637" y="-640.4" font-family="DejaVu Sans" font-size="12.00">→ réponse SSE (thought · tool ·</text>
|
||||
<text text-anchor="middle" x="637" y="-627.4" font-family="DejaVu Sans" font-size="12.00">confirmation · message · done)</text>
|
||||
</g>
|
||||
<!-- user->api -->
|
||||
<g id="edge1" class="edge">
|
||||
<title>user->api</title>
|
||||
<path fill="none" stroke="#555555" d="M637,-705.18C637,-696.86 637,-686.76 637,-677.2"/>
|
||||
<polygon fill="#555555" stroke="#555555" points="639.8,-677.01 637,-669.01 634.2,-677.01 639.8,-677.01"/>
|
||||
</g>
|
||||
<!-- deep -->
|
||||
<g id="node2" class="node">
|
||||
<title>deep</title>
|
||||
<polygon fill="#fef3c7" stroke="#333333" points="749,-919 519,-919 519,-842 755,-842 755,-913 749,-919"/>
|
||||
<polyline fill="none" stroke="#333333" points="749,-919 749,-913 "/>
|
||||
<polyline fill="none" stroke="#333333" points="755,-913 749,-913 "/>
|
||||
<text text-anchor="middle" x="637" y="-903.4" font-family="DejaVu Sans" font-size="12.00">Chip « Deep Research »</text>
|
||||
<text text-anchor="middle" x="637" y="-890.4" font-family="DejaVu Sans" font-size="12.00">= agent mode + prompt injecté :</text>
|
||||
<text text-anchor="middle" x="637" y="-877.4" font-family="DejaVu Sans" font-size="12.00">« décomposer les étapes, chercher</text>
|
||||
<text text-anchor="middle" x="637" y="-864.4" font-family="DejaVu Sans" font-size="12.00">dans le vault, croiser,</text>
|
||||
<text text-anchor="middle" x="637" y="-851.4" font-family="DejaVu Sans" font-size="12.00">synthétiser avec sources »</text>
|
||||
</g>
|
||||
<!-- deep->user -->
|
||||
<g id="edge2" class="edge">
|
||||
<title>deep->user</title>
|
||||
<path fill="none" stroke="#555555" stroke-dasharray="5,2" d="M637,-841.83C637,-813.95 637,-776.67 637,-751.91"/>
|
||||
<polygon fill="#555555" stroke="#555555" points="639.8,-751.71 637,-743.71 634.2,-751.71 639.8,-751.71"/>
|
||||
<text text-anchor="middle" x="652.5" y="-784.5" font-family="DejaVu Sans" font-size="10.00">active</text>
|
||||
</g>
|
||||
<!-- prompt -->
|
||||
<g id="node4" class="node">
|
||||
<title>prompt</title>
|
||||
<polygon fill="#f1f5f9" stroke="#333333" points="773.5,-585 500.5,-585 500.5,-508 773.5,-508 773.5,-585"/>
|
||||
<text text-anchor="middle" x="637" y="-569.4" font-family="DejaVu Sans" font-size="12.00">Construction du system prompt</text>
|
||||
<text text-anchor="middle" x="637" y="-556.4" font-family="DejaVu Sans" font-size="12.00">mode directory / documents / general</text>
|
||||
<text text-anchor="middle" x="637" y="-543.4" font-family="DejaVu Sans" font-size="12.00">contexte vault pré-chargé (BooksLM :</text>
|
||||
<text text-anchor="middle" x="637" y="-530.4" font-family="DejaVu Sans" font-size="12.00">≤200 fichiers, ≤200k chars, cache 5 min)</text>
|
||||
<text text-anchor="middle" x="637" y="-517.4" font-family="DejaVu Sans" font-size="12.00">protocole agent = function calling natif</text>
|
||||
</g>
|
||||
<!-- api->prompt -->
|
||||
<g id="edge3" class="edge">
|
||||
<title>api->prompt</title>
|
||||
<path fill="none" stroke="#555555" d="M637,-617.54C637,-610.12 637,-601.74 637,-593.43"/>
|
||||
<polygon fill="#555555" stroke="#555555" points="639.8,-593.17 637,-585.17 634.2,-593.17 639.8,-593.17"/>
|
||||
</g>
|
||||
<!-- llm -->
|
||||
<g id="node5" class="node">
|
||||
<title>llm</title>
|
||||
<polygon fill="#fee2e2" stroke="#333333" points="784.5,-453 489.5,-453 489.5,-376 784.5,-376 784.5,-453"/>
|
||||
<text text-anchor="middle" x="637" y="-437.4" font-family="DejaVu Sans" font-size="12.00">Appel LLM — chat_completion()</text>
|
||||
<text text-anchor="middle" x="637" y="-424.4" font-family="DejaVu Sans" font-size="12.00">fournisseur OpenAI-compatible / Gemini</text>
|
||||
<text text-anchor="middle" x="637" y="-411.4" font-family="DejaVu Sans" font-size="12.00">temp=0.3 · max_tokens 8192</text>
|
||||
<text text-anchor="middle" x="637" y="-398.4" font-family="DejaVu Sans" font-size="12.00">schemas des 50 outils exposés</text>
|
||||
<text text-anchor="middle" x="637" y="-385.4" font-family="DejaVu Sans" font-size="12.00">(si modèle sans tools → retombe chat simple)</text>
|
||||
</g>
|
||||
<!-- prompt->llm -->
|
||||
<g id="edge8" class="edge">
|
||||
<title>prompt->llm</title>
|
||||
<path fill="none" stroke="#555555" d="M637,-507.9C637,-493.35 637,-476.55 637,-461.3"/>
|
||||
<polygon fill="#555555" stroke="#555555" points="639.8,-461.02 637,-453.02 634.2,-461.02 639.8,-461.02"/>
|
||||
</g>
|
||||
<!-- decide -->
|
||||
<g id="node6" class="node">
|
||||
<title>decide</title>
|
||||
<polygon fill="#fff7ed" stroke="#333333" points="641,-325 489,-287 641,-249 793,-287 641,-325"/>
|
||||
<text text-anchor="middle" x="641" y="-290.4" font-family="DejaVu Sans" font-size="12.00">Le LLM demande des</text>
|
||||
<text text-anchor="middle" x="641" y="-277.4" font-family="DejaVu Sans" font-size="12.00">outils (tool_calls) ?</text>
|
||||
</g>
|
||||
<!-- llm->decide -->
|
||||
<g id="edge4" class="edge">
|
||||
<title>llm->decide</title>
|
||||
<path fill="none" stroke="#555555" d="M638.2,-375.85C638.63,-362.33 639.12,-346.95 639.57,-332.87"/>
|
||||
<polygon fill="#555555" stroke="#555555" points="642.37,-332.85 639.83,-324.76 636.77,-332.67 642.37,-332.85"/>
|
||||
</g>
|
||||
<!-- thought -->
|
||||
<g id="node7" class="node">
|
||||
<title>thought</title>
|
||||
<polygon fill="#fef9c3" stroke="#333333" points="786.5,-154.5 529.5,-154.5 529.5,-116.5 786.5,-116.5 786.5,-154.5"/>
|
||||
<text text-anchor="middle" x="658" y="-138.9" font-family="DejaVu Sans" font-size="12.00">note de raisonnement</text>
|
||||
<text text-anchor="middle" x="658" y="-125.9" font-family="DejaVu Sans" font-size="12.00">intermédiaire → step visible « pensée »</text>
|
||||
</g>
|
||||
<!-- decide->thought -->
|
||||
<g id="edge5" class="edge">
|
||||
<title>decide->thought</title>
|
||||
<path fill="none" stroke="#e11d48" d="M645.12,-249.81C648.17,-222.91 652.28,-186.83 655.02,-162.68"/>
|
||||
<polygon fill="#e11d48" stroke="#e11d48" points="657.81,-162.94 655.93,-154.67 652.25,-162.31 657.81,-162.94"/>
|
||||
<text text-anchor="middle" x="659.5" y="-205.2" font-family="DejaVu Sans" font-size="11.00">oui</text>
|
||||
</g>
|
||||
<!-- done -->
|
||||
<g id="node16" class="node">
|
||||
<title>done</title>
|
||||
<polygon fill="#bbf7d0" stroke="#333333" points="473,-167.5 275,-167.5 275,-103.5 473,-103.5 473,-167.5"/>
|
||||
<text text-anchor="middle" x="374" y="-151.9" font-family="DejaVu Sans" font-size="12.00">Réponse finale</text>
|
||||
<text text-anchor="middle" x="374" y="-138.9" font-family="DejaVu Sans" font-size="12.00">réponse sans tool_calls</text>
|
||||
<text text-anchor="middle" x="374" y="-125.9" font-family="DejaVu Sans" font-size="12.00">→ SSE « message »</text>
|
||||
<text text-anchor="middle" x="374" y="-112.9" font-family="DejaVu Sans" font-size="12.00">(Markdown + sources citées)</text>
|
||||
</g>
|
||||
<!-- decide->done -->
|
||||
<g id="edge19" class="edge">
|
||||
<title>decide->done</title>
|
||||
<path fill="none" stroke="#059669" stroke-width="2" d="M595.1,-260.3C551.33,-235.79 485.07,-198.69 436.49,-171.49"/>
|
||||
<polygon fill="#059669" stroke="#059669" stroke-width="2" points="437.81,-169.02 429.46,-167.55 435.07,-173.9 437.81,-169.02"/>
|
||||
<text text-anchor="middle" x="541.5" y="-205.2" font-family="DejaVu Sans" font-size="11.00">non</text>
|
||||
</g>
|
||||
<!-- final -->
|
||||
<g id="node17" class="node">
|
||||
<title>final</title>
|
||||
<polygon fill="#fed7aa" stroke="#333333" points="250,-174 0,-174 0,-97 250,-97 250,-174"/>
|
||||
<text text-anchor="middle" x="125" y="-158.4" font-family="DejaVu Sans" font-size="12.00">Budget épuisé (10 itérations</text>
|
||||
<text text-anchor="middle" x="125" y="-145.4" font-family="DejaVu Sans" font-size="12.00">ou 25 appels) → _finalize_answer :</text>
|
||||
<text text-anchor="middle" x="125" y="-132.4" font-family="DejaVu Sans" font-size="12.00">dernier appel SANS outils pour forcer</text>
|
||||
<text text-anchor="middle" x="125" y="-119.4" font-family="DejaVu Sans" font-size="12.00">une synthèse, sinon liste des sources</text>
|
||||
<text text-anchor="middle" x="125" y="-106.4" font-family="DejaVu Sans" font-size="12.00">déterministe (jamais de réponse vide)</text>
|
||||
</g>
|
||||
<!-- decide->final -->
|
||||
<g id="edge20" class="edge">
|
||||
<title>decide->final</title>
|
||||
<path fill="none" stroke="#ea580c" d="M565.01,-267.84C491.13,-250.18 390,-226 390,-226 390,-226 314.36,-200.46 243.91,-176.66"/>
|
||||
<polygon fill="#ea580c" stroke="#ea580c" points="244.71,-173.97 236.23,-174.07 242.92,-179.28 244.71,-173.97"/>
|
||||
<text text-anchor="middle" x="429.5" y="-205.2" font-family="DejaVu Sans" font-size="11.00">budget atteint</text>
|
||||
</g>
|
||||
<!-- tools -->
|
||||
<g id="node8" class="node">
|
||||
<title>tools</title>
|
||||
<polygon fill="#dcfce7" stroke="#333333" points="987,-57.5 787,-57.5 787,-19.5 987,-19.5 987,-57.5"/>
|
||||
<text text-anchor="middle" x="887" y="-41.9" font-family="DejaVu Sans" font-size="12.00">Exécution de chaque tool call</text>
|
||||
<text text-anchor="middle" x="887" y="-28.9" font-family="DejaVu Sans" font-size="12.00">via call_tool() (voir pipeline)</text>
|
||||
</g>
|
||||
<!-- thought->tools -->
|
||||
<g id="edge6" class="edge">
|
||||
<title>thought->tools</title>
|
||||
<path fill="none" stroke="#555555" d="M701.61,-116.41C739.89,-100.53 795.6,-77.42 835.95,-60.68"/>
|
||||
<polygon fill="#555555" stroke="#555555" points="837.23,-63.18 843.55,-57.53 835.09,-58 837.23,-63.18"/>
|
||||
</g>
|
||||
<!-- tools->llm -->
|
||||
<g id="edge7" class="edge">
|
||||
<title>tools->llm</title>
|
||||
<path fill="none" stroke="#e11d48" d="M881.93,-57.8C864.2,-121.83 806,-332 806,-332 806,-332 764.31,-352.1 722.35,-372.34"/>
|
||||
<polygon fill="#e11d48" stroke="#e11d48" points="721.06,-369.85 715.07,-375.85 723.49,-374.9 721.06,-369.85"/>
|
||||
<text text-anchor="middle" x="930" y="-217.2" font-family="DejaVu Sans" font-size="11.00">itération ≤ 10</text>
|
||||
<text text-anchor="middle" x="930" y="-205.2" font-family="DejaVu Sans" font-size="11.00">quota tools ≤ 25</text>
|
||||
<text text-anchor="middle" x="930" y="-193.2" font-family="DejaVu Sans" font-size="11.00">(BOOKSLM_MAX_TOOL_CALLS)</text>
|
||||
</g>
|
||||
<!-- p1 -->
|
||||
<g id="node10" class="node">
|
||||
<title>p1</title>
|
||||
<polygon fill="#ede9fe" stroke="#333333" points="1244,-743.5 1080,-743.5 1080,-705.5 1244,-705.5 1244,-743.5"/>
|
||||
<text text-anchor="middle" x="1162" y="-727.9" font-family="DejaVu Sans" font-size="12.00">1 · Schéma Pydantic</text>
|
||||
<text text-anchor="middle" x="1162" y="-714.9" font-family="DejaVu Sans" font-size="12.00">+ validation arguments</text>
|
||||
</g>
|
||||
<!-- tools->p1 -->
|
||||
<g id="edge15" class="edge">
|
||||
<title>tools->p1</title>
|
||||
<path fill="none" stroke="#555555" d="M927.18,-57.59C988.4,-85.16 1098,-134.5 1098,-134.5 1098,-644.5 1098,-644.5 1098,-644.5 1098,-644.5 1123.27,-675.7 1142.06,-698.88"/>
|
||||
<polygon fill="#555555" stroke="#555555" points="1139.94,-700.72 1147.16,-705.18 1144.3,-697.2 1139.94,-700.72"/>
|
||||
</g>
|
||||
<!-- back -->
|
||||
<g id="node9" class="node">
|
||||
<title>back</title>
|
||||
<polygon fill="#333333" stroke="transparent" points="1056,-440 810,-440 810,-389 1056,-389 1056,-440"/>
|
||||
<text text-anchor="middle" x="933" y="-424.4" font-family="DejaVu Sans" font-size="12.00">résultat (JSON, tronqué ≤100k chars)</text>
|
||||
<text text-anchor="middle" x="933" y="-411.4" font-family="DejaVu Sans" font-size="12.00">ajouté à la conversation →</text>
|
||||
<text text-anchor="middle" x="933" y="-398.4" font-family="DejaVu Sans" font-size="12.00">itération suivante</text>
|
||||
</g>
|
||||
<!-- back->p1 -->
|
||||
<!-- p2 -->
|
||||
<g id="node11" class="node">
|
||||
<title>p2</title>
|
||||
<polygon fill="#ede9fe" stroke="#333333" points="1282,-662.5 1146,-662.5 1146,-624.5 1282,-624.5 1282,-662.5"/>
|
||||
<text text-anchor="middle" x="1214" y="-646.9" font-family="DejaVu Sans" font-size="12.00">2 · Rate limit</text>
|
||||
<text text-anchor="middle" x="1214" y="-633.9" font-family="DejaVu Sans" font-size="12.00">par identité + outil</text>
|
||||
</g>
|
||||
<!-- p1->p2 -->
|
||||
<g id="edge9" class="edge">
|
||||
<title>p1->p2</title>
|
||||
<path fill="none" stroke="#555555" d="M1174.06,-705.18C1181.02,-694.61 1189.86,-681.17 1197.46,-669.62"/>
|
||||
<polygon fill="#555555" stroke="#555555" points="1199.95,-670.95 1202,-662.72 1195.27,-667.87 1199.95,-670.95"/>
|
||||
</g>
|
||||
<!-- p3 -->
|
||||
<g id="node12" class="node">
|
||||
<title>p3</title>
|
||||
<polygon fill="#ede9fe" stroke="#333333" points="1321.5,-572 1168.5,-572 1168.5,-521 1321.5,-521 1321.5,-572"/>
|
||||
<text text-anchor="middle" x="1245" y="-556.4" font-family="DejaVu Sans" font-size="12.00">3 · Permission vault</text>
|
||||
<text text-anchor="middle" x="1245" y="-543.4" font-family="DejaVu Sans" font-size="12.00">(+ déstructif si risque</text>
|
||||
<text text-anchor="middle" x="1245" y="-530.4" font-family="DejaVu Sans" font-size="12.00">DANGEROUS)</text>
|
||||
</g>
|
||||
<!-- p2->p3 -->
|
||||
<g id="edge10" class="edge">
|
||||
<title>p2->p3</title>
|
||||
<path fill="none" stroke="#555555" d="M1219.98,-624.18C1224.04,-611.72 1229.54,-594.88 1234.35,-580.15"/>
|
||||
<polygon fill="#555555" stroke="#555555" points="1237.11,-580.7 1236.93,-572.23 1231.79,-578.96 1237.11,-580.7"/>
|
||||
</g>
|
||||
<!-- p4 -->
|
||||
<g id="node13" class="node">
|
||||
<title>p4</title>
|
||||
<polygon fill="#f5f3ff" stroke="#333333" points="1278,-452.5 1136,-414.5 1278,-376.5 1420,-414.5 1278,-452.5"/>
|
||||
<text text-anchor="middle" x="1278" y="-417.9" font-family="DejaVu Sans" font-size="12.00">4 · Risque READ ?</text>
|
||||
<text text-anchor="middle" x="1278" y="-404.9" font-family="DejaVu Sans" font-size="12.00">→ exécution directe</text>
|
||||
</g>
|
||||
<!-- p3->p4 -->
|
||||
<g id="edge11" class="edge">
|
||||
<title>p3->p4</title>
|
||||
<path fill="none" stroke="#555555" d="M1251.28,-520.74C1255.74,-503.18 1261.84,-479.16 1267.08,-458.5"/>
|
||||
<polygon fill="#555555" stroke="#555555" points="1269.87,-458.92 1269.12,-450.48 1264.44,-457.54 1269.87,-458.92"/>
|
||||
</g>
|
||||
<!-- p5 -->
|
||||
<g id="node14" class="node">
|
||||
<title>p5</title>
|
||||
<polygon fill="#fde68a" stroke="#333333" points="1413.5,-332 1136.5,-332 1136.5,-242 1413.5,-242 1413.5,-332"/>
|
||||
<text text-anchor="middle" x="1275" y="-316.4" font-family="DejaVu Sans" font-size="12.00">PAUSE CONFIRMATION</text>
|
||||
<text text-anchor="middle" x="1275" y="-303.4" font-family="DejaVu Sans" font-size="12.00">outil WRITE/DANGEROUS → event SSE</text>
|
||||
<text text-anchor="middle" x="1275" y="-290.4" font-family="DejaVu Sans" font-size="12.00">« confirmation » avec le plan complet</text>
|
||||
<text text-anchor="middle" x="1275" y="-277.4" font-family="DejaVu Sans" font-size="12.00">batché (BUG-075) : une seule</text>
|
||||
<text text-anchor="middle" x="1275" y="-264.4" font-family="DejaVu Sans" font-size="12.00">approbation applique toutes les mutations</text>
|
||||
<text text-anchor="middle" x="1275" y="-251.4" font-family="DejaVu Sans" font-size="12.00">(resume via confirm / confirm_all)</text>
|
||||
</g>
|
||||
<!-- p4->p5 -->
|
||||
<g id="edge13" class="edge">
|
||||
<title>p4->p5</title>
|
||||
<path fill="none" stroke="#d97706" d="M1277.12,-376.52C1276.85,-365.25 1276.54,-352.62 1276.26,-340.52"/>
|
||||
<polygon fill="#d97706" stroke="#d97706" points="1279.05,-340.18 1276.06,-332.25 1273.45,-340.31 1279.05,-340.18"/>
|
||||
<text text-anchor="middle" x="1301" y="-351.2" font-family="DejaVu Sans" font-size="11.00">mutation</text>
|
||||
</g>
|
||||
<!-- p6 -->
|
||||
<g id="node15" class="node">
|
||||
<title>p6</title>
|
||||
<polygon fill="#dcfce7" stroke="#333333" points="1395.5,-161 1142.5,-161 1142.5,-110 1395.5,-110 1395.5,-161"/>
|
||||
<text text-anchor="middle" x="1269" y="-145.4" font-family="DejaVu Sans" font-size="12.00">5 · Handler exécuté</text>
|
||||
<text text-anchor="middle" x="1269" y="-132.4" font-family="DejaVu Sans" font-size="12.00">6 · Audit JSON-lines</text>
|
||||
<text text-anchor="middle" x="1269" y="-119.4" font-family="DejaVu Sans" font-size="12.00">7 · Redaction secrets (redact_payload)</text>
|
||||
</g>
|
||||
<!-- p4->p6 -->
|
||||
<g id="edge12" class="edge">
|
||||
<title>p4->p6</title>
|
||||
<path fill="none" stroke="#555555" d="M1323.61,-388.38C1367.1,-364.44 1426,-332 1426,-332 1426,-332 1426,-242 1426,-242 1426,-242 1358.97,-196.96 1312.51,-165.73"/>
|
||||
<polygon fill="#555555" stroke="#555555" points="1314.01,-163.37 1305.81,-161.24 1310.89,-168.02 1314.01,-163.37"/>
|
||||
<text text-anchor="middle" x="1438.5" y="-284.2" font-family="DejaVu Sans" font-size="11.00">read</text>
|
||||
</g>
|
||||
<!-- p5->p6 -->
|
||||
<g id="edge14" class="edge">
|
||||
<title>p5->p6</title>
|
||||
<path fill="none" stroke="#555555" stroke-dasharray="5,2" d="M1273.23,-241.95C1272.3,-218.69 1271.17,-190.62 1270.32,-169.29"/>
|
||||
<polygon fill="#555555" stroke="#555555" points="1273.11,-169.06 1269.99,-161.18 1267.51,-169.28 1273.11,-169.06"/>
|
||||
<text text-anchor="middle" x="1298" y="-205.2" font-family="DejaVu Sans" font-size="11.00">approuvé</text>
|
||||
</g>
|
||||
<!-- p6->tools -->
|
||||
<!-- p6->back -->
|
||||
<!-- cluster_tools -->
|
||||
<g id="node27" class="node">
|
||||
<title>cluster_tools</title>
|
||||
<polygon fill="#333333" stroke="#333333" points="1319,-56.5 1219,-56.5 1219,-20.5 1319,-20.5 1319,-56.5"/>
|
||||
<text text-anchor="middle" x="1269" y="-35.4" font-family="DejaVu Sans" font-size="12.00">cluster_tools</text>
|
||||
</g>
|
||||
<!-- p6->cluster_tools -->
|
||||
<!-- answer -->
|
||||
<g id="node18" class="node">
|
||||
<title>answer</title>
|
||||
<polygon fill="#dbeafe" stroke="#333333" points="347,-64 151,-64 151,-13 347,-13 347,-64"/>
|
||||
<text text-anchor="middle" x="249" y="-48.4" font-family="DejaVu Sans" font-size="12.00">Affichage UI : bloc</text>
|
||||
<text text-anchor="middle" x="249" y="-35.4" font-family="DejaVu Sans" font-size="12.00">« N étapes » Notion-style</text>
|
||||
<text text-anchor="middle" x="249" y="-22.4" font-family="DejaVu Sans" font-size="12.00">(pensées + outils dépliables)</text>
|
||||
</g>
|
||||
<!-- done->answer -->
|
||||
<g id="edge21" class="edge">
|
||||
<title>done->answer</title>
|
||||
<path fill="none" stroke="#555555" d="M333.07,-103.39C318.5,-92.32 302.14,-79.89 287.76,-68.96"/>
|
||||
<polygon fill="#555555" stroke="#555555" points="289.37,-66.66 281.3,-64.05 285.98,-71.12 289.37,-66.66"/>
|
||||
</g>
|
||||
<!-- final->answer -->
|
||||
<g id="edge22" class="edge">
|
||||
<title>final->answer</title>
|
||||
<path fill="none" stroke="#555555" d="M174.01,-96.95C186.01,-87.76 198.68,-78.05 210.13,-69.28"/>
|
||||
<polygon fill="#555555" stroke="#555555" points="212.07,-71.32 216.71,-64.24 208.66,-66.88 212.07,-71.32"/>
|
||||
</g>
|
||||
<!-- fam_vault -->
|
||||
<g id="node19" class="node">
|
||||
<title>fam_vault</title>
|
||||
<polygon fill="#f0f9ff" stroke="#333333" points="1958,-952.5 1720,-952.5 1720,-808.5 1958,-808.5 1958,-952.5"/>
|
||||
<text text-anchor="middle" x="1839" y="-938.5" font-family="DejaVu Sans" font-size="10.00">VAULT · service.py (22)</text>
|
||||
<text text-anchor="middle" x="1839" y="-927.5" font-family="DejaVu Sans" font-size="10.00">lecture : list_vaults, list_directory,</text>
|
||||
<text text-anchor="middle" x="1839" y="-916.5" font-family="DejaVu Sans" font-size="10.00">list_all_files, read_file, read_file_raw,</text>
|
||||
<text text-anchor="middle" x="1839" y="-905.5" font-family="DejaVu Sans" font-size="10.00">search_fulltext, search_advanced,</text>
|
||||
<text text-anchor="middle" x="1839" y="-894.5" font-family="DejaVu Sans" font-size="10.00">search_paths, get_graph, get_backlinks,</text>
|
||||
<text text-anchor="middle" x="1839" y="-883.5" font-family="DejaVu Sans" font-size="10.00">list_tags, suggest_tags, list_recent,</text>
|
||||
<text text-anchor="middle" x="1839" y="-872.5" font-family="DejaVu Sans" font-size="10.00">list_backups, diff_backup</text>
|
||||
<text text-anchor="middle" x="1839" y="-861.5" font-family="DejaVu Sans" font-size="10.00">écriture : create_file, create_directory,</text>
|
||||
<text text-anchor="middle" x="1839" y="-850.5" font-family="DejaVu Sans" font-size="10.00">append_to_file, edit_file, restore_backup</text>
|
||||
<text text-anchor="middle" x="1839" y="-839.5" font-family="DejaVu Sans" font-size="10.00">DANGEROUS : delete_file, delete_directory,</text>
|
||||
<text text-anchor="middle" x="1839" y="-828.5" font-family="DejaVu Sans" font-size="10.00">move_path, rename_file, rename_directory,</text>
|
||||
<text text-anchor="middle" x="1839" y="-817.5" font-family="DejaVu Sans" font-size="10.00">replace_in_files</text>
|
||||
</g>
|
||||
<!-- fam_web -->
|
||||
<g id="node20" class="node">
|
||||
<title>fam_web</title>
|
||||
<polygon fill="#f0f9ff" stroke="#333333" points="1938.5,-747 1739.5,-747 1739.5,-702 1938.5,-702 1938.5,-747"/>
|
||||
<text text-anchor="middle" x="1839" y="-733" font-family="DejaVu Sans" font-size="10.00">WEB · web.py + crawler.py</text>
|
||||
<text text-anchor="middle" x="1839" y="-722" font-family="DejaVu Sans" font-size="10.00">web_search · fetch_url</text>
|
||||
<text text-anchor="middle" x="1839" y="-711" font-family="DejaVu Sans" font-size="10.00">crawl_site (≤20 pages, même hôte)</text>
|
||||
</g>
|
||||
<!-- fam_vault->fam_web -->
|
||||
<!-- fam_doc -->
|
||||
<g id="node21" class="node">
|
||||
<title>fam_doc</title>
|
||||
<polygon fill="#f0f9ff" stroke="#333333" points="1922,-666 1756,-666 1756,-621 1922,-621 1922,-666"/>
|
||||
<text text-anchor="middle" x="1839" y="-652" font-family="DejaVu Sans" font-size="10.00">DOCUMENTS · documents.py</text>
|
||||
<text text-anchor="middle" x="1839" y="-641" font-family="DejaVu Sans" font-size="10.00">create_pdf · create_docx</text>
|
||||
<text text-anchor="middle" x="1839" y="-630" font-family="DejaVu Sans" font-size="10.00">create_xlsx · create_csv</text>
|
||||
</g>
|
||||
<!-- fam_web->fam_doc -->
|
||||
<!-- fam_xlsx -->
|
||||
<g id="node22" class="node">
|
||||
<title>fam_xlsx</title>
|
||||
<polygon fill="#f0f9ff" stroke="#333333" points="1944.5,-580 1733.5,-580 1733.5,-513 1944.5,-513 1944.5,-580"/>
|
||||
<text text-anchor="middle" x="1839" y="-566" font-family="DejaVu Sans" font-size="10.00">TABLEURS · spreadsheets.py</text>
|
||||
<text text-anchor="middle" x="1839" y="-555" font-family="DejaVu Sans" font-size="10.00">list_xlsx_sheets · xlsx_to_markdown</text>
|
||||
<text text-anchor="middle" x="1839" y="-544" font-family="DejaVu Sans" font-size="10.00">search_workbook · analyze_range</text>
|
||||
<text text-anchor="middle" x="1839" y="-533" font-family="DejaVu Sans" font-size="10.00">update_xlsx_cells · append_xlsx_rows</text>
|
||||
<text text-anchor="middle" x="1839" y="-522" font-family="DejaVu Sans" font-size="10.00">edit_xlsx_structure</text>
|
||||
</g>
|
||||
<!-- fam_doc->fam_xlsx -->
|
||||
<!-- fam_dup -->
|
||||
<g id="node23" class="node">
|
||||
<title>fam_dup</title>
|
||||
<polygon fill="#f0f9ff" stroke="#333333" points="1929.5,-437 1748.5,-437 1748.5,-392 1929.5,-392 1929.5,-437"/>
|
||||
<text text-anchor="middle" x="1839" y="-423" font-family="DejaVu Sans" font-size="10.00">DOUBLONS · duplicates.py</text>
|
||||
<text text-anchor="middle" x="1839" y="-412" font-family="DejaVu Sans" font-size="10.00">find_duplicates</text>
|
||||
<text text-anchor="middle" x="1839" y="-401" font-family="DejaVu Sans" font-size="10.00">merge_duplicate_notes (DANG.)</text>
|
||||
</g>
|
||||
<!-- fam_xlsx->fam_dup -->
|
||||
<!-- fam_git -->
|
||||
<g id="node24" class="node">
|
||||
<title>fam_git</title>
|
||||
<polygon fill="#f0f9ff" stroke="#333333" points="1931,-309.5 1747,-309.5 1747,-264.5 1931,-264.5 1931,-309.5"/>
|
||||
<text text-anchor="middle" x="1839" y="-295.5" font-family="DejaVu Sans" font-size="10.00">GIT CONNECTÉ · connected.py</text>
|
||||
<text text-anchor="middle" x="1839" y="-284.5" font-family="DejaVu Sans" font-size="10.00">git_list_repos · git_get_file</text>
|
||||
<text text-anchor="middle" x="1839" y="-273.5" font-family="DejaVu Sans" font-size="10.00">git_search_issues (Gitea/GitHub)</text>
|
||||
</g>
|
||||
<!-- fam_dup->fam_git -->
|
||||
<!-- fam_sched -->
|
||||
<g id="node25" class="node">
|
||||
<title>fam_sched</title>
|
||||
<polygon fill="#f0f9ff" stroke="#333333" points="1942.5,-153.5 1735.5,-153.5 1735.5,-117.5 1942.5,-117.5 1942.5,-153.5"/>
|
||||
<text text-anchor="middle" x="1839" y="-138.5" font-family="DejaVu Sans" font-size="10.00">SCHEDULER · scheduled.py</text>
|
||||
<text text-anchor="middle" x="1839" y="-127.5" font-family="DejaVu Sans" font-size="10.00">list/create/delete/run_scheduled_task</text>
|
||||
</g>
|
||||
<!-- fam_git->fam_sched -->
|
||||
<!-- fam_notify -->
|
||||
<g id="node26" class="node">
|
||||
<title>fam_notify</title>
|
||||
<polygon fill="#f0f9ff" stroke="#333333" points="1917.5,-61 1760.5,-61 1760.5,-16 1917.5,-16 1917.5,-61"/>
|
||||
<text text-anchor="middle" x="1839" y="-47" font-family="DejaVu Sans" font-size="10.00">NOTIFICATIONS · notify.py</text>
|
||||
<text text-anchor="middle" x="1839" y="-36" font-family="DejaVu Sans" font-size="10.00">notify_external (Discord,</text>
|
||||
<text text-anchor="middle" x="1839" y="-25" font-family="DejaVu Sans" font-size="10.00">Telegram, SMTP, webhook)</text>
|
||||
</g>
|
||||
<!-- fam_sched->fam_notify -->
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 26 KiB |
@@ -419,6 +419,24 @@ première modification et téléchargement du fichier d'origine.
|
||||
|
||||
---
|
||||
|
||||
## #157 — Recherche : facette « Extensions » ✅ TERMINÉ
|
||||
|
||||
Le panneau de facettes de la page de résultats de recherche affiche désormais **trois** groupes :
|
||||
**Vaults**, **Tags** et **Extensions**. Le troisième liste les extensions de fichiers présents
|
||||
dans le résultat courant (`.md`, `.xlsx`, `.pdf`, …) avec leur compteur ; un clic ajoute
|
||||
l'opérateur `ext:<type>` à la requête (un `ext:` déjà actif est remplacé, jamais dupliqué).
|
||||
L'ensemble du panneau se replie/rouvre d'un clic sur un bouton discret (chevron), état
|
||||
mémorisé dans `localStorage`.
|
||||
|
||||
| Aspect | Détail |
|
||||
|---|---|
|
||||
| Backend | `backend/search.py::advanced_search` — `facets.extensions` : compteurs par extension normalisée (sans point, en minuscules), triés par fréquence · **`backend/schemas.py::SearchFacets`** expose le champ (sinon le `response_model` le supprimait — corrigé après livraison de 2.46.0) |
|
||||
| Frontend | `frontend/js/search.js::renderAdvancedSearchResults` — groupe `[data-facet="extensions"]`, items montés en `textContent` (les suffixes viennent de noms de fichiers non fiables) ; panneau repliable via `.search-facets__toggle` + `data-collapsed` (état `obsigate_facets_collapsed`) |
|
||||
| i18n / aide | `help.desc_facet_extensions`, `search.facets_collapse`/`_expand` (FR/EN), guide `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md` |
|
||||
| Tests | `tests/test_search.py::test_facets` (route réelle `/api/search/advanced`, assertion stricte `facets.extensions`), `tests/frontend/search-facets.test.mjs` (5 tests JSDOM, ajoutés au CI), e2e `obsigate.spec.ts` (facette visible + repli/rouverture) |
|
||||
|
||||
---
|
||||
|
||||
## Grosses fonctionnalités — fiches dédiées
|
||||
|
||||
| # | Feature | Version | Fiche |
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# #166 · #168 · #170 — Agent IA phase 4 : doublons, notifications externes, tâches planifiées
|
||||
|
||||
> **Statut :** ✅ livré — **Effort :** ~5 jours | **Impacts :** 🟢
|
||||
> **Références :** [Roadmap](../ROADMAP.md#--évolutions-agent-ia-liste-v12--phase-4--ids-obsigate-165--178) · [Changelog](../../CHANGELOG.md)
|
||||
> **Guides :** [Assistant IA & Forge](../GUIDES/ASSISTANT_IA_FORGE.md) · [API REST](../GUIDES/API_REST.md) · [MCP](../GUIDES/MCP.md)
|
||||
|
||||
## 1. Périmètre
|
||||
|
||||
Trois items de la liste v1.2 « Phase 4 » livrés ensemble car ils partagent le
|
||||
même socle (registre `@tool`, stores JSON verrouillés, notifications) :
|
||||
|
||||
| ID | Fonctionnalité | Entrées |
|
||||
|---|---|---|
|
||||
| #166 | Détection & fusion de doublons | `backend/services/duplicates.py`, `backend/tools/duplicates.py`, `backend/routers/duplicates.py` |
|
||||
| #168 | Notifications externes Discord / Telegram / SMTP / webhook | `backend/notify.py`, `backend/tools/notify.py`, `backend/routers/notify.py` |
|
||||
| #170 | Tâches planifiées type cron | `backend/scheduler.py`, `backend/tools/scheduled.py`, `backend/routers/scheduler.py` |
|
||||
|
||||
Règle transverse respectée : **tout nouvel outil = `@tool` + libellé
|
||||
`labels.py` + clés i18n `ai.step.*` FR/EN + tests** (cf. `ai-tools-roadmap.md` §2).
|
||||
|
||||
## 2. #166 — Doublons
|
||||
|
||||
- **Score déterministe stdlib** (`similarity_score`) : Jaccard sur tokens
|
||||
(frontmatter exclu, accents conservés) à 70 % + similarité du titre/first-line
|
||||
(`difflib`) à 30 %. Pas de dépendance embeddings — l'index sémantique #70
|
||||
reste un raffinement optionnel, pas un prérequis.
|
||||
- **Scan borné** : 500 fichiers `.md` max, 200 Ko/fichier, pré-filtre Jaccard
|
||||
avant le score complet, `truncated` exposé quand le plafond est atteint.
|
||||
- **Fusion jamais sans filet** : backup des 2 fichiers avant écriture,
|
||||
outil `merge_duplicate_notes` en `DANGEROUS` (carte « Tout approuver »),
|
||||
route `POST /api/duplicates/merge` exige `{confirm: true}`, stratégies
|
||||
`append` (défaut, avec marqueur d'origine) / `prefer_target` / `prefer_source`.
|
||||
- **Outils** : `find_duplicates` (READ), `merge_duplicate_notes` (DANGEROUS).
|
||||
|
||||
## 3. #168 — Notifications externes
|
||||
|
||||
Canaux `discord` (webhook `discord.com`), `telegram` (Bot API + `chat_id`),
|
||||
`smtp` (stdlib, STARTTLS + login) et `webhook` générique (JSON
|
||||
`{event, title, message, timestamp, source}`).
|
||||
|
||||
- **Secrets** : jamais dans `notify_channels.json` — store `notify_secrets.json`
|
||||
(0600) ou `OBSIGATE_NOTIFY_SECRET_<ID>` (même motif que #9 / BUG-026) ;
|
||||
l'API n'expose que `***` + `has_secret`. Le token Telegram peut aussi venir
|
||||
de `OBSIGATE_TELEGRAM_BOT_TOKEN`.
|
||||
- **SSRF** : `validate_webhook_url` / `is_safe_target` réutilisés pour les
|
||||
webhooks génériques et Telegram ; Discord valide son préfixe d'URL.
|
||||
- **Déclencheurs** : `manual`, `schedule_failure`, `schedule_success`,
|
||||
`duplicate_found` — choisis par canal. `broadcast()` n'échoue jamais en bloc
|
||||
(résultat par canal, `last_error` persisté).
|
||||
- **CRUD admin** (`/api/notify/channels`), test d'envoi authentifié
|
||||
(`POST /api/notify/test`), outil `notify_external` (WRITE → confirmation).
|
||||
|
||||
## 4. #170 — Tâches planifiées
|
||||
|
||||
Store `data/scheduled_tasks.json` (RLock, écriture atomique tmp+replace).
|
||||
Actions = outils existants, aucun nouveau chemin d'écriture :
|
||||
|
||||
- `create_file` / `append_to_file` → `backend.services.mutations` ;
|
||||
- `notify` → `backend.notify.broadcast`.
|
||||
|
||||
Planifications `interval_hours` (≥ 0,25), `daily_time` (`HH:MM`) et `once_at`
|
||||
(ISO-8601, one-shot désactivé après exécution). `tick()` exécute les tâches
|
||||
dues, enregistre `last_status`/`last_error`/`run_count`/`next_run_at` et émet
|
||||
`schedule_failure` via #168 (sauf quand l'action elle-même est `notify` —
|
||||
anti-récursion). Boucle de fond dans le lifespan de `main.py` (tick 60 s via
|
||||
`asyncio.to_thread`, désactivable par `OBSIGATE_SCHEDULER=0`).
|
||||
|
||||
Outils : `create_scheduled_task` / `list_scheduled_tasks` /
|
||||
`delete_scheduled_task` / `run_scheduled_task_now` (WRITE sauf list).
|
||||
La création vérifie l'accès au vault **et** son existence (404 sinon).
|
||||
|
||||
## 5. API REST (toutes avec `response_model`)
|
||||
|
||||
| Route | Rôle |
|
||||
|---|---|
|
||||
| `GET /api/duplicates?vault&threshold&limit&subdir` | paires candidates |
|
||||
| `POST /api/duplicates/merge` | fusion (`confirm: true` obligatoire) |
|
||||
| `GET/POST /api/notify/channels`, `PATCH/DELETE /api/notify/channels/{id}` | CRUD admin |
|
||||
| `POST /api/notify/test` | test broadcast ou canal ciblé |
|
||||
| `GET/POST /api/scheduler/tasks`, `PATCH/DELETE /api/scheduler/tasks/{id}`, `POST …/run` | CRUD + exécution manuelle |
|
||||
|
||||
## 6. Tests
|
||||
|
||||
`tests/test_duplicates.py` (16), `tests/test_notify_channels.py` (14),
|
||||
`tests/test_scheduler.py` (18) : services, routes (fixture `client`,
|
||||
auth désactivée), outils (confirmation `DANGEROUS` vérifiée), stores isolés
|
||||
en tmp, réseau mocké (jamais d'Internet en CI). Labels couverts par le
|
||||
garde-fou `test_tool_labels.py` (tout outil IN_APP doit avoir son libellé).
|
||||
|
||||
## 7. Limites assumées (V1)
|
||||
|
||||
- Similarité lexicale (pas d'embeddings) — seuils réglables par l'agent.
|
||||
- Pas d'UI dédiée (API + agent uniquement) ; les clés i18n des étapes
|
||||
existent déjà pour la section « N étapes ».
|
||||
- Scheduler in-process (pas de persistance distribuée, tick 60 s) ;
|
||||
Redis/APScheduler resteraient l'option multi-workers.
|
||||
- SMTP sans OAuth2 (login STARTTLS) ; Slack natif non ciblé (webhook
|
||||
générique compatible `incoming-webhook` utilisable tel quel).
|
||||
@@ -0,0 +1,96 @@
|
||||
# #186 — Création d'un fichier Excel depuis « Créer un fichier »
|
||||
|
||||
> **Statut :** ✅ livré le 2026-10-05 | **Effort :** 0,5 jour | **Impact :** 🟢
|
||||
> **Références :** [Roadmap](../ROADMAP.md) · [Changelog](../../CHANGELOG.md) ·
|
||||
> [Guide Recherche, PDF, Excel & Excalidraw](../GUIDES/RECHERCHE_PDF_EXCALIDRAW.md) ·
|
||||
> [#153 — Visionneuse XLSX](./xlsx-viewer.md)
|
||||
|
||||
## 1. Constat
|
||||
|
||||
ObsiGate sait **lire et éditer** les classeurs depuis #152/#153, et l'assistant IA sait
|
||||
en **produire** un (`create_xlsx`, #92). Mais la modale « Créer un fichier » — celle
|
||||
du menu contextuel d'un vault / d'un dossier et de la palette de commandes —
|
||||
ne proposait que des formats **texte** :
|
||||
|
||||
`.md`, `.txt`, `.py`, `.js`, `.json`, `.yaml`, `.sh`, `.ps1` (+ Excalidraw).
|
||||
|
||||
Démarrer un classeur depuis l'UI imposait donc de passer par l'IA (avec
|
||||
confirmation) ou de déposer un fichier existant dans le vault. Le point de départ
|
||||
naturel manquait alors que l'éditeur complet était disponible.
|
||||
|
||||
## 2. Livré (A1-A3)
|
||||
|
||||
### A1 — Option Excel dans la liste des types
|
||||
|
||||
`frontend/js/ui.js`, `FileOperations.showCreateFileModal()` : ajout de
|
||||
`<option value=".xlsx">` dans le `<select id="file-ext-select">`, après `.json`
|
||||
(ordre : formats texte, puis formats enrichis, comme `.excalidraw`).
|
||||
|
||||
Au passage, les **dix libellés** du sélecteur passent en i18n
|
||||
(`modal.ext_md`, `modal.ext_txt`, …, `modal.ext_xlsx`) : ils étaient des chaînes
|
||||
françaises codées en dur, ce qui exposait un texte non traduit en mode EN et
|
||||
violait la règle « tout texte d'interface existe en FR et en EN ». Les deux
|
||||
locales sont alignées (`tests/test_i18n_parity.py`).
|
||||
|
||||
Aucun changement pour les autres formats : le nom saisi sans extension reçoit
|
||||
celle choisie (`name.endsWith(ext) ? name : name + ext`), donc un nom déjà
|
||||
suffiché n'est jamais doublé.
|
||||
|
||||
### A2 — Un vrai classeur côté backend
|
||||
|
||||
Le point non évident : `POST /api/file/{vault}` **écrit du texte**
|
||||
(`file_path.write_text(content)`), et la modale poste `content: ""`. Un `.xlsx`
|
||||
n'est pas un fichier texte mais une **archive OPC** (un ZIP) : écrire la charge
|
||||
utile vide produisait un fichier de 0 octet que ni `openpyxl`, ni la visionneuse,
|
||||
ni Excel n'ouvrent — l'utilisateur aurait vu une erreur à l'ouverture, ou pire,
|
||||
un fichier corrompu.
|
||||
|
||||
`backend/services/mutations.py` :
|
||||
|
||||
- `_empty_xlsx_bytes()` construit l'archive avec openpyxl (une feuille `Feuille1`,
|
||||
le titre par défaut déjà utilisé par l'outil IA `create_xlsx`) — import paresseux,
|
||||
comme le reste du module qui garde openpyxl optionnel à l'import ;
|
||||
- `create_file()` écrit ces octets (`write_bytes`) pour cette extension et ignore
|
||||
`content`, qui n'a pas de sens pour un classeur ; `size` renvoie le nombre
|
||||
d'octets réellement écrits (et non `len(content)`, qui aurait valu 0) ;
|
||||
- même garde-fou que `.excalidraw` : le squelette est injecté par le **service**,
|
||||
donc la palette, le glisser-déposer, l'IA et l'API en bénéficient, pas seulement
|
||||
la modale.
|
||||
|
||||
`openpyxl` reste importé à la demande : un vault sans tableur ne paie pas l'import.
|
||||
|
||||
### A3 — Tests
|
||||
|
||||
- `tests/test_api_main.py::TestFileCRUD` — `test_create_xlsx_empty_workbook` : le
|
||||
fichier créé est relu par `openpyxl` (`sheetnames == ["Feuille1"]`) **et** servi
|
||||
par `GET /api/file/{vault}` avec `is_xlsx: true` (donc lisible par la visionneuse) ;
|
||||
`test_create_xlsx_in_new_subdir` couvre la création dans un dossier inexistant
|
||||
(auto-créé). Non-régression : sans A2, l'archive serait vide et les deux tests
|
||||
échoueraient.
|
||||
- `tests/e2e/xlsx-viewer.spec.js` — bloc « création depuis l'UI » : l'option
|
||||
`.xlsx` existe et son libellé vient des locales, la création passe par le menu
|
||||
contextuel d'un dossier, et le classeur s'ouvre dans la grille (`.xlsx-table` +
|
||||
onglet `Feuille1`) ; l'API est interrogée pour confirmer `is_xlsx`. Le fichier est
|
||||
supprimé en fin de test (le run ne doit pas salir `test_vault/`).
|
||||
|
||||
## 3. Limites assumées
|
||||
|
||||
- **Le nom de feuille est `Feuille1`**, en dur. Un vrai *nouveau classeur* de
|
||||
Google Sheets le demande ; ici le classeur s'ouvre sur sa seule feuille et
|
||||
l'utilisateur la renomme via le menu Structure. La modale reste celle d'un
|
||||
éditeur de texte, volontairement simple.
|
||||
- **`.csv` reste absent de la liste**, alors que le backend l'accepte
|
||||
(`SUPPORTED_EXTENSIONS`) et que la visionneuse sait l'éditer comme tableur
|
||||
(#156-A14). L'écart est réel mais hors périmètre de #186 ; le même correctif
|
||||
s'appliquerait (une ligne dans le `<select>`).
|
||||
- **`frontend/app.js` (monolithe obsolète, non chargé par `index.html`)**
|
||||
duplique l'ancienne liste sans `.xlsx`. Le code vivant est dans `frontend/js/`;
|
||||
ce fichier n'a pas été touché.
|
||||
- L'outil IA `create_xlsx` reste le chemin pour un classeur **rempli** : la modale
|
||||
crée une grille vierge, comme un `.md` vierge.
|
||||
|
||||
## 4. Historique
|
||||
|
||||
| Date | Événement |
|
||||
|---|---|
|
||||
| 2026-10-05 | #186 ouvert puis livré dans la foulée : option `.xlsx` + squelette openpyxl dans `create_file()` |
|
||||
@@ -86,3 +86,40 @@
|
||||
- Python 3.11 embed — téléchargé depuis python.org
|
||||
- NSIS (Windows) — pour le générateur d'installateur `.exe`
|
||||
- AppImageKit (Linux) — pour le packaging portable
|
||||
|
||||
## G. Gestion des vaults & dossiers — #159 — ✅ livré (2026-10-02, v2.50.0)
|
||||
|
||||
- [x] Retrait vault/dossier racine par menu contextuel (`ContextMenuManager`,
|
||||
branche `vault`, gate `isTauriEnv()`) → `removeRoot()` dans `desktop.js` :
|
||||
résolution vault/dossier via `list_vaults`/`list_dirs`, confirmation i18n,
|
||||
`remove_vault`/`remove_dir` + `restart_backend` + reload — déregistration
|
||||
seule, zéro suppression disque.
|
||||
- [x] Section Configuration `cfg-desktop-roots` (+ entrée TOC i18n FR/EN) :
|
||||
liste des roots injectés, retrait par ligne, ajout vault
|
||||
(`pickAndAddVault`, existant) et ajout dossier (nouvelle commande Rust
|
||||
`pick_folder` — sélecteur sans effet de bord, contrairement à
|
||||
`pick_vault_folder` réservé au wizard). Section masquée hors desktop.
|
||||
- [x] Jump list rafraîchie après chaque ajout/retrait de vault
|
||||
(`refresh_jumplist()`).
|
||||
- [x] i18n FR/EN (7 clés `config.*`/`desktop.*`), garde-fou ACL automatique
|
||||
(`test_frontend_invokes_are_acl_allowed` : toute commande invoquée par le
|
||||
frontend doit figurer dans `permissions/commands.toml`).
|
||||
- [x] Tests : `tests/frontend/desktop-roots.test.mjs` (4 — helpers purs +
|
||||
gating hors desktop), inscrit au CI ; suites frontend/JSDOM vertes,
|
||||
`cargo test` 25 passed.
|
||||
|
||||
## H. Premier lancement & section Configuration harmonisée — #160 — ✅ livré (2026-10-02, v2.51.0)
|
||||
|
||||
- [x] `default_first_run_config(home)` (pure, testée) : `<home>/ObsiGate`
|
||||
monté comme vault « ObsiGate » ET comme `vault_path`, racine home nommée
|
||||
d'après son dernier segment — remplace `voute_obsidian` + le « bruno »
|
||||
codé en dur ; les champs fenêtre de la config existante sont préservés.
|
||||
- [x] `Prise en main.md` : contenu embarqué (`include_str!("prise_en_main.md")`),
|
||||
écrit au premier lancement **si absent** (jamais d'écrasement).
|
||||
- [x] Section `cfg-desktop-roots` refondue : markup à classes (zéro style
|
||||
inline), bloc CSS `#160` sur variables (`desktop-roots-*`, motif
|
||||
`webauthn-key-item`), boutons `.config-btn-sm` (primaire/secondaire),
|
||||
override mobile 44px dans le bloc `#config-modal`.
|
||||
- [x] Tests : `test_default_first_run_config`, `test_welcome_doc_embedded`
|
||||
— `cargo test` 28 passed ; suites frontend vertes (validate-imports,
|
||||
unit, config-mobile, desktop-roots, settings-order) + E2E locale.
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
# #196 — Partage dirigé entre utilisateurs
|
||||
|
||||
> **Statut :** livré | **Ouvert :** 2026-10-10 | **Type :** feature (sharing + auth)
|
||||
|
||||
## Problème
|
||||
|
||||
Le seul partage existant (#85) était un lien **public** : quiconque possède
|
||||
l'URL lit le document, sans compte ni session. Il n'existait aucun moyen de
|
||||
partager un document *à un utilisateur précis* de la plateforme — un
|
||||
utilisateur ne pouvait ni adresser un document à un autre, ni consulter les
|
||||
documents qu'on lui adresse, ni (côté créateur) retirer ce partage.
|
||||
|
||||
## Décision d'architecture
|
||||
|
||||
Deux formes étaient possibles :
|
||||
|
||||
1. **Réutiliser le partage par token existant, dirigé** (retenu) — le record
|
||||
`data/shares.json` gagne `shared_with: [usernames]`. Si la liste est non
|
||||
vide, les pages `/s/{token}` (et `/pdf`, `/raw`) exigent une session et
|
||||
n'acceptent que créateur + admin + destinataires. Vide/absent : le
|
||||
comportement public historique est inchangé (rétrocompatibilité totale,
|
||||
zéro migration — les shares existants n'ont pas le champ).
|
||||
2. Octroyer le vault entier au destinataire (`update_user {"vaults": …}`)
|
||||
(écartée) — accès lecture-**écriture** au vault complet au lieu d'un
|
||||
document en lecture seule ; c'est le modèle « collaborateur de vault »,
|
||||
pas « partager un document ». Reste la bonne réponse pour ce besoin-là.
|
||||
|
||||
Points de sécurité délibérés :
|
||||
|
||||
- **404, jamais 403/401, sur la gate** — un visiteur sans session (ou un
|
||||
utilisateur non destinataire) apprend le même « Share not found or
|
||||
expired » qu'un token inventé : l'existence d'un token dirigé n'est pas
|
||||
confirmable de l'extérieur.
|
||||
- **`GET /api/shares` scopé** (correctif au passage) — un non-admin ne voit
|
||||
que les partages qu'il a créés **ou reçus`** (avant : tout le monde voyait
|
||||
tous les partages, fuite préexistante devenue intenable avec du user-to-user).
|
||||
Un admin voit tout.
|
||||
- **Révocation réservée au créateur/admin** — un destinataire ne peut pas
|
||||
casser l'accès des autres.
|
||||
- Les **destinataires sont validés** à la création (`get_user`) : pas de lien
|
||||
mort vers un compte inexistant.
|
||||
|
||||
## Mise en place
|
||||
|
||||
| Élément | Où |
|
||||
|---|---|
|
||||
| Store : champ `shared_with`, param de `create_share` | `backend/share.py` |
|
||||
| Gate `_gate_share()` + branchement `/s/{token}`, `/s/{token}/pdf`, `/s/{token}/raw` | `backend/routers/sharing.py` |
|
||||
| Scope de `GET /api/shares` (non-admin = créés + reçus) | `backend/routers/sharing.py`, `backend/share.py::list_shares(user=…)` |
|
||||
| Révocabilité créateur/admin uniquement | `backend/routers/sharing.py::api_share_revoke` |
|
||||
| Validation des destinataires à la création | `backend/routers/sharing.py::api_share_create` |
|
||||
| `ShareModel.shared_with` | `backend/schemas.py` |
|
||||
| Dialogue : radio public/dirigé + champ destinataires (virgules) | `frontend/js/config.js::openShareDialog` |
|
||||
| Destinataires affichés dans la vue « déjà partagé » | `frontend/js/config.js` |
|
||||
| Dashboard : cartes reçues (« Partagé par X », ouverture `/s/`, pas de bouton révoquer) | `frontend/js/dashboard.js::DashboardSharedWidget` |
|
||||
| i18n FR/EN (6 clés config.* + dashboard.shared_by) | `frontend/locales/{fr,en}.json` |
|
||||
|
||||
Ce qui est gratuit par construction : lecture seule (le destinataire ne
|
||||
passe jamais par l'éditeur), expiration, compteur d'accès, suivi des
|
||||
renommages (`update_shares_after_rename`), masquage des secrets
|
||||
(`redact_file_content` déjà dans les 3 routes `/s/*`), isolation des
|
||||
dossiers perso #194 (le destinataire ne voit jamais l'arbre du vault de
|
||||
l'auteur, seulement le document rendu).
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/test_directed_shares.py` (9) : création dirigée, destinataire inconnu
|
||||
→ 400, anonyme → 404 / destinataire + créateur → 200, non-destinataire →
|
||||
404, lien public toujours anonyme, scope de `/api/shares`, destinataire ne
|
||||
peut pas révoquer (403), créateur révoque, la révocation coupe l'accès.
|
||||
|
||||
## Limites connues
|
||||
|
||||
- `shared_with` est une liste plate d'usernames — pas de groupes. Ajouter
|
||||
des groupes quand le besoin sera réel.
|
||||
- Pas de notification push : le destinataire découvre les partages reçus au
|
||||
rechargement du dashboard (badge/rafraîchissement suffisent ; SSE existe
|
||||
si un vrai push est demandé).
|
||||
- La date d'« expiration » reste globale au partage (pas par destinataire).
|
||||
|
||||
## Itération 2 — visibilité & recherche (2026-10-11)
|
||||
|
||||
| Élément | Où |
|
||||
|---|---|
|
||||
| Icône « partagé » (share-2 bleu) sur les fichiers de l'arborescence | `frontend/js/sidebar.js` (`loadSharedPaths` + `sharedFileIconSync`, appliqué dans `loadDirectory` et `incrementalLoadDirectory`) |
|
||||
| Dossier virtuel **Partage** à la racine du home du destinataire | `frontend/js/sidebar.js` (injection `virtual-share` + rendu, clic fichier → `/s/{token}`) |
|
||||
| Recherche des documents reçus | `backend/services/search.py::_shared_results` (merge dans `search_vaults` avant pagination), `backend/routers/search.py` (passe `username`), `SearchResultItem.share_token` |
|
||||
| Clic sur un résultat reçu → page de partage | `frontend/js/search.js` |
|
||||
| Invalidation du cache front | `invalidateSharedPaths()` exporté, appelé par `openShareDialog` après création/révocation |
|
||||
|
||||
Décisions :
|
||||
|
||||
- **Dossier virtuel, pas physique** : l'indexeur exclut les symlinks
|
||||
(sécurité path traversal) et un dossier réel ferait double indexation.
|
||||
La source de vérité reste `data/shares.json`.
|
||||
- **Recherche à la volée** (`_shared_results`) : le contenu des documents
|
||||
reçus est lu au moment de la requête, uniquement pour les destinataires —
|
||||
jamais indexé dans le TF-IDF global, donc aucune fuite vers un
|
||||
non-destinataire (testé). ponytail: lecture disque par requête, plafond
|
||||
~quelques centaines de partages reçus par user ; indexer dans le TF-IDF
|
||||
avec un champ "destinataires" si le volume devient réel.
|
||||
- Icône déterminée côté client via `/api/shares` (déjà scopé #196) : la
|
||||
liste des partages actifs "créés OU reçus" suffit — le créateur voit
|
||||
l'icône sur son fichier source, le destinataire voit le dossier Partage.
|
||||
|
||||
+2 tests : `test_search_finds_received_share_for_recipient`,
|
||||
`test_search_does_not_leak_share_to_other_user`.
|
||||
|
||||
|
||||
## Itération 3 — ouverture en onglet (2026-10-11)
|
||||
|
||||
- **Résolution serveur** : `_resolve_shared_file()` (`backend/routers/files_read.py`)
|
||||
mappe `home-<user>/Partage/<fichier>` vers le fichier source pour les routes
|
||||
`/api/file`, `/api/file/raw`, `/api/file/download`. Résolu AVANT l'ACL vault
|
||||
(le home virtuel peut ne pas être dans `user.vaults`) — l'autorisation réelle
|
||||
est l'appartenance au partage, vérifiée dans le resolver (destinataire seul).
|
||||
- **Front** : clic dossier Partage → simple dépliage (plus de `openNav`, fix
|
||||
« Directory not found ») ; clic fichier reçu → `TabManager.openPreview/openPersistent`
|
||||
(arbre, recherche, dashboard) — même parcours qu'un fichier ordinaire.
|
||||
- `POST /api/share` : `path` vide/null → 400 (un share `path: None` cassait
|
||||
`/api/shares` en 500).
|
||||
+2 tests : ouverture applicative du fichier reçu (file + raw), résolution
|
||||
scopée par utilisateur (home d'autrui → 403).
|
||||
|
||||
|
||||
## Itération 4 — correctifs d'ouverture (2026-10-11)
|
||||
|
||||
- **Le partage EST l'autorisation** : après résolution `Partage/…`, l'ACL du
|
||||
vault source n'est plus appliquée (`elif` dans `/api/file`, raw, download) —
|
||||
un destinataire sans accès au vault émetteur ouvrait « Accès refusé à la
|
||||
vault '…' », précisément le cas d'usage du partage. L'appartenance au
|
||||
partage (vérifiée dans `_resolve_shared_file` via `list_shares(user=…)`)
|
||||
reste la seule autorisation.
|
||||
- **Chemin canonique `Partage/<token>/<nom>`** : lève l'ambiguïté de deux
|
||||
partages homonymes (le token identifie le partage exact). Fallback par nom
|
||||
conservé pour les liens historiques. Transporté par l'arbre (`data-path`),
|
||||
la recherche et le dashboard.
|
||||
- **« Reçu » = adressé à moi** : sidebar `_loadReceivedShares` et dashboard
|
||||
filtrent désormais `shared_with.includes(me)` — un admin voyant TOUS les
|
||||
partages (`/api/shares` non scopé pour lui) n'a plus les partages d'autres
|
||||
comptes dans son dossier Partage (source des « File not found »).
|
||||
+3 tests : destinataire sans accès au vault source (200), disambiguïsation
|
||||
par token, home d'autrui (403). 14 tests au total.
|
||||
@@ -0,0 +1,272 @@
|
||||
# Chat intégré par fichier — #169
|
||||
|
||||
- **Statut :** ✅ livré le 2026-10-08
|
||||
- **Effort :** 3-4 j (réalisé en 1 session) | **Impact :** 🟢
|
||||
- **Transport :** SSE existant (#62) — pas de second WebSocket
|
||||
|
||||
## Ce qui a été livré
|
||||
|
||||
### Backend
|
||||
|
||||
- **Store `backend/file_chat.py`** — historique JSON par paire
|
||||
`(vault, path)` sous `data/chats/` :
|
||||
- nom de fichier = SHA-256 de `vault\0path` (32 premiers hex) : aucun
|
||||
séparateur de chemin utilisateur dans le nom → traversal impossible ;
|
||||
- plafond **500 messages** par fichier (les plus vieux supprimés) ;
|
||||
- texte tronqué à **4000 caractères** ;
|
||||
- écriture atomique (tmp + move) ; fichier corrompu → historique vide
|
||||
(fallback, pas de crash).
|
||||
- **Routes `backend/routers/file_chat.py`** (tag OpenAPI « Files » dérivé) :
|
||||
- `GET /api/file/{vault}/chat?path=…` → historique chronologique ;
|
||||
- `POST /api/file/{vault}/chat` `{path, text}` → ajout + broadcast SSE ;
|
||||
- garde commune : `require_auth` + `check_vault_access` +
|
||||
`resolve_safe_path` (traversal → 403/500 via le handler `ServiceError`) ;
|
||||
- `text` ou `path` vides → 400 ; vault inconnu → 404.
|
||||
- **Schémas** `ChatMessageItem` / `ChatHistoryResponse` /
|
||||
`ChatMessageResponse` dans `backend/schemas.py` (`response_model` sur les
|
||||
deux routes).
|
||||
- **Broadcast** : `sse_manager.broadcast("chat_message", {vault, path, message})`.
|
||||
|
||||
### Frontend
|
||||
|
||||
- **`frontend/js/filechat.js`** — panneau latéral :
|
||||
- bouton 💬 (`.btn-chat`) dans le groupe nav de la toolbar fichier
|
||||
(viewer.js), toggle ouvre/ferme ;
|
||||
- historique rendu chronologiquement, bulles « mine » à droite,
|
||||
`textContent` pour le corps (pas d'injection HTML) ;
|
||||
- envoi POST optimiste + dédoublonnage par `id` à la réception SSE ;
|
||||
- panneau fermé / autre fichier → **toast** « Nouveau message de … »
|
||||
(silencier si c'est son propre message).
|
||||
- **`frontend/js/sync.js`** — listener SSE `chat_message` avec import
|
||||
dynamique de `filechat.js` (pas de cycle d'imports).
|
||||
- **CSS** bloc `#169` : panneau fixe 340 px, **plein écran ≤ 768 px**,
|
||||
variables CSS avec fallbacks, input 16 px (anti-zoom iOS).
|
||||
- **i18n** : 10 clés `chat.*` dans `fr.json` et `en.json`.
|
||||
|
||||
## Tests
|
||||
|
||||
- `tests/test_file_chat.py` — 15 tests : roundtrip store, isolation par
|
||||
fichier, plafond de rétention, troncature, traversal hashée, fichier
|
||||
corrompu, routes (200/400/404/403), fixture autouse isolant `CHAT_DIR`.
|
||||
- `tests/frontend/filechat.test.mjs` — 6 tests JSDOM : empty state, rendu
|
||||
chronologique + classe `mine`, anti-injection HTML, append live + dédup
|
||||
par id, isolation inter-fichiers, toggle. Locales réellement chargées
|
||||
(fetch simulé vers `frontend/locales/`).
|
||||
- Les deux sont ajoutés au pipeline CI (`.gitea/workflows/ci.yml`).
|
||||
|
||||
## #190 — Suivi du document, chat général, ordre des onglets (2026-10-08)
|
||||
|
||||
### A. Le panneau suit le document
|
||||
|
||||
- En-tête du panneau : `.file-chat-target` affiche le titre du document
|
||||
(l'attribut `title` porte le chemin complet).
|
||||
- `followFileChat(vault, path, title)` est appelé en tête de `renderFile()` :
|
||||
panneau ouvert + autre document → re-cible (reload de l'historique) ;
|
||||
panneau fermé → no-op. Le bouton 💬 transmet `data.title`.
|
||||
|
||||
### B. Chat général en onglet sidebar
|
||||
|
||||
- Onglet `#sidebar-tab-chat` (dernier, `message-circle`) + panneau
|
||||
`#sidebar-panel-chat` (liste + 📎 + saisie + Envoyer, Entrée = envoyer).
|
||||
- **Store** : rien de nouveau — les sentinelles `GLOBAL_VAULT="__global__"` /
|
||||
`GLOBAL_PATH="general"` réutilisent `add_message`/`get_messages`.
|
||||
- **Routes** : `GET/POST /api/chat`, `POST /api/chat/upload`,
|
||||
`GET /api/chat/attachment/{name}`.
|
||||
- **Pièces jointes** : allow-list d'extensions (11 : images + vidéos), 25 MB,
|
||||
nom de stockage = UUID (le nom client n'est jamais utilisé), résolution
|
||||
par comparaison au nom + allow-list (aucun chemin utilisateur).
|
||||
- **Messages non lus** : timestamp de dernière visite dans
|
||||
`localStorage['obsigate-chat-unread']`, comptage des messages plus récents
|
||||
d'un autre auteur, pastille rouge `99+` sur l'onglet, effacée à l'ouverture.
|
||||
- **URL** : linkification `http(s)://` au rendu (texte autour en `TextNode`,
|
||||
donc pas d'injection).
|
||||
- **SSE** : `vault === "__global__"` route vers la liste sidebar (append +
|
||||
clear) ou vers badge + toast selon que l'onglet est actif.
|
||||
|
||||
### C. Ordre des onglets + filtre = recherche chat
|
||||
|
||||
- `index.html` : le bloc `.sidebar-filter` est **déplacé après** `.sidebar-tabs`.
|
||||
- `switchSidebarTab` : placeholder `sidebar.filter_chat`, dispatch `chat`.
|
||||
- `initSidebarFilter` (`sidebar.js`) : `routeFilter`/`routeClear` routent
|
||||
vers `filterChatMessages()` (texte **et** auteur, insensible à la casse).
|
||||
|
||||
## #191 — Suppression, messages privés, boîte compacte, link preview (2026-10-08)
|
||||
|
||||
### A. Suppression d'un post
|
||||
|
||||
- `DELETE /api/chat/{id}` et `DELETE /api/chat/dm/{peer}/{id}` :
|
||||
**auteur du message ou admin** (403 sinon), 404 si l'id n'existe pas.
|
||||
- Store : `delete_message(vault, path, id)` — écriture atomique,
|
||||
`False` si absent ; broadcast SSE `chat_deleted` `{vault, path, id}`.
|
||||
- Frontend : bouton 🗑 (`.file-chat-del`, visible au survol) rendu dans
|
||||
`_messageEl` uniquement si l'URL de suppression est fournie **et**
|
||||
que l'utilisateur est auteur/admin ; `confirm()` natif ; retrait local
|
||||
+ retrait en direct chez tous via `onChatDeleted` (relais `sync.js`).
|
||||
|
||||
### B. Messages privés 2 utilisateurs
|
||||
|
||||
- Store : `DM_VAULT = "__dm__"`, `dm_path(a, b)` = paire **triée** → les
|
||||
deux participants lisent/écrivent le même document, indépendamment de
|
||||
l'ordre.
|
||||
- Routes : `GET /api/chat/users` (destinataires — usernames +
|
||||
display_names, **sans soi-même**, aucun hash/mot de passe exposé),
|
||||
`GET/POST /api/chat/dm/{username}` (404 utilisateur inconnu, 400 DM à
|
||||
soi), `DELETE /api/chat/dm/{username}/{id}`.
|
||||
- Frontend : rangée de pastilles `.chat-channels` (« Général » + un
|
||||
bouton par utilisateur) au-dessus du fil ; `_channel` état courant,
|
||||
`_channelKey`/`_channelUrl`/`_channelDelUrl` ; placeholder dédié
|
||||
`chat.placeholder_dm` ; non-lus **par canal** (compteurs dans
|
||||
`localStorage`, l'ancien mélange timestamp/compteur est devenu un
|
||||
compteur unique `{"general": n, "dm:x": n}`, remis à zéro à l'ouverture,
|
||||
pastille = total) ; les DM SSE routés par `_pairOf(path)`.
|
||||
|
||||
### C. Boîte d'édition compacte
|
||||
|
||||
- Bouton d'envoi réduit à une icône `send` (`.file-chat-send-icon`) dans
|
||||
les deux formulaires (panneau document + sidebar) ; la saisie garde
|
||||
toute la largeur. `safeCreateIcons()` à la fin du `_renderShell`.
|
||||
|
||||
### D. Link preview
|
||||
|
||||
- `build_preview(text)` (store) : 1ʳᵉ URL du texte → garde SSRF
|
||||
`_assert_public_http_url` (host privé/loopback rejeté, schémas non
|
||||
http refusés) → `httpx.get` 5 s, 512 Ko, `follow_redirects` (limite
|
||||
httpx = 20) → parse OG par regex (`og:title` avec repli `<title>`,
|
||||
`og:description`, `og:image`, `og:site_name`) →
|
||||
`{url, title, description, image, site}`.
|
||||
- **Best-effort** : n'importe quelle erreur (URL morte, timeout, garde)
|
||||
→ `None`, le message passe quand même ; cache borné **200 entrées**.
|
||||
- Frontend : `_previewEl` rend une carte cliquable (vignette lazy
|
||||
`no-referrer`, site en capitales, titre/description tronqués 2 lignes)
|
||||
sous le corps du message.
|
||||
- Schéma : `ChatMessageItem.preview`.
|
||||
|
||||
### Tests #191
|
||||
|
||||
- pytest `TestDelete` (3), `TestPrivateChat` (8), `TestLinkPreview` (8,
|
||||
dont le happy path avec `httpx.get` mocké — un `max_redirects`
|
||||
inexistante sur `httpx.get` levait un TypeError silencieusement avalé
|
||||
par le `try`, testé par `test_build_preview_happy_path_parses_og`).
|
||||
- JSDOM `filechat.test.mjs` +5 : carte preview, bouton supprimer
|
||||
(droit auteur/admin), `onChatDeleted`, compteurs non-lus,
|
||||
routage DM isolé du général (16 au total).
|
||||
|
||||
## #192 — Saisie auto-agrandissante, accusé de réception, vignette de lien (2026-10-08)
|
||||
|
||||
### A. Boîte de saisie qui grandit avec le texte
|
||||
|
||||
`<input type="text">` → `<textarea rows="1">` (panneau document créé dans
|
||||
`_renderShell()`, sidebar statique dans `index.html`). `_autoGrow(el)`
|
||||
pose `height: auto` puis `min(scrollHeight, 160)px` à chaque `input`, la
|
||||
règle CSS `textarea.file-chat-input` ajoute `resize: none` +
|
||||
`overflow-y: auto` (au-delà du plafond la zone défile) et la hauteur repasse
|
||||
à une ligne après l'envoi. `Entrée` (sans `Maj`) est interceptée dans les
|
||||
deux formulaires → envoi, `Maj+Entrée` garde le retour à la ligne. Le
|
||||
placeholder de la sidebar est aussi appliqué à l'init (`t()`), le HTML ne
|
||||
portant que le FR.
|
||||
|
||||
### B. Accusé de réception (✓ envoyé / ✓✓ lu)
|
||||
|
||||
- **Store** (`backend/file_chat.py`) : le document de conversation gagne
|
||||
`read = {username: dernier_ts}` (`get_read()` / `mark_read()`), sous le
|
||||
`_LOCK` du read-modify-write partagé avec l'ajout et la suppression.
|
||||
- **API** : les trois `GET` d'historique renvoient `read` (schéma
|
||||
`ChatHistoryResponse.read`) ; nouveau `POST /api/chat/read`
|
||||
(`ChatReadResponse`) avec `_check_read()` — général = tout membre, DM =
|
||||
pair uniquement (403 sinon), fichier = ACL vault + `resolve_safe_path` —
|
||||
qui marque la lecture et diffuse `chat_read`
|
||||
`{vault, path, user, ts, read}` en SSE.
|
||||
- **Client** (`filechat.js`) : carte de lecture par surface (`_fileRead`,
|
||||
`_channelRead`). Un message est « lu » dès qu'un **autre** participant a
|
||||
un `ts ≥` celui du message (ma propre lecture ne compte pas).
|
||||
`_messageEl()` affiche `✓`/`✓✓` (`.file-chat-status`, bleu accent quand
|
||||
lu) ; `_applyRead()` fait basculer les indicateurs affichés ;
|
||||
`_syncRead()` fusionne une carte reçue ; `_markRead(vault, path)` (POST
|
||||
fire-and-forget) est émis à l'ouverture d'une conversation **et** à la
|
||||
réception d'un message pendant qu'elle est affichée. `onChatRead()`
|
||||
(relais `chat_read` ajouté dans `sync.js`) actualise en direct et
|
||||
notifie l'expéditeur par toast `chat.read_by` quand un de ses messages
|
||||
passe à ✓✓ — jamais pour son propre écho.
|
||||
|
||||
### C. BUG-109 — vignette des tuiles de lien
|
||||
|
||||
`img-src 'self' data: blob:` interdit toute `og:image` distante, et une
|
||||
`og:image` relative était résolue contre l'origine d'ObsiGate (404).
|
||||
`_proxy_image(img_url, page_url)` téléverse la vignette à l'envoi dans
|
||||
`chat_uploads` via `save_attachment()` (allow-list + nom UUID) et renvoie
|
||||
`/api/chat/attachment/<uuid>` — résolution `urljoin` contre la page,
|
||||
garde SSRF réutilisée, plafond `PREVIEW_IMAGE_MAX` (2 Mo), dérivation de
|
||||
l'extension depuis le `content-type`. Échec isolé → `image = ""`, la
|
||||
carte reste (jamais de message perdu).
|
||||
|
||||
### Tests #192
|
||||
|
||||
- pytest `TestReadReceipts` (7) : roundtrip store, messages préservés,
|
||||
`POST /api/chat/read` (enregistrement + restitution par le `GET`),
|
||||
403 DM hors pair, 400 champs manquants, ACL vault, broadcast `chat_read`
|
||||
(spy sur `sse_manager`) ; `TestLinkPreview` +2 (proxy same-origin avec
|
||||
fichier écrit sur disque, dégradation sans vignette).
|
||||
- JSDOM `filechat.test.mjs` +3 (19) : indicateur ✓/✓✓ (ma propre carte de
|
||||
lecture exclue, aucun indicateur sur les messages des autres), bascule en
|
||||
direct + notification + pas d'écho, textarea (tagName/rows, hauteur
|
||||
pilotée, `Entrée` intercepté / `Maj+Entrée` non).
|
||||
|
||||
## #193 — Rendu markdown des posts (2026-10-09)
|
||||
|
||||
Un post du chat s'affiche désormais **comme un document** : titres, listes,
|
||||
tableaux, citations, blocs de code colorés — au lieu du texte brut avec liens
|
||||
cliquables.
|
||||
|
||||
### A. Le rendu vient du serveur (pas de second moteur markdown)
|
||||
|
||||
- `backend/file_chat.py::_decorate()` ajoute un champ `html` à chaque message
|
||||
en réutilisant `backend/render.py::_render_markdown()` — **le pipeline exact
|
||||
des documents** : mistune (tableaux, strikethrough, notes, listes de tâches),
|
||||
résolution des wikilinks, normalisation des sauts de ligne, masquage des
|
||||
secrets (#188) et **sanitizer BUG-021** en sortie. Aucun markdown côté client,
|
||||
donc aucun XSS nouveau à traiter : la sanitisation est déjà éprouvée.
|
||||
- `ChatMessageItem.html` (schéma de réponse) : sans ce champ, `response_model`
|
||||
filtrait la clé.
|
||||
- Le `html` est calculé **à la lecture** (`get_messages()`) et à l'ajout
|
||||
(`_append()`), donc il n'est **jamais persisté** dans `data/chats/*.json` :
|
||||
un message reste du texte, le rendu suit les évolutions du pipeline.
|
||||
- L'écho SSE part avec le `html` : les autres clients voient le rendu sans
|
||||
attendre un refresh.
|
||||
- Un échec de rendu n'emporte pas le message : repli silencieux (`html = ""`),
|
||||
le client affiche le texte brut.
|
||||
|
||||
### B. Frontend : `.md-content` + highlight.js
|
||||
|
||||
- `_fillBody()` injecte le `html` dans un `<div class="md-content">` (la
|
||||
typographie des documents) puis appelle `safeHighlight()` sur chaque
|
||||
`pre code` — le même helper (et les mêmes alias de langages) que le viewer.
|
||||
Mermaid n'est pas déclenché dans le chat.
|
||||
- Repli intact : un message sans `html` (écho optimiste d'un autre client)
|
||||
repasse par l'ancien chemin texte + URL cliquable.
|
||||
- CSS : `.file-chat-body.file-chat-md` annule le `white-space: pre-wrap` hérité
|
||||
de la bulle (sinon les retours à la ligne du HTML source créaient des lignes
|
||||
vides) et compacte `.md-content` (tailles, marges, `pre`/`table` en
|
||||
`overflow-x: auto`) pour tenir dans une bulle de 92 % de large.
|
||||
|
||||
### Tests #193
|
||||
|
||||
- pytest `TestMarkdownRendering` (7) : `html` présent à l'ajout **et** à la
|
||||
lecture, `html` **absent** du JSON stocké, classe `language-xxx` conservée
|
||||
(ce sur quoi hljs se branche), tableaux/listes, `<script>` neutralisé,
|
||||
routes `POST/GET` (chat de fichier + chat général) et écho SSE porteur du
|
||||
`html`. Suite : 1689 passed.
|
||||
- JSDOM `filechat.test.mjs` +2 (27) : post avec `html` → `.md-content`,
|
||||
`<strong>`, `pre code.language-python` ; post sans `html` → texte brut et URL
|
||||
cliquable conservés.
|
||||
|
||||
## Décisions
|
||||
|
||||
- **SSE plutôt qu'un second WebSocket** : le transport de #62 (EventSource
|
||||
`/api/events`, auth par cookie) existe déjà côté client dans `sync.js` ;
|
||||
un WS par fichier aurait dupliqué reconnexion/heartbeat/auth pour zéro
|
||||
bénéfice sur du messages-postés.
|
||||
- **Pas d'événement dans le panneau de sync** (`_addEvent`) : les messages
|
||||
de chat gonfleraient l'historique d'événements d'index sans valeur.
|
||||
- **Tag OpenAPI** : pas de tag maison (`file-chat`) — non déclaré dans
|
||||
`TAGS_METADATA`, ce qui cassait `test_used_tags_are_declared`. Les routes
|
||||
`/api/file/*` sont dérivées automatiquement sous « Files ».
|
||||
@@ -0,0 +1,72 @@
|
||||
# #158 — Navigation : vue répertoire en onglet, filtres & tris, retour Home complet
|
||||
|
||||
> **Statut :** livré le 2026-10-02 · **ID :** `#158` (correctif associé : `BUG-100`)
|
||||
> **Fichiers :** `frontend/js/vaulthome.js`, `frontend/js/ui.js`, `frontend/js/sidebar.js`,
|
||||
> `frontend/js/navfacets.js` (nouveau), `frontend/js/viewer.js`, `frontend/js/legacy.js`,
|
||||
> `backend/services/vaults.py`, `backend/schemas.py`, `frontend/style.css`,
|
||||
> `frontend/locales/{fr,en}.json`
|
||||
|
||||
## Problème
|
||||
|
||||
La vue « chemin + Récents + fichiers du répertoire » (vault home) ne s'affichait qu'en mode
|
||||
focus vault, disparaissait dès qu'un fichier était ouvert (aucun onglet), ne listait pas les
|
||||
sous-répertoires, n'avait ni filtre ni tri, et le clic sur le titre ne rendait pas la vraie
|
||||
page d'accueil.
|
||||
|
||||
## Conception
|
||||
|
||||
**Un onglet de navigation par vault.** `TabManager.openNav(vault, dir)` (`frontend/js/ui.js`)
|
||||
crée/rétarget l'onglet `nav::<vault>` (icône dossier) ; `TabManager.activate()` branche sur
|
||||
`cache.nav` et rend `showVaultHome(vault, dir)` au lieu de fetcher un fichier. Les onglets de
|
||||
fichiers ne sont jamais fermés. Le dashboard appelle `TabManager.deactivate()` : aucun onglet
|
||||
ne reste « actif », donc un clic suivant ré-éffectue réellement l'onglet (avant : early-return
|
||||
et vue figée).
|
||||
|
||||
Points d'entrée des clics (`frontend/js/sidebar.js`) : clic **répertoire** dans l'arbre (2 sites,
|
||||
mode focus et mode All), clic sur la **racine d'un vault** (2 sites, expansion + navigation),
|
||||
changement de contexte vault (sélecteur), facettes/filtres et fil d'Ariane de la vue
|
||||
(`vaulthome.js` route tout par `openNav`, plus aucune délégation sur l'arbre).
|
||||
|
||||
**Menus contextuels** : les répertoires et les fichiers de la vue appellent le même
|
||||
`ContextMenuManager.show()` que la sidebar, avec `stopPropagation()` — le handler global de
|
||||
`ContextMenuManager` referme le menu sur tout clic droit hors `.tree-item`.
|
||||
|
||||
**Onglet Accueil** (`ui.js::openHome` + `pane-manager.js::openHome`) : le clic sur le titre
|
||||
crée (ou remonte en position 0) l'onglet `home` — icône Maison, libellé i18n `common.home` —
|
||||
et l'active ; `activate()` rend la page d'accueil via `showWelcome()` et `deactivate()`
|
||||
conserve l'onglet Accueil actif (il *est* la page d'accueil). En mode split, l'onglet vit dans
|
||||
le panneau actif (délégation `getActiveTabManager()`, libellé transmis par l'appelant pour ne
|
||||
pas d'importer `t` dans `pane-manager.js`, qui déclare déjà des variables locales `t`).
|
||||
|
||||
**Contenu de la vue** (`vaulthome.js`) : fil d'Ariane · barre **Pertinence / Date** + **Sauver**
|
||||
· panneau de facettes · **Répertoires** cliquables (`GET /api/browse`) · Récents (racine) ·
|
||||
fichiers (`GET /api/vault/{v}/files?recursive=false`). Les facettes réutilisent les classes de
|
||||
la page de recherche (`search-facets`, `search-sort`, `search-save-btn`) — même visuel, même
|
||||
repli `obsigate_facets_collapsed`, filtrage **local** (pas de nouvelle requête).
|
||||
|
||||
**Facettes** (`frontend/js/navfacets.js`, pur, sans DOM) : `buildNavFacets`,
|
||||
`filterNavFiles`, `sortNavFiles`. Le listing embarque les tags indexés :
|
||||
`GET /api/vault/{v}/files` renvoie `tags` (`_indexed_tags()` dans
|
||||
`backend/services/vaults.py`, lookup `vault::path` dans l'index inversé, `[]` si non indexé).
|
||||
|
||||
## Décisions / limites connues
|
||||
|
||||
- **Un seul onglet de navigation par vault** : cliquer un autre répertoire re-cible le même
|
||||
onglet (pas d'empilement d'onglets). En mode split, la vue s'affiche dans la zone principale.
|
||||
- **« Pertinence » = tri alphabétique** (`ponytail:` commenté) : un listing de répertoire n'a
|
||||
aucun signal de pertinence tant que la vue n'a pas sa propre requête — voir
|
||||
`navfacets.js::sortNavFiles`.
|
||||
- Facettes calculées sur la liste courante (répertoire non récursif, 200 fichiers max) : les
|
||||
sous-répertoires non listés n'apportent aucune compte.
|
||||
|
||||
## Tests
|
||||
|
||||
- `tests/test_nav_files.py` (3) : `tags` exposés + survivent au `response_model`, tags en
|
||||
sous-répertoire, miss d'index → `[]`.
|
||||
- `tests/frontend/navfacets.test.mjs` (10) : comptages, filtres tag/ext (insensible au point),
|
||||
combinaison ET, tris, non-mutation.
|
||||
- `tests/frontend/unit.test.mjs` : `navfacets.js` ajouté à la liste des modules purs.
|
||||
- `tests/e2e/nav-tab.spec.js` (4) : racine vault → page de navigation, icône + menus
|
||||
contextuels identiques à la sidebar, onglet Accueil en tête de barre (avec `#quick-help`).
|
||||
- Vérification manuelle Playwright (jetable) : 22/22 puis 16/16 checks sur les lots A1-A3 et
|
||||
A4-A6.
|
||||
@@ -0,0 +1,57 @@
|
||||
# Fiche — #188 · Secrets masqués : couverture universelle + clic pour copier
|
||||
|
||||
- **Statut** : ✅ livré le 2026-10-08 (ouvert le 2026-10-08)
|
||||
- **Effort** : ~1 jour · **Impact** : 🟡
|
||||
- **Zones** : `backend/secret_redactor.py`, `backend/render.py`,
|
||||
`backend/routers/files_read.py`, `frontend/js/viewer.js`,
|
||||
`frontend/style.css`, `frontend/locales/{fr,en}.json`
|
||||
|
||||
## Problème
|
||||
|
||||
Le redacteur (`secret_redactor.py`) ne masquait que les JWT, les clés
|
||||
`sk-`/`pk-`/`rk-`, `AKIA…`, `ghp_…`, les connection strings et les
|
||||
affectations `api_key=`/`token=`/`secret=` de **20 caractères ou plus**.
|
||||
Résultat : la majorité des clés API réelles (Google, Slack, Stripe, GitLab,
|
||||
Hugging Face, npm…) et **tous les mots de passe courts** passaient en clair
|
||||
dans l'aperçu markdown, et un masque affiché (`[CLÉ API MASQUÉE]`) n'était
|
||||
pas récupérable : il fallait ouvrir la source pour relire la valeur.
|
||||
|
||||
## Solution
|
||||
|
||||
1. **Détection universelle** — une alternation unique de formats
|
||||
fournisseur (OpenAI/Anthropic `sk-`, Stripe `sk_live_`/`whsec_`, GitLab
|
||||
`glpat-`, GitHub `ghp_`/`github_pat_`, Google `AIza…`/`ya29.`, AWS
|
||||
`AKIA`/`ASIA`, Slack `xoxb-`, SendGrid `SG.`, Hugging Face `hf_`, npm,
|
||||
Docker, Resend, Square, Atlassian, Discord, Telegram, `Bearer …`) en
|
||||
plus des motifs déjà présents ; le plancher des affectations génériques
|
||||
passe de 20 à 8 caractères.
|
||||
2. **Mots de passe** — motif dédié (`[MOT DE PASSE MASQUÉ]`) valable toute
|
||||
longueur et toute quote (`password=`, `"passwd": "…"`, `db_password=`,
|
||||
`passphrase=`, `mot de passe=`), qualificateur inclus. `PWD=` (dossier de
|
||||
travail shell) reste épargné : `pwd` n'est reconnu que qualifié.
|
||||
3. **Clic → copie** — le pipeline de rendu passe par des
|
||||
**placeholders** (`\uE000n\uE001`) qui survivent à la conversion markdown
|
||||
(un `<span>` littéral dans un bloc de code serait affiché tel quel), puis
|
||||
`restore_masks()` les réécrit après le rendu :
|
||||
- **aperçu authentifié** (`GET /api/file/…`, `click_to_copy=True`) :
|
||||
`<span class="secret-mask" data-secret="valeur">[CLÉ API MASQUÉE]</span>` —
|
||||
la valeur n'est jamais affichée, un clic la copie (listener délégué
|
||||
unique sur la zone de contenu, toast i18n FR/EN, infobulle) ;
|
||||
- **partages publics, exports PDF, contexte IA / MCP** : libellé seul,
|
||||
la valeur ne quitte pas le serveur.
|
||||
Les blocs `<pre><code>` portant un masque ne sont pas re-colorés
|
||||
(highlight.js reconstruirait le markup et perdrait le badge).
|
||||
|
||||
## Hors périmètre (volontaire)
|
||||
|
||||
- La vue « source » (fichier brut) et les aperçus de fichiers
|
||||
**non-markdown** ne sont pas masqués : c'est le fichier lui-même qui est
|
||||
affiché, le redacteur ne s'y appliquait jamais.
|
||||
- Le serveur MCP / l'assistant IA continuent de recevoir les libellés seuls
|
||||
(inchangé).
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/test_api_main.py::TestSecretRedactor` : formats fournisseurs
|
||||
(paramétrés), mots de passe (paramétrés), `PWD=` non masqué, round-trip
|
||||
placeholder → span cliquable, rendu markdown cliquable vs libellé seul.
|
||||
@@ -0,0 +1,107 @@
|
||||
# #194 — Dossier personnel par utilisateur
|
||||
|
||||
> **Statut :** livré | **Ouvert :** 2026-10-09 | **Type :** feature (auth + vaults)
|
||||
|
||||
## Problème
|
||||
|
||||
Un utilisateur créé dans ObsiGate n'avait aucun espace propre : il ne voyait
|
||||
que les vaults qu'un admin lui attribuait à la main, et rien n'était préparé
|
||||
sur le disque à sa création.
|
||||
|
||||
## Décision d'architecture
|
||||
|
||||
Deux formes étaient possibles :
|
||||
|
||||
1. **Un vault par utilisateur** (retenu) — `<racine>/<user>` devient le vault
|
||||
`home-<user>`, octroyé dans `user.vaults`. L'ACL par vault existante
|
||||
(`backend/auth/middleware.py::check_vault_access`, appliquée partout :
|
||||
API, MCP, search, push, tools) fait l'isolation **sans rien inventer**.
|
||||
2. Un vault `Home` unique + ACL par sous-dossier (écartée) — il n'existe
|
||||
aucune ACL par chemin dans le code ; il aurait fallu la brancher dans
|
||||
`files_read`, `files_media`, `history`, `backups`, `mcp/tools`, `push`…
|
||||
Surface de sécurité trop grande pour un gain marginal.
|
||||
|
||||
Décisions produit validées : nom `home-<user>`, dossier **conservé** à la
|
||||
suppression du compte, racine `/NFS/OBSIDIAN_DOC/Home` montée `/vaults/Home`.
|
||||
|
||||
## Mise en place
|
||||
|
||||
| Élément | Où |
|
||||
|---|---|
|
||||
| Service idempotent `ensure_user_home()` / `release_user_home()` / `ensure_all_user_homes()` | `backend/user_home.py` |
|
||||
| Registre persistant des vaults dynamiques `data/vaults.json`, fusionné par `load_vault_config()` | `backend/indexer.py` (`persist_vault` / `unpersist_vault`) |
|
||||
| Appel à la création et à la suppression d'un compte | `backend/auth/router.py` (`POST/DELETE /api/auth/admin/users`) |
|
||||
| Passe de réparation/migration au démarrage | `backend/main.py` (`lifespan` → après le watcher) |
|
||||
| Persistance des vaults ajoutés par `/api/vaults/add` + retrait | `backend/routers/vaults.py` |
|
||||
| Montage + variable d'environnement | `docker-compose.yml` (`/NFS/OBSIDIAN_DOC/Home:/vaults/Home`, `OBSIGATE_HOME_ROOT=/vaults/Home`) |
|
||||
|
||||
## Garde-fous
|
||||
|
||||
- **Fonctionnalité inactive sans `OBSIGATE_HOME_ROOT`** : dev, tests et
|
||||
desktop n'ont aucun comportement modifié.
|
||||
- **Validation du nom** : `^[a-zA-Z0-9_-]{2,32}$` (même grammaire que
|
||||
`CreateUserRequest.username_valid`), re-vérifiée avant de construire un
|
||||
chemin — défense en profondeur contre le path traversal.
|
||||
- **Nom de vault sans `/` ni `::`** : le nom sert de segment d'URL
|
||||
(`/api/file/{vault}/…`) et de clé d'index `vault::path`.
|
||||
- **Auto-réparation** : `ensure_all_user_homes()` au démarrage crée les
|
||||
dossiers manquants, ré-enregistre les vaults perdus et octroie l'accès aux
|
||||
users créés hors API. Toute erreur disque (NFS indisponible) est
|
||||
journalisée et rattrapée au boot.
|
||||
- **Suppression de compte** : index/watcher/registre refermés, dossier sur
|
||||
disque conservé.
|
||||
|
||||
## Bug corrigé au passage
|
||||
|
||||
- `load_vault_config()` ne lisait que les env `VAULT_N_*`/`DIR_N_*` : tout
|
||||
vault ajouté à la volée (y compris via `/api/vaults/add`) disparaissait au
|
||||
premier reindex complet et au redémarrage. → `data/vaults.json`.
|
||||
- `reload_single_vault()` perdait l'entrée `vault_config[vault]` après
|
||||
`remove_vault_from_index()`.
|
||||
|
||||
## Tests
|
||||
|
||||
- `tests/test_user_home.py` (10) : création (dossier + vault + octroi +
|
||||
registre), idempotence, username invalide, désactivation sans variable,
|
||||
relecture du registre par `load_vault_config`, `release` (dossier
|
||||
conservé), isolation `check_vault_access` entre users, passe de réparation.
|
||||
- `tests/test_auth_api.py::TestUserHome` (2) : provisionnement via l'API
|
||||
(création → dossier, vault visible, alice ne voit que son home, suppression
|
||||
→ vault refermé / dossier conservé) + absence de dossier sans
|
||||
`OBSIGATE_HOME_ROOT`.
|
||||
|
||||
## UI — arborescence (incrément)
|
||||
|
||||
- **Ordre** : tri stable `home-*` en premier, appliqué à la source
|
||||
(`frontend/js/sidebar.js::_homeFirst`, sur les deux sites qui alimentent
|
||||
`state.allVaults`) → arborescence, sélecteurs et refreshs homogènes.
|
||||
- **Icône** : `getVaultIcon()` (`frontend/js/viewer.js`) renvoie
|
||||
`icon("home", size)` pour `home-*` → même pipeline lucide que le reste de
|
||||
l'app (`safeCreateIcons`, `stroke: currentColor`, suit le thème).
|
||||
- **Test local** : `docker-compose.test-win.yml` monte
|
||||
`test_home:/vaults/Home` + `OBSIGATE_HOME_ROOT=/vaults/Home`
|
||||
(`test_home/` est dans `.gitignore`).
|
||||
- **Vérifié live** (instance 2020, Playwright) : ordre `home-admin, TestDir,
|
||||
TestVault` (l'admin ne voit plus le home des autres), `svg.lucide-home`
|
||||
rendu, placeholder `<i>` remplacé.
|
||||
|
||||
## Isolation — « * » ne couvre pas les dossiers persos
|
||||
|
||||
- **Racine** : `check_vault_access` (`backend/auth/middleware.py`) renvoie
|
||||
``vault_name in vaults`` pour tout vault ``home-*``, quel que soit le ``*``.
|
||||
Toutes les routes (89 usages) passent par cette fonction.
|
||||
- **Filtres « * » en dur remplacés** par cet appel : dashboard
|
||||
(`routers/config.py`), conflits, liens retour, favoris, abonnements push.
|
||||
- **Recherche** : `services/search.search_vaults(is_allowed=…)` filtre les
|
||||
bruts **avant** pagination — ``total`` et page restent justes.
|
||||
- **Octroi** : `_grant` n'écarte plus les comptes ``*`` → l'admin reçoit son
|
||||
propre ``home-admin`` (réparé au démarrage pour les comptes existants).
|
||||
- **Reste ouvert** (fuite pré-existante, hors #194) : `/api/search/advanced`,
|
||||
`/api/tags`, `/api/tags/suggest`, `/api/tree-search` et `/api/suggest` ne
|
||||
filtrent pas par vault accessible — à traiter dans une tâche dédiée.
|
||||
|
||||
## Impact utilisateur
|
||||
|
||||
Aucune nouvelle chaîne i18n : les noms de vault sont des données, la liste
|
||||
provient déjà de `GET /api/vaults`. Le dossier apparaît tout seul dans le
|
||||
sélecteur de vaults de l'utilisateur concerné.
|
||||
@@ -0,0 +1,72 @@
|
||||
# #155 — Ergonomie tableur : menu contextuel, sélection type Excel
|
||||
|
||||
> **Item de roadmap :** [#155 — Ergonomie tableur (menu contextuel + sélection)](../ROADMAP.md)
|
||||
> **Origine :** retours utilisateur après #154 (refonte UI/UX tableur)
|
||||
> **Statut :** ✅ **livré** — 2026-09-29
|
||||
> **Effort estimé :** 2-4 jours (réalisé en 1 lot)
|
||||
|
||||
---
|
||||
|
||||
## 1. Demande
|
||||
|
||||
1. **Menu contextuel** au clic droit (et **appui long** sur mobile/tactile) dans la grille, sur une
|
||||
cellule, un numéro de ligne ou un en-tête de colonne.
|
||||
2. **Comportement du curseur / de la sélection comme Excel** : le curseur en forme de croix
|
||||
au-dessus des cellules, sélection d'une **plage** (glisser, `Maj+clic`, `Maj+flèches`), clic sur
|
||||
un en-tête de ligne/colonne pour sélectionner toute la ligne/colonne.
|
||||
3. (BUG-094, traité séparément) une **feuille vide/nouvelle** doit rester éditable et permettre
|
||||
d'ajouter lignes/colonnes.
|
||||
|
||||
## 2. Conception
|
||||
|
||||
### Menu contextuel
|
||||
- Positionné au **pointeur** (souris) ou au point de contact (tactile), borné au viewport.
|
||||
- Thème de l'application (classes `.xlsx-context-menu`, réutilise `.btn-action`).
|
||||
- Contenu selon la cible et le mode :
|
||||
- **Insérer** : ligne au-dessus / en dessous, colonne à gauche / à droite (sur la plage
|
||||
sélectionnée, via `PUT …/xlsx/structure`, `count` = taille de la plage).
|
||||
- **Supprimer** : ligne(s) / colonne(s) — confirmation modale (destructif).
|
||||
- **Trier** : A→Z / Z→A sur la colonne de gauche de la sélection.
|
||||
- **Effacer le contenu** : vide les cellules sélectionnées (modification locale, non
|
||||
sauvegardée tant que l'utilisateur n'enregistre pas).
|
||||
- Masqué pour les formats en lecture seule (`.xls`/`.ods`) ; sans les actions de structure pour un
|
||||
`.csv`.
|
||||
- **Appui long** (tactile) : minuteur ~500 ms annulé au déplacement / relâchement.
|
||||
|
||||
### Sélection type Excel
|
||||
- **Clic** : cellule active + sélection simple ; la barre de formule (zone Nom) affiche la plage
|
||||
(`A1` ou `A1:B3`).
|
||||
- **Glisser** : sélection rectangulaire (`mousedown` → `mouseover` sur une autre cellule →
|
||||
`mouseup`) ; le texte de la cellule n'est plus sélectionné pendant le glisser.
|
||||
- **`Maj+clic`** et **`Maj+flèches`** : étendent la sélection depuis l'ancre.
|
||||
- **En-têtes** : clic sur un numéro de ligne / lettre de colonne sélectionne la ligne/colonne
|
||||
entière. Le tri n'est plus déclenché par simple clic sur l'en-tête (il passe au menu contextuel),
|
||||
conformément au comportement Excel.
|
||||
- **Curseur** : `cursor: cell` (croix) sur les cellules, `pointer` sur les en-têtes.
|
||||
|
||||
## 3. Fichiers
|
||||
|
||||
| Couche | Fichier | Rôle |
|
||||
|---|---|---|
|
||||
| Menu | `frontend/js/xlsx/context-menu.js` (nouveau) | Menu contextuel générique thémé (build + position + dismiss) |
|
||||
| Sélection + câblage | `frontend/js/viewer.js` | État de sélection, drag, `Maj`, en-têtes, ouverture du menu, actions |
|
||||
| Styles | `frontend/style.css` | `.xlsx-selected`, `.xlsx-context-menu`, curseurs |
|
||||
| i18n | `frontend/locales/{fr,en}.json` | Libellés du menu |
|
||||
| Tests | `tests/frontend/xlsx-viewer.test.mjs` | Sélection, menu contextuel, tri via menu |
|
||||
|
||||
## 4. Critères d'acceptation
|
||||
|
||||
- Clic droit sur une cellule ouvre un menu thémé aux coordonnées du pointeur ; `Échap` ou clic
|
||||
ailleurs le ferme.
|
||||
- Insérer/supprimer ligne/colonne via le menu envoie la bonne action `PUT …/xlsx/structure`
|
||||
(avec `count` pour une plage) puis re-rend.
|
||||
- Glisser de A1 vers B2 surligne A1:B2 et la zone Nom affiche `A1:B2`.
|
||||
- `Maj+clic` étend la sélection ; cliquer un en-tête sélectionne la ligne/colonne entière.
|
||||
- Aucune régression des tests #154 (édition, save, undo/redo, dashboard).
|
||||
|
||||
## 5. Historique
|
||||
|
||||
| Date | Événement |
|
||||
|---|---|
|
||||
| 2026-09-29 | Ouverture #155 (menu contextuel + sélection type Excel) suite aux retours utilisateur |
|
||||
| 2026-09-29 | **Livré** : menu contextuel thémé (`frontend/js/xlsx/context-menu.js`) ouvert au clic droit et à l'appui long, actions résultat de `PUT …/xlsx/structure` (insérer/supprimer ligne & colonne avec `count`), tri et effacement ; sélection type Excel (glisser, `Maj+clic`, `Maj+flèches`, en-têtes ligne/colonne, curseur croix, zone Nom affichant la plage) |
|
||||
@@ -0,0 +1,322 @@
|
||||
# #156 — Éditeur Excel : complétude fonctionnelle (presse-papiers, mise en forme, calcul, robustesse)
|
||||
|
||||
> **Item de roadmap :** [#156 — Éditeur Excel — complétude fonctionnelle](../ROADMAP.md)
|
||||
> **Origine :** audit de complétude demandé le 2026-09-29, après #153 (backlog tableur),
|
||||
> #154 (refonte UI/UX) et #155 (menu contextuel & sélection)
|
||||
> **Statut :** ✅ **livré le 2026-09-30** — P0 ✅ (BUG-096 → BUG-099), P1 ✅ (A5, A6, A7),
|
||||
> P2 ✅ (A8, A9, A10) et P3 ✅ (A11, A12, A13, A14) ; ouverture 2026-09-29 (audit statique,
|
||||
> A1 reproduit en JSDOM)
|
||||
> **Effort estimé :** 15-22 jours (P0 2-3 j · P1 4-6 j · P2 5-7 j · P3 4-6 j) — **réalisé dans
|
||||
> l'enveloppe annoncée**
|
||||
> **Règle de maintenance :** la Roadmap porte les cases à cocher (suivi), cette fiche porte
|
||||
> l'analyse, les défauts, les risques et les critères d'acceptation. **Ne pas dupliquer le détail.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Objectif
|
||||
|
||||
#153 a rendu l'éditeur XLSX **correct sur le périmètre « grille de valeurs »** (lecture/écriture
|
||||
gardées, styles en lecture, structure, formats `.xlsm`/`.xls`/`.ods`/`.csv`, indexation, outils IA,
|
||||
tableau de bord). #154/#155 l'ont rendu **convivial** (ruban, inspecteur, undo/redo des cellules,
|
||||
sélection et menu contextuel type Excel).
|
||||
|
||||
Reste l'écart avec ce qu'un utilisateur attend d'un **éditeur tableur** : coller une plage,
|
||||
penser la mise en forme, calculer, sortir le résultat, et se protéger d'un autre écrivain.
|
||||
Cet item recense **4 défauts** (A1 reproduit, A4 théorique) et les **manques fonctionnels**
|
||||
correspondants.
|
||||
|
||||
## 2. Périmètre (couches touchées)
|
||||
|
||||
| Couche | Fichier | Rôle dans cet item |
|
||||
|---|---|---|
|
||||
| Lecture XLSX | `backend/xlsx_reader.py` | Fenêtres de lignes, délimiteur CSV, sonde de perte, métadonnées |
|
||||
| Écriture XLSX | `backend/services/mutations.py` | Cellules, structure, **style (A8)**, coercion, `revision` (A12) |
|
||||
| Endpoints | `backend/routers/files_read.py`, `files_write.py` | `xlsx/sheet`, `xlsx/save`, `xlsx/structure`, **`xlsx/style` (A8)**, `csv/save` |
|
||||
| Outils IA | `backend/tools/spreadsheets.py` | Lecture/édition par l'assistant (`.xlsx`/`.xlsm`/`.csv`, recherche, analyse, structure — A14) |
|
||||
| Visionneuse | `frontend/js/viewer.js` + `frontend/js/xlsx/*` | Presse-papiers, clavier, barre de formule, undo/redo, export |
|
||||
| Styles | `frontend/style.css` | Affordances presse-papiers/mise en forme (variables CSS) |
|
||||
| i18n | `frontend/locales/{fr,en}.json` | Tout libellé nouveau, FR **et** EN |
|
||||
| Tests | `tests/test_xlsx_*.py`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js` | Non-régression par sous-tâche |
|
||||
|
||||
## 3. Audit
|
||||
|
||||
### 3.1 Défauts recensés (P0) — ✅ corrigés le 2026-09-29
|
||||
|
||||
#### ✅ A1 → BUG-096 — Enregistrer un `.csv` depuis la visionneuse échoue
|
||||
|
||||
`frontend/js/viewer.js` construit l'unique job de sauvegarde ainsi :
|
||||
|
||||
```js
|
||||
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 }; // ← ligne 1757
|
||||
}).filter((job) => Object.keys(job.cells).length);
|
||||
```
|
||||
|
||||
En mode CSV, la réponse de lecture (`backend/routers/files_read.py:548-556`) **ne contient pas
|
||||
`xlsx_sheets`** : `sheets` est donc vide et `sheets[...].name` lève un `TypeError`. Vérifié le
|
||||
2026-09-29 avec le harnais JSDOM (`tests/frontend/xlsx-viewer.test.mjs`) : après édition d'une
|
||||
cellule CSV et clic sur **Enregistrer**, `TypeError: Cannot read properties of undefined
|
||||
(reading 'name')` et **aucun** `PUT /api/file/{vault}/csv/save` n'est émis. Le service backend
|
||||
`save_csv_cells()` et l'endpoint sont pourtant corrects : le défaut est purement côté visionneuse.
|
||||
|
||||
*Cause racine :* le mode CSV réutilise un panneau unique (`data-sheet="0"`) sans feuille
|
||||
correspondante dans `sheets` ; le chemin d'enregistrement n'a pas été adapté comme l'ont été les
|
||||
onglets, la structure et le tableau de bord (qui, eux, testent `isCsv`).
|
||||
|
||||
**Correctif (2026-09-29) :** le nom de feuille est résolu avec repli
|
||||
(`sheets[idx]?.name || sheets[0]?.name || data.title || data.path`). Test JSDOM : un CSV monté
|
||||
**sans** `xlsx_sheets` émet un unique `PUT …/csv/save`.
|
||||
|
||||
#### ✅ A2 → BUG-097 — `.xlsm` éditable : « Charger la suite » renvoie 415 au-delà de 500 lignes
|
||||
|
||||
Un `.xlsm` est servi **éditable** (`backend/routers/files_read.py:491-512`, pas de
|
||||
`xlsx_readonly`) : la visionneuse câble donc le chargement paresseux. Mais
|
||||
`GET /api/file/{vault}/xlsx/sheet` refuse **tout ce qui n'est pas `.xlsx`** (`files_read.py:272`,
|
||||
**415**). Une feuille `.xlsm` de plus de 500 lignes affiche donc le pied « Charger la suite » dont
|
||||
chaque clic échoue (toast d'erreur), alors que la limitation n'est pas annoncée comme telle pour
|
||||
ce format.
|
||||
|
||||
**Correctif (2026-09-29) :** `GET …/xlsx/sheet` accepte `.xlsx` **et** `.xlsm` (les autres
|
||||
formats restent en 415). Tests : fenêtre servie pour un `.xlsm`, refus maintenu pour un `.csv`.
|
||||
|
||||
#### ✅ A3 → BUG-098 — Délimiteur CSV figé : un `.csv` français s'ouvre en une seule colonne
|
||||
|
||||
`render_csv_table(raw)` utilise le délimiteur par défaut `,` (`backend/xlsx_reader.py:784`),
|
||||
appelé sans argument (`backend/routers/files_read.py:551`) — tandis que l'**export** CSV de la
|
||||
visionneuse écrit du `;` et un BOM UTF-8. Un CSV produit par Excel/Sheets en locale française
|
||||
(`;`) s'affiche donc en une colonne. Le même `,` est utilisé par `save_csv_cells()` pour re-parser
|
||||
et réécrire le fichier : l'édition peut re-sérialiser dans un format différent de la source.
|
||||
|
||||
**Correctif (2026-09-29) :** `sniff_csv_delimiter()` (Sniffer, repli par décompte sur la
|
||||
première ligne, puis `,`) est partagé par le rendu et la réécriture — `save_csv_cells()`
|
||||
réutilise le délimiteur détecté. Tests : lecture, écriture et champs entre guillemets contenant
|
||||
le séparateur.
|
||||
|
||||
#### ✅ A4 → BUG-099 — Sonde de perte plafonnée à 8 Mo : perte silencieuse possible
|
||||
|
||||
`inspect_workbook()` détecte les valeurs calculées en cache via un budget global
|
||||
`_MAX_PROBE_BYTES = 8_000_000` (`backend/xlsx_reader.py:69`) partagé entre toutes les feuilles
|
||||
(`_has_cached_formulas`). Sur un classeur dont le XML des feuilles dépasse ce budget **avant** la
|
||||
première formule cachée, `xlsx_lossy_features` ne contient pas `cached_values` : le `409`
|
||||
`xlsx_lossy_content` n'est pas déclenché et l'enregistrement détruit ces valeurs **sans
|
||||
avertissement** — exactement le risque n°1 de #153. À ce stade, risque **théorique** (non
|
||||
reproduit).
|
||||
|
||||
**Correctif (2026-09-29) :** budget **par feuille** (4 Mo) + plafond global (32 Mo) ;
|
||||
`_scan_cached_formulas()` renvoie `(found, unverified)` et `inspect_workbook()` ajoute
|
||||
`cached_values_unverified` (libellé i18n FR/EN) : la sauvegarde demande une confirmation au lieu
|
||||
de passer en silence. Trucs de test : budgets monkeypatchés. Contre-preuve : budget épuisé →
|
||||
signalé au lieu de `[]` (comportement antérieur).
|
||||
|
||||
### 3.2 Fonctions manquantes (écart vs Excel / Google Sheets)
|
||||
|
||||
**Presse-papiers et sélection** — ✅ traité en P1 (A5)
|
||||
|
||||
- ✅ Coller une **plage** : le handler forcait tout sur une ligne
|
||||
(`viewer.js` : `replace(/\r?\n/g, " ")`) — coller un bloc TSV/CSV depuis Excel écrasait une seule
|
||||
cellule. Le collage découpe désormais le bloc (`parseMatrix`) et remplit la plage à partir de la
|
||||
cellule active (`fillFrom`), 1×1 restant un remplacement de cellule.
|
||||
- ✅ Copier / Couper / Coller de plage côté application : `Ctrl+C`/`Ctrl+X`/`Ctrl+V` (événements
|
||||
`copy`/`cut`/`paste`) **et** entrées du menu contextuel ; une sélection de texte dans la cellule
|
||||
éditée garde le comportement natif.
|
||||
- Toujours absents : poignée de recopie (fill), multi-sélection `Ctrl+clic`, glisser-déposer de
|
||||
lignes/colonnes, copie d'une image/du HTML.
|
||||
|
||||
**Clavier / navigation** — ✅ traité en P1 (A6, A7)
|
||||
|
||||
- ✅ `Ctrl+S`, `Suppr`, `F2`, `Ctrl+Home/End`, `Home`/`End`, `PgUp/PgDn`, `Ctrl+flèches`, `Ctrl+A`
|
||||
sont câblés dans un handler unique de la grille ; une cellule reste en mode « navigation »
|
||||
jusqu'à la saisie (ou `F2`), sinon `Suppr`/`Home`/`End` voleraient une touche du curseur.
|
||||
- ✅ `Maj+Entrée` insère un saut de ligne **dans** la cellule (texte uniquement).
|
||||
- ✅ La **zone Nom** est un champ éditable (« Atteindre ») : `B12`, `A1:B3`, `Feuille!A1`.
|
||||
- ✅ Aide à la saisie : la barre de formule propose les noms de fonctions courants (`<datalist>`,
|
||||
liste localisée FR/EN). L'autocomplétion contextuelle reste à faire si un moteur de formules
|
||||
arrive (A9).
|
||||
|
||||
**Mise en forme en écriture** — ✅ traitée (A8, 2026-09-30)
|
||||
|
||||
- ✅ Un bouton **Mise en forme** (`#xlsx-format-btn`, classeurs éditables non-CSV) agit sur la
|
||||
**sélection** via `PUT …/xlsx/style` : gras / italique / souligné / effacer, alignement
|
||||
gauche-centré-droite, couleur de police et de fond (`<input type="color">`, donc aucune
|
||||
palette codée en dur), formats de nombre (général, nombre, pourcentage, devise, date, texte),
|
||||
fusion/défusion, volets figés/libérés, largeur de colonne et hauteur de ligne. La fusion exige
|
||||
une vraie plage (`xlsx.format_merge_needs_range`).
|
||||
- Écriture sur le modèle de `xlsx/save` / `xlsx/structure` : verrou par fichier, backup, écriture
|
||||
atomique (`.tmp` + `os.replace`), garde de perte (refus sans `force`), `If-Match` (A12), garde
|
||||
anti-formule de BUG-088, plafond `MAX_STYLE_CELLS = 10 000`, invalidation du cache méta (A13).
|
||||
- Toujours **hors périmètre** : commentaires, liens hypertexte, validation de données, mise en
|
||||
forme conditionnelle, protection de cellules (lecture seule), bordures et palette Excel.
|
||||
|
||||
**Calcul** — ✅ décision documentée (A9, 2026-09-30)
|
||||
|
||||
- Aucun moteur de formule n'est livré : ObsiGate lit et écrit les formules telles qu'Excel les a
|
||||
enregistrées, sans recalcul (pas de références inter-feuilles, pas d'agrégats). Le choix est
|
||||
**annoncé à l'utilisateur** : un enregistrement contenant une valeur commençant par `=` ou `@`
|
||||
affiche « enregistrée comme texte » tant que le bouton `f(x)` n'est pas activé ; la garde
|
||||
anti-DDE de BUG-088 reste en place.
|
||||
|
||||
**Sortie et recherche** — ✅ traité (A11, 2026-09-30)
|
||||
|
||||
- ✅ Export de la **sélection** (CSV, nom `Feuille-A1B2.csv`) quand une plage de plusieurs cellules
|
||||
est active, sinon la feuille entière ; l'écart doc/code de #153-A13 est tranché dans ce sens.
|
||||
- ✅ Menu **Exporter** : Markdown (tableau), HTML (document autonome) et **impression** (iframe
|
||||
hors écran, cadre précédent détruit). Les quatre formats suivent le **contenu affiché**.
|
||||
- ✅ La recherche (`Ctrl+F`) parcourt **toutes les feuilles** : compteur `n/m · k feuilles` et
|
||||
activation automatique de l'onglet contenant la correspondance.
|
||||
|
||||
**Robustesse / multi-postes** — ✅ traitée (A10, A12, 2026-09-30)
|
||||
|
||||
- ✅ Concurrence optimiste : `revision` (`mtime_ns` + taille) renvoyée à la lecture et acceptée en
|
||||
`If-Match` sur `xlsx/save`, `xlsx/structure`, `xlsx/style` et `csv/save` ; un écrivain externe
|
||||
détecté donne un **409** `conflict` (`details.reason = "stale_revision"`) au lieu d'un
|
||||
écrasement, et le bandeau « Réessayer » **relit** le fichier avant de rejouer.
|
||||
- ✅ Piles undo/redo **unifiées et conservées au re-rendu** (`_xlsxHistory` par fichier) : édition,
|
||||
effacement, tri/filtre et structure ; action destructive (suppression) non annulable faute
|
||||
d'inverse, et lecture du filtre **coalescée** (une entrée par session de frappe).
|
||||
- Le tri/filtre restent **d'affichage** : après un tri, un enregistrement ne persiste pas l'ordre
|
||||
(seules les cellules sales sont écrites) — le libellé `xlsx.sort_applied` le dit.
|
||||
|
||||
**Outils IA** — ✅ traités (A14, 2026-09-30)
|
||||
|
||||
- ✅ `_spreadsheet_path()` accepte `.xlsx`, `.xlsm` (macros préservées) et `.csv` (délimiteur
|
||||
conservé) ; `update_xlsx_cells` délègue à `save_csv_cells` pour un CSV.
|
||||
- ✅ Nouveaux outils : `search_workbook` (recherche multi-feuilles, comptée par feuille),
|
||||
`analyze_range` (nombre, somme, moyenne, min, max d'une plage A1), `edit_xlsx_structure`
|
||||
(feuilles, lignes, colonnes — risque WRITE, **confirmation conservée**).
|
||||
- Bornes explicites : `MAX_SCAN_ROWS/MAX_SCAN_COLS`, `MAX_SEARCH_RESULTS = 100`,
|
||||
`MAX_RANGE_VALUES = 200`, `MAX_RANGE_CELLS = 10 000`.
|
||||
|
||||
### 3.3 Limites techniques relevées
|
||||
|
||||
- `MAX_COLS = 40` sans pagination de colonnes : le chargement paresseux ne concerne que les
|
||||
lignes (`read_sheet_window`), l'axe des colonnes reste tronqué.
|
||||
- Coût de lecture : `render_sheets()` charge le classeur pour les formules, une seconde fois si
|
||||
des valeurs cachées existent, puis `read_workbook_meta()` (troisième chargement, scan
|
||||
`500 × 40` par feuille). ✅ **A13** : `read_workbook_meta()` est mémoïsé par
|
||||
`(chemin, mtime_ns, taille)` (LRU 8) et invalidé à chaque écriture — le reload complet à chaque
|
||||
fenêtre demandée a disparu ; `render_sheets()` reste hors cache (il doit relire les valeurs).
|
||||
- `allow_formula` (bouton `f(x)`) reste actif pour toute la session une fois activé : rien ne le
|
||||
remet à zéro après un enregistrement.
|
||||
- `role="grid"` posé (#154-A4) mais sans `aria-rowcount`/`aria-colcount`/`aria-selected`/
|
||||
`aria-activedescendant` ni annonce des changements de cellule active.
|
||||
|
||||
## 4. Backlog #156 — sous-tâches
|
||||
|
||||
Légende : 🔴 P0 (défaut) · 🟡 P1 (productivité immédiate) · 🟢 P2 (fidélité/finitions) ·
|
||||
effort en jours-homme (développement + tests).
|
||||
|
||||
### P0 — défauts (2-3 j)
|
||||
|
||||
- [x] **A1 — Sauvegarde `.csv` depuis la visionneuse (BUG-096).** Nom de feuille résolu avec
|
||||
repli (feuille → `data.title`/`data.path`), le chemin d'enregistrement ne dépend plus de la
|
||||
structure du classeur. *Vérifié :* test JSDOM « saving an edited csv PUTs /csv/save
|
||||
(payload without xlsx_sheets) » (contre-preuve : échouait avec le `TypeError`).
|
||||
- [x] **A2 — Chargement paresseux des `.xlsm` (BUG-097).** `GET …/xlsx/sheet` accepte `.xlsx`
|
||||
**et** `.xlsm` ; les autres formats restent refusés (415). *Vérifié :*
|
||||
`test_sheet_window_is_served_for_xlsm` + `test_sheet_window_still_refuses_other_formats`.
|
||||
- [x] **A3 — Détection du délimiteur CSV (BUG-098).** `sniff_csv_delimiter()` partagé par la
|
||||
lecture et la réécriture, qui conserve le délimiteur détecté. *Vérifié :* `TestCsvDelimiter`
|
||||
(3 séparateurs + repli) et 3 tests client (lecture, écriture, guillemets).
|
||||
- [x] **A4 — Sonde de perte par feuille (BUG-099).** Budget par feuille + plafond global ;
|
||||
budget épuisé → clé `cached_values_unverified` (i18n FR/EN) au lieu d'un silence. *Vérifié :*
|
||||
`TestXlsxCachedValueProbe` (3), dont la contre-preuve du silence antérieur.
|
||||
|
||||
### P1 — presse-papiers & clavier (4-6 j)- [x] **A5 — Presse-papiers de plage (livré le 2026-09-29).** Copier / couper / coller un bloc
|
||||
(presse-papiers interne TSV, miroir système best-effort) via `Ctrl+C`/`Ctrl+X`/`Ctrl+V` et les
|
||||
entrées du menu contextuel ; remplissage multi-cellules depuis la cellule active, bloc laissé
|
||||
sélectionné, collage **texte** uniquement (R4) et annulable cellule par cellule ; un bloc plus
|
||||
grand que la grille rendue est tronqué et signalé (`xlsx.paste_out_of_grid`). Couper efface la
|
||||
source au collage, sauf en collage sur place.
|
||||
*Vérifié :* JSDOM — `A1:B2` copié puis collé en `D5` donne `D5:E6`, bloc TSV Excel + retour
|
||||
ligne final, 1×1 qui remplace la cellule, coupe → collage, menu contextuel, débordement
|
||||
signalé, `E2E` (Playwright : copie/collage par le menu contextuel sur la fixture 520 lignes).
|
||||
- [x] **A6 — Clavier complet (livré le 2026-09-29).** `Ctrl+S`, `Ctrl+A`, `Suppr`, `F2`,
|
||||
`Ctrl+Home/End`, `Home`/`End`, `PgUp/PgDn`, `Ctrl+flèches`, `Maj+Entrée` ; undo/redo déplacés
|
||||
dans le même handler priorisé et `Suppr` passe par `clearRange()` (donc **annulable**).
|
||||
*Vérifié :* JSDOM — un test par raccourci, dont la contre-épreuve « `Suppr` pendant la saisie
|
||||
supprime un caractère, pas la sélection » ; `E2E` (Ctrl+A, Ctrl+Home/End, Suppr).
|
||||
- [x] **A7 — Zone Nom éditable & aide à la saisie (livré le 2026-09-29).** La zone Nom est un
|
||||
`<input>` (`#xlsx-active-cell`) : `B12`, `A1:B3`, `$A$1`, `Feuille!A1` (changement d'onglet) ;
|
||||
référence inconnue → message `xlsx.name_box_invalid` et adresse précédente restaurée. Le champ
|
||||
de formule porte la liste des fonctions (`<datalist id="xlsx-function-list">`, `xlsx.fn_suggestions`).
|
||||
*Vérifié :* JSDOM (atteindre, plage normalisée, refus d'une référence inconnue, bascule
|
||||
d'onglet, liste de fonctions) ; `E2E` (zone Nom → `B12`).
|
||||
|
||||
### P2 — mise en forme, calcul, undo (5-7 j)
|
||||
|
||||
- [x] **A8 — Mise en forme en écriture (livré le 2026-09-30).** Bouton **Mise en forme** :
|
||||
gras/italique/souligné/effacer, alignements, couleurs de police et de fond (sélecteur natif),
|
||||
formats de nombre (général/nombre/pourcentage/devise/date/texte), fusion/défusion, volets
|
||||
figés/libérés, largeur de colonne et hauteur de ligne, sur la sélection courante. Nouvelle route
|
||||
`PUT …/xlsx/style` (`mutate_xlsx_style`) sur le modèle de `PUT …/xlsx/structure` : verrou,
|
||||
`.tmp`/`os.replace`, backup, garde de perte (`force`), `If-Match`, garde anti-formule BUG-088,
|
||||
plafond `MAX_STYLE_CELLS`. i18n FR/EN. *Vérifié :* `tests/test_xlsx_styles.py` (17 : rechargement,
|
||||
fusions/tailles/volets, effacement, entrées invalides, CSV refusé, `if_match`, backup) et JSDOM
|
||||
(application d'un format, refus d'un CSV, garde de perte).
|
||||
- [x] **A9 — Calcul : décision documentée (livré le 2026-09-30).** Pas de moteur de formule ; le
|
||||
gap est **annoncé dans l'UI** (toast « enregistrée comme texte » à l'enregistrement si une valeur
|
||||
commence par `=` ou `@` sans le bouton `f(x)`), la garde anti-DDE reste. *Vérifié :* JSDOM
|
||||
(l'avertissement apparaît, disparaît une fois `f(x)` activé).
|
||||
- [x] **A10 — Undo/redo unifié (livré le 2026-09-30).** Entrées typées `cell|order|hidden|structure`,
|
||||
piles **par fichier** (`_xlsxHistory`, 8 fichiers) conservées au re-rendu, filtre coalescé,
|
||||
rejeu de structure silencieux, action destructive non annulable. *Vérifié :* JSDOM (édition →
|
||||
effacement → tri annulés successivement, insertion de ligne rejouée, filtre annulé en un `Ctrl+Z`).
|
||||
|
||||
### P3 — sortie, robustesse, performances (4-6 j)
|
||||
|
||||
- [x] **A11 — Sortie & recherche (livré le 2026-09-30).** Bouton **Exporter** : CSV de la
|
||||
**sélection** (sinon de la feuille, nom `Feuille-A1B2.csv`), Markdown, HTML et impression
|
||||
(iframe hors écran) ; écart #153-A13 tranché. `runFind` parcourt **toutes** les feuilles avec
|
||||
compteur `n/m · k feuilles` et activation de l'onglet correspondant. *Vérifié :* JSDOM
|
||||
(sélection multi-cellules, feuille entière, 4 formats, compteur multi-feuilles) et E2E.
|
||||
- [x] **A12 — Concurrence optimiste (livré le 2026-09-30).** `revision` (`mtime_ns` + taille)
|
||||
renvoyée à la lecture (`xlsx_revision`) et acceptée en `If-Match`/`if_match` sur `xlsx/save`,
|
||||
`xlsx/structure`, `xlsx/style` et `csv/save` ; unconflict produit un **409** `conflict`
|
||||
(`details.reason = "stale_revision"`) et le bandeau « Réessayer » relit avant de rejouer.
|
||||
*Vérifié :* `TestOptimisticConcurrency` (6) + JSDOM (version rafraîchie par la réponse, relecture
|
||||
avant le retry).
|
||||
- [x] **A13 — Performances & plafonds (livré le 2026-09-30).** Cache de `read_workbook_meta()` par
|
||||
`(chemin, mtime_ns, taille)` (LRU 8), invalidé à chaque écriture : charger une fenêtre ne
|
||||
rescanne plus le classeur. *Hors périmètre :* la pagination de l'axe des colonnes au-delà de
|
||||
`MAX_COLS = 40` (documenté dans les limites du guide). *Vérifié :* `TestMetaCache` (4).
|
||||
- [x] **A14 — Outils IA étendus (livré le 2026-09-30).** `SPREADSHEET_EXTENSIONS` =
|
||||
`.xlsx`/`.xlsm`/`.csv` (`_spreadsheet_path`, `_read_grid`), `update_xlsx_cells`/`append_xlsx_rows`
|
||||
adaptés, nouveaux outils `search_workbook` (READ), `analyze_range` (READ) et `edit_xlsx_structure`
|
||||
(WRITE, **confirmation conservée**, refus d'un CSV). *Vérifié :*
|
||||
`tests/test_spreadsheet_tools.py` (34, dont `TestXlsmSupport`, `TestCsvSupport`,
|
||||
`TestSearchWorkbook`, `TestAnalyzeRange`, `TestEditXlsxStructure`).
|
||||
|
||||
## 5. Risques et sécurité
|
||||
|
||||
| # | Risque | Où | Traitement |
|
||||
|---|---|---|---|
|
||||
| R1 | Perte silencieuse de valeurs calculées (sonde plafonnée) | `xlsx_reader.inspect_workbook` | ✅ A4 — budget par feuille + clé `cached_values_unverified` (BUG-099) |
|
||||
| R2 | Écrasement par un écrivain externe | `mutations`, routes write | ✅ A12 — `If-Match` + **409** `conflict`, retry qui relit |
|
||||
| R3 | Réécriture CSV dans un délimiteur différent de la source | `xlsx_reader`, `mutations.save_csv_cells` | ✅ A3 — `sniff_csv_delimiter` partagé (BUG-098) |
|
||||
| R4 | Collage d'un bloc : ne jamais injecter de HTML/`contenteditable` brut | `viewer.js` | ✅ A5 — insertion **texte** uniquement, échappée |
|
||||
| R5 | Mise en forme en écriture : garder la garde anti-formule (BUG-088) et la garde de perte (BUG-085) | `mutations` | ✅ A8 — `mutate_xlsx_style` réutilise verrou, backup, swap atomique, garde de perte et `If-Match` |
|
||||
|
||||
## 6. Règles de livraison (rappel `AGENTS.md` / `DELIVERY_WORKFLOW.md`)
|
||||
|
||||
- Chaque sous-tâche démarre par son **ID stable** (`#156-A<n>`) ; un **défaut** est ouvert comme
|
||||
`BUG-NNN` dans `docs/ISSUES_TODOLIST.md` (A1→BUG-096, A2→BUG-097, A3→BUG-098, A4→BUG-099).
|
||||
- Tout correctif arrive avec son **test de non-régression** (contre-preuve quand c'est possible).
|
||||
- Frontend : `vanilla JS`, **zéro build**, `safeCreateIcons()`, **variables CSS**, **i18n FR+EN**
|
||||
(`test_i18n_parity.py` vert).
|
||||
- Backend : docstrings, `response_model` pour tout endpoint ajouté, exemple dans
|
||||
`backend/openapi_docs.py`, chemin utilisateur via `resolve_safe_path()`.
|
||||
- Documentation : `CHANGELOG.md` `[Unreleased]`, Roadmap (case cochée + index), cette fiche,
|
||||
guide utilisateur i18n + README si impact utilisateur.
|
||||
|
||||
## 7. Historique
|
||||
|
||||
| Date | Événement |
|
||||
|---|---|
|
||||
| 2026-09-29 | Audit de complétude de l'éditeur Excel → ouverture de #156 et de **BUG-096 → BUG-099** ; défaut A1 reproduit en JSDOM (`TypeError` sur l'enregistrement d'un CSV, aucun `PUT …/csv/save` émis) |
|
||||
| 2026-09-29 | **P1 livré** — A5 (presse-papiers de plage), A6 (clavier complet) et A7 (zone Nom éditable + aide à la saisie) : presse-papiers interne TSV (miroir système best-effort), collage texte/annulable, `Suppr`/`F2`/`Ctrl+flèches`/`PgUp-PgDn`/`Ctrl+A`/`Maj+Entrée`, zone Nom `<input>` et liste de fonctions i18n FR/EN. Tests : JSDOM `xlsx-viewer.test.mjs` 84/84 (20 nouveaux) + E2E Playwright |
|
||||
| 2026-09-30 | **P2 livré** — A8 (mise en forme en écriture : bouton Mise en forme, `PUT …/xlsx/style`, verrou/backup/garde de perte/`If-Match`), A9 (décision « pas de moteur de formule », annoncée dans l'UI), A10 (undo/redo unifié, piles conservées au re-rendu). Tests : `test_xlsx_styles.py` 17 + JSDOM `xlsx-viewer.test.mjs` |
|
||||
| 2026-09-30 | **P3 livré** — A11 (export sélection / Markdown / HTML / impression, recherche multi-feuilles), A12 (`If-Match` + 409 réparable), A13 (cache des métadonnées par `mtime`), A14 (outils IA `.xlsm`/`.csv`, recherche, analyse de plage, structure). Tests : `test_spreadsheet_tools.py` 34, `test_xlsx_viewer.py` (`TestOptimisticConcurrency`, `TestMetaCache`), JSDOM |
|
||||
| 2026-09-30 | **Backlog #156 clôturé** — P0 → P3 livrés (A1 → A14, dont 4 défauts **BUG-096 → BUG-099**). Suite complète : `pytest tests/` 1525 passed / 6 skipped, ruff/mypy 0, JSDOM `xlsx-viewer.test.mjs` 107/107, E2E `xlsx` 10/10 |
|
||||
| 2026-09-29 | **P0 livré** — A1-A4 corrigés et testés (BUG-096 → BUG-099) : sauvegarde `.csv` depuis la visionneuse, fenêtres `.xlsm`, délimiteur CSV détecté/réutilisé, sonde de perte par feuille + signal `cached_values_unverified`. Suite complète : 1490 passed / 6 skipped, ruff/mypy 0, JSDOM `xlsx-viewer.test.mjs` 61/61 |
|
||||
@@ -0,0 +1,72 @@
|
||||
# #179 — Éditeur tableur : icônes des menus & boutons, fermeture au focus, polish mobile
|
||||
|
||||
> **Statut :** 🔵 en cours (2026-10-04) | **Effort :** 1-2 jours | **Impact :** 🟡
|
||||
> **Références :** [Roadmap](../ROADMAP.md) · [Changelog](../../CHANGELOG.md) ·
|
||||
> [#154](./xlsx-ui-redesign.md) · [#155](./xlsx-context-menu.md) · [#156](./xlsx-editor-completeness.md)
|
||||
|
||||
## 1. Constat
|
||||
|
||||
La grille (#155), les menus Structure / Mise en forme / Export (#156-A8/A11/A14)
|
||||
et les boutons du ruban (#154) étaient textuels, et les menus Structure / Mise
|
||||
en forme / Export **restaient ouverts** quand le focus les quittait (seul le
|
||||
menu grille se fermait, au clic extérieur / `Échap` uniquement).
|
||||
|
||||
## 2. Livré (A1-A4)
|
||||
|
||||
### A1 — Icônes du menu contextuel de la grille
|
||||
|
||||
Chaque entrée de `buildContextItems` (`viewer.js`) porte une icône Lucide
|
||||
(`scissors`, `copy`, `clipboard-paste`, `rows`/`columns`, `trash-2` danger,
|
||||
`arrow-up`/`arrow-down`, `eraser`). Rendu dans `xlsx/context-menu.js`
|
||||
(`<i data-lucide>` + `<span class="xlsx-menu-label">`, label en `textContent`
|
||||
— jamais d'HTML injecté), hydraté par `safeCreateIcons()` (jamais
|
||||
`lucide.createIcons()` direct).
|
||||
|
||||
### A2 — Icônes Structure / Mise en forme / Export + boutons
|
||||
|
||||
- Structure : `file-plus`, `pencil`, `copy`, `trash-2` (danger), `rows`,
|
||||
`columns` ; suppressions marquées `.xlsx-context-danger`.
|
||||
- Mise en forme : `bold`/`italic`/`underline`, `remove-formatting`,
|
||||
`align-left/center/right`, `palette`/`paint-bucket` (sélecteurs natifs),
|
||||
`type`/`hash`/`percent`/`euro`/`calendar`/`pilcrow` (formats),
|
||||
`combine`/`ungroup`, `snowflake`/`sun` (figer/libérer),
|
||||
`move-horizontal`/`move-vertical` (largeur/hauteur).
|
||||
- Export : `file-text` (MD), `code` (HTML), `printer` (impression).
|
||||
- Ruban : bouton Enregistrer (`save` + libellé), onglet « + » (`plus`),
|
||||
`aria-haspopup="menu"` / `aria-expanded` sur les 3 boutons à menu.
|
||||
|
||||
### A3 — Fermeture à la perte de focus + clavier
|
||||
|
||||
- `trackDismissable(menu, { toggle, onClose })` dans `context-menu.js` :
|
||||
clic extérieur (le toggle garde ses propres clics), `Échap`, `focusout`
|
||||
(vers menu **et** toggle exemptés), scroll (capture), resize ; `dismiss()`
|
||||
idempotent qui débranche tout (pas de fuite entre re-rendus).
|
||||
- Menu grille : `focusout` + scroll/resize en plus de l'existant ;
|
||||
`enableArrowNav()` (`↓`/`↑`/`Home`/`End`, désactivés sautés), autofocus de
|
||||
la 1ʳᵉ entrée à l'ouverture (`preventScroll`), `:focus` toujours visible.
|
||||
- Menus Structure / Mise en forme / Export branchés sur le helper.
|
||||
|
||||
### A4 — Polish mobile
|
||||
|
||||
- `@media (pointer: coarse)` : entrées ≥ 44 px, police 0,9 rem.
|
||||
- Menus bornés au viewport (grille : `min(320px, 86vw)` + `max-height: 80vh`
|
||||
scrollable ; structure : `min(320px, 92vw)` ; export : `86vw`).
|
||||
- Le ruban s'enroule déjà (`flex-wrap`), l'inspecteur s'empile ≤ 900 px :
|
||||
vérifié sans débordement horizontal à 393 px (E2E projet mobile).
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- `tests/frontend/xlsx-menus.test.mjs` (11) : rendu icônes + XSS, autofocus,
|
||||
navigation clavier, `Échap` / clic / `focusout` / scroll / resize,
|
||||
`trackDismissable`, icônes du ruban.
|
||||
- `tests/frontend/xlsx-viewer.test.mjs` (+6, 113 total) : icônes et fermetures
|
||||
au niveau viewer réel.
|
||||
- `tests/e2e/xlsx-viewer.spec.js` (+5) : icônes par entrée, fermeture clic/`Échap`,
|
||||
Structure au focus perdu, Export au clic extérieur, mobile (ruban, 44 px,
|
||||
viewport) — ce dernier `skip` hors viewport ≤ 768 px.
|
||||
|
||||
## 4. Limites assumées
|
||||
|
||||
- Noms d'icônes de la gamme Lucide 0.344.0 (CDN `unpkg`) : si le CDN est
|
||||
injoignable, les libellés restent (dégradation texte, jamais de crash).
|
||||
- Pas de nouveaux libellés i18n (icônes + `aria-expanded` uniquement).
|
||||
@@ -0,0 +1,119 @@
|
||||
# #185 — Tableur : barre de menus Google Sheets, boutons façon Sheets, grille unie
|
||||
|
||||
> **Statut :** ✅ livré le 2026-10-08 (v2.54.0) — ouvert le 2026-10-05 | **Effort :** 1-2 jours | **Impact :** 🟡
|
||||
> **Références :** [Roadmap](../ROADMAP.md) · [Changelog](../../CHANGELOG.md) ·
|
||||
> [#154](./xlsx-ui-redesign.md) · [#179](./xlsx-menus-179.md) ·
|
||||
> [#180](./xlsx-sheetbar-180.md) · [#183](./xlsx-sheet-input-181.md))
|
||||
|
||||
## 1. Constat
|
||||
|
||||
L'éditeur tableur avait un ruban de boutons et trois menus (Structure, Mise en
|
||||
forme, Export) mais **aucune barre de menus** : ni Fichier, ni Modifier, ni
|
||||
Affichage, ni Insertion, ni Format, ni Données, ni Outils, ni Gemini, ni
|
||||
Extensions, ni Aide. Beaucoup de fonctions existentes n'étaient atteignables que
|
||||
par un clic droit ou par un bouton à connaître. Le ruban lui-même gardait un
|
||||
chromer bordersé qui ne ressemblait à rien, et la grille n'était bordée qu'en
|
||||
bas avec une couleur (`--border`) quasi identique au fond des cellules : le
|
||||
quadillage se lisait comme une tache, pas comme une grille.
|
||||
|
||||
## 2. Livré
|
||||
|
||||
### A1 — Barre de menus (les 10 menus de Google Sheets)
|
||||
|
||||
`frontend/js/xlsx/menus.js` (nouveau) décrit **les dix menus, dans l'ordre et avec
|
||||
les libellés exacts de Google Sheets** (Fichier · Modifier · Affichage ·
|
||||
Insertion · Format · Données · Outils · Gemini · Extensions · Aide), puis
|
||||
`buildMenuBar()` produit la barre et ses dix menus déroulants. 161 entrées, dont
|
||||
**125 wirables** (67 actions distinctes) et **36 honestement déclarées
|
||||
indisponibles** (elles s'affichent mais répondent « *« X » n'est pas disponible
|
||||
dans ObsiGate* »** — jamais un clic muet). Les sections imbriquées (Nombre,
|
||||
Thème, Alignement, Zoom, Nettoyage…) sont des **accordéons** comme dans Sheets :
|
||||
une seule ouverte à la fois par niveau.
|
||||
|
||||
Le menu **Fichier** a quitté la barre d'onglets pour la barre de menus, en
|
||||
conservant ses identifiants (`#xlsx-file-btn`, `#xlsx-file-menu`,
|
||||
`#xlsx-download-btn`, `#xlsx-csv-btn`, `[data-xlsx-export]`). Seul
|
||||
**Enregistrer** reste épinglé à droite de la barre d'onglets.
|
||||
|
||||
### A2 — Fonctions réellement câblées
|
||||
|
||||
- **Fichier** : Partager, Télécharger (original · CSV · Markdown · HTML),
|
||||
Imprimer, **Renommer** (`PATCH /api/file/{vault}`), **Corbeille**
|
||||
(`DELETE /api/file/{vault}`), **Historique des versions** (visionneuse de
|
||||
sauvegardes), Détails (vault, chemin, feuilles, cellules, révision, ·
|
||||
modifications en attente via `/api/health` pour le numéro de version).
|
||||
- **Modifier** : Annuler, Rétablir, Couper, Copier, Coller, **Collage spécial →
|
||||
valeurs uniquement** (les formules sont évaluées puis collées comme texte),
|
||||
**Supprimer → lignes / colonnes / contenu**, Tout sélectionner,
|
||||
**Rechercher et remplacer** (toutes les feuilles).
|
||||
- **Affichage** : Figer (ici / ligne / colonne / libérer), **Afficher → barre de
|
||||
formule · grille · en-têtes · tableau de bord**, Grouper (déclaré), Feuilles
|
||||
masquées (déclaré), **Zoom** (50 → 200 %, même état que le ruban), **Plein
|
||||
écran**.
|
||||
- **Insertion** : Lignes (au-dessus / en dessous), Colonnes (à gauche / à
|
||||
droite), Cellules, Lien, Commentaire, **Fonction** (insère l'appel et place le
|
||||
curseur entre les parenthèses).
|
||||
- **Format** : Nombre (6 formats), **Thème** (liste des 15 thèmes construite à la
|
||||
demande), Gras / Italique / Souligné / Barré, Taille de police, Alignement
|
||||
(horizontal + vertical), Retour à la ligne, Rotation, **Couleurs alternées**
|
||||
(une écriture par ligne, lots de 50 — plafond du backend), Effacer la mise en
|
||||
forme.
|
||||
- **Données** : Trier la plage (croissant / décroissant / réinitialiser),
|
||||
**Créer un filtre** (sur la valeur de la cellule active), **Plages nommées**
|
||||
(panneau du tableau de bord), **Nettoyage → supprimer les doublons ·trimmer**,
|
||||
**Diviser le texte en colonnes**.
|
||||
- **Outils** : **Saisie semi-automatique** (bascule la liste de fonctions de la
|
||||
barre de formule), **Accessibilité** (cellules plus grandes, grille plus
|
||||
contrastée).
|
||||
- **Gemini** : les six entrées ouvrent le panneau de l'assistant IA.
|
||||
- **Aide** : aide intégrée, **Mises à jour** (version), **Raccourcis clavier**
|
||||
(liste), **Signaler un problème** (diagnostic copié dans le presse-papiers).
|
||||
|
||||
### A3 — Ruban façon Google Sheets
|
||||
|
||||
Boutons **plats** : 30 × 30, sans bordure ni fond au repos, survol discret, état
|
||||
actif teinté, groupes séparés par des filets verticaux. Les sélecteurs (zoom,
|
||||
format de nombre, taille de police) suivent le même traitement. **Enregistrer**
|
||||
reste le seul pilier plein. Nouveau bouton **grille** (`#xs-grid`), piloté par le
|
||||
menu Affichage et inversement.
|
||||
|
||||
### A4 — Grille unie et visible
|
||||
|
||||
- Chaque cellule est encadrée sur **les quatre côtés** par **la même** ligne
|
||||
1 px (les en-têtes compris : ils se distinguent par leur fond, plus par une
|
||||
bordure plus lourde).
|
||||
- `--grid-border` n'est plus `--border` (1-2 % d'écart avec le fond sur la plupart
|
||||
des thèmes) mais un `color-mix` **du texte vers la surface** calculé par
|
||||
`themes.js` : une seule valeur pour toute la grille, lisible en sombre comme
|
||||
en clair et dans les modes générés (contraste élevé, sépia).
|
||||
- Les valeurs de repli des 4 blocs `:root` ont été remontées en contraste.
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- `tests/frontend/xlsx-menus.test.mjs` : i18n amorcé, barre de menus (10 libellés,
|
||||
un déroulant par bouton, accordéon), ** exhaustivité des actions déclarées**,
|
||||
disparition des entrées d'édition en lecture seule, absence de HTML dans les
|
||||
libellés, ids historiques du menu Fichier ; ruban (save, « + », grille).
|
||||
- `tests/frontend/xlsx-viewer.test.mjs` : barre présente au-dessus du ruban,
|
||||
entrée indisponible qui répond (et n'envoie rien), bascules Affichage, Zoom,
|
||||
écriture de structure (Insertion), écriture de style (Format ▸ Nombre),
|
||||
**Couleurs alternées** (3 lignes → 3 ops), Accessibilité, Raccourcis clavier,
|
||||
classeur en lecture seule, jeton `--grid-border`.
|
||||
- `tests/e2e/xlsx-viewer.spec.js` : les 10 menus dans le bon ordre et **au-dessus**
|
||||
du ruban, accordéon Format ▸ Nombre, option indisponible, **grille 1 px sur
|
||||
les 4 côtés** puis masquée à 0 px.
|
||||
|
||||
## 4. Limites assumées
|
||||
|
||||
- Les 36 options Google Sheets qu'ObsiGate n'implémente pas (macros, Apps
|
||||
Script, TCD, graphiques, formulaires,/modules, AppSheet, Looker Studio,
|
||||
training…) sont **listées pour rester fidèle** à la demande, et **annoncées**
|
||||
comme indisponibles au clic. Elles ne sont pas masquées : la parité visuelle
|
||||
prime, l'honnêteté prime sur le silence.
|
||||
- **Déplacer** lignes/colonnes est déclaré indisponible : leivinguer
|
||||
(supprimer + insérer + réécrire) sur un classeur avec fusions, styles et
|
||||
formules n'est pas sûr en un seul geste.
|
||||
- Le menu **Thème** est construit à la demande (import dynamique de
|
||||
`themes.js`) : 15 entrées, pas de coût au rendu.
|
||||
- Les couleurs de la grille sont dérivées via `color-mix` : un moteur qui ne le
|
||||
supporte pas retombe sur `--grid-border` (feuille de style).
|
||||
@@ -0,0 +1,81 @@
|
||||
# #189 — Tableur : mise en page mobile (barres repliées, grille prioritaire)
|
||||
|
||||
> **Statut :** ✅ livré (2026-10-08) | **Effort :** 0,5-1 jour | **Impact :** 🟡
|
||||
> **Références :** [Roadmap](../ROADMAP.md) · [Changelog](../../CHANGELOG.md) ·
|
||||
> [#180](./xlsx-sheetbar-180.md) · [#185](./xlsx-menus-185.md)
|
||||
|
||||
## 1. Constat
|
||||
|
||||
Sur mobile, l'empilement `.xlsx-menubar` (10 menus) + `.xlsx-cmdbar`
|
||||
(~40 boutons qui s'enroulent sur 4-5 rangées) + `.xlsx-sheetbar` +
|
||||
`.xlsx-formula-bar` occupait **plus de 50 % de la hauteur d'écran** : la
|
||||
grille, objet premier de l'éditeur, passait après la chrome.
|
||||
|
||||
## 2. Livré
|
||||
|
||||
### A1 — Repli par défaut (CSS, ≤ 768 px)
|
||||
|
||||
Bloc `#189` dans `frontend/style.css` (après les règles `pointer: coarse`) :
|
||||
|
||||
- `.xlsx-menubar`, `.xlsx-cmdbar`, `.xlsx-find-group` → `display: none` par
|
||||
défaut sous 768 px ;
|
||||
- `.xlsx-toolbar.xlsx-tools-open …` les réaffiche (état de la classe portée
|
||||
par la **barre entière**, donc menubar + ruban + Recherche restent cohérents) ;
|
||||
- `.xlsx-formula-bar` reste **toujours visible** (zone Nom + `fx` + champ) et
|
||||
passe en `flex-wrap` quand les outils sont ouverts (le groupe Recherche
|
||||
réapparaît sur une 2ᵉ ligne) ;
|
||||
- `.xlsx-tools-toggle { display: none }` sur desktop : **zéro changement
|
||||
visuel hors mobile**.
|
||||
|
||||
### A2 — Bascule unique (JS)
|
||||
|
||||
`frontend/js/viewer.js` (`renderXlsxViewer`) :
|
||||
|
||||
- bouton `#xlsx-tools-toggle` (icône Lucide `menu`, `aria-expanded`,
|
||||
`aria-label` i18n `xlsx.tools_toggle`) inséré **en tête de
|
||||
`.xlsx-sheetbar`** — donc toujours visible, entre les onglets et Enregistrer ;
|
||||
- listener : bascule `.xlsx-tools-open` sur `.xlsx-toolbar`, met à jour
|
||||
`aria-expanded` + classe `active`, `safeCreateIcons()` pour l'icône.
|
||||
|
||||
### A3 — Ergonomie tactile (CSS)
|
||||
|
||||
- cibles ≥ **44 px** : `.xlsx-toolbar .btn-action`, `.xlsx-menubtn`,
|
||||
`.xlsx-tab` / `.xlsx-tab-add` ; à cette taille un groupe du ruban
|
||||
(police, nombre…) déborde d'un téléphone : `.xlsx-cmd-group` passe en
|
||||
`flex: 0 1 auto; min-width: 0; flex-wrap: wrap` pour s'enrouler au lieu
|
||||
de sortir de l'écran (contre-preuve E2E : `scrollWidth - clientWidth ≤ 1`) ;
|
||||
- onglets de feuilles : une seule ligne, **défilement horizontal**
|
||||
(`overflow-x: auto`, scrollbars masquées) au lieu d'enrouler ;
|
||||
- champs de la barre de formule en **16 px** : en dessous, iOS zoome
|
||||
automatiquement la page au focus (la « classique » debout de l'UI) ;
|
||||
- groupe Recherche masqué avec le ruban pour ne pas écraser le champ de saisie.
|
||||
|
||||
### A4 — i18n
|
||||
|
||||
Clé `xlsx.tools_toggle` : FR « Afficher / masquer les barres d'outils » /
|
||||
EN « Show / hide toolbars » (parité vérifiée par `tests/test_i18n_parity.py`).
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- `tests/frontend/xlsx-viewer.test.mjs` (+2, **165/165**) :
|
||||
1. la bascule vit dans la barre des feuilles et flippe correctement
|
||||
classe / `aria-expanded` / `active` (aller-retour) ;
|
||||
2. `style.css` masque menubar/ruban/Recherche par défaut sous 768 px et les
|
||||
réaffiche via `.xlsx-tools-open` (44 px, onglets scrollables, inputs
|
||||
16 px présents dans le bloc).
|
||||
- `tests/e2e/xlsx-viewer.spec.js` — test mobile réécrit : repli par défaut
|
||||
(menubar/ruban cachés, formule + onglets visibles) → dépliage → ruban sans
|
||||
débordement horizontal → cibles ≥ 44 px → repli ; `openXlsx()` déplie
|
||||
automatiquement sous 768 px pour que les tests « desktop » restent valables
|
||||
sur les deux viewports.
|
||||
|
||||
## 4. Limites assumées
|
||||
|
||||
- Le repli est piloté par la **largeur** (`max-width: 768px`), pas par
|
||||
`pointer: coarse` : une tablette large en mode tactile garde la chrome
|
||||
complète (elle a la hauteur pour).
|
||||
- Le groupe Recherche n'est accessible sur mobile qu'avec les outils ouverts
|
||||
; le reset de tri reste atteignable par la mini-barre de sélection et le
|
||||
menu Données (vérifié).
|
||||
- Pas de drawer latéral ni de FAB : un seul bouton rétractable suffit à
|
||||
l'usage — ajouter un panneau glissant si le retour utilisateur le réclame.
|
||||
@@ -0,0 +1,356 @@
|
||||
# #181 — Éditeur tableur : modèle de saisie Google Sheets & inventaire des écarts
|
||||
|
||||
> **Statut :** ✅ livré le 2026-10-08 (v2.54.0) — ouvert le 2026-10-05 | **Impact :** 🟡
|
||||
> **Références :** [Roadmap](../ROADMAP.md) · [Changelog](../../CHANGELOG.md) ·
|
||||
> [#154](./xlsx-ui-redesign.md) · [#155](./xlsx-context-menu.md) ·
|
||||
> [#179](./xlsx-menus-179.md) · [#180](./xlsx-sheetbar-180.md)
|
||||
|
||||
## 1. Constat
|
||||
|
||||
L'éditeur ne distinguait pas **sélectionner** une cellule et **l'éditer** :
|
||||
|
||||
- le curseur de texte clignotait en permanence dans la cellule focalisée
|
||||
(tout est `contenteditable`, `caret-color` par défaut) ;
|
||||
- `Entrée` validait puis faisait `blur()` — le focus **quittait la grille** ;
|
||||
- l'indicateur de plage était un simple lavis, sans contour, et les en-têtes
|
||||
de la plage sélectionnée n'étaient pas reflétés ;
|
||||
- la première frappe **ajoutait** au contenu existant au lieu de le remplacer.
|
||||
|
||||
Un utilisateur habitué à Google Sheets ou Excel perd le repère : il ne sait
|
||||
plus s'il est en train de naviguer ou de saisir.
|
||||
|
||||
## 2. Livré (A1-A3)
|
||||
|
||||
### A1 — Deux états distincts : sélection et édition
|
||||
|
||||
Toutes les cellules portent la classe `xlsx-cell` et démarrent en
|
||||
**sélection** (`data-editing=""`) : contour visible, **aucun curseur**. Le
|
||||
curseur est masqué via `caret-color: transparent` — donc sans laisser
|
||||
`contenteditable`, ce qui préserve le collage, le clic et la frappe native.
|
||||
|
||||
L'**édition** s'atteint par trois voies, comme dans Google Sheets :
|
||||
|
||||
| Geste | Effet |
|
||||
|---|---|
|
||||
| Clic / flèches | sélectionne (pas de curseur) |
|
||||
| **Double-clic** | édite **sur place** — `caretRangeFromPoint` place le curseur sous le pointeur |
|
||||
| **Première frappe** | édite et **remplace** le contenu (jamais d'ajout) |
|
||||
| `F2` | édite, curseur en fin de texte |
|
||||
| `Alt`+flèche | saut de ligne dans la cellule |
|
||||
| `Échap` | quitte l'édition et **restaure** la valeur |
|
||||
|
||||
`enterEditing()` / `exitEditing()` centralisent la bascule. Un garde
|
||||
`editingRequested` empêche le handler `focus` de la cellule d'annuler l'état
|
||||
d'édition que `enterEditing()` vient d'installer (il se déclenche au moment du
|
||||
`focus`).
|
||||
|
||||
### A2 — Sélection multiple et indicateur visuel
|
||||
|
||||
- `Maj`+flèches étend depuis l'ancre, glisser avec la souris aussi, plus
|
||||
`Maj`+clic — le mécanisme existait déjà, il est désormais complet et lisible.
|
||||
- Les cellules portent `.xlsx-selected` **et** quatre classes de bord
|
||||
(`.xlsx-sel-top/bottom/left/right`) : le bloc est **contouré** exactement
|
||||
comme dans Excel/Sheets, au lieu d'un lavis uniforme. Chaque cellule ne
|
||||
reçoit que les bords qu'elle porte réellement (les coins n'héritent pas d'un
|
||||
bord intérieur).
|
||||
- Les en-têtes couverts (lettres `A`, `B`… et numéros `1`, `2`…) reçoivent
|
||||
`.xlsx-hdr-selected` : la plage se lit aussi dans les marges.
|
||||
|
||||
### A3 — `Entrée` contextuelle
|
||||
|
||||
| État | `Entrée` |
|
||||
|---|---|
|
||||
| **édition** | valide, puis le focus passe **à la cellule du dessous** |
|
||||
| **sélection** | bascule la cellule courante en **mode édition** |
|
||||
|
||||
`Tab` suit la même logique (validation + déplacement latéral en édition,
|
||||
déplacement simple en sélection), et un clic simple après une édition abandonne
|
||||
l'état d'édition sans laisser de curseur.
|
||||
|
||||
## 3. A4 — Inventaire des écarts vs Google Sheets / Excel
|
||||
|
||||
**Rapprochement exhaustif impossible en une tranche.** L'éditeur couvre déjà
|
||||
l'essentiel ; l'inventaire ci-dessous est l'état réel vérifié dans le code au
|
||||
2026-10-05.
|
||||
|
||||
### 3.1 Déjà couvert
|
||||
|
||||
Édition de cellules · types et formats · recherche **et** remplacement ·
|
||||
trier / filtrer · gel des volets · fusions · annuler / rétablir · presse-papiers
|
||||
de plage · insertion / suppression de lignes, colonnes et feuilles ·
|
||||
graphiques KPI du tableau de bord · commentaires · liens hypertexte (formule
|
||||
`=LIEN`) · export (CSV, Markdown, HTML, impression) · impression ·
|
||||
collaboration temps réel · import de formats · lecture seule `.xls` / `.ods`.
|
||||
|
||||
### 3.2 Manquants, par priorité
|
||||
|
||||
**P1 — usage quotidien**
|
||||
|
||||
- **Poignée de recopie** (fill handle) : le petit carré en bas à droite de la
|
||||
plage, glisser pour remplir séries et formules. Très attendu, absent.
|
||||
- **Remplacer** (`Ctrl+H`) : seule la *recherche* existe.
|
||||
- **Validation de données** : listes déroulantes, bornes numériques.
|
||||
- **Filtre automatique** : l filtrage texte existe, pas les entêtes de filtre
|
||||
par colonne.
|
||||
- **Tri multi-critères** et options de tri (couleur, taille, valeurs
|
||||
personnalisées).
|
||||
- **Déplacer lignes / colonnes** par glisser-déposer (l'insertion existe).
|
||||
|
||||
**P2 — présentation et analyse**
|
||||
|
||||
- Mise en forme conditionnelle (couleurs, barres de données, jeu d'icônes).
|
||||
- **Graphiques** dans le tableur : KPI et relevé de graphiques existants sont
|
||||
lus, mais on ne **crée pas** de graphique.
|
||||
- Tableaux croisés : lus et affichés, non modifiables.
|
||||
- Groupement / dégroupement de lignes et colonnes.
|
||||
- Images et formes dans les cellules (ObsiGate a Excalidraw séparé).
|
||||
- Protection d'une feuille ou de cellules (lecture seule).
|
||||
- Masquer des lignes / colonnes.
|
||||
|
||||
**P3 — avancé**
|
||||
|
||||
- Analyse de données : supprimer les doublons, convertir du texte en colonnes,
|
||||
supprimer les espaces superflus.
|
||||
- Fusion des cellules à l'identique, annotations, suivi des modifications,
|
||||
historique des révisions.
|
||||
- Macros (les `.xlsm` sont lus et réécrits avec `keep_vba`).
|
||||
- Simulation de scénarios, recherche de cible (goal seek).
|
||||
- Options de tri avancées (insensible à la casse, séparateur décimal
|
||||
personnalisé).
|
||||
- Peintures de cellules, texte en art.
|
||||
|
||||
## 4. Tests
|
||||
|
||||
`tests/frontend/xlsx-viewer.test.mjs` — **146/146** (10 nouveaux cas #181) :
|
||||
sélection sans curseur au clic et aux flèches · double-clic qui édite en place ·
|
||||
première frappe qui **remplace** · deuxième frappe qui n'efface pas ·
|
||||
`Entrée` qui passe en édition · `Entrée` qui valide et descend sous la cellule ·
|
||||
extension `Maj`+flèches avec les quatre bords du contour et les en-têtes
|
||||
reflétés · glisser qui peint le même contour · clic simple qui fait tomber
|
||||
l'état d'édition · `Échap` qui restaure la valeur.
|
||||
|
||||
Suites frontend également vertes : `xlsx-menus` 12/12, `unit` 13, `editor-inline`
|
||||
44, `toolbar-order`, `mobile-editor` 35, `ai` 100, `pane-manager` 9,
|
||||
`validate-imports` 41 modules / 355 exports.
|
||||
|
||||
## 5. Point d'attention
|
||||
|
||||
Le `caret-color: transparent` repose sur la prise en charge navigateur
|
||||
(`caret-color` est largement disponible). Sur un très ancien moteur, le curseur
|
||||
resterait visible en sélection — l'état reste correct, seul l'indicateur
|
||||
disparaît.
|
||||
|
||||
## 6. #182 — Désélection & couleurs sur une plage
|
||||
|
||||
### 6.1 La sélection « collée »
|
||||
|
||||
**Symptôme** : après un `Maj`+clic ou un glissement, la plage restait affichée et
|
||||
un clic simple ne la supprimait pas.
|
||||
|
||||
**Cause racine** — la désélection reposait entièrement sur l'événement `focus` de
|
||||
la cellule, qui appelle `setActiveCell()` et donc réduit la plage à la cellule
|
||||
focalisée. Or le handler `Maj`+clic appelle `e.preventDefault()`, ce qui
|
||||
**supprime le déplacement de focus natif du navigateur**. Résultat : après un
|
||||
`Maj`+clic, `activeTd` valait la cellule cliquée mais `document.activeElement`
|
||||
restait sur l'ancienne. Le clic suivant **sur la cellule déjà focalisée** ne
|
||||
déclenchait donc aucun `focus` : la plage ne pouvait plus s'effacer.
|
||||
|
||||
**Correctif**
|
||||
|
||||
- le clic simple appelle `setActiveCell(td)` dans `mousedown` : la plage est
|
||||
effacée de façon déterministe, sans dépendre du focus ;
|
||||
- le `Maj`+clic déplace réellement le focus, sous `suppressSelectionReset` pour
|
||||
que le handler `focus` ne rétrécisse pas la plage qui vient d'être étendue ;
|
||||
- le glissement ne vole **pas** le focus (comme dans un navigateur) — c'est
|
||||
précisément ce vol qui aurait fait disparaître la plage tracée ;
|
||||
- `endDrag` restaure la zone Nom sur la **plage** (`A1:B2`) au lieu de la
|
||||
cellule d'origine.
|
||||
|
||||
Le même correctif a guéri le symptôme connexe « la mini-barre de sélection ne
|
||||
se cachait plus après un glissement ».
|
||||
|
||||
### 6.2 Couleurs de texte et de fond
|
||||
|
||||
Les trois chemins ont été **vérifiés** (charge utile `PUT …/xlsx/style` puis
|
||||
peinture locale) sur une plage `A1:B2` obtenue par `Maj`+flèches **et** par
|
||||
glissement :
|
||||
|
||||
| Chemin | Charge utile | Résultat |
|
||||
|---|---|---|
|
||||
| Menu **Mise en forme** → couleur | `range: "A1:B2"` | 4 cellules peintes |
|
||||
| Nuancier du ruban (police) | `range: "A1:B2"` | 4 cellules peintes |
|
||||
| Nuancier du ruban (fond) | `range: "A1:B2"` | 4 cellules peintes |
|
||||
|
||||
Le mécanisme d'écriture était donc correct ; ce qui manquait réellement, c'était
|
||||
le **retour visuel** : les deux nuanciers du ruban n'affichaient rien, d'où
|
||||
l'impression que la couleur n'était pas appliquée. Ils portent désormais une
|
||||
**pastille** (`.xlsx-color-swatch`) qui prend la couleur choisie.
|
||||
|
||||
### 6.3 Tests
|
||||
|
||||
`tests/frontend/xlsx-viewer.test.mjs` — **149/149** (+3 cas #182) :
|
||||
|
||||
- un clic simple réduit toujours une plage « collée » (y compris sur la cellule
|
||||
qui porte déjà le focus) ;
|
||||
- la zone Nom affiche la plage après un glissement ;
|
||||
- couleurs de texte et de fond appliquées aux 4 cellules d'une plage, charge
|
||||
utile vérifiée, pastilles synchronisées.
|
||||
|
||||
> Note d'implémentation : la peinture locale est **asynchrone** (elle suit
|
||||
> l'écriture `PUT`), un test qui lit les styles inline doit donc attendre le
|
||||
> tour de boucle d'événements.
|
||||
|
||||
### 6.4 Les contours qui s'empilaient (le point manquant)
|
||||
|
||||
**Symptôme** : en déplaçant le focus de cellule en cellule, les encadrés de
|
||||
l'ancienne cellule **restaient** et s'accumulaient — la grille finit quadrillée.
|
||||
|
||||
**Cause racine** — le sélecteur de nettoyage était écrit **sans le point** :
|
||||
|
||||
```js
|
||||
// ✘ avant : en CSS, un nom nu est un sélecteur de BALISE (<xlsx-sel-top>)
|
||||
panel.querySelectorAll("xlsx-sel-top,xlsx-sel-bottom,...")
|
||||
// ✔ après
|
||||
panel.querySelectorAll(".xlsx-sel-top,.xlsx-sel-bottom,...")
|
||||
```
|
||||
|
||||
La requête ne renvoyait **aucun** élément, donc `classList.remove(...)` n'était
|
||||
jamais appelé et aucun contour n'était effacé. La même famille de faute
|
||||
touchait `classList.remove(".xlsx-hdr-selected")` (un point passé comme jeton
|
||||
de classe ne correspond à rien) : les en-têtes reflétés n'étaient jamais effacés
|
||||
non plus.
|
||||
|
||||
Le test de non-régression compte les contours dans toute la grille après une
|
||||
randonnée au clavier et exige qu'**une seule** cellule soit encadrée.
|
||||
|
||||
### 6.5 Flèches pendant la saisie
|
||||
|
||||
Dans Google Sheets comme dans Excel, une flèche pendant la saisie **valide la
|
||||
cellule et déplace la sélection** ; le curseur n'est pas parcouru dans le texte
|
||||
(`Alt`+flèche sert à insérer un saut de ligne). Notre implémentation laissait
|
||||
le curseur se déplacer et la cellule restait figée sous l'encadré — ce qui
|
||||
amplifiait la confusion. Corrigé.
|
||||
|
||||
## 7. #183 — Panneau de couleurs façon Google Sheets
|
||||
|
||||
### 7.1 Ce qui remplace quoi
|
||||
|
||||
Le `<input type="color">` natif (ruban **A** / seau, et les deux lignes du menu
|
||||
**Mise en forme**) est remplacé par un bouton ouvrant un panneau construit par
|
||||
`frontend/js/xlsx/color-picker.js` :
|
||||
|
||||
| Section | Contenu |
|
||||
|---|---|
|
||||
| En-tête | icône + **Réinitialiser** (gomme pour le fond, pinceau pour le texte) |
|
||||
| Palette | **8 × 10 = 80** pastilles — ligne 1 : 8 gris du noir au blanc ; lignes 2-10 : 8 teintes (rouge, orange, jaune, vert clair, cyan, bleu, violet, magenta) × 9 nuances, du clair au foncé |
|
||||
| **STANDARD** | 8 couleurs prédéfinies + crayon |
|
||||
| **PERSONNALISÉ** | pastille de la couleur personnalisée, bouton « + », crayon |
|
||||
| Pied | **Mise en forme conditionnelle**, et **Couleurs en alternance** (n'apparaît qu'une fois une couleur choisie, comme sur la maquette) |
|
||||
|
||||
Un clic sur une pastille **referme le panneau** et applique la couleur à la
|
||||
cellule ou à la plage sélectionnée. La coche de sélection est blanche ou noire
|
||||
selon la luminance de la pastille (`luminanceOf`).
|
||||
|
||||
### 7.2 Le piège d'encodage
|
||||
|
||||
La palette est calculée en HSL (lisible) mais **doit sortir en `#rrggbb`** :
|
||||
`normHex()` côté viewer n'accepte que l'hexadécimal à 6 chiffres, et une
|
||||
valeur `hsl(0, 0%, 1429%)` était donc rejetée **sans erreur** — la pastille se
|
||||
peignait à l'écran mais la couleur n'était jamais écrite sur la cellule.
|
||||
`hslToHex()` est doncexporté et testé par le contrôle de fumée.
|
||||
|
||||
### 7.3 Choix assumés
|
||||
|
||||
- **« + » et les crayons ouvrent le sélecteur natif** : le panneau propose 88
|
||||
couleurs curées, le natif reste le seul moyen d'atteindre une teinte
|
||||
arbitraire.
|
||||
- **Les deux entrées de pied de page sont annoncées, pas fonctionnelles** :
|
||||
« Mise en forme conditionnelle » et « Couleurs en alternance » ne sont pas
|
||||
livrées (cf. §3.2). Elles appellent `onUnavailable` et affichent un message
|
||||
explicite plutôt que d'être des boutons inertes.
|
||||
- **Chrome thémé** : uniquement des variables CSS, donc le panneau suit le
|
||||
thème actif ; seules les pastilles portent une couleur en ligne, ce sont des
|
||||
données.
|
||||
|
||||
## 8. #184 — Peinture de format, multi-lignes/colonnes, grille A → Z × 1000
|
||||
|
||||
### 8.1 Peindre le format
|
||||
|
||||
**Symptôme** : l'outil refusait toute cellule source sans mise en forme
|
||||
explicite (« *Veuillez sélectionner une cellule formatée* »). Peindre une
|
||||
cellule simple sur une cellule colorée et en grand corps ne produisait donc
|
||||
**rien** — c'est le cas le plus courant en pratique.
|
||||
|
||||
**Deux défauts distincts**
|
||||
|
||||
1. **Copie partielle** : seuls gras/italique/souligné/barré, couleurs, taille,
|
||||
alignement et format de nombre étaient capturés. valign, retour à la ligne,
|
||||
rotation et **bordures** ne l'étaient pas — une cellule bordée perdait ses
|
||||
bordures en « peignant » la même mise en forme ailleurs.
|
||||
2. **Source « neutre » rejetée** : sans propriété capturée, l'arme était
|
||||
annulée. Dans Sheets/Excel, une cellule sans mise en forme est une source
|
||||
**valide** : elle *réinitialise* la cible.
|
||||
|
||||
**Correctif** — `captureFormat()` renvoie toujours un jeu **complet** de
|
||||
propriétés (y compris `border: "none"`, `font_color: ""`, `fill_color: ""`,
|
||||
`number_format: "General"`), donc la cible finit toujours identique à la source,
|
||||
et une source neutre vide la cible.
|
||||
|
||||
**Fuite d'écouteur** : `Échap` ou un second clic sur le bouton désarmaient
|
||||
l'arme mais laissaient le gestionnaire « appliquer au prochain clic » en place —
|
||||
le clic suivant dans la grille repeignait une vieille mise en forme. Le
|
||||
gestionnaire est maintenant retiré par `stopPaint()`.
|
||||
|
||||
### 8.2 Sélection de plusieurs lignes / colonnes
|
||||
|
||||
Cliquer un numéro de ligne sélectionnait déjà la ligne entière ; il manquait
|
||||
l'extension à **plusieurs** lignes. Le mécanisme :
|
||||
|
||||
- `mousedown` sur un en-tête sélectionne la ligne / colonne **entière** ;
|
||||
- glisser au-dessus d'autres en-tête étend (`A1:Z5`), `Maj`+clic aussi
|
||||
(`A1:Z5`) — l'ancre est mémorisée après le glisser ;
|
||||
- la sélection réutilise le modèle `{anchor, focus}` d'une plage, donc
|
||||
*copier*, *effacer*, *insérer/supprimer*, le menu contextuel et la
|
||||
suppression de contenu fonctionnent sans code supplémentaire ;
|
||||
- le `click` sur en-tête a été **retiré** : il écrasait la multi-sélection
|
||||
juste après le glisser ;
|
||||
- une pression **près du bord** de l'en-tête reste le geste de
|
||||
**redimensionnement** (partage explicite avec `wireResize`).
|
||||
|
||||
### 8.3 Grille A → Z, et 1000 lignes à la demande
|
||||
|
||||
La grille de la feuille affichée est complétée jusqu'à **26 colonnes** (A → Z)
|
||||
d'emblée ; les **lignes s'ajoutent par blocs de 100** quand le lecteur approche
|
||||
du bas, jusqu'à 1000. Les cellules vides sont de vraies cellules éditables
|
||||
(`contentEditable`, `tabIndex`, écouteurs, `data-cell`). L'extension a lieu à
|
||||
l'activation d'un onglet, pas au montage : la faire pour toutes les feuilles
|
||||
multiplierait les cellules par le nombre d'onglets.
|
||||
|
||||
**Une feuille tronquée garde son chargement serveur** (« Charger la suite »,
|
||||
fenêtre par fenêtre) : ses lignes manquantes sont des données, pas du
|
||||
remplissage. Étendre la grille par-dessus aurait dupliqué les lignes et
|
||||
supprimait le pied de page — régression de **#153-A9bis**, corrigée en gardant
|
||||
les deux mécanismes séparés (les colonnes et les blocs vides pour une feuille
|
||||
complète, la fenêtre serveur pour une feuille tronquée).
|
||||
|
||||
**Pourquoi pas les 1000 lignes d'un coup.** Matérialiser A → Z × 1000 revient à
|
||||
**~26 000 cellules câblées** pour la seule feuille visible. Mesuré : ~0,3 Go
|
||||
retenus par rendu, non récupérables même après `innerHTML = ""` et GC forcé — la
|
||||
suite JSDOM `xlsx-viewer` (163 cas) épuisait le tas vers le 45ᵉ montage. La
|
||||
croissance par blocs offre la même étendue sans payer le coût d'un coup.
|
||||
|
||||
**Trois goulots levés** — toujours utiles sur la grille atteinte :
|
||||
|
||||
| Goulot | Avant | Après |
|
||||
|---|---|---|
|
||||
| `findTd` | un `querySelector` par appel → 26 000 requêtes pour une colonne | **index `ref → td`** avec auto-réparation |
|
||||
| `refreshPanelFormulas` | `cloneNode` sur chaque cellule, à chaque frappe | court-circuit sur cellule vide sans formule |
|
||||
| `applySheetMeta` | toutes les cellules touchées | court-circuit sur celles que le classeur ne style pas |
|
||||
|
||||
**Sorties.** L'export (CSV / Markdown / HTML / impression) écarte les lignes et
|
||||
colonnes vides de queue : sans cela un classeur de deux colonnes exportait les
|
||||
26 colonnes du quadrillage.
|
||||
|
||||
> **Reste à faire.** La virtualisation par fenêtre (ne garder que les lignes
|
||||
> visibles et recycler au défilement) remplacerait la croissance par blocs si la
|
||||
> grille de 1000 lignes redevenait lourde dans le navigateur.
|
||||
@@ -0,0 +1,107 @@
|
||||
# #180 — Éditeur tableur : redimensionnement au curseur, grille thémée, menu Fichier & barre épinglée
|
||||
|
||||
> **Statut :** ✅ livré le 2026-10-08 (v2.54.0) — ouvert le 2026-10-05 | **Effort :** 1-2 jours | **Impact :** 🟡
|
||||
> **Références :** [Roadmap](../ROADMAP.md) · [Changelog](../../CHANGELOG.md) ·
|
||||
> [#154](./xlsx-ui-redesign.md) · [#155](./xlsx-context-menu.md) ·
|
||||
> [#179](./xlsx-menus-179.md) · [#156](./xlsx-editor-completeness.md)
|
||||
|
||||
## 1. Constat
|
||||
|
||||
Retours utilisateur après #179, quatre défauts d'ergonomie et un défaut de thème :
|
||||
|
||||
1. **Le curseur de redimensionnement n'apparaît pas.** Le redimensionnement
|
||||
par les marges existe depuis #156-A8 (`wireResize`), mais l'indicateur est
|
||||
invisible : `.xlsx-table td[contenteditable]` impose `cursor: cell` et les
|
||||
en-têtes reçoivent `cursor: pointer` **en ligne** (`paintHeaders()`). Les
|
||||
deux beat la règle `table.style.cursor` posée par le `mousemove`, y compris
|
||||
pendant le glissement. L'utilisateur n'a aucun retour sur la zone cliquable.
|
||||
2. **La grille ne suit pas le thème.** `--grid-*` n'est déclaré que pour le
|
||||
thème par défaut (dark + light) dans `style.css`. Les 13 autres thèmes et
|
||||
les modes générés *contraste élevé* / *sépia* n'en fournissent pas : la
|
||||
grille y reste en couleurs GitHub. `themes.js` applique pourtant bien les
|
||||
variables via `setProperty`, sans les jetons de grille.
|
||||
3. **Les sorties de fichier sont dispersées.** Télécharger, CSV et Exporter
|
||||
occupent trois commandes du ruban, à gauche du bouton Enregistrer.
|
||||
4. **La barre n'est pas épinglée** et l'éditeur ne prend pas toute la largeur
|
||||
du contenu quand la sidebar est masquée (contrairement aux viewers
|
||||
PDF / images / Excalidraw, cf. la règle `:has(...)` de `.content-area`).
|
||||
5. La pastille « Formules calculées localement » occupe une ligne entière
|
||||
pour annoncer un comportement normal, pas une limite.
|
||||
|
||||
## 2. Livré (A1-A4)
|
||||
|
||||
### A1 — Curseur de redimensionnement sur les marges
|
||||
|
||||
`wireResize()` (viewer.js) remplace le `table.style.cursor = …` isolé par
|
||||
`paintResizeCursor(kind, th)` :
|
||||
|
||||
- la table reçoit `.xlsx-resize-col` / `.xlsx-resize-row`, repint par le CSS
|
||||
sur la table **et** ses cellules (surplus des deux curseurs concurrents) ;
|
||||
- l'en-tête de bord est **miroité en ligne** (`th.style.cursor`), seul moyen
|
||||
de passer devant le `cursor: pointer` inline — l'ancienne valeur est
|
||||
mémorisée puis restaurée quand le pointeur quitte le bord (`mouseleave`) ;
|
||||
- l'état est remis à zéro au `mouseup` global : le curseur ne reste pas
|
||||
« coincé » après un glissement.
|
||||
|
||||
### A2 — Grille compatible tous les thèmes
|
||||
|
||||
- `themes.js` : `applyTheme()` dérive les jetons `--grid-bg`,
|
||||
`--grid-header-bg`, `--grid-header-text`, `--grid-border` et `--grid-zebra`
|
||||
des surfaces du thème actif. Le zébré passe par `color-mix(… 4%, transparent)`
|
||||
pour rester subtil sur fond clair **et** sombre. Un thème importé sans ces
|
||||
jetons est donc couvert automatiquement.
|
||||
- `style.css` : jetons de repli (`var(--grid-bg, var(--surface))`, …) pour que
|
||||
la grille reste lisible même sans `applyTheme()`, plus les deux modes
|
||||
*contraste élevé* et *sépia* déclarés explicitement.
|
||||
- Le fond du menu Export passe de `var(--bg, #fff)` (repli clair cassé en
|
||||
thème sombre) à `var(--surface)`.
|
||||
|
||||
### A3 — Menu Fichier + Enregistrer au niveau des onglets
|
||||
|
||||
- `buildCommandBar()` renvoie un fragment `fileHtml` supplémentaire : menu
|
||||
**Fichier** (`download`, `file-spreadsheet`, `file-text`, `code`,
|
||||
`printer`) + bouton **Enregistrer** primary. Les trois commandes de sortie
|
||||
sortent du ruban, qui ne garde que l'édition.
|
||||
- La coquille (`viewer.js`) rend une barre de feuilles `.xlsx-sheetbar` :
|
||||
onglets à gauche, `.xlsx-sheetbar-end` (menu + Enregistrer) à droite.
|
||||
- Câblage : `trackDismissable()` (même contrat que #179), navigation clavier,
|
||||
fermeture au clic sur Télécharger / CSV, `aria-haspopup` / `aria-expanded`.
|
||||
- **Identifiants stables** : `#xlsx-download-btn`, `#xlsx-csv-btn`,
|
||||
`#xlsx-save-btn` conservés — seuls le conteneur (`#xlsx-file-btn` /
|
||||
`#xlsx-file-menu`) et la classe (`.xlsx-file-menu`) sont nouveaux.
|
||||
|
||||
### A4 — Retrait de la pastille + barre épinglée pleine largeur
|
||||
|
||||
- La pastille « Formules calculées localement » est retirée ; la barre de
|
||||
statut ne subsiste que pour la pastille *lecture seule* (format `.xls` /
|
||||
`.ods`), et disparaît entièrement quand elle n'a rien à afficher.
|
||||
- `.xlsx-toolbar` devient `position: sticky; top: 0` avec des marges négatives
|
||||
qui neutralizing le padding de `.content-area` : la barre se colle au bord
|
||||
haut de la page et l'éditeur occupe la largeur totale du contenu.
|
||||
- `.content-area:has(.xlsx-viewer)` rejoint les viewers plein cadre.
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- `tests/frontend/xlsx-viewer.test.mjs` — 136/136 :
|
||||
- barre de feuilles (onglets / menu / Enregistrer à droite, onglets sortis
|
||||
du ruban), contenu exact du menu Fichier (5 icônes dans l'ordre), toggle ;
|
||||
- curseur `col-resize` / `row-resize` sur les deux types de bord, classes de
|
||||
repaint, restauration du curseur d'en-tête à la sortie ;
|
||||
- jetons `--grid-*` pour 4 thèmes (Spotify dark, Notion light, GitHub
|
||||
contrasté élevé, GitHub sépia) + zébré `color-mix` ;
|
||||
- absence de pastille formules (xlsx **et** csv).
|
||||
- `tests/frontend/xlsx-menus.test.mjs` — 12/12 : icônes de `fileHtml`.
|
||||
- `tests/e2e/xlsx-viewer.spec.js` : le menu Fichier s'ouvre, expose les 5
|
||||
entrées, se ferme au clic extérieur ; Enregistrer présent dans
|
||||
`.xlsx-sheetbar-end`.
|
||||
- 8 tests préexistants réalignés sur la sémantique « écriture verrouillée
|
||||
puis application locale » (le viewer n'est plus re-lu après une écriture
|
||||
structure / mise en forme) : le second appel PUT n'a plus lieu, les
|
||||
assertions portent sur la grille peinte en place.
|
||||
|
||||
## 4. Points d'attention
|
||||
|
||||
- La copie des cellules vers le presse-papiers système et l'export sont
|
||||
inchangés : seul le **point d'entrée** des sorties de fichier a bougé.
|
||||
- `viewer.js` reste le propriétaire du câblage ; `command-bar.js` reste un
|
||||
constructeur de chaînes pur, donc testable sans DOM.
|
||||
@@ -8,6 +8,9 @@
|
||||
|
||||
<link rel="icon" type="image/svg+xml" href="/static/icons/icon-72x72.svg" />
|
||||
|
||||
<!-- Lucide : requis par safeCreateIcons() (icônes des modales, ex. bouton fermer) -->
|
||||
<script defer src="https://unpkg.com/[email protected]/dist/umd/lucide.min.js"></script>
|
||||
|
||||
<!-- Anti-FOUC : applique vite le mode du thème sauvegardé avant le CSS -->
|
||||
<script>
|
||||
(function () {
|
||||
|
||||
+170
-49
@@ -812,46 +812,6 @@
|
||||
role="navigation"
|
||||
aria-label="Navigation des vaults"
|
||||
>
|
||||
<!-- Sidebar filter -->
|
||||
<div class="sidebar-filter">
|
||||
<div class="sidebar-filter-input-wrapper">
|
||||
<i
|
||||
data-lucide="filter"
|
||||
class="sidebar-filter-icon"
|
||||
style="width: 14px; height: 14px"
|
||||
></i>
|
||||
<input
|
||||
type="text"
|
||||
id="sidebar-filter-input"
|
||||
data-i18n-placeholder="sidebar.filter_placeholder" placeholder="Filtrer fichiers..."
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div class="sidebar-filter-actions">
|
||||
<button
|
||||
class="sidebar-filter-btn"
|
||||
id="sidebar-filter-case-btn"
|
||||
type="button"
|
||||
title="Respecter la casse"
|
||||
aria-label="Respecter la casse"
|
||||
>
|
||||
<span>Aa</span>
|
||||
</button>
|
||||
<button
|
||||
class="sidebar-filter-btn"
|
||||
id="sidebar-filter-clear-btn"
|
||||
type="button"
|
||||
title="Effacer le filtrage"
|
||||
aria-label="Effacer le filtrage"
|
||||
>
|
||||
<i
|
||||
data-lucide="x"
|
||||
style="width: 14px; height: 14px"
|
||||
></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab bar -->
|
||||
<div
|
||||
class="sidebar-tabs"
|
||||
@@ -928,6 +888,61 @@
|
||||
style="width: 18px; height: 18px"
|
||||
></i>
|
||||
</button>
|
||||
<button
|
||||
class="sidebar-tab"
|
||||
id="sidebar-tab-chat"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
aria-controls="sidebar-panel-chat"
|
||||
data-tab="chat"
|
||||
title="Chat"
|
||||
>
|
||||
<i
|
||||
data-lucide="message-circle"
|
||||
style="width: 18px; height: 18px"
|
||||
></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar filter (#190 C : sous les onglets ; sert de
|
||||
recherche dans l'onglet Chat) -->
|
||||
<div class="sidebar-filter">
|
||||
<div class="sidebar-filter-input-wrapper">
|
||||
<i
|
||||
data-lucide="filter"
|
||||
class="sidebar-filter-icon"
|
||||
style="width: 14px; height: 14px"
|
||||
></i>
|
||||
<input
|
||||
type="text"
|
||||
id="sidebar-filter-input"
|
||||
data-i18n-placeholder="sidebar.filter_placeholder" placeholder="Filtrer fichiers..."
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div class="sidebar-filter-actions">
|
||||
<button
|
||||
class="sidebar-filter-btn"
|
||||
id="sidebar-filter-case-btn"
|
||||
type="button"
|
||||
title="Respecter la casse"
|
||||
aria-label="Respecter la casse"
|
||||
>
|
||||
<span>Aa</span>
|
||||
</button>
|
||||
<button
|
||||
class="sidebar-filter-btn"
|
||||
id="sidebar-filter-clear-btn"
|
||||
type="button"
|
||||
title="Effacer le filtrage"
|
||||
aria-label="Effacer le filtrage"
|
||||
>
|
||||
<i
|
||||
data-lucide="x"
|
||||
style="width: 14px; height: 14px"
|
||||
></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vaults panel -->
|
||||
@@ -1050,6 +1065,65 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- General chat panel (#190 B) -->
|
||||
<div
|
||||
class="sidebar-tab-panel sidebar-chat-panel"
|
||||
id="sidebar-panel-chat"
|
||||
role="tabpanel"
|
||||
aria-labelledby="sidebar-tab-chat"
|
||||
>
|
||||
<!-- #191 : rangée de canaux (Général + utilisateurs) -->
|
||||
<div
|
||||
id="sidebar-panel-chat-channels"
|
||||
class="chat-channels"
|
||||
role="tablist"
|
||||
aria-label="Canaux de discussion"
|
||||
></div>
|
||||
<div
|
||||
id="sidebar-panel-chat-list"
|
||||
class="file-chat-list sidebar-chat-list"
|
||||
></div>
|
||||
<div id="sidebar-panel-chat-pending" class="sidebar-chat-pending"></div>
|
||||
<form
|
||||
id="sidebar-panel-chat-form"
|
||||
class="file-chat-form sidebar-chat-form"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
id="sidebar-panel-chat-attach"
|
||||
class="file-chat-attach"
|
||||
title="Joindre une image ou une vidéo"
|
||||
aria-label="Joindre une image ou une vidéo"
|
||||
>
|
||||
<i
|
||||
data-lucide="paperclip"
|
||||
style="width: 15px; height: 15px"
|
||||
></i>
|
||||
</button>
|
||||
<textarea
|
||||
id="sidebar-panel-chat-input"
|
||||
class="file-chat-input"
|
||||
rows="1"
|
||||
maxlength="4000"
|
||||
placeholder="Écrire un message…"
|
||||
autocomplete="off"
|
||||
aria-label="Écrire un message"
|
||||
></textarea>
|
||||
<!-- #191 : bouton compact (icône) pour laisser la place à la saisie -->
|
||||
<button
|
||||
type="submit"
|
||||
class="file-chat-send file-chat-send-icon"
|
||||
title="Envoyer"
|
||||
aria-label="Envoyer"
|
||||
>
|
||||
<i
|
||||
data-lucide="send"
|
||||
style="width: 15px; height: 15px"
|
||||
></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- User section (bottom of the sidebar, #112) -->
|
||||
<div class="sidebar-user" id="sidebar-user" hidden>
|
||||
<button
|
||||
@@ -1196,16 +1270,15 @@
|
||||
>Ctrl+Shift+Space</span
|
||||
>
|
||||
<span class="qh-desc"
|
||||
>Palette de commandes</span
|
||||
>Palette de fichiers (nav.
|
||||
rapide)</span
|
||||
>
|
||||
</div>
|
||||
<div class="qh-row">
|
||||
<span class="qh-kbd"
|
||||
>Ctrl+Alt+Space</span
|
||||
>
|
||||
<span class="qh-desc"
|
||||
>Palette de fichiers (nav.
|
||||
rapide)</span
|
||||
<span class="qh-desc">Palette de commandes</span
|
||||
>
|
||||
</div>
|
||||
<div class="qh-row">
|
||||
@@ -1588,6 +1661,7 @@
|
||||
<li><a href="#cfg-recent" class="help-nav-link" data-i18n="config.section_recent"></a></li>
|
||||
<li><a href="#cfg-tags" class="help-nav-link" data-i18n="config.section_tags"></a></li>
|
||||
<li><a href="#cfg-hidden-files" class="help-nav-link" data-i18n="config.section_hidden"></a></li>
|
||||
<li><a href="#cfg-desktop-roots" class="help-nav-link" data-i18n="config.section_desktop"></a></li>
|
||||
<li><a href="#cfg-sync" class="help-nav-link" data-i18n="settings.sync"></a></li>
|
||||
<li><a href="#cfg-backend-settings" class="help-nav-link" data-i18n="config.section_backend"></a></li>
|
||||
<li><a href="#cfg-diags" class="help-nav-link" data-i18n="settings.diagnostics"></a></li>
|
||||
@@ -1951,6 +2025,45 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Desktop vaults & root directories (#159, #160) -->
|
||||
<section
|
||||
id="cfg-desktop-roots"
|
||||
class="config-section help-section"
|
||||
>
|
||||
<h2 data-i18n="config.section_desktop">🖥️ Vaults & dossiers (Desktop)</h2>
|
||||
<p
|
||||
class="config-description"
|
||||
data-i18n="config.desktop_roots_desc"
|
||||
>
|
||||
Ajoutez ou retirez les vaults et dossiers
|
||||
racine chargés par l'application desktop.
|
||||
</p>
|
||||
|
||||
<div
|
||||
id="desktop-roots-list"
|
||||
class="desktop-roots-list"
|
||||
>
|
||||
<!-- Racines injectées par renderDesktopRoots() -->
|
||||
</div>
|
||||
|
||||
<div class="config-actions-row desktop-roots-actions">
|
||||
<button
|
||||
class="config-btn-primary config-btn-sm"
|
||||
id="cfg-desktop-add-vault"
|
||||
data-i18n="desktop.add_vault"
|
||||
>
|
||||
📁 Ajouter un vault
|
||||
</button>
|
||||
<button
|
||||
class="config-btn-secondary config-btn-sm"
|
||||
id="cfg-desktop-add-dir"
|
||||
data-i18n="desktop.add_dir"
|
||||
>
|
||||
📂 Ajouter un dossier
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Watcher / Synchronisation -->
|
||||
<section
|
||||
class="config-section help-section"
|
||||
@@ -3695,6 +3808,10 @@
|
||||
<strong>Facettes vaults</strong><span data-i18n="help.desc_facet_vaults">: Compteurs
|
||||
par vault</span>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Facettes extensions</strong><span data-i18n="help.desc_facet_extensions">:
|
||||
Compteurs par extension de fichier dans les résultats</span>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Tri par pertinence</strong><span data-i18n="help.desc_9fec424d"> : Score
|
||||
TF-IDF (défaut)</span></li>
|
||||
@@ -4631,9 +4748,9 @@
|
||||
conversation.
|
||||
</li>
|
||||
<li data-i18n="help.assistant_agent">
|
||||
Le bouton « mode agent » active les outils (lire, lister,
|
||||
chercher) ; les actions de modification demandent une
|
||||
confirmation avec aperçu des changements.
|
||||
L'assistant utilise toujours ses outils (lire, lister,
|
||||
chercher, modifier) ; les actions de modification demandent
|
||||
une confirmation avec aperçu des changements.
|
||||
</li>
|
||||
<li data-i18n="help.assistant_agent_run">
|
||||
Les actions s'affichent dans le fil : l'assistant regroupe les
|
||||
@@ -5259,8 +5376,12 @@ curl -X POST https://votre-serveur.com/webhook \
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Secret redactor</strong><span data-i18n="help.desc_9846fc07"> : Masque
|
||||
JWT, clés API, tokens GitHub dans les
|
||||
aperçus</span></li>
|
||||
JWT, mots de passe et clés API (OpenAI,
|
||||
Anthropic, GitHub, Google, AWS, Slack,
|
||||
Stripe, GitLab, Hugging Face…) dans les
|
||||
aperçus markdown ; cliquez sur un masque
|
||||
pour copier la valeur dans le
|
||||
presse-papiers</span></li>
|
||||
<li>
|
||||
<strong>Path traversal</strong> : Validation
|
||||
des chemins contre les attaques
|
||||
|
||||
+19
-38
@@ -394,54 +394,35 @@ function _wireAuditFilters() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Section nav (scrollspy) ───────────────────────────────────────────
|
||||
// ── Section nav (une seule section visible à la fois) ──────────────────
|
||||
|
||||
/**
|
||||
* Wire the sticky section nav: smooth-scroll on click + highlight the section
|
||||
* currently in view (IntersectionObserver). Falls back to default anchor jump
|
||||
* if the observer is unavailable.
|
||||
* Wire the sticky section nav: clicking a link shows ONLY that section
|
||||
* (the others are hidden). The first section (Stats) is shown on load.
|
||||
*/
|
||||
function _wireSectionNav() {
|
||||
const links = Array.from(document.querySelectorAll("#admin-subnav a"));
|
||||
if (!links.length) return;
|
||||
const targets = links
|
||||
.map((a) => document.getElementById((a.getAttribute("href") || "#").slice(1)))
|
||||
.filter(Boolean);
|
||||
const sections = Array.from(document.querySelectorAll("#admin-main .admin-section"));
|
||||
|
||||
// Smooth scroll on click
|
||||
links.forEach((a) => {
|
||||
a.addEventListener("click", (e) => {
|
||||
const href = a.getAttribute("href");
|
||||
if (!href || !href.startsWith("#")) return;
|
||||
const t = document.getElementById(href.slice(1));
|
||||
if (!t) return;
|
||||
e.preventDefault();
|
||||
t.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
});
|
||||
});
|
||||
|
||||
// Scrollspy: mark the section currently at the top of the viewport
|
||||
let active = null;
|
||||
const setActive = (id) => {
|
||||
if (active === id) return;
|
||||
active = id;
|
||||
const show = (id) => {
|
||||
sections.forEach((s) => { s.style.display = s.id === id ? "" : "none"; });
|
||||
links.forEach((l) => l.classList.toggle("active", l.getAttribute("href") === "#" + id));
|
||||
};
|
||||
|
||||
if ("IntersectionObserver" in window) {
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((en) => {
|
||||
if (en.isIntersecting) setActive(en.target.id);
|
||||
});
|
||||
},
|
||||
{ rootMargin: "-64px 0px -70% 0px", threshold: 0 }
|
||||
);
|
||||
targets.forEach((t) => io.observe(t));
|
||||
} else {
|
||||
// Fallback: highlight the first section only (no scroll observation)
|
||||
links[0] && links[0].classList.add("active");
|
||||
}
|
||||
links.forEach((a) => {
|
||||
a.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
const href = a.getAttribute("href") || "";
|
||||
if (!href.startsWith("#")) return;
|
||||
show(href.slice(1));
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
});
|
||||
});
|
||||
|
||||
// État initial : seule la section demandée par le hash (ou la première) est visible.
|
||||
const initial = (location.hash && sections.find((s) => "#" + s.id === location.hash)) || sections[0];
|
||||
if (initial) show(initial.id);
|
||||
}
|
||||
|
||||
// ── Auth gate ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -46,8 +46,8 @@ export const ACTION_CATALOG = Object.freeze([
|
||||
{ id: 'checklist', cat: 'structure', icon: 'list-checks', labelKey: 'qa.checklist', promptKey: 'qa.checklist.prompt' },
|
||||
{ id: 'plan', cat: 'structure', icon: 'list-ordered', labelKey: 'qa.plan', promptKey: 'qa.plan.prompt' },
|
||||
{ id: 'memo', cat: 'structure', icon: 'scroll-text', labelKey: 'qa.memo', promptKey: 'qa.memo.prompt' },
|
||||
{ id: 'frontmatter', cat: 'structure', icon: 'braces', labelKey: 'qa.frontmatter', promptKey: 'qa.frontmatter.prompt', agent: true },
|
||||
{ id: 'frontmatter_update', cat: 'structure', icon: 'refresh-cw', labelKey: 'qa.frontmatter_update', promptKey: 'qa.frontmatter_update.prompt', agent: true },
|
||||
{ id: 'frontmatter', cat: 'structure', icon: 'braces', labelKey: 'qa.frontmatter', promptKey: 'qa.frontmatter.prompt' },
|
||||
{ id: 'frontmatter_update', cat: 'structure', icon: 'refresh-cw', labelKey: 'qa.frontmatter_update', promptKey: 'qa.frontmatter_update.prompt' },
|
||||
{ id: 'backlinks', cat: 'structure', icon: 'link-2', labelKey: 'qa.backlinks', promptKey: 'qa.backlinks.prompt' },
|
||||
{ id: 'sections', cat: 'structure', icon: 'heading', labelKey: 'qa.sections', promptKey: 'qa.sections.prompt' },
|
||||
// ── Code & Scripts ───────────────────────────────────────────────────
|
||||
|
||||
@@ -26,6 +26,7 @@ import { initDesktopIntegration, isTauriEnv } from './desktop.js';
|
||||
import { initPush } from './push.js';
|
||||
import { initPlugins } from './plugins.js';
|
||||
import { populateVersions } from './config.js';
|
||||
import { initSidebarChat } from './filechat.js';
|
||||
|
||||
// =========================================================================
|
||||
// Initialization — mirrors the original app.js init() ordering
|
||||
@@ -61,6 +62,7 @@ async function init() {
|
||||
Legacy.initConfigModal();
|
||||
Legacy.initAboutModal();
|
||||
Sidebar.initSidebarFilter();
|
||||
initSidebarChat(); // #190 B — formulaire du chat général (sidebar)
|
||||
UI.initSidebarResize();
|
||||
Utils.initEditor();
|
||||
Auth.initLoginForm();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user