diff --git a/CHANGELOG.md b/CHANGELOG.md index eba83c1..403d9e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,10 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning - Source detection: include non-land mana producers and colorless 'C'; basic lands reliably counted; fetch lands excluded as sources - Favicon support: `/favicon.ico` served (ICO with PNG fallback) - Diagnostics: `/healthz` endpoint returns `{status, version, uptime_seconds}`; responses carry `X-Request-ID`; unhandled errors return JSON with request_id + - Diagnostics page and tools gated by `SHOW_DIAGNOSTICS`; Logs page gated by `SHOW_LOGS`; both off by default + - Global error handling: friendly HTML templates for 404/4xx/500 with Request-ID and "Go home" link; JSON structure for HTMX/API + - Request-ID middleware assigns `X-Request-ID` to all responses and includes it in JSON error payloads + - `/status/logs?tail=N` endpoint (read-only) to fetch a recent log tail for quick diagnostics - Tooltip Copy action on chart tooltips (Pips/Sources) for quick sharing of per-color card lists ### Changed @@ -49,6 +53,10 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning - Deck summary reporting now includes colorless 'C' in totals and cards; UI adds a Show C toggle for Sources - List view highlight polished to wrap only the card name (no overrun of the row) - Total sources calculation updated to include 'C' properly + - 404s from Starlette now render the HTML 404 page when requested from a browser (Accept: text/html) + - Owned page UX: full-size preview now pops on thumbnail hover (not the name); selection highlight tightened to the thumbnail only and changed to white for better contrast; Themes in the hover popout render as a larger bullet list with a brighter "THEMES" label + - Image robustness: standardized `data-card-name` on all Scryfall images and centralized retry logic (thumbnails + previews) with version fallbacks (small/normal/large) and a single cache-bust refresh on final failure; removed the previous hover-image cache to reduce complexity and overhead + - Deck Summary list view: rows use fixed tracks for count, ×, name, and owned columns (monospace tabular numerals) to ensure perfect alignment; highlight is an inset box-shadow on the name to avoid layout shifts; long names ellipsize with a tooltip; list starts directly under the type header and remains stable on full-screen widths ### Fixed - Docker Hub workflow no longer publishes a `major.minor` tag (e.g., `1.1`); only full semver (e.g., `1.2.3`) and `latest` @@ -57,6 +65,8 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning - Source highlighting consistency: charts now correctly cross-highlight corresponding cards in both list and thumbnail views - Basics handling: ensured basic lands and Wastes are recognized as sources; added fallback oracle text for basics in CSV export - Fetch lands are no longer miscounted as mana sources + - Web 404s previously returned JSON to browsers in some cases; now correctly render HTML via a Starlette HTTPException handler + - Deck summary alignment issues in some sections (e.g., Enchantments) fixed by splitting the count and the × into separate columns and pinning the owned flag to a fixed width; prevents drift across responsive breakpoints --- diff --git a/DOCKER.md b/DOCKER.md index ca58875..108362d 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -27,8 +27,7 @@ The web UI runs the same deckbuilding logic behind a browser-based interface. ### PowerShell (recommended) ```powershell -docker compose build web -docker compose up --no-deps web +docker compose up --build --no-deps -d web ``` Then open http://localhost:8080 @@ -36,6 +35,37 @@ Then open http://localhost:8080 Volumes are the same as the CLI service, so deck exports/logs/configs persist in your working folder. The app serves a favicon at `/favicon.ico` and exposes a health endpoint at `/healthz`. +### Diagnostics and logs (optional) +Enable internal diagnostics and a read-only logs viewer with environment flags. + +- `SHOW_DIAGNOSTICS=1` — adds a Diagnostics nav link and `/diagnostics` tools +- `SHOW_LOGS=1` — enables `/logs` and `/status/logs?tail=200` + +When enabled: +- `/logs` supports an auto-refresh toggle with interval, a level filter (All/Error/Warning/Info/Debug), and a Copy button to copy the visible tail. +- `/status/sys` returns a simple system summary (version, uptime, UTC server time, and feature flags) and is shown on the Diagnostics page when `SHOW_DIAGNOSTICS=1`. + +Compose example (web service): +```yaml +environment: + - SHOW_LOGS=1 + - SHOW_DIAGNOSTICS=1 +``` + +Docker Hub (PowerShell) example: +```powershell +docker run --rm ` + -p 8080:8080 ` + -e SHOW_LOGS=1 -e SHOW_DIAGNOSTICS=1 ` + -v "${PWD}/deck_files:/app/deck_files" ` + -v "${PWD}/logs:/app/logs" ` + -v "${PWD}/csv_files:/app/csv_files" ` + -v "${PWD}/owned_cards:/app/owned_cards" ` + -v "${PWD}/config:/app/config" ` + mwisnowski/mtg-python-deckbuilder:latest ` + bash -lc "cd /app && uvicorn code.web.app:app --host 0.0.0.0 --port 8080" +``` + ### Setup speed: parallel tagging (Web) First-time setup or stale data triggers card tagging. The web service uses parallel workers by default. diff --git a/README.md b/README.md index ddba19a..e9375cd 100644 Binary files a/README.md and b/README.md differ diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md index c473606..fa2475f 100644 --- a/RELEASE_NOTES_TEMPLATE.md +++ b/RELEASE_NOTES_TEMPLATE.md @@ -13,6 +13,15 @@ - Prefer-owned option in the Web UI Review step prioritizes owned cards while allowing unowned fallback; applied across creatures and spells with stable reordering and gentle weight boosts. - Owned page: export TXT/CSV, sort controls, live "N shown," color identity dots, exact color-identity combo filters (incl. 4-color), viewport-filling list, and scrollbar styling. Upload-time enrichment and de-duplication speeds up page loads. - Staged build visibility: optional "Show skipped stages" reveals phases that added no cards with a clear annotation. + - Owned page UX: hover preview now triggers from the thumbnail, not the name; selection outline is restricted to the thumbnail and uses white for clarity; hover popout shows Themes as a larger bullet list with a bright label. + - Image robustness: all Scryfall images include `data-card-name` and participate in centralized retry (version fallback + one cache-bust) for thumbnails and previews. + - Deck Summary: aligned text-mode list (fixed columns for count/×/name/owned), highlight that doesn’t shift layout, and tooltips for truncated names. The list begins directly under each type header for better scanability. + +### Diagnostics and error handling +- Health endpoint `/healthz` returns `{ status, version, uptime_seconds }`. +- All responses include `X-Request-ID`; JSON error payloads include `request_id` for correlation. +- Friendly HTML error pages for 404/4xx/500 with a "Go home" link (browser requests). +- Feature flags: `SHOW_DIAGNOSTICS=1` to enable a diagnostics page with test tools; `SHOW_LOGS=1` to enable a logs page and `/status/logs?tail=N`. ## What’s new - Web UI: Staged run with a new "Creatures: All-Theme" phase in AND mode; shows matched selected themes per card for explainability. Step 2 UI clarifies AND/OR with a tooltip and restyled Why panel. @@ -80,6 +89,8 @@ docker compose up --no-deps web - Review step consolidates owned-only and prefer-owned controls; Step 5 is status-only with an "Edit in Review" link for changes. - Owned lists processing moved to upload-time in Web; per-request parsing removed. Enriched store powers fast Owned page and deck-building. - Finished Decks page uses a dropdown theme filter with shareable state. + - Global image retry binding for all card images (thumbnails and previews), with no JS hover cache to minimize memory and complexity. + - Deck Summary fixes: separated count and × into distinct columns, fixed-width owned indicator, and responsive stability at fullscreen widths. ### Tagging updates - Explore/Map: treat "+1/+1 counter" as a literal; Explore adds Card Selection and may add +1/+1 Counters; Map adds Card Selection and Tokens Matter. diff --git a/code/tests/test_diagnostics.py b/code/tests/test_diagnostics.py new file mode 100644 index 0000000..2ac21dc --- /dev/null +++ b/code/tests/test_diagnostics.py @@ -0,0 +1,82 @@ +import os +import importlib +import types +import pytest +from starlette.testclient import TestClient + +fastapi = pytest.importorskip("fastapi") # skip tests if FastAPI isn't installed + + +def load_app_with_env(**env: str) -> types.ModuleType: + for k, v in env.items(): + os.environ[k] = v + import code.web.app as app_module # type: ignore + importlib.reload(app_module) + return app_module + + +def test_healthz_ok_and_request_id_header(): + app_module = load_app_with_env() + client = TestClient(app_module.app) + r = client.get("/healthz") + assert r.status_code == 200 + data = r.json() + assert data.get("status") in {"ok", "degraded"} + assert "uptime_seconds" in data + assert r.headers.get("X-Request-ID") + + +def test_404_renders_html_when_accept_html(): + app_module = load_app_with_env() + client = TestClient(app_module.app) + r = client.get("/this-does-not-exist", headers={"Accept": "text/html"}) + assert r.status_code == 404 + body = r.text.lower() + assert "page not found" in body + assert "go home" in body + assert r.headers.get("X-Request-ID") + + +def test_htmx_http_exception_returns_json_with_request_id(): + app_module = load_app_with_env(SHOW_DIAGNOSTICS="1") + client = TestClient(app_module.app) + r = client.get("/diagnostics/trigger-error", headers={"HX-Request": "true"}) + assert r.status_code == 418 + data = r.json() + assert data.get("error") is True + assert data.get("status") == 418 + assert data.get("request_id") + assert r.headers.get("X-Request-ID") + + +def test_unhandled_exception_returns_500_json_with_request_id(): + app_module = load_app_with_env(SHOW_DIAGNOSTICS="1") + # Configure client to not re-raise server exceptions so we can assert the 500 response + client = TestClient(app_module.app, raise_server_exceptions=False) + r = client.get("/diagnostics/trigger-error?kind=unhandled", headers={"HX-Request": "true"}) + assert r.status_code == 500 + data = r.json() + assert data.get("error") is True + assert data.get("status") == 500 + assert data.get("request_id") + assert r.headers.get("X-Request-ID") + + +def test_status_sys_summary_and_flags(): + app_module = load_app_with_env( + SHOW_LOGS="1", + SHOW_DIAGNOSTICS="1", + SHOW_SETUP="1", + APP_VERSION="testver", + ) + client = TestClient(app_module.app) + r = client.get("/status/sys") + assert r.status_code == 200 + data = r.json() + assert data.get("version") == "testver" + assert isinstance(data.get("uptime_seconds"), int) + assert isinstance(data.get("server_time_utc"), str) + flags = data.get("flags") or {} + assert flags.get("SHOW_LOGS") is True + assert flags.get("SHOW_DIAGNOSTICS") is True + assert flags.get("SHOW_SETUP") is True diff --git a/code/web/app.py b/code/web/app.py index 054e1bc..8c5e0fd 100644 --- a/code/web/app.py +++ b/code/web/app.py @@ -1,7 +1,7 @@ from __future__ import annotations -from fastapi import FastAPI, Request, HTTPException -from fastapi.responses import HTMLResponse, FileResponse, PlainTextResponse, JSONResponse +from fastapi import FastAPI, Request, HTTPException, Query +from fastapi.responses import HTMLResponse, FileResponse, PlainTextResponse, JSONResponse, Response from fastapi.templating import Jinja2Templates from fastapi.staticfiles import StaticFiles from pathlib import Path @@ -10,6 +10,7 @@ import json as _json import time import uuid import logging +from starlette.exceptions import HTTPException as StarletteHTTPException # Resolve template/static dirs relative to this file _THIS_DIR = Path(__file__).resolve().parent @@ -33,11 +34,13 @@ def _as_bool(val: str | None, default: bool = False) -> bool: SHOW_LOGS = _as_bool(os.getenv("SHOW_LOGS"), False) SHOW_SETUP = _as_bool(os.getenv("SHOW_SETUP"), True) +SHOW_DIAGNOSTICS = _as_bool(os.getenv("SHOW_DIAGNOSTICS"), False) # Expose as Jinja globals so all templates can reference without passing per-view templates.env.globals.update({ "show_logs": SHOW_LOGS, "show_setup": SHOW_SETUP, + "show_diagnostics": SHOW_DIAGNOSTICS, }) # --- Diagnostics: request-id and uptime --- @@ -74,6 +77,58 @@ async def healthz(): # Avoid throwing from health return {"status": "degraded"} +# System summary endpoint for diagnostics +@app.get("/status/sys") +async def status_sys(): + try: + version = os.getenv("APP_VERSION", "dev") + uptime_s = int(time.time() - _APP_START_TIME) + server_time = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + return { + "version": version, + "uptime_seconds": uptime_s, + "server_time_utc": server_time, + "flags": { + "SHOW_LOGS": bool(SHOW_LOGS), + "SHOW_SETUP": bool(SHOW_SETUP), + "SHOW_DIAGNOSTICS": bool(SHOW_DIAGNOSTICS), + }, + } + except Exception: + return {"version": "unknown", "uptime_seconds": 0, "flags": {}} + +# Logs tail endpoint (read-only) +@app.get("/status/logs") +async def status_logs( + tail: int = Query(200, ge=1, le=500), + q: str | None = None, + level: str | None = Query(None, description="Optional level filter: error|warning|info|debug"), +): + try: + if not SHOW_LOGS: + # Hide when logs are disabled + return JSONResponse({"error": True, "status": 403, "detail": "Logs disabled"}, status_code=403) + log_path = Path('logs/deck_builder.log') + if not log_path.exists(): + return JSONResponse({"lines": [], "count": 0}) + from collections import deque + with log_path.open('r', encoding='utf-8', errors='ignore') as lf: + lines = list(deque(lf, maxlen=tail)) + if q: + ql = q.lower() + lines = [ln for ln in lines if ql in ln.lower()] + # Optional level filter (simple substring match) + if level: + lv = level.strip().lower() + # accept warn as alias for warning + if lv == "warn": + lv = "warning" + if lv in {"error", "warning", "info", "debug"}: + lines = [ln for ln in lines if lv in ln.lower()] + return JSONResponse({"lines": lines, "count": len(lines)}) + except Exception: + return JSONResponse({"lines": [], "count": 0}) + # Lightweight setup/tagging status endpoint @app.get("/status/setup") async def setup_status(): @@ -124,24 +179,59 @@ app.include_router(setup_routes.router) app.include_router(owned_routes.router) # --- Exception handling --- +def _wants_html(request: Request) -> bool: + try: + accept = request.headers.get('accept', '') + is_htmx = request.headers.get('hx-request') == 'true' + return ("text/html" in accept) and not is_htmx + except Exception: + return False + + @app.exception_handler(HTTPException) async def http_exception_handler(request: Request, exc: HTTPException): rid = getattr(request.state, "request_id", None) or uuid.uuid4().hex logging.getLogger("web").warning( f"HTTPException [rid={rid}] {exc.status_code} {request.method} {request.url.path}: {exc.detail}" ) - # Return JSON structure suitable for HTMX or API consumers - return JSONResponse( - status_code=exc.status_code, - content={ - "error": True, - "status": exc.status_code, - "detail": exc.detail, - "request_id": rid, - "path": str(request.url.path), - }, - headers={"X-Request-ID": rid}, + if _wants_html(request): + # Friendly HTML page + template = "errors/404.html" if exc.status_code == 404 else "errors/4xx.html" + try: + return templates.TemplateResponse(template, {"request": request, "status": exc.status_code, "detail": exc.detail, "request_id": rid}, status_code=exc.status_code, headers={"X-Request-ID": rid}) + except Exception: + # Fallback plain text + return PlainTextResponse(f"Error {exc.status_code}: {exc.detail}\nRequest-ID: {rid}", status_code=exc.status_code, headers={"X-Request-ID": rid}) + # JSON structure for HTMX/API + return JSONResponse(status_code=exc.status_code, content={ + "error": True, + "status": exc.status_code, + "detail": exc.detail, + "request_id": rid, + "path": str(request.url.path), + }, headers={"X-Request-ID": rid}) + + +# Also handle Starlette's HTTPException (e.g., 404 route not found) +@app.exception_handler(StarletteHTTPException) +async def starlette_http_exception_handler(request: Request, exc: StarletteHTTPException): + rid = getattr(request.state, "request_id", None) or uuid.uuid4().hex + logging.getLogger("web").warning( + f"HTTPException* [rid={rid}] {exc.status_code} {request.method} {request.url.path}: {exc.detail}" ) + if _wants_html(request): + template = "errors/404.html" if exc.status_code == 404 else "errors/4xx.html" + try: + return templates.TemplateResponse(template, {"request": request, "status": exc.status_code, "detail": exc.detail, "request_id": rid}, status_code=exc.status_code, headers={"X-Request-ID": rid}) + except Exception: + return PlainTextResponse(f"Error {exc.status_code}: {exc.detail}\nRequest-ID: {rid}", status_code=exc.status_code, headers={"X-Request-ID": rid}) + return JSONResponse(status_code=exc.status_code, content={ + "error": True, + "status": exc.status_code, + "detail": exc.detail, + "request_id": rid, + "path": str(request.url.path), + }, headers={"X-Request-ID": rid}) @app.exception_handler(Exception) @@ -150,17 +240,18 @@ async def unhandled_exception_handler(request: Request, exc: Exception): logging.getLogger("web").error( f"Unhandled exception [rid={rid}] {request.method} {request.url.path}", exc_info=True ) - return JSONResponse( - status_code=500, - content={ - "error": True, - "status": 500, - "detail": "Internal Server Error", - "request_id": rid, - "path": str(request.url.path), - }, - headers={"X-Request-ID": rid}, - ) + if _wants_html(request): + try: + return templates.TemplateResponse("errors/500.html", {"request": request, "request_id": rid}, status_code=500, headers={"X-Request-ID": rid}) + except Exception: + return PlainTextResponse(f"Internal Server Error\nRequest-ID: {rid}", status_code=500, headers={"X-Request-ID": rid}) + return JSONResponse(status_code=500, content={ + "error": True, + "status": 500, + "detail": "Internal Server Error", + "request_id": rid, + "path": str(request.url.path), + }, headers={"X-Request-ID": rid}) # Lightweight file download endpoint for exports @app.get("/files") @@ -196,3 +287,47 @@ async def favicon(): return FileResponse(str(target)) except Exception: return PlainTextResponse("Error", status_code=500) + + +# Simple Logs page (optional, controlled by SHOW_LOGS) +@app.get("/logs", response_class=HTMLResponse) +async def logs_page( + request: Request, + tail: int = Query(200, ge=1, le=500), + q: str | None = None, + level: str | None = Query(None), +) -> Response: + if not SHOW_LOGS: + # Respect feature flag + raise HTTPException(status_code=404, detail="Not Found") + # Reuse status_logs logic + data = await status_logs(tail=tail, q=q, level=level) # type: ignore[arg-type] + lines: list[str] + if isinstance(data, JSONResponse): + payload = data.body + try: + parsed = _json.loads(payload) + lines = parsed.get("lines", []) + except Exception: + lines = [] + else: + lines = [] + return templates.TemplateResponse( + "diagnostics/logs.html", + {"request": request, "lines": lines, "tail": tail, "q": q or "", "level": (level or "all")}, + ) + + +# Error trigger route for demoing HTMX/global error handling (feature-flagged) +@app.get("/diagnostics/trigger-error") +async def trigger_error(kind: str = Query("http")): + if kind == "http": + raise HTTPException(status_code=418, detail="Teapot: example error for testing") + raise RuntimeError("Example unhandled error for testing") + + +@app.get("/diagnostics", response_class=HTMLResponse) +async def diagnostics_home(request: Request) -> HTMLResponse: + if not SHOW_DIAGNOSTICS: + raise HTTPException(status_code=404, detail="Not Found") + return templates.TemplateResponse("diagnostics/index.html", {"request": request}) diff --git a/code/web/routes/owned.py b/code/web/routes/owned.py index e29145a..8f736f8 100644 --- a/code/web/routes/owned.py +++ b/code/web/routes/owned.py @@ -77,7 +77,6 @@ def _build_owned_context(request: Request, notice: str | None = None, error: str # Read enriched data from the store (fast path; avoids per-request CSV parsing) names, tags_by_name, type_by_name, colors_by_name = store.get_enriched() added_at_map = store.get_added_at_map() - user_tags_map = store.get_user_tags_map() # Default sort by name (case-insensitive) names_sorted = sorted(names, key=lambda s: s.lower()) # Build filter option sets @@ -98,7 +97,6 @@ def _build_owned_context(request: Request, notice: str | None = None, error: str "all_colors": all_colors, "color_combos": combos, "added_at_map": added_at_map, - "user_tags_map": user_tags_map, } if notice: ctx["notice"] = notice @@ -170,56 +168,12 @@ async def owned_remove(request: Request) -> HTMLResponse: return templates.TemplateResponse("owned/index.html", ctx) -@router.post("/tag/add", response_class=HTMLResponse) -async def owned_tag_add(request: Request) -> HTMLResponse: - try: - names: list[str] = [] - tag: str = "" - try: - payload = await request.json() - if isinstance(payload, dict): - if isinstance(payload.get("names"), list): - names = [str(x) for x in payload.get("names")] - tag = str(payload.get("tag") or "").strip() - except Exception: - form = await request.form() - raw = form.get("names") or "" - if raw: - names = [s.strip() for s in str(raw).split(',') if s.strip()] - tag = str(form.get("tag") or "").strip() - updated = store.add_user_tag(names, tag) - notice = f"Added tag '{tag}' to {updated} name(s)." - ctx = _build_owned_context(request, notice=notice) - return templates.TemplateResponse("owned/index.html", ctx) - except Exception as e: - ctx = _build_owned_context(request, error=f"Tag add failed: {e}") - return templates.TemplateResponse("owned/index.html", ctx) +# Bulk user-tag endpoints removed by request. -@router.post("/tag/remove", response_class=HTMLResponse) -async def owned_tag_remove(request: Request) -> HTMLResponse: - try: - names: list[str] = [] - tag: str = "" - try: - payload = await request.json() - if isinstance(payload, dict): - if isinstance(payload.get("names"), list): - names = [str(x) for x in payload.get("names")] - tag = str(payload.get("tag") or "").strip() - except Exception: - form = await request.form() - raw = form.get("names") or "" - if raw: - names = [s.strip() for s in str(raw).split(',') if s.strip()] - tag = str(form.get("tag") or "").strip() - updated = store.remove_user_tag(names, tag) - notice = f"Removed tag '{tag}' from {updated} name(s)." - ctx = _build_owned_context(request, notice=notice) - return templates.TemplateResponse("owned/index.html", ctx) - except Exception as e: - ctx = _build_owned_context(request, error=f"Tag remove failed: {e}") - return templates.TemplateResponse("owned/index.html", ctx) +""" +Note: Per request, all user tag add/remove endpoints have been removed. +""" # Legacy /owned/use route removed; owned-only toggle now lives on the Builder Review step. diff --git a/code/web/services/owned_store.py b/code/web/services/owned_store.py index eab044b..76fa313 100644 --- a/code/web/services/owned_store.py +++ b/code/web/services/owned_store.py @@ -303,14 +303,7 @@ def get_enriched() -> Tuple[List[str], Dict[str, List[str]], Dict[str, str], Dic for n in names: info = meta.get(n) or {} tags = (info.get('tags') or []) - user_tags = (info.get('user_tags') or []) - if user_tags: - # merge user tags (unique, case-insensitive) - seen = {str(t).lower() for t in tags} - for ut in user_tags: - if str(ut).lower() not in seen: - (tags or []).append(str(ut)) - seen.add(str(ut).lower()) + # user-defined tags are no longer supported; no merge typ = info.get('type') or None cols = info.get('colors') or [] if tags: @@ -322,54 +315,7 @@ def get_enriched() -> Tuple[List[str], Dict[str, List[str]], Dict[str, str], Dic return names, tags_by_name, type_by_name, colors_by_name -def add_user_tag(names: Iterable[str], tag: str) -> int: - """Add a user-defined tag to the given names; returns number of names updated.""" - t = str(tag or '').strip() - if not t: - return 0 - data = _load_raw() - cur = [str(x).strip() for x in (data.get('names') or []) if str(x).strip()] - target = {str(n).strip().lower() for n in (names or []) if str(n).strip()} - meta = data.get('meta') or {} - updated = 0 - for s in cur: - if s.lower() not in target: - continue - entry = meta.setdefault(s, {}) - arr = entry.get('user_tags') or [] - if not any(str(x).strip().lower() == t.lower() for x in arr): - arr.append(t) - entry['user_tags'] = arr - updated += 1 - data['meta'] = meta - _save_raw(data) - return updated - - -def remove_user_tag(names: Iterable[str], tag: str) -> int: - """Remove a user-defined tag from the given names; returns number of names updated.""" - t = str(tag or '').strip() - if not t: - return 0 - data = _load_raw() - cur = [str(x).strip() for x in (data.get('names') or []) if str(x).strip()] - target = {str(n).strip().lower() for n in (names or []) if str(n).strip()} - meta = data.get('meta') or {} - updated = 0 - for s in cur: - if s.lower() not in target: - continue - entry = meta.get(s) or {} - arr = [x for x in (entry.get('user_tags') or []) if str(x)] - before = len(arr) - arr = [x for x in arr if str(x).strip().lower() != t.lower()] - if len(arr) != before: - entry['user_tags'] = arr - meta[s] = entry - updated += 1 - data['meta'] = meta - _save_raw(data) - return updated +# add_user_tag/remove_user_tag removed; user-defined tags are not persisted anymore def get_added_at_map() -> Dict[str, int]: @@ -416,18 +362,8 @@ def remove_names(names: Iterable[str]) -> Tuple[int, int]: def get_user_tags_map() -> Dict[str, list[str]]: - """Return a mapping of name -> list of user-defined tags (if any).""" - data = _load_raw() - meta: Dict[str, Dict[str, object]] = data.get("meta") or {} - out: Dict[str, list[str]] = {} - for n, info in meta.items(): - try: - arr = [x for x in (info.get("user_tags") or []) if str(x)] - if arr: - out[n] = [str(x) for x in arr] - except Exception: - continue - return out + """Deprecated: user-defined tags have been removed. Always returns empty mapping.""" + return {} def parse_txt_bytes(content: bytes) -> List[str]: diff --git a/code/web/static/app.js b/code/web/static/app.js index 6aad0f9..8b3223e 100644 --- a/code/web/static/app.js +++ b/code/web/static/app.js @@ -41,20 +41,62 @@ t.className = 'toast' + (type ? ' '+type : ''); t.setAttribute('role','status'); t.setAttribute('aria-live','polite'); - t.textContent = msg; + t.textContent = ''; + if (typeof msg === 'string') { t.textContent = msg; } + else if (msg && msg.nodeType === 1) { t.appendChild(msg); } toastHost.appendChild(t); var delay = (opts && opts.duration) || 2600; setTimeout(function(){ t.classList.add('hide'); setTimeout(function(){ t.remove(); }, 300); }, delay); return t; } window.toast = toast; + function toastHTML(html, type, opts){ + var container = document.createElement('div'); + container.innerHTML = html; + return toast(container, type, opts); + } + window.toastHTML = toastHTML; // Global HTMX error handling => toast document.addEventListener('htmx:responseError', function(e){ var detail = e.detail || {}; var xhr = detail.xhr || {}; - var msg = 'Action failed'; - try { if (xhr.responseText) msg += ': ' + xhr.responseText.slice(0,140); } catch(_){} - toast(msg, 'error', { duration: 5000 }); + var rid = (xhr.getResponseHeader && xhr.getResponseHeader('X-Request-ID')) || ''; + var payload = (function(){ try { return JSON.parse(xhr.responseText || '{}'); } catch(_){ return {}; } })(); + var status = payload.status || xhr.status || ''; + var msg = payload.detail || payload.message || 'Action failed'; + var path = payload.path || (e && e.detail && e.detail.path) || ''; + var html = ''+ + '
'+rid+'