""" 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 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: 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 tags but doesn't escape HTML # We need to escape it but preserve the tags from markupsafe import escape, Markup formatted_results = [] for r in results: # Escape the snippet but allow 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>", "").replace("</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, )