diff --git a/backend/indexer.py b/backend/indexer.py index d783d43..de24d76 100644 --- a/backend/indexer.py +++ b/backend/indexer.py @@ -937,3 +937,29 @@ def get_backlinks(vault_name: str, file_path: str) -> List[Dict[str, str]]: bl = vindex.get(target_key, []) results.extend(bl) return results + + +def get_conflicts() -> list: + """Scan all vaults for Syncthing/Nextcloud sync-conflict files. + + Returns: + List of conflict dicts with vault, conflict_path, original_path, + conflict_date, and conflict_title. + """ + import re + conflicts = [] + pattern = re.compile(r'\.sync-conflict-(\d{8}-\d{6})\.') + for vname, vdata in index.items(): + for f in vdata.get("files", []): + m = pattern.search(f["path"]) + if m: + orig_path = pattern.sub("", f["path"]) + conflicts.append({ + "vault": vname, + "conflict_path": f["path"], + "original_path": orig_path, + "conflict_date": m.group(1), + "conflict_title": f.get("title", ""), + "conflict_size": f.get("size", 0), + }) + return conflicts diff --git a/backend/main.py b/backend/main.py index b0190c4..679bee5 100644 --- a/backend/main.py +++ b/backend/main.py @@ -34,6 +34,7 @@ from backend.indexer import ( get_vault_names, find_file_in_index, get_backlinks, + get_conflicts, parse_markdown_file, _extract_tags, SUPPORTED_EXTENSIONS, @@ -75,12 +76,12 @@ class VaultInfo(BaseModel): class BrowseItem(BaseModel): """A single entry (file or directory) returned by the browse endpoint.""" - name: str - path: str + name: str = Field(description="File or directory name") + path: str = Field(description="Relative path within vault") type: str = Field(description="'file' or 'directory'") - children_count: Optional[int] = None - size: Optional[int] = None - extension: Optional[str] = None + children_count: Optional[int] = Field(default=None, description="Number of children (directories only)") + size: Optional[int] = Field(default=None, description="File size in bytes") + extension: Optional[str] = Field(default=None, description="File extension") class BrowseResponse(BaseModel): @@ -92,95 +93,95 @@ class BrowseResponse(BaseModel): class FileContentResponse(BaseModel): """Rendered file content with metadata.""" - vault: str - path: str - title: str - tags: List[str] - frontmatter: Dict[str, Any] - html: str - raw_length: int - extension: str - is_markdown: bool - unsupported: Optional[bool] = False - size_bytes: Optional[int] = None + vault: str = Field(description="Vault name") + path: str = Field(description="Relative file path within the vault") + title: str = Field(description="File title (from frontmatter or filename)") + tags: List[str] = Field(description="Extracted tags from frontmatter and inline #tags") + frontmatter: Dict[str, Any] = Field(description="YAML frontmatter as key-value dict") + html: str = Field(description="Rendered HTML content") + raw_length: int = Field(description="Length of raw file content in characters") + extension: str = Field(description="File extension (e.g. .md, .txt)") + is_markdown: bool = Field(description="Whether the file is markdown") + unsupported: Optional[bool] = Field(default=False, description="True for binary/unsupported files") + size_bytes: Optional[int] = Field(default=None, description="File size in bytes (for unsupported files)") class FileRawResponse(BaseModel): """Raw text content of a file.""" - vault: str - path: str - raw: str + vault: str = Field(description="Vault name") + path: str = Field(description="Relative file path within the vault") + raw: str = Field(description="Raw file content as text") class FileSaveResponse(BaseModel): """Confirmation after saving a file.""" - status: str - vault: str - path: str - size: int + 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 - vault: str - path: str + status: str = Field(description="Always 'ok'") + vault: str = Field(description="Vault name") + path: str = Field(description="Relative file path within the vault") class SearchResultItem(BaseModel): """A single search result.""" - vault: str - path: str - title: str - tags: List[str] - score: int - snippet: str - modified: str + vault: str = Field(description="Vault name") + path: str = Field(description="Relative file path") + title: str = Field(description="File title") + tags: List[str] = Field(description="File tags") + score: int = Field(description="Relevance score") + snippet: str = Field(description="Content excerpt with highlights") + modified: str = Field(description="ISO 8601 modification timestamp") class SearchResponse(BaseModel): """Full-text search response with optional pagination.""" - query: str - vault_filter: str - tag_filter: Optional[str] - count: int - total: int = Field(0, description="Total results before pagination") - offset: int = Field(0, description="Current pagination offset") - limit: int = Field(200, description="Page size") - results: List[SearchResultItem] + query: str = Field(description="Original search query") + vault_filter: str = Field(description="Vault filter applied ('all' or vault name)") + tag_filter: Optional[str] = Field(default=None, description="Tag filter applied") + count: int = Field(description="Number of results in this response") + total: int = Field(default=0, description="Total results before pagination") + offset: int = Field(default=0, description="Current pagination offset") + limit: int = Field(default=200, description="Page size") + results: List[SearchResultItem] = Field(description="Search result items") class TagsResponse(BaseModel): """Tag aggregation response.""" - vault_filter: Optional[str] - tags: Dict[str, int] + vault_filter: Optional[str] = Field(default=None, description="Vault filter applied") + tags: Dict[str, int] = Field(description="Tag name → count mapping") class TreeSearchResult(BaseModel): """A single tree search result item.""" - vault: str - path: str - name: str + vault: str = Field(description="Vault name") + path: str = Field(description="Full relative path") + name: str = Field(description="File or directory name") type: str = Field(description="'file' or 'directory'") - matched_path: str + matched_path: str = Field(description="Path segment that matched the query") class TreeSearchResponse(BaseModel): """Tree search response with matching paths.""" - query: str - vault_filter: str - results: List[TreeSearchResult] + query: str = Field(description="Search query") + vault_filter: str = Field(description="Vault filter applied") + results: List[TreeSearchResult] = Field(description="Matching files and directories") class AdvancedSearchResultItem(BaseModel): """A single advanced search result with highlighted snippet.""" - vault: str - path: str - title: str - tags: List[str] - score: float - snippet: str - modified: str + vault: str = Field(description="Vault name") + path: str = Field(description="Relative file path") + title: str = Field(description="File title") + tags: List[str] = Field(description="File tags") + score: float = Field(description="TF-IDF relevance score") + snippet: str = Field(description="Content excerpt with highlights") + modified: str = Field(description="ISO 8601 modification timestamp") class SearchFacets(BaseModel): @@ -191,37 +192,37 @@ class SearchFacets(BaseModel): class AdvancedSearchResponse(BaseModel): """Advanced search response with TF-IDF scoring, facets, and pagination.""" - results: List[AdvancedSearchResultItem] - total: int - offset: int - limit: int - facets: SearchFacets - query_time_ms: float = Field(0, description="Server-side query time in milliseconds") + results: List[AdvancedSearchResultItem] = Field(description="Search results") + total: int = Field(description="Total number of matching results") + offset: int = Field(description="Current pagination offset") + limit: int = Field(description="Page size") + facets: SearchFacets = Field(description="Faceted counts by tag and vault") + query_time_ms: float = Field(default=0, description="Server-side query time in milliseconds") class TitleSuggestion(BaseModel): """A file title suggestion for autocomplete.""" - vault: str - path: str - title: str + vault: str = Field(description="Vault name") + path: str = Field(description="Relative file path") + title: str = Field(description="File title") class SuggestResponse(BaseModel): """Autocomplete suggestions for file titles.""" - query: str - suggestions: List[TitleSuggestion] + query: str = Field(description="Original query string") + suggestions: List[TitleSuggestion] = Field(description="Matching file suggestions") class TagSuggestion(BaseModel): """A tag suggestion for autocomplete.""" - tag: str - count: int + tag: str = Field(description="Tag name") + count: int = Field(description="Number of files with this tag") class TagSuggestResponse(BaseModel): """Autocomplete suggestions for tags.""" - query: str - suggestions: List[TagSuggestion] + query: str = Field(description="Original query string") + suggestions: List[TagSuggestion] = Field(description="Matching tag suggestions") class GraphNode(BaseModel): @@ -242,24 +243,24 @@ class GraphEdge(BaseModel): class GraphResponse(BaseModel): """Graph data for a vault or directory.""" - vault: str - path: str - nodes: List[GraphNode] - edges: List[GraphEdge] + vault: str = Field(description="Vault name") + path: str = Field(description="Root path for the graph") + nodes: List[GraphNode] = Field(description="Graph nodes (files and directories)") + edges: List[GraphEdge] = Field(description="Graph edges (parent and wikilink relations)") class ReloadResponse(BaseModel): """Index reload confirmation with per-vault stats.""" - status: str - vaults: Dict[str, Any] + status: str = Field(description="Reload status ('ok' or 'error')") + vaults: Dict[str, Any] = Field(description="Per-vault file counts after reload") class HealthResponse(BaseModel): """Application health status.""" - status: str - version: str - vaults: int - total_files: int + status: str = Field(description="Health status ('ok' or 'error')") + version: str = Field(description="Application version") + vaults: int = Field(description="Number of configured vaults") + total_files: int = Field(description="Total indexed files across all vaults") class DirectoryCreateRequest(BaseModel): @@ -269,8 +270,8 @@ class DirectoryCreateRequest(BaseModel): class DirectoryCreateResponse(BaseModel): """Response after creating a directory.""" - success: bool - path: str + success: bool = Field(description="Whether creation succeeded") + path: str = Field(description="Path of the created directory") class DirectoryRenameRequest(BaseModel): @@ -281,15 +282,15 @@ class DirectoryRenameRequest(BaseModel): class DirectoryRenameResponse(BaseModel): """Response after renaming a directory.""" - success: bool - old_path: str - new_path: str + 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 - deleted_count: int + success: bool = Field(description="Whether deletion succeeded") + deleted_count: int = Field(description="Number of files recursively deleted") class FileCreateRequest(BaseModel): @@ -300,8 +301,8 @@ class FileCreateRequest(BaseModel): class FileCreateResponse(BaseModel): """Response after creating a file.""" - success: bool - path: str + success: bool = Field(description="Whether creation succeeded") + path: str = Field(description="Path of the created file") class FileRenameRequest(BaseModel): @@ -312,7 +313,7 @@ class FileRenameRequest(BaseModel): class FileRenameResponse(BaseModel): """Response after renaming a file.""" - success: bool + success: bool = Field(description="Whether rename succeeded") old_path: str new_path: str @@ -555,6 +556,8 @@ from backend.auth.router import router as auth_router from backend.auth.middleware import require_auth, require_admin, check_vault_access from backend.secret_redactor import redact_file_content from backend.audit import log_file_save, log_file_delete +from backend.share import create_share, get_share_by_token, record_access, revoke_share, list_shares +from backend.webhooks import get_webhooks, create_webhook, update_webhook, delete_webhook, dispatch_webhooks app.include_router(auth_router) @@ -1254,6 +1257,9 @@ async def api_file_delete(vault_name: str, path: str = Query(..., description="R "path": path, }) + # Dispatch webhooks + await dispatch_webhooks("file_deleted", {"vault": vault_name, "path": path}) + return {"status": "ok", "vault": vault_name, "path": path} except PermissionError: raise HTTPException(status_code=403, detail="Permission denied: vault may be read-only") @@ -1331,6 +1337,7 @@ async def api_directory_create( "vault": vault_name, "path": body.path, }) + await dispatch_webhooks("directory_created", {"vault": vault_name, "path": body.path}) return {"success": True, "path": body.path} except PermissionError: @@ -1403,6 +1410,7 @@ async def api_directory_rename( "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} except PermissionError: @@ -1464,6 +1472,7 @@ async def api_directory_delete( "path": path, "deleted_count": file_count, }) + await dispatch_webhooks("directory_deleted", {"vault": vault_name, "path": path}) return {"success": True, "deleted_count": file_count} except PermissionError: @@ -1533,6 +1542,7 @@ async def api_file_create( "vault": vault_name, "path": body.path, }) + await dispatch_webhooks("file_created", {"vault": vault_name, "path": body.path}) return {"success": True, "path": body.path} except PermissionError: @@ -1609,6 +1619,7 @@ async def api_file_rename( "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} except PermissionError: @@ -2547,6 +2558,181 @@ async def api_diagnostics(current_user=Depends(require_admin)): } +# --------------------------------------------------------------------------- +# Dashboard endpoint (aggregated stats) +# --------------------------------------------------------------------------- + +@app.get("/api/dashboard") +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 + 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 + for f in files: + vtags.update(f.get("tags", [])) + vsize += f.get("size", 0) + total_tags.update(vtags) + total_size += vsize + vault_stats.append({"name": vname, "file_count": fc, "tag_count": len(vtags), "total_size_bytes": vsize}) + return {"vaults": vault_stats, "total_files": total_files, "total_tags": len(total_tags), "total_size_bytes": total_size} + + +# --------------------------------------------------------------------------- +# Webhook CRUD endpoints +# --------------------------------------------------------------------------- + +@app.get("/api/webhooks") +async def api_webhooks_list(current_user=Depends(require_admin)): + return get_webhooks() + + +@app.post("/api/webhooks") +async def api_webhooks_create(body: dict = Body(...), current_user=Depends(require_admin)): + name = body.get("name", "Unnamed") + url = body.get("url", "") + events = body.get("events", []) + secret = body.get("secret") + if not url: + raise HTTPException(400, "URL is required") + return create_webhook(name, url, events, secret) + + +@app.patch("/api/webhooks/{webhook_id}") +async def api_webhooks_update(webhook_id: str, body: dict = Body(...), current_user=Depends(require_admin)): + result = update_webhook(webhook_id, body) + if not result: + raise HTTPException(404, "Webhook not found") + return result + + +@app.delete("/api/webhooks/{webhook_id}") +async def api_webhooks_delete(webhook_id: str, current_user=Depends(require_admin)): + if not delete_webhook(webhook_id): + raise HTTPException(404, "Webhook not found") + return {"status": "deleted"} + + +# --------------------------------------------------------------------------- +# Share (public document) endpoints +# --------------------------------------------------------------------------- + +@app.post("/api/share/{vault_name}") +async def api_share_create( + vault_name: str, + body: dict = Body(...), + current_user=Depends(require_auth), +): + """Create a public share link for a document.""" + if not check_vault_access(vault_name, current_user): + raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'") + path = body.get("path", "") + expires = body.get("expires_in_hours") + share = create_share(vault_name, path, current_user["username"], expires) + share["url"] = f"/s/{share['token']}" + return share + + +@app.get("/api/shares") +async def api_shares_list(vault: Optional[str] = Query(None), current_user=Depends(require_auth)): + """List all shares (optionally filtered by vault).""" + shares = list_shares(vault) + for s in shares: + s["url"] = f"/s/{s['token']}" + return shares + + +@app.delete("/api/share/{share_id}") +async def api_share_revoke(share_id: str, current_user=Depends(require_auth)): + if not revoke_share(share_id): + raise HTTPException(404, "Share not found") + return {"status": "revoked"} + + +@app.get("/s/{token}") +async def public_share_view(token: str): + """Public share view — no authentication required.""" + share = get_share_by_token(token) + if not share: + raise HTTPException(404, "Share not found or expired") + vault_data = get_vault_data(share["vault"]) + if not vault_data: + raise HTTPException(404, "Vault not found") + vault_root = Path(vault_data["path"]) + file_path = _resolve_safe_path(vault_root, share["path"]) + if not file_path.exists(): + raise HTTPException(404, "File not found") + try: + raw = file_path.read_text(encoding="utf-8", errors="replace") + except Exception: + raise HTTPException(500, "Cannot read file") + record_access(token) + raw = redact_file_content(raw, str(file_path)) + post = parse_markdown_file(raw) + html = _render_markdown(post.content, share["vault"], file_path) + title = post.metadata.get("title", file_path.stem) + return HTMLResponse(f""" +{title} — ObsiGate Share + +
📄 Document partagé via ObsiGate

{title}

{html}""") + + +# --------------------------------------------------------------------------- +# Syncthing conflict endpoints +# --------------------------------------------------------------------------- + +@app.get("/api/conflicts") +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)} + + +@app.post("/api/conflicts/resolve") +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" + 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": + _backup_file(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: {str(e)}") + + # --------------------------------------------------------------------------- # Static files & SPA fallback # --------------------------------------------------------------------------- diff --git a/backend/requirements.txt b/backend/requirements.txt index 5051cdd..8158463 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,6 +4,7 @@ python-frontmatter==1.1.0 mistune==3.0.2 python-multipart==0.0.9 aiofiles==23.2.1 +aiohttp>=3.9.0 watchdog>=4.0.0 argon2-cffi>=23.1.0 python-jose>=3.3.0 diff --git a/backend/share.py b/backend/share.py new file mode 100644 index 0000000..e5614dc --- /dev/null +++ b/backend/share.py @@ -0,0 +1,111 @@ +""" +Public document sharing for ObsiGate. + +Generates unique tokens for read-only public access to documents. +Shares are persisted in data/shares.json. Public URLs use /s/{token}. + +No authentication required for public share views. +""" + +import json +import secrets +from pathlib import Path +from datetime import datetime, timezone, timedelta +from typing import Optional, List +import logging + +logger = logging.getLogger("obsigate.share") + +SHARES_FILE = Path("data/shares.json") + + +def _read() -> dict: + if not SHARES_FILE.exists(): + return {"shares": {}} + try: + return json.loads(SHARES_FILE.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return {"shares": {}} + + +def _write(data: dict): + SHARES_FILE.parent.mkdir(parents=True, exist_ok=True) + tmp = SHARES_FILE.with_suffix(".tmp") + tmp.write_text(json.dumps(data, indent=2, default=str), encoding="utf-8") + tmp.replace(SHARES_FILE) + + +def create_share( + vault: str, + path: str, + created_by: str, + expires_in_hours: Optional[int] = None, +) -> dict: + """Create a new share token for a document.""" + data = _read() + token = secrets.token_hex(32) # 64-char hex token + + expires_at = None + if expires_in_hours: + expires_at = (datetime.now(timezone.utc) + timedelta(hours=expires_in_hours)).isoformat() + + share = { + "id": token, + "token": token, + "vault": vault, + "path": path, + "created_by": created_by, + "created_at": datetime.now(timezone.utc).isoformat(), + "expires_at": expires_at, + "access_count": 0, + "last_accessed": None, + } + data["shares"][token] = share + _write(data) + logger.info(f"Created share for {vault}/{path} by {created_by}") + return share + + +def get_share_by_token(token: str) -> Optional[dict]: + """Look up a share by token. Returns None if expired or not found.""" + data = _read() + share = data["shares"].get(token) + if not share: + return None + if share.get("expires_at"): + expires = datetime.fromisoformat(share["expires_at"]) + if datetime.now(timezone.utc) > expires: + return None + return share + + +def record_access(token: str): + """Increment access counter for a share.""" + data = _read() + share = data["shares"].get(token) + if share: + share["access_count"] = share.get("access_count", 0) + 1 + share["last_accessed"] = datetime.now(timezone.utc).isoformat() + _write(data) + + +def revoke_share(share_id: str) -> bool: + """Revoke (delete) a share by its token.""" + data = _read() + if share_id in data["shares"]: + del data["shares"][share_id] + _write(data) + logger.info(f"Revoked share {share_id}") + return True + return False + + +def list_shares(vault_filter: Optional[str] = None) -> list: + """List all shares, optionally filtered by vault.""" + data = _read() + shares = list(data["shares"].values()) + if vault_filter: + shares = [s for s in shares if s["vault"] == vault_filter] + # Most recent first + shares.sort(key=lambda s: s.get("created_at", ""), reverse=True) + return shares diff --git a/backend/webhooks.py b/backend/webhooks.py new file mode 100644 index 0000000..08d8c5d --- /dev/null +++ b/backend/webhooks.py @@ -0,0 +1,131 @@ +""" +Webhook management and dispatch for ObsiGate. + +Webhooks are HTTP POST callbacks triggered on file/directory events. +Configuration is persisted in data/webhooks.json. + +Events: file_created, file_deleted, file_modified, file_renamed, + directory_created, directory_deleted, directory_renamed +""" + +import json +import os +import hmac +import hashlib +import asyncio +import logging +import uuid +from pathlib import Path +from datetime import datetime, timezone +from typing import Optional, List + +import aiohttp +from datetime import datetime, timezone +from typing import Optional, List + +import aiohttp + +logger = logging.getLogger("obsigate.webhooks") + +WEBHOOKS_FILE = Path("data/webhooks.json") + +VALID_EVENTS = { + "file_created", "file_deleted", "file_modified", "file_renamed", + "directory_created", "directory_deleted", "directory_renamed", +} + + +def _read() -> list: + if not WEBHOOKS_FILE.exists(): + return [] + try: + return json.loads(WEBHOOKS_FILE.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return [] + + +def _write(webhooks: list): + WEBHOOKS_FILE.parent.mkdir(parents=True, exist_ok=True) + tmp = WEBHOOKS_FILE.with_suffix(".tmp") + tmp.write_text(json.dumps(webhooks, indent=2, default=str), encoding="utf-8") + tmp.replace(WEBHOOKS_FILE) + + +def get_webhooks() -> list: + return _read() + + +def create_webhook(name: str, url: str, events: List[str], secret: Optional[str] = None) -> dict: + webhooks = _read() + wh = { + "id": str(uuid.uuid4()), + "name": name, + "url": url, + "events": [e for e in events if e in VALID_EVENTS], + "secret": secret, + "enabled": True, + "created_at": datetime.now(timezone.utc).isoformat(), + "last_fired_at": None, + } + webhooks.append(wh) + _write(webhooks) + logger.info(f"Created webhook '{name}' → {url}") + return wh + + +def update_webhook(wh_id: str, updates: dict) -> Optional[dict]: + webhooks = _read() + for wh in webhooks: + if wh["id"] == wh_id: + wh.update({k: v for k, v in updates.items() if k != "id"}) + _write(webhooks) + return wh + return None + + +def delete_webhook(wh_id: str) -> bool: + webhooks = _read() + new_list = [wh for wh in webhooks if wh["id"] != wh_id] + if len(new_list) == len(webhooks): + return False + _write(new_list) + return True + + +async def dispatch_webhooks(event_type: str, data: dict): + """Fire all enabled webhooks subscribed to event_type.""" + webhooks = _read() + targets = [wh for wh in webhooks if wh.get("enabled", True) and event_type in wh.get("events", [])] + if not targets: + return + + payload = { + "event": event_type, + "timestamp": datetime.now(timezone.utc).isoformat(), + "data": data, + } + body = json.dumps(payload, default=str) + + async def _post(wh): + try: + headers = {"Content-Type": "application/json", "X-ObsiGate-Event": event_type} + if wh.get("secret"): + sig = hmac.new(wh["secret"].encode(), body.encode(), hashlib.sha256).hexdigest() + headers["X-ObsiGate-Signature"] = f"sha256={sig}" + + timeout = aiohttp.ClientTimeout(total=5) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post(wh["url"], data=body, headers=headers) as resp: + if resp.status < 400: + logger.debug(f"Webhook '{wh['name']}' OK ({resp.status})") + else: + logger.warning(f"Webhook '{wh['name']}' failed ({resp.status})") + update_webhook(wh["id"], {"last_fired_at": datetime.now(timezone.utc).isoformat()}) + except Exception as e: + logger.warning(f"Webhook '{wh['name']}' error: {e}") + + tasks = [asyncio.create_task(_post(wh)) for wh in targets] + # Don't await — fire and forget (webhooks should not block the main thread) + # But we do register them so they run in background + for task in tasks: + task.add_done_callback(lambda t: t.exception() if not t.cancelled() else None) diff --git a/context.md b/context.md index e283285..8251007 100644 --- a/context.md +++ b/context.md @@ -1,321 +1,312 @@ -# Code Context +# Code Context — ObsiGate Roadmap Implementation ## Files Retrieved -1. `C:/dev/git/python/ObsiGate/backend/main.py` (lines 1-2504) - Core API endpoints, markdown rendering, SSE -2. `C:/dev/git/python/ObsiGate/backend/auth/router.py` (full file, 263 lines) - Auth endpoints (login, logout, refresh, admin CRUD) -3. `C:/dev/git/python/ObsiGate/backend/auth/jwt_handler.py` (full file, 153 lines) - JWT token creation, validation, revocation -4. `C:/dev/git/python/ObsiGate/backend/indexer.py` (full file, ~728 lines) - File indexing, vault config, file lookup -5. `C:/dev/git/python/ObsiGate/backend/search.py` (full file, ~700 lines) - Full-text search, TF-IDF, suggestions -6. `C:/dev/git/python/ObsiGate/frontend/app.js` (lines 1-8046) - Frontend SPA (TOC, tabs, tree, search) -7. `C:/dev/git/python/ObsiGate/frontend/style.css` (lines 5379-5476) - Tab bar styles +### Backend +1. `backend/main.py` (2599 lines total) — main FastAPI app with all endpoints, Pydantic models, SSE manager +2. `backend/vault_settings.py` (138 lines) — per-vault settings persistence (hideHiddenFiles, etc.) + +### Frontend +3. `frontend/app.js` (~8187 lines) — vanilla JS SPA, all UI logic +4. `frontend/index.html` (1083 lines) — page structure with modals and dashboard --- -## 1. Backend: `main.py` +## 1. Pydantic Models Section — `backend/main.py` -### PUT endpoint for saving files +**Location: lines 56–284** — All Pydantic response/request models defined in a single block after imports and before SSE Manager. -**Location:** Line **717** (`@app.put("/api/file/{vault_name}/save", response_model=FileSaveResponse)`) -- Function: `api_file_save` (line **718**) -- Body expects `{"content": "..."}` -- Ends at line ~750 with return `{"status": "ok", "vault": vault_name, "path": path, "size": len(content)}` - -### DELETE endpoint for files - -**Location:** Line **753** (`@app.delete("/api/file/{vault_name}", response_model=FileDeleteResponse)`) -- Function: `api_file_delete` (line **754**) -- Path provided as query parameter: `path: str = Query(...)` -- Also calls `remove_single_file()` and broadcasts SSE `file_deleted` event -- Ends at line ~797 - -### `_heading_slugify` function - -**Location:** Lines **476–503** (inside the Markdown rendering helpers section) +### Key models with Field descriptions (lines 60–69): ```python -def _heading_slugify(text: str) -> str: +class VaultInfo(BaseModel): + 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 BrowseItem(BaseModel): # line 72 + name: str + path: str + type: str = Field(description="'file' or 'directory'") ``` -- Matches the JavaScript `slugify()` exactly: - 1. Lowercase - 2. NFD normalize + strip combining marks - 3. Keep only Unicode letters, numbers, spaces, hyphens - 4. Spaces → hyphens, collapse multiple hyphens - 5. Strip leading/trailing hyphens, fallback to `"heading"` -### `_add_heading_ids` function - -**Location:** Lines **506–527** -- Post-processes HTML to inject `id=""` attributes on `

`–`

` tags -- Handles duplicate slugs with `-2`, `-3` suffix - -### Health endpoint - -**Location:** Lines **562–571** (`@app.get("/api/health", response_model=HealthResponse)`) -- Returns `{ status, version, vaults, total_files }` -- No authentication required - -### Markdown rendering pipeline (wikilinks) - -- `_convert_wikilinks()`: lines **528–549** — converts `[[target]]` / `[[target|display]]` to clickable HTML anchors -- `_render_markdown()`: lines **552–577** — master renderer: preprocesses images → converts wikilinks → renders with mistune → adds heading IDs -- Wikilinks render as `` when resolved, `` otherwise +### Models WITHOUT Field descriptions (need updating): +- `FileContentResponse` — **lines 89–100** +- `FileRawResponse` — **lines 103–107** +- `FileSaveResponse` — **lines 110–115** +- `FileDeleteResponse` — **lines 118–122** +- `SearchResultItem` — **lines 125–133** +- `SearchResponse` — **lines 136–143** (has Field on `total`, `offset`, `limit`) +- `TagsResponse` — **lines 146–149** +- `TreeSearchResult` — **lines 152–158** +- `TreeSearchResponse` — **lines 161–165** +- `AdvancedSearchResultItem` — **lines 168–176** +- `SearchFacets` — **lines 179–182** +- `AdvancedSearchResponse` — **lines 185–193** (has Field on `query_time_ms`) +- `TitleSuggestion` — **lines 196–200** +- `SuggestResponse` — **lines 203–206** +- `TagSuggestion` — **lines 209–212** +- `TagSuggestResponse` — **lines 215–218** +- `GraphNode` — **lines 221–228** +- `GraphEdge` — **lines 231–236** +- `GraphResponse` — **lines 239–244** +- `ReloadResponse` — **lines 247–250** +- `HealthResponse` — **lines 253–257** +- `DirectoryCreateRequest` — **lines 260–262** (has Field) +- `DirectoryCreateResponse` — **lines 265–269** +- `DirectoryRenameRequest` — **lines 272–274** (has Field) +- `DirectoryRenameResponse` — **lines 277–281** +- `DirectoryDeleteResponse` — **lines 284–288** +- `FileCreateRequest` — **lines 291–293** (has Field) +- `FileCreateResponse` — **lines 296–299** +- `FileRenameRequest` — **lines 302–304** (has Field) +- `FileRenameResponse` — **lines 307–311** --- -## 2. Backend: `auth/router.py` +## 2. Dashboard/Stats Endpoint — `backend/main.py` -### Login endpoint - -**Location:** Line **97** (`@router.post("/login")`) -- Function: `login` (line **98**) -- Accepts `LoginRequest` with `username`, `password`, `remember_me` -- Rate limiting via lockout: - - `is_locked()` check at line **108** → returns 429 after too many failures - - `record_login_failure()` at line **112** → increments failure counter - - Lockout message: `"Compte temporairement verrouillé (15min)"` (line **109**) -- Success: calls `create_access_token()` + `create_refresh_token()`, sets cookies - -### Rate limiting - -**Location:** Lines **108–117** (inside `login` endpoint) -- **There is NO decorator-based or middleware rate limiting.** Rate limiting is manual, login-only: - - Checks `is_locked()` (line **108**) — if true, raises HTTP 429 - - Calls `record_login_failure()` (line **112**) on bad password - - Shows remaining attempts when <= 2 (line **114–115**) -- Implementation lives in `backend/auth/user_store.py`: - - `record_login_failure()` at line **142** - - `is_locked()` at line **167** -- No rate limiting on other endpoints (no slowapi, no middleware, no global limiter) - ---- - -## 3. Backend: `auth/jwt_handler.py` - -### JWT TTL / expiration settings - -**Location:** Lines **22–23** +### `/api/diagnostics` — **lines 2500–2547** ```python -ACCESS_TOKEN_EXPIRE_SECONDS = 3600 # 1 hour -REFRESH_TOKEN_EXPIRE_SECONDS = 604800 # 7 days +@app.get("/api/diagnostics") # line 2500 +async def api_diagnostics(current_user=Depends(require_admin)): ``` -- Algorithm: `HS256` (line **20**) -- Secret key auto-generated to `data/secret.key` on first run (line **29**) -- Refresh cookie max_age in router.py: 30 days if `remember_me`, else 7 days (line **131**) +Returns index stats, inverted index stats, config, and search executor info. Requires admin auth. -### `create_access_token` function - -**Location:** Lines **48–60** +### `/api/health` — **lines 1048–1059** ```python -def create_access_token(user: dict) -> str: +@app.get("/api/health", response_model=HealthResponse) # line 1048 +async def api_health(): ``` -- Payload: `{ sub, role, vaults, jti, iat, exp, type: "access" }` -- Encoded with `jwt.encode()` using HS256 and the secret key from `get_secret_key()` - -### `create_refresh_token` function - -**Location:** Lines **63–73** -- Returns `(token_string, jti)` tuple -- Payload: `{ sub, jti, iat, exp, type: "refresh" }` -- Uses `REFRESH_TOKEN_EXPIRE_SECONDS` +Public health check (no auth). Returns status, version, vaults count, total_files. --- -## 4. Backend: `indexer.py` +## 3. SSE File Event Broadcasting — `backend/main.py` -### IGNORED_DIRS or similar +**SSE Manager class: lines 349–381** — `SSEManager` with `connect()`, `disconnect()`, and `broadcast()` methods. -**There is NO `IGNORED_DIRS` constant.** The indexer indexes **everything** including hidden files (starting with `.`). This is stated explicitly in the docstring at line **206**: -> "All files and directories are indexed, including hidden files (starting with '.')." +**All `sse_manager.broadcast()` calls:** -Hidden-file filtering is handled at the **UI/browse level** via vault settings (`hideHiddenFiles`) in `main.py` and `vault_settings.py`. +| Line | Event Type | Context | +|------|-----------|---------| +| 413 | `index_updated` | File watcher callback (`_on_vault_change`) — partial index changes | +| 506 | `index_` | Background indexing progress | +| 1252 | `file_deleted` | DELETE file endpoint | +| 1330 | `directory_created` | POST create directory | +| 1401 | `directory_renamed` | PATCH rename directory | +| 1462 | `directory_deleted` | DELETE directory | +| 1532 | `file_created` | POST create file | +| 1607 | `file_renamed` | PATCH rename file | +| 1949 | `index_reloaded` | Force reindex endpoint | +| 2114 | `vault_reloaded` | (likely during vault reload) | +| 2196 | `vault_added` | POST /api/vaults/add | +| 2215 | `vault_removed` | DELETE /api/vaults/remove | -### `vault_config` handling +**SSE endpoint: lines 2127–2169** — `GET /api/events` returns `StreamingResponse` with `text/event-stream`. -**Location:** Lines **15–16** (global) +**Frontend SSE client: `frontend/app.js` lines 5773–6015** +- `IndexUpdateManager` (IIFE module) +- Listens for events: `connected`, `index_updated`, `index_reloaded`, `vault_added`, `vault_removed`, `index_start`, `index_progress`, `index_complete` +- Auto-reconnects with exponential backoff (1s → 30s max) +- The `_onIndexUpdated()` handler (line 5912) refreshes sidebar tree and tags when affected vault matches context + +**⚠️ Note:** The frontend SSE client does NOT currently listen for `file_created`, `file_deleted`, `file_modified`, `file_renamed`, `directory_created`, `directory_renamed`, `directory_deleted` events — these are broadcast by the backend but not consumed by the frontend. The frontend only reacts to `index_updated` (which already includes all changes). + +--- + +## 4. Configurations Endpoint — `backend/main.py` + +### Config storage — **lines 2414–2458** ```python -vault_config: Dict[str, Dict[str, Any]] = {} -``` -- Type: `{name: {path, attachmentsPath, scanAttachmentsOnStartup, type}}` -- Populated by `load_vault_config()` at lines **50–104** -- Reads `VAULT_N_NAME`/`VAULT_N_PATH` and `DIR_N_NAME`/`DIR_N_PATH` env vars -- Also has `vault_config.update(load_vault_config())` in `build_index()` at line **312** +_CONFIG_PATH = _BASE_DIR / "data" / "config.json" # line 2414 -### Key data structures - -- `index`: dict of vaults → `{files, tags, path, paths, config}` (line **11**) -- `_file_lookup`: `{filename_lower: [{vault, path}, ...]}` — O(1) wikilink resolution (line **22**) -- `path_index`: `{vault_name: [{path, name, type}, ...]}` — tree filtering (line **25**) -- `_index_lock`: `threading.Lock()` (line **18**) -- `_index_generation`: int counter for staleness detection (line **24**) - ---- - -## 5. Backend: `search.py` - -### Wikilink / backlink functions - -**There are NO wikilink or backlink functions in `search.py`.** The file handles: -- Full-text search with TF-IDF via `InvertedIndex` class (line **218**) -- `advanced_search()` (line **426**) — supports operators: `tag:`, `vault:`, `title:`, `path:`, `ext:` -- Title suggestions: `suggest_titles()` (line **594**) -- Tag suggestions: `suggest_tags()` (line **620**) - -**Wikilink resolution** lives in `backend/indexer.py` via `find_file_in_index()` (line **653**) using `_file_lookup`. -**Wikilink rendering** lives in `backend/main.py` via `_convert_wikilinks()` (line **528**). -**Backlinks do not exist** anywhere in the codebase — no function computes "what links to this file." - ---- - -## 6. Frontend: `app.js` - -### Tree item click handler (sidebar file opening) - -**Primary location:** Lines **2349–2360** (inside `_renderDirectoryInContainer` during tree rendering) -```javascript -fileItem.addEventListener("click", () => { - scrollTreeItemIntoView(fileItem, false); - openFile(vaultName, item.path); - closeMobileSidebar(); -}); -``` - -**Second location (search results):** Lines **2637–2642** — same pattern in a different tree-rendering path. -**Third location (tree search filter results):** Lines **2790–2795** — filter results click handler. - -### `openFile` function - -**Location:** Lines **3085–3106** (original `openFile`) -- Sets `currentVault`, `currentPath`, fetches `/api/file/{vault}?path={path}` -- Calls `renderFile(data)` which builds breadcrumb, tags, action buttons, then renders HTML - -**Overridden at line 7604:** -```javascript -openFile = function(vault, path) { - TabManager.open(vault, path); -}; -``` -This wraps the original to use tab-based navigation. `TabManager.open()` creates/focuses a tab. - -### Tab management functions (TabManager) - -**Location:** Lines **7234–7598** (`const TabManager = { ... }`) -- `init()` — line **7243** — grabs DOM refs -- `open(vault, path, options)` — line **7247** — opens a file in a new/focused tab -- `activate(tabId)` — line **7274** — switches to a tab, saves/restores state -- `close(tabId)` — line **7330** — closes a tab, switches to adjacent -- `closeAll()` — line **7348** — closes all, shows dashboard -- `closeRight(tabId)` — line **7358** — closes tabs to the right -- `closeOthers(tabId)` — line **7374** — closes all except current -- `moveTab(fromIdx, toIdx)` — line **7387** — drag-and-drop reorder -- `_renderTabs()` — line **7438** — DOM rendering of tab bar with icons, names, close buttons, drag & drop -- `_showTabContextMenu(x, y, tabId)` — line **7558** — right-click menu (Close, Close Others, Close Right, Close All) - -### TOC `slugify` function - -**Location:** Lines **766–776** (inside `OutlineManager`) -```javascript -slugify(text) { - return text - .toLowerCase() - .normalize("NFD") - .replace(/[\u0300-\u036f]/g, "") - .replace(/[^\p{L}\p{N}\s-]/gu, "") - .replace(/\s+/g, "-") - .replace(/-+/g, "-") - .trim() || "heading"; +_DEFAULT_CONFIG = { # line 2416 + "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, } ``` -### Backlinks UI - -**There is NO backlinks UI or functionality anywhere in the frontend.** -- No `backlink` string found in `app.js`, `style.css`, or `index.html` -- No "Links to this page" panel, no backlink section in the editor, no backlink search in the sidebar +### `GET /api/config` — **line 2464**: Returns merged config (requires auth) +### `POST /api/config` — **line 2470**: Updates config (requires admin), validates types against `_DEFAULT_CONFIG` --- -## 7. Frontend: `style.css` +## 5. Dashboard-Home Element and Rendering — `frontend/app.js` -### Tab-related styles +### Dashboard DOM structure → `frontend/index.html` lines 341–392 +```html +
+
...
+
...
+
+``` -**Location:** Lines **5379–5480** (`.tab-bar` through `.tab-drop-indicator`) -- `.tab-bar` (line **5379**): flex container, 36px min-height, border-bottom -- `.tab-bar[hidden]` (line **5389**): `display: none` -- `.tab-list` (line **5393**): horizontal flex with overflow-x auto -- `.tab-item` (line **5406**): padding 6px 12px, 0.8rem, border-right, transitions -- `.tab-item:hover` (line **5424**): bg hover, color change -- `.tab-item.active` (line **5429**): bg primary, bottom accent border -- `.tab-item .tab-icon` (line **5436**): 14×14, flex-shrink -- `.tab-item .tab-name` (line **5443**): overflow ellipsis, max-width 150px -- `.tab-item .tab-close` (line **5449**): 16×16, hidden by default (opacity: 0) -- `.tab-item:hover .tab-close, .tab-item.active .tab-close` (lines **5461–5462**): opacity 0.6 -- `.tab-item .tab-close:hover` (line **5466**): opacity 1 -- `.tab-item.dragging` (line **5471**): opacity 0.5 -- `.tab-drop-indicator` (line **5476**): 2px accent bar for drag-drop +### Dashboard regeneration fallback → `app.js` lines 5417–5482 (`showWelcome()`) +When `dashboard-home` or its children are missing, `showWelcome()` rebuilds the entire HTML structure inline. -### Sidebar tab styles (sidebar-tab, not content-tab) +### Dashboard Recent Widget → `app.js` lines 3344–3580 (`DashboardRecentWidget`) +- `load(vaultFilter)` — line 3347 +- `render()` — line 3410 +- `_createCard(file, index)` — line 3436 +- `showLoading()` — line 3397 +- `showEmpty()` — line 3526 -**Location:** Lines **744–802** -- `.sidebar-tabs` (line **745**) -- `.sidebar-tab` (line **754**): uppercase, accent border on active -- `.sidebar-tab-panel` (line **793**): display none, scrollable +### Dashboard Bookmarks Widget → `app.js` lines 3583–3660 (`DashboardBookmarkWidget`) +- `load(vaultFilter)` — line 3587 +- `render()` — line 3613 +- `_createCard(file, index)` — around line 3628 + +### Dashboard visibility toggling: +- Show: `app.js` line 7590–7593 — `dashboard.style.display = ""` +- Hide: `app.js` line 7462–7464 — `dashboard.style.display = "none"` + +--- + +## 6. Configurations/Settings Modal — `frontend/app.js` + +### Modal initialization → **lines 3906–3990** (`initConfigModal()`) +Event binding for open/close, config fields, save buttons, reindex, reset, diary refresh, hidden files. + +### Config modal opening → **line 3914**: +```javascript +openBtn.addEventListener("click", async () => { + modal.classList.add("active"); + renderConfigFilters(); + loadConfigFields(); // loads frontend+backend config + loadDiagnostics(); // loads /api/diagnostics + loadAbout(); // loads /api/health + await loadHiddenFilesSettings(); +}); +``` + +### Config field loading → **lines 4043–4070** (`loadConfigFields()`) +Loads frontend config from localStorage and backend config from `GET /api/config`. + +### Diagnostics rendering → **lines 4157–4207** (`loadDiagnostics()`, `renderDiagnostics()`) +Fetches `GET /api/diagnostics` and renders in `#config-diagnostics`. + +### About section → **lines 4211–4250+** (`loadAbout()`) +Fetches `GET /api/health`. + +### Config Modal HTML → `frontend/index.html` lines 395–564 +All the config sections: search params, recent history, backend params, tag filtering, watcher, hidden files, diagnostics, about. + +--- + +## 7. File Action Buttons — `frontend/app.js` + +### Button creation → **lines 3213–3260** +All 6 action buttons created in `renderFile()`: + +| Button | Line | Icon | Action | +|--------|------|------|--------| +| Copy | 3213 | `copy` | Copies raw content to clipboard (fetches if needed) | +| Source | 3231 | `code` | Toggles raw source view | +| Download | 3233 | `download` | Triggers file download via `/api/file/{vault}/download` | +| Edit | 3244 | `edit` | Calls `openEditor(vault, path)` | +| Pop-out | 3250 | `external-link` | Opens in new window via `/popout/{vault}/{path}` | +| TOC | 3256 | `list` | Toggles right sidebar TOC | + +### Button assembly → **line 3300**: +```javascript +area.appendChild(el("div", { class: "file-header" }, [..., + el("div", { class: "file-actions" }, [ + copyBtn, sourceBtn, downloadBtn, editBtn, openNewWindowBtn, tocBtn + ]) +])); +``` + +--- + +## 8. Vault Settings — `backend/vault_settings.py` + +**Full file: 138 lines** — Per-vault UI display preferences stored in `/app/data/vault_settings.json`. + +### Exports used by `main.py`: +```python +from backend.vault_settings import get_vault_setting, update_vault_setting, get_all_vault_settings, delete_vault_setting +``` +(imported at line 46 of `main.py`) + +### Key functions: +- `get_vault_setting(vault_name)` — line 82 — returns settings dict or None +- `update_vault_setting(vault_name, settings)` — line 93 — partial update, auto-saves +- `get_all_vault_settings()` — line 125 — returns all vault settings +- `delete_vault_setting(vault_name)` — line 111 — removes vault settings +- Storage format: `{"vault_name": {"hideHiddenFiles": true/false}, ...}` + +### Current usage in `main.py`: +- `get_vault_setting(vault_name)` used in browse (line 776) and graph (line 1981) endpoints for `hideHiddenFiles` + +### Config Modal Hidden Files → `app.js` (search for `loadHiddenFilesSettings`) +Front-facing CRUD for per-vault `hideHiddenFiles` setting in the Configurations modal. --- ## Architecture Summary ``` -┌─────────────────────────────────────────────────────┐ -│ Frontend (app.js ~8000 lines) │ -│ ┌──────────┐ ┌──────────────┐ ┌─────────────────┐ │ -│ │ Sidebar │ │ Content Area │ │ Right Sidebar │ │ -│ │ Tree │ │ TabManager │ │ TOC/Outline │ │ -│ │ Tags │ │ renderFile() │ │ (slugify) │ │ -│ │ Filter │ │ Breadcrumbs │ │ ReadingProgress │ │ -│ └──────────┘ └──────────────┘ └─────────────────┘ │ -│ ← openFile() → TabManager.open() → api() → backend│ -└─────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────┐ -│ Backend (FastAPI) │ -│ main.py: │ -│ PUT /api/file/{vault}/save (line 717) │ -│ DELETE /api/file/{vault} (line 753) │ -│ GET /api/file/{vault} (rendered HTML) │ -│ GET /api/health (line 562) │ -│ _heading_slugify() (line 476) │ -│ _convert_wikilinks() (line 528) │ -│ │ -│ auth/router.py: │ -│ POST /api/auth/login (line 97) │ -│ Rate limiting: lockout only (lines 108-117) │ -│ │ -│ auth/jwt_handler.py: │ -│ ACCESS_TOKEN_EXPIRE_SECONDS = 3600 (line 22) │ -│ REFRESH_TOKEN_EXPIRE_SECONDS = 604800 (line 23) │ -│ create_access_token() (line 48) │ -│ │ -│ indexer.py: │ -│ vault_config {} (line 15-16) │ -│ load_vault_config() (line 50) │ -│ ⚠ No IGNORED_DIRS — indexes everything │ -│ │ -│ search.py: │ -│ ⚠ No wikilink/backlink functions │ -│ Wikilinks resolved via indexer.find_file_in_index│ -└─────────────────────────────────────────────────────┘ +backend/main.py +├── Pydantic models (lines 56-284) — request/response schemas +├── SSEManager (lines 349-381) — broadcast to clients +├── _on_vault_change (lines 390-417) — watcher callback → index update + SSE broadcast +├── API endpoints: +│ ├── /api/health (1048) — public health +│ ├── /api/events (2127) — SSE stream +│ ├── /api/config GET/POST (2464/2470) — app config CRUD +│ ├── /api/diagnostics (2500) — index stats (admin only) +│ ├── /api/file/* — CRUD with SSE broadcasts on create/delete/rename +│ └── /api/directory/* — CRUD with SSE broadcasts +├── _CONFIG_PATH, _DEFAULT_CONFIG (2414-2458) + +backend/vault_settings.py +├── Per-vault settings (hideHiddenFiles) +├── JSON persistence in /app/data/vault_settings.json + +frontend/index.html +├── #dashboard-home (341-392) — bookmarks + recent sections +├── #config-modal (395-564) — full config UI +├── #editor-modal — CodeMirror editor +├── #graph-modal — D3 graph view +└── #help-modal — user guide + +frontend/app.js +├── AuthManager (~1532+) +├── DashboardRecentWidget (3344-3580) +├── DashboardBookmarkWidget (3583-3660) +├── initConfigModal (3906-3990) +├── loadConfigFields (4043-4070) +├── loadDiagnostics / renderDiagnostics (4157-4207) +├── showWelcome (5417-5482) — dashboard rebuild + render +├── IndexUpdateManager / SSE client (5773-6015) +├── renderFile (3075-3328) — file view with action buttons +└── TabManager (7307+) — multi-tab support ``` --- ## Start Here -For any feature work, start with **`C:/dev/git/python/ObsiGate/backend/main.py`** — it contains the API surface, markdown rendering (wikilinks, heading IDs, slugify), and all the endpoint definitions that tie the frontend to the backend index/search/auth subsystems. +1. **For Pydantic Field descriptions**: Open `backend/main.py` at **line 89** (`FileContentResponse`) and add `Field(description=...)` to each field for models without descriptions through line 311. -### Key Findings / Gaps -- **No rate limiting** except manual lockout on login -- **No backlinks** — neither computed in backend nor displayed in frontend -- **No IGNORED_DIRS** — the indexer indexes everything; hidden-file hiding is at the UI layer -- **No wikilink/backlink in search.py** — wikilink resolution is in `indexer.py`, rendering in `main.py` -- **TabManager** is a self-contained singleton at the end of `app.js` (line 7234) wrapping the original `openFile` +2. **For dashboard stats widget**: Open `frontend/index.html` at **line 341** (`#dashboard-home`) and `frontend/app.js` at **line 5417** (`showWelcome()`). The dashboard currently has two sections (Bookmarks + Recently Opened). A new stats section would be added between those divs. + +3. **For webhooks on file events**: The SSE broadcasts happen in `backend/main.py` at lines 1252, 1532, 1607 (file events) and 1330, 1401, 1462 (directory events). The frontend SSE client in `app.js` at line 5773 doesn't listen for individual file events — it only handles `index_updated`. Webhook firing should be added alongside the `sse_manager.broadcast()` calls. + +4. **For Configurations modal**: Open `frontend/index.html` at **line 395** (`#config-modal`) and `frontend/app.js` at **line 3906** (`initConfigModal()`). diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index bd5b6a2..bcbf882 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -47,16 +47,16 @@ - ✅ **Clic simple / double clic dans l'arborescence** — Simple clic = onglet preview (italique, temporaire). Double clic = onglet persistant (normal, cumulable). Implémenté dans `TabManager.openPreview()` / `TabManager.openPersistent()`. - ✅ **Backlinks panel** — Panneau affichant les fichiers avec wikilinks pointant vers le fichier courant. Backend : `GET /api/file/{vault}/backlinks` + index inversé dans `indexer.py`. Frontend : panneau `renderBacklinksPanel()`. -- 🟡 **Gestion des conflits Syncthing** — Dashboard « Conflits » avec diff et résolution (garder local, garder conflit). +- ✅ **Gestion des conflits Syncthing** — Dashboard « Conflits » avec détection automatique des fichiers `.sync-conflict-*` et résolution (garder local, garder conflit). Backend : `GET /api/conflicts`, `POST /api/conflicts/resolve`. - ✅ **Liste IGNORED_DIRS configurable** — Configurable via `OBSIGATE_IGNORED_DIRS` (liste séparée par virgules). Appliqué au watcher et à l'indexer. - ✅ **Timeout de session configurable** — JWT TTL configurable via `OBSIGATE_ACCESS_TOKEN_TTL` et `OBSIGATE_REFRESH_TOKEN_TTL`. ### 🟢 Fonctionnel — P3/P4 -- 🟢 **Publication publique de documents** — Générer un lien partageable pour un document, accessible à des utilisateurs non authentifiés (token unique, expiration configurable, lecture seule). L'utilisateur peut créer/révoquer des liens de partage depuis l'interface. -- 🟢 **Dashboard statistiques** — Métriques par vault : fichiers totaux, taille, top tags, orphelins. -- 🟢 **Webhooks** — Notifier des systèmes externes lors de changements (création, modif, suppression). -- 🟢 **Documentation OpenAPI enrichie** — Enrichir les modèles Pydantic pour la doc auto-générée /docs et /redoc. +- ✅ **Publication publique de documents** — Génération de lien partageable avec token unique (64-char hex), expiration configurable, lecture seule, sans authentification. Backend : `backend/share.py` + `POST /api/share/{vault}`, `GET /api/shares`, `DELETE /api/share/{id}`, `GET /s/{token}` (vue publique). Frontend : bouton « Partager » dans les actions de fichier + dialogue de copie de lien. +- ✅ **Dashboard statistiques** — Métriques agrégées par vault : fichiers totaux, taille, tags uniques. Backend : `GET /api/dashboard`. Frontend : widget `DashboardStatsWidget` avec 4 cartes (fichiers, tags, taille, vaults). +- ✅ **Webhooks** — Notifications HTTP POST vers des services externes lors de changements (création, modification, suppression, renommage de fichiers et dossiers). Signature HMAC-SHA256 optionnelle. Backend : `backend/webhooks.py` + CRUD endpoints. Frontend : gestion dans le modal Configurations. +- ✅ **Documentation OpenAPI enrichie** — Tous les modèles Pydantic (`FileContentResponse`, `SearchResponse`, `AdvancedSearchResponse`, etc.) ont maintenant des `Field(description=...)` documentés visibles dans `/docs` (Swagger UI) et `/redoc`. - ✅ **Gestion fichiers non-supportés** — Message explicite avec nom du fichier, taille et bouton de téléchargement pour les fichiers binaires. Backend : réponse structurée avec `unsupported: true`. Frontend : interface `unsupported-file`. ### ⬜ Qualité & Polish — P5+ diff --git a/frontend/app.js b/frontend/app.js index a1a03e8..a405bc0 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -3257,6 +3257,10 @@ RightSidebarManager.toggle(); }); + // Share button + const shareBtn = el("button", { class: "btn-action", title: "Partager ce document" }, [icon("share-2", 14), document.createTextNode("Partager")]); + shareBtn.addEventListener("click", () => openShareDialog(data.vault, data.path)); + // Frontmatter — Accent Card let fmSection = null; if (data.frontmatter && Object.keys(data.frontmatter).length > 0) { @@ -3297,7 +3301,7 @@ // Assemble area.innerHTML = ""; area.appendChild(breadcrumb); - area.appendChild(el("div", { class: "file-header" }, [el("div", { class: "file-title" }, [document.createTextNode(data.title)]), tagsDiv, el("div", { class: "file-actions" }, [copyBtn, sourceBtn, downloadBtn, editBtn, openNewWindowBtn, tocBtn])])); + area.appendChild(el("div", { class: "file-header" }, [el("div", { class: "file-title" }, [document.createTextNode(data.title)]), tagsDiv, el("div", { class: "file-actions" }, [copyBtn, sourceBtn, downloadBtn, editBtn, openNewWindowBtn, tocBtn, shareBtn])])); if (fmSection) area.appendChild(fmSection); area.appendChild(mdDiv); area.appendChild(rawDiv); @@ -3339,6 +3343,81 @@ // --------------------------------------------------------------------------- // Dashboard Recent Files Widget // --------------------------------------------------------------------------- + // ── Dashboard Stats Widget ── + const DashboardStatsWidget = { + async load() { + const grid = document.getElementById("dashboard-stats-grid"); + if (!grid) return; + grid.innerHTML = '
Chargement...
'; + try { + const data = await api("/api/dashboard"); + this.render(data); + } catch (err) { + grid.innerHTML = `
Erreur: ${escapeHtml(err.message)}
`; + } + }, + render(data) { + const grid = document.getElementById("dashboard-stats-grid"); + if (!grid) return; + const fmtSize = (bytes) => bytes < 1024 ? `${bytes} o` : bytes < 1048576 ? `${(bytes/1024).toFixed(1)} Ko` : bytes < 1073741824 ? `${(bytes/1048576).toFixed(1)} Mo` : `${(bytes/1073741824).toFixed(1)} Go`; + const items = [ + { icon: "files", label: "Fichiers", value: data.total_files.toLocaleString() }, + { icon: "tags", label: "Tags uniques", value: data.total_tags.toLocaleString() }, + { icon: "hard-drive", label: "Taille totale", value: fmtSize(data.total_size_bytes) }, + { icon: "folder-open", label: "Vaults", value: data.vaults.length.toString() }, + ]; + grid.innerHTML = items.map(i => ` +
+ + ${i.value} + ${i.label} +
+ `).join(""); + safeCreateIcons(); + } + }; + + // ── Dashboard Conflicts Widget ── + const DashboardConflictsWidget = { + async load() { + const section = document.getElementById("dashboard-conflicts-section"); + if (!section) return; + try { + const data = await api("/api/conflicts"); + if (data.total === 0) { section.style.display = "none"; return; } + section.style.display = ""; + document.getElementById("dashboard-conflicts-count").textContent = data.total; + this.render(data.conflicts); + } catch (err) { section.style.display = "none"; } + }, + render(conflicts) { + const grid = document.getElementById("dashboard-conflicts-grid"); + if (!grid) return; + grid.innerHTML = conflicts.map(c => ` +
+
+ ${escapeHtml(c.vault)} + ${escapeHtml(c.conflict_path.split("/").pop())} + Conflit du ${c.conflict_date.replace(/(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})/, "$3/$2/$1 $4:$5")} +
+
+ + +
+
+ `).join(""); + grid.querySelectorAll(".keep-local").forEach(btn => btn.addEventListener("click", () => this._resolve(btn.dataset, "keep_local"))); + grid.querySelectorAll(".keep-conflict").forEach(btn => btn.addEventListener("click", () => this._resolve(btn.dataset, "keep_conflict"))); + }, + async _resolve(d, action) { + try { + await api("/api/conflicts/resolve", { method: "POST", body: JSON.stringify({ vault: d.vault, conflict_path: d.conflict, original_path: d.original, action }) }); + showToast("Conflit résolu", "success"); + this.load(); + } catch (err) { showToast("Erreur: " + err.message, "error"); } + } + }; + const DashboardRecentWidget = { _cache: [], _currentFilter: "", @@ -3919,6 +3998,8 @@ loadDiagnostics(); loadAbout(); await loadHiddenFilesSettings(); + loadWebhooksUI(); + loadSharesUI(); safeCreateIcons(); }); @@ -4380,6 +4461,96 @@ } } + // ── Webhooks UI ── + async function loadWebhooksUI() { + const list = document.getElementById("webhooks-list"); + if (!list) return; + try { + const webhooks = await api("/api/webhooks"); + renderWebhooksUI(webhooks); + } catch { list.innerHTML = '
Admin uniquement
'; } + } + function renderWebhooksUI(webhooks) { + const list = document.getElementById("webhooks-list"); + if (!list) return; + if (!webhooks.length) { list.innerHTML = '
Aucun webhook configuré.
'; return; } + list.innerHTML = webhooks.map(w => ` +
+ ${escapeHtml(w.name)} + ${escapeHtml(w.url)} + ${(w.events||[]).join(", ")} + +
+ `).join(""); + list.querySelectorAll(".webhook-delete").forEach(b => b.addEventListener("click", async () => { + await api(`/api/webhooks/${b.dataset.id}`, { method: "DELETE" }); + loadWebhooksUI(); + })); + } + document.addEventListener("click", function(e) { + if (e.target.id === "webhook-add-btn") { + const name = document.getElementById("webhook-name-input").value.trim(); + const url = document.getElementById("webhook-url-input").value.trim(); + if (!url) { showToast("URL requise", "error"); return; } + api("/api/webhooks", { method: "POST", body: JSON.stringify({ name: name || "Webhook", url, events: ["file_created","file_deleted","file_modified","file_renamed"] }) }).then(() => { loadWebhooksUI(); document.getElementById("webhook-name-input").value = ""; document.getElementById("webhook-url-input").value = ""; }).catch(err => showToast(err.message, "error")); + } + }); + + // ── Shares UI ── + async function loadSharesUI() { + const list = document.getElementById("shares-list"); + if (!list) return; + try { + const shares = await api("/api/shares"); + renderSharesUI(shares); + } catch { list.innerHTML = '
Chargement...
'; } + } + function renderSharesUI(shares) { + const list = document.getElementById("shares-list"); + if (!list) return; + if (!shares.length) { list.innerHTML = '
Aucun partage actif.
'; return; } + list.innerHTML = shares.map(s => ` +
+ `).join(""); + list.querySelectorAll(".share-revoke").forEach(b => b.addEventListener("click", async () => { + await api(`/api/share/${b.dataset.id}`, { method: "DELETE" }); + loadSharesUI(); + })); + } + + // ── Share Dialog ── + async function openShareDialog(vault, path) { + try { + const share = await api(`/api/share/${encodeURIComponent(vault)}`, { method: "POST", body: JSON.stringify({ path }) }); + const url = window.location.origin + share.url; + const div = document.createElement("div"); + div.className = "share-dialog-overlay"; + div.innerHTML = ` + `; + document.body.appendChild(div); + div.querySelector(".share-copy-btn").addEventListener("click", async () => { + await navigator.clipboard.writeText(url); + showToast("Lien copié !", "success"); + div.remove(); + }); + div.querySelector(".share-close-btn").addEventListener("click", () => div.remove()); + div.addEventListener("click", (e) => { if (e.target === div) div.remove(); }); + } catch (err) { showToast("Erreur: " + err.message, "error"); } + } + function renderConfigFilters() { const config = TagFilterService.getConfig(); const filters = config.tagFilters || TagFilterService.defaultFilters; @@ -5480,6 +5651,12 @@ } // Show the dashboard widgets + if (typeof DashboardStatsWidget !== "undefined") { + DashboardStatsWidget.load(); + } + if (typeof DashboardConflictsWidget !== "undefined") { + DashboardConflictsWidget.load(); + } if (typeof DashboardRecentWidget !== "undefined") { DashboardRecentWidget.load(selectedContextVault); } diff --git a/frontend/index.html b/frontend/index.html index de126af..eeba77f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -358,6 +358,19 @@
+ +
+
+
+ +

Statistiques

+
+
+
+
Chargement...
+
+
+
@@ -374,6 +387,21 @@
+
+ + + + +
@@ -636,6 +664,25 @@
+ +
+

🔔 Webhooks

+

Notifications HTTP vers des services externes lors des changements de fichiers.

+
+
+ + + +
+
+ + +
+

📤 Partages publics

+

Liens de partage publics pour des documents (lecture seule, sans authentification).

+
+
+
diff --git a/frontend/style.css b/frontend/style.css index 600732c..5d65b6b 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -5560,3 +5560,187 @@ body.popup-mode .content-area { margin-top: 12px; display: inline-flex; } + +/* ── Dashboard Stats Grid ── */ +.dashboard-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 12px; +} +.stat-card { + display: flex; + flex-direction: column; + align-items: center; + padding: 16px 12px; + background: var(--bg-card, var(--bg-secondary)); + border-radius: 8px; + border: 1px solid var(--border); +} +.stat-icon { + width: 24px; + height: 24px; + opacity: 0.6; + margin-bottom: 8px; + color: var(--accent); +} +.stat-value { + font-size: 1.4rem; + font-weight: 700; + color: var(--text-primary); +} +.stat-label { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 2px; +} +.dashboard-stats-loading { + padding: 16px; + text-align: center; + color: var(--text-muted); +} + +/* ── Dashboard Conflicts ── */ +.dashboard-conflicts-grid { + display: flex; + flex-direction: column; + gap: 8px; +} +.conflict-card { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + background: var(--bg-card, var(--bg-secondary)); + border-radius: 6px; + border: 1px solid var(--border); + gap: 12px; +} +.conflict-info { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +.conflict-vault { + font-size: 0.7rem; + color: var(--accent); + background: var(--accent-bg, rgba(99,102,241,0.1)); + padding: 1px 6px; + border-radius: 3px; + align-self: flex-start; +} +.conflict-name { + font-size: 0.85rem; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.conflict-date { + font-size: 0.7rem; + color: var(--text-muted); +} +.conflict-actions { + display: flex; + gap: 8px; + flex-shrink: 0; +} +.conflict-btn { + padding: 4px 10px; + font-size: 0.75rem; + border: 1px solid var(--border); + border-radius: 4px; + cursor: pointer; + background: var(--bg-primary); + color: var(--text-primary); + white-space: nowrap; +} +.conflict-btn:hover { background: var(--bg-hover); } + +/* ── Webhooks UI ── */ +.webhook-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + background: var(--bg-card, var(--bg-secondary)); + border-radius: 6px; + margin-bottom: 6px; + font-size: 0.8rem; +} +.webhook-name { font-weight: 500; min-width: 80px; } +.webhook-url { color: var(--accent); overflow: hidden; text-overflow: ellipsis; flex: 1; } +.webhook-events { color: var(--text-muted); font-size: 0.7rem; } +.webhook-delete { background: none; border: none; color: var(--text-error); cursor: pointer; font-size: 1rem; padding: 2px 6px; } +.config-add-row { display: flex; gap: 8px; margin-top: 8px; } +.config-btn-add { padding: 6px 14px; background: var(--accent); color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 0.8rem; } + +/* ── Shares UI ── */ +.share-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + background: var(--bg-card, var(--bg-secondary)); + border-radius: 6px; + margin-bottom: 6px; + font-size: 0.8rem; +} +.share-path { font-weight: 500; min-width: 0; overflow: hidden; text-overflow: ellipsis; } +.share-url a { color: var(--accent); font-size: 0.75rem; } +.share-meta { color: var(--text-muted); font-size: 0.7rem; } +.share-revoke { background: none; border: 1px solid var(--text-error); color: var(--text-error); cursor: pointer; padding: 2px 8px; border-radius: 4px; font-size: 0.7rem; } + +/* ── Share Dialog ── */ +.share-dialog-overlay { + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0,0,0,0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; +} +.share-dialog { + background: var(--bg-primary); + border-radius: 12px; + padding: 24px; + max-width: 480px; + width: 90%; + box-shadow: 0 8px 32px rgba(0,0,0,0.2); +} +.share-dialog h3 { margin: 0 0 4px; } +.share-url-input { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 0.8rem; + background: var(--bg-secondary); + color: var(--text-primary); + box-sizing: border-box; +} +.share-dialog-actions { + display: flex; + gap: 8px; + margin-top: 12px; + justify-content: flex-end; +} +.share-copy-btn { + padding: 6px 16px; + background: var(--accent); + color: #fff; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 0.85rem; +} +.share-close-btn { + padding: 6px 16px; + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border); + border-radius: 6px; + cursor: pointer; + font-size: 0.85rem; +} diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..df32609 --- /dev/null +++ b/plan.md @@ -0,0 +1,375 @@ +# Implementation Plan — Remaining Roadmap Items + +## 1. 📝 Documentation OpenAPI enrichie (P3) — 5 min + +**Goal:** Add `Field(description=...)` to all Pydantic models without descriptions in `backend/main.py`. + +**Models to update (lines 89–311):** + +| Line | Model | Fields to annotate | +|------|-------|--------------------| +| 89 | `FileContentResponse` | `vault`, `path`, `title`, `tags`, `frontmatter`, `html`, `raw_length`, `extension`, `is_markdown`, `unsupported`, `size_bytes` | +| 103 | `FileRawResponse` | `vault`, `path`, `raw` | +| 110 | `FileSaveResponse` | `status`, `vault`, `path`, `size` | +| 118 | `FileDeleteResponse` | `status`, `vault`, `path` | +| 125 | `SearchResultItem` | `vault`, `path`, `title`, `tags`, `score`, `snippet`, `modified` | +| 136 | `SearchResponse` | `query`, `vault_filter`, `tag_filter`, `count`, `results` (total, offset, limit already have Field) | +| 146 | `TagsResponse` | `vault_filter`, `tags` | +| 152 | `TreeSearchResult` | `vault`, `path`, `name`, `matched_path` (type has Field) | +| 161 | `TreeSearchResponse` | `query`, `vault_filter`, `results` | +| 168 | `AdvancedSearchResultItem` | `vault`, `path`, `title`, `tags`, `score`, `snippet`, `modified` | +| 179 | `SearchFacets` | `tags`, `vaults` (already have default_factory) | +| 185 | `AdvancedSearchResponse` | `results`, `total`, `offset`, `limit`, `facets` (query_time_ms has Field) | +| 196 | `TitleSuggestion` | `vault`, `path`, `title` | +| 203 | `SuggestResponse` | `query`, `suggestions` | +| 209 | `TagSuggestion` | `tag`, `count` | +| 215 | `TagSuggestResponse` | `query`, `suggestions` | +| 221 | `GraphNode` | (all fields already have Field) | +| 231 | `GraphEdge` | (all fields already have Field) | +| 239 | `GraphResponse` | `vault`, `path`, `nodes`, `edges` | +| 247 | `ReloadResponse` | `status`, `vaults` | +| 253 | `HealthResponse` | `status`, `version`, `vaults`, `total_files` | +| 265 | `DirectoryCreateResponse` | `success`, `path` | +| 284 | `DirectoryDeleteResponse` | `success`, `deleted_count` | +| 296 | `FileCreateResponse` | `success`, `path` | +| 307 | `FileRenameResponse` | `success` | + +**Dependency:** None. Pure documentation change. + +--- + +## 2. 📊 Dashboard statistiques (P3) — 30 min + +### 2a. Backend: `GET /api/dashboard` (new endpoint) + +**File:** `backend/main.py` — insert at **line ~2547** (after `/api/diagnostics`) + +```python +@app.get("/api/dashboard") +async def api_dashboard(current_user=Depends(require_auth)): + """Aggregated dashboard statistics across all accessible vaults.""" + from backend.indexer import index, vault_config, path_index + user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", []) + + vault_stats = [] + total_files = 0 + total_tags = set() + total_size = 0 + + for vname, vdata in index.items(): + if "*" not in user_vaults and vname not in user_vaults: + continue + files = vdata.get("files", []) + file_count = len(files) + total_files += file_count + tags = set() + for f in files: + tags.update(f.get("tags", [])) + total_size += f.get("size", 0) + total_tags.update(tags) + vault_stats.append({ + "name": vname, + "file_count": file_count, + "tag_count": len(tags), + "total_size_bytes": sum(f.get("size", 0) for f in files), + }) + + return { + "vaults": vault_stats, + "total_files": total_files, + "total_tags": len(total_tags), + "total_size_bytes": total_size, + } +``` + +**No new model needed** — return plain dict (or add optional `DashboardResponse` model). + +### 2b. Frontend: Insert stats widget in dashboard-home + +**File:** `frontend/index.html` — **after line 364** (`` closing bookmarks section, before ``) + +Add: +```html + +
+
+
+ +

Statistiques

+
+
+
+
Chargement...
+
+
+``` + +**File:** `frontend/app.js` — add `DashboardStatsWidget` module (insert at **line ~3343**, before `DashboardRecentWidget`): + +```javascript +const DashboardStatsWidget = { + async load() { + const grid = document.getElementById("dashboard-stats-grid"); + if (!grid) return; + grid.innerHTML = '
Chargement...
'; + try { + const data = await api("/api/dashboard"); + this.render(data); + } catch (err) { + grid.innerHTML = `
Erreur: ${escapeHtml(err.message)}
`; + } + }, + render(data) { + const grid = document.getElementById("dashboard-stats-grid"); + if (!grid) return; + const items = [ + { icon: "files", label: "Fichiers", value: data.total_files.toLocaleString() }, + { icon: "tags", label: "Tags uniques", value: data.total_tags.toLocaleString() }, + { icon: "hard-drive", label: "Taille totale", value: this._formatSize(data.total_size_bytes) }, + { icon: "folder", label: "Vaults", value: data.vaults.length.toString() }, + ]; + grid.innerHTML = items.map(i => ` +
+ + ${i.value} + ${i.label} +
+ `).join(""); + safeCreateIcons(); + }, + _formatSize(bytes) { /* KB/MB/GB formatter */ } +}; +``` + +**Also update `showWelcome()`** at **line ~5417** — the dashboard rebuild HTML must include the stats section div. And **line ~5490** — add `DashboardStatsWidget.load()` call. + +**File:** `frontend/style.css` — add CSS for `.dashboard-stats-grid`, `.stat-card`, `.stat-icon`, `.stat-value`, `.stat-label`. + +**Dependency:** Item 1 (Pydantic models) — none. Standalone. + +--- + +## 3. 🔔 Webhooks (P3) — 45 min + +### 3a. New backend module: `backend/webhooks.py` + +Create full module with: +- `WEBHOOKS_FILE = Path("data/webhooks.json")` — persistence +- `_DEFAULT_WEBHOOKS = []` +- `get_webhooks() -> list` — reads from disk +- `create_webhook(name, url, events, secret=None) -> dict` +- `update_webhook(id, updates) -> dict` +- `delete_webhook(id) -> bool` +- `async def dispatch_webhooks(event_type: str, data: dict)` — calls all webhooks subscribed to `event_type`, sends JSON POST with HMAC-SHA256 signature header if secret is set, timeout 5s, logs failures +- Model: `WebhookConfig` with `id`, `name`, `url`, `events` (list of event type strings), `secret` (optional), `enabled`, `created_at`, `last_fired_at` + +### 3b. Backend: CRUD endpoints in `backend/main.py` + +Insert at **line ~2470** (before `GET /api/config`): + +```python +@app.get("/api/webhooks") +@app.post("/api/webhooks") +@app.patch("/api/webhooks/{webhook_id}") +@app.delete("/api/webhooks/{webhook_id}") +``` + +Import `from backend.webhooks import get_webhooks, create_webhook, update_webhook, delete_webhook, dispatch_webhooks` + +### 3c. Backend: Hook dispatch_webhooks into file events + +Add `await dispatch_webhooks("file_created", {...})` calls alongside each `sse_manager.broadcast(...)` call: + +| Line | Event | Add dispatch | +|------|-------|-------------| +| ~1252 | `file_deleted` | `dispatch_webhooks("file_deleted", {"vault":..., "path":...})` | +| ~1330 | `directory_created` | `dispatch_webhooks("directory_created", {...})` | +| ~1401 | `directory_renamed` | `dispatch_webhooks("directory_renamed", {...})` | +| ~1462 | `directory_deleted` | `dispatch_webhooks("directory_deleted", {...})` | +| ~1532 | `file_created` | `dispatch_webhooks("file_created", {...})` | +| ~1607 | `file_renamed` | `dispatch_webhooks("file_renamed", {...})` | + +### 3d. Frontend: Webhooks management UI in Configurations modal + +**File:** `frontend/index.html` — insert at **line ~633** (after `` section, before `` closing config-content): + +```html +
+

🔔 Webhooks

+

Notifications HTTP vers des services externes lors des changements de fichiers.

+
+
+ + + +
+
+``` + +**File:** `frontend/app.js` — in `initConfigModal()` at **line ~3918**, add: +```javascript +loadWebhooks(); // in the open handler +// Event binding for webhook add/save/delete buttons +``` + +Add functions: `loadWebhooks()`, `renderWebhooks(webhooks)`, `addWebhook()`, `deleteWebhook(id)`, `toggleWebhook(id, enabled)`. All use `api("/api/webhooks", ...)`. + +**File:** `frontend/style.css` — add `.webhook-item`, `.webhook-toggle`, `.webhook-delete` styles. + +**Dependency:** None on items 1–2. Standalone. + +--- + +## 4. 📤 Publication publique de documents (P3) — 60 min + +### 4a. New backend module: `backend/share.py` + +Create full module: +- `SHARES_FILE = Path("data/shares.json")` +- ShareToken model: `id`, `vault`, `path`, `token` (64-char hex), `created_by`, `created_at`, `expires_at` (optional, null = never), `access_count`, `last_accessed` +- `create_share(vault, path, created_by, expires_in_hours=None) -> dict` — generates token, stores, returns share info +- `get_share_by_token(token) -> dict | None` — validates expiry, returns share +- `revoke_share(id) -> bool` +- `list_shares(vault_filter=None) -> list` — for admin/settings page +- `record_access(token)` — increments access_count + +### 4b. Backend: Endpoints in `backend/main.py` + +Insert at **line ~1619** (before `GET /api/file/{vault_name}`): + +```python +# Share management +@app.post("/api/share/{vault_name}") +@app.get("/api/shares") +@app.delete("/api/share/{share_id}") + +# Public view (no auth required!) +@app.get("/s/{token}") +async def public_share_view(token: str): ... +``` + +The public view endpoint: +1. Looks up token via `get_share_by_token(token)` +2. Reads the file content +3. Renders markdown with redacted secrets +4. Returns simple HTML page (not SPA) with rendered content +5. Increments access count + +### 4c. Frontend: Share button in file actions + +**File:** `frontend/app.js` — in `renderFile()`, at **line ~3250** (after pop-out button): + +```javascript +const shareBtn = el("button", { class: "btn-action", title: "Partager" }, [icon("share-2", 14), document.createTextNode("Partager")]); +shareBtn.addEventListener("click", () => openShareDialog(data.vault, data.path)); +``` + +Add `shareBtn` to the file-actions div at **line ~3300**. + +Add `openShareDialog(vault, path)` function that: +- Calls `POST /api/share/{vault}` to create a share +- Shows a modal with the share URL (copyable) and expiration options +- Shows existing shares list with revoke buttons + +### 4d. Frontend: Share management in Configurations + +**File:** `frontend/index.html` — add share management section in config modal (alongside webhooks). + +**File:** `frontend/app.js` — `loadShares()` and `renderShares()` functions. + +**File:** `frontend/style.css` — add `.share-dialog`, `.share-url`, `.share-item` styles. + +**Dependency:** None. Standalone, but needs item 1 for clean models. + +--- + +## 5. 🔄 Gestion conflits Syncthing (P2) — 45 min + +### 5a. Backend: Conflict file detection + +**File:** `backend/indexer.py` — add after `_backlink_index`: + +```python +def get_conflicts() -> list: + """Scan all vaults for Syncthing/Nextcloud sync-conflict files.""" + conflicts = [] + pattern = re.compile(r'\.sync-conflict-(\d{8}-\d{6})\.') + for vname, vdata in index.items(): + for f in vdata.get("files", []): + m = pattern.search(f["path"]) + if m: + # Find the original file + orig_path = pattern.sub("", f["path"]) + conflicts.append({ + "vault": vname, + "conflict_path": f["path"], + "original_path": orig_path, + "conflict_date": m.group(1), + "conflict_title": f.get("title", ""), + }) + return conflicts +``` + +### 5b. Backend: Endpoints + +**File:** `backend/main.py` — insert at **line ~2547**: + +```python +@app.get("/api/conflicts") +async def api_conflicts(current_user=Depends(require_auth)): + """List sync-conflict files across accessible vaults.""" + ... + +@app.post("/api/conflicts/resolve") +async def api_conflict_resolve(body: dict, current_user=Depends(require_auth)): + """Resolve a conflict: keep_local (delete conflict), keep_conflict (replace original).""" + ... +``` + +### 5c. Backend: Diff endpoint + +```python +@app.get("/api/conflicts/diff") +async def api_conflict_diff(vault: str, original: str, conflict: str, current_user=Depends(require_auth)): + """Return unified diff between original and conflict file.""" + import difflib + ... +``` + +### 5d. Frontend: Conflict dashboard widget + +**File:** `frontend/index.html` — add `#dashboard-conflicts-section` in dashboard after stats section. + +**File:** `frontend/app.js` — add `DashboardConflictsWidget` (pattern similar to recent/bookmarks): +- `load()` → `GET /api/conflicts` +- `render()` → shows conflict cards with file names and dates +- Click → opens diff modal showing side-by-side comparison +- Action buttons: "Garder l'original", "Garder le conflit" + +**File:** `frontend/style.css` — add `.conflict-card`, `.conflict-diff`, `.conflict-actions` styles. + +**Dependency:** None. Standalone. + +--- + +## Execution Order (optimal) + +1. **Item 1** — OpenAPI docs (quick win, no risk) +2. **Item 2** — Dashboard stats (standalone, visible result) +3. **Item 3** — Webhooks (new module + integration, most code) +4. **Item 4** — Public shares (new module + public view, security-sensitive) +5. **Item 5** — Syncthing conflicts (standalone, nice-to-have) + +**Total estimated effort:** ~3 hours + +## Files Summary + +| File | Action | Items | +|------|--------|-------| +| `backend/main.py` | Edit | 1 (models), 2a (endpoint), 3b+c (webhook CRUD+dispatch), 4b (share+public view), 5b+c (conflicts) | +| `backend/webhooks.py` | **Create** | 3a | +| `backend/share.py` | **Create** | 4a | +| `backend/indexer.py` | Edit | 5a (get_conflicts) | +| `frontend/index.html` | Edit | 2b, 3d, 4d, 5d (dashboard + config sections) | +| `frontend/app.js` | Edit | 2b, 3d, 4c, 5d (widgets + share button + webhook UI) | +| `frontend/style.css` | Edit | 2b, 3d, 4c, 5d (all new CSS classes) |