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:
+208
-13
@@ -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"])
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user