287 lines
10 KiB
Python
287 lines
10 KiB
Python
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 = [
|
|
"<!DOCTYPE NETSCAPE-Bookmark-file-1>",
|
|
"<!-- This is an automatically generated file. -->",
|
|
"<META HTTP-EQUIV=\"Content-Type\" CONTENT=\"text/html; charset=UTF-8\">",
|
|
"<TITLE>Bookmarks</TITLE>",
|
|
"<H1>Bookmarks</H1>",
|
|
"<DL><p>",
|
|
]
|
|
for cat in categories:
|
|
lines.append(" <DT><H3>" + cat["name"] + "</H3>")
|
|
lines.append(" <DL><p>")
|
|
for sub in cat["subcategories"]:
|
|
lines.append(" <DT><H3>" + sub["name"] + "</H3>")
|
|
lines.append(" <DL><p>")
|
|
for link in sub["links"]:
|
|
lines.append(" <DT><A HREF=\"" + link["url"] + "\">" + link["label"] + "</A>")
|
|
lines.append(" </DL><p>")
|
|
lines.append(" </DL><p>")
|
|
lines.append("</DL>")
|
|
|
|
body = "\n".join(lines)
|
|
headers = {"Content-Disposition": "attachment; filename=startpage-bookmarks.html"}
|
|
return HTMLResponse(content=body, headers=headers)
|