mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-05-18 07:15:49 +02:00
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:
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user