Compare commits

...
5 Commits
19 changed files with 2750 additions and 2318 deletions
+54 -1
View File
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> **En cours de développement** : les changements à venir sont listés dans la section
> [Unreleased](#unreleased). La dernière version livrée est **2.27.3**.
> [Unreleased](#unreleased). La dernière version livrée est **2.27.8**.
---
@@ -14,6 +14,26 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.27.8] — 2026-09-26
---
## [2.27.7] — 2026-09-26
---
## [2.27.6] — 2026-09-26
---
## [2.27.5] — 2026-09-26
---
## [2.27.4] — 2026-09-26
---
## [2.27.3] — 2026-09-26
---
@@ -22,6 +42,39 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Modifié
- **#85 (T6b) — extraction mutations fichiers/dossiers hors du monolithe `backend/main.py`.**
`PUT .../save|xlsx/save`, `DELETE/POST/PATCH /api/file`, `POST/PATCH/DELETE
/api/directory`, `POST /api/move`, `POST .../batch-upload` sont servis par
le nouveau `backend/routers/files_write.py` (effets de bord inchangés :
audit, index, SSE, webhooks, plugins, historique) ; 15 modèles dans
`schemas.py`.
- **#85 (T6a) — extraction lecture fichiers hors du monolithe `backend/main.py`.**
`/api/browse/{vault}`, `/api/file/{vault}/raw|download|backlinks` et
`GET /api/file/{vault}` (vue rendue tous formats) sont servis par le
nouveau `backend/routers/files_read.py` ; modèles dans `schemas.py`,
`_content_disposition`/`_media_max_inline_bytes` dans
`backend/routers/helpers.py` (partagés avec les tranches suivantes).
Correctif au passage : décorateur orphelin `/s/{token}` resté en T3 et
double-enregistrement de `/api/conflicts` supprimés.
- **#85 (T5) — extraction du domaine `search` hors du monolithe `backend/main.py`.**
Les 11 routes (`/api/search`, `/advanced`, `/replace`, `/tags`,
`/tree-search`, `/vault/{vault}/paths`, `/suggest`, `/tags/suggest`,
`/graph/{vault}`, `/index/reload`, `/index/reload/{vault}`) sont servies
par le nouveau `backend/routers/search.py` ; les modèles search dans
`schemas.py` et le pool de threads dans `backend/search_executor.py`
(même dimensionnement, même cycle de vie) — comportement inchangé.
- **#85 (T4) — extraction du domaine `backups` hors du monolithe `backend/main.py`.**
Les 9 routes (`/api/file/{vault}/backups|diff|restore`, `/api/backups`,
`/delete`, `/purge`, `/content`, `/compress`, `/auto`) sont servies par le
nouveau `backend/routers/backups.py` ; `Diff/Restore*` déménagent dans
`schemas.py` et le singleton SSE dans `backend/sse.py` (partagé avec
`main`) — comportement inchangé, aucun impact utilisateur.
- **#85 (T3) — extraction du domaine `sharing` hors du monolithe `backend/main.py`.**
`POST /api/share/{vault}`, `GET /api/shares`, `DELETE /api/share/{share_id}`
et les pages publiques `/s/{token}`, `/s/{token}/raw`, `/s/{token}/pdf`
sont servis par le nouveau `backend/routers/sharing.py` — chemins,
réponses, tags OpenAPI et authentification inchangés (aucun impact
utilisateur).
- **#85 (T2) — extraction du domaine `webhooks` hors du monolithe `backend/main.py`.**
Le CRUD `GET/POST/PATCH/DELETE /api/webhooks` (admin) est servi par le
nouveau `backend/routers/webhooks.py` — chemins, réponses, tags OpenAPI et
+3 -3
View File
@@ -4,7 +4,7 @@
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
[![Version](https://img.shields.io/badge/Version-2.27.3-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.27.8-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
## 📝 Changelog
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.27.3).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.27.8).
---
*Projet : ObsiGate | Version : 2.27.3 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.27.8 | Dernière mise à jour : Septembre 2026*
+3 -3
View File
@@ -2,7 +2,7 @@
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
[![Version](https://img.shields.io/badge/Version-2.27.3-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.27.8-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
## 📝 Changelog
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.27.3).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.27.8).
---
*Project: ObsiGate | Version: 2.27.3 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.27.8 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.27.3
2.27.8
+51 -2303
View File
File diff suppressed because it is too large Load Diff
+412
View File
@@ -0,0 +1,412 @@
"""Backup endpoints (ROADMAP #85, tranche 4).
Handlers déplacés depuis :mod:`backend.main` sans changement de
comportement : mêmes chemins (``/api/file/{vault}/backups|diff|restore``,
``/api/backups*``), mêmes modèles de réponse, mêmes dépendances
d'authentification. La logique métier vit déjà dans
:mod:`backend.services.backups`.
Adaptations strictement équivalentes :
- ``_resolve_safe_path`` / ``_backup_file`` / ``_list_backup_files`` de
``main`` n'étaient que des wrappers directs : appelés ici via
:mod:`backend.services.paths` et :mod:`backend.services.backups`.
- ``RestoreRequest`` / ``RestoreResponse`` / ``DiffResponse`` ont déménagé
dans :mod:`backend.schemas`.
- Le singleton SSE vit désormais dans :mod:`backend.sse` (partagé avec
``main`` : les clients ``/api/events`` reçoivent les mêmes broadcasts).
"""
import logging
import os
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from backend.auth.middleware import check_vault_access, require_auth
from backend.indexer import get_vault_data, index, update_single_file
from backend.schemas import (
BackupContentResponse,
BackupsAutoResponse,
BackupsCompressResponse,
BackupsDeletedResponse,
BackupsListResponse,
BackupsResponse,
DiffResponse,
RestoreRequest,
RestoreResponse,
)
from backend.services.backups import (
create_backup,
)
from backend.services.backups import (
diff_backup as service_diff_backup,
)
from backend.services.backups import (
list_backup_files as service_list_backup_files,
)
from backend.services.mutations import (
restore_backup as service_restore_backup,
)
from backend.services.paths import resolve_safe_path
from backend.sse import sse_manager
from backend.webhooks import dispatch_webhooks
logger = logging.getLogger("obsigate")
router = APIRouter(tags=["backups"])
@router.get("/api/file/{vault_name}/backups", response_model=BackupsResponse)
async def api_file_backups(
vault_name: str,
path: str = Query(..., description="Relative path to file"),
current_user=Depends(require_auth),
):
"""List all available backups for a file.
Args:
vault_name: Name of the vault.
path: Relative path of the file within the vault.
Returns:
BackupListResponse with backups sorted newest first.
"""
if 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")
vault_root = Path(vault_data["path"])
file_path = resolve_safe_path(vault_root, path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail=f"File not found: {path}")
try:
backups = service_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: {e!s}")
return {"vault": vault_name, "path": path, "backups": backups}
@router.get("/api/file/{vault_name}/diff", response_model=DiffResponse)
async def api_file_diff(
vault_name: str,
path: str = Query(..., description="Relative path to file"),
version: int = Query(..., description="Timestamp of the backup version (left/old side)"),
compare_with: int | None = Query(default=None, description="Timestamp of another backup (right/new side). If omitted, compares with the current file."),
current_user=Depends(require_auth),
):
"""Generate a unified diff between a backup version and another version or the current file.
Args:
vault_name: Name of the vault.
path: Relative path of the file within the vault.
version: Timestamp of the backup to use as the old/left side.
compare_with: Optional timestamp of another backup as the new/right side.
If omitted, the current file on disk is used.
Returns:
DiffResponse containing the unified diff string.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
return service_diff_backup(vault_name, path, version, compare_with)
@router.post("/api/file/{vault_name}/restore", response_model=RestoreResponse)
async def api_file_restore(
vault_name: str,
path: str = Query(..., description="Relative path to file"),
body: RestoreRequest = ..., # type: ignore
current_user=Depends(require_auth),
):
"""Restore a file from a backup version.
The current file is backed up before being overwritten (so the operation is reversible).
Args:
vault_name: Name of the vault.
path: Relative path of the file within the vault.
body: RestoreRequest with the backup version timestamp.
Returns:
RestoreResponse confirming the restore.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
result = service_restore_backup(vault_name, path, body.version)
current_backed_up = result["current_backed_up"]
# Update index
await update_single_file(vault_name, path)
# Broadcast SSE event
await sse_manager.broadcast("file_restored", {
"vault": vault_name,
"path": path,
"restored_from": body.version,
"current_backed_up": current_backed_up,
})
await dispatch_webhooks("file_restored", {"vault": vault_name, "path": path, "restored_from": body.version})
return {
"success": True,
"vault": vault_name,
"path": path,
"restored_from": body.version,
"current_backed_up": current_backed_up,
}
@router.get("/api/backups", response_model=BackupsListResponse)
async def api_backups_list(
vault: str | None = Query(None, description="Filter by vault name"),
current_user=Depends(require_auth),
):
"""List all backups across vaults, grouped by file."""
result: list[dict[str, Any]] = []
try:
for vault_name in index:
if vault and vault_name != vault:
continue
if not check_vault_access(vault_name, current_user):
continue
vd = get_vault_data(vault_name)
if not vd:
continue
vault_root = Path(vd["path"])
backup_root = Path(os.environ.get("OBSIGATE_BACKUP_DIR", ".obsigate-backup"))
if not backup_root.is_absolute():
backup_root = vault_root / backup_root
vault_backup_dir = backup_root / vault_name
if not vault_backup_dir.exists():
continue
for fpath in vault_backup_dir.rglob("*.bak"):
if not fpath.is_file():
continue
st = fpath.stat()
fsize = st.st_size
ts_part = fpath.name.rsplit(".", 2)
if len(ts_part) < 3 or not ts_part[-2].isdigit():
continue
ts = int(ts_part[-2])
rel_dir = str(fpath.parent.relative_to(vault_backup_dir)).replace("\\", "/")
rel_file = rel_dir + "/" + ts_part[0] if rel_dir != "." else ts_part[0]
result.append({
"vault": vault_name,
"file": rel_file,
"backup_file": fpath.name,
"timestamp": ts,
"datetime": datetime.fromtimestamp(ts, tz=timezone.utc).isoformat(),
"size": fsize,
"full_path": str(fpath),
})
result.sort(key=lambda x: x["timestamp"], reverse=True)
total_size = sum(r["size"] for r in result)
return {"backups": result, "total": len(result), "total_size_bytes": total_size}
except Exception as e:
logger.error(f"Error listing backups: {type(e).__name__}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Erreur listing backups: {e!s}")
@router.post("/api/backups/delete", response_model=BackupsDeletedResponse)
async def api_backups_delete(
body: dict = Body(...),
current_user=Depends(require_auth),
):
"""Delete one or more backup files."""
paths = body.get("paths", [])
if not paths:
raise HTTPException(status_code=400, detail="No backup paths provided")
deleted = 0
for p in paths:
try:
fpath = Path(p)
# Security: ensure path is within a backup directory
if ".obsigate-backup" not in str(fpath):
continue
if fpath.exists() and fpath.is_file():
fpath.unlink()
deleted += 1
except Exception as e:
logger.warning(f"Failed to delete backup {p}: {e}")
return {"deleted": deleted}
@router.post("/api/backups/purge", response_model=BackupsDeletedResponse)
async def api_backups_purge(
body: dict = Body(...),
current_user=Depends(require_auth),
):
"""Purge all backups for a specific file or entire vault."""
vault_name = body.get("vault")
file_path = body.get("file") # optional
if not vault_name:
raise HTTPException(status_code=400, detail="Vault name required")
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail="Access denied")
vd = get_vault_data(vault_name)
if not vd:
raise HTTPException(status_code=404, detail="Vault not found")
vault_root = Path(vd["path"])
backup_root = Path(os.environ.get("OBSIGATE_BACKUP_DIR", ".obsigate-backup"))
if not backup_root.is_absolute():
backup_root = vault_root / backup_root
if file_path:
# Delete backups for specific file
backup_dir = backup_root / vault_name / Path(file_path).parent
if backup_dir.exists():
fname = Path(file_path).name
deleted = 0
for f in backup_dir.iterdir():
if f.is_file() and f.name.startswith(fname + ".") and f.name.endswith(".bak"):
f.unlink()
deleted += 1
return {"deleted": deleted}
return {"deleted": 0}
else:
# Delete all backups for vault
vault_backup_dir = backup_root / vault_name
if vault_backup_dir.exists():
deleted = 0
for f in vault_backup_dir.rglob("*.bak"):
if f.is_file():
f.unlink()
deleted += 1
return {"deleted": deleted}
return {"deleted": 0}
@router.get("/api/backups/content", response_model=BackupContentResponse)
async def api_backups_content(
path: str = Query(..., description="Full path to backup file"),
current_user=Depends(require_auth),
):
"""Return the content of a specific backup file."""
try:
fpath = Path(path)
if ".obsigate-backup" not in str(fpath):
raise HTTPException(status_code=403, detail="Access denied")
if not fpath.exists() or not fpath.is_file():
raise HTTPException(status_code=404, detail="Backup not found")
content = fpath.read_text(encoding="utf-8", errors="replace")
# Truncate large files to 100KB
if len(content) > 102400:
content = content[:102400] + "\n\n... (tronque a 100 Ko)"
return {"content": content, "name": fpath.name, "size": len(content)}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/backups/compress", response_model=BackupsCompressResponse)
async def api_backups_compress(
body: dict = Body(...),
current_user=Depends(require_auth),
):
"""Compress backups older than N days. Body: {older_than_days: 30, dry_run: false}"""
import gzip as gz_mod
older_than = body.get("older_than_days", 30)
dry_run = body.get("dry_run", False)
cutoff = time.time() - (older_than * 86400)
compressed = 0
saved_bytes = 0
for vault_name in index:
if not check_vault_access(vault_name, current_user):
continue
vd = get_vault_data(vault_name)
if not vd:
continue
vault_root = Path(vd["path"])
backup_root = Path(os.environ.get("OBSIGATE_BACKUP_DIR", ".obsigate-backup"))
if not backup_root.is_absolute():
backup_root = vault_root / backup_root
vault_dir = backup_root / vault_name
if not vault_dir.exists():
continue
for fpath in vault_dir.rglob("*.bak"):
if not fpath.is_file():
continue
if fpath.name.endswith(".bak.gz"):
continue
mtime = fpath.stat().st_mtime
if mtime > cutoff:
continue
if not dry_run:
try:
gz_path = fpath.with_suffix(fpath.suffix + ".gz")
data = fpath.read_bytes()
with gz_mod.open(str(gz_path), "wb", compresslevel=6) as gzf:
gzf.write(data)
orig_size = len(data)
gz_size = gz_path.stat().st_size
if gz_size < orig_size:
fpath.unlink()
saved_bytes += (orig_size - gz_size)
else:
gz_path.unlink() # compression didn't help
compressed += 1
except Exception as e:
logger.warning(f"Failed to compress {fpath}: {e}")
else:
compressed += 1
return {"compressed": compressed, "saved_bytes": saved_bytes, "dry_run": dry_run}
@router.post("/api/backups/auto", response_model=BackupsAutoResponse)
async def api_backups_auto(
body: dict = Body(...),
current_user=Depends(require_auth),
):
"""Create backups for files modified since a given time. Body: {since_hours: 24}"""
since_hours = body.get("since_hours", 24)
cutoff = time.time() - (since_hours * 3600)
backed_up = 0
for vault_name in index:
if not check_vault_access(vault_name, current_user):
continue
vd = get_vault_data(vault_name)
if not vd:
continue
vault_root = Path(vd["path"])
for fpath in vault_root.rglob("*"):
if not fpath.is_file():
continue
if fpath.name.startswith('.'):
continue
if any(p.startswith('.') or p in {'.obsidian', '.trash', '.git', '.obsigate-backup', '__pycache__', 'node_modules'} for p in fpath.relative_to(vault_root).parts):
continue
mtime = fpath.stat().st_mtime
if mtime < cutoff:
continue
try:
rel = str(fpath.relative_to(vault_root)).replace("\\", "/")
create_backup(fpath, vault_name, rel)
backed_up += 1
except Exception as e:
logger.warning(f"Auto-backup failed for {rel}: {e}")
return {"backed_up": backed_up, "since_hours": since_hours}
+525
View File
@@ -0,0 +1,525 @@
"""File browsing & reading endpoints (ROADMAP #85, tranche 6a).
Handlers déplacés depuis :mod:`backend.main` sans changement de
comportement : mêmes chemins (``/api/browse/*``, ``/api/file/*`` en
lecture), mêmes modèles de réponse (déménagés dans
:mod:`backend.schemas`), mêmes dépendances d'authentification.
Adaptations strictement équivalentes :
- ``_resolve_safe_path`` → :mod:`backend.services.paths` (pass-through).
- ``_render_markdown`` reste dans ``main`` (import différé, extraction
prévue dans une tranche ultérieure).
- ``_content_disposition`` / ``_media_max_inline_bytes`` / ``EXT_TO_LANG``
ont déménagé : helpers partagés dans :mod:`backend.routers.helpers`
(``EXT_TO_LANG`` n'était utilisé que par la vue fichier).
"""
import html as html_mod
import logging
from pathlib import Path
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse
from backend.auth.middleware import check_vault_access, require_auth
from backend.history import record_open
from backend.indexer import (
_extract_tags,
get_backlinks,
get_vault_data,
parse_markdown_file,
)
from backend.media_types import is_audio, is_image, is_video, media_mime_type
from backend.routers.helpers import media_max_inline_bytes
from backend.schemas import (
BacklinksResponse,
BrowseResponse,
FileContentResponse,
FileRawResponse,
)
from backend.services.files import read_raw_file
from backend.services.paths import resolve_safe_path
from backend.services.vaults import browse_directory
logger = logging.getLogger("obsigate")
# Map file extensions to highlight.js language hints
EXT_TO_LANG = {
".py": "python", ".js": "javascript", ".ts": "typescript",
".jsx": "jsx", ".tsx": "tsx", ".sh": "bash", ".bash": "bash",
".zsh": "bash", ".fish": "fish", ".bat": "batch", ".cmd": "batch",
".ps1": "powershell", ".json": "json", ".yaml": "yaml", ".yml": "yaml",
".toml": "toml", ".xml": "xml", ".csv": "plaintext",
".cfg": "ini", ".ini": "ini", ".conf": "ini", ".env": "bash",
".html": "html", ".css": "css", ".scss": "scss", ".less": "less",
".java": "java", ".c": "c", ".cpp": "cpp", ".h": "c", ".hpp": "cpp",
".cs": "csharp", ".go": "go", ".rs": "rust", ".rb": "ruby",
".php": "php", ".sql": "sql", ".r": "r", ".swift": "swift",
".kt": "kotlin", ".txt": "plaintext", ".log": "plaintext",
".lua": "lua", ".pl": "perl", ".pm": "perl", ".ex": "elixir", ".exs": "elixir",
".dart": "dart", ".tf": "haskell", ".gradle": "groovy", ".groovy": "groovy",
".graphql": "graphql", ".gql": "graphql", ".prisma": "sql", ".proto": "c",
".vb": "basic", ".asm": "x86asm", ".s": "armasm",
".vue": "xml", ".svelte": "xml", ".astro": "xml",
".properties": "ini", ".service": "ini", ".hosts": "ini",
".ksh": "bash", ".dockerfile": "dockerfile",
".makefile": "makefile", ".cmake": "cmake",
}
router = APIRouter(tags=["files"])
@router.get("/api/browse/{vault_name}", response_model=BrowseResponse)
async def api_browse(vault_name: str, path: str = "", current_user=Depends(require_auth)):
"""Browse directories and files in a vault at a given path level.
Returns sorted entries (directories first, then files) with metadata.
Hidden files/directories (starting with ``"."`` ) are excluded.
Args:
vault_name: Name of the vault to browse.
path: Relative directory path within the vault (empty = root).
Returns:
``BrowseResponse`` with vault name, path, and item list.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
return browse_directory(vault_name, path)
@router.get("/api/file/{vault_name}/raw", response_model=FileRawResponse)
async def api_file_raw(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
"""Return raw file content as plain text.
Args:
vault_name: Name of the vault.
path: Relative file path within the vault.
Returns:
``FileRawResponse`` with vault, path, and raw text content.
"""
if 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)
@router.get("/api/file/{vault_name}/download", response_class=FileResponse)
async def api_file_download(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
"""Download a file as an attachment.
Args:
vault_name: Name of the vault.
path: Relative file path within the vault.
Returns:
``FileResponse`` with ``application/octet-stream`` content-type.
"""
if 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")
vault_root = Path(vault_data["path"])
file_path = resolve_safe_path(vault_root, path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail=f"File not found: {path}")
# Record history
record_open(current_user.get("username"), vault_name, path)
return FileResponse(
path=str(file_path),
filename=file_path.name,
media_type="application/octet-stream",
)
@router.get("/api/file/{vault_name}/backlinks", response_model=BacklinksResponse)
async def api_file_backlinks(
vault_name: str,
path: str = Query(..., description="Relative path to file"),
current_user=Depends(require_auth),
):
"""Get backlinks (files linking to this file via wikilinks).
Returns a list of files that contain `[[wikilinks]]` pointing
to the requested file, across all accessible vaults.
Args:
vault_name: Name of the vault containing the target file.
path: Relative path of the target file within the vault.
Returns:
``{"vault": str, "path": str, "backlinks": [...]}``
"""
if 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")
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]
return {
"vault": vault_name,
"path": path,
"backlinks": backlinks,
"total": len(backlinks),
}
@router.get("/api/file/{vault_name}", response_model=FileContentResponse)
async def api_file(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
"""Return rendered HTML and metadata for a file.
Markdown files are parsed for frontmatter, rendered with wikilink
support, and returned with extracted tags. Other supported file
types are syntax-highlighted as code blocks.
Args:
vault_name: Name of the vault.
path: Relative file path within the vault.
Returns:
``FileContentResponse`` with HTML, metadata, and tags.
"""
from backend.main import _render_markdown # différé : évite l'import circulaire (#85)
if 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")
vault_root = Path(vault_data["path"])
file_path = resolve_safe_path(vault_root, path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail=f"File not found: {path}")
# Record history
record_open(current_user.get("username"), vault_name, path, title=file_path.name)
ext = file_path.suffix.lower()
# === PDF: special handling before read_text (binary file) ===
if ext == ".pdf":
try:
from backend.pdf_reader import extract_pdf_metadata, extract_pdf_text, extract_pdf_toc
pdf_text = extract_pdf_text(file_path, max_chars=100000)
pdf_meta = extract_pdf_metadata(file_path)
pdf_toc = extract_pdf_toc(file_path)
size = file_path.stat().st_size
return {
"vault": vault_name,
"path": path,
"title": pdf_meta.get("title") or file_path.name,
"tags": [],
"frontmatter": {},
"html": f"<div class='pdf-viewer'><p>PDF — {pdf_meta.get('pages', '?')} pages</p><pre>{pdf_text[:5000]}</pre></div>",
"raw_length": size,
"extension": ext,
"is_markdown": False,
"is_pdf": True,
"unsupported": False,
"pdf_metadata": pdf_meta,
"pdf_toc": pdf_toc,
"size_bytes": size,
}
except Exception as e:
logger.error(f"PDF read error for {path}: {e}")
raise HTTPException(status_code=500, detail=f"Error reading PDF: {e!s}")
# === Excel .xlsx: render sheets as HTML tables (binary, before read_text) ===
if ext == ".xlsx":
try:
from backend.xlsx_reader import render_sheets
sheets = render_sheets(file_path)
size = file_path.stat().st_size
return {
"vault": vault_name,
"path": path,
"title": file_path.name,
"tags": [],
"frontmatter": {},
"html": sheets[0]["html"] if sheets else "",
"raw_length": size,
"extension": ext,
"is_markdown": False,
"is_xlsx": True,
"xlsx_sheets": sheets,
"unsupported": False,
"size_bytes": size,
}
except Exception as e:
logger.error(f"XLSX read error for {path}: {e}")
raise HTTPException(status_code=500, detail=f"Error reading XLSX: {e!s}")
# === Images: return as viewable image ===
if is_image(ext):
size = file_path.stat().st_size
mime = media_mime_type(str(file_path))
# #108-B1 — the raw endpoint returns JSON (FileRawResponse), so the
# standalone <img> must point to /api/image, which serves the bytes
# with the right MIME type. Paths are URL-encoded (accents, spaces).
img_url = f"/api/image/{quote(vault_name, safe='')}?path={quote(path, safe='')}"
html = (
f'<div class="image-viewer">'
f'<img src="{img_url}" '
f'alt="{html_mod.escape(file_path.name, quote=True)}" '
f'style="max-width:100%;max-height:80vh;object-fit:contain" />'
f'</div>'
)
return {
"vault": vault_name,
"path": path,
"title": file_path.name,
"tags": [],
"frontmatter": {},
"html": html,
"raw_length": size,
"extension": ext,
"is_markdown": False,
"is_image": True,
"image_mime": mime,
"size_bytes": size,
}
# === Audio / Video: HTML5 players streamed from /api/media (roadmap #109) ===
if is_audio(ext) or is_video(ext):
size = file_path.stat().st_size
mime = media_mime_type(str(file_path))
media_kind = "audio" if is_audio(ext) else "video"
# #109-A3 — beyond the inline limit the viewer falls back to download
# (a single uvicorn worker must not be pinned by multi-GB media).
if size > media_max_inline_bytes():
return {
"vault": vault_name,
"path": path,
"title": file_path.name,
"tags": [],
"frontmatter": {},
"html": "",
"raw_length": size,
"extension": ext,
"is_markdown": False,
"unsupported": True,
"media_too_large": True,
"size_bytes": size,
}
# #109-A2 — byte-range endpoint: enables scrub and is required by Safari.
stream_url = f"/api/media/{quote(vault_name, safe='')}?path={quote(path, safe='')}"
if media_kind == "audio":
html = (
f'<div class="audio-viewer">'
f'<audio controls preload="metadata" src="{stream_url}"></audio>'
f'</div>'
)
else:
html = (
f'<div class="video-viewer">'
f'<video controls playsinline preload="metadata" src="{stream_url}"></video>'
f'</div>'
)
return {
"vault": vault_name,
"path": path,
"title": file_path.name,
"tags": [],
"frontmatter": {},
"html": html,
"raw_length": size,
"extension": ext,
"is_markdown": False,
"is_audio": media_kind == "audio",
"is_video": media_kind == "video",
"media_mime": mime,
"stream_url": stream_url,
"size_bytes": size,
}
try:
raw = file_path.read_text(encoding="utf-8", errors="replace")
except PermissionError as e:
logger.error(f"Permission denied reading file {path}: {e}")
raise HTTPException(status_code=403, detail=f"Permission denied: cannot read file {path}")
except UnicodeDecodeError:
# Binary / unsupported file — return structured info with download option
size = file_path.stat().st_size
return {
"vault": vault_name,
"path": path,
"title": file_path.name,
"tags": [],
"frontmatter": {},
"html": "",
"raw_length": size,
"extension": ext,
"is_markdown": False,
"unsupported": True,
"size_bytes": size,
}
except Exception as e:
logger.error(f"Unexpected error reading file {path}: {e}")
raise HTTPException(status_code=500, detail=f"Error reading file: {e!s}")
# === CSV: render as HTML table ===
if ext == ".csv":
import csv
import io as csv_io
reader = csv.reader(csv_io.StringIO(raw))
rows = list(reader)
if not rows:
html = "<p><em>Fichier CSV vide</em></p>"
else:
headers = rows[0]
data_rows = rows[1:]
html = '<div class="csv-table-wrapper"><table class="csv-table"><thead><tr>'
for h in headers:
html += f"<th>{h}</th>"
html += "</tr></thead><tbody>"
for row in data_rows:
html += "<tr>"
for cell in row:
html += f"<td>{cell}</td>"
html += "</tr>"
html += "</tbody></table></div>"
return {
"vault": vault_name, "path": path,
"title": file_path.name, "tags": [], "frontmatter": {},
"html": html, "raw_length": len(raw), "extension": ext,
"is_markdown": False, "is_csv": True,
}
# === JSON: syntax-highlighted display ===
if ext == ".json":
import json as json_mod
try:
parsed = json_mod.loads(raw)
formatted = json_mod.dumps(parsed, indent=2, ensure_ascii=False)
except json_mod.JSONDecodeError:
formatted = raw
html = f"<pre class='json-viewer'><code>{html_mod.escape(formatted)}</code></pre>"
return {
"vault": vault_name, "path": path,
"title": file_path.name, "tags": [], "frontmatter": {},
"html": html, "raw_length": len(raw), "extension": ext,
"is_markdown": False, "is_json": True,
}
# === Excalidraw .excalidraw.md (Obsidian plugin format) ===
if path.lower().endswith(".excalidraw.md"):
import re as re_mod
raw_lower = file_path.read_text(encoding="utf-8", errors="replace")
# Check for excalidraw-plugin in frontmatter or body
if "excalidraw-plugin:" in raw_lower:
# Extract compressed JSON block
match = re_mod.search(r'```compressed-json\n(.*?)\n```', raw_lower, re_mod.DOTALL)
if match:
compressed = match.group(1).strip()
return {
"vault": vault_name, "path": path,
"title": file_path.name.replace(".excalidraw.md", ""),
"tags": [], "frontmatter": {},
"html": "", "raw_length": len(raw_lower),
"extension": ".excalidraw.md",
"is_markdown": False,
"is_excalidraw": True,
"excalidraw_data_compressed": compressed,
}
# Fallback: treat as regular markdown
raw = raw_lower
if ext == ".excalidraw":
import json as json_mod
try:
parsed = json_mod.loads(raw)
except json_mod.JSONDecodeError:
parsed = None
if parsed and parsed.get("type") == "excalidraw":
return {
"vault": vault_name,
"path": path,
"title": parsed.get("appState", {}).get("name") or file_path.name,
"tags": [],
"frontmatter": {},
"html": "",
"raw_length": len(raw),
"extension": ext,
"is_markdown": False,
"is_excalidraw": True,
"excalidraw_data": {
"elements": parsed.get("elements", []),
"appState": parsed.get("appState", {}),
"files": parsed.get("files", {}),
},
}
else:
# Not a valid Excalidraw file — fall through to text viewer
pass
# === Plain text / other readable files ===
TEXT_EXTENSIONS = {".txt", ".log", ".yml", ".yaml", ".toml", ".ini", ".cfg",
".sh", ".bash", ".py", ".js", ".ts", ".html", ".css",
".xml", ".rst", ".tex", ".sql", ".conf", ".env"}
if ext in TEXT_EXTENSIONS or ext == ".md":
pass # handled below or by markdown section
if ext == ".md":
post = parse_markdown_file(raw)
# Extract metadata using shared indexer logic
tags = _extract_tags(post)
title = post.metadata.get("title", file_path.stem.replace("-", " ").replace("_", " "))
html_content = _render_markdown(post.content, vault_name, file_path)
return {
"vault": vault_name,
"path": path,
"title": str(title),
"tags": tags,
"frontmatter": dict(post.metadata) if post.metadata else {},
"html": html_content,
"raw_length": len(raw),
"extension": ext,
"is_markdown": True,
}
else:
# Non-markdown: wrap in syntax-highlighted code block
lang = EXT_TO_LANG.get(ext, "")
if not lang:
# Fichiers sans extension usuels (Dockerfile, Makefile, etc.)
NAME_TO_LANG = {
"dockerfile": "dockerfile", "makefile": "makefile",
"cmakelists.txt": "cmake", "jenkinsfile": "groovy",
"vagrantfile": "ruby", "rakefile": "ruby", "gemfile": "ruby",
"procfile": "plaintext", "bashrc": "bash", "bash_profile": "bash",
"zshrc": "bash", "profile": "bash", "gitignore": "plaintext",
}
lang = NAME_TO_LANG.get(file_path.name.lower(), "plaintext")
escaped = html_mod.escape(raw)
html_content = f'<pre><code class="language-{lang}">{escaped}</code></pre>'
return {
"vault": vault_name,
"path": path,
"title": file_path.name,
"tags": [],
"frontmatter": {},
"html": html_content,
"raw_length": len(raw),
"extension": ext,
"is_markdown": False,
}
+506
View File
@@ -0,0 +1,506 @@
"""File & directory mutation endpoints (ROADMAP #85, tranche 6b).
Handlers déplacés depuis :mod:`backend.main` sans changement de
comportement : mêmes chemins (``PUT/DELETE/PATCH/POST /api/file/*``,
``/api/directory/*``, ``/api/move/*``, ``/api/vault/*/batch-upload``),
mêmes modèles de requête/réponse (déménagés dans :mod:`backend.schemas`),
mêmes dépendances d'authentification et mêmes effets de bord (audit, index
incrémental, SSE, webhooks, plugins, historique).
La logique métier vit déjà dans :mod:`backend.services.mutations`.
"""
import logging
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from backend.audit import log_file_delete, log_file_save
from backend.auth.middleware import check_vault_access, require_auth
from backend.history import (
remove_recent,
update_bookmarks_after_rename,
update_history_after_rename,
)
from backend.indexer import handle_file_move, remove_single_file, update_single_file
from backend.schemas import (
BatchUploadRequest,
BatchUploadResponse,
DirectoryCreateRequest,
DirectoryCreateResponse,
DirectoryDeleteResponse,
DirectoryRenameRequest,
DirectoryRenameResponse,
FileCreateRequest,
FileCreateResponse,
FileDeleteResponse,
FileMoveRequest,
FileMoveResponse,
FileRenameRequest,
FileRenameResponse,
FileSaveResponse,
)
from backend.services.mutations import (
batch_upload_files as service_batch_upload_files,
)
from backend.services.mutations import (
create_directory as service_create_directory,
)
from backend.services.mutations import (
create_file as service_create_file,
)
from backend.services.mutations import (
delete_directory as service_delete_directory,
)
from backend.services.mutations import (
delete_file as service_delete_file,
)
from backend.services.mutations import (
edit_file as service_edit_file,
)
from backend.services.mutations import (
edit_xlsx_cells as service_edit_xlsx_cells,
)
from backend.services.mutations import (
move_path as service_move_path,
)
from backend.services.mutations import (
rename_directory as service_rename_directory,
)
from backend.services.mutations import (
rename_file as service_rename_file,
)
from backend.share import update_shares_after_rename
from backend.sse import sse_manager
from backend.webhooks import dispatch_webhooks
logger = logging.getLogger("obsigate")
router = APIRouter(tags=["files"])
@router.put("/api/file/{vault_name}/save", response_model=FileSaveResponse)
async def api_file_save(
vault_name: str,
path: str = Query(..., description="Relative path to file"),
body: dict = Body(...),
backup: bool = Query(True, description="Create a backup before saving (default true, set false for auto-save)"),
current_user=Depends(require_auth),
):
"""Save (overwrite) a file's content.
Expects a JSON body with a ``content`` key containing the new text.
The path is validated against traversal attacks before writing.
Args:
vault_name: Name of the vault.
path: Relative file path within the vault.
body: JSON body with ``content`` string.
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}'")
content = body.get("content", "")
result = service_edit_file(vault_name, path, content, backup=backup)
# Audit log
client_ip = current_user.get("_request_ip", "unknown")
log_file_save(current_user["username"], vault_name, path, len(content), client_ip)
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]}
@router.put("/api/file/{vault_name}/xlsx/save", response_model=FileSaveResponse)
async def api_file_xlsx_save(
vault_name: str,
path: str = Query(..., description="Relative path to the .xlsx file"),
body: dict = Body(..., description='{"sheet": str, "cells": {"A1": value}}'),
current_user=Depends(require_auth),
):
"""Apply cell edits to an .xlsx workbook.
Expects a JSON body with ``sheet`` and ``cells`` (A1 references to new
scalar values, max 500 per request). A backup is created before the
workbook is rewritten.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
sheet = body.get("sheet")
cells = body.get("cells")
if not isinstance(sheet, str) or not sheet:
raise HTTPException(status_code=400, detail="Feuille manquante")
if not isinstance(cells, dict) or not cells or len(cells) > 500:
raise HTTPException(status_code=400, detail="Cellules invalides (1 à 500 par requête)")
for ref, value in cells.items():
if not isinstance(ref, str) or not isinstance(value, (str, int, float, bool, type(None))):
raise HTTPException(status_code=400, detail=f"Cellule invalide: {ref!r}")
result = service_edit_xlsx_cells(vault_name, path, sheet, cells)
log_file_save(
current_user["username"], vault_name, path,
sum(len(str(v)) for v in cells.values()),
current_user.get("_request_ip", "unknown"),
)
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]}
@router.delete("/api/file/{vault_name}", response_model=FileDeleteResponse)
async def api_file_delete(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
"""Delete a file from the vault.
The path is validated against traversal attacks before deletion.
Args:
vault_name: Name of the vault.
path: Relative file path within the vault.
Returns:
``FileDeleteResponse`` confirming the deletion.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
result = service_delete_file(vault_name, path)
# Audit log
client_ip = current_user.get("_request_ip", "unknown")
log_file_delete(current_user["username"], vault_name, path, client_ip)
# Update index
await remove_single_file(vault_name, path)
# Broadcast SSE event
await sse_manager.broadcast("file_deleted", {
"vault": vault_name,
"path": path,
})
from backend.plugins import emit_file_deleted
emit_file_deleted(vault_name, path)
# Remove from recent files
remove_recent(current_user["username"], vault_name, path)
# Dispatch webhooks
await dispatch_webhooks("file_deleted", {"vault": vault_name, "path": path})
return {"status": "ok", "vault": result["vault"], "path": result["path"]}
@router.post("/api/directory/{vault_name}", response_model=DirectoryCreateResponse)
async def api_directory_create(
vault_name: str,
body: DirectoryCreateRequest,
current_user=Depends(require_auth),
):
"""Create a new directory in a vault.
Args:
vault_name: Name of the vault.
body: Request body with directory path.
Returns:
DirectoryCreateResponse confirming creation.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
result = service_create_directory(vault_name, body.path)
# Update path_index with the new directory
from backend.indexer import _index_lock
from backend.indexer import path_index as _path_idx
with _index_lock:
if vault_name not in _path_idx:
_path_idx[vault_name] = []
existing = {p["path"] for p in _path_idx[vault_name]}
# Build all parent segments
parts = body.path.split("/")
for i in range(1, len(parts) + 1):
seg_path = "/".join(parts[:i])
if seg_path and seg_path not in existing:
existing.add(seg_path)
_path_idx[vault_name].append({
"path": seg_path,
"name": parts[i - 1],
"type": "directory",
})
# Broadcast SSE event
await sse_manager.broadcast("directory_created", {
"vault": vault_name,
"path": result["path"],
})
await dispatch_webhooks("directory_created", {"vault": vault_name, "path": result["path"]})
return {"success": True, "path": result["path"]}
@router.patch("/api/directory/{vault_name}", response_model=DirectoryRenameResponse)
async def api_directory_rename(
vault_name: str,
body: DirectoryRenameRequest,
current_user=Depends(require_auth),
):
"""Rename a directory in a vault.
Args:
vault_name: Name of the vault.
body: Request body with current path and new name.
Returns:
DirectoryRenameResponse with old and new paths.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
result = service_rename_directory(vault_name, body.path, body.new_name)
old_path_str = result["old_path"]
new_path_str = result["new_path"]
# Update index for all files in the directory
from backend.indexer import reload_single_vault
await reload_single_vault(vault_name)
# Broadcast SSE event
await sse_manager.broadcast("directory_renamed", {
"vault": vault_name,
"old_path": old_path_str,
"new_path": new_path_str,
})
await dispatch_webhooks("directory_renamed", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str})
return {"success": True, "old_path": old_path_str, "new_path": new_path_str}
@router.delete("/api/directory/{vault_name}", response_model=DirectoryDeleteResponse)
async def api_directory_delete(
vault_name: str,
path: str = Query(..., description="Relative path to directory"),
current_user=Depends(require_auth),
):
"""Delete a directory and all its contents from a vault.
Args:
vault_name: Name of the vault.
path: Relative directory path within the vault.
Returns:
DirectoryDeleteResponse with count of deleted files.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
result = service_delete_directory(vault_name, path, recursive=True)
file_count = result["deleted_count"]
# Update index
from backend.indexer import reload_single_vault
await reload_single_vault(vault_name)
# Broadcast SSE event
await sse_manager.broadcast("directory_deleted", {
"vault": vault_name,
"path": result["path"],
"deleted_count": file_count,
})
await dispatch_webhooks("directory_deleted", {"vault": vault_name, "path": result["path"]})
return {"success": True, "deleted_count": file_count}
@router.post("/api/file/{vault_name}", response_model=FileCreateResponse)
async def api_file_create(
vault_name: str,
body: FileCreateRequest,
current_user=Depends(require_auth),
):
"""Create a new file in a vault.
Args:
vault_name: Name of the vault.
body: Request body with file path and initial content.
Returns:
FileCreateResponse confirming creation.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
result = service_create_file(vault_name, body.path, body.content)
# Update index
await update_single_file(vault_name, result["path"])
# Broadcast SSE event
await sse_manager.broadcast("file_created", {
"vault": vault_name,
"path": result["path"],
})
await dispatch_webhooks("file_created", {"vault": vault_name, "path": result["path"]})
from backend.plugins import emit_file_created
emit_file_created(vault_name, result["path"])
return {"success": True, "path": result["path"]}
@router.post("/api/vault/{vault_name}/batch-upload", response_model=BatchUploadResponse)
async def api_batch_upload(
vault_name: str,
body: BatchUploadRequest,
current_user=Depends(require_auth),
):
"""Upload multiple files and directories (recursively) into a vault.
Accepts base64 encoded or plain text files with relative directory paths.
Creates missing parent folders safely.
Args:
vault_name: Target vault name.
body: BatchUploadRequest with target_dir and files list.
Returns:
BatchUploadResponse with summary of uploaded files and errors.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
import base64
items: list[dict[str, Any]] = []
for f in body.files:
if f.is_dir:
items.append({"path": f.path, "is_dir": True})
continue
raw_bytes = b""
if f.content is not None:
# Check if content is base64 encoded data URI or raw base64
content_str = f.content
if content_str.startswith("data:") and ";base64," in content_str:
content_str = content_str.split(";base64,", 1)[1]
try:
raw_bytes = base64.b64decode(content_str)
except Exception:
# Fallback to utf-8 text encoding
raw_bytes = f.content.encode("utf-8")
items.append({"path": f.path, "content": raw_bytes, "is_dir": False})
result = service_batch_upload_files(
vault_name,
body.target_dir,
items,
overwrite=body.overwrite,
)
# Update index and SSE notifications for uploaded files
for path in result["uploaded"]:
try:
await update_single_file(vault_name, path)
await sse_manager.broadcast("file_created", {
"vault": vault_name,
"path": path,
})
await dispatch_webhooks("file_created", {"vault": vault_name, "path": path})
except Exception as e:
logger.warning(f"Failed to post-process upload of {path}: {e}")
# SSE notification for tree refresh
if result["uploaded"] or result["created_dirs"]:
await sse_manager.broadcast("tree_updated", {
"vault": vault_name,
"target_dir": result["target_dir"],
})
return result
@router.patch("/api/file/{vault_name}", response_model=FileRenameResponse)
async def api_file_rename(
vault_name: str,
body: FileRenameRequest,
current_user=Depends(require_auth),
):
"""Rename a file in a vault.
Args:
vault_name: Name of the vault.
body: Request body with current path and new name.
Returns:
FileRenameResponse with old and new paths.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
result = service_rename_file(vault_name, body.path, body.new_name)
old_path_str = result["old_path"]
new_path_str = result["new_path"]
# Update index
await handle_file_move(vault_name, old_path_str, new_path_str)
# Update bookmarks, history, and shares
update_bookmarks_after_rename(vault_name, old_path_str, new_path_str)
update_history_after_rename(vault_name, old_path_str, new_path_str)
update_shares_after_rename(vault_name, old_path_str, new_path_str)
# Broadcast SSE event
await sse_manager.broadcast("file_renamed", {
"vault": vault_name,
"old_path": old_path_str,
"new_path": new_path_str,
})
await dispatch_webhooks("file_renamed", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str})
return {"success": True, "old_path": old_path_str, "new_path": new_path_str}
@router.post("/api/move/{vault_name}", response_model=FileMoveResponse)
async def api_file_move(
vault_name: str,
body: FileMoveRequest,
current_user=Depends(require_auth),
):
"""Move a file or directory to a different parent directory within the same vault.
Supports both files and directories. The item keeps its original name;
only the parent directory changes.
Args:
vault_name: Name of the vault.
body: Request body with source_path and destination_dir.
Returns:
FileMoveResponse with old and new paths.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
result = service_move_path(vault_name, body.source_path, body.destination_dir)
old_path_str = result["old_path"]
new_path_str = result["new_path"]
item_type = result["item_type"]
# Update index
if item_type == "directory":
from backend.indexer import reload_single_vault
await reload_single_vault(vault_name)
else:
await handle_file_move(vault_name, old_path_str, new_path_str)
# Broadcast SSE event
await sse_manager.broadcast("item_moved", {
"vault": vault_name,
"old_path": old_path_str,
"new_path": new_path_str,
"item_type": item_type,
})
await dispatch_webhooks("item_moved", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type})
return {"success": True, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type}
+51
View File
@@ -0,0 +1,51 @@
"""Shared helpers for the file routers (ROADMAP #85, tranche 6a).
Petites fonctions pures extraites de :mod:`backend.main` sans changement
de comportement. Regroupées ici car utilisées par plusieurs routers
(``files_read`` aujourd'hui, ``files_media`` / mutations ensuite) :
- :func:`content_disposition` — aussi utilisée par ``_stream_file_with_range``
(resté dans ``main`` jusqu'à la tranche media).
- :func:`media_max_inline_bytes` — aussi utilisée par ``/api/media``.
"""
from __future__ import annotations
import os
from pathlib import Path
def content_disposition(disposition: str, filename: str) -> str:
"""Build a header-safe Content-Disposition value.
HTTP header values must be ASCII. Unicode filenames are sent per
RFC 5987 via ``filename*`` (percent-encoded UTF-8) with a pure-ASCII
``filename`` fallback. This avoids a UnicodeDecodeError / HTTP 500 when
the filename contains accented characters (e.g. 'Bière blonde…pdf').
"""
from urllib.parse import quote
ascii_name = "".join(c for c in filename if c.isascii() and (c.isalnum() or c in " _-.")).strip() or "file"
ext = Path(filename).suffix
if ext and not Path(ascii_name).suffix:
ascii_name = ascii_name + ext
return f"{disposition}; filename=\"{ascii_name}\"; filename*=UTF-8''{quote(filename)}"
def media_max_inline_bytes() -> int:
"""Maximum size (bytes) for inline audio/video playback (roadmap #109-A3).
Configurable via ``OBSIGATE_MEDIA_MAX_INLINE_MB`` (default 500 MB). Files
above the limit are not streamed in the viewer (the UI falls back to the
download button), which keeps a single uvicorn worker from being pinned by
multi-gigabyte media. Invalid or non-positive values fall back to default.
"""
default_mb = 500
raw = os.environ.get("OBSIGATE_MEDIA_MAX_INLINE_MB", "").strip()
if not raw:
return default_mb * 1024 * 1024
try:
mb = float(raw)
except ValueError:
return default_mb * 1024 * 1024
if mb <= 0:
return default_mb * 1024 * 1024
return int(mb * 1024 * 1024)
+353
View File
@@ -0,0 +1,353 @@
"""Search, suggest, graph & index-reload endpoints (ROADMAP #85, tranche 5).
Handlers déplacés depuis :mod:`backend.main` sans changement de
comportement : mêmes chemins, mêmes modèles de réponse (déménagés dans
:mod:`backend.schemas`), mêmes dépendances d'authentification. La logique
métier vit déjà dans :mod:`backend.services.search`,
:mod:`backend.search`, :mod:`backend.services.graph` et
:mod:`backend.services.mutations`.
Adaptations strictement équivalentes :
- Le pool ``_search_executor`` de ``main`` vit désormais dans
:mod:`backend.search_executor` (même dimensionnement, même cycle de vie
géré par le lifespan de ``main``) : accès via
:func:`get_search_executor`.
"""
import asyncio
import logging
from functools import partial
from pathlib import Path
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from backend.audit import log_file_save
from backend.auth.middleware import check_vault_access, require_admin, require_auth
from backend.indexer import get_vault_data, reload_index, update_single_file
from backend.schemas import (
AdvancedSearchResponse,
GraphResponse,
ReloadResponse,
ReplaceResponse,
SearchResponse,
SuggestResponse,
TagsResponse,
TagSuggestResponse,
TreeSearchResponse,
VaultPathsResponse,
VaultStatsResponse,
)
from backend.search import suggest_tags, suggest_titles
from backend.search_executor import get_search_executor
from backend.services.graph import get_graph as service_get_graph
from backend.services.mutations import (
replace_in_files as service_replace_in_files,
)
from backend.services.search import advanced_search_vaults, list_paths, search_paths, search_vaults
from backend.services.search import list_tags as service_list_tags
from backend.sse import sse_manager
logger = logging.getLogger("obsigate")
router = APIRouter(tags=["search"])
@router.get("/api/search", response_model=SearchResponse)
async def api_search(
q: str = Query("", description="Search query"),
vault: str = Query("all", description="Vault filter"),
tag: str | None = Query(None, description="Tag filter"),
limit: int = Query(50, ge=1, le=200, description="Results per page"),
offset: int = Query(0, ge=0, description="Pagination offset"),
current_user=Depends(require_auth),
):
"""Full-text search across vaults with relevance scoring.
Supports combining free-text queries with tag filters.
Results are ranked by a multi-factor scoring algorithm.
Pagination via ``limit`` and ``offset`` (defaults preserve backward compat).
Args:
q: Free-text search string.
vault: Vault name or ``"all"`` to search everywhere.
tag: Comma-separated tag names to require.
limit: Max results per page (1–200).
offset: Pagination offset.
Returns:
``SearchResponse`` with ranked results and snippets.
"""
loop = asyncio.get_event_loop()
# Fetch the full result set (capped at DEFAULT_SEARCH_LIMIT internally) and
# 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),
)
@router.get("/api/tags", response_model=TagsResponse)
async def api_tags(vault: str | None = Query(None, description="Vault filter"), current_user=Depends(require_auth)):
"""Return all unique tags with occurrence counts.
Args:
vault: Optional vault name to restrict tag aggregation.
Returns:
``TagsResponse`` with tags sorted by descending count.
"""
return {"vault_filter": vault, "tags": service_list_tags(vault)}
@router.get("/api/tree-search", response_model=TreeSearchResponse)
async def api_tree_search(
q: str = Query("", description="Search query"),
vault: str = Query("all", description="Vault filter"),
current_user=Depends(require_auth),
):
"""Search for files and directories in the tree structure using pre-built index.
Uses the in-memory path index for instant filtering without filesystem access.
Args:
q: Search string to match against file/directory paths.
vault: Vault name or "all" to search everywhere.
Returns:
``TreeSearchResponse`` with matching paths.
"""
return search_paths(q, vault)
@router.get("/api/vault/{vault_name}/paths", response_model=VaultPathsResponse)
async def api_vault_paths(
vault_name: str,
limit: int = Query(5000, ge=1, le=20000, description="Maximum number of indexed paths to return"),
current_user=Depends(require_auth),
):
"""Return a flat list of every indexed file and directory in a vault.
Used by the AI assistant ``@`` mention menu to filter paths instantly on
the client (one request instead of one per keystroke).
Args:
vault_name: Name of the vault.
limit: Maximum number of entries returned.
Returns:
``VaultPathsResponse`` with the vault's indexed paths.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
return list_paths(vault_name, limit=limit)
@router.get("/api/search/advanced", response_model=AdvancedSearchResponse)
async def api_advanced_search(
q: str = Query("", description="Advanced search query (supports tag:, vault:, title:, path:, ext: operators)"),
vault: str = Query("all", description="Vault filter"),
tag: str | None = Query(None, description="Comma-separated tag filter"),
limit: int = Query(50, ge=1, le=200, description="Results per page"),
offset: int = Query(0, ge=0, description="Pagination offset"),
sort: str = Query("relevance", description="Sort by 'relevance' or 'modified'"),
case_sensitive: bool = Query(False, description="Match case"),
whole_word: bool = Query(False, description="Match whole words only"),
regex: bool = Query(False, description="Treat query as regex"),
include_paths: str | None = Query(None, description="Comma-separated glob patterns to include"),
exclude_paths: str | None = Query(None, description="Comma-separated glob patterns to exclude"),
created: str | None = Query(None, description="Created date filter (>date, <date, date..date)"),
modified: str | None = Query(None, description="Modified date filter (>date, <date, date..date, <Nd)"),
size: str | None = Query(None, description="Size filter (>size, <size, size..size, e.g. >1MB, <10KB)"),
semantic: bool = Query(False, description="Fuse TF-IDF with semantic embeddings (RRF)"),
current_user=Depends(require_auth),
):
"""Advanced full-text search with TF-IDF scoring, facets, and pagination.
Supports advanced query operators:
- ``tag:<name>`` or ``#<name>`` — filter by tag
- ``vault:<name>`` — filter by vault
- ``title:<text>`` — filter by title substring
- ``path:<text>`` — filter by path substring
- ``ext:<type>`` — filter by file extension
- ``created:>2024-01-01`` — filter by creation date
- ``modified:<7d`` or ``modified:2024-01-01..2024-06-01`` — filter by modification date
- ``size:>1MB`` or ``size:100KB..1MB`` — filter by file size
- Remaining text is scored using TF-IDF with accent normalization.
- Toggles: case_sensitive, whole_word, regex
- Path filters: include_paths, exclude_paths (glob patterns)
- ``semantic=true`` — fuse the TF-IDF ranking with the semantic (embedding)
ranking via Reciprocal Rank Fusion and expose ``semantic_score`` per result.
Results include ``<mark>``-highlighted snippets and faceted tag/vault counts.
"""
loop = asyncio.get_event_loop()
search_fn = partial(advanced_search_vaults, q, vault=vault, tag=tag,
limit=limit, offset=offset, sort=sort,
case_sensitive=case_sensitive, whole_word=whole_word, regex=regex,
include_paths=include_paths, exclude_paths=exclude_paths,
created=created, modified=modified, size=size, semantic=semantic)
try:
return await loop.run_in_executor(get_search_executor(), search_fn)
except ValueError as e:
raise HTTPException(400, str(e)) from e
@router.post("/api/search/replace", response_model=ReplaceResponse)
async def api_search_replace(
body: dict = Body(...),
current_user=Depends(require_auth),
):
"""Find and replace across vault files."""
query = body.get("query", "")
replacement = body.get("replacement", "")
vault_filter = body.get("vault", "all")
case_sensitive = body.get("case_sensitive", False)
whole_word = body.get("whole_word", False)
regex_mode = body.get("regex", False)
include_paths = body.get("include_paths")
exclude_paths = body.get("exclude_paths")
replace_all = body.get("replace_all", False)
dry_run = body.get("dry_run", not replace_all)
if not query:
raise HTTPException(400, "Query is required")
result = service_replace_in_files(
query,
replacement,
vault=vault_filter,
case_sensitive=case_sensitive,
whole_word=whole_word,
regex=regex_mode,
include_paths=include_paths,
exclude_paths=exclude_paths,
replace_all=replace_all,
dry_run=dry_run,
is_vault_allowed=lambda v: check_vault_access(v, current_user),
)
if dry_run:
return result
# Side effects for applied replacements (audit + incremental index).
for match in result.get("replaced", []):
log_file_save(current_user["username"], match["vault"], match["path"], match.get("size", 0))
vault_data = get_vault_data(match["vault"])
if vault_data:
abs_path = str(Path(vault_data["path"]) / match["path"])
await update_single_file(match["vault"], abs_path)
return result
@router.get("/api/suggest", response_model=SuggestResponse)
async def api_suggest(
q: str = Query("", description="Prefix to search for in file titles"),
vault: str = Query("all", description="Vault filter"),
limit: int = Query(10, ge=1, le=50, description="Max suggestions"),
current_user=Depends(require_auth),
):
"""Suggest file titles matching a prefix (accent-insensitive).
Used for autocomplete in the search input.
Args:
q: User-typed prefix (minimum 2 characters).
vault: Vault name or ``"all"``.
limit: Max number of suggestions.
Returns:
``SuggestResponse`` with matching file title suggestions.
"""
suggestions = suggest_titles(q, vault_filter=vault, limit=limit)
return {"query": q, "suggestions": suggestions}
@router.get("/api/tags/suggest", response_model=TagSuggestResponse)
async def api_tags_suggest(
q: str = Query("", description="Prefix to search for in tags"),
vault: str = Query("all", description="Vault filter"),
limit: int = Query(10, ge=1, le=50, description="Max suggestions"),
current_user=Depends(require_auth),
):
"""Suggest tags matching a prefix (accent-insensitive).
Used for autocomplete when typing ``tag:`` or ``#`` in the search input.
Args:
q: User-typed prefix (with or without ``#``, minimum 2 characters).
vault: Vault name or ``"all"``.
limit: Max number of suggestions.
Returns:
``TagSuggestResponse`` with matching tag suggestions and counts.
"""
suggestions = suggest_tags(q, vault_filter=vault, limit=limit)
return {"query": q, "suggestions": suggestions}
@router.get("/api/index/reload", response_model=ReloadResponse)
async def api_reload(current_user=Depends(require_admin)):
"""Force a full re-index of all configured vaults.
Returns:
``ReloadResponse`` with per-vault file and tag counts.
"""
stats = await reload_index()
await sse_manager.broadcast("index_reloaded", {
"vaults": list(stats.keys()),
"stats": stats,
})
return {"status": "ok", "vaults": stats}
@router.get("/api/graph/{vault_name}", response_model=GraphResponse)
async def api_graph(
vault_name: str,
path: str = Query("", description="Relative path to focus on"),
depth: int = Query(1, ge=0, le=3, description="How many levels deep to expand"),
scope: str = Query("directory", description="'directory' (default) or 'full' for entire vault"),
tag: str = Query("", description="Filter: only show files with this tag"),
current_user=Depends(require_auth),
):
"""Return graph data (nodes and edges) for a vault or directory.
Nodes represent files and directories. Edges represent parent-child
relationships and wikilinks between markdown files.
Args:
vault_name: Name of the vault.
path: Relative directory path to focus on (empty = root).
depth: Expansion depth (0 = only direct children, 1-3 = deeper).
scope: 'directory' for subtree, 'full' for entire vault.
tag: Optional tag filter (only files with this tag appear).
Returns:
``GraphResponse`` with nodes and edges.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
return service_get_graph(vault_name, path=path, depth=depth, scope=scope, tag=tag)
@router.get("/api/index/reload/{vault_name}", response_model=VaultStatsResponse)
async def api_reload_vault(vault_name: str, current_user=Depends(require_admin)):
"""Force a re-index of a single vault.
Args:
vault_name: Name of the vault to reindex.
Returns:
Dict with vault statistics.
"""
try:
from backend.indexer import reload_single_vault
stats = await reload_single_vault(vault_name)
await sse_manager.broadcast("vault_reloaded", {
"vault": vault_name,
"stats": stats,
})
return {"status": "ok", "vault": vault_name, "stats": stats}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
+300
View File
@@ -0,0 +1,300 @@
"""Public share endpoints (ROADMAP #85, tranche 3).
Handlers déplacés depuis :mod:`backend.main` sans changement de
comportement : mêmes chemins (``/api/share/*``, ``/api/shares``,
``/s/{token}*``), mêmes modèles de réponse, mêmes dépendances
d'authentification (les pages ``/s/*`` restent publiques). La logique
métier vit déjà dans :mod:`backend.share`.
Adaptations strictement équivalentes (pas de changement de comportement) :
- ``_resolve_safe_path`` / ``_backup_file`` de ``main`` n'étaient que des
wrappers directs : appelés ici via :mod:`backend.services.paths` et
:mod:`backend.services.backups` (mêmes signatures, mêmes exceptions
``ServiceError`` toujours mappées par le handler global de ``main``).
- ``_render_markdown`` reste défini dans ``main`` (extraction prévue dans
une tranche ultérieure) : import différé à l'intérieur des handlers, donc
sans import circulaire au chargement.
"""
import html as html_mod
import json as _json
import logging
from pathlib import Path
import frontmatter
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from fastapi.responses import FileResponse, HTMLResponse, Response
from backend.auth.middleware import check_vault_access, require_auth
from backend.indexer import get_vault_data, parse_markdown_file, update_single_file
from backend.schemas import ShareModel, StatusResponse
from backend.secret_redactor import redact_file_content
from backend.services.backups import create_backup
from backend.services.paths import resolve_safe_path
from backend.share import (
create_share,
get_share_by_token,
list_shares,
record_access,
revoke_share,
)
logger = logging.getLogger("obsigate")
# Lazy import: WeasyPrint PDF export (requires GTK, may not be available everywhere)
try:
from backend.pdf_export import build_pdf_html, generate_pdf
except Exception: # pragma: no cover - WeasyPrint/GTK missing
generate_pdf = None # type: ignore[assignment]
build_pdf_html = None # type: ignore[assignment]
logging.getLogger("obsigate").warning("PDF export unavailable (WeasyPrint/GTK not found)")
router = APIRouter(tags=["sharing"])
@router.post("/api/share/{vault_name}", response_model=ShareModel)
async def api_share_create(
vault_name: str,
body: dict = Body(...),
current_user=Depends(require_auth),
):
"""Create a public share link for a document.
Also sets ``publish: true`` in the file's YAML frontmatter so the
frontend can visually indicate the file is publicly shared.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'")
path = body.get("path", "")
expires = body.get("expires_in_hours")
share = create_share(vault_name, path, current_user["username"], expires)
share["url"] = f"/s/{share['token']}"
# Set publish: true in the file's frontmatter
vault_data = get_vault_data(vault_name)
if vault_data:
file_path = resolve_safe_path(Path(vault_data["path"]), path)
if file_path.exists() and file_path.suffix == ".md":
try:
raw = file_path.read_text(encoding="utf-8", errors="replace")
post = frontmatter.loads(raw)
if not post.metadata.get("publish"):
post.metadata["publish"] = True
new_raw = frontmatter.dumps(post)
create_backup(file_path, vault_name, path)
file_path.write_text(new_raw, encoding="utf-8")
await update_single_file(vault_name, str(file_path))
logger.info(f"Set publish:true on {vault_name}/{path}")
except Exception as e:
logger.warning(f"Failed to set publish metadata on {vault_name}/{path}: {e}")
return share
@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)
for s in shares:
s["url"] = f"/s/{s['token']}"
return shares
@router.delete("/api/share/{share_id}", response_model=StatusResponse)
async def api_share_revoke(share_id: str, current_user=Depends(require_auth)):
if not revoke_share(share_id):
raise HTTPException(404, "Share not found")
return {"status": "revoked"}
@router.get(
"/s/{token}/pdf",
response_class=Response,
responses={200: {"content": {"application/pdf": {}}, "description": "Shared document as PDF"}},
)
async def public_share_pdf_download(token: str):
"""Download shared document as real PDF via WeasyPrint."""
from backend.main import _render_markdown # différé : évite l'import circulaire (#85)
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")
vault_data = get_vault_data(share["vault"])
if not vault_data:
raise HTTPException(404, "Vault not found")
vault_root = Path(vault_data["path"])
file_path = resolve_safe_path(vault_root, share["path"])
if not file_path.exists():
raise HTTPException(404, "File not found")
try:
raw = file_path.read_text(encoding="utf-8", errors="replace")
except Exception:
raise HTTPException(500, "Cannot read file")
record_access(token)
raw = redact_file_content(raw, str(file_path))
post = parse_markdown_file(raw)
ext = file_path.suffix.lower()
if ext == ".md":
html = _render_markdown(post.content, share["vault"], file_path)
else:
html = f'<pre style="font-family:monospace;font-size:12px;line-height:1.6;white-space:pre-wrap">{html_mod.escape(raw)}</pre>'
title = post.metadata.get("title", file_path.stem)
pdf_html = build_pdf_html(html, str(title))
pdf_bytes = generate_pdf(pdf_html, str(title))
safe_name = "".join(c for c in str(title) if c.isascii() and (c.isalnum() or c in " _-.")).strip() or "document"
return Response(content=pdf_bytes, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="{safe_name}.pdf"'})
@router.get("/s/{token}/raw", response_class=FileResponse)
async def public_share_raw(token: str):
"""Download the raw (original) shared document."""
share = get_share_by_token(token)
if not share:
raise HTTPException(404, "Share not found or expired")
vault_data = get_vault_data(share["vault"])
if not vault_data:
raise HTTPException(404, "Vault not found")
vault_root = Path(vault_data["path"])
file_path = resolve_safe_path(vault_root, share["path"])
if not file_path.exists():
raise HTTPException(404, "File not found")
record_access(token)
return FileResponse(path=str(file_path), filename=file_path.name, media_type="application/octet-stream")
@router.get("/s/{token}", response_class=HTMLResponse)
async def public_share_view(token: str):
"""Public share view — no authentication required."""
from backend.main import _render_markdown # différé : évite l'import circulaire (#85)
share = get_share_by_token(token)
if not share:
raise HTTPException(404, "Share not found or expired")
vault_data = get_vault_data(share["vault"])
if not vault_data:
raise HTTPException(404, "Vault not found")
vault_root = Path(vault_data["path"])
file_path = resolve_safe_path(vault_root, share["path"])
if not file_path.exists():
raise HTTPException(404, "File not found")
try:
raw = file_path.read_text(encoding="utf-8", errors="replace")
except Exception:
raise HTTPException(500, "Cannot read file")
record_access(token)
raw = redact_file_content(raw, str(file_path))
post = parse_markdown_file(raw)
ext = file_path.suffix.lower()
if ext == ".md":
html = _render_markdown(post.content, share["vault"], file_path)
else:
escaped = html_mod.escape(raw)
html = f'<pre style="background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:16px;overflow-x:auto;font-size:0.85rem;line-height:1.6"><code>{escaped}</code></pre>'
title = post.metadata.get("title", file_path.stem)
# Escape everything user-controlled before embedding in HTML/JS (BUG-022).
title_esc = html_mod.escape(str(title))
# Neutralise ``</script>`` in the JS string literal too.
title_download_js = (
_json.dumps(f"{title}.md")
.replace("<", "\\u003c")
.replace(">", "\\u003e")
.replace("&", "\\u0026")
)
# JSON-escape raw content for embedding in HTML, and neutralise ``</script>``.
raw_json = (
_json.dumps(raw)
.replace("<", "\\u003c")
.replace(">", "\\u003e")
.replace("&", "\\u0026")
)
fm_html = ""
if post.metadata:
fm_items = []
skip_keys = {"title", "titre"}
for k, v in post.metadata.items():
if k in skip_keys:
continue
if isinstance(v, list):
v = ", ".join(str(x) for x in v)
elif isinstance(v, bool):
v = "✓" if v else "✗"
elif v is None:
v = "—"
fm_items.append(
f'<div class="fm-row"><span class="fm-key">{html_mod.escape(str(k))}</span>'
f'<span class="fm-val">{html_mod.escape(str(v))}</span></div>'
)
if fm_items:
fm_html = f'<div class="fm-section"><div class="fm-header">Frontmatter</div><div class="fm-body">{"".join(fm_items)}</div></div>'
return HTMLResponse(f"""<!DOCTYPE html><html lang="fr" data-theme="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>{title_esc} — ObsiGate Share</title>
<style>
:root {{ --bg:#1a1a2e; --bg-card:#16213e; --text:#e0e0e0; --text-muted:#888; --accent:#6366f1; --border:#2a2a4a; --banner-bg:var(--accent); --banner-text:#fff; }}
[data-theme="light"] {{ --bg:#f8f9fa; --bg-card:#fff; --text:#1a1a2e; --text-muted:#666; --accent:#4f46e5; --border:#ddd; --banner-bg:#eef2ff; --banner-text:#4338ca; }}
*{{box-sizing:border-box;margin:0;padding:0}}
body{{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);line-height:1.7;min-height:100vh}}
.toolbar{{position:sticky;top:0;z-index:10;background:var(--bg-card);border-bottom:1px solid var(--border);padding:8px 16px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}}
.toolbar-title{{font-weight:600;font-size:0.9rem;margin-right:auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
.toolbar-btn{{padding:6px 12px;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);cursor:pointer;font-size:0.8rem;display:flex;align-items:center;gap:5px;transition:all .15s}}
.toolbar-btn:hover{{background:var(--accent);color:#fff;border-color:var(--accent)}}
.toolbar-btn svg{{width:15px;height:15px;flex-shrink:0}}
.toolbar-btn:hover svg{{stroke:#fff}}
.share-banner{{background:var(--banner-bg);color:var(--banner-text);padding:6px 16px;font-size:0.8rem;text-align:center;display:flex;align-items:center;justify-content:center;gap:6px}}
.share-banner svg{{width:14px;height:14px;flex-shrink:0}}
.content{{max-width:820px;margin:0 auto;padding:24px 20px 60px}}
.content h1{{font-size:1.8rem;margin-bottom:16px;border-bottom:2px solid var(--border);padding-bottom:8px}}
.content h2{{font-size:1.4rem;margin:24px 0 12px}}
.content h3{{font-size:1.15rem;margin:20px 0 8px}}
.content p{{margin:8px 0}}
.content pre{{background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:12px 16px;overflow-x:auto;font-size:0.85rem}}
.content code{{font-size:0.9em;background:var(--bg-card);padding:1px 4px;border-radius:3px}}
.content pre code{{background:none;padding:0}}
.content a{{color:var(--accent)}}.content img{{max-width:100%;border-radius:6px}}
.fm-section{{background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:20px}}
.fm-header{{font-weight:600;font-size:0.8rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:8px}}
.fm-body{{display:grid;grid-template-columns:1fr 2fr;gap:4px 12px;font-size:0.85rem}}
.fm-row{{display:contents}}
.fm-key{{color:var(--accent);font-weight:500}}
.fm-val{{color:var(--text);word-break:break-word}}
.content blockquote{{border-left:3px solid var(--accent);padding-left:16px;color:var(--text-muted);margin:12px 0}}
.content table{{border-collapse:collapse;width:100%;margin:12px 0}}
.content th,.content td{{border:1px solid var(--border);padding:8px 12px;text-align:left}}
.content th{{background:var(--bg-card)}}
@media print{{.toolbar,.share-banner{{display:none}}body{{background:#fff;color:#000}}}}
@media(max-width:600px){{.content{{padding:16px 12px 40px}}.toolbar{{gap:4px}}.toolbar-btn{{padding:4px 8px;font-size:0.7rem}}}}
</style></head>
<body>
<div class="share-banner">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg>
Document partagé via ObsiGate
</div>
<div class="toolbar">
<span class="toolbar-title">{title_esc}</span>
<button class="toolbar-btn" onclick="toggleTheme()" title="Thème clair/sombre">
<svg id="theme-icon-dark" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
<svg id="theme-icon-light" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
</button>
<button class="toolbar-btn" onclick="exportMD()" title="Télécharger en Markdown">
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
.md
</button>
<button class="toolbar-btn" onclick="location.href=location.pathname+'/pdf'" title="Télécharger en PDF">
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
PDF
</button>
</div>
<div class="content" id="content">{fm_html}{html}</div>
<script id="raw-content" type="text/plain" style="display:none">{raw_json}</script>
<script>
function toggleTheme(){{var t=document.documentElement;var isDark=t.dataset.theme==="dark";t.dataset.theme=isDark?"light":"dark";document.getElementById("theme-icon-dark").style.display=isDark?"none":"";document.getElementById("theme-icon-light").style.display=isDark?"":"none";localStorage.setItem("obsigate-share-theme",t.dataset.theme)}}
(function(){{var s=localStorage.getItem("obsigate-share-theme");if(!s)s="dark";document.documentElement.dataset.theme=s;var isDark=s==="dark";document.getElementById("theme-icon-dark").style.display=isDark?"":"none";document.getElementById("theme-icon-light").style.display=isDark?"none":""}})();
function exportMD(){{var raw=JSON.parse(document.getElementById("raw-content").textContent);var b=new Blob([raw],{{type:"text/markdown"}});var a=document.createElement("a");a.href=URL.createObjectURL(b);a.download={title_download_js};a.click()}}
</script></body></html>""")
+395
View File
@@ -188,6 +188,401 @@ class BackupsAutoResponse(BaseModel):
since_hours: int | float = Field(description="Look-back window in hours")
class DiffResponse(BaseModel):
"""Response containing a unified diff between two file versions (#85 — extrait de backend.main, inchangé)."""
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path")
version: int = Field(description="Backup version timestamp (left/old side)")
compare_with: int | None = Field(default=None, description="Other backup version or null for current file (right/new side)")
diff: str = Field(description="Unified diff (empty if no changes)")
class RestoreRequest(BaseModel):
"""Request to restore a file from a backup (#85 — extrait de backend.main, inchangé)."""
version: int = Field(description="Timestamp of the backup version to restore")
class RestoreResponse(BaseModel):
"""Response after restoring a file from backup (#85 — extrait de backend.main, inchangé)."""
success: bool = Field(description="Whether restore succeeded")
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path")
restored_from: int = Field(description="Timestamp of the backup used")
current_backed_up: int | None = Field(default=None, description="Timestamp of the backup created from the current version before restore, if any")
# ---------------------------------------------------------------------------
# Files — browse / read (#85 — extrait de backend.main, inchangé)
# ---------------------------------------------------------------------------
class BrowseItem(BaseModel):
"""A single entry (file or directory) returned by the browse endpoint."""
name: str = Field(description="File or directory name")
path: str = Field(description="Relative path within vault")
type: str = Field(description="'file' or 'directory'")
children_count: int | None = Field(default=None, description="Number of children (directories only)")
size: int | None = Field(default=None, description="File size in bytes")
extension: str | None = Field(default=None, description="File extension")
class BrowseResponse(BaseModel):
"""Paginated directory listing for a vault."""
vault: str
path: str
items: list[BrowseItem]
class FileContentResponse(BaseModel):
"""Rendered file content with metadata."""
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path within the vault")
title: str = Field(description="File title (from frontmatter or filename)")
tags: list[str] = Field(description="Extracted tags from frontmatter and inline #tags")
frontmatter: dict[str, Any] = Field(description="YAML frontmatter as key-value dict")
html: str = Field(description="Rendered HTML content")
raw_length: int = Field(description="Length of raw file content in characters")
extension: str = Field(description="File extension (e.g. .md, .txt)")
is_markdown: bool = Field(description="Whether the file is markdown")
unsupported: bool | None = Field(default=False, description="True for binary/unsupported files")
size_bytes: int | None = Field(default=None, description="File size in bytes (for unsupported files)")
is_pdf: bool | None = Field(default=None, description="True for PDF files")
is_image: bool | None = Field(default=None, description="True for image files")
is_audio: bool | None = Field(default=None, description="True for audio files (HTML5 <audio>, roadmap #109)")
is_video: bool | None = Field(default=None, description="True for video files (HTML5 <video>, roadmap #109)")
media_too_large: bool | None = Field(default=None, description="True when audio/video exceeds the inline streaming limit")
stream_url: str | None = Field(default=None, description="Byte-range streaming URL under /api/media (audio/video)")
media_mime: str | None = Field(default=None, description="MIME type for audio/video files")
is_csv: bool | None = Field(default=None, description="True for CSV files")
is_xlsx: bool | None = Field(default=None, description="True for Excel .xlsx files")
xlsx_sheets: list[dict[str, Any]] | None = Field(
default=None, description="Rendered xlsx sheets [{name, html}]"
)
is_json: bool | None = Field(default=None, description="True for JSON files")
is_excalidraw: bool | None = Field(default=None, description="True for Excalidraw diagram files")
excalidraw_data: dict[str, Any] | None = Field(default=None, description="Excalidraw diagram data (elements, appState, files)")
excalidraw_data_compressed: str | None = Field(default=None, description="Compressed Excalidraw data for .excalidraw.md files")
pdf_metadata: dict[str, Any] | None = Field(default=None, description="PDF metadata")
pdf_toc: list[dict[str, Any]] | None = Field(default=None, description="PDF table of contents")
image_mime: str | None = Field(default=None, description="MIME type for image files")
class FileRawResponse(BaseModel):
"""Raw text content of a file."""
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path within the vault")
raw: str = Field(description="Raw file content as text")
# ---------------------------------------------------------------------------
# Files — mutations (#85 — extrait de backend.main, inchangé)
# ---------------------------------------------------------------------------
class FileSaveResponse(BaseModel):
"""Confirmation after saving a file."""
status: str = Field(description="Always 'ok'")
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")
class FileDeleteResponse(BaseModel):
"""Confirmation after deleting a file."""
status: str = Field(description="Always 'ok'")
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path within the vault")
class DirectoryCreateRequest(BaseModel):
"""Request to create a new directory."""
path: str = Field(description="Relative path of the new directory")
class DirectoryCreateResponse(BaseModel):
"""Response after creating a directory."""
success: bool = Field(description="Whether creation succeeded")
path: str = Field(description="Path of the created directory")
class DirectoryRenameRequest(BaseModel):
"""Request to rename a directory."""
path: str = Field(description="Current path of the directory")
new_name: str = Field(description="New name for the directory")
class DirectoryRenameResponse(BaseModel):
"""Response after renaming a directory."""
success: bool = Field(description="Whether rename succeeded")
old_path: str = Field(description="Original directory path")
new_path: str = Field(description="New directory path")
class DirectoryDeleteResponse(BaseModel):
"""Response after deleting a directory."""
success: bool = Field(description="Whether deletion succeeded")
deleted_count: int = Field(description="Number of files recursively deleted")
class FileCreateRequest(BaseModel):
"""Request to create a new file."""
path: str = Field(description="Relative path of the new file")
content: str = Field(default="", description="Initial content")
class FileCreateResponse(BaseModel):
"""Response after creating a file."""
success: bool = Field(description="Whether creation succeeded")
path: str = Field(description="Path of the created file")
class BatchUploadFileItem(BaseModel):
"""A single file/dir entry in a batch upload request."""
path: str = Field(description="Relative path of the item within the batch")
content: str | None = Field(default=None, description="Base64 encoded or text content for files")
is_dir: bool = Field(default=False, description="True if entry represents an empty directory")
class BatchUploadRequest(BaseModel):
"""Request payload for batch file/directory upload."""
target_dir: str = Field(default="", description="Base directory in vault to upload into (empty for root)")
files: list[BatchUploadFileItem] = Field(description="List of files and directories to upload")
overwrite: bool = Field(default=True, description="Whether to overwrite existing files (creates backups)")
class BatchUploadResponse(BaseModel):
"""Response from batch file/directory upload."""
success: bool = Field(description="True if all files uploaded without error")
vault: str = Field(description="Vault name")
target_dir: str = Field(description="Target directory")
uploaded: list[str] = Field(description="List of created/updated file paths")
created_dirs: list[str] = Field(description="List of created directory paths")
errors: list[dict[str, Any]] = Field(default_factory=list, description="List of items that failed")
total_files: int = Field(description="Total uploaded files count")
class FileRenameRequest(BaseModel):
"""Request to rename a file."""
path: str = Field(description="Current path of the file")
new_name: str = Field(description="New name for the file")
class FileRenameResponse(BaseModel):
"""Response after renaming a file."""
success: bool = Field(description="Whether rename succeeded")
old_path: str
new_path: str
class FileMoveRequest(BaseModel):
"""Request to move a file or directory to a different parent directory."""
source_path: str = Field(description="Current relative path of the file/directory")
destination_dir: str = Field(description="Target directory relative path (empty string for vault root)")
class FileMoveResponse(BaseModel):
"""Response after moving a file or directory."""
success: bool = Field(description="Whether move succeeded")
old_path: str = Field(description="Original path")
new_path: str = Field(description="New path after move")
item_type: str = Field(description="Type of item moved: 'file' or 'directory'")
# ---------------------------------------------------------------------------
# Search / suggest / graph (#85 — extrait de backend.main, inchangé)
# ---------------------------------------------------------------------------
class SearchResultItem(BaseModel):
"""A single search result."""
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")
class SearchResponse(BaseModel):
"""Full-text search response with optional pagination."""
query: str = Field(description="Original search query")
vault_filter: str = Field(description="Vault filter applied ('all' or vault name)")
tag_filter: str | None = Field(default=None, description="Tag filter applied")
count: int = Field(description="Number of results in this response")
total: int = Field(default=0, description="Total results before pagination")
offset: int = Field(default=0, description="Current pagination offset")
limit: int = Field(default=200, description="Page size")
results: list[SearchResultItem] = Field(description="Search result items")
class TagsResponse(BaseModel):
"""Tag aggregation response."""
vault_filter: str | None = Field(default=None, description="Vault filter applied")
tags: dict[str, int] = Field(description="Tag name → count mapping")
class TreeSearchResult(BaseModel):
"""A single tree search result item."""
vault: str = Field(description="Vault name")
path: str = Field(description="Full relative path")
name: str = Field(description="File or directory name")
type: str = Field(description="'file' or 'directory'")
matched_path: str = Field(description="Path segment that matched the query")
class TreeSearchResponse(BaseModel):
"""Tree search response with matching paths."""
query: str = Field(description="Search query")
vault_filter: str = Field(description="Vault filter applied")
results: list[TreeSearchResult] = Field(description="Matching files and directories")
class VaultPathEntry(BaseModel):
"""A single indexed path (file or directory) in a vault."""
vault: str = Field(description="Vault name")
path: str = Field(description="Full relative path")
name: str = Field(description="File or directory name")
type: str = Field(description="'file' or 'directory'")
class VaultPathsResponse(BaseModel):
"""Flat list of every indexed path in a vault (capped)."""
vault: str = Field(description="Vault name")
count: int = Field(description="Number of returned entries")
results: list[VaultPathEntry] = Field(description="Indexed files and directories")
class AdvancedSearchResultItem(BaseModel):
"""A single advanced search result with highlighted snippet."""
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: float = Field(description="TF-IDF relevance score (or fused RRF score in semantic mode)")
semantic_score: float = Field(default=0.0, description="Cosine similarity from the semantic index (0 when unavailable)")
snippet: str = Field(description="Content excerpt with <mark> highlights")
modified: str = Field(description="ISO 8601 modification timestamp")
extension: str = Field(default="", description="File extension")
class SearchFacets(BaseModel):
"""Faceted counts for search results."""
tags: dict[str, int] = Field(default_factory=dict)
vaults: dict[str, int] = Field(default_factory=dict)
class AdvancedSearchResponse(BaseModel):
"""Advanced search response with TF-IDF scoring, facets, and pagination."""
results: list[AdvancedSearchResultItem] = Field(description="Search results")
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")
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")
class TitleSuggestion(BaseModel):
"""A file title suggestion for autocomplete."""
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path")
title: str = Field(description="File title")
class SuggestResponse(BaseModel):
"""Autocomplete suggestions for file titles."""
query: str = Field(description="Original query string")
suggestions: list[TitleSuggestion] = Field(description="Matching file suggestions")
class TagSuggestion(BaseModel):
"""A tag suggestion for autocomplete."""
tag: str = Field(description="Tag name")
count: int = Field(description="Number of files with this tag")
class TagSuggestResponse(BaseModel):
"""Autocomplete suggestions for tags."""
query: str = Field(description="Original query string")
suggestions: list[TagSuggestion] = Field(description="Matching tag suggestions")
class GraphNode(BaseModel):
"""A single node in the graph view."""
id: str = Field(description="Unique node identifier")
name: str = Field(description="Display name")
type: str = Field(description="'vault', 'directory', or 'file'")
path: str = Field(description="Relative path within vault")
size: int = Field(default=0, description="File size in bytes")
tags: list[str] = Field(default_factory=list, description="Tags from frontmatter")
incoming_count: int = Field(default=0, description="Number of incoming wikilinks")
outgoing_count: int = Field(default=0, description="Number of outgoing wikilinks")
class GraphEdge(BaseModel):
"""An edge between two nodes in the graph view."""
source: str = Field(description="Source node ID")
target: str = Field(description="Target node ID")
relation: str = Field(description="'parent', 'wikilink', or 'backlink'")
class GraphResponse(BaseModel):
"""Graph data for a vault or directory."""
vault: str = Field(description="Vault name")
path: str = Field(description="Root path for the graph")
scope: str = Field(default="directory", description="'directory' or 'full'")
nodes: list[GraphNode] = Field(description="Graph nodes (files and directories)")
edges: list[GraphEdge] = Field(description="Graph edges (parent and wikilink relations)")
class ReloadResponse(BaseModel):
"""Index reload confirmation with per-vault stats."""
status: str = Field(description="Reload status ('ok' or 'error')")
vaults: dict[str, Any] = Field(description="Per-vault file counts after reload")
# ---------------------------------------------------------------------------
# PDF
# ---------------------------------------------------------------------------
+35
View File
@@ -0,0 +1,35 @@
"""Shared thread pool for CPU-bound search (ROADMAP #85, tranche 5).
Holder extrait de :mod:`backend.main` sans changement de comportement :
un seul pool (2 workers, préfixe ``"search"``) créé au démarrage et arrêté
à l'extinction par le lifespan de ``main``. Les routers et les endpoints
restants y accèdent via :func:`get_search_executor` au lieu du global de
``main`` (plus d'import circulaire potentiel).
"""
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
_executor: ThreadPoolExecutor | None = None
def init_search_executor(max_workers: int = 2) -> ThreadPoolExecutor:
"""Create (or reuse) the shared search thread pool."""
global _executor
if _executor is None:
_executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="search")
return _executor
def shutdown_search_executor() -> None:
"""Stop the shared search thread pool (best-effort, non-blocking)."""
global _executor
if _executor is not None:
_executor.shutdown(wait=False)
_executor = None
def get_search_executor() -> ThreadPoolExecutor | None:
"""Return the shared search thread pool (``None`` before startup)."""
return _executor
+54
View File
@@ -0,0 +1,54 @@
"""Server-Sent Events manager (ROADMAP #85, tranche 4).
Singleton extrait de :mod:`backend.main` sans changement de comportement :
les routers montés par ``main`` partagent la même instance (les clients SSE
connectés sur ``/api/events`` reçoivent les broadcasts émis depuis
n'importe quel router).
"""
from __future__ import annotations
import asyncio
import json as _json
import logging
logger = logging.getLogger("obsigate")
class SSEManager:
"""Manages SSE client connections and broadcasts events."""
def __init__(self):
self._clients: list[asyncio.Queue] = []
async def connect(self) -> asyncio.Queue:
"""Register a new SSE client and return its message queue."""
queue: asyncio.Queue = asyncio.Queue()
self._clients.append(queue)
logger.debug(f"SSE client connected (total: {len(self._clients)})")
return queue
def disconnect(self, queue: asyncio.Queue):
"""Remove a disconnected SSE client."""
if queue in self._clients:
self._clients.remove(queue)
logger.debug(f"SSE client disconnected (total: {len(self._clients)})")
async def broadcast(self, event_type: str, data: dict):
"""Send an event to all connected SSE clients."""
message = _json.dumps(data, ensure_ascii=False)
dead: list[asyncio.Queue] = []
for q in self._clients:
try:
q.put_nowait({"event": event_type, "data": message})
except asyncio.QueueFull:
dead.append(q)
for q in dead:
self.disconnect(q)
@property
def client_count(self) -> int:
return len(self._clients)
sse_manager = SSEManager()
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.27.3"
version = "2.27.8"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.27.3"
version = "2.27.8"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.27.3",
"version": "2.27.8",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+3 -3
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.27.3 | **Dernière mise à jour :** 2026-09-26
> **Version :** 2.27.8 | **Dernière mise à jour :** 2026-09-26
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
> vers les fonctionnalités livrées.
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
@@ -67,10 +67,10 @@
- **Effort :** 8-12 jours | **Impact :** 🟡 | **Zone :** backend
- **Décision 2026-09-26 : prioritaire (axe Dette & sécurité).**
- **Statut :** 🔵 en cours depuis 2026-09-26 — découpe par tranches à impact minimal (comportement inchangé, un domaine par commit). **T1 livrée (v2.27.2) :** `health` (`/api/health`, `/api/health/detailed` → `backend/routers/health.py`, `HealthResponse` → `schemas.py`). **T2 livrée (v2.27.3) :** `webhooks` (CRUD `/api/webhooks` → `backend/routers/webhooks.py`, logique déjà dans `backend/webhooks.py`).
- **Statut :** 🔵 en cours depuis 2026-09-26 — découpe par tranches à impact minimal (comportement inchangé, un domaine par commit). **T1 livrée (v2.27.2) :** `health` (`/api/health`, `/api/health/detailed` → `backend/routers/health.py`, `HealthResponse` → `schemas.py`). **T2 livrée (v2.27.3) :** `webhooks` (CRUD `/api/webhooks` → `backend/routers/webhooks.py`, logique déjà dans `backend/webhooks.py`). **T3 livrée (v2.27.4) :** `sharing` (`/api/share/*`, `/api/shares`, `/s/{token}*` → `backend/routers/sharing.py`, logique déjà dans `backend/share.py`). **T4 livrée (v2.27.5) :** `backups` (9 routes `/api/file/{vault}/backups|diff|restore` + `/api/backups*` → `backend/routers/backups.py`, `Diff/Restore*` → `schemas.py`, singleton SSE → `backend/sse.py`). **T5 livrée (v2.27.6) :** `search` (11 routes search/tags/suggest/graph/reload → `backend/routers/search.py`, modèles search → `schemas.py`, pool threads → `backend/search_executor.py`). **T6a livrée (v2.27.7) :** lecture fichiers (`/api/browse`, `/raw`, `/download`, `/backlinks`, `GET /api/file` → `backend/routers/files_read.py`, modèles + `EXT_TO_LANG` déplacés, helpers `_content_disposition`/`_media_max_inline_bytes` → `backend/routers/helpers.py`). **T6b livrée (v2.27.8) :** mutations fichiers/dossiers (save, xlsx/save, delete, create, rename, move, directories ×3, batch-upload → `backend/routers/files_write.py`, 15 modèles → `schemas.py`).
- **Description :** extraire le monolithe `backend/main.py` (~4 827 lignes au 2026-09-26, ~17 % du backend) en routers FastAPI par domaine et rendre persistant l'état qui ne l'est pas (index de recherche, JTI révoqués, compteurs de rate-limit) pour préparer le multi-nœuds. L'état mémoire actuel (index, inverted index, vecteurs sémantiques, `SSEManager`, collab) rend le multi-workers unsafe.
- **Sous-tâches :**
- [ ] Routers par domaine : files, search, share, webhooks, plugins, collab, admin, ai — `main.py` conservé comme assemblage (< 500 lignes) ; dédupliquer les modèles Pydantic vers `schemas.py`. **Avancement :** `health` ✅ (T1, `backend/routers/health.py`), `webhooks` ✅ (T2, `backend/routers/webhooks.py`) ; `tools/registry.py` existe déjà (permissions/quotas/redaction — à compléter, pas à créer)
- [ ] Routers par domaine : files, search, share, webhooks, plugins, collab, admin, ai — `main.py` conservé comme assemblage (< 500 lignes) ; dédupliquer les modèles Pydantic vers `schemas.py`. **Avancement :** `health` ✅ (T1, `backend/routers/health.py`), `webhooks` ✅ (T2, `backend/routers/webhooks.py`), `sharing` ✅ (T3, `backend/routers/sharing.py`), `backups` ✅ (T4, `backend/routers/backups.py` + `backend/sse.py`), `search` ✅ (T5, `backend/routers/search.py` + `backend/search_executor.py`), `files-read` ✅ (T6a, `backend/routers/files_read.py` + `helpers.py`), `files-write` ✅ (T6b, `backend/routers/files_write.py`) ; `tools/registry.py` existe déjà (permissions/quotas/redaction — à compléter, pas à créer)
- [ ] Compléter `tools/registry.py` (existant : permissions/quotas/redaction) comme contrat central des outils IA si des manques sont constatés
- [ ] Persister index, JTI révoqués et compteurs de rate-limit (SQLite par défaut, Redis en option multi-nœuds ; le rate-limit actuel est in-memory mono-process)
- [ ] Verrous asyncio autour de l'index global et des stores JSON ; auditer les `except Exception` larges (> 100 occurrences) : best-effort (backup/audit) vs masquage d'erreur (erreurs typées 4xx/5xx + test)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.27.3",
"version": "2.27.8",
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
"main": "patch.js",
"directories": {