Files
StarPunk/starpunk/routes/search.py
Phil Skentelbery 0acefa4670 docs: Update Phase 0 with specific test fix requirements
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>
2025-12-16 20:45:41 -07:00

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("&lt;mark&gt;", "<mark>").replace("&lt;/mark&gt;", "</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,
)