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

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