Files
myworkspace/LinkSyncServer/api/endpoints/sync.py
DavidSaylor 77b076c7d7 feat: add web UI with login, CRUD, admin, and API key management
- Add login page with JWT authentication
- Add dashboard with stats and quick actions
- Add links management page (full CRUD with search)
- Add collections management page
- Add API key management page with copy-to-clipboard
- Add admin user management page (admin only)
- Fix UUID type mismatches across all endpoints
- Add updated_at column to api_keys and audit_log in schema.sql
- Fix DB_PASSWORD default in docker-compose.yml
- Add PyJWT to requirements.txt
- Fix API docs URL (/docs instead of /api/docs)
- Improve JS error handling (show actual messages)
- Rewrite conftest.py with proper DB lifecycle management
- Add 42 new integration tests (84 total, all passing)
  - test_admin.py: 15 tests for admin endpoints
  - test_auth_extended.py: 9 tests for API key CRUD
  - test_tags.py: 12 tests for tag endpoints
  - test_sync.py: 6 tests for sync endpoints
2026-05-21 07:21:49 -05:00

247 lines
9.0 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.bookmark_id == parsed_bid,
)
.first()
)
if not existing:
db.add(CollectionBookmark(collection_id=parsed_cid, bookmark_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()