""" LinkSyncServer - Sync Endpoint for Browser Extension """ import uuid from typing import Any, Dict, List from fastapi import APIRouter, HTTPException, status from pydantic import BaseModel, Field from models.base import Bookmark, get_session router = APIRouter(prefix="/api/sync", tags=["Sync"]) class SyncConfig(BaseModel): mode: str = Field(..., description="bi-directional, browser-authoritative, or server-authoritative") deletions_enabled: bool = False class BookmarkSyncData(BaseModel): id: str url: str title: str description: str = "" notes: str = "" tags: List[str] = Field(default_factory=list) favicon_url: str = "" path: str = "" visit_count: int = 0 is_bookmarked: bool = False class SyncResponse(BaseModel): actions: List[Dict[str, Any]] synced_count: int def parse_uuid(id_str: str): try: return uuid.UUID(id_str) if isinstance(id_str, str) else id_str except (ValueError, AttributeError): return None def apply_sync(sync_config: SyncConfig, browser_bookmarks: List[BookmarkSyncData]) -> SyncResponse: db = get_session() try: actions = [] server_bookmarks = {str(b.id): b for b in db.query(Bookmark).all()} for bm in browser_bookmarks: existing = server_bookmarks.get(bm.id) if sync_config.mode == "bi-directional": if existing: existing.url = bm.url existing.title = bm.title existing.description = bm.description existing.notes = bm.notes existing.tags = bm.tags existing.favicon_url = bm.favicon_url existing.path = bm.path existing.visit_count = bm.visit_count existing.is_bookmarked = bm.is_bookmarked actions.append({"type": "update", "link_id": bm.id}) else: try: bm_uuid = uuid.UUID(bm.id) except (ValueError, AttributeError): bm_uuid = uuid.uuid4() new_bm = Bookmark( id=bm_uuid, url=bm.url, title=bm.title, description=bm.description, notes=bm.notes, tags=bm.tags, favicon_url=bm.favicon_url, path=bm.path, visit_count=bm.visit_count, is_bookmarked=bm.is_bookmarked, ) db.add(new_bm) actions.append({"type": "create", "link_id": bm.id}) elif sync_config.mode == "browser-authoritative": if existing: existing.url = bm.url existing.title = bm.title existing.description = bm.description existing.notes = bm.notes existing.tags = bm.tags existing.favicon_url = bm.favicon_url existing.path = bm.path existing.visit_count = bm.visit_count existing.is_bookmarked = bm.is_bookmarked actions.append({"type": "update", "link_id": bm.id}) else: try: bm_uuid = uuid.UUID(bm.id) except (ValueError, AttributeError): bm_uuid = uuid.uuid4() new_bm = Bookmark( id=bm_uuid, url=bm.url, title=bm.title, description=bm.description, notes=bm.notes, tags=bm.tags, favicon_url=bm.favicon_url, path=bm.path, visit_count=bm.visit_count, is_bookmarked=bm.is_bookmarked, ) db.add(new_bm) actions.append({"type": "create", "link_id": bm.id}) elif sync_config.mode == "server-authoritative": if not existing: try: bm_uuid = uuid.UUID(bm.id) except (ValueError, AttributeError): bm_uuid = uuid.uuid4() new_bm = Bookmark( id=bm_uuid, url=bm.url, title=bm.title, description=bm.description, notes=bm.notes, tags=bm.tags, favicon_url=bm.favicon_url, path=bm.path, visit_count=bm.visit_count, is_bookmarked=bm.is_bookmarked, ) db.add(new_bm) actions.append({"type": "create", "link_id": bm.id}) if sync_config.deletions_enabled: browser_ids = {bm.id for bm in browser_bookmarks} for server_id in server_bookmarks: if server_id not in browser_ids: parsed_id = parse_uuid(server_id) if parsed_id: db.query(Bookmark).filter(Bookmark.id == parsed_id).delete() actions.append({"type": "delete", "link_id": server_id}) db.commit() return SyncResponse(actions=actions, synced_count=len(actions)) except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=str(e)) finally: db.close() @router.post("/", response_model=SyncResponse) async def sync(config: SyncConfig, browser_bookmarks: List[BookmarkSyncData]): return apply_sync(config, browser_bookmarks) @router.get("/collections", response_model=List[dict]) async def list_collections(): db = get_session() try: from models.base import Collection collections = db.query(Collection).all() return [c.to_dict() for c in collections] finally: db.close() @router.get("/collections/{collection_id}", response_model=dict) async def get_collection(collection_id: str): db = get_session() try: from models.base import Collection parsed_id = parse_uuid(collection_id) if parsed_id is None: raise HTTPException(status_code=404, detail="Collection not found") collection = db.query(Collection).filter(Collection.id == parsed_id).first() if not collection: raise HTTPException(status_code=404, detail="Collection not found") return collection.to_dict() finally: db.close() @router.post("/collections/{collection_id}/add-links", response_model=dict) async def add_links_to_collection(collection_id: str, bookmark_ids: List[str]): db = get_session() try: from models.base import Collection, CollectionBookmark parsed_cid = parse_uuid(collection_id) if parsed_cid is None: raise HTTPException(status_code=404, detail="Collection not found") collection = db.query(Collection).filter(Collection.id == parsed_cid).first() if not collection: raise HTTPException(status_code=404, detail="Collection not found") if collection.query_type != "static": raise HTTPException(status_code=400, detail="Can only add links to static collections") added = 0 for bid in bookmark_ids: parsed_bid = parse_uuid(bid) if parsed_bid is None: continue existing = ( db.query(CollectionBookmark) .filter( CollectionBookmark.collection_id == parsed_cid, CollectionBookmark.link_id == parsed_bid, ) .first() ) if not existing: db.add(CollectionBookmark(collection_id=parsed_cid, link_id=parsed_bid)) added += 1 db.commit() return {"collection_id": collection_id, "added_count": added, "message": "Links added successfully"} finally: db.close() @router.delete("/collections/{collection_id}", response_model=dict) async def delete_collection(collection_id: str): db = get_session() try: from models.base import Collection, CollectionBookmark parsed_cid = parse_uuid(collection_id) if parsed_cid is None: raise HTTPException(status_code=404, detail="Collection not found") collection = db.query(Collection).filter(Collection.id == parsed_cid).first() if not collection: raise HTTPException(status_code=404, detail="Collection not found") db.query(CollectionBookmark).filter( CollectionBookmark.collection_id == parsed_cid ).delete() db.delete(collection) db.commit() return {"message": "Collection deleted successfully"} finally: db.close()