Compare commits

...
4 Commits
24 changed files with 2357 additions and 2020 deletions
+41 -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.7**.
> [Unreleased](#unreleased). La dernière version livrée est **2.27.11**.
---
@@ -14,6 +14,22 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.27.11] — 2026-09-26
---
## [2.27.10] — 2026-09-26
---
## [2.27.9] — 2026-09-26
---
## [2.27.8] — 2026-09-26
---
## [2.27.7] — 2026-09-26
---
@@ -38,6 +54,30 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Modifié
- **#85 (T8) — extraction vaults/history/conflicts hors du monolithe `backend/main.py`.**
13 routes servies par `backend/routers/vaults.py`, `history.py` et
`conflicts.py` ; `VaultInfo`/`BookmarkToggleRequest` dans `schemas.py`,
handle watcher partagé dans `backend/watcher_state.py`.
`tests/test_api_main.py` importe `humanize_mtime` depuis son module
canonique (`services.recent`).
- **#85 (T7) — extraction du domaine `config` hors du monolithe `backend/main.py`.**
`/api/config`, ai-keys (get/post/delete/test), tool-keys (×3), ai-models,
diagnostics et dashboard sont servis par `backend/routers/config.py`
(`_FALLBACK_MODELS`, store clés et config déplacés ; `main` réimporte
`_load_config` pour son lifespan, les fixtures de tests inchangées).
`tests/test_ai_models.py` patch désormais la référence du router.
- **#85 (T6c) — extraction media/pdf/export/guide hors du monolithe `backend/main.py`.**
file/pdf, exports (html/md-bundle/epub), guide/download, pdf/stream|info,
image, media+thumb, attachments (rescan/stats), vault settings (get/post/all)
et vault files sont servis par `backend/routers/files_media.py` ; le helper
Range partagé vit dans `backend/routers/helpers.py` (tags OpenAPI inchangés,
tests statiques frontend `media-viewer`/`image-viewer` réalignés).
- **#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
+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.7-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.27.11-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.7).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.27.11).
---
*Projet : ObsiGate | Version : 2.27.7 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.27.11 | 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.7-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.27.11-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.7).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.27.11).
---
*Project: ObsiGate | Version: 2.27.7 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.27.11 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.27.7
2.27.11
+66 -1991
View File
File diff suppressed because it is too large Load Diff
+531
View File
@@ -0,0 +1,531 @@
"""Configuration, AI keys, diagnostics & dashboard endpoints (ROADMAP #85, tranche 7).
Handlers déplacés depuis :mod:`backend.main` sans changement de
comportement : mêmes chemins (``/api/config*``, ``/api/diagnostics``,
``/api/dashboard``), mêmes modèles de réponse, mêmes dépendances
d'authentification.
Adaptations strictement équivalentes :
- ``_load_config`` / ``_save_config`` / ``_DEFAULT_CONFIG`` /
``_CONFIG_PATH`` / ``_BASE_DIR`` ont déménagé ici : ``main`` les
réimporte pour son lifespan (pas de cycle : ce module ne dépend pas de
``main``).
- ``AI_KEYS_FILE`` / ``_write_ai_keys`` / ``_FALLBACK_MODELS`` ont déménagé
ici (``AI_KEYS_FILE`` garde son chemin relatif ``data/api_keys.json``,
résolu depuis le même CWD au runtime).
"""
import json as _json
import logging
import os
import urllib.request
from pathlib import Path
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from backend.ai import PROVIDERS, _read_ai_keys, get_ai_key
from backend.auth.middleware import require_admin, require_auth
from backend.indexer import index
from backend.media_types import IMAGE_EXTENSIONS
from backend.schemas import (
AIKeyDeleteResponse,
AIKeysResponse,
AIModelsResponse,
AITestResponse,
AppConfigResponse,
DashboardResponse,
DiagnosticsResponse,
StatusResponse,
)
from backend.search_executor import get_search_executor
from backend.tools.secrets import (
TOOL_KEY_NAMES as _TOOL_KEY_NAMES,
)
from backend.tools.secrets import (
delete_tool_key as _delete_tool_key,
)
from backend.tools.secrets import (
get_tool_key as _get_tool_key,
)
from backend.tools.secrets import (
mask_value as _mask_tool_value,
)
from backend.tools.secrets import (
set_tool_key as _set_tool_key,
)
logger = logging.getLogger("obsigate")
router = APIRouter(tags=["System"])
_BASE_DIR = Path(__file__).resolve().parent.parent.parent
_CONFIG_PATH = _BASE_DIR / "data" / "config.json"
_DEFAULT_CONFIG = {
"search_workers": 2,
"debounce_ms": 300,
"results_per_page": 50,
"min_query_length": 2,
"search_timeout_ms": 30000,
"max_content_size": 100000,
"snippet_context_chars": 120,
"max_snippet_highlights": 5,
"title_boost": 3.0,
"path_boost": 1.5,
"watcher_enabled": True,
"watcher_use_polling": False,
"watcher_polling_interval": 5.0,
"watcher_debounce": 2.0,
"tag_boost": 2.0,
"prefix_max_expansions": 50,
"recent_files_limit": 20,
"max_backups_per_file": 10,
"ai_default_provider": "deepseek",
"ai_default_models": {},
}
def _load_config() -> dict:
"""Load config from disk, merging with defaults."""
config = dict(_DEFAULT_CONFIG)
if _CONFIG_PATH.exists():
try:
stored = _json.loads(_CONFIG_PATH.read_text(encoding="utf-8"))
config.update(stored)
except Exception as e:
logger.warning(f"Failed to read config.json: {e}")
return config
def _save_config(config: dict) -> None:
"""Persist config to disk."""
try:
_CONFIG_PATH.write_text(
_json.dumps(config, indent=2, ensure_ascii=False),
encoding="utf-8",
)
except Exception as e:
logger.error(f"Failed to write config.json: {e}")
raise HTTPException(status_code=500, detail=f"Failed to save config: {e}")
AI_KEYS_FILE = Path("data/api_keys.json")
def _write_ai_keys(data: dict):
AI_KEYS_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = AI_KEYS_FILE.with_suffix(".tmp")
tmp.write_text(_json.dumps(data, indent=2), encoding="utf-8")
tmp.replace(AI_KEYS_FILE)
@router.get("/api/config", response_model=AppConfigResponse)
async def api_get_config(current_user=Depends(require_auth)):
"""Return current configuration with defaults for missing keys."""
return _load_config()
@router.post("/api/config", response_model=AppConfigResponse)
async def api_set_config(body: dict = Body(...), current_user=Depends(require_admin)):
"""Update configuration. Only known keys are accepted.
Keys matching ``_DEFAULT_CONFIG`` are validated and persisted.
Unknown keys are silently ignored.
Returns the full merged config after update.
"""
current = _load_config()
updated_keys = []
for key, value in body.items():
if key in _DEFAULT_CONFIG:
expected_type = type(_DEFAULT_CONFIG[key])
if isinstance(value, expected_type) or (expected_type is float and isinstance(value, (int, float))):
current[key] = value
updated_keys.append(key)
else:
raise HTTPException(
status_code=400,
detail=f"Invalid type for '{key}': expected {expected_type.__name__}, got {type(value).__name__}",
)
_save_config(current)
if any(k.startswith("ai_") for k in updated_keys):
try:
from backend.ai import reload_ai_config
reload_ai_config()
except Exception as e:
logger.warning(f"Failed to reload AI config: {e}")
logger.info(f"Config updated: {updated_keys}")
return current
@router.get("/api/config/ai-keys", response_model=AIKeysResponse)
async def api_get_ai_keys(current_user=Depends(require_admin)):
"""Return stored AI keys (values masked)."""
keys = _read_ai_keys()
masked = {}
for k in ["DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY", "NVIDIA_API_KEY", "QWENCLOUD_API_KEY", "XIAOMI_API_KEY", "MISTRAL_API_KEY"]:
val = keys.get(k, "") or os.environ.get(k, "")
if val:
masked[k] = val[:4] + "..." + val[-4:] if len(val) > 8 else "***"
else:
masked[k] = ""
return masked
@router.post("/api/config/ai-keys", response_model=StatusResponse)
async def api_set_ai_keys(body: dict = Body(...), current_user=Depends(require_admin)):
"""Save AI keys. Pass {"DEEPSEEK_API_KEY":"sk-...","OPENROUTER_API_KEY":"...","GEMINI_API_KEY":"..."}"""
keys = _read_ai_keys()
for k in ["DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY", "NVIDIA_API_KEY", "QWENCLOUD_API_KEY", "XIAOMI_API_KEY", "MISTRAL_API_KEY"]:
if body.get(k):
keys[k] = body[k]
_write_ai_keys(keys)
logger.info("AI keys updated")
return {"status": "ok"}
@router.delete("/api/config/ai-keys/{provider_env}", response_model=AIKeyDeleteResponse)
async def api_delete_ai_key(provider_env: str, current_user=Depends(require_admin)):
"""Delete a specific AI provider key from storage."""
allowed = {"DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY",
"NVIDIA_API_KEY", "QWENCLOUD_API_KEY", "XIAOMI_API_KEY", "MISTRAL_API_KEY"}
key_name = provider_env.upper()
if key_name not in allowed:
raise HTTPException(status_code=400, detail=f"Clé inconnue: {provider_env}")
keys = _read_ai_keys()
if key_name in keys:
del keys[key_name]
_write_ai_keys(keys)
# Also clear from env at runtime so get_ai_key() no longer finds it
os.environ.pop(key_name, None)
logger.info(f"AI key deleted: {key_name}")
return {"status": "deleted", "key": key_name}
@router.get("/api/config/tool-keys", response_model=AIKeysResponse)
async def api_get_tool_keys(current_user=Depends(require_admin)):
"""Return tool/connected-source configuration (tokens masked, URLs clear)."""
masked = {}
for name in _TOOL_KEY_NAMES:
masked[name] = _mask_tool_value(name, _get_tool_key(name))
return masked
@router.post("/api/config/tool-keys", response_model=StatusResponse)
async def api_set_tool_keys(body: dict = Body(...), current_user=Depends(require_admin)):
"""Save tool/connected-source keys.
Only whitelisted names (``backend.tools.secrets.TOOL_KEY_NAMES``) are
accepted: Tavily/Brave/SerpAPI/Exa API keys, Gitea URL + token, GitHub
token. Empty values delete the stored entry.
"""
updated = []
for name, value in body.items():
if name not in _TOOL_KEY_NAMES:
raise HTTPException(status_code=400, detail=f"Clé inconnue: {name}")
if value is not None and not isinstance(value, str):
raise HTTPException(status_code=400, detail=f"Type invalide pour {name}")
_set_tool_key(name, value or "")
updated.append(name)
logger.info(f"Tool keys updated: {updated}")
return {"status": "ok"}
@router.delete("/api/config/tool-keys/{name}", response_model=AIKeyDeleteResponse)
async def api_delete_tool_key(name: str, current_user=Depends(require_admin)):
"""Delete a stored tool key (the environment fallback still applies)."""
key_name = name.upper()
try:
existed = _delete_tool_key(key_name)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
logger.info(f"Tool key deleted: {key_name} (existed={existed})")
return {"status": "deleted", "key": key_name}
@router.post("/api/config/ai-keys/test", response_model=AITestResponse)
async def api_test_ai_keys(current_user=Depends(require_admin)):
"""Test which AI providers are configured.
Each provider has a dedicated (URL, header-name) test pair.
- Most OpenAI-compatible APIs use `Authorization: Bearer KEY`
- Xiaomi MiMo uses `api-key: KEY`
- Gemini uses a query-string key
"""
results = {}
for key_name, label, test_url_tmpl, header_name in [
# OpenAI-compatible — Authorization: Bearer
("DEEPSEEK_API_KEY", "deepseek", "https://api.deepseek.com/v1/models", "Authorization"),
("OPENROUTER_API_KEY","openrouter", "https://openrouter.ai/api/v1/models", "Authorization"),
("NVIDIA_API_KEY", "nvidia", "https://integrate.api.nvidia.com/v1/models", "Authorization"),
("QWENCLOUD_API_KEY", "qwencloud", "https://dashscope.aliyuncs.com/compatible-mode/v1/models", "Authorization"),
("MISTRAL_API_KEY", "mistral", "https://api.mistral.ai/v1/models", "Authorization"),
# Xiaomi MiMo — dedicated api-key header (NOT Authorization: Bearer)
("XIAOMI_API_KEY", "xiaomi", "https://api.xiaomimimo.com/v1/models", "api-key"),
# Gemini — key in query string
("GEMINI_API_KEY", "gemini", "https://generativelanguage.googleapis.com/v1beta/models?key={key}", None),
]:
key = get_ai_key(key_name)
if not key:
results[label] = "non configuré"
continue
try:
url = test_url_tmpl.replace("{key}", key) if "{key}" in test_url_tmpl else test_url_tmpl
if header_name:
req = urllib.request.Request(url, headers={header_name: key})
else:
req = urllib.request.Request(url)
urllib.request.urlopen(req, timeout=5)
results[label] = "ok"
except Exception as e:
# Truncate the error to keep the response small.
results[label] = "erreur: " + str(e)[:80]
return results
@router.get("/api/config/ai-models", response_model=AIModelsResponse)
async def api_list_ai_models(provider: str = Query(...), current_user=Depends(require_admin)):
"""List available models for a given AI provider.
Strategy:
1. Try the provider's public models endpoint (OpenAI-compatible /v1/models or Gemini).
2. If the network call fails (timeout, 4xx, 5xx, DNS, etc.), fall back to a
curated static list of known-good models for that provider.
3. Always return a non-empty list when the provider is known, so the UI
dropdown is never empty.
"""
provider = provider.lower()
from backend.model_capabilities import get_capabilities_for_models
from backend.provider_capabilities import remember_declared_capabilities
all_providers = ("deepseek", "openrouter", "gemini", "nvidia", "qwencloud", "xiaomi", "mistral")
if provider not in all_providers:
return {"models": [], "error": f"Unknown provider: {provider}", "source": "validation"}
key_name = f"{provider.upper()}_API_KEY"
key = get_ai_key(key_name)
if not key:
# No key configured — return curated fallback list so the UI can
# still show what WOULD be available once a key is set.
fallback = _FALLBACK_MODELS.get(provider, [])
return {"models": fallback, "source": "fallback",
"capabilities": get_capabilities_for_models(provider, fallback),
"note": "API key not configured — showing default model list"}
# Build URL
if provider == "gemini":
url = f"https://generativelanguage.googleapis.com/v1beta/models?key={key}"
elif provider == "deepseek":
url = "https://api.deepseek.com/v1/models"
elif provider == "openrouter":
url = "https://openrouter.ai/api/v1/models"
elif provider == "nvidia":
url = "https://integrate.api.nvidia.com/v1/models"
elif provider == "qwencloud":
url = "https://dashscope.aliyuncs.com/compatible-mode/v1/models"
elif provider == "xiaomi":
# Xiaomi MiMo — dedicated api-key header (NOT Authorization: Bearer).
# Endpoint: https://api.xiaomimimo.com/v1/models
url = "https://api.xiaomimimo.com/v1/models"
models = [] # parsed below with the custom header
elif provider == "mistral":
url = "https://api.mistral.ai/v1/models"
try:
if provider == "gemini":
req = urllib.request.Request(url)
elif provider == "xiaomi":
# Xiaomi MiMo uses a dedicated api-key header.
req = urllib.request.Request(url, headers={"api-key": key})
else:
req = urllib.request.Request(url, headers={"Authorization": "Bearer " + key})
with urllib.request.urlopen(req, timeout=10) as resp:
data = _json.loads(resp.read().decode())
if provider == "gemini":
models = [m.get("name", "") for m in data.get("models", []) if m.get("name")]
# Gemini returns names like "models/gemini-1.5-flash" — strip prefix
models = [m.replace("models/", "") for m in models]
else:
models = [m.get("id", "") for m in data.get("data", []) if m.get("id")]
# Cache the capabilities the provider declares for these models
# (BUG-044) — get_capabilities_for_models() below then returns the
# provider's own truth for the flags it declares, the curated table
# for the rest. Providers that declare nothing are left untouched.
remember_declared_capabilities(provider, data)
if models:
# Prepend the configured default if not already present
default = PROVIDERS.get(provider, {}).get("model")
if default and default not in models:
models = [default] + models
return {"models": models, "source": "live", "count": len(models),
"capabilities": get_capabilities_for_models(provider, models)}
# Empty list from API — fall through to fallback
raise ValueError("empty model list from provider API")
except Exception as e:
# Network error, auth error, parsing error — use curated fallback
fallback = _FALLBACK_MODELS.get(provider, [])
return {"models": fallback, "source": "fallback", "error": str(e)[:200],
"capabilities": get_capabilities_for_models(provider, fallback),
"note": "Could not reach provider API — showing default model list"}
# ── Curated fallback model lists ──────────────────────────────────────────
# Used when the provider API is unreachable or returns empty.
# Keep these short and focused on models known to work with the
# OpenAI-compatible chat completions interface (or Gemini's generateContent).
_FALLBACK_MODELS: dict[str, list[str]] = {
"deepseek": [
"deepseek-chat",
"deepseek-reasoner",
],
"openrouter": [
"openai/gpt-4o-mini",
"openai/gpt-4o",
"anthropic/claude-3.5-sonnet",
"anthropic/claude-3-haiku",
"google/gemini-2.0-flash-exp:free",
"meta-llama/llama-3.1-70b-instruct",
"meta-llama/llama-3.1-8b-instruct:free",
"mistralai/mistral-large-latest",
],
"gemini": [
"gemini-2.0-flash",
"gemini-2.0-flash-exp",
"gemini-1.5-pro",
"gemini-1.5-flash",
"gemini-1.5-flash-8b",
],
"nvidia": [
"meta/llama-3.1-405b-instruct",
"meta/llama-3.1-70b-instruct",
"meta/llama-3.1-8b-instruct",
"mistralai/mistral-large",
"google/gemma-2-27b-it",
"nvidia/llama-3.1-nemotron-70b-instruct",
],
"qwencloud": [
"qwen-max",
"qwen-plus",
"qwen-turbo",
"qwen-long",
"qwen-vl-max",
"qwen-vl-plus",
],
"xiaomi": [
# Xiaomi MiMo models — the public /v1/models endpoint requires the
# `api-key` custom header (NOT Authorization: Bearer), so the live
# call often fails with 401 even with the right key. We ship a
# known-good list as fallback. See https://mimo.mi.com/docs/
"mimo-v2.5-pro",
"mimo-v2.5",
"mimo-v2.5-asr",
"mimo-v2.5-tts",
"mimo-v2.5-tts-voiceclone",
"mimo-v2.5-tts-voicedesign",
],
"mistral": [
"mistral-large-latest",
"mistral-medium-latest",
"mistral-small-latest",
"open-mistral-7b",
"open-mixtral-8x7b",
"codestral-latest",
],
}
@router.get("/api/diagnostics", response_model=DiagnosticsResponse)
async def api_diagnostics(current_user=Depends(require_admin)):
"""Return index statistics and system diagnostics.
Includes document counts, token counts, memory estimates,
and inverted index status.
"""
import sys
from backend.search import get_inverted_index
inv = get_inverted_index()
# Per-vault stats
vault_stats = {}
total_files = 0
total_tags = 0
# Snapshot both dicts first: the indexer mutates them from background
# threads, and iterating a live dict raises "dictionary changed size".
for vname, vdata in list(index.items()):
file_count = len(vdata.get("files", []))
tag_count = len(vdata.get("tags", {}))
vault_stats[vname] = {"file_count": file_count, "tag_count": tag_count}
total_files += file_count
total_tags += tag_count
# Memory estimate for inverted index
word_index = inv.word_index.copy()
word_index_entries = sum(len(docs) for docs in word_index.values())
mem_estimate_mb = round(
(sys.getsizeof(inv.word_index) + word_index_entries * 80
+ len(inv.doc_info) * 200
+ len(inv._sorted_tokens) * 60) / (1024 * 1024), 2
)
return {
"index": {
"total_files": total_files,
"total_tags": total_tags,
"vaults": vault_stats,
},
"inverted_index": {
"unique_tokens": len(word_index),
"total_postings": word_index_entries,
"documents": inv.doc_count,
"sorted_tokens": len(inv._sorted_tokens),
"is_stale": inv.is_stale(),
"memory_estimate_mb": mem_estimate_mb,
},
"config": _load_config(),
"search_executor": {
"active": get_search_executor() is not None,
"max_workers": get_search_executor()._max_workers if get_search_executor() else 0,
},
}
@router.get("/api/dashboard", response_model=DashboardResponse)
async def api_dashboard(current_user=Depends(require_auth)):
"""Aggregated dashboard statistics across all accessible vaults."""
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
vault_stats = []
total_files = 0
total_tags = set()
total_size = 0
total_images = 0
for vname, vdata in index.items():
if "*" not in user_vaults and vname not in user_vaults:
continue
files = vdata.get("files", [])
fc = len(files)
total_files += fc
vtags = set()
vsize = 0
vimages = 0
for f in files:
vtags.update(f.get("tags", []))
vsize += f.get("size", 0)
if (f.get("extension") or "").lower() in IMAGE_EXTENSIONS:
vimages += 1
total_tags.update(vtags)
total_size += vsize
total_images += vimages
vault_stats.append({
"name": vname, "file_count": fc, "tag_count": len(vtags),
"total_size_bytes": vsize, "image_count": vimages,
})
return {
"vaults": vault_stats,
"total_files": total_files,
"total_tags": len(total_tags),
"total_size_bytes": total_size,
"total_images": total_images,
}
+73
View File
@@ -0,0 +1,73 @@
"""Syncthing conflict endpoints (ROADMAP #85, tranche 8).
Handlers déplacés depuis :mod:`backend.main` sans changement de
comportement : mêmes chemins (``/api/conflicts*``), mêmes modèles de
réponse, mêmes dépendances d'authentification.
Adaptations strictement équivalentes :
- ``_resolve_safe_path`` / ``_backup_file`` → :mod:`backend.services.paths`
et :mod:`backend.services.backups` (pass-through).
"""
import logging
import shutil
from pathlib import Path
from fastapi import APIRouter, Body, Depends, HTTPException
from backend.audit import log_file_delete
from backend.auth.middleware import check_vault_access, require_auth
from backend.indexer import get_conflicts, get_vault_data, remove_single_file
from backend.schemas import ConflictResolveResponse, ConflictsResponse
from backend.services.backups import create_backup
from backend.services.paths import resolve_safe_path
from backend.sse import sse_manager
logger = logging.getLogger("obsigate")
router = APIRouter(tags=["conflicts"])
@router.get("/api/conflicts", response_model=ConflictsResponse)
async def api_conflicts(current_user=Depends(require_auth)):
"""List sync-conflict files across accessible vaults."""
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
all_conflicts = get_conflicts()
if "*" not in user_vaults:
all_conflicts = [c for c in all_conflicts if c["vault"] in user_vaults]
return {"conflicts": all_conflicts, "total": len(all_conflicts)}
@router.post("/api/conflicts/resolve", response_model=ConflictResolveResponse)
async def api_conflict_resolve(body: dict = Body(...), current_user=Depends(require_auth)):
"""Resolve a conflict: keep_local (delete conflict file) or keep_conflict (replace original)."""
vault_name = body.get("vault")
conflict_path = body.get("conflict_path")
original_path = body.get("original_path")
action = body.get("action") # "keep_local" or "keep_conflict"
# mypy: narrow down from dict values
assert isinstance(vault_name, str), "'vault' is required and must be a string"
assert isinstance(conflict_path, str), "'conflict_path' is required and must be a string"
assert isinstance(original_path, str), "'original_path' is required and must be a string"
if not check_vault_access(vault_name, current_user):
raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(404, "Vault not found")
vault_root = Path(vault_data["path"])
conf_file = resolve_safe_path(vault_root, conflict_path)
orig_file = resolve_safe_path(vault_root, original_path)
if not conf_file.exists():
raise HTTPException(404, "Conflict file not found")
try:
if action == "keep_conflict":
create_backup(orig_file, vault_name, original_path)
shutil.copy2(conf_file, orig_file)
logger.info(f"Conflict resolved (keep_conflict): {conflict_path} → {original_path}")
conf_file.unlink()
await remove_single_file(vault_name, conflict_path)
log_file_delete(current_user["username"], vault_name, conflict_path)
await sse_manager.broadcast("file_deleted", {"vault": vault_name, "path": conflict_path})
return {"status": "resolved", "action": action}
except Exception as e:
raise HTTPException(500, f"Error resolving conflict: {e!s}")
+569
View File
@@ -0,0 +1,569 @@
"""Media, PDF, export & vault-settings endpoints (ROADMAP #85, tranche 6c).
Handlers déplacés depuis :mod:`backend.main` sans changement de
comportement : mêmes chemins (``/api/file/*/pdf*``, ``/api/export/*``,
``/api/guide/download``, ``/api/image/*``, ``/api/media*``,
``/api/attachments/*``, ``/api/vaults/*/settings``, ``/api/vault/*/files``,
``/api/vaults/settings/all``), mêmes modèles de réponse, 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é).
- ``_resolve_export_target`` / ``_safe_export_name`` (export uniquement)
sont définis ici ; ``stream_file_with_range`` vit dans
:mod:`backend.routers.helpers` (partagé).
"""
import asyncio
import logging
from pathlib import Path
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request
from fastapi.responses import FileResponse, Response
from backend.attachment_indexer import get_attachment_stats, rescan_vault_attachments
from backend.auth.middleware import check_vault_access, require_admin, require_auth
from backend.export import ExportError, export_epub, export_html, export_md_bundle
from backend.history import record_open
from backend.indexer import get_vault_data, index, parse_markdown_file
from backend.media_thumbs import generate_thumbnail, is_decodable
from backend.media_types import is_audio, is_image, is_video, media_mime_type
from backend.routers.helpers import media_max_inline_bytes, stream_file_with_range
from backend.schemas import (
AllVaultSettingsResponse,
AttachmentRescanResponse,
AttachmentStatsResponse,
PdfInfoResponse,
VaultFilesResponse,
VaultSettingsResponse,
)
from backend.secret_redactor import redact_file_content
from backend.services.paths import resolve_safe_path
from backend.services.vaults import list_all_files
from backend.vault_settings import get_vault_setting, update_vault_setting
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() # pas de tags : assignation par chemin via openapi_docs.tag_for_path (comme avant)
def _resolve_export_target(vault_name: str, path: str, current_user: dict) -> tuple[Path, Path]:
"""Resolve a vault + relative path into (vault_root, absolute file path).
Enforces auth (vault access) and path traversal protection.
"""
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"])
target = resolve_safe_path(vault_root, path)
return vault_root, target
def _safe_export_name(name: str) -> str:
"""ASCII-safe, filename-safe download name (falls back to 'document')."""
cleaned = "".join(c for c in name if c.isascii() and (c.isalnum() or c in " _-.")).strip()
return cleaned or "document"
@router.get(
"/api/file/{vault_name}/pdf",
response_class=Response,
responses={200: {"content": {"application/pdf": {}}, "description": "PDF document"}},
)
async def api_file_pdf(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
"""Download a markdown file as PDF."""
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)")
if not check_vault_access(vault_name, current_user):
raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(404, 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():
raise HTTPException(404, f"File not found: {path}")
try:
raw = file_path.read_text(encoding="utf-8", errors="replace")
except Exception:
raise HTTPException(500, "Cannot read file")
record_open(current_user.get("username"), vault_name, path)
raw = redact_file_content(raw, str(file_path))
post = parse_markdown_file(raw)
html = _render_markdown(post.content, vault_name, file_path)
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(
"/api/export/html",
response_class=Response,
responses={200: {"content": {"text/html": {}}, "description": "Standalone HTML file"}},
)
async def api_export_html(
vault: str = Query(..., description="Vault name"),
path: str = Query(..., description="Relative path to file"),
current_user=Depends(require_auth),
):
"""Export a markdown note as a standalone HTML file."""
try:
vault_root, target = _resolve_export_target(vault, path, current_user)
html_bytes = export_html(vault_root, target)
except ExportError as e:
raise HTTPException(status_code=400, detail=str(e))
record_open(current_user.get("username"), vault, path)
safe_name = _safe_export_name(target.stem)
return Response(
content=html_bytes,
media_type="text/html; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{safe_name}.html"'},
)
@router.get(
"/api/export/md-bundle",
response_class=Response,
responses={200: {"content": {"application/zip": {}}, "description": "Markdown ZIP bundle"}},
)
async def api_export_md_bundle(
vault: str = Query(..., description="Vault name"),
path: str = Query(..., description="Relative path to directory or file"),
current_user=Depends(require_auth),
):
"""Export a directory (or single file) of markdown as a ZIP bundle."""
try:
vault_root, target = _resolve_export_target(vault, path, current_user)
zip_bytes = export_md_bundle(vault_root, target)
except ExportError as e:
raise HTTPException(status_code=400, detail=str(e))
safe_name = _safe_export_name(target.name)
return Response(
content=zip_bytes,
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{safe_name}.zip"'},
)
@router.get(
"/api/export/epub",
response_class=Response,
responses={200: {"content": {"application/epub+zip": {}}, "description": "ePub document"}},
)
async def api_export_epub(
vault: str = Query(..., description="Vault name"),
path: str = Query(..., description="Relative path to file"),
current_user=Depends(require_auth),
):
"""Export a markdown note as an ePub document."""
try:
vault_root, target = _resolve_export_target(vault, path, current_user)
epub_bytes = export_epub(vault_root, target)
except ExportError as e:
raise HTTPException(status_code=400, detail=str(e))
record_open(current_user.get("username"), vault, path)
safe_name = _safe_export_name(target.stem)
return Response(
content=epub_bytes,
media_type="application/epub+zip",
headers={"Content-Disposition": f'attachment; filename="{safe_name}.epub"'},
)
@router.get(
"/api/guide/download",
response_class=Response,
responses={200: {"content": {"application/pdf": {}, "text/markdown": {}}}},
)
async def api_guide_download(
format: str = Query("md", description="Download format: 'md' or 'pdf'"),
lang: str = Query("fr", description="Guide language: 'fr' or 'en'"),
current_user=Depends(require_auth),
):
"""Download the in-app user guide as Markdown or PDF (#105).
The document is generated from the live help modal in index.html resolved
through the locale files, so it always mirrors exactly what the user sees.
"""
from backend.guide_export import get_guide_document
if format not in ("md", "pdf"):
raise HTTPException(status_code=400, detail="format doit être 'md' ou 'pdf'")
try:
payload, media, fname = get_guide_document(format, lang)
except Exception as e: # weasyprint/reportlab unavailable
logger.exception("guide export failed")
raise HTTPException(status_code=500, detail=f"Export impossible: {e}") from e
return Response(
content=payload,
media_type=media,
headers={"Content-Disposition": f'attachment; filename="{fname}"'},
)
@router.get("/api/file/{vault_name}/pdf/stream", response_class=FileResponse)
async def api_pdf_stream(
request: Request,
vault_name: str,
path: str = Query(...),
current_user=Depends(require_auth),
):
"""Stream a PDF file with Content-Type: application/pdf for inline browser viewing.
Supports HTTP Range requests (206 Partial Content) so browsers can
progressively render large PDFs in the native viewer.
"""
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}")
if file_path.suffix.lower() != ".pdf":
raise HTTPException(status_code=400, detail="Not a PDF file")
return stream_file_with_range(file_path, request, "application/pdf")
@router.get("/api/file/{vault_name}/pdf/info", response_model=PdfInfoResponse)
async def api_pdf_info(
vault_name: str,
path: str = Query(..., description="Relative path to PDF file"),
current_user=Depends(require_auth),
):
"""Return PDF metadata (pages, title, author, size) without the document content.
Lets the UI display file info before loading a heavy PDF into the viewer.
"""
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}")
if file_path.suffix.lower() != ".pdf":
raise HTTPException(status_code=400, detail="Not a PDF file")
from backend.pdf_reader import extract_pdf_metadata
meta = extract_pdf_metadata(file_path)
stat = file_path.stat()
return {
"vault": vault_name,
"path": path,
"pages": meta.get("pages", 0),
"title": meta.get("title") or file_path.name,
"author": meta.get("author", ""),
"size_bytes": stat.st_size,
}
@router.get(
"/api/image/{vault_name}",
response_class=Response,
responses={200: {"content": {"application/octet-stream": {}}, "description": "Image bytes"}},
)
async def api_image(vault_name: str, path: str = Query(..., description="Relative path to image"), current_user=Depends(require_auth)):
"""Serve an image file with proper MIME type.
Args:
vault_name: Name of the vault.
path: Relative file path within the vault.
Returns:
Image file with appropriate content-type header.
"""
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"Image not found: {path}")
mime_type = media_mime_type(str(file_path))
# #108-B3 — a standalone SVG opened in a tab executes its embedded JS
# (same-origin XSS). ``sandbox`` forces a unique opaque origin with no
# script execution; inside an <img> tag the header is irrelevant.
headers = {"X-Content-Type-Options": "nosniff"}
if file_path.suffix.lower() == ".svg":
headers["Content-Security-Policy"] = "sandbox"
try:
# Read and return the image file
content = file_path.read_bytes()
return Response(content=content, media_type=mime_type, headers=headers)
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied")
except Exception as e:
logger.error(f"Error serving image {vault_name}/{path}: {e}")
raise HTTPException(status_code=500, detail=f"Error serving image: {e!s}")
@router.get("/api/media/{vault_name}", response_class=FileResponse)
async def api_media_stream(
request: Request,
vault_name: str,
path: str = Query(..., description="Relative path to audio/video file"),
current_user=Depends(require_auth),
):
"""Stream an audio/video file with HTTP Range support (roadmap #109-A2).
Serves the bytes with the correct MIME type and honours ``Range`` requests
(``206 Partial Content`` + ``Content-Range``/``Accept-Ranges``), which is
what enables scrubbing in ``<audio>``/``<video>`` and is required by Safari
for MP4. Files above ``OBSIGATE_MEDIA_MAX_INLINE_MB`` (default 500 MB) are
refused with ``413`` — the viewer falls back to the download button.
"""
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"Media not found: {path}")
ext = file_path.suffix.lower()
if not (is_audio(ext) or is_video(ext)):
raise HTTPException(status_code=400, detail="Not an audio/video file")
if file_path.stat().st_size > media_max_inline_bytes():
raise HTTPException(status_code=413, detail="Media too large for inline streaming")
return stream_file_with_range(file_path, request, media_mime_type(str(file_path)))
@router.get("/api/media/{vault_name}/thumb", response_class=FileResponse)
async def api_media_thumb(
vault_name: str,
path: str = Query(..., description="Relative path to image"),
size: int = Query(256, ge=32, le=1024, description="Max thumbnail edge in pixels"),
current_user=Depends(require_auth),
):
"""Serve a cached WebP thumbnail of an image (roadmap #108-C).
SVG (and any format Pillow cannot decode) falls back to the original
bytes. Generation runs in a thread and is capped at 2 s; on timeout or
failure the original is served so the UI never breaks.
"""
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"Image not found: {path}")
if not is_image(file_path.suffix.lower()):
raise HTTPException(status_code=400, detail="Not an image file")
mime_type = media_mime_type(str(file_path))
if not is_decodable(file_path):
# SVG: never let a standalone navigation execute embedded JS (#108-B3).
svg_headers = {"X-Content-Type-Options": "nosniff"}
if file_path.suffix.lower() == ".svg":
svg_headers["Content-Security-Policy"] = "sandbox"
return FileResponse(str(file_path), media_type=mime_type, headers=svg_headers)
loop = asyncio.get_running_loop()
thumb: Path | None = None
try:
thumb = await asyncio.wait_for(
loop.run_in_executor(None, generate_thumbnail, file_path, size),
timeout=2.0,
)
except Exception:
thumb = None
if thumb is not None and thumb.exists():
return FileResponse(str(thumb), media_type="image/webp")
return FileResponse(str(file_path), media_type=mime_type)
@router.post("/api/attachments/rescan/{vault_name}", response_model=AttachmentRescanResponse)
async def api_rescan_attachments(vault_name: str, current_user=Depends(require_admin)):
"""Rescan attachments for a specific vault.
Args:
vault_name: Name of the vault to rescan.
Returns:
Dict with status and attachment count.
"""
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_path = vault_data["path"]
count = await rescan_vault_attachments(vault_name, vault_path)
logger.info(f"Rescanned attachments for vault '{vault_name}': {count} attachments")
return {"status": "ok", "vault": vault_name, "attachment_count": count}
@router.get("/api/attachments/stats", response_model=AttachmentStatsResponse)
async def api_attachment_stats(vault: str | None = Query(None, description="Vault filter"), current_user=Depends(require_auth)):
"""Get attachment statistics for vaults.
Args:
vault: Optional vault name to filter stats.
Returns:
Dict with vault names as keys and attachment counts as values.
"""
stats = get_attachment_stats(vault)
return {"vaults": stats}
@router.get("/api/vaults/{vault_name}/settings", response_model=VaultSettingsResponse)
async def api_get_vault_settings(vault_name: str, current_user=Depends(require_auth)):
"""Get UI display settings for a specific vault.
Args:
vault_name: Name of the vault.
Returns:
Dict with vault settings including hideHiddenFiles.
"""
if vault_name not in index:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
# Get persisted settings
persisted = get_vault_setting(vault_name) or {}
# Default settings
settings = {
"hideHiddenFiles": False,
}
settings.update(persisted)
return settings
@router.post("/api/vaults/{vault_name}/settings", response_model=VaultSettingsResponse)
async def api_update_vault_settings(vault_name: str, body: dict = Body(...), current_user=Depends(require_admin)):
"""Update UI display settings for a specific vault.
Args:
vault_name: Name of the vault.
body: Dict with settings to update (hideHiddenFiles).
Returns:
Updated settings dict.
"""
if vault_name not in index:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
# Validate settings
settings_to_update = {}
if "hideHiddenFiles" in body:
if not isinstance(body["hideHiddenFiles"], bool):
raise HTTPException(status_code=400, detail="hideHiddenFiles must be a boolean")
settings_to_update["hideHiddenFiles"] = body["hideHiddenFiles"]
# Update persisted settings
try:
updated = update_vault_setting(vault_name, settings_to_update)
except PermissionError as e:
logger.error(f"Permission error saving settings for vault '{vault_name}': {e}")
raise HTTPException(
status_code=500,
detail="Permission denied: Cannot write to settings file. Check /app/data permissions."
)
except Exception as e:
logger.error(f"Error saving settings for vault '{vault_name}': {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to save settings: {e!s}"
)
logger.info(f"Updated settings for vault '{vault_name}': {settings_to_update}")
return updated
@router.get("/api/vault/{vault_name}/files", response_model=VaultFilesResponse)
async def api_vault_recent_files(
vault_name: str,
dir: str = Query("", description="Directory path within the vault (empty = root)"),
limit: int = Query(200, description="Maximum number of files to return"),
recursive: bool = Query(True, description="If true, list files recursively from directory and all subdirectories"),
current_user=Depends(require_auth),
):
"""List files in a vault directory sorted by modification time (newest first).
Returns file metadata suitable for a vault home page display.
Unlike /api/browse, this endpoint sorts by mtime and returns
additional metadata (size, modified time, extension).
When recursive=True (default), lists files from the directory
AND all its subdirectories, with a ``rel_dir`` field indicating
the subdirectory path relative to the requested directory.
Args:
vault_name: Name of the vault.
dir: Relative directory path within the vault (empty for root).
limit: Maximum files to return (default 200).
recursive: If true, recursively list files in subdirectories (default true).
Returns:
JSON with vault, directory, count, recursive flag, and list of file entries.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
return list_all_files(vault_name, dir=dir, limit=limit, recursive=recursive)
@router.get("/api/vaults/settings/all", response_model=AllVaultSettingsResponse)
async def api_get_all_vault_settings(current_user=Depends(require_auth)):
"""Get UI display settings for all vaults.
Returns:
Dict mapping vault names to their settings.
"""
all_settings = {}
for vault_name in index:
persisted = get_vault_setting(vault_name) or {}
settings = {
"hideHiddenFiles": False,
}
settings.update(persisted)
all_settings[vault_name] = settings
return all_settings
+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}
+78
View File
@@ -10,9 +10,14 @@ de comportement. Regroupées ici car utilisées par plusieurs routers
from __future__ import annotations
import asyncio
import os
import re
from pathlib import Path
from fastapi import HTTPException, Request
from fastapi.responses import FileResponse, StreamingResponse
def content_disposition(disposition: str, filename: str) -> str:
"""Build a header-safe Content-Disposition value.
@@ -49,3 +54,76 @@ def media_max_inline_bytes() -> int:
if mb <= 0:
return default_mb * 1024 * 1024
return int(mb * 1024 * 1024)
def stream_file_with_range(file_path: Path, request: Request, media_type: str):
"""Return a file response honouring the HTTP ``Range`` header (roadmap #109).
Extrait de :mod:`backend.main` (``_stream_file_with_range``) sans
changement de comportement. Shared by ``pdf/stream`` and ``/api/media``:
a plain :class:`FileResponse` with ``Accept-Ranges: bytes`` when no range
is requested, or a :class:`StreamingResponse` (206 Partial Content,
64 KiB chunks) for a valid single range. An unsatisfiable range yields
``416`` with a ``Content-Range: bytes */size`` header.
Reads are offloaded to threads so the event loop is never blocked
(ASYNC230), matching the previous inline implementation.
"""
file_size = file_path.stat().st_size
range_header = request.headers.get("range")
disposition = content_disposition("inline", file_path.name)
if range_header:
# Parse "bytes=start-end" (single range only; multi-range is not used by viewers)
m = re.match(r"bytes=(\d*)-(\d*)", range_header)
if not m:
raise HTTPException(status_code=416,
headers={"Content-Range": f"bytes */{file_size}"})
start_s, end_s = m.group(1), m.group(2)
if start_s == "" and end_s == "":
raise HTTPException(status_code=416,
headers={"Content-Range": f"bytes */{file_size}"})
if start_s == "":
# suffix range: last N bytes
length = min(int(end_s), file_size)
start = file_size - length
end = file_size - 1
else:
start = int(start_s)
end = int(end_s) if end_s else file_size - 1
end = min(end, file_size - 1)
if start > end or start >= file_size:
raise HTTPException(status_code=416,
headers={"Content-Range": f"bytes */{file_size}"})
chunk_size = end - start + 1
async def _partial():
f = await asyncio.to_thread(open, str(file_path), "rb")
try:
await asyncio.to_thread(f.seek, start)
remaining = chunk_size
while remaining > 0:
data = await asyncio.to_thread(f.read, min(64 * 1024, remaining))
if not data:
break
remaining -= len(data)
yield data
finally:
await asyncio.to_thread(f.close)
return StreamingResponse(
_partial(),
status_code=206,
media_type=media_type,
headers={
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Accept-Ranges": "bytes",
"Content-Length": str(chunk_size),
"Content-Disposition": disposition,
},
)
return FileResponse(str(file_path), media_type=media_type, headers={
"Accept-Ranges": "bytes",
"Content-Disposition": disposition})
+160
View File
@@ -0,0 +1,160 @@
"""History endpoints — recent, bookmarks, saved searches (ROADMAP #85, tranche 8).
Handlers déplacés depuis :mod:`backend.main` sans changement de
comportement : mêmes chemins, mêmes modèles (``BookmarkToggleRequest``
déménagé dans :mod:`backend.schemas`), mêmes dépendances
d'authentification.
Adaptations strictement équivalentes :
- ``_resolve_safe_path`` / ``_backup_file`` → :mod:`backend.services.paths`
et :mod:`backend.services.backups` (pass-through).
- ``_load_config`` vient de :mod:`backend.routers.config`.
"""
import logging
from pathlib import Path
import frontmatter
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from backend.auth.middleware import check_vault_access, require_auth
from backend.history import get_bookmarks, toggle_bookmark
from backend.indexer import find_file_in_index, get_vault_data, update_single_file
from backend.routers.config import _load_config
from backend.saved_searches import delete_saved, get_saved, save_search
from backend.schemas import (
BookmarksResponse,
BookmarkToggleRequest,
BookmarkToggleResponse,
RecentResponse,
SavedSearch,
StatusResponse,
)
from backend.services.backups import create_backup
from backend.services.paths import resolve_safe_path
from backend.services.recent import humanize_mtime, list_recent
logger = logging.getLogger("obsigate")
router = APIRouter(tags=["Bookmarks"])
@router.get("/api/recent", response_model=RecentResponse)
async def api_recent(limit: int | None = Query(None), vault: str | None = Query(None), mode: str | None = Query("opened"), current_user=Depends(require_auth)):
config = _load_config()
actual_limit = limit if limit is not None else config.get("recent_files_limit", 20)
username = current_user.get("username")
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
return list_recent(
username,
user_vaults,
vault=vault,
limit=actual_limit,
mode=mode or "opened",
)
@router.get("/api/bookmarks", response_model=BookmarksResponse)
async def api_bookmarks(vault: str | None = Query(None), current_user=Depends(require_auth)):
username = current_user.get("username")
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
if not username:
return {"files": []}
history = get_bookmarks(username, vault_filter=vault)
files_resp = []
for item in history:
v_name = item["vault"]
if "*" not in user_vaults and v_name not in user_vaults:
continue
# Find in index to get metadata
f_idx = find_file_in_index(item["path"], v_name)
if f_idx:
files_resp.append({
"path": f_idx["path"],
"title": f_idx.get("title") or item["path"].split("/")[-1],
"vault": v_name,
"mtime": item["bookmarked_at"],
"mtime_human": humanize_mtime(item["bookmarked_at"]),
"size_bytes": f_idx.get("size", 0),
"tags": [f"#{t}" for t in f_idx.get("tags", [])][:5],
"bookmarked": True
})
else:
files_resp.append({
"path": item["path"],
"title": item.get("title") or item["path"].split("/")[-1],
"vault": v_name,
"mtime": item["bookmarked_at"],
"mtime_human": humanize_mtime(item["bookmarked_at"]),
"tags": [],
"bookmarked": True
})
return {
"files": files_resp,
"total": len(files_resp)
}
@router.post("/api/bookmarks/toggle", response_model=BookmarkToggleResponse)
async def api_toggle_bookmark(req: BookmarkToggleRequest, current_user=Depends(require_auth)):
username = current_user.get("username")
if not username:
raise HTTPException(status_code=401, detail="Not authenticated")
# Check vault access
if not check_vault_access(req.vault, current_user):
raise HTTPException(status_code=403, detail="Access denied to vault")
is_now_bookmarked = toggle_bookmark(username, req.vault, req.path, req.title or "")
# Update the file's YAML frontmatter: favoris: true/false
vault_data = get_vault_data(req.vault)
if vault_data:
file_path = resolve_safe_path(Path(vault_data["path"]), req.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 is_now_bookmarked:
post.metadata["favoris"] = True
elif "favoris" in post.metadata:
del post.metadata["favoris"]
new_raw = frontmatter.dumps(post)
create_backup(file_path, req.vault, req.path)
file_path.write_text(new_raw, encoding="utf-8")
await update_single_file(req.vault, str(file_path))
except Exception as e:
logger.warning(f"Failed to update favoris metadata on {req.vault}/{req.path}: {e}")
return {"bookmarked": is_now_bookmarked}
@router.get("/api/saved-searches", response_model=list[SavedSearch])
async def api_saved_searches(current_user=Depends(require_auth)):
username = current_user.get("username")
if not username:
raise HTTPException(401)
return get_saved(username)
@router.post("/api/saved-searches", response_model=SavedSearch)
async def api_save_search(body: dict = Body(...), current_user=Depends(require_auth)):
username = current_user.get("username")
if not username:
raise HTTPException(401)
return save_search(username, body)
@router.delete("/api/saved-searches/{search_id}", response_model=StatusResponse)
async def api_delete_saved_search(search_id: str, current_user=Depends(require_auth)):
username = current_user.get("username")
if not username:
raise HTTPException(401)
if not delete_saved(username, search_id):
raise HTTPException(404, "Not found")
return {"status": "deleted"}
+107
View File
@@ -0,0 +1,107 @@
"""Vault management endpoints (ROADMAP #85, tranche 8).
Handlers déplacés depuis :mod:`backend.main` sans changement de
comportement : mêmes chemins (``/api/vaults*``), mêmes modèles de réponse
(``VaultInfo`` déménagé dans :mod:`backend.schemas`), mêmes dépendances
d'authentification.
Le handle du file-watcher vit désormais dans :mod:`backend.watcher_state`
(partagé avec le lifespan de ``main``) au lieu du global de ``main``.
"""
from pathlib import Path
from fastapi import APIRouter, Body, Depends, HTTPException
from backend.auth.middleware import require_admin, require_auth
from backend.indexer import add_vault_to_index, index, remove_vault_from_index
from backend.schemas import VaultActionResponse, VaultInfo, VaultsStatusResponse, VaultStatsResponse
from backend.services.vaults import list_accessible_vaults
from backend.sse import sse_manager
from backend.watcher_state import get_watcher
router = APIRouter(tags=["vaults"])
@router.get("/api/vaults", response_model=list[VaultInfo])
async def api_vaults(current_user=Depends(require_auth)):
"""List configured vaults the user has access to.
Returns:
List of vault summary objects filtered by user permissions.
"""
return list_accessible_vaults(current_user)
@router.post("/api/vaults/add", response_model=VaultStatsResponse)
async def api_add_vault(body: dict = Body(...), current_user=Depends(require_admin)):
"""Add a new vault dynamically without restarting.
Body:
name: Display name for the vault.
path: Absolute filesystem path to the vault directory.
"""
name = body.get("name", "").strip()
vault_path = body.get("path", "").strip()
if not name or not vault_path:
raise HTTPException(status_code=400, detail="Both 'name' and 'path' are required")
if name in index:
raise HTTPException(status_code=409, detail=f"Vault '{name}' already exists")
if not Path(vault_path).exists():
raise HTTPException(status_code=400, detail=f"Path does not exist: {vault_path}")
stats = await add_vault_to_index(name, vault_path)
# Start watching the new vault
watcher = get_watcher()
if watcher:
await watcher.add_vault(name, vault_path)
await sse_manager.broadcast("vault_added", {"vault": name, "stats": stats})
return {"status": "ok", "vault": name, "stats": stats}
@router.delete("/api/vaults/{vault_name}", response_model=VaultActionResponse)
async def api_remove_vault(vault_name: str, current_user=Depends(require_admin)):
"""Remove a vault from the index and stop watching it.
Args:
vault_name: Name of the vault to remove.
"""
if vault_name not in index:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
# Stop watching
watcher = get_watcher()
if watcher:
await watcher.remove_vault(vault_name)
await remove_vault_from_index(vault_name)
await sse_manager.broadcast("vault_removed", {"vault": vault_name})
return {"status": "ok", "vault": vault_name}
@router.get("/api/vaults/status", response_model=VaultsStatusResponse)
async def api_vaults_status(current_user=Depends(require_auth)):
"""Detailed status of all vaults including watcher state.
Returns per-vault: file count, tag count, watching status, vault path.
"""
watcher = get_watcher()
statuses = {}
for vname, vdata in index.items():
watching = watcher is not None and vname in watcher.observers
statuses[vname] = {
"file_count": len(vdata.get("files", [])),
"tag_count": len(vdata.get("tags", {})),
"path": vdata.get("path", ""),
"watching": watching,
}
return {
"vaults": statuses,
"watcher_active": watcher is not None,
"sse_clients": sse_manager.client_count,
}
+152
View File
@@ -281,6 +281,158 @@ class FileRawResponse(BaseModel):
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'")
# ---------------------------------------------------------------------------
# Vaults & history (#85 — extrait de backend.main, inchangé)
# ---------------------------------------------------------------------------
class VaultInfo(BaseModel):
"""Summary information about a configured vault."""
name: str = Field(description="Display name of the vault")
file_count: int = Field(description="Number of indexed files")
tag_count: int = Field(description="Number of unique tags")
type: str = Field(default="VAULT", description="Type of the vault mapping (VAULT or DIR)")
class BookmarkToggleRequest(BaseModel):
"""Request to toggle a bookmark on a file."""
vault: str
path: str
title: str | None = None
# ---------------------------------------------------------------------------
# Search / suggest / graph (#85 — extrait de backend.main, inchangé)
# ---------------------------------------------------------------------------
+1 -1
View File
@@ -31,7 +31,7 @@ DEFAULT_MAX_BACKUPS = 10
def _default_max_backups() -> int:
"""Read ``max_backups_per_file`` from app config (lazy, best-effort)."""
try:
from backend.main import _load_config
from backend.routers.config import _load_config # ROADMAP #85 T7 — déménagé depuis backend.main
return int(_load_config().get("max_backups_per_file", DEFAULT_MAX_BACKUPS))
except Exception: # pragma: no cover - config unavailable
+28
View File
@@ -0,0 +1,28 @@
"""Shared VaultWatcher handle (ROADMAP #85, tranche 8).
Holder extrait de :mod:`backend.main` sans changement de comportement : le
lifespan de ``main`` y dépose l'instance (``set_watcher``) et l'y reprend à
l'extinction ; le router ``vaults`` la consulte via :func:`get_watcher`
(démarrage/arrêt de surveillance à l'ajout/retrait dynamique de vault,
état dans ``/api/vaults/status``).
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from backend.watcher import VaultWatcher
_watcher: VaultWatcher | None = None
def get_watcher() -> VaultWatcher | None:
"""Return the shared VaultWatcher instance (``None`` if disabled)."""
return _watcher
def set_watcher(watcher: VaultWatcher | None) -> None:
"""Store (or clear) the shared VaultWatcher instance."""
global _watcher
_watcher = watcher
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.27.7"
version = "2.27.11"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.27.7"
version = "2.27.11"
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.7",
"version": "2.27.11",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+3 -3
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.27.7 | **Dernière mise à jour :** 2026-09-26
> **Version :** 2.27.11 | **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`). **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`).
- **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`). **T6c livrée (v2.27.9) :** media/pdf/export/guide (file/pdf, exports ×3, guide, pdf/stream|info, image, media+thumb, attachments ×2, vault settings ×3, vault files → `backend/routers/files_media.py`, Range helper → `helpers.py`). **T7 livrée (v2.27.10) :** config (app/ai-keys/tool-keys/ai-models/diagnostics/dashboard → `backend/routers/config.py`, `_FALLBACK_MODELS` + clés déplacés, `test_ai_models` réaligné). **T8 livrée (v2.27.11) :** vaults + history + conflicts (→ `backend/routers/vaults.py|history.py|conflicts.py`, `VaultInfo`/`BookmarkToggleRequest` → `schemas.py`, watcher → `backend/watcher_state.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`), `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`) ; `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`), `files-media` ✅ (T6c, `backend/routers/files_media.py`), `config` ✅ (T7, `backend/routers/config.py`), `vaults|history|conflicts` ✅ (T8, 3 routers + `watcher_state.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.7",
"version": "2.27.11",
"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": {
+4 -2
View File
@@ -22,6 +22,7 @@ const viewer = readFileSync(path.join(ROOT, "frontend", "js", "viewer.js"), "utf
const utils = readFileSync(path.join(ROOT, "frontend", "js", "utils.js"), "utf8");
const css = readFileSync(path.join(ROOT, "frontend", "style.css"), "utf8");
const main = readFileSync(path.join(ROOT, "backend", "main.py"), "utf8");
const filesRead = readFileSync(path.join(ROOT, "backend", "routers", "files_read.py"), "utf8");
function test(label, fn) {
try {
@@ -186,8 +187,9 @@ test("utils.js maps image extensions to the Lucide 'image' icon", () => {
});
test("backend api_file_view points <img> at /api/image", () => {
assert.match(main, /img_url = f"\/api\/image\//);
assert.match(main, /f'<img src="\{img_url\}"/);
// #85 T6a : le handler vit dans backend/routers/files_read.py
assert.match(filesRead, /img_url = f"\/api\/image\//);
assert.match(filesRead, /f'<img src="\{img_url\}"/);
});
if (process.exitCode) {
+8 -3
View File
@@ -24,6 +24,9 @@ const utils = readFileSync(path.join(ROOT, "frontend", "js", "utils.js"), "utf8"
const css = readFileSync(path.join(ROOT, "frontend", "style.css"), "utf8");
const sw = readFileSync(path.join(ROOT, "frontend", "sw.js"), "utf8");
const main = readFileSync(path.join(ROOT, "backend", "main.py"), "utf8");
const filesMedia = readFileSync(path.join(ROOT, "backend", "routers", "files_media.py"), "utf8");
const filesRead = readFileSync(path.join(ROOT, "backend", "routers", "files_read.py"), "utf8");
const routerHelpers = readFileSync(path.join(ROOT, "backend", "routers", "helpers.py"), "utf8");
const fr = readFileSync(path.join(ROOT, "frontend", "locales", "fr.json"), "utf8");
const en = readFileSync(path.join(ROOT, "frontend", "locales", "en.json"), "utf8");
@@ -177,9 +180,11 @@ test("service worker never caches streamed media", () => {
// ── Static checks: backend ─────────────────────────────────────────────────
test("backend exposes /api/media and the shared Range helper", () => {
assert.match(main, /@app\.get\("\/api\/media\/\{vault_name\}"/);
assert.match(main, /def _stream_file_with_range\(/);
assert.match(main, /is_audio\(ext\) or is_video\(ext\)/);
// #85 T6c : routes dans backend/routers/files_media.py, helper Range partagé
// dans backend/routers/helpers.py, branche audio/vidéo dans files_read.py
assert.match(filesMedia, /@router\.get\("\/api\/media\/\{vault_name\}"/);
assert.match(routerHelpers, /def stream_file_with_range\(/);
assert.match(filesRead, /is_audio\(ext\) or is_video\(ext\)/);
});
if (process.exitCode) {
+14 -3
View File
@@ -12,7 +12,8 @@ from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from backend.main import _FALLBACK_MODELS, app
from backend.main import app
from backend.routers.config import _FALLBACK_MODELS # ROADMAP #85 T7 — déménagé depuis backend.main
#: Trimmed-down copy of what api.mistral.ai/v1/models really returns (BUG-044).
MISTRAL_LIVE_PAYLOAD = {
@@ -28,11 +29,12 @@ MISTRAL_LIVE_PAYLOAD = {
@pytest.fixture
def admin_client(tmp_path):
"""Minimal admin client for the /api/config/ai-models endpoint."""
from backend.auth.password import hash_password
import json
import os
from pathlib import Path
from backend.auth.password import hash_password
data_dir = tmp_path / "data"
data_dir.mkdir()
users = {
@@ -69,8 +71,9 @@ def admin_client(tmp_path):
import backend.main
backend.main._load_config = lambda: {"watcher_enabled": False}
from backend.indexer import build_index, index
import asyncio
from backend.indexer import build_index, index
for key in list(index.keys()):
del index[key]
loop = asyncio.new_event_loop()
@@ -232,9 +235,11 @@ class TestListModelsEndpoint:
# in backend.main (which does `from backend.ai import ... get_ai_key`).
import backend.ai as aimod
import backend.main as bmain
import backend.routers.config as rconfig
monkeypatch.setattr(aimod, "get_ai_key", lambda name: "fake-xiaomi-key")
if hasattr(bmain, "get_ai_key"):
monkeypatch.setattr(bmain, "get_ai_key", lambda name: "fake-xiaomi-key")
monkeypatch.setattr(rconfig, "get_ai_key", lambda name: "fake-xiaomi-key")
captured = {}
@@ -316,9 +321,11 @@ class TestListModelsEndpoint:
# Patch BOTH the source module AND the imported reference in backend.main
import backend.ai as aimod
import backend.main as bmain
import backend.routers.config as rconfig
monkeypatch.setattr(aimod, "get_ai_key", lambda name: "fake-key")
if hasattr(bmain, "get_ai_key"):
monkeypatch.setattr(bmain, "get_ai_key", lambda name: "fake-key")
monkeypatch.setattr(rconfig, "get_ai_key", lambda name: "fake-key")
import urllib.request as global_urllib_mod
captured_urls = []
@@ -393,10 +400,12 @@ class TestDeclaredCapabilities:
"""main.py binds get_ai_key at import: patch that binding too."""
import backend.ai as aimod
import backend.main as bmain
import backend.routers.config as rconfig
monkeypatch.setattr(aimod, "get_ai_key", lambda name: "fake-mistral-key")
if hasattr(bmain, "get_ai_key"):
monkeypatch.setattr(bmain, "get_ai_key", lambda name: "fake-mistral-key")
monkeypatch.setattr(rconfig, "get_ai_key", lambda name: "fake-mistral-key")
def test_declared_vision_reaches_both_endpoints(self, admin_client, monkeypatch):
self._fake_key(monkeypatch)
@@ -450,10 +459,12 @@ class TestDeclaredCapabilities:
"""No API key: the curated list must still answer correctly (offline)."""
import backend.ai as aimod
import backend.main as bmain
import backend.routers.config as rconfig
monkeypatch.setattr(aimod, "get_ai_key", lambda name: None)
if hasattr(bmain, "get_ai_key"):
monkeypatch.setattr(bmain, "get_ai_key", lambda name: None)
monkeypatch.setattr(rconfig, "get_ai_key", lambda name: None)
token = _login_admin(admin_client)
resp = admin_client.get(
+5 -5
View File
@@ -589,30 +589,30 @@ class TestHumanizeMtime:
pass
def test_humanize_mtime_now(self):
from backend.main import humanize_mtime
from backend.services.recent import humanize_mtime # ROADMAP #85 T8 — ne plus passer par backend.main
import time
assert "instant" in humanize_mtime(time.time())
def test_humanize_mtime_minutes(self):
from backend.main import humanize_mtime
from backend.services.recent import humanize_mtime # ROADMAP #85 T8 — ne plus passer par backend.main
import time
result = humanize_mtime(time.time() - 120)
assert "min" in result
def test_humanize_mtime_hours(self):
from backend.main import humanize_mtime
from backend.services.recent import humanize_mtime # ROADMAP #85 T8 — ne plus passer par backend.main
import time
result = humanize_mtime(time.time() - 7200)
assert "h" in result or "jour" in result
def test_humanize_mtime_days(self):
from backend.main import humanize_mtime
from backend.services.recent import humanize_mtime # ROADMAP #85 T8 — ne plus passer par backend.main
import time
result = humanize_mtime(time.time() - 172800) # 2 days
assert "j" in result
def test_humanize_mtime_old(self):
from backend.main import humanize_mtime
from backend.services.recent import humanize_mtime # ROADMAP #85 T8 — ne plus passer par backend.main
import time
result = humanize_mtime(time.time() - 86400 * 30)
assert "202" in result or result # Should show formatted date