import re import logging from pathlib import Path from typing import Optional import frontmatter import mistune from fastapi import FastAPI, HTTPException, Query from fastapi.staticfiles import StaticFiles from fastapi.responses import HTMLResponse, JSONResponse from backend.indexer import ( build_index, reload_index, index, get_vault_data, find_file_in_index, ) from backend.search import search, get_all_tags logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", ) logger = logging.getLogger("obsigate") app = FastAPI(title="ObsiGate", version="1.0.0") # Resolve frontend path relative to this file FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend" # --------------------------------------------------------------------------- # Startup # --------------------------------------------------------------------------- @app.on_event("startup") async def startup_event(): logger.info("ObsiGate starting — building index...") await build_index() logger.info("ObsiGate ready.") # --------------------------------------------------------------------------- # Markdown rendering helpers # --------------------------------------------------------------------------- def _convert_wikilinks(content: str, current_vault: str) -> str: """Convert [[wikilinks]] and [[target|display]] to HTML links.""" def _replace(match): target = match.group(1).strip() display = match.group(2).strip() if match.group(2) else target found = find_file_in_index(target, current_vault) if found: return ( f'{display}' ) return f'{display}' pattern = r'\[\[([^\]|]+)(?:\|([^\]]+))?\]\]' return re.sub(pattern, _replace, content) def _render_markdown(raw_md: str, vault_name: str) -> str: """Render markdown string to HTML with wikilink support.""" converted = _convert_wikilinks(raw_md, vault_name) md = mistune.create_markdown( escape=False, plugins=["table", "strikethrough", "footnotes", "task_lists"], ) return md(converted) # --------------------------------------------------------------------------- # API Endpoints # --------------------------------------------------------------------------- @app.get("/api/vaults") async def api_vaults(): """List configured vaults with file counts.""" result = [] for name, data in index.items(): result.append({ "name": name, "file_count": len(data["files"]), "tag_count": len(data["tags"]), }) return result @app.get("/api/browse/{vault_name}") async def api_browse(vault_name: str, path: str = ""): """Browse directories and files in a vault at a given path level.""" 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 if not target.exists(): raise HTTPException(status_code=404, detail=f"Path not found: {path}") items = [] try: for entry in sorted(target.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())): # Skip hidden files/dirs if entry.name.startswith("."): continue rel = str(entry.relative_to(vault_root)).replace("\\", "/") if entry.is_dir(): # Count .md files recursively md_count = sum(1 for _ in entry.rglob("*.md")) items.append({ "name": entry.name, "path": rel, "type": "directory", "children_count": md_count, }) elif entry.suffix.lower() == ".md": items.append({ "name": entry.name, "path": rel, "type": "file", "size": entry.stat().st_size, }) except PermissionError: raise HTTPException(status_code=403, detail="Permission denied") return {"vault": vault_name, "path": path, "items": items} @app.get("/api/file/{vault_name}") async def api_file(vault_name: str, path: str = Query(..., description="Relative path to .md file")): """Return rendered HTML + metadata for a markdown file.""" 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 if not file_path.exists() or not file_path.is_file(): raise HTTPException(status_code=404, detail=f"File not found: {path}") raw = file_path.read_text(encoding="utf-8", errors="replace") post = frontmatter.loads(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 = [] title = post.metadata.get("title", file_path.stem.replace("-", " ").replace("_", " ")) html_content = _render_markdown(post.content, vault_name) return { "vault": vault_name, "path": path, "title": str(title), "tags": tags, "frontmatter": dict(post.metadata) if post.metadata else {}, "html": html_content, "raw_length": len(raw), } @app.get("/api/search") 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.""" 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") async def api_tags(vault: Optional[str] = Query(None, description="Vault filter")): """Return all unique tags with counts.""" tags = get_all_tags(vault_filter=vault) return {"vault_filter": vault, "tags": tags} @app.get("/api/index/reload") async def api_reload(): """Force a re-index of all vaults.""" stats = await reload_index() return {"status": "ok", "vaults": stats} # --------------------------------------------------------------------------- # Static files & SPA fallback # --------------------------------------------------------------------------- if FRONTEND_DIR.exists(): app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static") @app.get("/{full_path:path}") async def serve_spa(full_path: str): """Serve the SPA index.html for all non-API routes.""" index_file = FRONTEND_DIR / "index.html" if index_file.exists(): return HTMLResponse(content=index_file.read_text(encoding="utf-8")) raise HTTPException(status_code=404, detail="Frontend not found")