feat(ai): catalogue d'outils lecture & recherche (#79 phase C)
CI / lint (push) Successful in 59s
CI / security (push) Successful in 49s
CI / test (push) Successful in 1m24s
CI / build (push) Successful in 37s
CI / e2e (push) Successful in 10m24s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s

This commit is contained in:
2026-09-11 20:33:15 -04:00
parent 7cf7d33b6d
commit 31b65ade5b
13 changed files with 1220 additions and 524 deletions
+32 -502
View File
@@ -28,8 +28,6 @@ from starlette.middleware.base import BaseHTTPMiddleware
from backend.attachment_indexer import get_attachment_stats, rescan_vault_attachments
from backend.history import (
get_bookmarks,
get_recent_opened,
is_bookmarked,
record_open,
remove_recent,
toggle_bookmark,
@@ -38,7 +36,6 @@ from backend.history import (
)
from backend.image_processor import preprocess_images
from backend.indexer import (
IGNORED_DIRS,
SUPPORTED_EXTENSIONS,
_extract_tags,
add_vault_to_index,
@@ -50,7 +47,6 @@ from backend.indexer import (
handle_file_move,
index,
parse_markdown_file,
path_index,
reload_index,
remove_single_file,
remove_vault_from_index,
@@ -103,11 +99,20 @@ from backend.search import (
suggest_tags,
suggest_titles,
)
from backend.services.backups import diff_backup as service_diff_backup
from backend.services.backups import get_backup_dir as service_get_backup_dir
from backend.services.backups import list_backup_files as service_list_backup_files
from backend.services.errors import ServiceError
from backend.services.files import read_raw_file
from backend.services.graph import get_graph as service_get_graph
from backend.services.recent import humanize_mtime, list_recent
from backend.services.search import advanced_search_vaults, search_paths, search_vaults
from backend.services.search import list_tags as service_list_tags
from backend.services.search import search_vaults
from backend.services.vaults import browse_directory, list_accessible_vaults
from backend.services.vaults import (
browse_directory,
list_accessible_vaults,
list_all_files,
)
from backend.vault_settings import (
get_vault_setting,
update_vault_setting,
@@ -1272,111 +1277,21 @@ async def api_vaults(current_user=Depends(require_auth)):
return list_accessible_vaults(current_user)
def humanize_mtime(mtime: float) -> str:
delta = time.time() - mtime
if delta < 60:
return "à l'instant"
if delta < 3600:
return f"il y a {int(delta/60)} min"
if delta < 86400:
return f"il y a {int(delta/3600)} h"
if delta < 604800:
return f"il y a {int(delta/86400)} j"
return datetime.fromtimestamp(mtime).strftime("%d %b %Y")
@app.get("/api/recent", response_model=RecentResponse)
async def api_recent(limit: int | None = Query(None), vault: str | None = Query(None), mode: str | None = Query("opened"), current_user=Depends(require_auth)):
config = _load_config()
actual_limit = limit if limit is not None else config.get("recent_files_limit", 20)
username = current_user.get("username")
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
if mode == "opened" and username:
# Use history file for "last opened"
history = get_recent_opened(username, vault_filter=vault, limit=actual_limit)
files_resp = []
for item in history:
v_name = item["vault"]
if "*" not in user_vaults and v_name not in user_vaults:
continue
# Find in index to get metadata/preview
f_idx = find_file_in_index(item["path"], v_name)
if f_idx:
files_resp.append({
"path": f_idx["path"],
"title": f_idx.get("title") or item["path"].split("/")[-1],
"vault": v_name,
"mtime": item["opened_at"],
"mtime_human": humanize_mtime(item["opened_at"]),
"size_bytes": f_idx.get("size", 0),
"tags": [f"#{t}" for t in f_idx.get("tags", [])][:5],
"preview": f_idx.get("content_preview", "")[:120],
"bookmarked": is_bookmarked(username, v_name, f_idx["path"])
})
else:
# File might have been renamed/deleted since last open
files_resp.append({
"path": item["path"],
"title": item.get("title") or item["path"].split("/")[-1],
"vault": v_name,
"mtime": item["opened_at"],
"mtime_human": humanize_mtime(item["opened_at"]),
"tags": [],
"preview": "",
"bookmarked": is_bookmarked(username, v_name, item["path"])
})
return {
"files": files_resp,
"total": len(files_resp),
"limit": actual_limit,
"mode": "opened"
}
# Fallback to "last modified" (original logic)
all_files = []
for v_name, v_data in index.items():
if vault and v_name != vault:
continue
if "*" not in user_vaults and v_name not in user_vaults:
continue
for f in v_data.get("files", []):
all_files.append((v_name, f))
# Sort descending by ISO string "modified"
all_files.sort(key=lambda x: x[1].get("modified", ""), reverse=True)
recent = all_files[:actual_limit]
files_resp = []
for v_name, f in recent:
iso_modified = f.get("modified", "")
try:
mtime_dt = datetime.fromisoformat(iso_modified.replace("Z", "+00:00"))
mtime_val = mtime_dt.timestamp()
except Exception:
mtime_val = time.time()
files_resp.append({
"path": f["path"],
"title": f["title"],
"vault": v_name,
"mtime": mtime_val,
"mtime_human": humanize_mtime(mtime_val),
"mtime_iso": iso_modified,
"size_bytes": f.get("size", 0),
"tags": [f"#{t}" for t in f.get("tags", [])][:5],
"preview": f.get("content_preview", "")[:120],
"bookmarked": is_bookmarked(username, v_name, f["path"])
})
return {
"files": files_resp,
"total": len(all_files),
"limit": actual_limit,
"mode": "modified"
}
return list_recent(
username,
user_vaults,
vault=vault,
limit=actual_limit,
mode=mode or "opened",
)
@app.get("/api/bookmarks", response_model=BookmarksResponse)
@@ -2315,73 +2230,20 @@ async def api_file_move(
def _get_backup_dir(vault_name: str, relative_path: str) -> Path:
"""Return the directory where backups for a specific file are stored.
Resolves relative backup paths against the SPECIFIC vault's directory,
matching _backup_file's resolution logic exactly.
Thin wrapper around :func:`backend.services.backups.get_backup_dir`
(single implementation used by both routes and tools).
"""
backup_root = Path(os.environ.get("OBSIGATE_BACKUP_DIR", ".obsigate-backup"))
if not backup_root.is_absolute():
vault_data = get_vault_data(vault_name)
if vault_data:
backup_root = Path(vault_data["path"]) / backup_root
return backup_root / vault_name / Path(relative_path).parent
return service_get_backup_dir(vault_name, relative_path)
def _list_backup_files(vault_name: str, relative_path: str) -> list[dict]:
"""List all backup files for a given vault file, sorted newest first.
Backup filename format: {original_filename}.{timestamp}.bak
Returns a list of dicts with timestamp, datetime, size, filename.
Thin wrapper around :func:`backend.services.backups.list_backup_files`
(single implementation used by both routes and tools).
"""
backup_dir = _get_backup_dir(vault_name, relative_path)
if not backup_dir.exists():
return []
original_name = Path(relative_path).name
prefix = original_name + "."
backups: list[dict[str, Any]] = []
try:
dir_entries = list(backup_dir.iterdir())
except PermissionError:
logger.warning(f"Permission denied reading backup dir: {backup_dir}")
return []
except OSError as e:
logger.error(f"Error reading backup dir {backup_dir}: {e}")
return []
for f in dir_entries:
try:
if not f.is_file():
continue
name = f.name
if not name.startswith(prefix) or not name.endswith(".bak"):
continue
# Extract timestamp: strip prefix and .bak suffix
ts_part = name[len(prefix):-len(".bak")]
try:
ts = int(ts_part)
except (ValueError, TypeError):
continue
try:
dt = datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
except (OSError, OverflowError, ValueError) as ts_err:
logger.warning(f"Skipping backup with invalid timestamp {ts}: {ts_err}")
continue
st = f.stat()
backups.append({
"timestamp": ts,
"datetime": dt,
"size": st.st_size,
"filename": name,
})
except Exception as entry_err:
logger.warning(f"Skipping unreadable backup entry {f}: {entry_err}")
continue
backups.sort(key=lambda b: b["timestamp"], reverse=True)
return backups
return service_list_backup_files(vault_name, relative_path)
@app.get("/api/file/{vault_name}/backups", response_model=BackupsResponse)
@@ -2444,69 +2306,7 @@ async def api_file_diff(
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"])
file_path = _resolve_safe_path(vault_root, path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail=f"File not found: {path}")
original_name = Path(path).name
def _read_backup(ts: int) -> tuple[str, str]:
"""Read a backup file. Returns (content, label)."""
backup_dir = _get_backup_dir(vault_name, path)
backup_path = backup_dir / f"{original_name}.{ts}.bak"
if not backup_path.exists():
raise HTTPException(status_code=404, detail=f"Backup version {ts} not found for {path}")
try:
content = backup_path.read_text(encoding="utf-8", errors="replace")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to read backup {ts}: {e}")
dt = datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
return content, f"{path}@{dt}"
try:
# Read left side (backup version)
left_content, left_label = _read_backup(version)
# Read right side
if compare_with is not None:
right_content, right_label = _read_backup(compare_with)
else:
try:
right_content = file_path.read_text(encoding="utf-8", errors="replace")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to read current file: {e}")
right_label = f"{path} (current)"
# Generate unified diff using difflib
import difflib
left_lines = left_content.splitlines(keepends=True)
right_lines = right_content.splitlines(keepends=True)
diff_lines = list(difflib.unified_diff(
left_lines, right_lines,
fromfile=left_label, tofile=right_label,
))
return {
"vault": vault_name,
"path": path,
"version": version,
"compare_with": compare_with,
"diff": "".join(diff_lines),
"left_content": left_content,
"right_content": right_content,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error generating diff for {vault_name}/{path}: {type(e).__name__}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Erreur lors de la génération du diff: {e!s}")
return service_diff_backup(vault_name, path, version, compare_with)
@app.post("/api/file/{vault_name}/restore", response_model=RestoreResponse)
@@ -3069,31 +2869,7 @@ async def api_tree_search(
Returns:
``TreeSearchResponse`` with matching paths.
"""
if not q:
return {"query": q, "vault_filter": vault, "results": []}
query_lower = q.lower()
results = []
vaults_to_search = [vault] if vault != "all" else list(path_index.keys())
for vault_name in vaults_to_search:
vault_paths = path_index.get(vault_name, [])
for entry in vault_paths:
path_lower = entry["path"].lower()
name_lower = entry["name"].lower()
if query_lower in name_lower or query_lower in path_lower:
results.append({
"vault": vault_name,
"path": entry["path"],
"name": entry["name"],
"type": entry["type"],
"matched_path": entry["path"],
})
return {"query": q, "vault_filter": vault, "results": results}
return search_paths(q, vault)
@app.get("/api/search/advanced", response_model=AdvancedSearchResponse)
@@ -3134,8 +2910,8 @@ async def api_advanced_search(
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
_search_executor,
partial(advanced_search, q, vault_filter=vault, tag_filter=tag,
limit=limit, offset=offset, sort_by=sort,
partial(advanced_search_vaults, q, vault=vault, tag=tag,
limit=limit, offset=offset, sort=sort,
case_sensitive=case_sensitive, whole_word=whole_word, regex=regex,
include_paths=include_paths, exclude_paths=exclude_paths,
created=created, modified=modified, size=size),
@@ -3318,175 +3094,7 @@ async def api_graph(
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,
tags: list[str] | None = None, incoming: int = 0, outgoing: 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, "tags": tags or [],
"incoming_count": incoming, "outgoing_count": outgoing,
})
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)
# Build tag index from the in-memory index for fast lookups
_tag_index: dict[str, list[str]] = {}
for doc_key, info in index.items():
vn, fp = doc_key.split("::", 1) if "::" in doc_key else ("", "")
if vn == vault_name:
for t in info.get("tags", []):
_tag_index.setdefault(t.lower(), []).append(fp)
# Determine scope
if scope == "full":
# Full vault — walk entire vault root, ignore path param
target = vault_root.resolve()
effective_depth = depth if depth > 0 else 2 # minimum depth 2 for full view
else:
target = _resolve_safe_path(vault_root, path) if path else vault_root.resolve()
effective_depth = depth
if not target.exists():
raise HTTPException(status_code=404, detail=f"Path not found: {path}")
# 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 > effective_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("\\", "/")
# Tag filter: skip files that don't have the requested tag
if tag and entry.is_file():
file_tags = [t.lower() for t in _tag_index.get(rel, [])]
if tag.lower() not in file_tags:
continue
if entry.is_dir():
did = _add_node(entry.name, "directory", rel)
_add_edge(parent_id, did, "parent")
if current_depth < effective_depth:
_walk_dir(entry, did, current_depth + 1)
elif entry.suffix.lower() in SUPPORTED_EXTENSIONS or entry.name.lower() in ("dockerfile", "makefile"):
file_tags = _tag_index.get(rel, [])
fid = _add_node(entry.name, "file", rel, entry.stat().st_size, tags=file_tags)
_add_edge(parent_id, fid, "parent")
except PermissionError:
pass
if target.is_dir():
_walk_dir(target, focus_id, 0)
elif target.is_file():
_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)
# Compute incoming/outgoing counts from edges
edge_counts: dict[str, dict[str, int]] = {}
for node in nodes:
edge_counts[node["id"]] = {"incoming": 0, "outgoing": 0}
for edge in edges:
if edge["relation"] in ("wikilink", "backlink"):
src = edge["source"]
tgt = edge["target"]
if src in edge_counts:
edge_counts[src]["outgoing"] += 1
if tgt in edge_counts:
edge_counts[tgt]["incoming"] += 1
for node in nodes:
counts = edge_counts.get(node["id"], {"incoming": 0, "outgoing": 0})
node["incoming_count"] = counts["incoming"]
node["outgoing_count"] = counts["outgoing"]
return {"vault": vault_name, "path": path, "scope": scope,
"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."""
# 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
return service_get_graph(vault_name, path=path, depth=depth, scope=scope, tag=tag)
@app.get("/api/index/reload/{vault_name}", response_model=VaultStatsResponse)
@@ -3814,85 +3422,7 @@ async def api_vault_recent_files(
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"])
dir_path = _resolve_safe_path(vault_root, dir) if dir else vault_root
if not dir_path.exists() or not dir_path.is_dir():
raise HTTPException(status_code=404, detail=f"Directory not found: {dir}")
files: list[dict[str, Any]] = []
dir_prefix = (dir or "").strip("/")
try:
iterator = dir_path.rglob('*') if recursive else dir_path.iterdir()
for entry in iterator:
if not entry.is_file():
continue
if entry.name.startswith('.'):
continue
if entry.name in IGNORED_DIRS:
continue
# Skip entries whose parent dir chain contains an ignored dir (for rglob)
if recursive and dir_prefix:
skip = False
try:
for part in entry.relative_to(dir_path).parts[:-1]:
if part.startswith('.') or part in IGNORED_DIRS:
skip = True
break
except ValueError:
pass
if skip:
continue
stat = entry.stat()
rel_path = str(entry.relative_to(vault_root)).replace("\\", "/")
# Compute relative directory from the requested dir
if recursive and dir_prefix:
try:
rel_to_dir = str(entry.parent.relative_to(dir_path)).replace("\\", "/")
except ValueError:
rel_to_dir = ""
else:
rel_to_dir = ""
ext = entry.suffix.lower() if entry.suffix else ""
file_entry = {
"name": entry.name,
"path": rel_path,
"vault": vault_name,
"size": stat.st_size,
"modified": stat.st_mtime,
"modified_iso": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
"extension": ext.lstrip(".") if ext else "",
}
if rel_to_dir and rel_to_dir != ".":
file_entry["rel_dir"] = rel_to_dir
files.append(file_entry)
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied reading directory")
# Sort by modification time, newest first
files.sort(key=lambda f: f["modified"], reverse=True)
# Apply limit
files = files[:limit]
return {
"vault": vault_name,
"directory": dir,
"recursive": recursive,
"count": len(files),
"files": files,
}
return list_all_files(vault_name, dir=dir, limit=limit, recursive=recursive)
@app.get("/api/vaults/settings/all", response_model=AllVaultSettingsResponse)