- Web UI: login, dashboard, links CRUD, collections, API keys, admin pages - Query engine: AND/OR/XOR with field filters, tag search, preview endpoint - Session management: token expiry detection, 401 interceptor, expiry banner - Links search: tags included, multi-word AND, query mode with set operations - Collections: static/dynamic, query builder with preview, public tree view - Save as Collection: convert search results (static) or query (dynamic) - Dashboard stats: resilient loading with allSettled pattern - Login page: redesigned with public collections tree view - Bug fix: query executor None fields crash (notes/description/url/title) - E2E tests: 20 Playwright tests covering all critical user flows - All 104 tests passing (84 unit/integration + 20 E2E)
247 lines
8.9 KiB
Python
247 lines
8.9 KiB
Python
"""
|
|
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()
|