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

@@ -44,8 +44,12 @@ def get_current_user_id(request: Request) -> Optional[str]:
from config.settings import settings
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
return payload.get("sub")
except Exception:
pass
except jwt.ExpiredSignatureError:
logger.warning("Token expired")
except jwt.InvalidTokenError as e:
logger.warning(f"Invalid token: {e}")
except Exception as e:
logger.warning(f"Token decode error: {e}")
return None
@@ -98,6 +102,40 @@ async def list_collections(
db.close()
@router.get("/public-tree", response_model=List[dict])
async def public_collections_tree():
db = get_session()
try:
collections = db.query(Collection).filter(Collection.is_public == True).order_by(Collection.name).all()
result = []
for col in collections:
col_data = col.to_dict()
links = []
if col.query_type == "static":
cbs = db.query(CollectionBookmark).filter(CollectionBookmark.collection_id == col.id).all()
for cb in cbs:
bm = db.query(Bookmark).filter(Bookmark.id == cb.link_id).first()
if bm:
links.append(bm.to_dict())
elif col.query_type == "dynamic" and col.query_expression:
from queries.executor import execute_query
from queries.parser import QueryParser
try:
parser = QueryParser()
parsed = parser.parse(col.query_expression.get("expression", ""))
if parsed:
all_bookmarks = db.query(Bookmark).all()
matched = execute_query(parsed, [b.to_dict() for b in all_bookmarks])
links = matched
except Exception:
pass
col_data["links"] = links
result.append(col_data)
return result
finally:
db.close()
@router.get("/{collection_id}", response_model=dict)
async def get_collection(collection_id: str):
db = get_session()
@@ -112,7 +150,7 @@ async def get_collection(collection_id: str):
.filter(CollectionBookmark.collection_id == parse_uuid(collection_id))
.all()
)
result["link_ids"] = [lb.bookmark_id for lb in links]
result["link_ids"] = [lb.link_id for lb in links]
return result
finally:
db.close()
@@ -150,7 +188,7 @@ async def create_collection(data: CollectionCreate, request: Request):
lid = uuid.UUID(link_id) if isinstance(link_id, str) else link_id
except (ValueError, AttributeError):
continue
cb = CollectionBookmark(collection_id=collection.id, bookmark_id=lid)
cb = CollectionBookmark(collection_id=collection.id, link_id=lid)
db.add(cb)
db.commit()
@@ -245,7 +283,7 @@ async def add_links_to_collection(collection_id: str, link_ids: List[str]):
raise HTTPException(status_code=400, detail="Can only add links to static collections")
existing = {
cb.bookmark_id
cb.link_id
for cb in db.query(CollectionBookmark)
.filter(CollectionBookmark.collection_id == parsed_cid)
.all()
@@ -257,7 +295,7 @@ async def add_links_to_collection(collection_id: str, link_ids: List[str]):
except (ValueError, AttributeError):
continue
if lid not in existing:
db.add(CollectionBookmark(collection_id=parsed_cid, bookmark_id=lid))
db.add(CollectionBookmark(collection_id=parsed_cid, link_id=lid))
added += 1
db.commit()
@@ -288,7 +326,7 @@ async def remove_links_from_collection(collection_id: str, link_ids: List[str]):
db.query(CollectionBookmark)
.filter(
CollectionBookmark.collection_id == parsed_cid,
CollectionBookmark.bookmark_id.in_(parsed_link_ids),
CollectionBookmark.link_id.in_(parsed_link_ids),
)
.delete(synchronize_session=False)
)

View File

@@ -8,7 +8,7 @@ from typing import List, Optional
from fastapi import APIRouter, HTTPException, Query, Request, status
from pydantic import BaseModel, Field
from sqlalchemy import or_
from sqlalchemy import or_, func
from config.settings import settings
from models.base import AuditLog, Bookmark, User, get_session
@@ -90,13 +90,31 @@ async def list_bookmarks(
try:
query = db.query(Bookmark)
if search:
query = query.filter(
or_(
Bookmark.title.ilike(f"%{search}%"),
Bookmark.description.ilike(f"%{search}%"),
Bookmark.url.ilike(f"%{search}%"),
from sqlalchemy.sql import text
from models.base import get_engine
engine = get_engine()
dialect = engine.dialect.name
terms = [t.strip() for t in search.split() if t.strip()]
for term in terms:
term_lower = term.lower()
if dialect == "postgresql":
tag_match = text(
"EXISTS (SELECT 1 FROM jsonb_array_elements_text(links.tags) AS t WHERE lower(t) LIKE :search_tag)"
).bindparams(search_tag=f"%{term_lower}%")
else:
tag_match = text(
"EXISTS (SELECT 1 FROM json_each(links.tags) WHERE lower(value) LIKE :search_tag)"
).bindparams(search_tag=f"%{term_lower}%")
term_filter = or_(
Bookmark.title.ilike(f"%{term}%"),
Bookmark.description.ilike(f"%{term}%"),
Bookmark.url.ilike(f"%{term}%"),
Bookmark.notes.ilike(f"%{term}%"),
tag_match,
)
)
query = query.filter(term_filter)
if tags_filter:
for tag in tags_filter:
query = query.filter(Bookmark.tags.contains(tag))

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()

View File

@@ -210,12 +210,12 @@ async def add_links_to_collection(collection_id: str, bookmark_ids: List[str]):
db.query(CollectionBookmark)
.filter(
CollectionBookmark.collection_id == parsed_cid,
CollectionBookmark.bookmark_id == parsed_bid,
CollectionBookmark.link_id == parsed_bid,
)
.first()
)
if not existing:
db.add(CollectionBookmark(collection_id=parsed_cid, bookmark_id=parsed_bid))
db.add(CollectionBookmark(collection_id=parsed_cid, link_id=parsed_bid))
added += 1
db.commit()