05/18/2026 Catchup - linksync project work and TicTacToe evaluations on different coding LLMs with OpenCode.

This commit is contained in:
DavidSaylor
2026-05-18 19:55:48 -05:00
parent aed69afdfd
commit c5d3912070
544 changed files with 140434 additions and 364 deletions

Binary file not shown.

Binary file not shown.

View File

@@ -1,29 +1,40 @@
"""
LinkSyncServer - Collection CRUD Endpoints
LinkSyncServer - Collection CRUD Endpoints with SQLAlchemy
"""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
from sqlalchemy.orm import Session
from sqlalchemy import func, and_, or_, exists
from typing import List, Optional
import uuid
import logging
from models.base import Base, Bookmark, Collection, AuditLog, get_engine, sessionmaker
from pydantic import BaseModel, Field
import os
router = APIRouter(prefix="/api/collections", tags=["Collections"])
# Logging
logger = logging.getLogger(__name__)
class CollectionCreate(BaseModel):
name: str
description: Optional[str] = None
query_type: str # "static" or "dynamic"
query_expression: Optional[dict] = None
is_public: bool = False
name: str = Field(..., description="Collection name")
description: Optional[str] = Field(None, max_length=1024, description="Collection description")
query_type: str = Field(default="static", description="Static or dynamic collection")
query_expression: Optional[dict] = Field(None, description="Query expression for dynamic collections")
is_public: bool = Field(default=False, description="Is collection public")
tags: Optional[List[str]] = Field(default_factory=list, description="Collection tags")
class CollectionUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
query_type: Optional[str] = None
query_expression: Optional[dict] = None
name: Optional[str] = Field(None, max_length=255)
description: Optional[str] = Field(None, max_length=1024)
query_type: Optional[str] = Field(None)
query_expression: Optional[dict] = Field(None)
is_public: Optional[bool] = None
tags: Optional[List[str]] = Field(None)
class CollectionResponse(BaseModel):
@@ -35,135 +46,188 @@ class CollectionResponse(BaseModel):
is_public: bool
created_at: str
updated_at: str
tags: List[str]
def mock_create_collection(data: CollectionCreate) -> CollectionResponse:
"""Create collection (mock implementation)."""
return {
"id": str(uuid.uuid4()),
"name": data.name,
"description": data.description,
"query_type": data.query_type,
"query_expression": data.query_expression,
"is_public": data.is_public,
"created_at": "2026-05-11T00:00:00Z",
"updated_at": "2026-05-11T00:00:00Z"
}
def get_db():
"""Get database session."""
db_session = sessionmaker(get_engine())()
return db_session
def mock_get_collections() -> List[CollectionResponse]:
"""Get all collections (mock implementation)."""
return [
{
"id": str(uuid.uuid4()),
"name": "Work Links",
"description": "Links for work use",
"query_type": "dynamic",
"query_expression": {"operation": "OR", "operands": [{"operation": "TERM", "value": "work"}]},
"is_public": False,
"created_at": "2026-05-11T00:00:00Z",
"updated_at": "2026-05-11T00:00:00Z"
}
]
def get_current_user(request: Request):
"""Get current authenticated user."""
SECRET_KEY = os.environ.get("SECRET_KEY")
auth_header = request.headers.get("Authorization") or request.headers.get("authorization")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header[7:]
try:
import jwt
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
return {"username": payload.get("sub"), "id": payload.get("sub")}
except Exception:
pass
return {"username": "guest"}
def mock_get_collection(collection_id: str) -> CollectionResponse | None:
"""Get collection by ID (mock implementation)."""
if collection_id == "mock-id":
return {
"id": "mock-id",
"name": "Work Links",
"description": "Links for work use",
"query_type": "dynamic",
"query_expression": {"operation": "OR", "operands": [{"operation": "TERM", "value": "work"}]},
"is_public": False,
"created_at": "2026-05-11T00:00:00Z",
"updated_at": "2026-05-11T00:00:00Z"
}
return None
def mock_update_collection(collection_id: str, data: CollectionUpdate) -> CollectionResponse | None:
"""Update collection."""
return mock_get_collection(collection_id)
def mock_delete_collection(collection_id: str) -> bool:
"""Delete collection."""
return True
def mock_execute_query(query_expression: dict) -> List[dict]:
"""Execute query against bookmarks (mock implementation)."""
return [
{
"id": str(uuid.uuid4()),
"url": "https://example.com/work",
"title": "Work Example",
"description": "An example",
"notes": "",
"tags": ["work"],
"favicon_url": None,
"path": "/Work",
"created_at": "2026-05-11T00:00:00Z",
"updated_at": "2026-05-11T00:00:00Z",
"visit_count": 0,
"is_bookmarked": False,
"source_set_id": None
}
]
@router.get("/", response_model=List[CollectionResponse])
async def list_collections():
"""List all collections."""
return mock_get_collections()
@router.get("/{collection_id}", response_model=CollectionResponse)
async def get_collection(collection_id: str):
"""Get collection by ID."""
collection = mock_get_collection(collection_id)
if not collection:
raise HTTPException(status_code=404, detail="Collection not found")
return collection
@router.post("/", response_model=CollectionResponse, status_code=status.HTTP_201_CREATED)
async def create_collection(data: CollectionCreate):
"""Create new collection."""
collection = mock_create_collection(data)
return collection
@router.put("/{collection_id}", response_model=CollectionResponse)
async def update_collection(
collection_id: str,
data: CollectionUpdate
):
"""Update collection."""
collection = mock_update_collection(collection_id, data)
if not collection:
raise HTTPException(status_code=404, detail="Collection not found")
return collection
@router.delete("/{collection_id}", response_model=dict)
async def delete_collection(collection_id: str):
"""Delete collection."""
success = mock_delete_collection(collection_id)
if not success:
raise HTTPException(status_code=404, detail="Collection not found")
return {"message": "Collection deleted successfully"}
@router.post("/{collection_id}/refresh", response_model=dict)
async def refresh_collection(collection_id: str):
"""Refresh dynamic collection (re-evaluate query)."""
return {"message": "Collection refreshed successfully"}
@router.post("/execute", response_model=List[dict])
async def execute_query(query_expression: dict):
"""Execute query and return result set."""
return mock_execute_query(query_expression)
class CollectionManager:
"""Collection management helper."""
@staticmethod
def get_collection(collection_id: str) -> Optional[Collection]:
"""Get collection by ID."""
db = get_db()
try:
collection = db.query(Collection).filter(Collection.id == collection_id).first()
return collection
except Exception:
return None
@staticmethod
def create_collection(data: CollectionCreate, request: Request) -> Collection:
"""Create new collection."""
db = get_db()
collection = Collection(
name=data.name,
description=data.description,
query_type=data.query_type,
query_expression=data.query_expression,
is_public=data.is_public,
tags=TagCollection(tags=data.tags or []),
)
db.add(collection)
db.commit()
db.refresh(collection)
# Create audit log
user = get_current_user(request)
try:
audit = AuditLog(
action="create",
entity_type="Collection",
entity_id=collection.id,
old_value=None,
new_value=collection.dict(),
user_id=user.get("id")
)
db.add(audit)
db.commit()
except Exception:
pass
return collection
@staticmethod
def update_collection(collection_id: str, data: CollectionUpdate, request: Request) -> Optional[Collection]:
"""Update collection."""
db = get_db()
collection = db.query(Collection).filter(Collection.id == collection_id).first()
if not collection:
return None
# Update fields
for field_name, value in data.dict().items():
if value is not None:
if hasattr(collection, field_name):
setattr(collection, field_name, value)
elif field_name == "tags":
if isinstance(value, list):
collection.tags.add(*value)
else:
collection.tags.update(str(value))
db.commit()
db.refresh(collection)
# Create audit log
user = get_current_user(request)
try:
audit = AuditLog(
action="update",
entity_type="Collection",
entity_id=collection_id,
old_value=collection.dict(),
new_value=collection.dict(),
user_id=user.get("id")
)
db.add(audit)
db.commit()
except Exception:
pass
return collection
@staticmethod
def delete_collection(collection_id: str, request: Request) -> dict:
"""Delete collection."""
db = get_db()
collection = db.query(Collection).filter(Collection.id == collection_id).first()
if not collection:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Collection not found")
db.delete(collection)
db.commit()
# Create audit log
user = get_current_user(request)
try:
audit = AuditLog(
action="delete",
entity_type="Collection",
entity_id=collection_id,
old_value=collection.dict(),
new_value=None,
user_id=user.get("id")
)
db.add(audit)
db.commit()
except Exception:
pass
return {"message": "Collection deleted successfully", "deleted_id": collection_id}
@staticmethod
def get_collection_tags(collection_id: str) -> List[str]:
"""Get collection tags."""
db = get_db()
collection = db.query(Collection).filter(Collection.id == collection_id).first()
if not collection:
return []
return list(collection.tags)
@staticmethod
def get_collection_bookmarks(collection_id: str, limit: int = 50, offset: int = 0) -> List[Bookmark]:
"""
Get bookmarks for collection (static or dynamic).
For dynamic collections with query expression:
Use query executor to parse and filter bookmarks
"""
db = get_db()
collection = db.query(Collection).filter(Collection.id == collection_id).first()
if not collection:
return []
if collection.query_type == "static":
# Static collection: get all bookmarks
bookmarks = db.query(Bookmark).filter(Bookmark.collection_id == collection_id).limit(limit).offset(offset).all()
else:
# Dynamic collection: query expression
# TODO: Use query executor to parse expression (executor module)
bookmarks = db.query(Bookmark).limit(limit).offset(offset).all()
return bookmarks

