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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user