Implement multi-stage Docker build with security hardening, add health check endpoint, optimize in-memory search with O(1) wikilink lookup, extract inline tags from markdown content, and enhance documentation with architecture diagrams and performance metrics
This commit is contained in:
+145
-31
@@ -2,6 +2,7 @@ import os
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Optional, Any
|
||||
@@ -16,6 +17,15 @@ index: Dict[str, Dict[str, Any]] = {}
|
||||
# Vault config: {name: path}
|
||||
vault_config: Dict[str, str] = {}
|
||||
|
||||
# Thread-safe lock for index updates
|
||||
_index_lock = threading.Lock()
|
||||
|
||||
# O(1) lookup table for wikilink resolution: {filename_lower: [{vault, path}, ...]}
|
||||
_file_lookup: Dict[str, List[Dict[str, str]]] = {}
|
||||
|
||||
# Maximum content size stored per file for in-memory search (bytes)
|
||||
SEARCH_CONTENT_LIMIT = 100_000
|
||||
|
||||
# Supported text-based file extensions
|
||||
SUPPORTED_EXTENSIONS = {
|
||||
".md", ".txt", ".log", ".py", ".js", ".ts", ".jsx", ".tsx",
|
||||
@@ -30,7 +40,15 @@ SUPPORTED_EXTENSIONS = {
|
||||
|
||||
|
||||
def load_vault_config() -> Dict[str, str]:
|
||||
"""Read VAULT_N_NAME / VAULT_N_PATH env vars and return {name: path}."""
|
||||
"""Read VAULT_N_NAME / VAULT_N_PATH env vars and return {name: path}.
|
||||
|
||||
Scans environment variables ``VAULT_1_NAME``/``VAULT_1_PATH``,
|
||||
``VAULT_2_NAME``/``VAULT_2_PATH``, etc. in sequential order.
|
||||
Stops at the first missing pair.
|
||||
|
||||
Returns:
|
||||
Dict mapping vault display names to filesystem paths.
|
||||
"""
|
||||
vaults: Dict[str, str] = {}
|
||||
n = 1
|
||||
while True:
|
||||
@@ -43,8 +61,25 @@ def load_vault_config() -> Dict[str, str]:
|
||||
return vaults
|
||||
|
||||
|
||||
# Regex for extracting inline #tags from markdown body (excludes code blocks)
|
||||
_INLINE_TAG_RE = re.compile(r'(?:^|\s)#([a-zA-Z][a-zA-Z0-9_/-]{1,50})', re.MULTILINE)
|
||||
# Regex patterns for stripping code blocks before inline tag extraction
|
||||
_CODE_BLOCK_RE = re.compile(r'```.*?```', re.DOTALL)
|
||||
_INLINE_CODE_RE = re.compile(r'`[^`]+`')
|
||||
|
||||
|
||||
def _extract_tags(post: frontmatter.Post) -> List[str]:
|
||||
"""Extract tags from frontmatter metadata."""
|
||||
"""Extract tags from frontmatter metadata.
|
||||
|
||||
Handles tags as comma-separated string, list, or other types.
|
||||
Strips leading ``#`` from each tag.
|
||||
|
||||
Args:
|
||||
post: Parsed frontmatter Post object.
|
||||
|
||||
Returns:
|
||||
List of cleaned tag strings.
|
||||
"""
|
||||
tags = post.metadata.get("tags", [])
|
||||
if isinstance(tags, str):
|
||||
tags = [t.strip().lstrip("#") for t in tags.split(",") if t.strip()]
|
||||
@@ -55,8 +90,36 @@ def _extract_tags(post: frontmatter.Post) -> List[str]:
|
||||
return tags
|
||||
|
||||
|
||||
def _extract_inline_tags(content: str) -> List[str]:
|
||||
"""Extract inline #tag patterns from markdown content.
|
||||
|
||||
Strips fenced and inline code blocks before scanning to avoid
|
||||
false positives from code comments or shell commands.
|
||||
|
||||
Args:
|
||||
content: Raw markdown content (without frontmatter).
|
||||
|
||||
Returns:
|
||||
Deduplicated list of inline tag strings.
|
||||
"""
|
||||
stripped = _CODE_BLOCK_RE.sub('', content)
|
||||
stripped = _INLINE_CODE_RE.sub('', stripped)
|
||||
return list(set(_INLINE_TAG_RE.findall(stripped)))
|
||||
|
||||
|
||||
def _extract_title(post: frontmatter.Post, filepath: Path) -> str:
|
||||
"""Extract title from frontmatter or derive from filename."""
|
||||
"""Extract title from frontmatter or derive from filename.
|
||||
|
||||
Falls back to the file stem with hyphens/underscores replaced by spaces
|
||||
when no ``title`` key is present in frontmatter.
|
||||
|
||||
Args:
|
||||
post: Parsed frontmatter Post object.
|
||||
filepath: Path to the source file.
|
||||
|
||||
Returns:
|
||||
Human-readable title string.
|
||||
"""
|
||||
title = post.metadata.get("title", "")
|
||||
if not title:
|
||||
title = filepath.stem.replace("-", " ").replace("_", " ")
|
||||
@@ -64,7 +127,17 @@ def _extract_title(post: frontmatter.Post, filepath: Path) -> str:
|
||||
|
||||
|
||||
def parse_markdown_file(raw: str) -> frontmatter.Post:
|
||||
"""Parse markdown frontmatter, falling back to plain content if YAML is invalid."""
|
||||
"""Parse markdown frontmatter, falling back to plain content if YAML is invalid.
|
||||
|
||||
When the YAML block is malformed, strips it and returns a Post with
|
||||
empty metadata so that rendering can still proceed.
|
||||
|
||||
Args:
|
||||
raw: Full raw markdown string including optional frontmatter.
|
||||
|
||||
Returns:
|
||||
``frontmatter.Post`` with ``.content`` and ``.metadata`` attributes.
|
||||
"""
|
||||
try:
|
||||
return frontmatter.loads(raw)
|
||||
except Exception as exc:
|
||||
@@ -78,7 +151,19 @@ def parse_markdown_file(raw: str) -> frontmatter.Post:
|
||||
|
||||
|
||||
def _scan_vault(vault_name: str, vault_path: str) -> Dict[str, Any]:
|
||||
"""Synchronously scan a single vault directory."""
|
||||
"""Synchronously scan a single vault directory and build file index.
|
||||
|
||||
Walks the vault tree, reads supported files, extracts metadata
|
||||
(tags, title, content preview) and stores a capped content snapshot
|
||||
for in-memory full-text search.
|
||||
|
||||
Args:
|
||||
vault_name: Display name of the vault.
|
||||
vault_path: Absolute filesystem path to the vault root.
|
||||
|
||||
Returns:
|
||||
Dict with keys ``files`` (list), ``tags`` (counter dict), ``path`` (str).
|
||||
"""
|
||||
vault_root = Path(vault_path)
|
||||
files: List[Dict[str, Any]] = []
|
||||
tag_counts: Dict[str, int] = {}
|
||||
@@ -113,6 +198,9 @@ def _scan_vault(vault_name: str, vault_path: str) -> Dict[str, Any]:
|
||||
if ext == ".md":
|
||||
post = parse_markdown_file(raw)
|
||||
tags = _extract_tags(post)
|
||||
# Merge inline #tags found in content body
|
||||
inline_tags = _extract_inline_tags(post.content)
|
||||
tags = list(set(tags) | set(inline_tags))
|
||||
title = _extract_title(post, fpath)
|
||||
content_preview = post.content[:200].strip()
|
||||
|
||||
@@ -121,6 +209,7 @@ def _scan_vault(vault_name: str, vault_path: str) -> Dict[str, Any]:
|
||||
"title": title,
|
||||
"tags": tags,
|
||||
"content_preview": content_preview,
|
||||
"content": raw[:SEARCH_CONTENT_LIMIT],
|
||||
"size": stat.st_size,
|
||||
"modified": modified,
|
||||
"extension": ext,
|
||||
@@ -138,7 +227,12 @@ def _scan_vault(vault_name: str, vault_path: str) -> Dict[str, Any]:
|
||||
|
||||
|
||||
async def build_index() -> None:
|
||||
"""Build the full in-memory index for all configured vaults."""
|
||||
"""Build the full in-memory index for all configured vaults.
|
||||
|
||||
Runs vault scans concurrently in a thread pool, then performs
|
||||
an atomic swap of the global index and lookup table under a lock
|
||||
to ensure thread-safe reads during reload.
|
||||
"""
|
||||
global index, vault_config
|
||||
vault_config = load_vault_config()
|
||||
|
||||
@@ -156,14 +250,35 @@ async def build_index() -> None:
|
||||
for name, task in tasks:
|
||||
new_index[name] = await task
|
||||
|
||||
index.clear()
|
||||
index.update(new_index)
|
||||
# Build O(1) lookup table for wikilink resolution
|
||||
new_lookup: Dict[str, List[Dict[str, str]]] = {}
|
||||
for vname, vdata in new_index.items():
|
||||
for f in vdata["files"]:
|
||||
entry = {"vault": vname, "path": f["path"]}
|
||||
fname = f["path"].rsplit("/", 1)[-1].lower()
|
||||
fpath_lower = f["path"].lower()
|
||||
for key in (fname, fpath_lower):
|
||||
if key not in new_lookup:
|
||||
new_lookup[key] = []
|
||||
new_lookup[key].append(entry)
|
||||
|
||||
# Atomic swap under lock for thread safety during concurrent reads
|
||||
with _index_lock:
|
||||
index.clear()
|
||||
index.update(new_index)
|
||||
_file_lookup.clear()
|
||||
_file_lookup.update(new_lookup)
|
||||
|
||||
total_files = sum(len(v["files"]) for v in index.values())
|
||||
logger.info(f"Index built: {len(index)} vaults, {total_files} total files")
|
||||
|
||||
|
||||
async def reload_index() -> Dict[str, Any]:
|
||||
"""Force a full re-index and return stats."""
|
||||
"""Force a full re-index of all vaults and return per-vault statistics.
|
||||
|
||||
Returns:
|
||||
Dict mapping vault names to their file/tag counts.
|
||||
"""
|
||||
await build_index()
|
||||
stats = {}
|
||||
for name, data in index.items():
|
||||
@@ -172,39 +287,38 @@ async def reload_index() -> Dict[str, Any]:
|
||||
|
||||
|
||||
def get_vault_names() -> List[str]:
|
||||
"""Return the list of all indexed vault names."""
|
||||
return list(index.keys())
|
||||
|
||||
|
||||
def get_vault_data(vault_name: str) -> Optional[Dict[str, Any]]:
|
||||
"""Return the full index data for a vault, or ``None`` if not found."""
|
||||
return index.get(vault_name)
|
||||
|
||||
|
||||
def find_file_in_index(link_target: str, current_vault: str) -> Optional[Dict[str, str]]:
|
||||
"""Find a file matching a wikilink target. Search current vault first, then all."""
|
||||
"""Find a file matching a wikilink target using O(1) lookup table.
|
||||
|
||||
Searches by filename first, then by full relative path.
|
||||
Prefers results from *current_vault* when multiple matches exist.
|
||||
|
||||
Args:
|
||||
link_target: The wikilink target (e.g. ``"My Note"`` or ``"folder/My Note"``).
|
||||
current_vault: Name of the vault the link originates from.
|
||||
|
||||
Returns:
|
||||
Dict with ``vault`` and ``path`` keys, or ``None`` if not found.
|
||||
"""
|
||||
target_lower = link_target.lower().strip()
|
||||
if not target_lower.endswith(".md"):
|
||||
target_lower += ".md"
|
||||
|
||||
def _search_vault(vname: str, vdata: Dict[str, Any]):
|
||||
for f in vdata["files"]:
|
||||
fpath = f["path"].lower()
|
||||
fname = fpath.rsplit("/", 1)[-1]
|
||||
if fname == target_lower or fpath == target_lower:
|
||||
return {"vault": vname, "path": f["path"]}
|
||||
candidates = _file_lookup.get(target_lower, [])
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
# Search current vault first
|
||||
if current_vault in index:
|
||||
result = _search_vault(current_vault, index[current_vault])
|
||||
if result:
|
||||
return result
|
||||
|
||||
# Search all other vaults
|
||||
for vname, vdata in index.items():
|
||||
if vname == current_vault:
|
||||
continue
|
||||
result = _search_vault(vname, vdata)
|
||||
if result:
|
||||
return result
|
||||
|
||||
return None
|
||||
# Prefer current vault when multiple vaults contain a match
|
||||
for c in candidates:
|
||||
if c["vault"] == current_vault:
|
||||
return c
|
||||
return candidates[0]
|
||||
|
||||
+318
-65
@@ -1,14 +1,16 @@
|
||||
import re
|
||||
import html as html_mod
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
import frontmatter
|
||||
import mistune
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from fastapi import FastAPI, HTTPException, Query, Body
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, PlainTextResponse
|
||||
from fastapi.responses import HTMLResponse, FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.indexer import (
|
||||
build_index,
|
||||
@@ -17,6 +19,7 @@ from backend.indexer import (
|
||||
get_vault_data,
|
||||
find_file_in_index,
|
||||
parse_markdown_file,
|
||||
_extract_tags,
|
||||
SUPPORTED_EXTENSIONS,
|
||||
)
|
||||
from backend.search import search, get_all_tags
|
||||
@@ -27,29 +30,182 @@ logging.basicConfig(
|
||||
)
|
||||
logger = logging.getLogger("obsigate")
|
||||
|
||||
app = FastAPI(title="ObsiGate", version="1.0.0")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pydantic response models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class VaultInfo(BaseModel):
|
||||
"""Summary information about a configured vault."""
|
||||
name: str = Field(description="Display name of the vault")
|
||||
file_count: int = Field(description="Number of indexed files")
|
||||
tag_count: int = Field(description="Number of unique tags")
|
||||
|
||||
|
||||
class BrowseItem(BaseModel):
|
||||
"""A single entry (file or directory) returned by the browse endpoint."""
|
||||
name: str
|
||||
path: str
|
||||
type: str = Field(description="'file' or 'directory'")
|
||||
children_count: Optional[int] = None
|
||||
size: Optional[int] = None
|
||||
extension: Optional[str] = None
|
||||
|
||||
|
||||
class BrowseResponse(BaseModel):
|
||||
"""Paginated directory listing for a vault."""
|
||||
vault: str
|
||||
path: str
|
||||
items: List[BrowseItem]
|
||||
|
||||
|
||||
class FileContentResponse(BaseModel):
|
||||
"""Rendered file content with metadata."""
|
||||
vault: str
|
||||
path: str
|
||||
title: str
|
||||
tags: List[str]
|
||||
frontmatter: Dict[str, Any]
|
||||
html: str
|
||||
raw_length: int
|
||||
extension: str
|
||||
is_markdown: bool
|
||||
|
||||
|
||||
class FileRawResponse(BaseModel):
|
||||
"""Raw text content of a file."""
|
||||
vault: str
|
||||
path: str
|
||||
raw: str
|
||||
|
||||
|
||||
class FileSaveResponse(BaseModel):
|
||||
"""Confirmation after saving a file."""
|
||||
status: str
|
||||
vault: str
|
||||
path: str
|
||||
size: int
|
||||
|
||||
|
||||
class FileDeleteResponse(BaseModel):
|
||||
"""Confirmation after deleting a file."""
|
||||
status: str
|
||||
vault: str
|
||||
path: str
|
||||
|
||||
|
||||
class SearchResultItem(BaseModel):
|
||||
"""A single search result."""
|
||||
vault: str
|
||||
path: str
|
||||
title: str
|
||||
tags: List[str]
|
||||
score: int
|
||||
snippet: str
|
||||
modified: str
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
"""Full-text search response."""
|
||||
query: str
|
||||
vault_filter: str
|
||||
tag_filter: Optional[str]
|
||||
count: int
|
||||
results: List[SearchResultItem]
|
||||
|
||||
|
||||
class TagsResponse(BaseModel):
|
||||
"""Tag aggregation response."""
|
||||
vault_filter: Optional[str]
|
||||
tags: Dict[str, int]
|
||||
|
||||
|
||||
class ReloadResponse(BaseModel):
|
||||
"""Index reload confirmation with per-vault stats."""
|
||||
status: str
|
||||
vaults: Dict[str, Any]
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
"""Application health status."""
|
||||
status: str
|
||||
version: str
|
||||
vaults: int
|
||||
total_files: int
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Application lifespan (replaces deprecated on_event)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan: build index on startup, cleanup on shutdown."""
|
||||
logger.info("ObsiGate starting \u2014 building index...")
|
||||
await build_index()
|
||||
logger.info("ObsiGate ready.")
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="ObsiGate", version="1.1.0", lifespan=lifespan)
|
||||
|
||||
# Resolve frontend path relative to this file
|
||||
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Startup
|
||||
# Path safety helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
logger.info("ObsiGate starting — building index...")
|
||||
await build_index()
|
||||
logger.info("ObsiGate ready.")
|
||||
def _resolve_safe_path(vault_root: Path, relative_path: str) -> Path:
|
||||
"""Resolve a relative path safely within the vault root.
|
||||
|
||||
Prevents directory traversal attacks by ensuring the resolved
|
||||
absolute path is a descendant of *vault_root*.
|
||||
|
||||
Args:
|
||||
vault_root: The vault's root directory (absolute).
|
||||
relative_path: The user-supplied relative path.
|
||||
|
||||
Returns:
|
||||
Resolved absolute ``Path``.
|
||||
|
||||
Raises:
|
||||
HTTPException(403): When the resolved path escapes the vault root.
|
||||
"""
|
||||
resolved = (vault_root / relative_path).resolve()
|
||||
vault_resolved = vault_root.resolve()
|
||||
try:
|
||||
resolved.relative_to(vault_resolved)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=403, detail="Access denied: path outside vault")
|
||||
return resolved
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Markdown rendering helpers
|
||||
# Markdown rendering helpers (singleton renderer)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Cached mistune renderer — avoids re-creating on every request
|
||||
_markdown_renderer = mistune.create_markdown(
|
||||
escape=False,
|
||||
plugins=["table", "strikethrough", "footnotes", "task_lists"],
|
||||
)
|
||||
|
||||
|
||||
def _convert_wikilinks(content: str, current_vault: str) -> str:
|
||||
"""Convert [[wikilinks]] and [[target|display]] to HTML links."""
|
||||
"""Convert ``[[wikilinks]]`` and ``[[target|display]]`` to clickable HTML.
|
||||
|
||||
Resolved links get a ``data-vault`` / ``data-path`` attribute pair.
|
||||
Unresolved links are rendered as ``<span class="wikilink-missing">``.
|
||||
|
||||
Args:
|
||||
content: Markdown string potentially containing wikilinks.
|
||||
current_vault: Active vault name for resolution priority.
|
||||
|
||||
Returns:
|
||||
Markdown string with wikilinks replaced by HTML anchors.
|
||||
"""
|
||||
def _replace(match):
|
||||
target = match.group(1).strip()
|
||||
display = match.group(2).strip() if match.group(2) else target
|
||||
@@ -67,22 +223,48 @@ def _convert_wikilinks(content: str, current_vault: str) -> str:
|
||||
|
||||
|
||||
def _render_markdown(raw_md: str, vault_name: str) -> str:
|
||||
"""Render markdown string to HTML with wikilink support."""
|
||||
"""Render a markdown string to HTML with wikilink support.
|
||||
|
||||
Uses the cached singleton mistune renderer for performance.
|
||||
|
||||
Args:
|
||||
raw_md: Raw markdown text (frontmatter already stripped).
|
||||
vault_name: Current vault for wikilink resolution context.
|
||||
|
||||
Returns:
|
||||
HTML string.
|
||||
"""
|
||||
converted = _convert_wikilinks(raw_md, vault_name)
|
||||
md = mistune.create_markdown(
|
||||
escape=False,
|
||||
plugins=["table", "strikethrough", "footnotes", "task_lists"],
|
||||
)
|
||||
return md(converted)
|
||||
return _markdown_renderer(converted)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/vaults")
|
||||
@app.get("/api/health", response_model=HealthResponse)
|
||||
async def api_health():
|
||||
"""Health check endpoint for Docker and monitoring.
|
||||
|
||||
Returns:
|
||||
Application status, version, vault count and total file count.
|
||||
"""
|
||||
total_files = sum(len(v["files"]) for v in index.values())
|
||||
return {
|
||||
"status": "ok",
|
||||
"version": app.version,
|
||||
"vaults": len(index),
|
||||
"total_files": total_files,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/vaults", response_model=List[VaultInfo])
|
||||
async def api_vaults():
|
||||
"""List configured vaults with file counts."""
|
||||
"""List all configured vaults with file and tag counts.
|
||||
|
||||
Returns:
|
||||
List of vault summary objects.
|
||||
"""
|
||||
result = []
|
||||
for name, data in index.items():
|
||||
result.append({
|
||||
@@ -93,15 +275,27 @@ async def api_vaults():
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/browse/{vault_name}")
|
||||
@app.get("/api/browse/{vault_name}", response_model=BrowseResponse)
|
||||
async def api_browse(vault_name: str, path: str = ""):
|
||||
"""Browse directories and files in a vault at a given path level."""
|
||||
"""Browse directories and files in a vault at a given path level.
|
||||
|
||||
Returns sorted entries (directories first, then files) with metadata.
|
||||
Hidden files/directories (starting with ``"."`` ) are excluded.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault to browse.
|
||||
path: Relative directory path within the vault (empty = root).
|
||||
|
||||
Returns:
|
||||
``BrowseResponse`` with vault name, path, and item list.
|
||||
"""
|
||||
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 = vault_root / path if path else vault_root
|
||||
# Path traversal protection
|
||||
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}")
|
||||
@@ -161,15 +355,23 @@ EXT_TO_LANG = {
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/file/{vault_name}/raw")
|
||||
@app.get("/api/file/{vault_name}/raw", response_model=FileRawResponse)
|
||||
async def api_file_raw(vault_name: str, path: str = Query(..., description="Relative path to file")):
|
||||
"""Return raw file content."""
|
||||
"""Return raw file content as plain text.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative file path within the vault.
|
||||
|
||||
Returns:
|
||||
``FileRawResponse`` with vault, path, and raw text content.
|
||||
"""
|
||||
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 = vault_root / 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}")
|
||||
@@ -180,13 +382,21 @@ async def api_file_raw(vault_name: str, path: str = Query(..., description="Rela
|
||||
|
||||
@app.get("/api/file/{vault_name}/download")
|
||||
async def api_file_download(vault_name: str, path: str = Query(..., description="Relative path to file")):
|
||||
"""Download a file as attachment."""
|
||||
"""Download a file as an attachment.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative file path within the vault.
|
||||
|
||||
Returns:
|
||||
``FileResponse`` with ``application/octet-stream`` content-type.
|
||||
"""
|
||||
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 = vault_root / 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}")
|
||||
@@ -198,28 +408,37 @@ async def api_file_download(vault_name: str, path: str = Query(..., description=
|
||||
)
|
||||
|
||||
|
||||
@app.put("/api/file/{vault_name}/save")
|
||||
async def api_file_save(vault_name: str, path: str = Query(..., description="Relative path to file"), body: dict = {}):
|
||||
"""Save file content."""
|
||||
@app.put("/api/file/{vault_name}/save", response_model=FileSaveResponse)
|
||||
async def api_file_save(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to file"),
|
||||
body: dict = Body(...),
|
||||
):
|
||||
"""Save (overwrite) a file's content.
|
||||
|
||||
Expects a JSON body with a ``content`` key containing the new text.
|
||||
The path is validated against traversal attacks before writing.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative file path within the vault.
|
||||
body: JSON body with ``content`` string.
|
||||
|
||||
Returns:
|
||||
``FileSaveResponse`` confirming the write.
|
||||
"""
|
||||
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 = vault_root / path
|
||||
|
||||
# Security: ensure path is within vault
|
||||
try:
|
||||
file_path.resolve().relative_to(vault_root.resolve())
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=403, detail="Access denied: path outside vault")
|
||||
file_path = _resolve_safe_path(vault_root, path)
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
|
||||
# Get content from body
|
||||
content = body.get('content', '')
|
||||
|
||||
|
||||
try:
|
||||
file_path.write_text(content, encoding="utf-8")
|
||||
logger.info(f"File saved: {vault_name}/{path}")
|
||||
@@ -231,20 +450,25 @@ async def api_file_save(vault_name: str, path: str = Query(..., description="Rel
|
||||
raise HTTPException(status_code=500, detail=f"Error saving file: {str(e)}")
|
||||
|
||||
|
||||
@app.delete("/api/file/{vault_name}")
|
||||
@app.delete("/api/file/{vault_name}", response_model=FileDeleteResponse)
|
||||
async def api_file_delete(vault_name: str, path: str = Query(..., description="Relative path to file")):
|
||||
"""Delete a file."""
|
||||
"""Delete a file from the vault.
|
||||
|
||||
The path is validated against traversal attacks before deletion.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative file path within the vault.
|
||||
|
||||
Returns:
|
||||
``FileDeleteResponse`` confirming the deletion.
|
||||
"""
|
||||
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 = vault_root / path
|
||||
|
||||
try:
|
||||
file_path.resolve().relative_to(vault_root.resolve())
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=403, detail="Access denied: path outside vault")
|
||||
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}")
|
||||
@@ -260,15 +484,27 @@ async def api_file_delete(vault_name: str, path: str = Query(..., description="R
|
||||
raise HTTPException(status_code=500, detail=f"Error deleting file: {str(e)}")
|
||||
|
||||
|
||||
@app.get("/api/file/{vault_name}")
|
||||
@app.get("/api/file/{vault_name}", response_model=FileContentResponse)
|
||||
async def api_file(vault_name: str, path: str = Query(..., description="Relative path to file")):
|
||||
"""Return rendered HTML + metadata for a file."""
|
||||
"""Return rendered HTML and metadata for a file.
|
||||
|
||||
Markdown files are parsed for frontmatter, rendered with wikilink
|
||||
support, and returned with extracted tags. Other supported file
|
||||
types are syntax-highlighted as code blocks.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative file path within the vault.
|
||||
|
||||
Returns:
|
||||
``FileContentResponse`` with HTML, metadata, and tags.
|
||||
"""
|
||||
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 = vault_root / 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}")
|
||||
@@ -279,14 +515,8 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
||||
if ext == ".md":
|
||||
post = parse_markdown_file(raw)
|
||||
|
||||
# Extract metadata
|
||||
tags = post.metadata.get("tags", [])
|
||||
if isinstance(tags, str):
|
||||
tags = [t.strip().lstrip("#") for t in tags.split(",") if t.strip()]
|
||||
elif isinstance(tags, list):
|
||||
tags = [str(t).strip().lstrip("#") for t in tags]
|
||||
else:
|
||||
tags = []
|
||||
# Extract metadata using shared indexer logic
|
||||
tags = _extract_tags(post)
|
||||
|
||||
title = post.metadata.get("title", file_path.stem.replace("-", " ").replace("_", " "))
|
||||
html_content = _render_markdown(post.content, vault_name)
|
||||
@@ -321,27 +551,50 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/search")
|
||||
@app.get("/api/search", response_model=SearchResponse)
|
||||
async def api_search(
|
||||
q: str = Query("", description="Search query"),
|
||||
vault: str = Query("all", description="Vault filter"),
|
||||
tag: Optional[str] = Query(None, description="Tag filter"),
|
||||
):
|
||||
"""Full-text search across vaults."""
|
||||
"""Full-text search across vaults with relevance scoring.
|
||||
|
||||
Supports combining free-text queries with tag filters.
|
||||
Results are ranked by a multi-factor scoring algorithm.
|
||||
|
||||
Args:
|
||||
q: Free-text search string.
|
||||
vault: Vault name or ``"all"`` to search everywhere.
|
||||
tag: Comma-separated tag names to require.
|
||||
|
||||
Returns:
|
||||
``SearchResponse`` with ranked results and snippets.
|
||||
"""
|
||||
results = search(q, vault_filter=vault, tag_filter=tag)
|
||||
return {"query": q, "vault_filter": vault, "tag_filter": tag, "count": len(results), "results": results}
|
||||
|
||||
|
||||
@app.get("/api/tags")
|
||||
@app.get("/api/tags", response_model=TagsResponse)
|
||||
async def api_tags(vault: Optional[str] = Query(None, description="Vault filter")):
|
||||
"""Return all unique tags with counts."""
|
||||
"""Return all unique tags with occurrence counts.
|
||||
|
||||
Args:
|
||||
vault: Optional vault name to restrict tag aggregation.
|
||||
|
||||
Returns:
|
||||
``TagsResponse`` with tags sorted by descending count.
|
||||
"""
|
||||
tags = get_all_tags(vault_filter=vault)
|
||||
return {"vault_filter": vault, "tags": tags}
|
||||
|
||||
|
||||
@app.get("/api/index/reload")
|
||||
@app.get("/api/index/reload", response_model=ReloadResponse)
|
||||
async def api_reload():
|
||||
"""Force a re-index of all vaults."""
|
||||
"""Force a full re-index of all configured vaults.
|
||||
|
||||
Returns:
|
||||
``ReloadResponse`` with per-vault file and tag counts.
|
||||
"""
|
||||
stats = await reload_index()
|
||||
return {"status": "ok", "vaults": stats}
|
||||
|
||||
|
||||
+85
-28
@@ -1,34 +1,44 @@
|
||||
import re
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
from backend.indexer import index, get_vault_data
|
||||
from backend.indexer import index
|
||||
|
||||
logger = logging.getLogger("obsigate.search")
|
||||
|
||||
# Default maximum number of search results returned
|
||||
DEFAULT_SEARCH_LIMIT = 200
|
||||
|
||||
|
||||
def _normalize_tag_filter(tag_filter: Optional[str]) -> List[str]:
|
||||
"""Parse a comma-separated tag filter string into a clean list.
|
||||
|
||||
Strips whitespace and leading ``#`` from each tag.
|
||||
|
||||
Args:
|
||||
tag_filter: Raw tag filter string (e.g. ``"docker,linux"``).
|
||||
|
||||
Returns:
|
||||
List of normalised tag strings, empty list if input is falsy.
|
||||
"""
|
||||
if not tag_filter:
|
||||
return []
|
||||
return [tag.strip().lstrip("#") for tag in tag_filter.split(",") if tag.strip()]
|
||||
|
||||
|
||||
def _read_file_content(vault_name: str, file_path: str) -> str:
|
||||
"""Read raw markdown content of a file from disk."""
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
return ""
|
||||
vault_root = Path(vault_data["path"])
|
||||
full_path = vault_root / file_path
|
||||
try:
|
||||
return full_path.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_snippet(content: str, query: str, context_chars: int = 120) -> str:
|
||||
"""Extract a text snippet around the first occurrence of query."""
|
||||
"""Extract a text snippet around the first occurrence of *query*.
|
||||
|
||||
Returns up to ``context_chars`` characters before and after the match.
|
||||
Falls back to the first 200 characters when the query is not found.
|
||||
|
||||
Args:
|
||||
content: Full text to search within.
|
||||
query: The search term.
|
||||
context_chars: Number of context characters on each side.
|
||||
|
||||
Returns:
|
||||
Snippet string, optionally prefixed/suffixed with ``...``.
|
||||
"""
|
||||
lower_content = content.lower()
|
||||
lower_query = query.lower()
|
||||
pos = lower_content.find(lower_query)
|
||||
@@ -51,10 +61,30 @@ def search(
|
||||
query: str,
|
||||
vault_filter: str = "all",
|
||||
tag_filter: Optional[str] = None,
|
||||
limit: int = DEFAULT_SEARCH_LIMIT,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Full-text search across indexed vaults.
|
||||
Returns scored results with snippets.
|
||||
"""Full-text search across indexed vaults with relevance scoring.
|
||||
|
||||
Scoring heuristics (when a text query is provided):
|
||||
- **+20** exact title match (case-insensitive)
|
||||
- **+10** partial title match
|
||||
- **+5** query found in file path
|
||||
- **+3** query matches a tag name
|
||||
- **+1 per occurrence** in content (capped at 10)
|
||||
|
||||
When only tag filters are active, all matching files receive score 1.
|
||||
Results are sorted descending by score and capped at *limit*.
|
||||
|
||||
Uses the in-memory cached content from the index — **no disk I/O**.
|
||||
|
||||
Args:
|
||||
query: Free-text search string.
|
||||
vault_filter: Vault name or ``"all"``.
|
||||
tag_filter: Comma-separated tag names to require.
|
||||
limit: Maximum number of results to return.
|
||||
|
||||
Returns:
|
||||
List of result dicts sorted by descending relevance score.
|
||||
"""
|
||||
query = query.strip() if query else ""
|
||||
has_query = len(query) > 0
|
||||
@@ -63,6 +93,7 @@ def search(
|
||||
if not has_query and not selected_tags:
|
||||
return []
|
||||
|
||||
query_lower = query.lower()
|
||||
results: List[Dict[str, Any]] = []
|
||||
|
||||
for vault_name, vault_data in index.items():
|
||||
@@ -70,6 +101,7 @@ def search(
|
||||
continue
|
||||
|
||||
for file_info in vault_data["files"]:
|
||||
# Tag filter: all selected tags must be present
|
||||
if selected_tags and not all(tag in file_info["tags"] for tag in selected_tags):
|
||||
continue
|
||||
|
||||
@@ -77,14 +109,32 @@ def search(
|
||||
snippet = file_info.get("content_preview", "")
|
||||
|
||||
if has_query:
|
||||
# Title match (high weight)
|
||||
if query.lower() in file_info["title"].lower():
|
||||
title_lower = file_info["title"].lower()
|
||||
|
||||
# Exact title match (highest weight)
|
||||
if query_lower == title_lower:
|
||||
score += 20
|
||||
# Partial title match
|
||||
elif query_lower in title_lower:
|
||||
score += 10
|
||||
|
||||
# Content match
|
||||
content = _read_file_content(vault_name, file_info["path"])
|
||||
if query.lower() in content.lower():
|
||||
score += 1
|
||||
# Path match (folder/filename relevance)
|
||||
if query_lower in file_info["path"].lower():
|
||||
score += 5
|
||||
|
||||
# Tag name match
|
||||
for tag in file_info.get("tags", []):
|
||||
if query_lower in tag.lower():
|
||||
score += 3
|
||||
break # count once per file
|
||||
|
||||
# Content match — use cached content (no disk I/O)
|
||||
content = file_info.get("content", "")
|
||||
content_lower = content.lower()
|
||||
if query_lower in content_lower:
|
||||
# Frequency-based scoring, capped to avoid over-weighting
|
||||
occurrences = content_lower.count(query_lower)
|
||||
score += min(occurrences, 10)
|
||||
snippet = _extract_snippet(content, query)
|
||||
else:
|
||||
# Tag-only filter: all matching files get score 1
|
||||
@@ -102,11 +152,18 @@ def search(
|
||||
})
|
||||
|
||||
results.sort(key=lambda x: -x["score"])
|
||||
return results
|
||||
return results[:limit]
|
||||
|
||||
|
||||
def get_all_tags(vault_filter: Optional[str] = None) -> Dict[str, int]:
|
||||
"""Aggregate tag counts across vaults."""
|
||||
"""Aggregate tag counts across vaults, sorted by descending count.
|
||||
|
||||
Args:
|
||||
vault_filter: Optional vault name to restrict to a single vault.
|
||||
|
||||
Returns:
|
||||
Dict mapping tag names to their total occurrence count.
|
||||
"""
|
||||
merged: Dict[str, int] = {}
|
||||
for vault_name, vault_data in index.items():
|
||||
if vault_filter and vault_name != vault_filter:
|
||||
|
||||
Reference in New Issue
Block a user