feat(search): add global message search with FTS5 backend

- New GET /api/messages/search endpoint using existing FTS5 indexes
- Search modal accessible from navbar search icon
- Debounced search (300ms) across all channel and DM messages
- Results show source (channel/DM), sender, timestamp with highlights
- Click result navigates to the relevant channel or DM conversation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-03-12 07:20:04 +01:00
parent a501da914a
commit d6e2a3472a
4 changed files with 254 additions and 0 deletions
+71
View File
@@ -1739,6 +1739,77 @@ def get_messages_updates():
}), 500
# =============================================================================
# Message Search
# =============================================================================
@api_bp.route('/messages/search', methods=['GET'])
def search_messages():
"""
Full-text search across all channel and direct messages (FTS5).
Query params:
q (str): Search query (required)
limit (int): Max results (default: 50)
Returns:
JSON with search results sorted by timestamp descending.
"""
query = request.args.get('q', '').strip()
if not query:
return jsonify({'success': False, 'error': 'Missing search query'}), 400
limit = request.args.get('limit', 50, type=int)
limit = min(limit, 200)
try:
dm = current_app.config.get('DEVICE_MANAGER')
db = dm.db if dm else None
if not db:
return jsonify({'success': False, 'error': 'Database not available'}), 503
# FTS5 query — wrap in quotes for phrase search if contains spaces
# and add * for prefix matching
fts_query = query
results = db.search_messages(fts_query, limit=limit)
# Enrich results with channel names and contact info
success, channels_list = get_channels_cached()
channel_names = {ch['index']: ch['name'] for ch in channels_list} if success else {}
enriched = []
for r in results:
item = {
'id': r.get('id'),
'content': r.get('content', ''),
'timestamp': r.get('timestamp', 0),
'source': r.get('msg_source', 'channel'),
}
if r.get('msg_source') == 'channel':
item['sender'] = r.get('sender', '')
item['channel_idx'] = r.get('channel_idx')
item['channel_name'] = channel_names.get(r.get('channel_idx'), f"Channel {r.get('channel_idx')}")
item['is_own'] = bool(r.get('is_own'))
else:
item['contact_pubkey'] = r.get('contact_pubkey', '')
item['direction'] = r.get('direction', '')
# Look up contact name
contact = db.get_contact(r.get('contact_pubkey', '')) if r.get('contact_pubkey') else None
item['contact_name'] = contact.get('name', r.get('contact_pubkey', '')[:12]) if contact else r.get('contact_pubkey', '')[:12]
enriched.append(item)
return jsonify({
'success': True,
'results': enriched,
'count': len(enriched),
'query': query
}), 200
except Exception as e:
logger.error(f"Search error: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
# =============================================================================
# Direct Messages (DM) Endpoints
# =============================================================================
+23
View File
@@ -1402,3 +1402,26 @@ main {
font-size: 0.9rem;
}
}
/* =============================================================================
Global Search
============================================================================= */
#searchResults {
max-height: 60vh;
overflow-y: auto;
}
#searchResults .list-group-item {
border-left: 3px solid transparent;
}
#searchResults .list-group-item:hover {
border-left-color: var(--bs-primary);
}
#searchResults mark {
background-color: #fff3cd;
padding: 0 2px;
border-radius: 2px;
}
+131
View File
@@ -3625,3 +3625,134 @@ function clearFilterState() {
}
}
// =============================================================================
// Global Message Search (FTS5)
// =============================================================================
let searchDebounceTimer = null;
function initializeSearch() {
const input = document.getElementById('searchInput');
const btn = document.getElementById('searchBtn');
if (!input || !btn) return;
// Search on Enter or button click
btn.addEventListener('click', () => performSearch(input.value));
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') performSearch(input.value);
});
// Debounced search as user types (300ms)
input.addEventListener('input', () => {
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(() => {
if (input.value.trim().length >= 2) {
performSearch(input.value);
}
}, 300);
});
// Focus input when modal opens
document.getElementById('searchModal')?.addEventListener('shown.bs.modal', () => {
input.focus();
});
}
async function performSearch(query) {
query = query.trim();
const container = document.getElementById('searchResults');
if (!container) return;
if (query.length < 2) {
container.innerHTML = '<div class="text-center text-muted py-4"><p>Type at least 2 characters to search</p></div>';
return;
}
container.innerHTML = '<div class="text-center py-4"><div class="spinner-border spinner-border-sm"></div> Searching...</div>';
try {
const response = await fetch(`/api/messages/search?q=${encodeURIComponent(query)}&limit=50`);
const data = await response.json();
if (!data.success) {
container.innerHTML = `<div class="alert alert-danger">${escapeHtml(data.error)}</div>`;
return;
}
if (data.results.length === 0) {
container.innerHTML = `<div class="text-center text-muted py-4"><i class="bi bi-inbox" style="font-size: 2rem;"></i><p class="mt-2">No results for "${escapeHtml(query)}"</p></div>`;
return;
}
container.innerHTML = `<div class="text-muted small mb-2">${data.count} result${data.count !== 1 ? 's' : ''}</div>`;
const list = document.createElement('div');
list.className = 'list-group';
data.results.forEach(r => {
const item = document.createElement('a');
item.className = 'list-group-item list-group-item-action';
item.style.cursor = 'pointer';
const time = formatTime(r.timestamp);
const snippet = highlightSearchTerm(escapeHtml(r.content), query);
if (r.source === 'channel') {
item.innerHTML = `
<div class="d-flex justify-content-between align-items-start">
<div>
<span class="badge bg-primary me-1">#${escapeHtml(r.channel_name || '')}</span>
<strong class="small">${r.is_own ? 'You' : escapeHtml(r.sender || '')}</strong>
</div>
<small class="text-muted">${time}</small>
</div>
<div class="small mt-1">${snippet}</div>
`;
item.addEventListener('click', () => {
// Navigate to channel
const selector = document.getElementById('channelSelector');
if (selector) {
selector.value = r.channel_idx;
selector.dispatchEvent(new Event('change'));
}
bootstrap.Modal.getInstance(document.getElementById('searchModal'))?.hide();
});
} else {
item.innerHTML = `
<div class="d-flex justify-content-between align-items-start">
<div>
<span class="badge bg-success me-1">DM</span>
<strong class="small">${escapeHtml(r.contact_name || '')}</strong>
<span class="text-muted small">${r.direction === 'out' ? '(sent)' : '(received)'}</span>
</div>
<small class="text-muted">${time}</small>
</div>
<div class="small mt-1">${snippet}</div>
`;
item.addEventListener('click', () => {
// Navigate to DM conversation
window.location.href = `/dm?conversation=${encodeURIComponent(r.contact_pubkey)}`;
});
}
list.appendChild(item);
});
container.appendChild(list);
} catch (error) {
console.error('Search error:', error);
container.innerHTML = '<div class="alert alert-danger">Search failed. Please try again.</div>';
}
}
function highlightSearchTerm(html, query) {
if (!query) return html;
const normalizedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`(${normalizedQuery})`, 'gi');
return html.replace(regex, '<mark>$1</mark>');
}
// Initialize search when DOM is ready
document.addEventListener('DOMContentLoaded', initializeSearch);
+29
View File
@@ -38,6 +38,9 @@
{% endif %}
</span>
<div class="d-flex align-items-center gap-2">
<button id="globalSearchBtn" class="btn btn-outline-light btn-sm" data-bs-toggle="modal" data-bs-target="#searchModal" title="Search all messages">
<i class="bi bi-search"></i>
</button>
<div id="notificationBell" class="btn btn-outline-light btn-sm position-relative" style="cursor: pointer;" onclick="markAllChannelsRead()" title="Mark all as read">
<i class="bi bi-bell"></i>
</div>
@@ -352,6 +355,32 @@
</div>
</div>
<!-- Search Modal -->
<div class="modal fade" id="searchModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-search"></i> Search Messages</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="input-group mb-3">
<input type="text" class="form-control" id="searchInput" placeholder="Search all messages..." autofocus>
<button class="btn btn-primary" type="button" id="searchBtn">
<i class="bi bi-search"></i>
</button>
</div>
<div id="searchResults">
<div class="text-center text-muted py-4">
<i class="bi bi-search" style="font-size: 2rem;"></i>
<p class="mt-2 mb-0">Search across all channel and direct messages</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Toast container for notifications -->
<div class="toast-container position-fixed top-0 start-0 p-3">
<div id="notificationToast" class="toast" role="alert">