Add enhanced search and filtering (M2 feature)

- Add /api/v1/tags endpoint for listing available tags
- Enhance search API with tag filtering (AND logic), multi-category
  filtering (OR logic), owner filter, download range, and date range
- Add faceted response support (category/tag/owner counts)
- Update registry client with new search parameters and get_tags method
- Add CLI search options: -t/--tag, -o/--owner, --min-downloads,
  --popular, --new, --since, --before, --json, --show-facets
- Add new 'registry tags' CLI subcommand
- Add web UI filter sidebar with checkboxes, dropdowns, and active
  filter chips with URL-based state management
- Display clickable tags on tool cards

Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
rob
2026-01-13 06:13:37 -04:00
co-authored by Claude Opus 4.5
parent 614b145f5f
commit 14448408af
8 changed files with 762 additions and 101 deletions
+208 -13
View File
@@ -482,14 +482,77 @@ def create_app() -> Flask:
page, per_page, sort, order, error = parse_pagination("/tools/search", "downloads")
if error:
return error
category = request.args.get("category")
offset = (page - 1) * per_page
where_clause = "WHERE tools_fts MATCH ?"
# Parse filter parameters
category = request.args.get("category") # Single category (backward compat)
categories_param = request.args.get("categories", "") # Multi-category (OR logic)
tags_param = request.args.get("tags", "") # Tags (AND logic)
owner_filter = request.args.get("owner")
min_downloads = request.args.get("min_downloads", type=int)
max_downloads = request.args.get("max_downloads", type=int)
published_after = request.args.get("published_after")
published_before = request.args.get("published_before")
include_deprecated = request.args.get("deprecated", "false").lower() == "true"
include_facets = request.args.get("include_facets", "false").lower() == "true"
# Parse multi-value params
categories = [c.strip() for c in categories_param.split(",") if c.strip()]
if category and category not in categories:
categories.append(category)
tags = [t.strip() for t in tags_param.split(",") if t.strip()]
# Build WHERE clause
where_clauses = ["tools_fts MATCH ?"]
params: List[Any] = [query_text]
if category:
where_clause += " AND tools.category = ?"
params.append(category)
if categories:
placeholders = ",".join(["?" for _ in categories])
where_clauses.append(f"tools.category IN ({placeholders})")
params.extend(categories)
if owner_filter:
where_clauses.append("tools.owner = ?")
params.append(owner_filter)
if min_downloads is not None:
where_clauses.append("tools.downloads >= ?")
params.append(min_downloads)
if max_downloads is not None:
where_clauses.append("tools.downloads <= ?")
params.append(max_downloads)
if published_after:
where_clauses.append("tools.published_at >= ?")
params.append(published_after)
if published_before:
where_clauses.append("tools.published_at <= ?")
params.append(published_before)
if not include_deprecated:
where_clauses.append("tools.deprecated = 0")
where_clause = "WHERE " + " AND ".join(where_clauses)
# Tag filtering CTE (AND logic - must have ALL specified tags)
tag_cte = ""
tag_join = ""
if tags:
tag_placeholders = ",".join(["?" for _ in tags])
tag_cte = f"""
tag_matches AS (
SELECT tools.id
FROM tools, json_each(tools.tags) AS tag
WHERE tag.value IN ({tag_placeholders})
GROUP BY tools.id
HAVING COUNT(DISTINCT tag.value) = ?
),
"""
tag_join = "JOIN tag_matches tm ON m.id = tm.id"
# Prepend tag params
params = tags + [len(tags)] + params
order_dir = "DESC" if order == "desc" else "ASC"
if sort == "relevance":
@@ -500,46 +563,57 @@ def create_app() -> Flask:
rows = query_all(
g.db,
f"""
WITH matches AS (
WITH {tag_cte}
matches AS (
SELECT tools.*, bm25(tools_fts) AS rank
FROM tools_fts
JOIN tools ON tools_fts.rowid = tools.id
{where_clause}
),
filtered AS (
SELECT m.* FROM matches m
{tag_join}
),
latest_any AS (
SELECT owner, name, MAX(id) AS max_id
FROM matches
FROM filtered
GROUP BY owner, name
),
latest_stable AS (
SELECT owner, name, MAX(id) AS max_id
FROM matches
FROM filtered
WHERE version NOT LIKE '%-%'
GROUP BY owner, name
)
SELECT m.* FROM matches m
SELECT f.* FROM filtered f
JOIN (
SELECT a.owner, a.name, COALESCE(s.max_id, a.max_id) AS max_id
FROM latest_any a
LEFT JOIN latest_stable s ON s.owner = a.owner AND s.name = a.name
) latest
ON m.owner = latest.owner AND m.name = latest.name AND m.id = latest.max_id
ON f.owner = latest.owner AND f.name = latest.name AND f.id = latest.max_id
ORDER BY {order_sql}
LIMIT ? OFFSET ?
""",
params + [per_page, offset],
)
# Count query (reuse same params without pagination)
count_row = query_one(
g.db,
f"""
WITH matches AS (
WITH {tag_cte}
matches AS (
SELECT tools.*
FROM tools_fts
JOIN tools ON tools_fts.rowid = tools.id
{where_clause}
),
filtered AS (
SELECT m.* FROM matches m
{tag_join}
)
SELECT COUNT(DISTINCT owner || '/' || name) AS total FROM matches
SELECT COUNT(DISTINCT owner || '/' || name) AS total FROM filtered
""",
params,
)
@@ -560,7 +634,90 @@ def create_app() -> Flask:
"score": score,
})
return jsonify({"data": data, "meta": paginate(page, per_page, total)})
result: dict = {"data": data, "meta": paginate(page, per_page, total)}
# Compute facets if requested
if include_facets:
# Category facets
cat_rows = query_all(
g.db,
f"""
WITH {tag_cte}
matches AS (
SELECT tools.*
FROM tools_fts
JOIN tools ON tools_fts.rowid = tools.id
{where_clause}
),
filtered AS (
SELECT m.* FROM matches m
{tag_join}
)
SELECT category, COUNT(DISTINCT owner || '/' || name) AS count
FROM filtered
WHERE category IS NOT NULL
GROUP BY category
ORDER BY count DESC
LIMIT 20
""",
params,
)
# Tag facets
tag_rows = query_all(
g.db,
f"""
WITH {tag_cte}
matches AS (
SELECT tools.*
FROM tools_fts
JOIN tools ON tools_fts.rowid = tools.id
{where_clause}
),
filtered AS (
SELECT m.* FROM matches m
{tag_join}
)
SELECT tag.value AS name, COUNT(DISTINCT filtered.owner || '/' || filtered.name) AS count
FROM filtered, json_each(filtered.tags) AS tag
GROUP BY tag.value
ORDER BY count DESC
LIMIT 30
""",
params,
)
# Owner facets
owner_rows = query_all(
g.db,
f"""
WITH {tag_cte}
matches AS (
SELECT tools.*
FROM tools_fts
JOIN tools ON tools_fts.rowid = tools.id
{where_clause}
),
filtered AS (
SELECT m.* FROM matches m
{tag_join}
)
SELECT owner, COUNT(DISTINCT owner || '/' || name) AS count
FROM filtered
GROUP BY owner
ORDER BY count DESC
LIMIT 20
""",
params,
)
result["facets"] = {
"categories": [{"name": r["category"], "count": r["count"]} for r in cat_rows],
"tags": [{"name": r["name"], "count": r["count"]} for r in tag_rows],
"owners": [{"name": r["owner"], "count": r["count"]} for r in owner_rows],
}
return jsonify(result)
@app.route("/api/v1/tools/<owner>/<name>", methods=["GET"])
def get_tool(owner: str, name: str) -> Response:
@@ -726,6 +883,44 @@ def create_app() -> Flask:
response.headers["Cache-Control"] = "max-age=3600"
return response
@app.route("/api/v1/tags", methods=["GET"])
def list_tags() -> Response:
"""List all tags with usage counts."""
category = request.args.get("category")
limit = min(int(request.args.get("limit", 100)), 500)
# Build query - extract tags from JSON array and count occurrences
if category:
rows = query_all(
g.db,
"""
SELECT tag.value AS name, COUNT(DISTINCT tools.owner || '/' || tools.name) AS count
FROM tools, json_each(tools.tags) AS tag
WHERE tools.category = ?
GROUP BY tag.value
ORDER BY count DESC
LIMIT ?
""",
(category, limit),
)
else:
rows = query_all(
g.db,
"""
SELECT tag.value AS name, COUNT(DISTINCT tools.owner || '/' || tools.name) AS count
FROM tools, json_each(tools.tags) AS tag
GROUP BY tag.value
ORDER BY count DESC
LIMIT ?
""",
(limit,),
)
data = [{"name": row["name"], "count": row["count"]} for row in rows]
response = jsonify({"data": data, "meta": {"total": len(data)}})
response.headers["Cache-Control"] = "max-age=3600"
return response
# ─── Collections ─────────────────────────────────────────────────────────────
@app.route("/api/v1/collections", methods=["GET"])
+1
View File
@@ -120,6 +120,7 @@ CREATE TABLE IF NOT EXISTS web_sessions (
);
CREATE INDEX IF NOT EXISTS idx_tools_owner_name ON tools(owner, name);
CREATE INDEX IF NOT EXISTS idx_tools_owner ON tools(owner);
CREATE INDEX IF NOT EXISTS idx_tools_category ON tools(category);
CREATE INDEX IF NOT EXISTS idx_tools_published_at ON tools(published_at DESC);
CREATE INDEX IF NOT EXISTS idx_tools_downloads ON tools(downloads DESC);