import json from pathlib import Path from fastapi import FastAPI, Request, UploadFile, File, Query, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse, JSONResponse from fastapi.templating import Jinja2Templates from backend.database import get_db, init_db, add_bookmark, get_bookmark, update_bookmark, delete_bookmark, list_bookmarks, get_all_tags, get_stats from backend.search import hybrid_search from backend.models import BookmarkCreate, BookmarkUpdate from backend.embeddings import encode, build_embedding_text from backend.tagger import generate_tags from backend.importer import import_bookmarks app = FastAPI(title="BookmarkBrain", version="0.1.0") app.add_middleware( CORSMiddleware, allow_origins=["*"], # chrome-extension://* and moz-extension://* allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) TEMPLATES_DIR = Path(__file__).parent / "templates" templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) templates.env.filters["from_json"] = json.loads @app.on_event("startup") def startup(): init_db() # --- Web UI --- @app.get("/", response_class=HTMLResponse) async def index(request: Request): with get_db() as conn: tags = get_all_tags(conn) stats = get_stats(conn) return templates.TemplateResponse("index.html", { "request": request, "tags": tags, "stats": stats, }) # --- Search --- @app.get("/api/search") async def search(request: Request, q: str = Query(..., min_length=1), limit: int = Query(10, ge=1, le=50)): results = hybrid_search(q, limit=limit) # If htmx request, return HTML partial if "HX-Request" in request.headers: return templates.TemplateResponse("components/search_results.html", { "request": request, "results": results, "query": q, }) return {"results": results, "query": q, "total": len(results)} # --- CRUD --- @app.get("/api/bookmarks") async def api_list_bookmarks(page: int = Query(1, ge=1), per_page: int = Query(20, ge=1, le=100)): with get_db() as conn: bookmarks, total = list_bookmarks(conn, page=page, per_page=per_page) return {"bookmarks": bookmarks, "total": total, "page": page, "per_page": per_page} @app.get("/api/bookmarks/{bookmark_id}") async def api_get_bookmark(bookmark_id: int): with get_db() as conn: bm = get_bookmark(conn, bookmark_id) if not bm: raise HTTPException(status_code=404, detail="Bookmark not found") return bm @app.post("/api/bookmarks") async def api_create_bookmark(data: BookmarkCreate): from urllib.parse import urlparse domain = urlparse(data.url).netloc auto_tags = generate_tags(data.url, data.title, domain=domain) embedding_text = build_embedding_text(data.title, data.description, data.context, auto_tags, domain) embedding_blob = encode(embedding_text) with get_db() as conn: bookmark_id = add_bookmark( conn, url=data.url, title=data.title, description=data.description, context=data.context, folder_path="", domain=domain, auto_tags=auto_tags, user_tags=data.user_tags or [], content_snippet="", embedding_text=embedding_text, embedding_blob=embedding_blob, ) conn.commit() bm = get_bookmark(conn, bookmark_id) return bm @app.put("/api/bookmarks/{bookmark_id}") async def api_update_bookmark(bookmark_id: int, data: BookmarkUpdate): with get_db() as conn: existing = get_bookmark(conn, bookmark_id) if not existing: raise HTTPException(status_code=404, detail="Bookmark not found") updates = data.model_dump(exclude_none=True) if "user_tags" in updates: updates["user_tags"] = json.dumps(updates["user_tags"]) update_bookmark(conn, bookmark_id, **updates) # Re-embed if content changed if any(k in updates for k in ("title", "description", "context")): bm = get_bookmark(conn, bookmark_id) auto_tags = json.loads(bm["auto_tags"]) if isinstance(bm["auto_tags"], str) else bm["auto_tags"] embedding_text = build_embedding_text(bm["title"], bm["description"], bm["context"], auto_tags, bm["domain"]) embedding_blob = encode(embedding_text) # Update vec_bookmarks conn.execute("DELETE FROM vec_bookmarks WHERE id = ?", (bookmark_id,)) conn.execute("INSERT INTO vec_bookmarks (id, embedding) VALUES (?, ?)", (bookmark_id, embedding_blob)) conn.execute("UPDATE bookmarks SET embedding_text = ? WHERE id = ?", (embedding_text, bookmark_id)) conn.commit() return get_bookmark(conn, bookmark_id) @app.delete("/api/bookmarks/{bookmark_id}") async def api_delete_bookmark(bookmark_id: int): with get_db() as conn: existing = get_bookmark(conn, bookmark_id) if not existing: raise HTTPException(status_code=404, detail="Bookmark not found") delete_bookmark(conn, bookmark_id) conn.commit() return {"status": "deleted", "id": bookmark_id} # --- Import --- @app.post("/api/import") async def api_import(file: UploadFile = File(...)): import tempfile suffix = ".html" if file.filename and file.filename.endswith(".html") else ".html" with tempfile.NamedTemporaryFile(suffix=suffix, delete=False, mode="wb") as tmp: content = await file.read() tmp.write(content) tmp_path = tmp.name result = import_bookmarks(tmp_path) Path(tmp_path).unlink(missing_ok=True) return result # --- Tags & Stats --- @app.get("/api/tags") async def api_tags(): with get_db() as conn: tags = get_all_tags(conn) return [{"tag": t, "count": c} for t, c in tags] @app.get("/api/stats") async def api_stats(): with get_db() as conn: return get_stats(conn) # --- Reindex --- @app.post("/api/reindex") async def api_reindex(): from backend.embeddings import encode_batch, build_embedding_text with get_db() as conn: rows = conn.execute("SELECT id, title, description, context, auto_tags, domain FROM bookmarks").fetchall() texts = [] ids = [] for row in rows: auto_tags = json.loads(row["auto_tags"]) if isinstance(row["auto_tags"], str) else row["auto_tags"] text = build_embedding_text(row["title"], row["description"], row["context"], auto_tags, row["domain"]) texts.append(text) ids.append(row["id"]) blobs = encode_batch(texts) # Clear and rebuild vec table conn.execute("DELETE FROM vec_bookmarks") for id, blob, text in zip(ids, blobs, texts): conn.execute("INSERT INTO vec_bookmarks (id, embedding) VALUES (?, ?)", (id, blob)) conn.execute("UPDATE bookmarks SET embedding_text = ? WHERE id = ?", (text, id)) conn.commit() return {"reindexed": len(ids)}