Add share, webhook, and conflict management features

This commit is contained in:
Bruno Charest 2026-05-26 11:00:48 -04:00
parent ed2bb4f7fb
commit 0b611a8735
11 changed files with 1595 additions and 366 deletions

View File

@ -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

View File

@ -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 <mark> 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"""<!DOCTYPE html><html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>{title} ObsiGate Share</title>
<style>body{{font-family:system-ui,sans-serif;max-width:800px;margin:0 auto;padding:20px;line-height:1.6;color:#1a1a2e;background:#f8f9fa}}
pre{{background:#eee;padding:12px;border-radius:6px;overflow-x:auto}}code{{font-size:0.9em}}a{{color:#6366f1}}.wikilink{{color:#6366f1;cursor:default}}
img{{max-width:100%}}.share-banner{{background:#6366f1;color:#fff;padding:8px 16px;border-radius:6px;margin-bottom:20px;font-size:0.85rem}}</style></head>
<body><div class="share-banner">📄 Document partagé via ObsiGate</div><h1>{title}</h1>{html}</body></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
# ---------------------------------------------------------------------------

View File

@ -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

111
backend/share.py Normal file
View File

@ -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

131
backend/webhooks.py Normal file
View File

@ -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)

View File

@ -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 56284** — 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 **476503** (inside the Markdown rendering helpers section)
### Key models with Field descriptions (lines 6069):
```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 **506527**
- Post-processes HTML to inject `id=""` attributes on `<h1>``<h6>` tags
- Handles duplicate slugs with `-2`, `-3` suffix
### Health endpoint
**Location:** Lines **562571** (`@app.get("/api/health", response_model=HealthResponse)`)
- Returns `{ status, version, vaults, total_files }`
- No authentication required
### Markdown rendering pipeline (wikilinks)
- `_convert_wikilinks()`: lines **528549** — converts `[[target]]` / `[[target|display]]` to clickable HTML anchors
- `_render_markdown()`: lines **552577** — master renderer: preprocesses images → converts wikilinks → renders with mistune → adds heading IDs
- Wikilinks render as `<a class="wikilink" data-vault="..." data-path="...">` when resolved, `<span class="wikilink-missing">` otherwise
### Models WITHOUT Field descriptions (need updating):
- `FileContentResponse` — **lines 89100**
- `FileRawResponse` — **lines 103107**
- `FileSaveResponse` — **lines 110115**
- `FileDeleteResponse` — **lines 118122**
- `SearchResultItem` — **lines 125133**
- `SearchResponse`**lines 136143** (has Field on `total`, `offset`, `limit`)
- `TagsResponse` — **lines 146149**
- `TreeSearchResult` — **lines 152158**
- `TreeSearchResponse` — **lines 161165**
- `AdvancedSearchResultItem` — **lines 168176**
- `SearchFacets` — **lines 179182**
- `AdvancedSearchResponse`**lines 185193** (has Field on `query_time_ms`)
- `TitleSuggestion` — **lines 196200**
- `SuggestResponse` — **lines 203206**
- `TagSuggestion` — **lines 209212**
- `TagSuggestResponse` — **lines 215218**
- `GraphNode` — **lines 221228**
- `GraphEdge` — **lines 231236**
- `GraphResponse` — **lines 239244**
- `ReloadResponse` — **lines 247250**
- `HealthResponse` — **lines 253257**
- `DirectoryCreateRequest`**lines 260262** (has Field)
- `DirectoryCreateResponse` — **lines 265269**
- `DirectoryRenameRequest`**lines 272274** (has Field)
- `DirectoryRenameResponse` — **lines 277281**
- `DirectoryDeleteResponse` — **lines 284288**
- `FileCreateRequest`**lines 291293** (has Field)
- `FileCreateResponse` — **lines 296299**
- `FileRenameRequest`**lines 302304** (has Field)
- `FileRenameResponse` — **lines 307311**
---
## 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 **108117** (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 **114115**)
- 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 **2223**
### `/api/diagnostics` — **lines 25002547**
```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 **4860**
### `/api/health` — **lines 10481059**
```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 **6373**
- 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 349381** — `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_<event_type>` | 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 21272169** — `GET /api/events` returns `StreamingResponse` with `text/event-stream`.
**Location:** Lines **1516** (global)
**Frontend SSE client: `frontend/app.js` lines 57736015**
- `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 24142458**
```python
vault_config: Dict[str, Dict[str, Any]] = {}
```
- Type: `{name: {path, attachmentsPath, scanAttachmentsOnStartup, type}}`
- Populated by `load_vault_config()` at lines **50104**
- 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 **23492360** (inside `_renderDirectoryInContainer` during tree rendering)
```javascript
fileItem.addEventListener("click", () => {
scrollTreeItemIntoView(fileItem, false);
openFile(vaultName, item.path);
closeMobileSidebar();
});
```
**Second location (search results):** Lines **26372642** — same pattern in a different tree-rendering path.
**Third location (tree search filter results):** Lines **27902795** — filter results click handler.
### `openFile` function
**Location:** Lines **30853106** (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 **72347598** (`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 **766776** (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 341392
```html
<div id="dashboard-home" class="dashboard-home" role="region" aria-label="Tableau de bord">
<div id="dashboard-bookmarks-section" class="dashboard-section">...</div>
<div id="dashboard-recent-section" class="dashboard-section">...</div>
</div>
```
**Location:** Lines **53795480** (`.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 **54615462**): 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 54175482 (`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 33443580 (`DashboardRecentWidget`)
- `load(vaultFilter)` — line 3347
- `render()` — line 3410
- `_createCard(file, index)` — line 3436
- `showLoading()` — line 3397
- `showEmpty()` — line 3526
**Location:** Lines **744802**
- `.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 35833660 (`DashboardBookmarkWidget`)
- `load(vaultFilter)` — line 3587
- `render()` — line 3613
- `_createCard(file, index)` — around line 3628
### Dashboard visibility toggling:
- Show: `app.js` line 75907593 — `dashboard.style.display = ""`
- Hide: `app.js` line 74627464 — `dashboard.style.display = "none"`
---
## 6. Configurations/Settings Modal — `frontend/app.js`
### Modal initialization → **lines 39063990** (`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 40434070** (`loadConfigFields()`)
Loads frontend config from localStorage and backend config from `GET /api/config`.
### Diagnostics rendering → **lines 41574207** (`loadDiagnostics()`, `renderDiagnostics()`)
Fetches `GET /api/diagnostics` and renders in `#config-diagnostics`.
### About section → **lines 42114250+** (`loadAbout()`)
Fetches `GET /api/health`.
### Config Modal HTML → `frontend/index.html` lines 395564
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 32133260**
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()`).

View File

@ -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+

View File

@ -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 = '<div class="dashboard-stats-loading">Chargement...</div>';
try {
const data = await api("/api/dashboard");
this.render(data);
} catch (err) {
grid.innerHTML = `<div class="dashboard-recent-empty">Erreur: ${escapeHtml(err.message)}</div>`;
}
},
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 => `
<div class="stat-card">
<i data-lucide="${i.icon}" class="stat-icon"></i>
<span class="stat-value">${i.value}</span>
<span class="stat-label">${i.label}</span>
</div>
`).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 => `
<div class="conflict-card">
<div class="conflict-info">
<span class="conflict-vault">${escapeHtml(c.vault)}</span>
<span class="conflict-name">${escapeHtml(c.conflict_path.split("/").pop())}</span>
<span class="conflict-date">Conflit du ${c.conflict_date.replace(/(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})/, "$3/$2/$1 $4:$5")}</span>
</div>
<div class="conflict-actions">
<button class="conflict-btn keep-local" data-vault="${escapeHtml(c.vault)}" data-conflict="${escapeHtml(c.conflict_path)}" data-original="${escapeHtml(c.original_path)}">Garder l'original</button>
<button class="conflict-btn keep-conflict" data-vault="${escapeHtml(c.vault)}" data-conflict="${escapeHtml(c.conflict_path)}" data-original="${escapeHtml(c.original_path)}">Garder le conflit</button>
</div>
</div>
`).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 = '<div class="config-description">Admin uniquement</div>'; }
}
function renderWebhooksUI(webhooks) {
const list = document.getElementById("webhooks-list");
if (!list) return;
if (!webhooks.length) { list.innerHTML = '<div class="config-description">Aucun webhook configuré.</div>'; return; }
list.innerHTML = webhooks.map(w => `
<div class="webhook-item">
<span class="webhook-name">${escapeHtml(w.name)}</span>
<span class="webhook-url">${escapeHtml(w.url)}</span>
<span class="webhook-events">${(w.events||[]).join(", ")}</span>
<button class="webhook-delete" data-id="${w.id}"></button>
</div>
`).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 = '<div class="config-description">Chargement...</div>'; }
}
function renderSharesUI(shares) {
const list = document.getElementById("shares-list");
if (!list) return;
if (!shares.length) { list.innerHTML = '<div class="config-description">Aucun partage actif.</div>'; return; }
list.innerHTML = shares.map(s => `
<div class="share-item">
<span class="share-path">${escapeHtml(s.vault)}/${escapeHtml(s.path)}</span>
<span class="share-url"><a href="${s.url}" target="_blank">${s.url}</a></span>
<span class="share-meta">${s.access_count} vue(s)${s.expires_at ? ' · Expire' : ''}</span>
<button class="share-revoke" data-id="${s.id}">Révoquer</button>
</div>
`).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 = `
<div class="share-dialog">
<h3>📤 Lien de partage</h3>
<p style="font-size:0.85rem;color:var(--text-muted);margin-bottom:8px">${escapeHtml(vault)}/${escapeHtml(path)}</p>
<input type="text" class="share-url-input" value="${url}" readonly onclick="this.select()">
<div class="share-dialog-actions">
<button class="share-copy-btn">📋 Copier</button>
<button class="share-close-btn">Fermer</button>
</div>
</div>`;
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);
}

View File

@ -358,6 +358,19 @@
<!-- Content -->
<main class="content-area" id="content-area" aria-label="Contenu principal">
<div id="dashboard-home" class="dashboard-home" role="region" aria-label="Tableau de bord">
<!-- Stats Section -->
<div id="dashboard-stats-section" class="dashboard-section">
<div class="dashboard-header">
<div class="dashboard-title-row">
<i data-lucide="bar-chart-3" class="dashboard-icon" style="color:var(--accent)"></i>
<h2>Statistiques</h2>
</div>
</div>
<div id="dashboard-stats-grid" class="dashboard-stats-grid">
<div class="dashboard-stats-loading">Chargement...</div>
</div>
</div>
<!-- Bookmarks Section -->
<div id="dashboard-bookmarks-section" class="dashboard-section">
<div class="dashboard-header">
@ -374,6 +387,21 @@
</div>
</div>
</div>
</div>
<!-- Sync Conflicts Section -->
<div id="dashboard-conflicts-section" class="dashboard-section" style="display:none">
<div class="dashboard-header">
<div class="dashboard-title-row">
<i data-lucide="alert-triangle" class="dashboard-icon" style="color:var(--accent-orange)"></i>
<h2>Conflits de synchronisation</h2>
<span id="dashboard-conflicts-count" class="dashboard-badge" style="background:var(--accent-orange)"></span>
</div>
</div>
<div id="dashboard-conflicts-grid" class="dashboard-conflicts-grid"></div>
</div>
<!-- Recently Opened Section -->
<div id="dashboard-recent-section" class="dashboard-section">
<div class="dashboard-header">
@ -636,6 +664,25 @@
</div>
</section>
<!-- Webhooks -->
<section class="config-section">
<h2>🔔 Webhooks</h2>
<p class="config-description">Notifications HTTP vers des services externes lors des changements de fichiers.</p>
<div id="webhooks-list"></div>
<div class="config-add-row">
<input type="text" id="webhook-name-input" placeholder="Nom" class="config-input" style="width:100px">
<input type="text" id="webhook-url-input" placeholder="https://..." class="config-input" style="flex:1">
<button id="webhook-add-btn" class="config-btn config-btn-add">Ajouter</button>
</div>
</section>
<!-- Partages publics -->
<section class="config-section">
<h2>📤 Partages publics</h2>
<p class="config-description">Liens de partage publics pour des documents (lecture seule, sans authentification).</p>
<div id="shares-list"></div>
</section>
</div>
</div>
</div>

View File

@ -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;
}

375
plan.md Normal file
View File

@ -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 89311):**
| 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** (`</div>` closing bookmarks section, before `<!-- Recently Opened Section -->`)
Add:
```html
<!-- Stats Section -->
<div id="dashboard-stats-section" class="dashboard-section">
<div class="dashboard-header">
<div class="dashboard-title-row">
<i data-lucide="bar-chart-3" class="dashboard-icon" style="color:var(--accent)"></i>
<h2>Statistiques</h2>
</div>
</div>
<div id="dashboard-stats-grid" class="dashboard-stats-grid">
<div class="dashboard-stats-loading">Chargement...</div>
</div>
</div>
```
**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 = '<div class="dashboard-stats-loading">Chargement...</div>';
try {
const data = await api("/api/dashboard");
this.render(data);
} catch (err) {
grid.innerHTML = `<div class="dashboard-recent-empty">Erreur: ${escapeHtml(err.message)}</div>`;
}
},
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 => `
<div class="stat-card">
<i data-lucide="${i.icon}" class="stat-icon"></i>
<span class="stat-value">${i.value}</span>
<span class="stat-label">${i.label}</span>
</div>
`).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 `<!-- À propos -->` section, before `</div>` closing config-content):
```html
<section class="config-section">
<h2>🔔 Webhooks</h2>
<p class="config-description">Notifications HTTP vers des services externes lors des changements de fichiers.</p>
<div id="webhooks-list"></div>
<div class="config-add-pattern">
<input type="text" id="webhook-name-input" placeholder="Nom" class="config-input" style="width:120px">
<input type="text" id="webhook-url-input" placeholder="https://..." class="config-input" style="flex:1">
<button id="webhook-add-btn" class="config-btn-add">Ajouter</button>
</div>
</section>
```
**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 12. 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) |