feat: add web UI, query engine, session management, and 20 E2E tests

- Web UI: login, dashboard, links CRUD, collections, API keys, admin pages
- Query engine: AND/OR/XOR with field filters, tag search, preview endpoint
- Session management: token expiry detection, 401 interceptor, expiry banner
- Links search: tags included, multi-word AND, query mode with set operations
- Collections: static/dynamic, query builder with preview, public tree view
- Save as Collection: convert search results (static) or query (dynamic)
- Dashboard stats: resilient loading with allSettled pattern
- Login page: redesigned with public collections tree view
- Bug fix: query executor None fields crash (notes/description/url/title)
- E2E tests: 20 Playwright tests covering all critical user flows
- All 104 tests passing (84 unit/integration + 20 E2E)
This commit is contained in:
DavidSaylor
2026-05-22 07:46:53 -05:00
parent 77b076c7d7
commit fe4cbc3537
29 changed files with 1410 additions and 78 deletions

View File

@@ -2,17 +2,20 @@
LinkSyncServer - Query Engine Endpoints
"""
import logging
import uuid
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Query, Request
from models.base import Bookmark, get_session
from queries.executor import execute_query
from queries.parser import QueryParser
from queries.parser import QueryParser, QuerySyntaxError
router = APIRouter(prefix="/api/queries", tags=["Queries"])
logger = logging.getLogger(__name__)
@router.post("/parse", response_model=Dict[str, Any])
async def parse_expression(expression: str):
@@ -33,6 +36,32 @@ async def parse_expression(expression: str):
}
@router.get("/preview", response_model=dict)
async def preview_query(expression: str = Query(...), limit: int = Query(20, le=100, ge=1)):
db = get_session()
try:
parser = QueryParser()
try:
parsed = parser.parse(expression)
except QuerySyntaxError as e:
return {"error": str(e), "results": [], "count": 0}
except Exception as e:
logger.error(f"Query parse error: {e}")
return {"error": f"Invalid query: {str(e)}", "results": [], "count": 0}
if not parsed:
return {"error": "Empty query expression", "results": [], "count": 0}
all_bookmarks = db.query(Bookmark).all()
results = execute_query(parsed, [b.to_dict() for b in all_bookmarks])
return {"results": results[:limit], "count": len(results), "error": None}
except Exception as e:
logger.error(f"Query preview error: {e}", exc_info=True)
return {"error": str(e), "results": [], "count": 0}
finally:
db.close()
@router.post("/execute", response_model=List[dict])
async def execute(expression: str, limit: int = 20, offset: int = 0):
db = get_session()
@@ -69,3 +98,25 @@ async def get_saved_query(query_id: str):
}
finally:
db.close()
@router.get("/{query_id}", response_model=Dict[str, Any])
async def get_saved_query(query_id: str):
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()