Complete LinkSyncServer and LinkSyncExtension implementation

LinkSyncServer:
- Fix app.py imports, add CORS middleware, lifespan events
- Create api/routes.py router aggregator
- Create config/settings.py for centralized configuration
- Rewrite models/base.py with proper relationships and serialization
- Rewrite all API endpoints with real DB integration (auth, links, collections, sync, queries, tags)
- Add admin endpoints (user management, stats, audit log)
- Complete query parser with recursive descent and proper precedence
- Complete query executor with set operations and field filters
- Set up Alembic migrations with initial schema
- Create web interface (templates, CSS, JS)
- Add 42 passing tests (auth, links, collections, queries)
- Add deploy.ps1 and deploy.sh scripts
- Update README with deployment workflow

LinkSyncExtension:
- Create utils/api.js (REST client with retries, auth, error handling)
- Create utils/sync.js (3 sync modes + conflict detection)
- Create utils/collection.js (collection management)
- Create utils/query-engine.js (client-side query parser)
- Rewrite background.js (sync loop, bookmark events, message routing)
- Rewrite popup.js (tabs, settings modal, notifications, CRUD)
- Update popup.html (tabbed interface, query builder, modal)
- Update popup.css (full redesign)
- Create content/content.js (page metadata extraction)
- Create options.html/js (dedicated settings page)
- Generate icons (48x48, 96x96)
- Update manifest.json (host permissions, content scripts, options)
- Create AGENTS.md
This commit is contained in:
DavidSaylor
2026-05-19 13:21:26 -05:00
parent c5d3912070
commit 09d30427f4
54 changed files with 5918 additions and 3177 deletions

View File

@@ -2,29 +2,33 @@
LinkSyncServer - Sync Endpoint for Browser Extension
"""
from fastapi import APIRouter, Depends, HTTPException, status
from typing import List, Dict, Any
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 # "bi-directional", "browser-authoritative", "server-authoritative"
mode: str = Field(..., description="bi-directional, browser-authoritative, or server-authoritative")
deletions_enabled: bool = False
class BookmarkData(BaseModel):
class BookmarkSyncData(BaseModel):
id: str
url: str
title: str
description: str
notes: str
tags: List[str]
favicon_url: str
path: str
visit_count: int
is_bookmarked: bool
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):
@@ -32,119 +36,178 @@ class SyncResponse(BaseModel):
synced_count: int
def mock_apply_sync(sync_config: SyncConfig, browser_bookmarks: List[Dict]) -> SyncResponse:
"""
Apply sync based on mode.
For demo, return mock actions.
"""
actions = []
for bookmark in browser_bookmarks:
if sync_config.mode == "bi-directional":
actions.append({
"type": "create" if not bookmark.get("from_server", False) else "update",
"link_id": bookmark["id"],
"message": "Synced from browser"
})
elif sync_config.mode == "browser-authoritative":
actions.append({
"type": "update",
"link_id": bookmark["id"],
"message": "Overwritten from browser"
})
elif sync_config.mode == "server-authoritative":
actions.append({
"type": "download",
"link_id": bookmark["id"],
"message": "Downloaded from server"
})
# If deletions enabled, would remove stale bookmarks here
return SyncResponse(
actions=actions,
synced_count=len(actions)
)
def apply_sync(sync_config: SyncConfig, browser_bookmarks: List[BookmarkSyncData]) -> SyncResponse:
db = get_session()
try:
actions = []
server_bookmarks = {b.id: b for b in db.query(Bookmark).all()}
for bm in browser_bookmarks:
existing = server_bookmarks.get(bm.id)
def mock_get_server_bookmarks() -> List[Dict]:
"""Get bookmarks from server (mock)."""
return [
{
"id": str(uuid.uuid4()),
"url": "https://example.com/example",
"title": "Example",
"description": "An example",
"notes": "",
"tags": ["example"],
"favicon_url": None,
"path": "/Example",
"visit_count": 0,
"is_bookmarked": False
}
]
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:
new_bm = Bookmark(
id=bm.id,
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:
new_bm = Bookmark(
id=bm.id,
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:
new_bm = Bookmark(
id=bm.id,
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:
db.query(Bookmark).filter(Bookmark.id == server_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[BookmarkData],
server_bookmarks: List[Dict] = Depends(mock_get_server_bookmarks)
):
"""
Sync bookmarks between browser and server.
Mode options:
- bi-directional: Push both ways
- browser-authoritative: Browser overwrites server
- server-authoritative: Download from server only
"""
response = mock_apply_sync(config, [b.model_dump() for b in browser_bookmarks])
return response
async def sync(config: SyncConfig, browser_bookmarks: List[BookmarkSyncData]):
return apply_sync(config, browser_bookmarks)
@router.get("/collections")
@router.get("/collections", response_model=List[dict])
async def list_collections():
"""List user's collections."""
return [
{
"id": str(uuid.uuid4()),
"name": "Work Links",
"description": "Work-related links",
"query_type": "dynamic",
"query_expression": {"operation": "OR", "operands": [{"operation": "TERM", "value": "work"}]},
"is_public": False
}
]
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}")
@router.get("/collections/{collection_id}", response_model=dict)
async def get_collection(collection_id: str):
"""Get collection details."""
return {
"id": collection_id,
"name": "Work Links",
"description": "Work-related links",
"query_type": "dynamic",
"query_expression": {"operation": "OR", "operands": [{"operation": "TERM", "value": "work"}]},
"is_public": False
}
db = get_session()
try:
from models.base import Collection
collection = db.query(Collection).filter(Collection.id == collection_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")
async def add_links_to_collection(
collection_id: str,
bookmark_ids: List[str]
):
"""Add links to static collection."""
return {
"collection_id": collection_id,
"added_count": len(bookmark_ids),
"message": "Links added successfully"
}
@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
collection = db.query(Collection).filter(Collection.id == collection_id).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:
existing = (
db.query(CollectionBookmark)
.filter(
CollectionBookmark.collection_id == collection_id,
CollectionBookmark.bookmark_id == bid,
)
.first()
)
if not existing:
db.add(CollectionBookmark(collection_id=collection_id, bookmark_id=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}")
@router.delete("/collections/{collection_id}", response_model=dict)
async def delete_collection(collection_id: str):
"""Delete collection."""
return {"message": "Collection deleted successfully"}
db = get_session()
try:
from models.base import Collection, CollectionBookmark
collection = db.query(Collection).filter(Collection.id == collection_id).first()
if not collection:
raise HTTPException(status_code=404, detail="Collection not found")
db.query(CollectionBookmark).filter(
CollectionBookmark.collection_id == collection_id
).delete()
db.delete(collection)
db.commit()
return {"message": "Collection deleted successfully"}
finally:
db.close()