feat: finalisation éditeur Forge + mode hors-ligne PWA + consolidation roadmap
CI / lint (push) Failing after 7s
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / security (push) Failing after -7s

- Forge: Tab indent, Ctrl+K, auto-focus, AI status, messages erreur FR
- Forge: slash commands clean, cache-bust iframe, preview serveur
- Forge: iframe 82vh, hauteur auto, table inline, sélecteur langage
- PWA hors-ligne: IndexedDB (offline-db.js + offline.js)
- Mermaid: thème dark complet, highlight.js preview
- Backend: màj CSP, rename chain, bookmarks/history/shares
- Roadmap: consolidation dans docs/ROADMAP.md (75 items)
- Infra: docker-compose.podman.yml, suppression test-vault
- .gitignore: exclusion odysseus/
This commit is contained in:
2026-06-14 20:27:37 -04:00
parent 0cdebc5b40
commit feeb46b873
31 changed files with 1769 additions and 981 deletions
+118 -4
View File
@@ -9,6 +9,7 @@ import secrets
import shutil
import string
import time
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager
from datetime import datetime, timezone
@@ -50,7 +51,7 @@ from backend.vault_settings import (
get_vault_setting,
update_vault_setting,
)
from backend.history import record_open, get_recent_opened, remove_recent, toggle_bookmark, get_bookmarks, is_bookmarked
from backend.history import record_open, get_recent_opened, remove_recent, toggle_bookmark, get_bookmarks, is_bookmarked, update_bookmarks_after_rename, update_history_after_rename
logging.basicConfig(
level=logging.INFO,
@@ -545,9 +546,9 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net; "
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com; "
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.jsdelivr.net; "
"img-src 'self' data: blob:; "
"connect-src 'self' https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com; "
"connect-src 'self' https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net; "
"font-src 'self' https://fonts.gstatic.com;"
)
# Cache static assets aggressively (fingerprinted by PWA manifest hash)
@@ -654,7 +655,7 @@ except OSError:
build_pdf_html = None # type: ignore[assignment]
import logging
logging.getLogger("obsigate").warning("PDF export unavailable (WeasyPrint/GTK not found)")
from backend.share import create_share, get_share_by_token, record_access, revoke_share, list_shares # noqa: E402
from backend.share import create_share, get_share_by_token, record_access, revoke_share, list_shares, update_shares_after_rename # noqa: E402
from backend.webhooks import get_webhooks, create_webhook, update_webhook, delete_webhook, dispatch_webhooks # noqa: E402
from backend.saved_searches import get_saved, save_search, delete_saved # noqa: E402
from backend.ai_routes import router as ai_router # noqa: E402
@@ -1814,6 +1815,11 @@ async def api_file_rename(
# 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,
@@ -3602,6 +3608,114 @@ async def api_set_config(body: dict = Body(...), current_user=Depends(require_ad
return current
# ---------------------------------------------------------------------------
# AI API Keys — stored in data/api_keys.json, fallback to .env
# ---------------------------------------------------------------------------
from backend.ai import get_ai_key, _read_ai_keys # noqa: E402
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)
@app.get("/api/config/ai-keys")
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"]:
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
@app.post("/api/config/ai-keys")
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"]:
if k in body and body[k]:
keys[k] = body[k]
_write_ai_keys(keys)
logger.info("AI keys updated")
return {"status": "ok"}
@app.post("/api/config/ai-keys/test")
async def api_test_ai_keys(current_user=Depends(require_admin)):
"""Test which AI providers are configured."""
results = {}
for key_name, label, test_url, test_header in [
("DEEPSEEK_API_KEY", "deepseek", "https://api.deepseek.com/v1/models", "Authorization"),
("OPENROUTER_API_KEY", "openrouter", "https://openrouter.ai/api/v1/models", "Authorization"),
("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:
import urllib.request
if test_header:
req = urllib.request.Request(test_url, headers={test_header: "Bearer " + key})
else:
req = urllib.request.Request(test_url.replace("{key}", key))
urllib.request.urlopen(req, timeout=5)
results[label] = "ok"
except Exception as e:
results[label] = "erreur: " + str(e)[:80]
return results
# ---------------------------------------------------------------------------
# AI Models — list available models per provider
# ---------------------------------------------------------------------------
@app.get("/api/config/ai-models")
async def api_list_ai_models(provider: str = Query(...), current_user=Depends(require_admin)):
"""List available models for a given AI provider."""
provider = provider.lower()
if provider not in ("deepseek", "openrouter", "gemini"):
return {"models": [], "error": f"Unknown provider: {provider}"}
key_name = f"{provider.upper()}_API_KEY"
key = get_ai_key(key_name)
if not key:
return {"models": [], "error": "API key not configured"}
# Build URL and request
if provider == "gemini":
url = f"https://generativelanguage.googleapis.com/v1beta/models?key={key}"
elif provider == "deepseek":
url = "https://api.deepseek.com/v1/models"
else: # openrouter
url = "https://openrouter.ai/api/v1/models"
try:
if provider == "gemini":
req = urllib.request.Request(url)
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", [])]
else:
models = [m.get("id", "") for m in data.get("data", [])]
return {"models": models}
except Exception as e:
return {"models": [], "error": str(e)}
# ---------------------------------------------------------------------------
# Diagnostics API
# ---------------------------------------------------------------------------