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
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:
+32
-502
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user