ajout de fonctionnalités

This commit is contained in:
2026-05-25 20:21:42 -04:00
parent e1fcbe9ce7
commit 370420aa00
7 changed files with 1412 additions and 6 deletions
+27 -2
View File
@@ -582,6 +582,30 @@ def _remove_file_from_structures(vault_name: str, rel_path: str) -> Optional[Dic
return removed
def _ensure_parent_dirs_in_path_index(vault_name: str, rel_path: str, existing: set):
"""Ensure all parent directories of a file path exist in path_index.
For a path like ``a/b/c/file.md``, ensures ``a``, ``a/b``, and ``a/b/c``
directory entries exist in path_index.
Args:
vault_name: Name of the vault.
rel_path: Relative path of the file (e.g. ``a/b/c/file.md``).
existing: Set of paths already in path_index for this vault.
"""
parts = rel_path.split("/")
# Build parent directory paths
for i in range(1, len(parts)):
dir_path = "/".join(parts[:i])
if dir_path and dir_path not in existing:
existing.add(dir_path)
path_index[vault_name].append({
"path": dir_path,
"name": parts[i - 1],
"type": "directory",
})
def _add_file_to_structures(vault_name: str, file_info: Dict[str, Any]):
"""Add a file entry to all index structures.
@@ -608,9 +632,8 @@ def _add_file_to_structures(vault_name: str, file_info: Dict[str, Any]):
_file_lookup[key] = []
_file_lookup[key].append(entry)
# Add to path_index
# Add to path_index — also ensures parent directories are present
if vault_name in path_index:
# Check if already present (avoid duplicates)
existing = {p["path"] for p in path_index[vault_name]}
if rel_path not in existing:
path_index[vault_name].append({
@@ -618,6 +641,8 @@ def _add_file_to_structures(vault_name: str, file_info: Dict[str, Any]):
"name": rel_path.rsplit("/", 1)[-1],
"type": "file",
})
# Ensure all parent directories are in path_index
_ensure_parent_dirs_in_path_index(vault_name, rel_path, existing)
_index_generation += 1
+260 -1
View File
@@ -221,6 +221,30 @@ class TagSuggestResponse(BaseModel):
suggestions: List[TagSuggestion]
class GraphNode(BaseModel):
"""A single node in the graph view."""
id: str = Field(description="Unique node identifier")
name: str = Field(description="Display name")
type: str = Field(description="'vault', 'directory', or 'file'")
path: str = Field(description="Relative path within vault")
size: int = Field(default=0, description="File size in bytes")
class GraphEdge(BaseModel):
"""An edge between two nodes in the graph view."""
source: str = Field(description="Source node ID")
target: str = Field(description="Target node ID")
relation: str = Field(description="'parent' or 'wikilink'")
class GraphResponse(BaseModel):
"""Graph data for a vault or directory."""
vault: str
path: str
nodes: List[GraphNode]
edges: List[GraphEdge]
class ReloadResponse(BaseModel):
"""Index reload confirmation with per-vault stats."""
status: str
@@ -595,6 +619,71 @@ def _check_vault_writable(vault_root: Path) -> bool:
# Markdown rendering helpers (singleton renderer)
# ---------------------------------------------------------------------------
import unicodedata
def _heading_slugify(text: str) -> str:
"""Generate a URL-safe slug from heading text.
Matches the JavaScript slugify algorithm exactly:
1. Lowercase
2. NFD normalize + strip combining marks
3. Keep only Unicode letters, numbers, spaces, hyphens
4. Replace spaces with hyphens, collapse multiple hyphens
Args:
text: The heading text content.
Returns:
A URL-safe slug string.
"""
text = text.lower()
text = unicodedata.normalize("NFD", text)
text = "".join(ch for ch in text if not unicodedata.combining(ch))
# Keep only Unicode letters, numbers, spaces, and hyphens
cleaned = []
for ch in text:
if ch.isalpha() or ch.isdigit() or ch in (" ", "-"):
cleaned.append(ch)
text = "".join(cleaned)
text = re.sub(r"\s+", "-", text)
text = re.sub(r"-+", "-", text)
return text.strip("-") or "heading"
def _add_heading_ids(html: str) -> str:
"""Post-process rendered HTML to add IDs to heading tags.
Adds an ``id`` attribute to every ``<h1>`` through ``<h6>`` tag
using a slug generated from the heading's text content.
Duplicate slugs get a ``-2``, ``-3``, etc. suffix.
Args:
html: Rendered HTML string.
Returns:
HTML with heading IDs injected.
"""
used_ids: Dict[str, int] = {}
def _replace_heading(match):
tag = match.group(1)
content = match.group(2)
slug = _heading_slugify(content)
count = used_ids.get(slug, 0)
used_ids[slug] = count + 1
if count > 0:
slug = f"{slug}-{count + 1}"
return f'<{tag} id="{slug}">{content}</{tag}>'
# Match h1-h6 tags with text content (no existing id attribute)
return re.sub(
r'<(h[1-6])>([^<]*(?:<(?!/?h[1-6])[^<]*)*)</h[1-6]>',
_replace_heading,
html,
)
# Cached mistune renderer — avoids re-creating on every request
_markdown_renderer = mistune.create_markdown(
escape=False,
@@ -656,7 +745,12 @@ def _render_markdown(raw_md: str, vault_name: str, current_file_path: Optional[P
# Convert wikilinks
converted = _convert_wikilinks(raw_md, vault_name)
return _markdown_renderer(converted)
rendered = _markdown_renderer(converted)
# Add heading IDs for TOC navigation
rendered = _add_heading_ids(rendered)
return rendered
# ---------------------------------------------------------------------------
@@ -1165,6 +1259,26 @@ async def api_directory_create(
dir_path.mkdir(parents=True, exist_ok=False)
logger.info(f"Directory created: {vault_name}/{body.path}")
# Update path_index with the new directory
from backend.indexer import path_index as _path_idx, _index_generation
import threading
from backend.indexer import _index_lock
with _index_lock:
if vault_name not in _path_idx:
_path_idx[vault_name] = []
existing = {p["path"] for p in _path_idx[vault_name]}
# Build all parent segments
parts = body.path.split("/")
for i in range(1, len(parts) + 1):
seg_path = "/".join(parts[:i])
if seg_path and seg_path not in existing:
existing.add(seg_path)
_path_idx[vault_name].append({
"path": seg_path,
"name": parts[i - 1],
"type": "directory",
})
# Broadcast SSE event
await sse_manager.broadcast("directory_created", {
"vault": vault_name,
@@ -1744,6 +1858,151 @@ async def api_reload(current_user=Depends(require_admin)):
return {"status": "ok", "vaults": stats}
@app.get("/api/graph/{vault_name}", response_model=GraphResponse)
async def api_graph(
vault_name: str,
path: str = Query("", description="Relative path to focus on"),
depth: int = Query(1, ge=0, le=3, description="How many levels deep to expand"),
current_user=Depends(require_auth),
):
"""Return graph data (nodes and edges) for a vault or directory.
Nodes represent files and directories. Edges represent parent-child
relationships and wikilinks between markdown files.
Args:
vault_name: Name of the vault.
path: Relative directory path to focus on (empty = root).
depth: Expansion depth (0 = only direct children, 1-3 = deeper).
Returns:
``GraphResponse`` with nodes and edges.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_root = Path(vault_data["path"])
target = _resolve_safe_path(vault_root, path) if path else vault_root.resolve()
if not target.exists():
raise HTTPException(status_code=404, detail=f"Path not found: {path}")
nodes: List[dict] = []
edges: List[dict] = []
node_ids: set = set()
def _add_node(name: str, ntype: str, npath: str, size: int = 0) -> str:
nid = f"{vault_name}:{npath}"
if nid not in node_ids:
node_ids.add(nid)
nodes.append({"id": nid, "name": name, "type": ntype, "path": npath, "size": size})
return nid
def _add_edge(source: str, target: str, relation: str):
edges.append({"source": source, "target": target, "relation": relation})
# Get vault settings for hidden files
from backend.vault_settings import get_vault_setting
settings = get_vault_setting(vault_name) or {}
hide_hidden = settings.get("hideHiddenFiles", False)
# Add the focus node
focus_name = path.split("/")[-1] if path else vault_name
focus_type = "directory" if path else "vault"
focus_id = _add_node(focus_name, focus_type, path)
# Walk directory tree up to depth levels
def _walk_dir(dir_path: Path, parent_id: str, current_depth: int):
if current_depth > depth:
return
try:
for entry in sorted(dir_path.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())):
if hide_hidden and entry.name.startswith("."):
continue
rel = str(entry.relative_to(vault_root)).replace("\\", "/")
if entry.is_dir():
did = _add_node(entry.name, "directory", rel)
_add_edge(parent_id, did, "parent")
if current_depth < depth:
_walk_dir(entry, did, current_depth + 1)
elif entry.suffix.lower() in SUPPORTED_EXTENSIONS or entry.name.lower() in ("dockerfile", "makefile"):
fid = _add_node(entry.name, "file", rel, entry.stat().st_size)
_add_edge(parent_id, fid, "parent")
except PermissionError:
pass
if target.is_dir():
_walk_dir(target, focus_id, 0)
elif target.is_file():
# For a single file, show siblings in same directory
_walk_dir(target.parent, focus_id, 0)
# Add wikilink edges between markdown files in the current scope
_add_wikilink_edges(nodes, edges, node_ids, vault_name)
return {"vault": vault_name, "path": path, "nodes": nodes, "edges": edges}
def _add_wikilink_edges(nodes: list, edges: list, node_ids: set, vault_name: str):
"""Add edges for wikilinks between markdown files in the current graph scope."""
from backend.indexer import find_file_in_index
# Only consider files nodes
file_nodes = [n for n in nodes if n["type"] == "file" and n["path"].endswith(".md")]
if len(file_nodes) < 2:
return
# Build lookup: relative_path → node_id
path_to_id = {n["path"]: n["id"] for n in file_nodes}
wikilink_pattern = re.compile(r"\[\[([^\]|#]+)(?:[|#][^\]]+)?\]\]")
for node in file_nodes:
# Get the file content from index
vault_data = index.get(vault_name)
if not vault_data:
continue
file_entry = None
for f in vault_data.get("files", []):
if f["path"] == node["path"]:
file_entry = f
break
if not file_entry:
continue
content = file_entry.get("content", "")
if not content:
continue
# Find all wikilinks in content
for match in wikilink_pattern.finditer(content):
target = match.group(1).strip()
# Try to find the target in our graph scope first
target_lower = target.lower()
if not target_lower.endswith(".md"):
target_lower += ".md"
for target_path, target_id in path_to_id.items():
if target_id == node["id"]:
continue
target_name = target_path.rsplit("/", 1)[-1].lower()
if target_name == target_lower or target_path.lower() == target_lower:
# Avoid duplicate edges
edge_key = tuple(sorted([node["id"], target_id]))
if edge_key not in {(e["source"], e["target"]) for e in edges} and \
edge_key not in {(e["target"], e["source"]) for e in edges}:
edges.append({
"source": node["id"],
"target": target_id,
"relation": "wikilink"
})
break
@app.get("/api/index/reload/{vault_name}")
async def api_reload_vault(vault_name: str, current_user=Depends(require_admin)):
"""Force a re-index of a single vault.