diff --git a/ROADMAP.md b/ROADMAP.md index 0db3cab..6cc88fa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -153,21 +153,23 @@ - **Effort** : 0.5 jour | **Impact** : 🟢 - `indexer.py` et `watcher.py` ont des définitions séparées → mutualiser -### 11. Indicateur AI Actif — FAIT +### ✅ 11. Indicateur AI Actif — FAIT - **Effort** : 0.5 jour | **Impact** : 🟡 -- Dans le bouton/indicateur à droite du titre, en cliquant il y a un panneau avec les statuts de syncro. Ajouter un indicateur visuel dans ce panneau pour indiquer si l'IA est active, configurée, et fonctionnelle. Durant la phase de traitement de l'IA, le bouton/indicateur devrait changer de couleur pour un blanc qui flash indiquant le statut de la tâche AI en cours. ✅ +- Dans le bouton/indicateur à droite du titre, en cliquant il y a un panneau avec les statuts de syncro. Ajouter un indicateur visuel dans ce panneau pour indiquer si l'IA est active, configurée, et fonctionnelle. Durant la phase de traitement de l'IA, le bouton/indicateur devrait changer de couleur pour un blanc qui flash indiquant le statut de la tâche AI en cours. - **Fichiers** : `backend/main.py` (`/api/ai/status`), `frontend/js/ai.js`, `frontend/js/sync.js`, `frontend/style.css` - [x] **Badge header flash blanc** : le `#sync-badge` devient blanc avec animation flash quand une tâche AI est en cours - [x] **Événements** : `ai-processing-start` / `ai-processing-end` dispatchés depuis `ai.js` - [x] **Section IA dans panneau sync** : statut (configurée/non configurée/traitement en cours) avec icône et couleur - [x] **Endpoint `/api/ai/status`** : vérifie si au moins un provider AI est configuré -### 12. Page d'accueil de voute — FAIT +### 12. Page d'accueil de voute — FAIT (récursif) - **Effort** : 1 jour | **Impact** : 🟡 -- Une page d'accueil qui affiche les fichiers de la voute actuelle dans l'ordre du plus récent au plus ancien. Si l'utilisateur change de répertoire dans cette voute, la liste devrait se mettre à jour automatiquement. ✅ +- Une page d'accueil qui affiche les fichiers de la voute actuelle dans l'ordre du plus récent au plus ancien, récursivement (répertoire + tous les sous-répertoires). Si l'utilisateur change de répertoire dans cette voute, la liste se met à jour automatiquement. ✅ - **Fichiers** : `backend/main.py` (`/api/vault/{vault}/files`), `frontend/js/vaulthome.js`, `frontend/js/sidebar.js`, `frontend/style.css` -- [x] **Endpoint `/api/vault/{vault}/files?dir=...`** : liste les fichiers d'un répertoire triés par date de modification décroissante -- [x] **Affichage style résultats de recherche** : chaque fichier utilise le même look `.search-result-item` (extension, icône, titre, métadonnées) +- [x] **Endpoint `/api/vault/{vault}/files?dir=...&recursive=true`** : liste les fichiers d'un répertoire et de tous ses sous-répertoires récursivement (`rglob`), triés par date de modification décroissante +- [x] **Champ `rel_dir`** : chaque fichier indique son sous-répertoire relatif pour afficher le chemin dans les résultats +- [x] **Affichage style résultats de recherche** : chaque fichier utilise le même look `.search-result-item` (extension, icône, titre, métadonnées avec `sous-dossier/ · date · taille`) +- [x] **Badge « récursif »** : indicateur visuel dans le header confirmant le mode récursif - [x] **Breadcrumb navigable** : fil d'Ariane cliquable (vault → dossier → sous-dossier) - [x] **Mise à jour automatique** : quand on clique sur un dossier dans l'arborescence, la page se met à jour - [x] **Déclenchement** : affiché automatiquement quand un vault spécifique est sélectionné diff --git a/backend/main.py b/backend/main.py index 51ce5f1..2606ff6 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1952,31 +1952,49 @@ def _list_backup_files(vault_name: str, relative_path: str) -> list[dict]: prefix = original_name + "." backups = [] - for f in backup_dir.iterdir(): - if not f.is_file(): - continue - name = f.name - if not name.startswith(prefix) or not name.endswith(".bak"): - continue - # Extract timestamp: strip prefix and .bak suffix - ts_part = name[len(prefix):-len(".bak")] + try: + dir_entries = list(backup_dir.iterdir()) + except PermissionError: + logger.warning(f"Permission denied reading backup dir: {backup_dir}") + return [] + except OSError as e: + logger.error(f"Error reading backup dir {backup_dir}: {e}") + return [] + + for f in dir_entries: try: - ts = int(ts_part) - except (ValueError, TypeError): + if not f.is_file(): + continue + name = f.name + if not name.startswith(prefix) or not name.endswith(".bak"): + continue + # Extract timestamp: strip prefix and .bak suffix + ts_part = name[len(prefix):-len(".bak")] + try: + ts = int(ts_part) + except (ValueError, TypeError): + continue + try: + dt = datetime.fromtimestamp(ts, tz=timezone.utc).isoformat() + except (OSError, OverflowError, ValueError) as ts_err: + logger.warning(f"Skipping backup with invalid timestamp {ts}: {ts_err}") + continue + st = f.stat() + backups.append({ + "timestamp": ts, + "datetime": dt, + "size": st.st_size, + "filename": name, + }) + except Exception as entry_err: + logger.warning(f"Skipping unreadable backup entry {f}: {entry_err}") continue - dt = datetime.fromtimestamp(ts, tz=timezone.utc).isoformat() - backups.append({ - "timestamp": ts, - "datetime": dt, - "size": f.stat().st_size, - "filename": name, - }) backups.sort(key=lambda b: b["timestamp"], reverse=True) return backups -@app.get("/api/file/{vault_name}/backups", response_model=BackupListResponse) +@app.get("/api/file/{vault_name}/backups") async def api_file_backups( vault_name: str, path: str = Query(..., description="Relative path to file"), @@ -2004,12 +2022,16 @@ async def api_file_backups( if not file_path.exists() or not file_path.is_file(): raise HTTPException(status_code=404, detail=f"File not found: {path}") - backups = _list_backup_files(vault_name, path) + try: + backups = _list_backup_files(vault_name, path) + except Exception as e: + logger.error(f"Error listing backups for {vault_name}/{path}: {type(e).__name__}: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Erreur lors de la lecture des backups: {str(e)}") return {"vault": vault_name, "path": path, "backups": backups} -@app.get("/api/file/{vault_name}/diff", response_model=DiffResponse) +@app.get("/api/file/{vault_name}/diff") async def api_file_diff( vault_name: str, path: str = Query(..., description="Relative path to file"), @@ -2050,40 +2072,52 @@ async def api_file_diff( backup_path = backup_dir / f"{original_name}.{ts}.bak" if not backup_path.exists(): raise HTTPException(status_code=404, detail=f"Backup version {ts} not found for {path}") - content = backup_path.read_text(encoding="utf-8") + try: + content = backup_path.read_text(encoding="utf-8", errors="replace") + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to read backup {ts}: {e}") dt = datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") return content, f"{path}@{dt}" - # Read left side (backup version) - left_content, left_label = _read_backup(version) + try: + # Read left side (backup version) + left_content, left_label = _read_backup(version) - # Read right side - if compare_with is not None: - right_content, right_label = _read_backup(compare_with) - else: - right_content = file_path.read_text(encoding="utf-8") - right_label = f"{path} (current)" + # Read right side + if compare_with is not None: + right_content, right_label = _read_backup(compare_with) + else: + try: + right_content = file_path.read_text(encoding="utf-8", errors="replace") + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to read current file: {e}") + right_label = f"{path} (current)" - # Generate unified diff using difflib - import difflib - left_lines = left_content.splitlines(keepends=True) - right_lines = right_content.splitlines(keepends=True) + # Generate unified diff using difflib + import difflib + left_lines = left_content.splitlines(keepends=True) + right_lines = right_content.splitlines(keepends=True) - diff_lines = list(difflib.unified_diff( - left_lines, right_lines, - fromfile=left_label, tofile=right_label, - )) + diff_lines = list(difflib.unified_diff( + left_lines, right_lines, + fromfile=left_label, tofile=right_label, + )) - return { - "vault": vault_name, - "path": path, - "version": version, - "compare_with": compare_with, - "diff": "".join(diff_lines), - } + return { + "vault": vault_name, + "path": path, + "version": version, + "compare_with": compare_with, + "diff": "".join(diff_lines), + } + except HTTPException: + raise + except Exception as e: + logger.error(f"Error generating diff for {vault_name}/{path}: {type(e).__name__}: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Erreur lors de la génération du diff: {str(e)}") -@app.post("/api/file/{vault_name}/restore", response_model=RestoreResponse) +@app.post("/api/file/{vault_name}/restore") async def api_file_restore( vault_name: str, path: str = Query(..., description="Relative path to file"), @@ -3075,7 +3109,8 @@ async def api_update_vault_settings(vault_name: str, body: dict = Body(...), cur async def api_vault_recent_files( vault_name: str, dir: str = Query("", description="Directory path within the vault (empty = root)"), - limit: int = Query(100, description="Maximum number of files to return"), + limit: int = Query(200, description="Maximum number of files to return"), + recursive: bool = Query(True, description="If true, list files recursively from directory and all subdirectories"), current_user=Depends(require_auth), ): """List files in a vault directory sorted by modification time (newest first). @@ -3084,13 +3119,18 @@ async def api_vault_recent_files( Unlike /api/browse, this endpoint sorts by mtime and returns additional metadata (size, modified time, extension). + When recursive=True (default), lists files from the directory + AND all its subdirectories, with a ``rel_dir`` field indicating + the subdirectory path relative to the requested directory. + Args: vault_name: Name of the vault. dir: Relative directory path within the vault (empty for root). - limit: Maximum files to return (default 100). + limit: Maximum files to return (default 200). + recursive: If true, recursively list files in subdirectories (default true). Returns: - JSON with vault, directory, and list of file entries. + JSON with vault, directory, count, recursive flag, and list of file entries. """ if not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") @@ -3107,21 +3147,46 @@ async def api_vault_recent_files( files = [] ignored = {'.obsidian', '.trash', '.git', '.obsigate-backup', '__pycache__', 'node_modules'} + dir_prefix = (dir or "").strip("/") try: - for entry in dir_path.iterdir(): - if entry.name.startswith('.') and entry.name not in ('.', '..'): + iterator = dir_path.rglob('*') if recursive else dir_path.iterdir() + for entry in iterator: + if not entry.is_file(): + continue + if entry.name.startswith('.'): continue if entry.name in ignored: continue - if not entry.is_file(): - continue + + # Skip entries whose parent dir chain contains an ignored dir (for rglob) + if recursive and dir_prefix: + skip = False + try: + for part in entry.relative_to(dir_path).parts[:-1]: + if part.startswith('.') or part in ignored: + skip = True + break + except ValueError: + pass + if skip: + continue stat = entry.stat() rel_path = str(entry.relative_to(vault_root)).replace("\\", "/") + + # Compute relative directory from the requested dir + if recursive and dir_prefix: + try: + rel_to_dir = str(entry.parent.relative_to(dir_path)).replace("\\", "/") + except ValueError: + rel_to_dir = "" + else: + rel_to_dir = "" + ext = entry.suffix.lower() if entry.suffix else "" - files.append({ + file_entry = { "name": entry.name, "path": rel_path, "vault": vault_name, @@ -3129,7 +3194,11 @@ async def api_vault_recent_files( "modified": stat.st_mtime, "modified_iso": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), "extension": ext.lstrip(".") if ext else "", - }) + } + if rel_to_dir and rel_to_dir != ".": + file_entry["rel_dir"] = rel_to_dir + + files.append(file_entry) except PermissionError: raise HTTPException(status_code=403, detail="Permission denied reading directory") @@ -3142,6 +3211,7 @@ async def api_vault_recent_files( return { "vault": vault_name, "directory": dir, + "recursive": recursive, "count": len(files), "files": files, } diff --git a/frontend/js/backups.js b/frontend/js/backups.js index 5d423ac..743dcab 100644 --- a/frontend/js/backups.js +++ b/frontend/js/backups.js @@ -59,7 +59,8 @@ export async function openBackupViewer(vault, path) { const data = await api(`/api/file/${encodeURIComponent(vault)}/backups?path=${encodeURIComponent(path)}`); state.backups = data.backups || []; } catch (err) { - setBody(renderError('Failed to load backups')); + const msg = err?.detail || err?.message || 'Erreur inconnue'; + setBody(renderError(`Erreur API: ${msg}`)); return; } @@ -95,6 +96,17 @@ function createOverlay() { panel.id = 'backup-viewer-panel'; overlay.appendChild(panel); + // Event delegation fallback for close button (in case inline onclick fails) + panel.addEventListener('click', (e) => { + // e.target might be a text node (e.g. "×") — use parentElement as fallback + const target = e.target.nodeType === Node.TEXT_NODE ? e.target.parentElement : e.target; + const closeBtn = target && target.closest ? target.closest('.backup-close-btn') : null; + if (closeBtn) { + e.stopPropagation(); + close(); + } + }); + return overlay; } @@ -113,6 +125,9 @@ function close() { } } +// Expose globally for inline onclick handlers (bypasses text-node target issues) +window._backupViewerClose = close; + // --------------------------------------------------------------------------- // Rendering // --------------------------------------------------------------------------- @@ -121,7 +136,7 @@ function renderLoading() { return `
Aucun backup disponible pour ce fichier.
@@ -177,7 +192,7 @@ function renderMainView() {