Per ADR-012, Phase 0 now specifies: - 5 tests to REMOVE (broken multiprocessing) - 4 tests to FIX (brittle assertions) - 1 test to RENAME (misleading name) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
194 lines
5.6 KiB
Python
194 lines
5.6 KiB
Python
"""
|
|
Search routes for StarPunk
|
|
|
|
Provides both API and HTML endpoints for full-text search functionality.
|
|
"""
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
from flask import Blueprint, current_app, g, jsonify, render_template, request
|
|
|
|
from starpunk.search import has_fts_table, search_notes
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
bp = Blueprint("search", __name__)
|
|
|
|
|
|
@bp.route("/api/search", methods=["GET"])
|
|
def api_search():
|
|
"""
|
|
Search API endpoint
|
|
|
|
Query Parameters:
|
|
q (required): Search query string
|
|
limit (optional): Results limit, default 20, max 100
|
|
offset (optional): Pagination offset, default 0
|
|
|
|
Returns:
|
|
JSON response with search results
|
|
|
|
Status Codes:
|
|
200: Success (even with 0 results)
|
|
400: Bad request (empty query)
|
|
503: Service unavailable (FTS5 not available)
|
|
"""
|
|
# Extract and validate query parameter
|
|
query = request.args.get("q", "").strip()
|
|
if not query:
|
|
return (
|
|
jsonify(
|
|
{
|
|
"error": "Missing required parameter: q",
|
|
"message": "Search query cannot be empty",
|
|
}
|
|
),
|
|
400,
|
|
)
|
|
|
|
# Parse limit with bounds checking
|
|
try:
|
|
limit = min(int(request.args.get("limit", 20)), 100)
|
|
if limit < 1:
|
|
limit = 20
|
|
except ValueError:
|
|
limit = 20
|
|
|
|
# Parse offset
|
|
try:
|
|
offset = max(int(request.args.get("offset", 0)), 0)
|
|
except ValueError:
|
|
offset = 0
|
|
|
|
# Check if user is authenticated (for unpublished notes)
|
|
# Anonymous users (g.me not set) see only published notes
|
|
published_only = not hasattr(g, "me") or g.me is None
|
|
|
|
db_path = Path(current_app.config["DATABASE_PATH"])
|
|
|
|
# Check FTS availability
|
|
if not has_fts_table(db_path):
|
|
return (
|
|
jsonify(
|
|
{
|
|
"error": "Search unavailable",
|
|
"message": "Full-text search is not configured on this server",
|
|
}
|
|
),
|
|
503,
|
|
)
|
|
|
|
try:
|
|
results = search_notes(
|
|
query=query,
|
|
db_path=db_path,
|
|
published_only=published_only,
|
|
limit=limit,
|
|
offset=offset,
|
|
)
|
|
except Exception as e:
|
|
current_app.logger.error(f"Search failed: {e}")
|
|
return (
|
|
jsonify(
|
|
{"error": "Search failed", "message": "An error occurred during search"}
|
|
),
|
|
500,
|
|
)
|
|
|
|
# Format response
|
|
response = {
|
|
"query": query,
|
|
"count": len(results),
|
|
"limit": limit,
|
|
"offset": offset,
|
|
"results": [
|
|
{
|
|
"slug": r["slug"],
|
|
"title": r["title"] or f"Note from {r['created_at'][:10]}",
|
|
"excerpt": r["snippet"], # Already has <mark> tags
|
|
"published_at": r["created_at"],
|
|
"url": f"/notes/{r['slug']}",
|
|
}
|
|
for r in results
|
|
],
|
|
}
|
|
|
|
return jsonify(response), 200
|
|
|
|
|
|
@bp.route("/search")
|
|
def search_page():
|
|
"""
|
|
Search results HTML page
|
|
|
|
Query Parameters:
|
|
q: Search query string
|
|
offset: Pagination offset
|
|
"""
|
|
query = request.args.get("q", "").strip()
|
|
limit = 20 # Fixed for HTML view
|
|
|
|
# Parse offset
|
|
try:
|
|
offset = max(int(request.args.get("offset", 0)), 0)
|
|
except ValueError:
|
|
offset = 0
|
|
|
|
# Check authentication for unpublished notes
|
|
# Anonymous users (g.me not set) see only published notes
|
|
published_only = not hasattr(g, "me") or g.me is None
|
|
|
|
results = []
|
|
error = None
|
|
|
|
if query:
|
|
db_path = Path(current_app.config["DATABASE_PATH"])
|
|
|
|
if not has_fts_table(db_path):
|
|
error = "Full-text search is not configured on this server"
|
|
else:
|
|
try:
|
|
raw_results = search_notes(
|
|
query=query,
|
|
db_path=db_path,
|
|
published_only=published_only,
|
|
limit=limit,
|
|
offset=offset,
|
|
)
|
|
# Format results for template
|
|
# Format results and escape HTML in excerpts for safety
|
|
# FTS5 snippet() returns content with <mark> tags but doesn't escape HTML
|
|
# We need to escape it but preserve the <mark> tags
|
|
from markupsafe import escape, Markup
|
|
|
|
formatted_results = []
|
|
for r in raw_results:
|
|
# Escape the snippet but allow <mark> tags
|
|
snippet = r["snippet"]
|
|
# Simple approach: escape all HTML, then unescape our mark tags
|
|
escaped = escape(snippet)
|
|
# Replace escaped mark tags with real ones
|
|
safe_snippet = str(escaped).replace("<mark>", "<mark>").replace("</mark>", "</mark>")
|
|
|
|
formatted_results.append({
|
|
"slug": r["slug"],
|
|
"title": r["title"] or f"Note from {r['created_at'][:10]}",
|
|
"excerpt": Markup(safe_snippet), # Mark as safe since we've escaped it ourselves
|
|
"published_at": r["created_at"],
|
|
"url": f"/notes/{r['slug']}",
|
|
})
|
|
results = formatted_results
|
|
except Exception as e:
|
|
current_app.logger.error(f"Search failed: {e}")
|
|
error = "An error occurred during search"
|
|
|
|
return render_template(
|
|
"search.html",
|
|
query=query,
|
|
results=results,
|
|
error=error,
|
|
limit=limit,
|
|
offset=offset,
|
|
)
|