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