Add share, webhook, and conflict management features
This commit is contained in:
parent
ed2bb4f7fb
commit
0b611a8735
@ -937,3 +937,29 @@ def get_backlinks(vault_name: str, file_path: str) -> List[Dict[str, str]]:
|
|||||||
bl = vindex.get(target_key, [])
|
bl = vindex.get(target_key, [])
|
||||||
results.extend(bl)
|
results.extend(bl)
|
||||||
return results
|
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
|
||||||
|
|||||||
370
backend/main.py
370
backend/main.py
@ -34,6 +34,7 @@ from backend.indexer import (
|
|||||||
get_vault_names,
|
get_vault_names,
|
||||||
find_file_in_index,
|
find_file_in_index,
|
||||||
get_backlinks,
|
get_backlinks,
|
||||||
|
get_conflicts,
|
||||||
parse_markdown_file,
|
parse_markdown_file,
|
||||||
_extract_tags,
|
_extract_tags,
|
||||||
SUPPORTED_EXTENSIONS,
|
SUPPORTED_EXTENSIONS,
|
||||||
@ -75,12 +76,12 @@ class VaultInfo(BaseModel):
|
|||||||
|
|
||||||
class BrowseItem(BaseModel):
|
class BrowseItem(BaseModel):
|
||||||
"""A single entry (file or directory) returned by the browse endpoint."""
|
"""A single entry (file or directory) returned by the browse endpoint."""
|
||||||
name: str
|
name: str = Field(description="File or directory name")
|
||||||
path: str
|
path: str = Field(description="Relative path within vault")
|
||||||
type: str = Field(description="'file' or 'directory'")
|
type: str = Field(description="'file' or 'directory'")
|
||||||
children_count: Optional[int] = None
|
children_count: Optional[int] = Field(default=None, description="Number of children (directories only)")
|
||||||
size: Optional[int] = None
|
size: Optional[int] = Field(default=None, description="File size in bytes")
|
||||||
extension: Optional[str] = None
|
extension: Optional[str] = Field(default=None, description="File extension")
|
||||||
|
|
||||||
|
|
||||||
class BrowseResponse(BaseModel):
|
class BrowseResponse(BaseModel):
|
||||||
@ -92,95 +93,95 @@ class BrowseResponse(BaseModel):
|
|||||||
|
|
||||||
class FileContentResponse(BaseModel):
|
class FileContentResponse(BaseModel):
|
||||||
"""Rendered file content with metadata."""
|
"""Rendered file content with metadata."""
|
||||||
vault: str
|
vault: str = Field(description="Vault name")
|
||||||
path: str
|
path: str = Field(description="Relative file path within the vault")
|
||||||
title: str
|
title: str = Field(description="File title (from frontmatter or filename)")
|
||||||
tags: List[str]
|
tags: List[str] = Field(description="Extracted tags from frontmatter and inline #tags")
|
||||||
frontmatter: Dict[str, Any]
|
frontmatter: Dict[str, Any] = Field(description="YAML frontmatter as key-value dict")
|
||||||
html: str
|
html: str = Field(description="Rendered HTML content")
|
||||||
raw_length: int
|
raw_length: int = Field(description="Length of raw file content in characters")
|
||||||
extension: str
|
extension: str = Field(description="File extension (e.g. .md, .txt)")
|
||||||
is_markdown: bool
|
is_markdown: bool = Field(description="Whether the file is markdown")
|
||||||
unsupported: Optional[bool] = False
|
unsupported: Optional[bool] = Field(default=False, description="True for binary/unsupported files")
|
||||||
size_bytes: Optional[int] = None
|
size_bytes: Optional[int] = Field(default=None, description="File size in bytes (for unsupported files)")
|
||||||
|
|
||||||
|
|
||||||
class FileRawResponse(BaseModel):
|
class FileRawResponse(BaseModel):
|
||||||
"""Raw text content of a file."""
|
"""Raw text content of a file."""
|
||||||
vault: str
|
vault: str = Field(description="Vault name")
|
||||||
path: str
|
path: str = Field(description="Relative file path within the vault")
|
||||||
raw: str
|
raw: str = Field(description="Raw file content as text")
|
||||||
|
|
||||||
|
|
||||||
class FileSaveResponse(BaseModel):
|
class FileSaveResponse(BaseModel):
|
||||||
"""Confirmation after saving a file."""
|
"""Confirmation after saving a file."""
|
||||||
status: str
|
status: str = Field(description="Always 'ok'")
|
||||||
vault: str
|
vault: str = Field(description="Vault name")
|
||||||
path: str
|
path: str = Field(description="Relative file path within the vault")
|
||||||
size: int
|
size: int = Field(description="Size of saved content in characters")
|
||||||
|
|
||||||
|
|
||||||
class FileDeleteResponse(BaseModel):
|
class FileDeleteResponse(BaseModel):
|
||||||
"""Confirmation after deleting a file."""
|
"""Confirmation after deleting a file."""
|
||||||
status: str
|
status: str = Field(description="Always 'ok'")
|
||||||
vault: str
|
vault: str = Field(description="Vault name")
|
||||||
path: str
|
path: str = Field(description="Relative file path within the vault")
|
||||||
|
|
||||||
|
|
||||||
class SearchResultItem(BaseModel):
|
class SearchResultItem(BaseModel):
|
||||||
"""A single search result."""
|
"""A single search result."""
|
||||||
vault: str
|
vault: str = Field(description="Vault name")
|
||||||
path: str
|
path: str = Field(description="Relative file path")
|
||||||
title: str
|
title: str = Field(description="File title")
|
||||||
tags: List[str]
|
tags: List[str] = Field(description="File tags")
|
||||||
score: int
|
score: int = Field(description="Relevance score")
|
||||||
snippet: str
|
snippet: str = Field(description="Content excerpt with highlights")
|
||||||
modified: str
|
modified: str = Field(description="ISO 8601 modification timestamp")
|
||||||
|
|
||||||
|
|
||||||
class SearchResponse(BaseModel):
|
class SearchResponse(BaseModel):
|
||||||
"""Full-text search response with optional pagination."""
|
"""Full-text search response with optional pagination."""
|
||||||
query: str
|
query: str = Field(description="Original search query")
|
||||||
vault_filter: str
|
vault_filter: str = Field(description="Vault filter applied ('all' or vault name)")
|
||||||
tag_filter: Optional[str]
|
tag_filter: Optional[str] = Field(default=None, description="Tag filter applied")
|
||||||
count: int
|
count: int = Field(description="Number of results in this response")
|
||||||
total: int = Field(0, description="Total results before pagination")
|
total: int = Field(default=0, description="Total results before pagination")
|
||||||
offset: int = Field(0, description="Current pagination offset")
|
offset: int = Field(default=0, description="Current pagination offset")
|
||||||
limit: int = Field(200, description="Page size")
|
limit: int = Field(default=200, description="Page size")
|
||||||
results: List[SearchResultItem]
|
results: List[SearchResultItem] = Field(description="Search result items")
|
||||||
|
|
||||||
|
|
||||||
class TagsResponse(BaseModel):
|
class TagsResponse(BaseModel):
|
||||||
"""Tag aggregation response."""
|
"""Tag aggregation response."""
|
||||||
vault_filter: Optional[str]
|
vault_filter: Optional[str] = Field(default=None, description="Vault filter applied")
|
||||||
tags: Dict[str, int]
|
tags: Dict[str, int] = Field(description="Tag name → count mapping")
|
||||||
|
|
||||||
|
|
||||||
class TreeSearchResult(BaseModel):
|
class TreeSearchResult(BaseModel):
|
||||||
"""A single tree search result item."""
|
"""A single tree search result item."""
|
||||||
vault: str
|
vault: str = Field(description="Vault name")
|
||||||
path: str
|
path: str = Field(description="Full relative path")
|
||||||
name: str
|
name: str = Field(description="File or directory name")
|
||||||
type: str = Field(description="'file' or 'directory'")
|
type: str = Field(description="'file' or 'directory'")
|
||||||
matched_path: str
|
matched_path: str = Field(description="Path segment that matched the query")
|
||||||
|
|
||||||
|
|
||||||
class TreeSearchResponse(BaseModel):
|
class TreeSearchResponse(BaseModel):
|
||||||
"""Tree search response with matching paths."""
|
"""Tree search response with matching paths."""
|
||||||
query: str
|
query: str = Field(description="Search query")
|
||||||
vault_filter: str
|
vault_filter: str = Field(description="Vault filter applied")
|
||||||
results: List[TreeSearchResult]
|
results: List[TreeSearchResult] = Field(description="Matching files and directories")
|
||||||
|
|
||||||
|
|
||||||
class AdvancedSearchResultItem(BaseModel):
|
class AdvancedSearchResultItem(BaseModel):
|
||||||
"""A single advanced search result with highlighted snippet."""
|
"""A single advanced search result with highlighted snippet."""
|
||||||
vault: str
|
vault: str = Field(description="Vault name")
|
||||||
path: str
|
path: str = Field(description="Relative file path")
|
||||||
title: str
|
title: str = Field(description="File title")
|
||||||
tags: List[str]
|
tags: List[str] = Field(description="File tags")
|
||||||
score: float
|
score: float = Field(description="TF-IDF relevance score")
|
||||||
snippet: str
|
snippet: str = Field(description="Content excerpt with <mark> highlights")
|
||||||
modified: str
|
modified: str = Field(description="ISO 8601 modification timestamp")
|
||||||
|
|
||||||
|
|
||||||
class SearchFacets(BaseModel):
|
class SearchFacets(BaseModel):
|
||||||
@ -191,37 +192,37 @@ class SearchFacets(BaseModel):
|
|||||||
|
|
||||||
class AdvancedSearchResponse(BaseModel):
|
class AdvancedSearchResponse(BaseModel):
|
||||||
"""Advanced search response with TF-IDF scoring, facets, and pagination."""
|
"""Advanced search response with TF-IDF scoring, facets, and pagination."""
|
||||||
results: List[AdvancedSearchResultItem]
|
results: List[AdvancedSearchResultItem] = Field(description="Search results")
|
||||||
total: int
|
total: int = Field(description="Total number of matching results")
|
||||||
offset: int
|
offset: int = Field(description="Current pagination offset")
|
||||||
limit: int
|
limit: int = Field(description="Page size")
|
||||||
facets: SearchFacets
|
facets: SearchFacets = Field(description="Faceted counts by tag and vault")
|
||||||
query_time_ms: float = Field(0, description="Server-side query time in milliseconds")
|
query_time_ms: float = Field(default=0, description="Server-side query time in milliseconds")
|
||||||
|
|
||||||
|
|
||||||
class TitleSuggestion(BaseModel):
|
class TitleSuggestion(BaseModel):
|
||||||
"""A file title suggestion for autocomplete."""
|
"""A file title suggestion for autocomplete."""
|
||||||
vault: str
|
vault: str = Field(description="Vault name")
|
||||||
path: str
|
path: str = Field(description="Relative file path")
|
||||||
title: str
|
title: str = Field(description="File title")
|
||||||
|
|
||||||
|
|
||||||
class SuggestResponse(BaseModel):
|
class SuggestResponse(BaseModel):
|
||||||
"""Autocomplete suggestions for file titles."""
|
"""Autocomplete suggestions for file titles."""
|
||||||
query: str
|
query: str = Field(description="Original query string")
|
||||||
suggestions: List[TitleSuggestion]
|
suggestions: List[TitleSuggestion] = Field(description="Matching file suggestions")
|
||||||
|
|
||||||
|
|
||||||
class TagSuggestion(BaseModel):
|
class TagSuggestion(BaseModel):
|
||||||
"""A tag suggestion for autocomplete."""
|
"""A tag suggestion for autocomplete."""
|
||||||
tag: str
|
tag: str = Field(description="Tag name")
|
||||||
count: int
|
count: int = Field(description="Number of files with this tag")
|
||||||
|
|
||||||
|
|
||||||
class TagSuggestResponse(BaseModel):
|
class TagSuggestResponse(BaseModel):
|
||||||
"""Autocomplete suggestions for tags."""
|
"""Autocomplete suggestions for tags."""
|
||||||
query: str
|
query: str = Field(description="Original query string")
|
||||||
suggestions: List[TagSuggestion]
|
suggestions: List[TagSuggestion] = Field(description="Matching tag suggestions")
|
||||||
|
|
||||||
|
|
||||||
class GraphNode(BaseModel):
|
class GraphNode(BaseModel):
|
||||||
@ -242,24 +243,24 @@ class GraphEdge(BaseModel):
|
|||||||
|
|
||||||
class GraphResponse(BaseModel):
|
class GraphResponse(BaseModel):
|
||||||
"""Graph data for a vault or directory."""
|
"""Graph data for a vault or directory."""
|
||||||
vault: str
|
vault: str = Field(description="Vault name")
|
||||||
path: str
|
path: str = Field(description="Root path for the graph")
|
||||||
nodes: List[GraphNode]
|
nodes: List[GraphNode] = Field(description="Graph nodes (files and directories)")
|
||||||
edges: List[GraphEdge]
|
edges: List[GraphEdge] = Field(description="Graph edges (parent and wikilink relations)")
|
||||||
|
|
||||||
|
|
||||||
class ReloadResponse(BaseModel):
|
class ReloadResponse(BaseModel):
|
||||||
"""Index reload confirmation with per-vault stats."""
|
"""Index reload confirmation with per-vault stats."""
|
||||||
status: str
|
status: str = Field(description="Reload status ('ok' or 'error')")
|
||||||
vaults: Dict[str, Any]
|
vaults: Dict[str, Any] = Field(description="Per-vault file counts after reload")
|
||||||
|
|
||||||
|
|
||||||
class HealthResponse(BaseModel):
|
class HealthResponse(BaseModel):
|
||||||
"""Application health status."""
|
"""Application health status."""
|
||||||
status: str
|
status: str = Field(description="Health status ('ok' or 'error')")
|
||||||
version: str
|
version: str = Field(description="Application version")
|
||||||
vaults: int
|
vaults: int = Field(description="Number of configured vaults")
|
||||||
total_files: int
|
total_files: int = Field(description="Total indexed files across all vaults")
|
||||||
|
|
||||||
|
|
||||||
class DirectoryCreateRequest(BaseModel):
|
class DirectoryCreateRequest(BaseModel):
|
||||||
@ -269,8 +270,8 @@ class DirectoryCreateRequest(BaseModel):
|
|||||||
|
|
||||||
class DirectoryCreateResponse(BaseModel):
|
class DirectoryCreateResponse(BaseModel):
|
||||||
"""Response after creating a directory."""
|
"""Response after creating a directory."""
|
||||||
success: bool
|
success: bool = Field(description="Whether creation succeeded")
|
||||||
path: str
|
path: str = Field(description="Path of the created directory")
|
||||||
|
|
||||||
|
|
||||||
class DirectoryRenameRequest(BaseModel):
|
class DirectoryRenameRequest(BaseModel):
|
||||||
@ -281,15 +282,15 @@ class DirectoryRenameRequest(BaseModel):
|
|||||||
|
|
||||||
class DirectoryRenameResponse(BaseModel):
|
class DirectoryRenameResponse(BaseModel):
|
||||||
"""Response after renaming a directory."""
|
"""Response after renaming a directory."""
|
||||||
success: bool
|
success: bool = Field(description="Whether rename succeeded")
|
||||||
old_path: str
|
old_path: str = Field(description="Original directory path")
|
||||||
new_path: str
|
new_path: str = Field(description="New directory path")
|
||||||
|
|
||||||
|
|
||||||
class DirectoryDeleteResponse(BaseModel):
|
class DirectoryDeleteResponse(BaseModel):
|
||||||
"""Response after deleting a directory."""
|
"""Response after deleting a directory."""
|
||||||
success: bool
|
success: bool = Field(description="Whether deletion succeeded")
|
||||||
deleted_count: int
|
deleted_count: int = Field(description="Number of files recursively deleted")
|
||||||
|
|
||||||
|
|
||||||
class FileCreateRequest(BaseModel):
|
class FileCreateRequest(BaseModel):
|
||||||
@ -300,8 +301,8 @@ class FileCreateRequest(BaseModel):
|
|||||||
|
|
||||||
class FileCreateResponse(BaseModel):
|
class FileCreateResponse(BaseModel):
|
||||||
"""Response after creating a file."""
|
"""Response after creating a file."""
|
||||||
success: bool
|
success: bool = Field(description="Whether creation succeeded")
|
||||||
path: str
|
path: str = Field(description="Path of the created file")
|
||||||
|
|
||||||
|
|
||||||
class FileRenameRequest(BaseModel):
|
class FileRenameRequest(BaseModel):
|
||||||
@ -312,7 +313,7 @@ class FileRenameRequest(BaseModel):
|
|||||||
|
|
||||||
class FileRenameResponse(BaseModel):
|
class FileRenameResponse(BaseModel):
|
||||||
"""Response after renaming a file."""
|
"""Response after renaming a file."""
|
||||||
success: bool
|
success: bool = Field(description="Whether rename succeeded")
|
||||||
old_path: str
|
old_path: str
|
||||||
new_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.auth.middleware import require_auth, require_admin, check_vault_access
|
||||||
from backend.secret_redactor import redact_file_content
|
from backend.secret_redactor import redact_file_content
|
||||||
from backend.audit import log_file_save, log_file_delete
|
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)
|
app.include_router(auth_router)
|
||||||
|
|
||||||
@ -1254,6 +1257,9 @@ async def api_file_delete(vault_name: str, path: str = Query(..., description="R
|
|||||||
"path": path,
|
"path": path,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Dispatch webhooks
|
||||||
|
await dispatch_webhooks("file_deleted", {"vault": vault_name, "path": path})
|
||||||
|
|
||||||
return {"status": "ok", "vault": vault_name, "path": path}
|
return {"status": "ok", "vault": vault_name, "path": path}
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
raise HTTPException(status_code=403, detail="Permission denied: vault may be read-only")
|
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,
|
"vault": vault_name,
|
||||||
"path": body.path,
|
"path": body.path,
|
||||||
})
|
})
|
||||||
|
await dispatch_webhooks("directory_created", {"vault": vault_name, "path": body.path})
|
||||||
|
|
||||||
return {"success": True, "path": body.path}
|
return {"success": True, "path": body.path}
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
@ -1403,6 +1410,7 @@ async def api_directory_rename(
|
|||||||
"old_path": old_path_str,
|
"old_path": old_path_str,
|
||||||
"new_path": new_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}
|
return {"success": True, "old_path": old_path_str, "new_path": new_path_str}
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
@ -1464,6 +1472,7 @@ async def api_directory_delete(
|
|||||||
"path": path,
|
"path": path,
|
||||||
"deleted_count": file_count,
|
"deleted_count": file_count,
|
||||||
})
|
})
|
||||||
|
await dispatch_webhooks("directory_deleted", {"vault": vault_name, "path": path})
|
||||||
|
|
||||||
return {"success": True, "deleted_count": file_count}
|
return {"success": True, "deleted_count": file_count}
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
@ -1533,6 +1542,7 @@ async def api_file_create(
|
|||||||
"vault": vault_name,
|
"vault": vault_name,
|
||||||
"path": body.path,
|
"path": body.path,
|
||||||
})
|
})
|
||||||
|
await dispatch_webhooks("file_created", {"vault": vault_name, "path": body.path})
|
||||||
|
|
||||||
return {"success": True, "path": body.path}
|
return {"success": True, "path": body.path}
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
@ -1609,6 +1619,7 @@ async def api_file_rename(
|
|||||||
"old_path": old_path_str,
|
"old_path": old_path_str,
|
||||||
"new_path": new_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}
|
return {"success": True, "old_path": old_path_str, "new_path": new_path_str}
|
||||||
except PermissionError:
|
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
|
# Static files & SPA fallback
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -4,6 +4,7 @@ python-frontmatter==1.1.0
|
|||||||
mistune==3.0.2
|
mistune==3.0.2
|
||||||
python-multipart==0.0.9
|
python-multipart==0.0.9
|
||||||
aiofiles==23.2.1
|
aiofiles==23.2.1
|
||||||
|
aiohttp>=3.9.0
|
||||||
watchdog>=4.0.0
|
watchdog>=4.0.0
|
||||||
argon2-cffi>=23.1.0
|
argon2-cffi>=23.1.0
|
||||||
python-jose>=3.3.0
|
python-jose>=3.3.0
|
||||||
|
|||||||
111
backend/share.py
Normal file
111
backend/share.py
Normal 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
131
backend/webhooks.py
Normal 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)
|
||||||
527
context.md
527
context.md
@ -1,321 +1,312 @@
|
|||||||
# Code Context
|
# Code Context — ObsiGate Roadmap Implementation
|
||||||
|
|
||||||
## Files Retrieved
|
## Files Retrieved
|
||||||
|
|
||||||
1. `C:/dev/git/python/ObsiGate/backend/main.py` (lines 1-2504) - Core API endpoints, markdown rendering, SSE
|
### Backend
|
||||||
2. `C:/dev/git/python/ObsiGate/backend/auth/router.py` (full file, 263 lines) - Auth endpoints (login, logout, refresh, admin CRUD)
|
1. `backend/main.py` (2599 lines total) — main FastAPI app with all endpoints, Pydantic models, SSE manager
|
||||||
3. `C:/dev/git/python/ObsiGate/backend/auth/jwt_handler.py` (full file, 153 lines) - JWT token creation, validation, revocation
|
2. `backend/vault_settings.py` (138 lines) — per-vault settings persistence (hideHiddenFiles, etc.)
|
||||||
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
|
### Frontend
|
||||||
6. `C:/dev/git/python/ObsiGate/frontend/app.js` (lines 1-8046) - Frontend SPA (TOC, tabs, tree, search)
|
3. `frontend/app.js` (~8187 lines) — vanilla JS SPA, all UI logic
|
||||||
7. `C:/dev/git/python/ObsiGate/frontend/style.css` (lines 5379-5476) - Tab bar styles
|
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)`)
|
### Key models with Field descriptions (lines 60–69):
|
||||||
- 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)
|
|
||||||
```python
|
```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
|
### Models WITHOUT Field descriptions (need updating):
|
||||||
|
- `FileContentResponse` — **lines 89–100**
|
||||||
**Location:** Lines **506–527**
|
- `FileRawResponse` — **lines 103–107**
|
||||||
- Post-processes HTML to inject `id=""` attributes on `<h1>`–`<h6>` tags
|
- `FileSaveResponse` — **lines 110–115**
|
||||||
- Handles duplicate slugs with `-2`, `-3` suffix
|
- `FileDeleteResponse` — **lines 118–122**
|
||||||
|
- `SearchResultItem` — **lines 125–133**
|
||||||
### Health endpoint
|
- `SearchResponse` — **lines 136–143** (has Field on `total`, `offset`, `limit`)
|
||||||
|
- `TagsResponse` — **lines 146–149**
|
||||||
**Location:** Lines **562–571** (`@app.get("/api/health", response_model=HealthResponse)`)
|
- `TreeSearchResult` — **lines 152–158**
|
||||||
- Returns `{ status, version, vaults, total_files }`
|
- `TreeSearchResponse` — **lines 161–165**
|
||||||
- No authentication required
|
- `AdvancedSearchResultItem` — **lines 168–176**
|
||||||
|
- `SearchFacets` — **lines 179–182**
|
||||||
### Markdown rendering pipeline (wikilinks)
|
- `AdvancedSearchResponse` — **lines 185–193** (has Field on `query_time_ms`)
|
||||||
|
- `TitleSuggestion` — **lines 196–200**
|
||||||
- `_convert_wikilinks()`: lines **528–549** — converts `[[target]]` / `[[target|display]]` to clickable HTML anchors
|
- `SuggestResponse` — **lines 203–206**
|
||||||
- `_render_markdown()`: lines **552–577** — master renderer: preprocesses images → converts wikilinks → renders with mistune → adds heading IDs
|
- `TagSuggestion` — **lines 209–212**
|
||||||
- Wikilinks render as `<a class="wikilink" data-vault="..." data-path="...">` when resolved, `<span class="wikilink-missing">` otherwise
|
- `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
|
### `/api/diagnostics` — **lines 2500–2547**
|
||||||
|
|
||||||
**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**
|
|
||||||
```python
|
```python
|
||||||
ACCESS_TOKEN_EXPIRE_SECONDS = 3600 # 1 hour
|
@app.get("/api/diagnostics") # line 2500
|
||||||
REFRESH_TOKEN_EXPIRE_SECONDS = 604800 # 7 days
|
async def api_diagnostics(current_user=Depends(require_admin)):
|
||||||
```
|
```
|
||||||
- Algorithm: `HS256` (line **20**)
|
Returns index stats, inverted index stats, config, and search executor info. Requires admin auth.
|
||||||
- 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**)
|
|
||||||
|
|
||||||
### `create_access_token` function
|
### `/api/health` — **lines 1048–1059**
|
||||||
|
|
||||||
**Location:** Lines **48–60**
|
|
||||||
```python
|
```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" }`
|
Public health check (no auth). Returns status, version, vaults count, total_files.
|
||||||
- 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`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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 `sse_manager.broadcast()` calls:**
|
||||||
> "All files and directories are indexed, including hidden files (starting with '.')."
|
|
||||||
|
|
||||||
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 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
|
```python
|
||||||
vault_config: Dict[str, Dict[str, Any]] = {}
|
_CONFIG_PATH = _BASE_DIR / "data" / "config.json" # line 2414
|
||||||
```
|
|
||||||
- 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**
|
|
||||||
|
|
||||||
### Key data structures
|
_DEFAULT_CONFIG = { # line 2416
|
||||||
|
"search_workers": 2,
|
||||||
- `index`: dict of vaults → `{files, tags, path, paths, config}` (line **11**)
|
"debounce_ms": 300,
|
||||||
- `_file_lookup`: `{filename_lower: [{vault, path}, ...]}` — O(1) wikilink resolution (line **22**)
|
"results_per_page": 50,
|
||||||
- `path_index`: `{vault_name: [{path, name, type}, ...]}` — tree filtering (line **25**)
|
"min_query_length": 2,
|
||||||
- `_index_lock`: `threading.Lock()` (line **18**)
|
"search_timeout_ms": 30000,
|
||||||
- `_index_generation`: int counter for staleness detection (line **24**)
|
"max_content_size": 100000,
|
||||||
|
"snippet_context_chars": 120,
|
||||||
---
|
"max_snippet_highlights": 5,
|
||||||
|
"title_boost": 3.0,
|
||||||
## 5. Backend: `search.py`
|
"path_boost": 1.5,
|
||||||
|
"watcher_enabled": True,
|
||||||
### Wikilink / backlink functions
|
"watcher_use_polling": False,
|
||||||
|
"watcher_polling_interval": 5.0,
|
||||||
**There are NO wikilink or backlink functions in `search.py`.** The file handles:
|
"watcher_debounce": 2.0,
|
||||||
- Full-text search with TF-IDF via `InvertedIndex` class (line **218**)
|
"tag_boost": 2.0,
|
||||||
- `advanced_search()` (line **426**) — supports operators: `tag:`, `vault:`, `title:`, `path:`, `ext:`
|
"prefix_max_expansions": 50,
|
||||||
- Title suggestions: `suggest_titles()` (line **594**)
|
"recent_files_limit": 20,
|
||||||
- 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";
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Backlinks UI
|
### `GET /api/config` — **line 2464**: Returns merged config (requires auth)
|
||||||
|
### `POST /api/config` — **line 2470**: Updates config (requires admin), validates types against `_DEFAULT_CONFIG`
|
||||||
**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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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
|
||||||
|
<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 **5379–5480** (`.tab-bar` through `.tab-drop-indicator`)
|
### Dashboard regeneration fallback → `app.js` lines 5417–5482 (`showWelcome()`)
|
||||||
- `.tab-bar` (line **5379**): flex container, 36px min-height, border-bottom
|
When `dashboard-home` or its children are missing, `showWelcome()` rebuilds the entire HTML structure inline.
|
||||||
- `.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
|
|
||||||
|
|
||||||
### 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**
|
### Dashboard Bookmarks Widget → `app.js` lines 3583–3660 (`DashboardBookmarkWidget`)
|
||||||
- `.sidebar-tabs` (line **745**)
|
- `load(vaultFilter)` — line 3587
|
||||||
- `.sidebar-tab` (line **754**): uppercase, accent border on active
|
- `render()` — line 3613
|
||||||
- `.sidebar-tab-panel` (line **793**): display none, scrollable
|
- `_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
|
## Architecture Summary
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────┐
|
backend/main.py
|
||||||
│ Frontend (app.js ~8000 lines) │
|
├── Pydantic models (lines 56-284) — request/response schemas
|
||||||
│ ┌──────────┐ ┌──────────────┐ ┌─────────────────┐ │
|
├── SSEManager (lines 349-381) — broadcast to clients
|
||||||
│ │ Sidebar │ │ Content Area │ │ Right Sidebar │ │
|
├── _on_vault_change (lines 390-417) — watcher callback → index update + SSE broadcast
|
||||||
│ │ Tree │ │ TabManager │ │ TOC/Outline │ │
|
├── API endpoints:
|
||||||
│ │ Tags │ │ renderFile() │ │ (slugify) │ │
|
│ ├── /api/health (1048) — public health
|
||||||
│ │ Filter │ │ Breadcrumbs │ │ ReadingProgress │ │
|
│ ├── /api/events (2127) — SSE stream
|
||||||
│ └──────────┘ └──────────────┘ └─────────────────┘ │
|
│ ├── /api/config GET/POST (2464/2470) — app config CRUD
|
||||||
│ ← openFile() → TabManager.open() → api() → backend│
|
│ ├── /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 (FastAPI) │
|
backend/vault_settings.py
|
||||||
│ main.py: │
|
├── Per-vault settings (hideHiddenFiles)
|
||||||
│ PUT /api/file/{vault}/save (line 717) │
|
├── JSON persistence in /app/data/vault_settings.json
|
||||||
│ DELETE /api/file/{vault} (line 753) │
|
|
||||||
│ GET /api/file/{vault} (rendered HTML) │
|
frontend/index.html
|
||||||
│ GET /api/health (line 562) │
|
├── #dashboard-home (341-392) — bookmarks + recent sections
|
||||||
│ _heading_slugify() (line 476) │
|
├── #config-modal (395-564) — full config UI
|
||||||
│ _convert_wikilinks() (line 528) │
|
├── #editor-modal — CodeMirror editor
|
||||||
│ │
|
├── #graph-modal — D3 graph view
|
||||||
│ auth/router.py: │
|
└── #help-modal — user guide
|
||||||
│ POST /api/auth/login (line 97) │
|
|
||||||
│ Rate limiting: lockout only (lines 108-117) │
|
frontend/app.js
|
||||||
│ │
|
├── AuthManager (~1532+)
|
||||||
│ auth/jwt_handler.py: │
|
├── DashboardRecentWidget (3344-3580)
|
||||||
│ ACCESS_TOKEN_EXPIRE_SECONDS = 3600 (line 22) │
|
├── DashboardBookmarkWidget (3583-3660)
|
||||||
│ REFRESH_TOKEN_EXPIRE_SECONDS = 604800 (line 23) │
|
├── initConfigModal (3906-3990)
|
||||||
│ create_access_token() (line 48) │
|
├── loadConfigFields (4043-4070)
|
||||||
│ │
|
├── loadDiagnostics / renderDiagnostics (4157-4207)
|
||||||
│ indexer.py: │
|
├── showWelcome (5417-5482) — dashboard rebuild + render
|
||||||
│ vault_config {} (line 15-16) │
|
├── IndexUpdateManager / SSE client (5773-6015)
|
||||||
│ load_vault_config() (line 50) │
|
├── renderFile (3075-3328) — file view with action buttons
|
||||||
│ ⚠ No IGNORED_DIRS — indexes everything │
|
└── TabManager (7307+) — multi-tab support
|
||||||
│ │
|
|
||||||
│ search.py: │
|
|
||||||
│ ⚠ No wikilink/backlink functions │
|
|
||||||
│ Wikilinks resolved via indexer.find_file_in_index│
|
|
||||||
└─────────────────────────────────────────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Start Here
|
## 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
|
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.
|
||||||
- **No rate limiting** except manual lockout on login
|
|
||||||
- **No backlinks** — neither computed in backend nor displayed in frontend
|
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.
|
||||||
- **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`
|
4. **For Configurations modal**: Open `frontend/index.html` at **line 395** (`#config-modal`) and `frontend/app.js` at **line 3906** (`initConfigModal()`).
|
||||||
- **TabManager** is a self-contained singleton at the end of `app.js` (line 7234) wrapping the original `openFile`
|
|
||||||
|
|||||||
@ -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()`.
|
- ✅ **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()`.
|
- ✅ **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.
|
- ✅ **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`.
|
- ✅ **Timeout de session configurable** — JWT TTL configurable via `OBSIGATE_ACCESS_TOKEN_TTL` et `OBSIGATE_REFRESH_TOKEN_TTL`.
|
||||||
|
|
||||||
### 🟢 Fonctionnel — P3/P4
|
### 🟢 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.
|
- ✅ **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 par vault : fichiers totaux, taille, top tags, orphelins.
|
- ✅ **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** — Notifier des systèmes externes lors de changements (création, modif, suppression).
|
- ✅ **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** — Enrichir les modèles Pydantic pour la doc auto-générée /docs et /redoc.
|
- ✅ **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`.
|
- ✅ **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+
|
### ⬜ Qualité & Polish — P5+
|
||||||
|
|||||||
179
frontend/app.js
179
frontend/app.js
@ -3257,6 +3257,10 @@
|
|||||||
RightSidebarManager.toggle();
|
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
|
// Frontmatter — Accent Card
|
||||||
let fmSection = null;
|
let fmSection = null;
|
||||||
if (data.frontmatter && Object.keys(data.frontmatter).length > 0) {
|
if (data.frontmatter && Object.keys(data.frontmatter).length > 0) {
|
||||||
@ -3297,7 +3301,7 @@
|
|||||||
// Assemble
|
// Assemble
|
||||||
area.innerHTML = "";
|
area.innerHTML = "";
|
||||||
area.appendChild(breadcrumb);
|
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);
|
if (fmSection) area.appendChild(fmSection);
|
||||||
area.appendChild(mdDiv);
|
area.appendChild(mdDiv);
|
||||||
area.appendChild(rawDiv);
|
area.appendChild(rawDiv);
|
||||||
@ -3339,6 +3343,81 @@
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Dashboard Recent Files Widget
|
// 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 = {
|
const DashboardRecentWidget = {
|
||||||
_cache: [],
|
_cache: [],
|
||||||
_currentFilter: "",
|
_currentFilter: "",
|
||||||
@ -3919,6 +3998,8 @@
|
|||||||
loadDiagnostics();
|
loadDiagnostics();
|
||||||
loadAbout();
|
loadAbout();
|
||||||
await loadHiddenFilesSettings();
|
await loadHiddenFilesSettings();
|
||||||
|
loadWebhooksUI();
|
||||||
|
loadSharesUI();
|
||||||
safeCreateIcons();
|
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() {
|
function renderConfigFilters() {
|
||||||
const config = TagFilterService.getConfig();
|
const config = TagFilterService.getConfig();
|
||||||
const filters = config.tagFilters || TagFilterService.defaultFilters;
|
const filters = config.tagFilters || TagFilterService.defaultFilters;
|
||||||
@ -5480,6 +5651,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Show the dashboard widgets
|
// Show the dashboard widgets
|
||||||
|
if (typeof DashboardStatsWidget !== "undefined") {
|
||||||
|
DashboardStatsWidget.load();
|
||||||
|
}
|
||||||
|
if (typeof DashboardConflictsWidget !== "undefined") {
|
||||||
|
DashboardConflictsWidget.load();
|
||||||
|
}
|
||||||
if (typeof DashboardRecentWidget !== "undefined") {
|
if (typeof DashboardRecentWidget !== "undefined") {
|
||||||
DashboardRecentWidget.load(selectedContextVault);
|
DashboardRecentWidget.load(selectedContextVault);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -358,6 +358,19 @@
|
|||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<main class="content-area" id="content-area" aria-label="Contenu principal">
|
<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">
|
<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 -->
|
<!-- Bookmarks Section -->
|
||||||
<div id="dashboard-bookmarks-section" class="dashboard-section">
|
<div id="dashboard-bookmarks-section" class="dashboard-section">
|
||||||
<div class="dashboard-header">
|
<div class="dashboard-header">
|
||||||
@ -374,6 +387,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Recently Opened Section -->
|
||||||
<div id="dashboard-recent-section" class="dashboard-section">
|
<div id="dashboard-recent-section" class="dashboard-section">
|
||||||
<div class="dashboard-header">
|
<div class="dashboard-header">
|
||||||
@ -636,6 +664,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -5560,3 +5560,187 @@ body.popup-mode .content-area {
|
|||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
display: inline-flex;
|
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
375
plan.md
Normal 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 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** (`</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 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) |
|
||||||
Loading…
x
Reference in New Issue
Block a user