View File

@@ -1,36 +1,46 @@
"""
LinkSyncServer - Link CRUD Endpoints
LinkSyncServer - Link CRUD Endpoints with SQLAlchemy
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy import func, or_
from typing import List, Optional
import uuid
import logging
import hashlib
from models.base import Base, Bookmark, User, AuditLog, get_engine, create_engine
from pydantic import BaseModel, Field
import os
router = APIRouter(prefix="/api/links", tags=["Links"])
# Logging
logger = logging.getLogger(__name__)
class BookmarkCreate(BaseModel):
url: str
title: str
description: Optional[str] = None
notes: Optional[str] = None
tags: Optional[List[str]] = None
favicon_url: Optional[str] = None
path: Optional[str] = None
visit_count: int = 0
is_bookmarked: bool = False
url: str = Field(..., description="Bookmark URL")
title: str = Field(..., min_length=1, max_length=255, description="Bookmark title")
description: Optional[str] = Field(None, max_length=500, description="Optional description")
notes: Optional[str] = Field(None, max_length=2000, description="Optional notes")
tags: Optional[List[str]] = Field(default_factory=list, description="List of tag names")
favicon_url: Optional[str] = Field(None, max_length=512, description="Favicon URL")
path: Optional[str] = Field(None, max_length=512, description="Folder path")
visit_count: int = Field(ge=0, description="Visit counter")
is_bookmarked: bool = Field(default=False, description="Bookmark flag")
class BookmarkUpdate(BaseModel):
url: Optional[str] = None
title: Optional[str] = None
description: Optional[str] = None
notes: Optional[str] = None
tags: Optional[List[str]] = None
favicon_url: Optional[str] = None
path: Optional[str] = None
visit_count: Optional[int] = None
url: Optional[str] = Field(None, description="New URL")
title: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = Field(None, max_length=500)
notes: Optional[str] = Field(None, max_length=2000)
tags: Optional[List[str]] = Field(None)
favicon_url: Optional[str] = Field(None, max_length=512)
path: Optional[str] = Field(None, max_length=512)
visit_count: Optional[int] = Field(None, ge=0)
is_bookmarked: Optional[bool] = None
@@ -48,128 +58,301 @@ class BookmarkResponse(BaseModel):
visit_count: int
is_bookmarked: bool
source_set_id: Optional[str]
user_id: Optional[str]
def get_db():
def get_db_session():
"""Get database session."""
from models.base import get_engine
db = get_engine()
return db
try:
return sessionmaker(get_engine())()
except Exception:
return None
def mock_create_bookmark(data: BookmarkCreate) -> dict:
"""Create bookmark (mock implementation for demo)."""
bookmark = {
"id": str(uuid.uuid4()),
"url": data.url,
"title": data.title,
"description": data.description,
"notes": data.notes,
"tags": data.tags or [],
"favicon_url": data.favicon_url,
"path": data.path,
"created_at": "2026-05-11T00:00:00Z",
"updated_at": "2026-05-11T00:00:00Z",
"visit_count": data.visit_count,
"is_bookmarked": data.is_bookmarked,
"source_set_id": None
}
return bookmark
def get_current_user(request: Request):
"""Get current authenticated user."""
SECRET_KEY = os.environ.get("SECRET_KEY")
auth_header = request.headers.get("Authorization") or request.headers.get("authorization")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header[7:]
try:
import jwt
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
return {"username": payload.get("sub"), "id": payload.get("sub")}
except Exception:
pass
def mock_get_bookmarks() -> List[dict]:
"""Get all bookmarks (mock implementation)."""
return [
{
"id": str(uuid.uuid4()),
"url": "https://example.com",
"title": "Example",
"description": "An example website",
"notes": "",
"tags": ["example", "demo"],
"favicon_url": None,
"path": "/Home",
"created_at": "2026-05-11T00:00:00Z",
"updated_at": "2026-05-11T00:00:00Z",
"visit_count": 0,
"is_bookmarked": False,
"source_set_id": None
}
]
def mock_get_bookmark(bookmark_id: str) -> dict | None:
"""Get single bookmark by ID."""
# Mock implementation
if bookmark_id == "mock-id":
return {
"id": "mock-id",
"url": "https://example.com",
"title": "Example",
"description": "An example website",
"notes": "",
"tags": ["example", "demo"],
"favicon_url": None,
"path": "/Home",
"created_at": "2026-05-11T00:00:00Z",
"updated_at": "2026-05-11T00:00:00Z",
"visit_count": 0,
"is_bookmarked": False,
"source_set_id": None
}
return None
def mock_update_bookmark(bookmark_id: str, data: BookmarkUpdate) -> dict | None:
"""Update bookmark."""
# Mock implementation
return mock_get_bookmark(bookmark_id)
def mock_delete_bookmark(bookmark_id: str) -> bool:
"""Delete bookmark."""
return True
return {"username": "guest"}
@router.get("/", response_model=List[BookmarkResponse])
async def list_bookmarks(limit: int = 20, offset: int = 0):
"""List all bookmarks."""
bookmarks = mock_get_bookmarks()
return bookmarks[offset:offset + limit]
async def list_bookmarks(
limit: int = Query(20, le=100, ge=1, description="Number of results per page"),
offset: int = Query(0, ge=0, description="Offset for pagination"),
search: Optional[str] = Query(None, description="Search query"),
tags_filter: Optional[List[str]] = Query(None, description="Filter by tags"),
path_filter: Optional[str] = Query(None, description="Filter by folder path")
):
"""List all bookmarks with optional filters."""
db = get_db_session()
if not db:
return []
query = Bookmark.query
# Search filter
if search:
query = query.filter((Bookmark.title.contains(search)) |
(Bookmark.description.contains(search)) |
(Bookmark.url.contains(search)))
# Tag filter
if tags_filter:
or_clause = or_(*[Bookmark.tags.contains(tag) for tag in tags_filter])
query = query.filter(or_clause)
# Path filter
if path_filter:
query = query.filter(Bookmark.path.contains(path_filter))
bookmarks = query.limit(limit).offset(offset).all()
return bookmarks
@router.get("/{bookmark_id}", response_model=BookmarkResponse)
async def get_bookmark(bookmark_id: str):
"""Get bookmark by ID."""
bookmark = mock_get_bookmark(bookmark_id)
db = get_db_session()
if not db:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bookmark not found")
bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first()
if not bookmark:
raise HTTPException(status_code=404, detail="Bookmark not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bookmark not found")
return bookmark
@router.post("/", response_model=BookmarkResponse, status_code=status.HTTP_201_CREATED)
async def create_bookmark(data: BookmarkCreate):
async def create_bookmark(data: BookmarkCreate, request: Request):
"""Create new bookmark."""
bookmark = mock_create_bookmark(data)
db = get_db_session()
if not db:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Database unavailable")
bookmark = Bookmark(
url=data.url,
title=data.title,
description=data.description,
notes=data.notes,
tags=data.tags or [],
favicon_url=data.favicon_url,
path=data.path,
visit_count=data.visit_count,
is_bookmarked=data.is_bookmarked
)
bookmark_id = f"{data.url[:20]}-{uuid.uuid4()[:8]}"
bookmark = db.add(bookmark)
db.commit()
db.refresh(bookmark)
# Get user for audit log
user = get_current_user(request)
# Create audit log (optional)
try:
audit = AuditLog(
action="create",
entity_type="Bookmark",
entity_id=bookmark_id,
old_value=None,
new_value=bookmark.dict(),
user_id=user.get("id")
)
db.add(audit)
db.commit()
except Exception:
pass
return bookmark
@router.put("/{bookmark_id}", response_model=BookmarkResponse)
async def update_bookmark(
bookmark_id: str,
data: BookmarkUpdate
data: BookmarkUpdate,
request: Request
):
"""Update bookmark."""
bookmark = mock_update_bookmark(bookmark_id, data)
db = get_db_session()
if not db:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Database unavailable")
bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first()
if not bookmark:
raise HTTPException(status_code=404, detail="Bookmark not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bookmark not found")
# Update fields
for field_name, value in data.dict().items():
if value is not None:
setattr(bookmark, field_name, value)
db.commit()
db.refresh(bookmark)
# Get user for audit log
user = get_current_user(request)
# Create audit log
try:
old_data = Bookmark(id=bookmark_id, url=bookmark.url, title=bookmark.title).dict()
audit = AuditLog(
action="update",
entity_type="Bookmark",
entity_id=bookmark_id,
old_value=old_data,
new_value=bookmark.dict(),
user_id=user.get("id")
)
db.add(audit)
db.commit()
except Exception:
pass
return bookmark
@router.delete("/{bookmark_id}", response_model=dict)
async def delete_bookmark(bookmark_id: str):
async def delete_bookmark(bookmark_id: str, request: Request):
"""Delete bookmark."""
success = mock_delete_bookmark(bookmark_id)
if not success:
raise HTTPException(status_code=404, detail="Bookmark not found")
return {"message": "Bookmark deleted successfully"}
db = get_db_session()
if not db:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Database unavailable")
bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first()
if not bookmark:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bookmark not found")
db.delete(bookmark)
db.commit()
# Get user for audit log
user = get_current_user(request)
# Create audit log
try:
audit = AuditLog(
action="delete",
entity_type="Bookmark",
entity_id=bookmark_id,
old_value=bookmark.dict(),
new_value=None,
user_id=user.get("id")
)
db.add(audit)
db.commit()
except Exception:
pass
return {"message": "Bookmark deleted successfully", "deleted_id": bookmark_id}
@router.post("/{bookmark_id}/tags")
async def add_tags(bookmark_id: str, tags: List[str], request: Request):
"""Add tags to bookmark."""
db = get_db_session()
if not db:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Database unavailable")
bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first()
if not bookmark:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bookmark not found")
for tag in tags:
if tag.lower() not in [t.lower() for t in bookmark.tags]:
bookmark.tags.append(tag)
db.commit()
db.refresh(bookmark)
return bookmark
@router.delete("/{bookmark_id}/tags")
async def remove_tags(bookmark_id: str, tags_to_remove: List[str], request: Request):
"""Remove tags from bookmark."""
db = get_db_session()
if not db:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Database unavailable")
bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first()
if not bookmark:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bookmark not found")
bookmark.tags = [t for t in bookmark.tags if t.lower() not in [tag.lower() for tag in tags_to_remove]]
db.commit()
db.refresh(bookmark)
return bookmark
@router.get("/{bookmark_id}/stats")
async def get_bookmark_stats(bookmark_id: str, request: Request):
"""Get bookmark statistics."""
db = get_db_session()
if not db:
return {}
bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first()
if not bookmark:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bookmark not found")
# Get visit count
visits = db.query("SELECT COUNT(*) FROM visits WHERE bookmark_id = :bookmark_id")
visit_count = visits.execute({"bookmark_id": bookmark_id})
return {
"bookmark_id": bookmark_id,
"visit_count": visit_count[0][0],
"last_visited": visits.execute({"bookmark_id": bookmark_id})
}
# Audit log helper (optional)
def create_audit_log(action: str, entity_type: str, entity_id: str, old_value: dict, new_value: dict):
"""Create audit log entry."""
db = get_db_session()
if not db:
return
try:
audit = AuditLog(
action=action,
entity_type=entity_type,
entity_id=entity_id,
old_value=old_value,
new_value=new_value,
ip_address=request.client.host if hasattr(request, 'client') and hasattr(request.client, 'host') else None
)
db.add(audit)
db.commit()
except Exception:
pass

View File

@@ -0,0 +1,188 @@
"""
LinkSyncServer - Tag Management Endpoints
"""
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from typing import List, Optional
import logging
import uuid
from models.base import Base, Tag, Bookmark, get_engine
from pydantic import BaseModel, Field
router = APIRouter(prefix="/api/tags", tags=["Tags"])
class TagCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=50)
color: Optional[str] = Field(None, max_length=7)
class TagUpdate(BaseModel):
name: Optional[str] = Field(None)
color: Optional[str] = Field(None)
class TagResponse(BaseModel):
id: str
name: str
color: Optional[str]
created_at: str
updated_at: str
def get_db_session():
"""Get database session."""
try:
return Session(get_engine())
except Exception:
return None
def get_current_user(request):
"""Get current authenticated user."""
SECRET_KEY = None
try:
auth_header = request.headers.get("Authorization") or request.headers.get("authorization")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header[7:]
import jwt
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
return {"username": payload.get("sub"), "id": payload.get("sub")}
elif not auth_header:
return {"username": "guest"}
except:
pass
return {"username": "guest"}
@router.get("/", response_model=List[TagResponse])
async def list_tags(page: int = Query(1, ge=1), per_page: int = Query(50, ge=1, le=200)):
"""List tags with pagination."""
db = get_db_session()
if not db:
return []
count = db.query(Tag).count()
tags = db.query(Tag).order_by(Tag.name).offset((page - 1) * per_page).limit(per_page).all()
return tags
@router.get("/count", response_model=dict)
async def tag_count():
"""Get total tag count."""
db = get_db_session()
if not db:
return {"count": 0}
return {"count": db.query(Tag).count()}
@router.get("/{tag_id}", response_model=TagResponse)
async def get_tag(tag_id: str):
"""Get tag by ID."""
db = get_db_session()
if not db:
raise HTTPException(status_code=404, detail="Tag not found")
tag = db.query(Tag).filter(Tag.id == tag_id).first()
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
return tag
@router.get("/{tag_id}/links")
async def get_tag_links(tag_id: str, limit: int = Query(50, ge=1), offset: int = Query(0, ge=0)):
"""Get links for tag.""""
db = get_db_session()
if not db:
return []
tag = db.query(Tag).filter(Tag.id == tag_id).first()
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
links = db.query(Bookmark).join(Tag).filter(Tag.id == tag_id).limit(limit).offset(offset).all()
return links
@router.post("/", response_model=TagResponse, status_code=201)
async def create_tag(data: TagCreate, request):
"""Create new tag."""
db = get_db_session()
if not db:
raise HTTPException(status_code=500, detail="Database unavailable")
tag = Tag(
id=f"tag-{uuid.uuid4()[:8]}",
name=data.name,
color=data.color
)
db.add(tag)
db.commit()
db.refresh(tag)
return tag
@router.put("/{tag_id}", response_model=TagResponse)
async def update_tag(tag_id: str, data: TagUpdate):
"""Update tag."""
db = get_db_session()
if not db:
raise HTTPException(status_code=500, detail="Database unavailable")
tag = db.query(Tag).filter(Tag.id == tag_id).first()
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
for field_name in ["name", "color"]:
if field_name in data.dict() and data.dict()[field_name] is not None:
setattr(tag, field_name, data.dict()[field_name])
db.commit()
db.refresh(tag)
return tag
@router.delete("/{tag_id}", response_model=dict)
async def delete_tag(tag_id: str):
"""Delete tag (and remove from all links)."""
db = get_db_session()
if not db:
raise HTTPException(status_code=500, detail="Database unavailable")
tag = db.query(Tag).filter(Tag.id == tag_id).first()
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
# Remove tag from all bookmarks
bookmarks = db.query(Bookmark).filter(Bookmark.tags.contains(tag.name)).all()
for bookmark in bookmarks:
bookmark.tags = [t for t in bookmark.tags if t[0] != tag_id]
db.delete(tag)
db.commit()
return {"message": f"Tag '{tag.name}' deleted and removed from all links"}

View File

@@ -0,0 +1,151 @@
"""
LinkSyncServer - Sync Endpoint for Browser Extension
"""
from fastapi import APIRouter, HTTPException, status
from typing import List, Dict
import jwt
import logging
from datetime import datetime
import json
from models.base import Bookmark, Collection, get_engine
from api.parsers.bookmarks import BookmarkParser
from api.parsers.sync import SyncParser
import os
router = APIRouter(prefix="/api/v1/sync", tags=["Sync"])
logger = logging.getLogger(__name__)
# Get database and secrets
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///links.db")
SECRET_KEY = os.environ.get("SECRET_KEY", "fallback-for-dev")
# Initialize parser
bookmark_parser = BookmarkParser()
sync_parser = SyncParser()
def get_db_session():
"""Get database session."""
from sqlalchemy.pool import StaticPool
from sqlalchemy.orm import Session
from sqlalchemy import create_engine
engine = create_engine(
DATABASE_URL,
connect_args={'check_same_thread': False}
)
return Session(engine)
def validate_request_token(request_token: str) -> Dict:
"""
Validate sync request token.
Accepts:
- Token header from extension
- No auth for demo/maintenance
"""
if not request_token:
# Allow anonymous for demo
return {"type": "anonymous", "permissions": {}}
try:
# Try to decode as JWT
payload = jwt.decode(request_token, SECRET_KEY, algorithms=["HS256"])
# Check permissions
permissions = {
"collections": payload.get("permissions", {}).get("collections", []),
"bookmarks": payload.get("permissions", {}).get("bookmarks", [])
}
return {
"type": "authorized",
"permissions": permissions
}
except Exception:
# Token invalid, fall back to anonymous
return {"type": "anonymous", "permissions": {}}
def sync_with_github(account_id: str, collection_id: str, request_token: str) -> Dict:
"""
Sync bookmarks from GitHub to local collection.
Args:
account_id: GitHub account ID
collection_id: LinkSync collection ID
request_token: Token from extension request
Returns:
Sync response (JSON payload for extension)
"""
# Validate token
token_info = validate_request_token(request_token)
if token_info["type"] != "authorized":
raise HTTPException(status_code=403, detail="Unauthorized access")
# Get collection
db = get_db_session()
collection = db.query(Collection).filter(Collection.id == collection_id).first()
if not collection:
raise HTTPException(status_code=404, detail="Collection not found")
# Make request to GitHub API (using library or requests)
try:
# GitHub API v3
# GET /users/{user_id}/starred
# Response: list of starred repositories and Gists (links)
github_api_base = "https://api.github.com"
starred_response = requests.get(
f"{github_api_base}/users/{account_id}/starred",
headers={
"Accept": "application/vnd.github.v3+json"
}
)
if starred_response.status_code != 200:
raise HTTPException(status_code=502, detail="Failed to fetch GitHub data")
github_links = starred_response.json()
# Parse GitHub data
github_bookmarks = sync_parser.parse_github_links(github_links)
# Create/update/delete based on sync
changes = bookmark_parser.parse_sync(
github_bookmarks, collection_id
)
# Commit changes
db.commit()
# Build response
sync_response = {
"_links": {
"sync": {
"_links": {
"self": {}
}
}
},
"meta": {
"account_id": account_id,
"collections": [collection_id],
"changes": changes,
"total_synced": len(github_links)
}
}
return sync_response
except Exception as e:
logger.error(f"Sync error: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -1,128 +1,18 @@
"""
LinkSyncServer - Main Application
FastAPI application for bookmark management with advanced collection
and query capabilities.
"""
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTML, JSONResponse
from pydantic import BaseModel
from typing import Optional
import os
import secrets
import logging
from fastapi import FastAPI
from routes import router as api_router
# Configure logging
logging.basicConfig(level=os.environ.get('LOG_LEVEL', 'INFO'))
logger = logging.getLogger(__name__)
# Create FastAPI app
app = FastAPI(
title="LinkSyncServer",
description="Self-hosted bookmark server with advanced collection capabilities",
description="Self-hosted bookmark server with collections",
version="1.0.0",
)
# CORS configuration
allow_origins = os.environ.get('CORS_ORIGINS', 'http://localhost:5555').split(',')
app.include_router(api_router)
app.add_middleware(
CORSMiddleware,
allow_origins=allow_origins,
allow_credentials=True,
allow_methods=['*'],
allow_headers=['*'],
)
# Static files
app.mount("/static", StaticFiles(directory="static"), name="static")
# Templates
app.mount(
"/templates",
StaticFiles(directory="templates"),
name="templates"
)
# Database configuration
DATABASE_URL = os.environ.get('DATABASE_URL')
SECRET_KEY = os.environ.get('SECRET_KEY', secrets.token_urlsafe(32))
DEBUG = os.environ.get('DEBUG', 'False').lower() == 'true'
HOST = os.environ.get('HOST', '0.0.0.0')
PORT = int(os.environ.get('PORT', 5000))
# CORS origins from environment
CORS_ORIGINS = [o.strip() for o in os.environ.get('CORS_ORIGINS', 'http://localhost:5555').split(',')]
# Add CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=CORS_ORIGINS,
allow_credentials=True,
allow_methods=['*'],
allow_headers=['*'],
)
# Health check endpoint
@app.get("/health")
async def health_check():
"""Health check endpoint for Docker monitoring."""
return {"status": "ok", "service": "LinkSyncServer"}
# Root endpoint
@app.get("/")
async def root():
"""Root endpoint with redirect to web UI."""
return HTML("""
<!DOCTYPE html>
<html>
<head><title>LinkSyncServer</title></head>
<body>
<h1>LinkSyncServer</h1>
<p>Web UI: <a href="/login">Login</a></p>
<p>API Docs: <a href="/api/docs">API Documentation</a></p>
</body>
</html>
""")
# Error handler
@app.exception_handler(Exception)
async def global_exception_handler(request, exc):
logger.error(f"Exception: {exc}")
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"detail": "Internal server error"}
)
# Error handler for specific exceptions
@app.exception_handler(HTTPException)
async def http_exception_handler(request, exc):
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail}
)
def get_api_key(user):
"""Get API key from database for a user."""
return None
async def get_current_user(token: Optional[str] = None):
"""Get current authenticated user."""
if token:
# Validate JWT token
# In production, implement proper JWT validation
pass
return None
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"app:app",
host=HOST,
port=PORT,
reload=DEBUG
)
def health():
return {"status": "ok"}

View File

View File

@@ -0,0 +1,220 @@
"""
LinkSyncServer - Query Executor
"""
from typing import List, Dict, Any, Optional
from sqlalchemy.orm import Session
from sqlalchemy import func, and_, or_
import logging
import sys
sys.path.insert(0, 'models')
from base import Bookmark, User
logger = logging.getLogger(__name__)
def parse_query_expression(query_expression: dict, expressions: list = None) -> Dict[str, Any]:
"""
Parse query expression in dict format.
Example:
{
"operation": "OR",
"operands": [
{"operation": "TERM", "value": "work"},
{"operation": "TERM", "value": "company"}
]
}
"""
if not query_expression:
return
operation = query_expression.get('operation')
operands = query_expression.get('operands', [])
if not operands:
# Top-level expression (e.g., TERM)
if operation == 'TERM':
value = query_expression.get('value', '')
if value.startswith('url:'):
search_term = value[4:]
return parse_term(search_term, 'url')
elif value.startswith('tag:'):
search_term = value[4:]
return parse_term(search_term, 'tags')
elif value.startswith('title:'):
search_term = value[6:]
return parse_term(search_term, 'title')
elif value.startswith('description:'):
search_term = value[12:]
return parse_term(search_term, 'description')
elif value.startswith('id:'):
return {'operation': 'EQUALS', 'value': value[3:]}
else:
# Default: search title and description
return {'operation': 'OR', 'operands': [
{'operation': 'TERM', 'value': value, 'field': 'title'},
{'operation': 'TERM', 'value': value, 'field': 'description'}
]}
def parse_term(term: str, field: str):
"""
Parse field:value term.
Returns SQLAlchemy filter clause.
"""
# Handle different field types
field_filters = {
'tags': lambda term: and_(*[Bookmark.tags.ilike(f'%{term}%') for tag in term.split(',')]),
'title': lambda term: Bookmark.title.ilike(f'%{term}%'),
'description': lambda term: Bookmark.description.ilike(f'%{term}%'),
'url': lambda term: Bookmark.url.ilike(f'%{term}%'),
'path': lambda term: Bookmark.path.ilike(f'%{term}%')
}
# Get filter function
filter_fn = field_filters.get(field, lambda term: Bookmark.tags.ilike(f'%{term}%'))
# Apply filter
filter_clause = filter_fn(term)
# Return filter clause with field
return {'field': field, 'value': term, 'clause': filter_clause}
def parse_or_filter(operators: list, operands: list) -> Any:
"""
Parse OR filter.
Operators: ['AND', 'OR', 'XOR']
"""
if not operands:
return False
# Default to AND for safety
op_type = operators[0] if operators else 'AND'
if op_type == 'OR':
return or_(*[parse_and_filter(operators[1:], operands[1:]) for _ in range(1)])
elif op_type == 'AND':
return and_(*[parse_and_filter(operators[1:], operands[1:]) for _ in range(1)])
else:
# XOR: not supported yet
raise ValueError("XOR not supported")
def parse_and_filter(operands: list) -> Any:
"""Parse AND filter (default)."""
if not operands:
return False
# Parse each operand
clauses = []
for operand in operands:
if isinstance(operand, str):
clause = operand
elif isinstance(operand, dict):
if operand.get('operation') == 'EQUALS':
clause = operand['value']
elif operand.get('operation') == 'TERM':
clauses.append(parse_term(operand.get('value', ''), operand.get('field', 'tags')))
# Add other term types as needed
else:
clauses.append(operand)
else:
raise ValueError(f"Unknown operand type: {type(operand)}")
if not clauses:
return False
return clauses
def execute_query(query_expression: dict) -> List[Dict[str, Any]]:
"""
Execute query and return results.
query_expression: dict from parser
returns: list of bookmarks
"""
# Default session
session = Session()
if not query_expression:
return []
# Parse query expression
try:
# Handle single-term queries
if query_expression.get('operation') == 'TERM':
search_term = query_expression.get('value', '')
field = query_expression.get('field', 'title')
if field == 'tags':
tags = search_term.split(',')
filters = [Bookmark.tags.contains(tag) for tag in tags]
result = session.query(Bookmark).filter(or_(*filters)).all()
elif field == 'title':
result = session.query(Bookmark).filter(Bookmark.title.contains(search_term)).all()
elif field == 'description':
result = session.query(Bookmark).filter(Bookmark.description.contains(search_term)).all()
elif field == 'url':
result = session.query(Bookmark).filter(Bookmark.url.contains(search_term)).all()
else:
# Default: search title and description
filters = [
or_(Bookmark.title.contains(search_term),
Bookmark.description.contains(search_term))
]
result = session.query(Bookmark).filter(or_(*filters)).all()
elif query_expression.get('operation') == 'AND':
# AND clause
clauses = parse_and_filter(query_expression.get('operands', []))
if isinstance(clauses, list):
result = session.query(Bookmark).filter(and_(*clauses)).all()
else:
result = session.query(Bookmark).filter(clauses).all()
else:
# Default: search title and description
search_term = query_expression.get('value', '')
result = session.query(Bookmark).filter(
or_(Bookmark.title.contains(search_term),
Bookmark.description.contains(search_term))
).all()
except Exception as e:
logger.error(f"Query execution error: {e}")
result = []
return result
def create_bookmarks_from_sync(sync_data: dict):
"""
Create bookmarks from sync response.
sync_data: dict from GitHub API
"""
if not sync_data:
return []
# Parse sync JSON
sync_info = sync_data.get('_links', {}).get('sync', {}).get('_links', {})
# Extract bookmarks
bookmarks = []
if 'objects' in sync_data:
for obj in sync_data['objects']:
if 'title' in obj:
bookmarks.append({
'url': obj.get('url', ''),
'title': obj.get('title', ''),
'description': obj.get('description', ''),
'tags': obj.get('tags', []),
'favicon_url': obj.get('favicon_url', ''),
'path': obj.get('path', ''),
'visit_count': obj.get('visit_count', 0)
})
return bookmarks

View File

@@ -0,0 +1,351 @@
"""
LinkSyncServer - Query Parser for Expression Parser
"""
import re
from typing import Union, Dict, List, Any
from enum import Enum
class TokenType(Enum):
OPERATOR = "OPERATOR"
TERM = "TERM"
VALUE = "VALUE"
LPAREN = "LPAREN"
RPAREN = "RPAREN"
class Token:
def __init__(self, token_type: TokenType, value: Any, line: int = 0, column: int = 0):
self.type = token_type
self.value = value
self.line = line
self.column = column
def __repr__(self):
return f"Token({self.type.value}, {self.value!r})"
class QuerySyntaxError(Exception):
"""Syntax error in query expression."""
def __init__(self, message: str, line: int = None, column: int = None):
self.message = message
self.line = line
self.column = column
super().__init__(f"{message} at line {line}, column {column}" if line and column else message)
def lex(expression: str) -> List[Token]:
"""
Lexical analysis - convert string to tokens.
Grammar:
expression := query_item (OP query_item)*
query_item := (expression) | value | term
term := OP | value
value := url:value | tag:value | title:value | description:value | id:value
"""
tokens = []
pos = 0
# Operators
operators = ['AND', 'OR', 'XOR']
while pos < len(expression):
# Skip whitespace
if expression[pos].isspace():
pos += 1
continue
# Check for parentheses
if expression[pos] == '(':
tokens.append(Token(TokenType.LPAREN, '('))
pos += 1
continue
if expression[pos] == ')':
tokens.append(Token(TokenType.RPAREN, ')'))
pos += 1
continue
# Check for operators (AND, OR, XOR)
if expression[pos:pos+4] == 'AND':
tokens.append(Token(TokenType.OPERATOR, 'AND'))
pos += 4
continue
if expression[pos:pos+3] == 'OR':
tokens.append(Token(TokenType.OPERATOR, 'OR'))
pos += 3
continue
if expression[pos:pos+4] == 'XOR':
tokens.append(Token(TokenType.OPERATOR, 'XOR'))
pos += 4
continue
# Check for url: prefix
if expression[pos:pos+4] == 'url:':
pos += 4
# Find end of URL
end = expression.find(':', pos)
if end == -1 and expression[pos] == '://':
# Find end of URL (next space or end of string)
end = expression.find(' ', pos)
if end == -1:
end = len(expression)
tokens.append(Token(TokenType.TERM, expression[pos:end]))
pos = end
continue
# Check for tag: prefix
if expression[pos:pos+5] == 'tag:':
pos += 5
end = expression.find(':', pos)
if end == -1:
end = len(expression)
tokens.append(Token(TokenType.TERM, expression[pos:end]))
pos = end
continue
# Check for title: or description: prefixes
if expression[pos:pos+6] in ['title:', 'description:']:
field = 'title' if expression[pos:pos+6] == 'title:' else 'description'
pos += 6
end = expression.find(':', pos)
if end == -1 and expression[pos] == ':' :
end = len(expression)
tokens.append(Token(TokenType.TERM, expression[pos:end]))
pos = end
continue
# Check for colon (key:value)
if expression[pos] == ':':
pos += 1
# Get field name (key)
field = expression[pos]
pos += 1
# Get value
end = expression.find(' ', pos)
if end == -1:
end = len(expression)
token_val = expression[pos:end].strip('"\'')
tokens.append(Token(TokenType.VALUE, f'{field}:{token_val}'))
continue
# Regular term - alphanumeric
if expression[pos].isalnum() or expression[pos] in '-_':
start = pos
while pos < len(expression) and (expression[pos].isalnum() or expression[pos] in '-_./?=?&'):
pos += 1
tokens.append(Token(TokenType.TERM, expression[start:pos]))
continue
# Unknown character - skip or error
pos += 1
return tokens
class ASTNode:
"""Abstract Syntax Tree Node."""
def __init__(self, operator: str, children: List[Union[ASTNode, str, dict]] = None):
self.operator = operator
self.children = children if children else []
def __repr__(self):
return f"AST({self.operator}, {self.children})"
def parse_operator(token: Token) -> str:
"""Convert operator token to Python operator string."""
if token.type != TokenType.OPERATOR:
raise QuerySyntaxError(f"Expected operator, got {token.value}")
if token.value == 'AND':
return 'and'
elif token.value == 'OR':
return 'or'
elif token.value == 'XOR':
return 'xor'
else:
raise QuerySyntaxError(f"Unknown operator: {token.value}")
class QueryParser:
"""Parser for query expressions."""
def __init__(self):
self.tokens = []
self.pos = 0
self.current_token = None
self.error = False
def error(self, message: str):
"""Record and return error."""
self.error = True
return QuerySyntaxError(message)
def parse_expression(self) -> List[ASTNode]:
"""Parse top-level expression (list of clauses)."""
if not self.tokens:
return []
expressions = []
# Parse first clause
expr = self.parse_or()
if expr:
expressions.append(expr)
# Parse remaining clauses
while self.current_token and self.current_token.value in ['AND', 'OR', 'XOR']:
operator = self.current_token.value
self.pos += 1
expressions.append(operator)
expr2 = self.parse_or()
if expr2:
expressions.append(expr2)
return expressions
def parse_or(self) -> Union[ASTNode, None]:
"""Parse OR clause."""
if not self.current_token:
return None
return self.parse_and()
def parse_and(self) -> Union[ASTNode, None]:
"""Parse AND clause."""
left = self.parse_xor()
while self.current_token and self.current_token.value == 'OR':
operator = self.parse_operator(self.current_token)
right = self.parse_xor()
left = ASTNode(operator, [left, right])
return left
def parse_xor(self) -> Union[ASTNode, None]:
"""Parse XOR clause."""
left = self.parse_term()
while self.current_token and self.current_token.value == 'AND':
operator = self.parse_operator(self.current_token)
right = self.parse_term()
left = ASTNode(operator, [left, right])
return left
def parse_term(self):
"""Parse term."""
if self.error:
return None
if self.pos >= len(self.tokens):
return None
token = self.current_token
# Check for parentheses (subexpression)
if token and token.value == '(':
self.pos += 1
self.current_token = self.tokens[self.pos] if self.pos < len(self.tokens) else None
sub_expr = self.parse_expression()
if not sub_expr and not self.error:
return None
if self.error:
return None
if self.current_token and self.current_token.value == ')':
self.pos += 1
return sub_expr
elif token and token.value != ')':
return token
def parse_value(self) -> Union[None, str]:
"""Parse value term."""
if self.error:
return None
token = self.current_token
if not token or token.type != TokenType.TERM:
return None
# Extract URL, TAG, etc.
term = token.value
# Check for url: value
if term.startswith('url:'):
query = {'operation': 'TERM', 'value': term[4:]}
self.pos += 1
self.current_token = self.tokens[self.pos] if self.pos < len(self.tokens) else None
return query
elif term.startswith('tag:'):
query = {'operation': 'TERM', 'value': term[4:]}
self.pos += 1
self.current_token = self.tokens[self.pos] if self.pos < len(self.tokens) else None
return query
elif term.startswith('title:'):
query = {'operation': 'TERM', 'value': term[6:]}
self.pos += 1
self.current_token = self.tokens[self.pos] if self.pos < len(self.tokens) else None
return query
elif term.startswith('description:'):
query = {'operation': 'TERM', 'value': term[12:]}
self.pos += 1
self.current_token = self.tokens[self.pos] if self.pos < len(self.tokens) else None
return query
elif term.startswith('id:'):
query = {'operation': 'EQUALS', 'value': term[3:]}
self.pos += 1
self.current_token = self.tokens[self.pos] if self.pos < len(self.tokens) else None
return query
elif term.startswith('"') or term.startswith("'"):
# Direct value
return term
else:
self.error(f"Unknown term: {term}")
return None
def parse(self, expression: str) -> List[ASTNode]:
"""Parse complete expression."""
if not expression:
return []
# Check for empty expression
if not expression.strip():
return []
# Lexical analysis
self.tokens = lex(expression)
self.pos = 0
self.current_token = self.tokens[0] if self.tokens else None
if not self.tokens:
return []
# Parse expression into AST
expr = self.parse_expression()
# Return AST as dict
return [self.ast_to_dict(node) for node in expr] if expr else []
def ast_to_dict(self, node, indent=0):
"""Convert AST node to dict representation."""
if isinstance(node, ASTNode):
if node.children:
return {
"operation": node.operator,
"operands": [self.ast_to_dict(child, indent + 1) for child in node.children]
}
else:
return node.value
elif isinstance(node, str):
return node
elif isinstance(node, dict):
return node
else:
return str(node)