Files
StarPunk/templates/search.html
Phil Skentelbery 8f71ff36ec feat(search): Add complete Search UI with API and web interface
Implements full search functionality for StarPunk v1.1.0.

Search API Endpoint (/api/search):
- GET endpoint with query parameter (q) validation
- Pagination via limit (default 20, max 100) and offset parameters
- JSON response with results count and formatted search results
- Authentication-aware: anonymous users see published notes only
- Graceful handling of FTS5 unavailability (503 error)
- Proper error responses for missing/empty queries

Search Web Interface (/search):
- HTML search results page with Bootstrap-inspired styling
- Search form with HTML5 validation (minlength=2, maxlength=100)
- Results display with title, excerpt, date, and links
- Empty state for no results
- Error state for FTS5 unavailability
- Simple pagination (Next/Previous navigation)

Navigation Integration:
- Added search box to site navigation in base.html
- Preserves query parameter on results page
- Responsive design with emoji search icon
- Accessible with proper ARIA labels

FTS Index Population:
- Added startup check in __init__.py for empty FTS index
- Automatic rebuild from existing notes on first run
- Graceful degradation if population fails
- Logging for troubleshooting

Security Features:
- XSS prevention: HTML in search results properly escaped
- Safe highlighting: FTS5 <mark> tags preserved, user content escaped
- Query validation: empty queries rejected, length limits enforced
- SQL injection prevention via FTS5 query parser
- Authentication filtering: unpublished notes hidden from anonymous users

Testing:
- Added 41 comprehensive tests across 3 test files
- test_search_api.py: 12 tests for API endpoint validation
- test_search_integration.py: 17 tests for UI rendering and integration
- test_search_security.py: 12 tests for XSS, SQL injection, auth filtering
- All tests passing with no regressions

Implementation follows architect specifications from:
- docs/architecture/v1.1.0-validation-report.md
- docs/architecture/v1.1.0-feature-architecture.md
- docs/decisions/ADR-034-full-text-search.md

Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 10:34:00 -07:00

115 lines
4.0 KiB
HTML

{% extends "base.html" %}
{% block title %}{% if query %}Search: {{ query }}{% else %}Search{% endif %} - StarPunk{% endblock %}
{% block content %}
<div class="search-container">
<!-- Search Header -->
<div class="search-header">
<h2>Search Results</h2>
{% if query %}
<p class="note-meta">
Found {{ results|length }} result{{ 's' if results|length != 1 else '' }}
for "<strong>{{ query }}</strong>"
</p>
{% endif %}
</div>
<!-- Search Form -->
<div class="search-form-container" style="background: var(--color-bg-alt); padding: var(--spacing-md); border-radius: var(--border-radius); margin-bottom: var(--spacing-lg);">
<form action="/search" method="get" role="search">
<div class="form-group" style="margin-bottom: 0;">
<div style="display: flex; gap: var(--spacing-sm);">
<input
type="search"
name="q"
placeholder="Enter search terms..."
value="{{ query }}"
minlength="2"
maxlength="100"
required
autofocus
style="flex: 1;"
>
<button type="submit" class="button button-primary">Search</button>
</div>
</div>
</form>
</div>
<!-- Results -->
{% if query %}
{% if error %}
<!-- Error state (if search unavailable) -->
<div class="flash flash-warning" role="alert">
<h3 style="margin-bottom: var(--spacing-sm);">Search Unavailable</h3>
<p>{{ error }}</p>
<p style="margin-bottom: 0; margin-top: var(--spacing-sm);">Full-text search is temporarily unavailable. Please try again later.</p>
</div>
{% elif results %}
<div class="search-results">
{% for result in results %}
<article class="search-result" style="margin-bottom: var(--spacing-lg); padding-bottom: var(--spacing-lg); border-bottom: 1px solid var(--color-border);">
<h3 style="margin-bottom: var(--spacing-sm);">
<a href="{{ result.url }}">{{ result.title }}</a>
</h3>
<div class="search-excerpt" style="margin-bottom: var(--spacing-sm);">
<!-- Excerpt with highlighted terms (safe because we control the <mark> tags) -->
<p style="margin-bottom: 0;">{{ result.excerpt|safe }}</p>
</div>
<div class="note-meta">
<time datetime="{{ result.published_at }}">
{{ result.published_at[:10] }}
</time>
</div>
</article>
{% endfor %}
</div>
<!-- Pagination (if more than limit results possible) -->
{% if results|length == limit %}
<nav aria-label="Search pagination" style="margin-top: var(--spacing-lg);">
<div style="display: flex; gap: var(--spacing-md); justify-content: center;">
{% if offset > 0 %}
<a class="button button-secondary" href="/search?q={{ query|urlencode }}&offset={{ [0, offset - limit]|max }}">
Previous
</a>
{% endif %}
<a class="button button-secondary" href="/search?q={{ query|urlencode }}&offset={{ offset + limit }}">
Next
</a>
</div>
</nav>
{% endif %}
{% else %}
<!-- No results -->
<div class="flash flash-info" role="alert">
<h3 style="margin-bottom: var(--spacing-sm);">No results found</h3>
<p>Your search for "<strong>{{ query }}</strong>" didn't match any notes.</p>
<p style="margin-bottom: 0; margin-top: var(--spacing-sm);">Try different keywords or check your spelling.</p>
</div>
{% endif %}
{% else %}
<!-- No query yet -->
<div class="empty-state">
<p style="font-size: 3rem; margin-bottom: var(--spacing-md);">🔍</p>
<p>Enter search terms above to find notes</p>
</div>
{% endif %}
</div>
<style>
/* Search-specific styles */
mark {
background-color: #ffeb3b;
padding: 0.125rem 0.25rem;
border-radius: 2px;
font-weight: 600;
}
.search-result:last-child {
border-bottom: none;
}
</style>
{% endblock %}