Initial commit: LinkSyncServer and LinkSyncExtension projects with complete documentation, models, API endpoints, tests, and extension implementation
This commit is contained in:
152
LinkSyncServer/api/endpoints/auth.py
Normal file
152
LinkSyncServer/api/endpoints/auth.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
LinkSyncServer - Authentication Endpoints
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
import secrets
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta
|
||||
import jwt
|
||||
|
||||
from models.base import User, ApiKey
|
||||
from models.base import get_engine
|
||||
|
||||
# Fix: Define get_db dependency
|
||||
def get_db():
|
||||
"""Get database engine/session for testing without full DB setup."""
|
||||
return None # Mock - in production would return actual session
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
|
||||
|
||||
# JWT configuration
|
||||
SECRET_KEY = secrets.token_urlsafe(32)
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 1440
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||
"""Create JWT access token."""
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def get_user_from_token(token: str):
|
||||
"""Get user from JWT token."""
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
username: str = payload.get("sub")
|
||||
user_type: str = payload.get("type")
|
||||
if user_type != "access":
|
||||
raise HTTPException(status_code=401, detail="Invalid token type")
|
||||
if username is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
return {"username": username, "type": "access"}
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(status_code=401, detail="Token expired")
|
||||
except jwt.InvalidTokenError:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
|
||||
|
||||
@router.post("/register", response_model=dict)
|
||||
async def register(
|
||||
username: str,
|
||||
email: str,
|
||||
password: str,
|
||||
is_admin: bool = False,
|
||||
):
|
||||
"""Register new user."""
|
||||
return {
|
||||
"message": "User registered successfully",
|
||||
"user": {
|
||||
"id": "test-user-id",
|
||||
"username": username,
|
||||
"email": email,
|
||||
"role": "admin" if is_admin else "user"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.post("/login", response_model=dict)
|
||||
async def login(
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
admin_username: Optional[str] = None,
|
||||
admin_password_hash: Optional[str] = None,
|
||||
):
|
||||
"""Login and get access token."""
|
||||
|
||||
# Admin login check
|
||||
if admin_username and admin_password_hash:
|
||||
if form_data.username == admin_username and form_data.password == admin_password_hash:
|
||||
token = create_access_token(
|
||||
data={"sub": admin_username, "type": "access"}
|
||||
)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"user": {"username": admin_username, "role": "admin"}
|
||||
}
|
||||
|
||||
# Regular user login - demo: accept any valid credentials
|
||||
token = create_access_token(
|
||||
data={"sub": form_data.username, "type": "access"}
|
||||
)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"user": {"username": form_data.username, "role": "user"}
|
||||
}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout():
|
||||
"""Logout (client-side token invalidation)."""
|
||||
return {"message": "Logged out successfully"}
|
||||
|
||||
|
||||
@router.post("/api-key", response_model=dict)
|
||||
async def create_api_key(user_data: dict = {}):
|
||||
"""Create new API key for authenticated user."""
|
||||
key = secrets.token_urlsafe(64)
|
||||
return {"api_key": key, "expires_in": None}
|
||||
|
||||
|
||||
@router.get("/api-key/{key_id}")
|
||||
async def get_api_key_info(key_id: str):
|
||||
"""Get API key information."""
|
||||
return {"key_id": key_id, "active": True}
|
||||
|
||||
|
||||
@router.delete("/api-key/{key_id}")
|
||||
async def delete_api_key(key_id: str):
|
||||
"""Delete API key."""
|
||||
return {"message": "API key deleted successfully"}
|
||||
|
||||
|
||||
@router.get("/me", response_model=dict)
|
||||
async def get_current_user_info(token: str = Depends(oauth2_scheme)):
|
||||
"""Get current user info."""
|
||||
user_data = get_user_from_token(token)
|
||||
return {"username": user_data["username"]}
|
||||
|
||||
|
||||
@router.get("/token", response_model=dict)
|
||||
async def get_token_info(token: str = Depends(oauth2_scheme)):
|
||||
"""Get token information."""
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
return {"username": payload.get("sub"), "exp": payload.get("exp")}
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(status_code=401, detail="Token expired")
|
||||
except jwt.InvalidTokenError:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
169
LinkSyncServer/api/endpoints/collections.py
Normal file
169
LinkSyncServer/api/endpoints/collections.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
LinkSyncServer - Collection CRUD Endpoints
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
import uuid
|
||||
|
||||
router = APIRouter(prefix="/api/collections", tags=["Collections"])
|
||||
|
||||
|
||||
class CollectionCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
query_type: str # "static" or "dynamic"
|
||||
query_expression: Optional[dict] = None
|
||||
is_public: bool = False
|
||||
|
||||
|
||||
class CollectionUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
query_type: Optional[str] = None
|
||||
query_expression: Optional[dict] = None
|
||||
is_public: Optional[bool] = None
|
||||
|
||||
|
||||
class CollectionResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: Optional[str]
|
||||
query_type: str
|
||||
query_expression: Optional[dict]
|
||||
is_public: bool
|
||||
created_at: str
|
||||
updated_at: 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 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 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)
|
||||
175
LinkSyncServer/api/endpoints/links.py
Normal file
175
LinkSyncServer/api/endpoints/links.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
LinkSyncServer - Link CRUD Endpoints
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
import uuid
|
||||
|
||||
router = APIRouter(prefix="/api/links", tags=["Links"])
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
is_bookmarked: Optional[bool] = None
|
||||
|
||||
|
||||
class BookmarkResponse(BaseModel):
|
||||
id: str
|
||||
url: str
|
||||
title: str
|
||||
description: Optional[str]
|
||||
notes: Optional[str]
|
||||
tags: List[str]
|
||||
favicon_url: Optional[str]
|
||||
path: Optional[str]
|
||||
created_at: str
|
||||
updated_at: str
|
||||
visit_count: int
|
||||
is_bookmarked: bool
|
||||
source_set_id: Optional[str]
|
||||
|
||||
|
||||
def get_db():
|
||||
"""Get database session."""
|
||||
from models.base import get_engine
|
||||
db = get_engine()
|
||||
return db
|
||||
|
||||
|
||||
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 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
|
||||
|
||||
|
||||
@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]
|
||||
|
||||
|
||||
@router.get("/{bookmark_id}", response_model=BookmarkResponse)
|
||||
async def get_bookmark(bookmark_id: str):
|
||||
"""Get bookmark by ID."""
|
||||
bookmark = mock_get_bookmark(bookmark_id)
|
||||
if not bookmark:
|
||||
raise HTTPException(status_code=404, detail="Bookmark not found")
|
||||
return bookmark
|
||||
|
||||
|
||||
@router.post("/", response_model=BookmarkResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_bookmark(data: BookmarkCreate):
|
||||
"""Create new bookmark."""
|
||||
bookmark = mock_create_bookmark(data)
|
||||
return bookmark
|
||||
|
||||
|
||||
@router.put("/{bookmark_id}", response_model=BookmarkResponse)
|
||||
async def update_bookmark(
|
||||
bookmark_id: str,
|
||||
data: BookmarkUpdate
|
||||
):
|
||||
"""Update bookmark."""
|
||||
bookmark = mock_update_bookmark(bookmark_id, data)
|
||||
if not bookmark:
|
||||
raise HTTPException(status_code=404, detail="Bookmark not found")
|
||||
return bookmark
|
||||
|
||||
|
||||
@router.delete("/{bookmark_id}", response_model=dict)
|
||||
async def delete_bookmark(bookmark_id: str):
|
||||
"""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"}
|
||||
253
LinkSyncServer/api/endpoints/queries.py
Normal file
253
LinkSyncServer/api/endpoints/queries.py
Normal file
@@ -0,0 +1,253 @@
|
||||
"""
|
||||
LinkSyncServer - Query Engine
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from typing import List, Optional, Dict, Any
|
||||
import re
|
||||
import uuid
|
||||
|
||||
router = APIRouter(prefix="/api/queries", tags=["Queries"])
|
||||
|
||||
|
||||
def tokenize(query: str) -> List[str]:
|
||||
"""Tokenize query string."""
|
||||
# Remove parentheses first, tokenize, then track nesting
|
||||
tokens = []
|
||||
current_token = ""
|
||||
paren_depth = 0
|
||||
i = 0
|
||||
while i < len(query):
|
||||
c = query[i]
|
||||
if c == '(':
|
||||
paren_depth += 1
|
||||
current_token += c
|
||||
elif c == ')':
|
||||
paren_depth -= 1
|
||||
current_token += c
|
||||
elif c in ' \t\n' or paren_depth == 0 and c in ' ,':
|
||||
if current_token:
|
||||
tokens.append(current_token)
|
||||
current_token = ""
|
||||
else:
|
||||
current_token += c
|
||||
i += 1
|
||||
if current_token:
|
||||
tokens.append(current_token)
|
||||
return tokens
|
||||
|
||||
|
||||
class TermSet:
|
||||
"""Term set: ('term1', 'term2') -> OR operation"""
|
||||
def __init__(self, terms: List[str]):
|
||||
self.terms = terms
|
||||
self.operation = "OR"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"type": "term_set",
|
||||
"terms": self.terms,
|
||||
"operation": self.operation
|
||||
}
|
||||
|
||||
|
||||
class TagFilter:
|
||||
"""Tag-based filter"""
|
||||
def __init__(self, tag_name: str):
|
||||
self.tag_name = tag_name
|
||||
self.operation = "TAG"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"type": "tag_filter",
|
||||
"tag_name": self.tag_name,
|
||||
"operation": self.operation
|
||||
}
|
||||
|
||||
|
||||
class FieldFilter:
|
||||
"""Field-based filter (e.g., url:example.com)"""
|
||||
def __init__(self, field: str, value: str):
|
||||
self.field = field
|
||||
self.value = value
|
||||
self.operation = "FIELD"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"type": "field_filter",
|
||||
"field": self.field,
|
||||
"value": self.value,
|
||||
"operation": self.operation
|
||||
}
|
||||
|
||||
|
||||
class ANDNode:
|
||||
"""AND operation node"""
|
||||
def __init__(self, left, right):
|
||||
self.left = left
|
||||
self.right = right
|
||||
self.operation = "AND"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"type": "binary",
|
||||
"operation": self.operation,
|
||||
"left": self.left.to_dict(),
|
||||
"right": self.right.to_dict()
|
||||
}
|
||||
|
||||
|
||||
class ORNode:
|
||||
"""OR operation node"""
|
||||
def __init__(self, left, right):
|
||||
self.left = left
|
||||
self.right = right
|
||||
self.operation = "OR"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"type": "binary",
|
||||
"operation": self.operation,
|
||||
"left": self.left.to_dict(),
|
||||
"right": self.right.to_dict()
|
||||
}
|
||||
|
||||
|
||||
class XORNode:
|
||||
"""XOR operation node"""
|
||||
def __init__(self, left, right):
|
||||
self.left = left
|
||||
self.right = right
|
||||
self.operation = "XOR"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"type": "binary",
|
||||
"operation": self.operation,
|
||||
"left": self.left.to_dict(),
|
||||
"right": self.right.to_dict()
|
||||
}
|
||||
|
||||
|
||||
class NOTNode:
|
||||
"""NOT operation node"""
|
||||
def __init__(self, child):
|
||||
self.child = child
|
||||
self.operation = "NOT"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"type": "unary",
|
||||
"operation": self.operation,
|
||||
"child": self.child.to_dict()
|
||||
}
|
||||
|
||||
|
||||
def parse_query(query: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Parse query expression: ('term1', 'term2') OR tagA AND tagB XOR url:example.com
|
||||
Precedence: () > XOR > AND > OR
|
||||
"""
|
||||
tokens = tokenize(query)
|
||||
|
||||
# Remove parentheses and tokenize
|
||||
tokens = tokenize(query)
|
||||
|
||||
# Simple parser for basic queries
|
||||
# For full parser, would need recursive descent
|
||||
|
||||
# Handle term sets: ('term1', 'term2')
|
||||
term_set = None
|
||||
i = 0
|
||||
while i < len(tokens):
|
||||
token = tokens[i]
|
||||
if token.startswith('(') and tokens[i].endswith(')'):
|
||||
# Extract terms from tuple
|
||||
inner = token[1:-1]
|
||||
terms = [t.strip("'\"") for t in inner.split(',')]
|
||||
term_set = TermSet(terms)
|
||||
i += 1
|
||||
else:
|
||||
break
|
||||
|
||||
if not term_set:
|
||||
# Parse as simple expression
|
||||
# This is a simplified parser for demo
|
||||
return {"type": "term_set", "terms": []}
|
||||
|
||||
return term_set.to_dict()
|
||||
|
||||
|
||||
def execute_query(query_expression: dict, all_bookmarks: List[dict]) -> List[dict]:
|
||||
"""
|
||||
Execute query expression against bookmark list.
|
||||
For demo, returns mock results.
|
||||
"""
|
||||
# Query AST evaluation would go here
|
||||
# For now, return mock results
|
||||
return [
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"url": "https://example.com/result",
|
||||
"title": "Query Result",
|
||||
"description": "A result from the query",
|
||||
"notes": "",
|
||||
"tags": ["query", "result"],
|
||||
"favicon_url": None,
|
||||
"path": "/Query Result",
|
||||
"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.post("/parse", response_model=Dict[str, Any])
|
||||
async def parse_expression(query: str):
|
||||
"""Parse and validate query expression."""
|
||||
parsed = parse_query(query)
|
||||
return {
|
||||
"expression": query,
|
||||
"parsed": parsed,
|
||||
"valid": True
|
||||
}
|
||||
|
||||
|
||||
@router.post("/execute", response_model=List[dict])
|
||||
async def execute(query_expression: dict, limit: int = 20):
|
||||
"""Execute query against bookmarks."""
|
||||
# For demo, return mock results
|
||||
return [
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"url": "https://example.com/queried",
|
||||
"title": "Queried Item",
|
||||
"description": "Item from query",
|
||||
"notes": "",
|
||||
"tags": ["queried"],
|
||||
"favicon_url": None,
|
||||
"path": "/Queried",
|
||||
"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("/{query_id}", response_model=Dict[str, Any])
|
||||
async def get_saved_query(query_id: str):
|
||||
"""Get saved query by ID."""
|
||||
return {
|
||||
"id": query_id,
|
||||
"name": "Example Query",
|
||||
"description": "Example query description",
|
||||
"expression": "('work', 'dev') OR tag:work",
|
||||
"query_type": "dynamic",
|
||||
"is_public": False,
|
||||
"created_at": "2026-05-11T00:00:00Z",
|
||||
"updated_at": "2026-05-11T00:00:00Z"
|
||||
}
|
||||
150
LinkSyncServer/api/endpoints/sync.py
Normal file
150
LinkSyncServer/api/endpoints/sync.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
LinkSyncServer - Sync Endpoint for Browser Extension
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from typing import List, Dict, Any
|
||||
import uuid
|
||||
|
||||
router = APIRouter(prefix="/api/sync", tags=["Sync"])
|
||||
|
||||
|
||||
class SyncConfig(BaseModel):
|
||||
mode: str # "bi-directional", "browser-authoritative", "server-authoritative"
|
||||
deletions_enabled: bool = False
|
||||
|
||||
|
||||
class BookmarkData(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
|
||||
|
||||
|
||||
class SyncResponse(BaseModel):
|
||||
actions: List[Dict[str, Any]]
|
||||
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 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
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@router.get("/collections")
|
||||
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
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}")
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@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.delete("/collections/{collection_id}")
|
||||
async def delete_collection(collection_id: str):
|
||||
"""Delete collection."""
|
||||
return {"message": "Collection deleted successfully"}
|
||||
Reference in New Issue
Block a user