from fastapi import FastAPI, UploadFile, File, HTTPException from fastapi.responses import JSONResponse, HTMLResponse import json as json_lib from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel from typing import Optional, List import aiosqlite import shutil import os app = FastAPI() app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], ) DB_PATH = "/data/startpage.db" FAVICON_DIR = "/data/favicons" os.makedirs(FAVICON_DIR, exist_ok=True) class CategoryIn(BaseModel): name: str icon: Optional[str] = None position: Optional[int] = 0 class SubcategoryIn(BaseModel): category_id: int name: str position: Optional[int] = 0 class LinkIn(BaseModel): subcategory_id: int label: str url: str favicon: Optional[str] = None position: Optional[int] = 0 class ReorderIn(BaseModel): type: str # "categories", "subcategories", "links" ids: List[int] async def init_db(): async with aiosqlite.connect(DB_PATH) as db: await db.execute(""" CREATE TABLE IF NOT EXISTS categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, icon TEXT, position INTEGER DEFAULT 0 ) """) await db.execute(""" CREATE TABLE IF NOT EXISTS subcategories ( id INTEGER PRIMARY KEY AUTOINCREMENT, category_id INTEGER NOT NULL, name TEXT NOT NULL, position INTEGER DEFAULT 0, FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE ) """) await db.execute(""" CREATE TABLE IF NOT EXISTS links ( id INTEGER PRIMARY KEY AUTOINCREMENT, subcategory_id INTEGER NOT NULL, label TEXT NOT NULL, url TEXT NOT NULL, favicon TEXT, position INTEGER DEFAULT 0, FOREIGN KEY (subcategory_id) REFERENCES subcategories(id) ON DELETE CASCADE ) """) await db.execute("PRAGMA foreign_keys = ON") await db.commit() @app.on_event("startup") async def startup(): await init_db() @app.get("/categories") async def get_categories(): async with aiosqlite.connect(DB_PATH) as db: db.row_factory = aiosqlite.Row async with db.execute("SELECT * FROM categories ORDER BY position") as cursor: rows = await cursor.fetchall() return [dict(row) for row in rows] @app.post("/categories") async def create_category(cat: CategoryIn): async with aiosqlite.connect(DB_PATH) as db: cursor = await db.execute( "INSERT INTO categories (name, icon, position) VALUES (?, ?, ?)", (cat.name, cat.icon, cat.position) ) await db.commit() return {"id": cursor.lastrowid, **cat.dict()} @app.put("/categories/{cat_id}") async def update_category(cat_id: int, cat: CategoryIn): async with aiosqlite.connect(DB_PATH) as db: await db.execute( "UPDATE categories SET name=?, icon=?, position=? WHERE id=?", (cat.name, cat.icon, cat.position, cat_id) ) await db.commit() return {"id": cat_id, **cat.dict()} @app.delete("/categories/{cat_id}") async def delete_category(cat_id: int): async with aiosqlite.connect(DB_PATH) as db: await db.execute("PRAGMA foreign_keys = ON") await db.execute("DELETE FROM categories WHERE id=?", (cat_id,)) await db.commit() return {"deleted": cat_id} @app.get("/subcategories/{cat_id}") async def get_subcategories(cat_id: int): async with aiosqlite.connect(DB_PATH) as db: db.row_factory = aiosqlite.Row async with db.execute( "SELECT * FROM subcategories WHERE category_id=? ORDER BY position", (cat_id,) ) as cursor: rows = await cursor.fetchall() return [dict(row) for row in rows] @app.post("/subcategories") async def create_subcategory(sub: SubcategoryIn): async with aiosqlite.connect(DB_PATH) as db: cursor = await db.execute( "INSERT INTO subcategories (category_id, name, position) VALUES (?, ?, ?)", (sub.category_id, sub.name, sub.position) ) await db.commit() return {"id": cursor.lastrowid, **sub.dict()} @app.put("/subcategories/{sub_id}") async def update_subcategory(sub_id: int, sub: SubcategoryIn): async with aiosqlite.connect(DB_PATH) as db: await db.execute( "UPDATE subcategories SET name=?, position=? WHERE id=?", (sub.name, sub.position, sub_id) ) await db.commit() return {"id": sub_id, **sub.dict()} @app.delete("/subcategories/{sub_id}") async def delete_subcategory(sub_id: int): async with aiosqlite.connect(DB_PATH) as db: await db.execute("PRAGMA foreign_keys = ON") await db.execute("DELETE FROM subcategories WHERE id=?", (sub_id,)) await db.commit() return {"deleted": sub_id} @app.get("/links/{sub_id}") async def get_links(sub_id: int): async with aiosqlite.connect(DB_PATH) as db: db.row_factory = aiosqlite.Row async with db.execute( "SELECT * FROM links WHERE subcategory_id=? ORDER BY position", (sub_id,) ) as cursor: rows = await cursor.fetchall() return [dict(row) for row in rows] @app.post("/links") async def create_link(link: LinkIn): async with aiosqlite.connect(DB_PATH) as db: cursor = await db.execute( "INSERT INTO links (subcategory_id, label, url, favicon, position) VALUES (?, ?, ?, ?, ?)", (link.subcategory_id, link.label, link.url, link.favicon, link.position) ) await db.commit() return {"id": cursor.lastrowid, **link.dict()} @app.put("/links/{link_id}") async def update_link(link_id: int, link: LinkIn): async with aiosqlite.connect(DB_PATH) as db: await db.execute( "UPDATE links SET label=?, url=?, favicon=?, position=? WHERE id=?", (link.label, link.url, link.favicon, link.position, link_id) ) await db.commit() return {"id": link_id, **link.dict()} @app.delete("/links/{link_id}") async def delete_link(link_id: int): async with aiosqlite.connect(DB_PATH) as db: await db.execute("DELETE FROM links WHERE id=?", (link_id,)) await db.commit() return {"deleted": link_id} @app.put("/reorder") async def reorder(data: ReorderIn): table_map = { "categories": "categories", "subcategories": "subcategories", "links": "links", } if data.type not in table_map: raise HTTPException(status_code=400, detail="Invalid type") table = table_map[data.type] async with aiosqlite.connect(DB_PATH) as db: for position, item_id in enumerate(data.ids): await db.execute( f"UPDATE {table} SET position=? WHERE id=?", (position, item_id) ) await db.commit() return {"reordered": data.type, "count": len(data.ids)} @app.post("/favicons/upload") async def upload_favicon(file: UploadFile = File(...)): filename = file.filename.replace(" ", "_").lower() dest = os.path.join(FAVICON_DIR, filename) with open(dest, "wb") as f: shutil.copyfileobj(file.file, f) return {"favicon": filename} @app.get("/export/json") async def export_json(): async with aiosqlite.connect(DB_PATH) as db: db.row_factory = aiosqlite.Row async with db.execute("SELECT * FROM categories ORDER BY position") as cur: categories = [dict(r) for r in await cur.fetchall()] for cat in categories: async with db.execute( "SELECT * FROM subcategories WHERE category_id=? ORDER BY position", (cat["id"],) ) as cur: subs = [dict(r) for r in await cur.fetchall()] for sub in subs: async with db.execute( "SELECT * FROM links WHERE subcategory_id=? ORDER BY position", (sub["id"],) ) as cur: sub["links"] = [dict(r) for r in await cur.fetchall()] cat["subcategories"] = subs headers = {"Content-Disposition": "attachment; filename=startpage-bookmarks.json"} return JSONResponse(content=categories, headers=headers) @app.get("/export/html") async def export_html(): async with aiosqlite.connect(DB_PATH) as db: db.row_factory = aiosqlite.Row async with db.execute("SELECT * FROM categories ORDER BY position") as cur: categories = [dict(r) for r in await cur.fetchall()] for cat in categories: async with db.execute( "SELECT * FROM subcategories WHERE category_id=? ORDER BY position", (cat["id"],) ) as cur: subs = [dict(r) for r in await cur.fetchall()] for sub in subs: async with db.execute( "SELECT * FROM links WHERE subcategory_id=? ORDER BY position", (sub["id"],) ) as cur: sub["links"] = [dict(r) for r in await cur.fetchall()] cat["subcategories"] = subs lines = [ "", "", "", "Bookmarks", "

Bookmarks

", "

", ] for cat in categories: lines.append("

" + cat["name"] + "

") lines.append("

") for sub in cat["subcategories"]: lines.append("

" + sub["name"] + "

") lines.append("

") for link in sub["links"]: lines.append("

" + link["label"] + "") lines.append("

") lines.append("

") lines.append("

") body = "\n".join(lines) headers = {"Content-Disposition": "attachment; filename=startpage-bookmarks.html"} return HTMLResponse(content=body, headers=headers)