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:
@@ -0,0 +1,195 @@
|
||||
"""Backup inspection services shared by REST routes and the AI tool layer.
|
||||
|
||||
Single source of truth for locating, listing and diffing the timestamped
|
||||
backups created before each file mutation. The write-side (creating a backup)
|
||||
stays in the route layer; only the read/inspection logic lives here so both
|
||||
``/api/file/{vault}/backups``, ``/api/file/{vault}/diff`` and the tools
|
||||
``list_backups`` / ``diff_backup`` behave identically.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from backend.services.errors import ServiceError
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.services.vaults import get_vault_root
|
||||
|
||||
logger = logging.getLogger("obsigate.services.backups")
|
||||
|
||||
|
||||
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 the backup-creation logic exactly.
|
||||
"""
|
||||
from backend.indexer import get_vault_data
|
||||
|
||||
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
|
||||
|
||||
|
||||
def list_backup_files(vault_name: str, relative_path: str) -> list[dict[str, Any]]:
|
||||
"""List all backup files for a vault file, sorted newest first.
|
||||
|
||||
Backup filename format: ``{original_filename}.{timestamp}.bak``.
|
||||
|
||||
Returns a list of dicts with ``timestamp``, ``datetime``, ``size`` and
|
||||
``filename``. Missing or unreadable backup directories yield an empty list.
|
||||
"""
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
def diff_backup(
|
||||
vault_name: str,
|
||||
path: str,
|
||||
version: int,
|
||||
compare_with: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Generate a unified diff between a backup version and another version or the current file.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative path of the file within the vault.
|
||||
version: Timestamp of the backup used as the old/left side.
|
||||
compare_with: Optional timestamp of another backup as the new/right
|
||||
side. If omitted, the current file on disk is used.
|
||||
|
||||
Returns:
|
||||
Dict with ``vault``, ``path``, ``version``, ``compare_with``, ``diff``,
|
||||
``left_content`` and ``right_content``.
|
||||
|
||||
Raises:
|
||||
ServiceError: ``not_found`` (404) when the file or a backup is missing,
|
||||
``read_error`` (500) when a file cannot be read.
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise ServiceError(
|
||||
f"File not found: {path}",
|
||||
code="not_found",
|
||||
status=404,
|
||||
details={"vault": vault_name, "path": path},
|
||||
)
|
||||
|
||||
original_name = Path(path).name
|
||||
|
||||
def _read_backup(ts: int) -> tuple[str, str]:
|
||||
backup_dir = get_backup_dir(vault_name, path)
|
||||
backup_path = backup_dir / f"{original_name}.{ts}.bak"
|
||||
if not backup_path.exists():
|
||||
raise ServiceError(
|
||||
f"Backup version {ts} not found for {path}",
|
||||
code="not_found",
|
||||
status=404,
|
||||
details={"vault": vault_name, "path": path, "version": ts},
|
||||
)
|
||||
try:
|
||||
content = backup_path.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception as e:
|
||||
raise ServiceError(
|
||||
f"Failed to read backup {ts}: {e}",
|
||||
code="read_error",
|
||||
status=500,
|
||||
details={"vault": vault_name, "path": path, "version": ts},
|
||||
) from e
|
||||
dt = datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
return content, f"{path}@{dt}"
|
||||
|
||||
try:
|
||||
left_content, left_label = _read_backup(version)
|
||||
|
||||
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 ServiceError(
|
||||
f"Failed to read current file: {e}",
|
||||
code="read_error",
|
||||
status=500,
|
||||
details={"vault": vault_name, "path": path},
|
||||
) from e
|
||||
right_label = f"{path} (current)"
|
||||
|
||||
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 ServiceError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating diff for {vault_name}/{path}: {type(e).__name__}: {e}", exc_info=True)
|
||||
raise ServiceError(f"Erreur lors de la génération du diff: {e!s}", code="read_error", status=500) from e
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Graph data services shared by the REST route and the AI tool layer.
|
||||
|
||||
Builds the nodes/edges structure consumed by the graph view. The route
|
||||
``/api/graph/{vault}`` and the tool ``get_graph`` both delegate here so the
|
||||
graph semantics (parent/child edges, wikilinks, tag filter) stay identical.
|
||||
Permission checks are the caller's responsibility.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from backend.indexer import SUPPORTED_EXTENSIONS, index
|
||||
from backend.services.errors import ServiceError
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.services.vaults import get_vault_root
|
||||
|
||||
_WIKILINK_PATTERN = re.compile(r"\[\[([^\]|#]+)(?:[|#][^\]]+)?\]\]")
|
||||
|
||||
|
||||
def get_graph(
|
||||
vault_name: str,
|
||||
path: str = "",
|
||||
depth: int = 1,
|
||||
scope: str = "directory",
|
||||
tag: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Return graph data (nodes and edges) for a vault or directory.
|
||||
|
||||
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).
|
||||
scope: ``"directory"`` for a subtree, ``"full"`` for the whole vault.
|
||||
tag: Optional tag filter (only files with this tag appear).
|
||||
|
||||
Returns:
|
||||
Dict with ``vault``, ``path``, ``scope``, ``nodes`` and ``edges``.
|
||||
|
||||
Raises:
|
||||
ServiceError: ``not_found`` (404) when the vault or path is missing.
|
||||
"""
|
||||
from backend.vault_settings import get_vault_setting
|
||||
|
||||
vault_root = get_vault_root(vault_name)
|
||||
target = resolve_safe_path(vault_root, path) if path else vault_root.resolve()
|
||||
|
||||
if not target.exists():
|
||||
raise ServiceError(
|
||||
f"Path not found: {path}",
|
||||
code="not_found",
|
||||
status=404,
|
||||
details={"vault": vault_name, "path": path},
|
||||
)
|
||||
|
||||
nodes: list[dict[str, Any]] = []
|
||||
edges: list[dict[str, Any]] = []
|
||||
node_ids: set[str] = 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_id: str, relation: str) -> None:
|
||||
edges.append({"source": source, "target": target_id, "relation": relation})
|
||||
|
||||
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)
|
||||
|
||||
if scope == "full":
|
||||
target = vault_root.resolve()
|
||||
effective_depth = depth if depth > 0 else 2
|
||||
else:
|
||||
target = resolve_safe_path(vault_root, path) if path else vault_root.resolve()
|
||||
effective_depth = depth
|
||||
|
||||
if not target.exists():
|
||||
raise ServiceError(
|
||||
f"Path not found: {path}",
|
||||
code="not_found",
|
||||
status=404,
|
||||
details={"vault": vault_name, "path": path},
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
def _walk_dir(dir_path: Path, parent_id: str, current_depth: int) -> None:
|
||||
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("\\", "/")
|
||||
|
||||
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(nodes, edges, vault_name)
|
||||
|
||||
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[dict[str, Any]], edges: list[dict[str, Any]], vault_name: str) -> None:
|
||||
"""Add edges for wikilinks between markdown files in the current graph scope."""
|
||||
file_nodes = [n for n in nodes if n["type"] == "file" and n["path"].endswith(".md")]
|
||||
if len(file_nodes) < 2:
|
||||
return
|
||||
|
||||
path_to_id = {n["path"]: n["id"] for n in file_nodes}
|
||||
existing = {(e["source"], e["target"]) for e in edges}
|
||||
|
||||
for node in file_nodes:
|
||||
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
|
||||
|
||||
for match in _WIKILINK_PATTERN.finditer(content):
|
||||
target = match.group(1).strip()
|
||||
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:
|
||||
edge_key = tuple(sorted([node["id"], target_id]))
|
||||
if edge_key not in existing and (edge_key[1], edge_key[0]) not in existing:
|
||||
edges.append({"source": node["id"], "target": target_id, "relation": "wikilink"})
|
||||
existing.add((node["id"], target_id))
|
||||
break
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Recent-files services shared by the REST route and the AI tool layer.
|
||||
|
||||
Provides ``list_recent`` (last opened files, falling back to last modified)
|
||||
and ``humanize_mtime``. The route ``/api/recent`` and the tool ``list_recent``
|
||||
both delegate here. Permission filtering is applied against the caller's
|
||||
vault list.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from backend.history import get_recent_opened, is_bookmarked
|
||||
from backend.indexer import find_file_in_index, index
|
||||
|
||||
|
||||
def humanize_mtime(mtime: float) -> str:
|
||||
"""Return a short, human-friendly French rendering of a timestamp."""
|
||||
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")
|
||||
|
||||
|
||||
def _can_access(vault: str, user_vaults: list[str]) -> bool:
|
||||
return "*" in user_vaults or vault in user_vaults
|
||||
|
||||
|
||||
def list_recent(
|
||||
username: str | None,
|
||||
user_vaults: list[str] | None = None,
|
||||
*,
|
||||
vault: str | None = None,
|
||||
limit: int = 20,
|
||||
mode: str = "opened",
|
||||
) -> dict[str, Any]:
|
||||
"""Return the caller's recent files.
|
||||
|
||||
Args:
|
||||
username: Caller username (needed for the "opened" history mode).
|
||||
user_vaults: Vaults the caller may access (``["*"]`` = all).
|
||||
vault: Optional single-vault filter.
|
||||
limit: Maximum number of files to return.
|
||||
mode: ``"opened"`` to use the open history, anything else for the
|
||||
last-modified fallback.
|
||||
|
||||
Returns:
|
||||
Dict with ``files``, ``total``, ``limit`` and ``mode``.
|
||||
"""
|
||||
user_vaults = user_vaults or []
|
||||
|
||||
if mode == "opened" and username:
|
||||
history = get_recent_opened(username, vault_filter=vault, limit=limit)
|
||||
files_resp: list[dict[str, Any]] = []
|
||||
for item in history:
|
||||
v_name = item["vault"]
|
||||
if not _can_access(v_name, user_vaults):
|
||||
continue
|
||||
|
||||
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:
|
||||
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": limit,
|
||||
"mode": "opened",
|
||||
}
|
||||
|
||||
all_files: list[tuple[str, dict[str, Any]]] = []
|
||||
for v_name, v_data in index.items():
|
||||
if vault and v_name != vault:
|
||||
continue
|
||||
if not _can_access(v_name, user_vaults):
|
||||
continue
|
||||
for f in v_data.get("files", []):
|
||||
all_files.append((v_name, f))
|
||||
|
||||
all_files.sort(key=lambda x: x[1].get("modified", ""), reverse=True)
|
||||
recent = all_files[: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"]) if username else False,
|
||||
})
|
||||
|
||||
return {
|
||||
"files": files_resp,
|
||||
"total": len(all_files),
|
||||
"limit": limit,
|
||||
"mode": "modified",
|
||||
}
|
||||
@@ -39,3 +39,74 @@ def list_tags(vault: str | None = None) -> dict[str, int]:
|
||||
from backend.search import get_all_tags
|
||||
|
||||
return get_all_tags(vault_filter=vault)
|
||||
|
||||
|
||||
def advanced_search_vaults(
|
||||
query: str = "",
|
||||
vault: str = "all",
|
||||
tag: str | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
sort: str = "relevance",
|
||||
case_sensitive: bool = False,
|
||||
whole_word: bool = False,
|
||||
regex: bool = False,
|
||||
include_paths: str | None = None,
|
||||
exclude_paths: str | None = None,
|
||||
created: str | None = None,
|
||||
modified: str | None = None,
|
||||
size: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Advanced full-text search (TF-IDF, facets, operators).
|
||||
|
||||
No permission filtering is applied: callers that need it (the tool layer)
|
||||
filter the ``results`` list themselves.
|
||||
"""
|
||||
from backend.search import advanced_search
|
||||
|
||||
return advanced_search(
|
||||
query,
|
||||
vault_filter=vault,
|
||||
tag_filter=tag,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
sort_by=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,
|
||||
)
|
||||
|
||||
|
||||
def search_paths(q: str, vault: str = "all") -> dict[str, Any]:
|
||||
"""Search files and directories by path substring using the path index.
|
||||
|
||||
No permission filtering is applied: callers that need it (the tool layer)
|
||||
filter the ``results`` list themselves.
|
||||
"""
|
||||
from backend.indexer import path_index
|
||||
|
||||
if not q:
|
||||
return {"query": q, "vault_filter": vault, "results": []}
|
||||
|
||||
query_lower = q.lower()
|
||||
results: list[dict[str, Any]] = []
|
||||
|
||||
vaults_to_search = [vault] if vault != "all" else list(path_index.keys())
|
||||
|
||||
for vault_name in vaults_to_search:
|
||||
for entry in path_index.get(vault_name, []):
|
||||
if query_lower in entry["name"].lower() or query_lower in entry["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}
|
||||
|
||||
@@ -100,3 +100,115 @@ def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
|
||||
raise ServiceError("Permission denied", code="permission_denied", status=403) from None
|
||||
|
||||
return {"vault": vault_name, "path": path, "items": items}
|
||||
|
||||
|
||||
def list_all_files(
|
||||
vault_name: str,
|
||||
dir: str = "",
|
||||
limit: int = 200,
|
||||
recursive: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""List files in a vault directory sorted by modification time (newest first).
|
||||
|
||||
Unlike :func:`browse_directory`, this returns files only (with metadata:
|
||||
size, mtime, extension) and can recurse into subdirectories. Hidden files
|
||||
and ignored directories are skipped.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
dir: Relative directory path within the vault (empty = root).
|
||||
limit: Maximum number of files to return.
|
||||
recursive: Recurse into subdirectories when True.
|
||||
|
||||
Returns:
|
||||
Dict with ``vault``, ``directory``, ``recursive``, ``count`` and
|
||||
``files``.
|
||||
|
||||
Raises:
|
||||
ServiceError: ``not_found`` (404) if the directory does not exist,
|
||||
``permission_denied`` (403) if it cannot be read.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from backend.indexer import IGNORED_DIRS
|
||||
|
||||
root = get_vault_root(vault_name)
|
||||
dir_path = resolve_safe_path(root, dir) if dir else root
|
||||
|
||||
if not dir_path.exists() or not dir_path.is_dir():
|
||||
raise ServiceError(
|
||||
f"Directory not found: {dir}",
|
||||
code="not_found",
|
||||
status=404,
|
||||
details={"vault": vault_name, "path": 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
|
||||
|
||||
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(root)).replace("\\", "/")
|
||||
|
||||
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 ServiceError(
|
||||
"Permission denied reading directory",
|
||||
code="permission_denied",
|
||||
status=403,
|
||||
details={"vault": vault_name, "path": dir},
|
||||
) from None
|
||||
|
||||
files.sort(key=lambda f: f["modified"], reverse=True)
|
||||
files = files[:limit]
|
||||
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"directory": dir,
|
||||
"recursive": recursive,
|
||||
"count": len(files),
|
||||
"files": files,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user