"""Notes browser โ€” timeline view, search, and quick capture. Mounted at /browse on the NoteGraph ingestion service. Auth: same token-based auth as the upload endpoint (SB_USER env var). """ import logging import os import re import secrets from datetime import date, datetime from pathlib import Path from typing import Optional import yaml from fastapi import APIRouter, HTTPException, Query, Request from fastapi.responses import HTMLResponse, JSONResponse from pydantic import BaseModel from slugify import slugify from ingestion.config import IngestionConfig logger = logging.getLogger(__name__) router = APIRouter(prefix="/browse") def _get_config() -> IngestionConfig: return IngestionConfig() def _get_notes_dir() -> Path: """Resolve notes directory โ€” env override or default relative to repo root.""" env_notes = os.environ.get("NOTES_DIR") if env_notes: return Path(env_notes) # Default: ../notes/ relative to the ingestion package return Path(__file__).resolve().parent.parent.parent / "notes" def _verify_token(request: Request, token: str = None): """Verify upload token from query param or X-Upload-Token header.""" config = _get_config() sb_user = config.sb_user.strip() parts = sb_user.split(":", 1) expected_token = parts[1].strip() if len(parts) == 2 else sb_user # Check query param if token and secrets.compare_digest(token.encode(), expected_token.encode()): return True # Check header header_token = request.headers.get("X-Upload-Token", "") if header_token and secrets.compare_digest(header_token.encode(), expected_token.encode()): return True raise HTTPException(status_code=401, detail="Invalid or missing token. Use ?token=YOUR_PASSWORD") def _parse_frontmatter(content: str) -> dict: """Parse YAML frontmatter from markdown content.""" if not content.startswith("---"): return {} parts = content.split("---", 2) if len(parts) < 3: return {} try: return yaml.safe_load(parts[1]) or {} except yaml.YAMLError: return {} def _get_body(content: str) -> str: """Get markdown body (after frontmatter).""" if not content.startswith("---"): return content parts = content.split("---", 2) if len(parts) < 3: return content return parts[2].strip() def _first_line_preview(body: str, max_len: int = 120) -> str: """Get first non-empty, non-heading line as preview.""" for line in body.split("\n"): line = line.strip() if line and not line.startswith("#"): return line[:max_len] + ("..." if len(line) > max_len else "") return "" def _scan_notes(notes_dir: Path) -> list[dict]: """Scan all markdown files and return metadata list.""" notes = [] if not notes_dir.exists(): return notes for md_file in sorted(notes_dir.rglob("*.md")): # Skip index files, templates, and trash if md_file.name == "index.md" or "templates" in md_file.parts or "_trash" in md_file.parts: continue try: content = md_file.read_text(encoding="utf-8") except (OSError, UnicodeDecodeError): continue fm = _parse_frontmatter(content) body = _get_body(content) # Extract date โ€” from frontmatter or filename or file mtime note_date = None if "date" in fm: d = fm["date"] if isinstance(d, date): note_date = d.isoformat() elif isinstance(d, str): note_date = d if not note_date: # Try to extract from filename like 2026-05-15-... match = re.match(r"(\d{4}-\d{2}-\d{2})", md_file.name) if match: note_date = match.group(1) if not note_date: note_date = datetime.fromtimestamp(md_file.stat().st_mtime).strftime("%Y-%m-%d") # Relative path from notes_dir rel_path = str(md_file.relative_to(notes_dir)).replace("\\", "/") notes.append({ "title": fm.get("title", md_file.stem.replace("-", " ").title()), "date": note_date, "tags": fm.get("tags", []), "path": rel_path, "preview": _first_line_preview(body), }) # Sort by date descending notes.sort(key=lambda n: n["date"], reverse=True) return notes # --- API Endpoints --- @router.get("", response_class=HTMLResponse) @router.get("/", response_class=HTMLResponse) async def browse_page(request: Request, token: str = Query(default=None)): """Serve the notes browser HTML page.""" _verify_token(request, token) return BROWSE_HTML @router.get("/api/notes") async def list_notes(request: Request, token: str = Query(default=None)): """Return JSON list of all notes.""" _verify_token(request, token) notes_dir = _get_notes_dir() notes = _scan_notes(notes_dir) return JSONResponse(content=notes) @router.get("/api/notes/{path:path}") async def get_note(path: str, request: Request, token: str = Query(default=None)): """Return full content of a single note.""" _verify_token(request, token) notes_dir = _get_notes_dir() note_file = notes_dir / path # Security: ensure path doesn't escape notes dir try: note_file.resolve().relative_to(notes_dir.resolve()) except ValueError: raise HTTPException(status_code=403, detail="Access denied") if not note_file.exists() or not note_file.is_file(): raise HTTPException(status_code=404, detail="Note not found") content = note_file.read_text(encoding="utf-8") fm = _parse_frontmatter(content) body = _get_body(content) return JSONResponse(content={ "title": fm.get("title", note_file.stem.replace("-", " ").title()), "date": str(fm.get("date", "")), "tags": fm.get("tags", []), "path": path, "content": body, }) class NoteCreate(BaseModel): title: str content: str tags: list[str] = [] class NoteUpdate(BaseModel): title: str content: str tags: list[str] = [] class NoteSplit(BaseModel): splitAt: int newTitle: str = "" @router.post("/api/notes") async def create_note(note: NoteCreate, request: Request, token: str = Query(default=None)): """Create a new note with frontmatter.""" _verify_token(request, token) notes_dir = _get_notes_dir() inbox_dir = notes_dir / "inbox" inbox_dir.mkdir(parents=True, exist_ok=True) today = date.today().isoformat() slug = slugify(note.title, max_length=60) filename = f"{today}-{slug}.md" filepath = inbox_dir / filename # Build frontmatter frontmatter = { "title": note.title, "date": today, } if note.tags: frontmatter["tags"] = note.tags fm_str = yaml.dump(frontmatter, default_flow_style=False, allow_unicode=True).strip() md_content = f"---\n{fm_str}\n---\n\n# {note.title}\n\n{note.content}\n" filepath.write_text(md_content, encoding="utf-8") logger.info("Created note: %s", filepath) rel_path = str(filepath.relative_to(notes_dir)).replace("\\", "/") return JSONResponse(content={"message": "Note created", "path": rel_path}, status_code=201) @router.delete("/api/notes/{path:path}") async def delete_note(path: str, request: Request, token: str = Query(default=None)): """Delete a note by moving it to _trash/ subfolder.""" _verify_token(request, token) notes_dir = _get_notes_dir() note_file = notes_dir / path # Security: ensure path doesn't escape notes dir try: note_file.resolve().relative_to(notes_dir.resolve()) except ValueError: raise HTTPException(status_code=403, detail="Access denied") if not note_file.exists() or not note_file.is_file(): raise HTTPException(status_code=404, detail="Note not found") # Move to _trash/ preserving relative structure trash_dir = notes_dir / "_trash" trash_path = trash_dir / path trash_path.parent.mkdir(parents=True, exist_ok=True) # If a file with the same name already exists in trash, add timestamp if trash_path.exists(): stem = trash_path.stem suffix = trash_path.suffix timestamp = datetime.now().strftime("%Y%m%d%H%M%S") trash_path = trash_path.with_name(f"{stem}_{timestamp}{suffix}") note_file.rename(trash_path) logger.info("Moved note to trash: %s -> %s", note_file, trash_path) return JSONResponse(content={"message": "Note moved to trash", "trashPath": str(trash_path.relative_to(notes_dir)).replace("\\", "/")}) @router.put("/api/notes/{path:path}") async def update_note(path: str, note: NoteUpdate, request: Request, token: str = Query(default=None)): """Update a note's content, title, and tags.""" _verify_token(request, token) notes_dir = _get_notes_dir() note_file = notes_dir / path # Security: ensure path doesn't escape notes dir try: note_file.resolve().relative_to(notes_dir.resolve()) except ValueError: raise HTTPException(status_code=403, detail="Access denied") if not note_file.exists() or not note_file.is_file(): raise HTTPException(status_code=404, detail="Note not found") # Read existing to preserve date existing_content = note_file.read_text(encoding="utf-8") existing_fm = _parse_frontmatter(existing_content) # Build updated frontmatter frontmatter = { "title": note.title, "date": existing_fm.get("date", date.today().isoformat()), } if note.tags: frontmatter["tags"] = note.tags fm_str = yaml.dump(frontmatter, default_flow_style=False, allow_unicode=True).strip() md_content = f"---\n{fm_str}\n---\n\n{note.content}\n" note_file.write_text(md_content, encoding="utf-8") logger.info("Updated note: %s", note_file) return JSONResponse(content={"message": "Note updated", "path": path}) @router.post("/api/notes/{path:path}/split") async def split_note(path: str, split: NoteSplit, request: Request, token: str = Query(default=None)): """Split a note at a given line number. Content above stays, content below becomes a new note.""" _verify_token(request, token) notes_dir = _get_notes_dir() note_file = notes_dir / path # Security: ensure path doesn't escape notes dir try: note_file.resolve().relative_to(notes_dir.resolve()) except ValueError: raise HTTPException(status_code=403, detail="Access denied") if not note_file.exists() or not note_file.is_file(): raise HTTPException(status_code=404, detail="Note not found") content = note_file.read_text(encoding="utf-8") fm = _parse_frontmatter(content) body = _get_body(content) lines = body.split("\n") if split.splitAt < 1 or split.splitAt >= len(lines): raise HTTPException(status_code=400, detail=f"splitAt must be between 1 and {len(lines) - 1}") # Split the body upper_body = "\n".join(lines[:split.splitAt]) lower_body = "\n".join(lines[split.splitAt:]) # Update original note with upper part original_title = fm.get("title", note_file.stem.replace("-", " ").title()) original_tags = fm.get("tags", []) original_date = fm.get("date", date.today().isoformat()) fm_original = { "title": original_title, "date": original_date, } if original_tags: fm_original["tags"] = original_tags fm_str = yaml.dump(fm_original, default_flow_style=False, allow_unicode=True).strip() note_file.write_text(f"---\n{fm_str}\n---\n\n{upper_body}\n", encoding="utf-8") # Create new note from lower part in inbox/ new_title = split.newTitle.strip() if split.newTitle else f"Split from {original_title}" today = date.today().isoformat() slug = slugify(new_title, max_length=60) filename = f"{today}-{slug}.md" inbox_dir = notes_dir / "inbox" inbox_dir.mkdir(parents=True, exist_ok=True) new_filepath = inbox_dir / filename fm_new = { "title": new_title, "date": today, } if original_tags: fm_new["tags"] = original_tags fm_new_str = yaml.dump(fm_new, default_flow_style=False, allow_unicode=True).strip() new_filepath.write_text(f"---\n{fm_new_str}\n---\n\n{lower_body}\n", encoding="utf-8") logger.info("Split note %s at line %d -> new note %s", note_file, split.splitAt, new_filepath) new_rel_path = str(new_filepath.relative_to(notes_dir)).replace("\\", "/") return JSONResponse(content={ "message": "Note split successfully", "originalPath": path, "newPath": new_rel_path, "newTitle": new_title, }, status_code=201) @router.get("/api/backlinks/{path:path}") async def get_backlinks(path: str, request: Request, token: str = Query(default=None)): """Return all notes that contain a wiki-link referencing the given path. For example, requesting backlinks for 'people/john-doe.md' will search all notes for the pattern [[people/john-doe]]. """ _verify_token(request, token) notes_dir = _get_notes_dir() # Strip .md extension to get the wiki-link target link_target = path if link_target.endswith(".md"): link_target = link_target[:-3] # The pattern to search for: [[people/john-doe]] or [[projects/my-project]] search_pattern = f"[[{link_target}]]" results = [] if not notes_dir.exists(): return JSONResponse(content=[]) for md_file in notes_dir.rglob("*.md"): if md_file.name == "index.md" or "templates" in md_file.parts or "_trash" in md_file.parts: continue # Don't include the target note itself rel_path = str(md_file.relative_to(notes_dir)).replace("\\", "/") if rel_path == path: continue try: content = md_file.read_text(encoding="utf-8") except (OSError, UnicodeDecodeError): continue if search_pattern not in content: continue fm = _parse_frontmatter(content) body = _get_body(content) # Find the line containing the link for context context = "" for line in body.split("\n"): if search_pattern in line: context = line.strip()[:150] break note_date = None if "date" in fm: d = fm["date"] note_date = d.isoformat() if isinstance(d, date) else str(d) if not note_date: match = re.match(r"(\d{4}-\d{2}-\d{2})", md_file.name) note_date = match.group(1) if match else datetime.fromtimestamp(md_file.stat().st_mtime).strftime("%Y-%m-%d") results.append({ "title": fm.get("title", md_file.stem.replace("-", " ").title()), "date": note_date, "tags": fm.get("tags", []), "path": rel_path, "preview": context or _first_line_preview(body), }) results.sort(key=lambda n: n["date"], reverse=True) return JSONResponse(content=results) @router.get("/api/search") async def search_notes(request: Request, q: str = Query(...), token: str = Query(default=None)): """Full-text search across all notes.""" _verify_token(request, token) notes_dir = _get_notes_dir() if not q or len(q) < 2: return JSONResponse(content=[]) query_lower = q.lower() results = [] if not notes_dir.exists(): return JSONResponse(content=[]) for md_file in notes_dir.rglob("*.md"): if md_file.name == "index.md" or "templates" in md_file.parts or "_trash" in md_file.parts: continue try: content = md_file.read_text(encoding="utf-8") except (OSError, UnicodeDecodeError): continue if query_lower not in content.lower(): continue fm = _parse_frontmatter(content) body = _get_body(content) # Find matching line for context context = "" for line in body.split("\n"): if query_lower in line.lower(): context = line.strip()[:150] break note_date = None if "date" in fm: d = fm["date"] note_date = d.isoformat() if isinstance(d, date) else str(d) if not note_date: match = re.match(r"(\d{4}-\d{2}-\d{2})", md_file.name) note_date = match.group(1) if match else datetime.fromtimestamp(md_file.stat().st_mtime).strftime("%Y-%m-%d") rel_path = str(md_file.relative_to(notes_dir)).replace("\\", "/") results.append({ "title": fm.get("title", md_file.stem.replace("-", " ").title()), "date": note_date, "tags": fm.get("tags", []), "path": rel_path, "preview": context or _first_line_preview(body), }) results.sort(key=lambda n: n["date"], reverse=True) return JSONResponse(content=results) # --- Inline HTML Template --- BROWSE_HTML = """ NoteGraph โ€” Browse

๐Ÿง  NoteGraph

Loading notes...
"""