Browse catalog dialog (issue #10 phase 2)
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 11s
CI / Tests (py3.12 / windows-latest) (pull_request) Failing after 23s
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 11s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 11s
CI / Catalog signature (pull_request) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 11s
CI / Tests (py3.12 / windows-latest) (pull_request) Failing after 23s
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 11s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 11s
CI / Catalog signature (pull_request) Successful in 7s
Adds the user-facing half of the server catalog: a searchable, signed catalog browser wired to the existing paste/import path. bcc_core.py (pure, GUI-free, per the design comment on #10): - CATALOG_CATEGORY_GROUPS / catalog_category_group(): collapses the raw taxonomy to the 7 UI chips (unknown categories fall back to Other, never drop an entry). - catalog_entry_matches_query() / filter_catalog_entries() / catalog_entries_in_group(): search-as-you-type + category chip filtering over catalog entries. - format_freshness_hint(): last_release ISO date -> "Last updated N months/years ago", '' when missing/unparseable/future. - first_unfilled_focus_target(): finds the first <PLACEHOLDER> arg or blank env_required key so the GUI can focus it after Add. - load_bundled_catalog_entries(): reads+verifies+validates the bundled catalog.json/.sig pair, returns servers or an EMPTY list on ANY failure -- the load-bearing guarantee behind the dialog's empty state. - Bug fix: catalog_entry_to_paste_json() only copied config.env, ignoring env_required -- the field where 9 of the 19 seed entries (postgres, github, notion, obsidian, brave-search, tavily, home-assistant, n8n, grafana) actually declare their secret var names. Add would have silently added these servers with no env field for the user to fill in. Now env_required keys are seeded as empty-string placeholders unless config.env already sets them. bcc.py: - BrowseCatalogDialog: search box, category chips, list, detail pane (description/notes/homepage/freshness hint/verbatim command preview), per-tier action (Add for basic, Open setup docs for link-only). Every catalog-derived string goes through plain_label() (Qt.PlainText + html.escape, matching catalog_console.py's approach) or an inherently-plain QPlainTextEdit for the command preview -- catalog.json takes community PRs, so every field is attacker-influenceable. - "Browse catalog…" button next to Paste JSON. - ServerEditor.focus_target(): selects the first unfilled arg line or opens the env-table cell editor for the first blank env row. - Save-time placeholder guard: warns (Yes/No, defaults No) when any server still has an unfilled <PLACEHOLDER>; never blocks a deliberate save, never saves one unnoticed. bcc.spec: bundle data/catalog.json.sig alongside catalog.json -- the signature file was missing from datas, so a frozen build would have had a catalog.json with no matching .sig for resolve_catalog() to verify against. tests/test_core.py: +82 tests covering category-group mapping, catalog search/filter, freshness-hint formatting (including the month/year rounding boundary), first_unfilled_focus_target, the catalog_entry_to_paste_json env_required fix (incl. a regression against the real shipped postgres entry), and load_bundled_catalog_entries under valid/tampered/wrong-key/missing- file/invalid-but-signed conditions plus an end-to-end check against the real data/catalog.json + .sig (19 entries). GUI code (BrowseCatalogDialog, ServerEditor.focus_target, MainWindow.browse_catalog) is unavoidably untested here -- PySide6 cannot import in this sandbox (missing libEGL/system GL libs) -- but all the logic it depends on is pushed into bcc_core.py and covered above.
This commit is contained in:
+203
-4
@@ -29,6 +29,7 @@ import tempfile
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
from urllib.parse import urlparse
|
||||
@@ -2509,8 +2510,17 @@ def catalog_entry_to_paste_json(entry: dict) -> dict:
|
||||
"""
|
||||
Convert a basic-tier catalog entry into the {name: {command, args, env}}
|
||||
shape parse_pasted_json()/_import_server() already understand, so the
|
||||
(future) catalog picker dialog can feed a selection straight into the
|
||||
existing paste-import path instead of growing a parallel one.
|
||||
Browse-catalog dialog can feed a selection straight into the existing
|
||||
paste-import path instead of growing a parallel one.
|
||||
|
||||
`env` is seeded from two sources: config.env (rare -- e.g. grafana's
|
||||
non-secret GRAFANA_URL) and, for every key in `env_required` not already
|
||||
present, an empty-string placeholder. env_required is where the seed
|
||||
data actually keeps its secret VAR NAMES (validate_catalog requires its
|
||||
values to be "" -- never a real secret); config.env alone, without this,
|
||||
would silently drop those names on Add for the 9 of 19 seed entries that
|
||||
need a secret and only declare it via env_required -- the user would
|
||||
see a server added with no field prompting them for the key it needs.
|
||||
"""
|
||||
config = entry.get("config") or {}
|
||||
name = entry.get("id") or entry.get("display") or "server"
|
||||
@@ -2518,9 +2528,11 @@ def catalog_entry_to_paste_json(entry: dict) -> dict:
|
||||
"command": config.get("command", ""),
|
||||
"args": list(config.get("args") or []),
|
||||
}
|
||||
env = config.get("env")
|
||||
env = dict(config.get("env") or {})
|
||||
for key in entry.get("env_required") or {}:
|
||||
env.setdefault(key, "")
|
||||
if env:
|
||||
data["env"] = dict(env)
|
||||
data["env"] = env
|
||||
return {str(name): data}
|
||||
|
||||
|
||||
@@ -2540,3 +2552,190 @@ def config_has_unfilled_placeholders(cfg: dict) -> bool:
|
||||
if isinstance(env, dict):
|
||||
values.extend(v for v in env.values() if isinstance(v, str))
|
||||
return any(_PLACEHOLDER_RE.search(v) for v in values)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Catalog: Browse-dialog helpers (issue #10 phase 2)
|
||||
#
|
||||
# Everything below is pure and GUI-free on purpose (per the design comment on
|
||||
# #10): the dialog itself should be a thin shell that calls into this module,
|
||||
# the same relationship bcc.py already has with the rest of bcc_core.py.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
# Collapses the 20-value category taxonomy from the catalog research pass
|
||||
# down to the 7 chips shown in the Browse dialog. Any category not listed
|
||||
# here (including one a future catalog entry introduces that we don't yet
|
||||
# know about) falls back to "Other" rather than raising -- an unrecognized
|
||||
# category must never make an entry disappear from the dialog.
|
||||
CATALOG_CATEGORY_GROUPS: dict[str, str] = {
|
||||
"files": "Files & Dev",
|
||||
"dev": "Files & Dev",
|
||||
"code-hosting": "Files & Dev",
|
||||
"browser": "Files & Dev",
|
||||
"database": "Data",
|
||||
"data": "Data",
|
||||
"search": "Search & AI",
|
||||
"ai": "Search & AI",
|
||||
"cloud": "Cloud & Infra",
|
||||
"infra": "Cloud & Infra",
|
||||
"observability": "Cloud & Infra",
|
||||
"productivity": "Work",
|
||||
"communication": "Work",
|
||||
"crm": "Work",
|
||||
"finance": "Work",
|
||||
"design": "Work",
|
||||
"media": "Home & Personal",
|
||||
"smart-home": "Home & Personal",
|
||||
"personal": "Home & Personal",
|
||||
}
|
||||
|
||||
# Ordered for chip display: "All" first, the 6 named groups next in the order
|
||||
# given in the #10 design comment, "Other" last as the catch-all.
|
||||
CATALOG_CATEGORY_CHIPS: tuple[str, ...] = (
|
||||
"All",
|
||||
"Files & Dev",
|
||||
"Data",
|
||||
"Search & AI",
|
||||
"Cloud & Infra",
|
||||
"Work",
|
||||
"Home & Personal",
|
||||
"Other",
|
||||
)
|
||||
|
||||
|
||||
def catalog_category_group(category: str) -> str:
|
||||
"""Collapse a raw catalog `category` value to one of the 7 UI chips.
|
||||
|
||||
Unknown/missing categories map to "Other" -- never raises, never drops
|
||||
an entry from the list just because its category tag doesn't match one
|
||||
of the ones known at the time this mapping was written.
|
||||
"""
|
||||
return CATALOG_CATEGORY_GROUPS.get(str(category or "").strip().lower(), "Other")
|
||||
|
||||
|
||||
def catalog_entry_matches_query(entry: dict, query: str) -> bool:
|
||||
"""
|
||||
Case-insensitive substring match against a catalog entry's id, display
|
||||
name, description, and category. An empty/whitespace-only query matches
|
||||
everything, so the search box doubles as "no filter" when cleared --
|
||||
the same convention server_matches_filter() uses for the main table.
|
||||
"""
|
||||
q = (query or "").strip().lower()
|
||||
if not q:
|
||||
return True
|
||||
haystacks = (
|
||||
str(entry.get("id", "")),
|
||||
str(entry.get("display", "")),
|
||||
str(entry.get("description", "")),
|
||||
str(entry.get("category", "")),
|
||||
)
|
||||
return any(q in h.lower() for h in haystacks)
|
||||
|
||||
|
||||
def filter_catalog_entries(entries: list[dict], query: str) -> list[dict]:
|
||||
"""Return only the catalog entries that match `query` (see
|
||||
catalog_entry_matches_query)."""
|
||||
return [e for e in entries if catalog_entry_matches_query(e, query)]
|
||||
|
||||
|
||||
def catalog_entries_in_group(entries: list[dict], group: str) -> list[dict]:
|
||||
"""
|
||||
Return only the entries whose category collapses into `group` (one of
|
||||
CATALOG_CATEGORY_CHIPS). "All" (or a falsy/unrecognized group) returns
|
||||
every entry unfiltered -- that's the default chip state.
|
||||
"""
|
||||
if not group or group == "All":
|
||||
return list(entries)
|
||||
return [e for e in entries if catalog_category_group(e.get("category", "")) == group]
|
||||
|
||||
|
||||
def format_freshness_hint(last_release: str | None, today: date | None = None) -> str:
|
||||
"""
|
||||
Turn a catalog entry's `last_release` (an ISO "YYYY-MM-DD" date, or None
|
||||
when the registry didn't expose one) into a short freshness hint for the
|
||||
detail pane, e.g. "Last updated 14 months ago".
|
||||
|
||||
Returns "" (nothing to show) when `last_release` is missing or
|
||||
unparseable, or when it's somehow in the future relative to `today` --
|
||||
a bogus "-3 months ago" would undermine the one signal this hint exists
|
||||
to give the user, so we'd rather show nothing than something wrong.
|
||||
|
||||
`today` is an injectable override so this is exactly reproducible in
|
||||
tests without depending on the wall clock.
|
||||
"""
|
||||
if not last_release or not isinstance(last_release, str):
|
||||
return ""
|
||||
try:
|
||||
released = date.fromisoformat(last_release)
|
||||
except ValueError:
|
||||
return ""
|
||||
now = today or date.today()
|
||||
if released > now:
|
||||
return ""
|
||||
|
||||
months = (now.year - released.year) * 12 + (now.month - released.month)
|
||||
if now.day < released.day:
|
||||
months -= 1
|
||||
months = max(months, 0)
|
||||
|
||||
if months == 0:
|
||||
return "Last updated this month"
|
||||
if months == 1:
|
||||
return "Last updated 1 month ago"
|
||||
if months < 24:
|
||||
return f"Last updated {months} months ago"
|
||||
years = months // 12
|
||||
return f"Last updated {years} year{'s' if years != 1 else ''} ago"
|
||||
|
||||
|
||||
def first_unfilled_focus_target(data: dict) -> tuple[str, int | str] | None:
|
||||
"""
|
||||
Given a server config dict shaped like catalog_entry_to_paste_json()'s
|
||||
output (command/args/env), find the first thing a user must fill in
|
||||
after a catalog Add: a <PLACEHOLDER>-style arg (checked first, since a
|
||||
missing path/target usually blocks the server from starting at all) or
|
||||
else the first env var the catalog left blank.
|
||||
|
||||
Returns ("args", index) or ("env", key), or None when there's nothing
|
||||
left to fill (e.g. a server with no placeholders and no required env).
|
||||
The GUI uses this to focus+select the right field right after Add,
|
||||
instead of leaving the user to hunt for what still needs a value.
|
||||
"""
|
||||
args = data.get("args") or []
|
||||
for i, a in enumerate(args):
|
||||
if isinstance(a, str) and _PLACEHOLDER_RE.search(a):
|
||||
return ("args", i)
|
||||
|
||||
env = data.get("env") or {}
|
||||
if isinstance(env, dict):
|
||||
for k, v in env.items():
|
||||
if not isinstance(v, str) or not v.strip() or _PLACEHOLDER_RE.search(v):
|
||||
return ("env", k)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def load_bundled_catalog_entries(catalog_path: Path, sig_path: Path) -> list[dict]:
|
||||
"""
|
||||
Read+verify+validate the bundled catalog.json/.sig pair from disk and
|
||||
return its `servers` list -- or an EMPTY list if anything at all is
|
||||
wrong: files missing/unreadable, signature doesn't verify, JSON doesn't
|
||||
parse, or validate_catalog() finds a problem.
|
||||
|
||||
🔴 SECURITY: this is the load-bearing guarantee for the Browse dialog.
|
||||
There is deliberately no partial-success path here -- a signature
|
||||
failure must never surface a half-trusted list, only an empty one, so
|
||||
the GUI's only job is to render "Catalog unavailable" when this comes
|
||||
back empty. All the real trust decisions (signature, schema, command
|
||||
allowlist) already live in resolve_catalog()/validate_catalog(); this
|
||||
is a thin disk-reading wrapper around them so the GUI never touches
|
||||
catalog bytes directly.
|
||||
"""
|
||||
try:
|
||||
raw = catalog_path.read_bytes()
|
||||
sig = sig_path.read_bytes()
|
||||
except OSError:
|
||||
return []
|
||||
data = resolve_catalog(bundled=(raw, sig), cached=None, remote=None)
|
||||
servers = data.get("servers") if isinstance(data, dict) else None
|
||||
return servers if isinstance(servers, list) else []
|
||||
|
||||
Reference in New Issue
Block a user