feat(workspace): 接入真实Vault数据链路
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
"""Web 联调 Workspace:把单一配置 Vault 映射为前端可用的真实文件树。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from app import repository
|
||||
from app.config import get_settings
|
||||
from app.contracts import (
|
||||
IndexRebuildRequest,
|
||||
OperationResponse,
|
||||
WorkspaceEntry,
|
||||
WorkspaceInfo,
|
||||
WorkspaceSnapshot,
|
||||
)
|
||||
from app.database.db import connect, transaction
|
||||
from app.errors import ApiError
|
||||
from app.retrieval.vectorstore import SqliteVecStore
|
||||
from app.services import index_service
|
||||
from app.services.coordination import serialized_vault_mutation
|
||||
from app.services.vault_paths import normalize_entry_name, normalize_folder, resolve_in_vault
|
||||
|
||||
vector_store = SqliteVecStore()
|
||||
|
||||
|
||||
def _entry_id(kind: str, path: str) -> str:
|
||||
digest = hashlib.sha256(f"{kind}:{path}".encode("utf-8")).hexdigest()[:16]
|
||||
return f"{kind}_{digest}"
|
||||
|
||||
|
||||
def _disk_markdown_paths() -> set[str]:
|
||||
root = get_settings().vault_path
|
||||
if not root.exists():
|
||||
return set()
|
||||
resolved_root = root.resolve()
|
||||
paths: set[str] = set()
|
||||
for path in root.rglob("*.md"):
|
||||
if path.is_symlink():
|
||||
continue
|
||||
resolved = path.resolve()
|
||||
if resolved.is_file() and resolved.is_relative_to(resolved_root):
|
||||
paths.add(resolved.relative_to(resolved_root).as_posix())
|
||||
return paths
|
||||
|
||||
|
||||
def get_workspace_info() -> WorkspaceInfo:
|
||||
root = get_settings().vault_path.resolve()
|
||||
disk_paths = _disk_markdown_paths()
|
||||
indexed_paths = {item.file_path for item in repository.list_note_locations()}
|
||||
return WorkspaceInfo(
|
||||
name=root.name or "Vault",
|
||||
path=str(root),
|
||||
file_count=len(disk_paths),
|
||||
indexed_note_count=len(indexed_paths),
|
||||
requires_refresh=disk_paths != indexed_paths,
|
||||
)
|
||||
|
||||
|
||||
def _tree(directory: Path, locations: dict[str, repository.NoteLocation]) -> list[WorkspaceEntry]:
|
||||
if not directory.exists():
|
||||
return []
|
||||
root = get_settings().vault_path.resolve()
|
||||
entries: list[WorkspaceEntry] = []
|
||||
children = sorted(
|
||||
directory.iterdir(), key=lambda item: (not item.is_dir(), item.name.casefold())
|
||||
)
|
||||
for child in children:
|
||||
if child.name.startswith(".") or child.is_symlink():
|
||||
continue
|
||||
resolved = child.resolve()
|
||||
if not resolved.is_relative_to(root):
|
||||
continue
|
||||
relative = resolved.relative_to(root).as_posix()
|
||||
public_path = f"/{relative}"
|
||||
if resolved.is_dir():
|
||||
entries.append(
|
||||
WorkspaceEntry(
|
||||
entry_id=_entry_id("folder", relative),
|
||||
name=child.name,
|
||||
path=public_path,
|
||||
type="folder",
|
||||
children=_tree(resolved, locations),
|
||||
)
|
||||
)
|
||||
elif resolved.is_file() and child.suffix.lower() == ".md":
|
||||
location = locations.get(relative)
|
||||
entries.append(
|
||||
WorkspaceEntry(
|
||||
entry_id=location.note_id if location else _entry_id("file", relative),
|
||||
note_id=location.note_id if location else None,
|
||||
name=child.name,
|
||||
path=public_path,
|
||||
type="file",
|
||||
)
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def get_workspace_tree() -> list[WorkspaceEntry]:
|
||||
locations = {item.file_path: item for item in repository.list_note_locations()}
|
||||
return _tree(get_settings().vault_path.resolve(), locations)
|
||||
|
||||
|
||||
async def open_workspace(requested_path: str | None) -> WorkspaceSnapshot:
|
||||
"""打开当前配置 Vault;发现未索引文件时先执行一次安全全量刷新。"""
|
||||
|
||||
root = get_settings().vault_path.resolve()
|
||||
if requested_path and Path(requested_path).resolve() != root:
|
||||
raise ApiError(
|
||||
409,
|
||||
"WORKSPACE_PATH_MISMATCH",
|
||||
"Web development mode can only open the backend configured Vault.",
|
||||
{"configured_path": str(root)},
|
||||
)
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
info = get_workspace_info()
|
||||
if info.requires_refresh:
|
||||
await index_service.rebuild(IndexRebuildRequest())
|
||||
info = get_workspace_info()
|
||||
return WorkspaceSnapshot(workspace=info, items=get_workspace_tree())
|
||||
|
||||
|
||||
@serialized_vault_mutation
|
||||
async def create_folder(parent: str, name: str) -> WorkspaceEntry:
|
||||
clean_parent = normalize_folder(parent)
|
||||
clean_name = normalize_entry_name(name)
|
||||
relative = f"{clean_parent}/{clean_name}" if clean_parent else clean_name
|
||||
target = resolve_in_vault(relative)
|
||||
if not clean_parent:
|
||||
get_settings().vault_path.mkdir(parents=True, exist_ok=True)
|
||||
if target.exists():
|
||||
raise ApiError(
|
||||
409, "RESOURCE_CONFLICT", "folder already exists", {"path": relative}
|
||||
)
|
||||
if not target.parent.is_dir():
|
||||
raise ApiError(
|
||||
404,
|
||||
"RESOURCE_NOT_FOUND",
|
||||
"parent folder not found",
|
||||
{"parent": clean_parent},
|
||||
)
|
||||
target.mkdir(parents=False)
|
||||
return WorkspaceEntry(
|
||||
entry_id=_entry_id("folder", relative),
|
||||
name=clean_name,
|
||||
path=f"/{relative}",
|
||||
type="folder",
|
||||
)
|
||||
|
||||
|
||||
@serialized_vault_mutation
|
||||
async def rename_folder(path: str, new_name: str) -> WorkspaceEntry:
|
||||
old_folder = normalize_folder(path)
|
||||
if not old_folder:
|
||||
raise ApiError(400, "INVALID_PATH", "the Vault root cannot be renamed")
|
||||
clean_name = normalize_entry_name(new_name)
|
||||
parent = Path(old_folder).parent.as_posix()
|
||||
parent = "" if parent == "." else parent
|
||||
new_folder = f"{parent}/{clean_name}" if parent else clean_name
|
||||
source = resolve_in_vault(old_folder)
|
||||
target = resolve_in_vault(new_folder)
|
||||
if not source.is_dir() or source.is_symlink():
|
||||
raise ApiError(404, "RESOURCE_NOT_FOUND", "folder not found", {"path": path})
|
||||
if target.exists():
|
||||
raise ApiError(
|
||||
409, "RESOURCE_CONFLICT", "target folder already exists", {"path": new_folder}
|
||||
)
|
||||
|
||||
affected = [
|
||||
item
|
||||
for item in repository.list_note_locations()
|
||||
if item.folder == old_folder or item.folder.startswith(f"{old_folder}/")
|
||||
]
|
||||
source.replace(target)
|
||||
conn = connect()
|
||||
now = datetime.now(timezone.utc)
|
||||
try:
|
||||
with transaction(conn):
|
||||
for item in affected:
|
||||
file_suffix = item.file_path[len(old_folder) :].lstrip("/")
|
||||
folder_suffix = item.folder[len(old_folder) :].lstrip("/")
|
||||
repository.update_note_location(
|
||||
conn=conn,
|
||||
note_id=item.note_id,
|
||||
title=item.title,
|
||||
file_path=f"{new_folder}/{file_suffix}",
|
||||
folder=(
|
||||
f"{new_folder}/{folder_suffix}" if folder_suffix else new_folder
|
||||
),
|
||||
updated_at=now,
|
||||
)
|
||||
except BaseException:
|
||||
target.replace(source)
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return WorkspaceEntry(
|
||||
entry_id=_entry_id("folder", new_folder),
|
||||
name=clean_name,
|
||||
path=f"/{new_folder}",
|
||||
type="folder",
|
||||
children=_tree(target, {item.file_path: item for item in repository.list_note_locations()}),
|
||||
)
|
||||
|
||||
|
||||
@serialized_vault_mutation
|
||||
async def delete_folder(path: str) -> OperationResponse:
|
||||
folder = normalize_folder(path)
|
||||
if not folder:
|
||||
raise ApiError(400, "INVALID_PATH", "the Vault root cannot be deleted")
|
||||
source = resolve_in_vault(folder)
|
||||
if not source.is_dir() or source.is_symlink():
|
||||
raise ApiError(404, "RESOURCE_NOT_FOUND", "folder not found", {"path": path})
|
||||
|
||||
affected = [
|
||||
item
|
||||
for item in repository.list_note_locations()
|
||||
if item.folder == folder or item.folder.startswith(f"{folder}/")
|
||||
]
|
||||
tombstone = source.with_name(f".{source.name}.{uuid4().hex}.deleting")
|
||||
source.replace(tombstone)
|
||||
conn = connect()
|
||||
try:
|
||||
with transaction(conn):
|
||||
block_ids: list[str] = []
|
||||
for item in affected:
|
||||
block_ids.extend(repository.delete_note(item.note_id, conn=conn))
|
||||
await vector_store.delete(block_ids, conn=conn)
|
||||
except BaseException:
|
||||
tombstone.replace(source)
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
try:
|
||||
shutil.rmtree(tombstone)
|
||||
except OSError:
|
||||
# 已提交的删除不回滚;隐藏 tombstone 可由后续维护任务清理。
|
||||
pass
|
||||
return OperationResponse(
|
||||
status="completed",
|
||||
resource_id=_entry_id("folder", folder),
|
||||
message=f"deleted folder and {len(affected)} indexed notes",
|
||||
)
|
||||
Reference in New Issue
Block a user