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:
@@ -1,253 +1,71 @@
|
||||
"""
|
||||
LinkSyncServer - Query Engine
|
||||
LinkSyncServer - Query Engine Endpoints
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from typing import List, Optional, Dict, Any
|
||||
import re
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from models.base import Bookmark, get_session
|
||||
from queries.executor import execute_query
|
||||
from queries.parser import QueryParser
|
||||
|
||||
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
|
||||
}
|
||||
async def parse_expression(expression: str):
|
||||
try:
|
||||
parser = QueryParser()
|
||||
parsed = parser.parse(expression)
|
||||
return {
|
||||
"expression": expression,
|
||||
"parsed": parsed,
|
||||
"valid": True,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"expression": expression,
|
||||
"parsed": None,
|
||||
"valid": False,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
|
||||
@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
|
||||
}
|
||||
]
|
||||
async def execute(expression: str, limit: int = 20, offset: int = 0):
|
||||
db = get_session()
|
||||
try:
|
||||
parser = QueryParser()
|
||||
parsed = parser.parse(expression)
|
||||
if not parsed:
|
||||
raise HTTPException(status_code=400, detail="Invalid query expression")
|
||||
|
||||
all_bookmarks = db.query(Bookmark).all()
|
||||
results = execute_query(parsed, [b.to_dict() for b in all_bookmarks])
|
||||
return results[offset : offset + limit]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@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"
|
||||
}
|
||||
db = get_session()
|
||||
try:
|
||||
from models.base import Collection
|
||||
collection = db.query(Collection).filter(Collection.id == query_id).first()
|
||||
if not collection or collection.query_type != "dynamic":
|
||||
raise HTTPException(status_code=404, detail="Saved query not found")
|
||||
return {
|
||||
"id": collection.id,
|
||||
"name": collection.name,
|
||||
"description": collection.description,
|
||||
"expression": collection.query_expression,
|
||||
"query_type": collection.query_type,
|
||||
"is_public": collection.is_public,
|
||||
"created_at": collection.created_at.isoformat() if collection.created_at else None,
|
||||
"updated_at": collection.updated_at.isoformat() if collection.updated_at else None,
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
Reference in New Issue
Block a user