first commit
This commit is contained in:
+213
@@ -0,0 +1,213 @@
|
||||
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'<a class="wikilink" href="#" '
|
||||
f'data-vault="{found["vault"]}" '
|
||||
f'data-path="{found["path"]}">{display}</a>'
|
||||
)
|
||||
return f'<span class="wikilink-missing">{display}</span>'
|
||||
|
||||
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")
|
||||
Reference in New Issue
Block a user