ajout de fonctionnalités
This commit is contained in:
+27
-2
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user