diff --git a/web/public/assets/js/app/__tests__/chat-search.test.js b/web/public/assets/js/app/__tests__/chat-search.test.js new file mode 100644 index 0000000..6da443a --- /dev/null +++ b/web/public/assets/js/app/__tests__/chat-search.test.js @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2025 l5yth + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { CHAT_LOG_ENTRY_TYPES } from '../chat-log-tabs.js'; +import { + chatLogEntryMatchesQuery, + chatMessageMatchesQuery, + filterChatModel, + normaliseChatFilterQuery +} from '../chat-search.js'; + +test('normaliseChatFilterQuery lower-cases and trims user input', () => { + assert.equal(normaliseChatFilterQuery(' MIXED Case '), 'mixed case'); + assert.equal(normaliseChatFilterQuery(null), ''); +}); + +test('chatMessageMatchesQuery inspects text and node metadata', () => { + const message = { text: 'Hello Mesh', node: { short_name: 'ALFA', long_name: 'Alpha Node' } }; + const helloQuery = normaliseChatFilterQuery('mesh'); + assert.equal(chatMessageMatchesQuery(message, helloQuery), true); + const aliasQuery = normaliseChatFilterQuery('alfa'); + assert.equal(chatMessageMatchesQuery(message, aliasQuery), true); + const missQuery = normaliseChatFilterQuery('bravo'); + assert.equal(chatMessageMatchesQuery(message, missQuery), false); +}); + +test('chatLogEntryMatchesQuery recognises position highlight values', () => { + const entry = { + type: CHAT_LOG_ENTRY_TYPES.POSITION, + ts: 1, + position: { latitude: 51.5, longitude: 0 }, + node: { node_id: '!alpha', short_name: 'Alpha' } + }; + const query = normaliseChatFilterQuery('51.50000'); + assert.equal(chatLogEntryMatchesQuery(entry, query), true); + const missQuery = normaliseChatFilterQuery('bravo'); + assert.equal(chatLogEntryMatchesQuery(entry, missQuery), false); +}); + +test('chatLogEntryMatchesQuery uses enriched node context for lookups', () => { + const entry = { + type: CHAT_LOG_ENTRY_TYPES.TELEMETRY, + nodeId: '!alpha', + telemetry: { voltage: 12.1 }, + node: { short_name: 'ALFA', long_name: 'Alpha Node' } + }; + const query = normaliseChatFilterQuery('alpha node'); + assert.equal(chatLogEntryMatchesQuery(entry, query), true); +}); + +test('chatLogEntryMatchesQuery inspects neighbor node context', () => { + const entry = { + type: CHAT_LOG_ENTRY_TYPES.NEIGHBOR, + neighborId: '!bravo', + neighborNode: { short_name: 'BRAV', long_name: 'Bravo Station' } + }; + const query = normaliseChatFilterQuery('bravo station'); + assert.equal(chatLogEntryMatchesQuery(entry, query), true); +}); + +test('filterChatModel filters both log entries and channel messages', () => { + const model = { + logEntries: [ + { type: CHAT_LOG_ENTRY_TYPES.NODE_INFO, nodeId: '!alpha', node: { short_name: 'Alpha' } }, + { type: CHAT_LOG_ENTRY_TYPES.NODE_INFO, nodeId: '!bravo', node: { short_name: 'Bravo' } } + ], + channels: [ + { + index: 0, + label: '0', + entries: [ + { ts: 1, message: { text: 'Ping Alpha', node: { short_name: 'Alpha' } } }, + { ts: 2, message: { text: 'Ack Bravo', node: { short_name: 'Bravo' } } } + ] + } + ] + }; + const result = filterChatModel(model, 'bravo'); + assert.equal(result.logEntries.length, 1); + assert.equal(result.logEntries[0].nodeId, '!bravo'); + assert.equal(result.channels.length, 1); + assert.deepEqual(result.channels[0].entries.map(entry => entry.message.text), ['Ack Bravo']); +}); + +test('filterChatModel returns original references when query is empty', () => { + const model = { + logEntries: [{ type: CHAT_LOG_ENTRY_TYPES.NODE_INFO, nodeId: '!alpha', node: { short_name: 'Alpha' } }], + channels: [{ index: 0, label: '0', entries: [] }] + }; + const result = filterChatModel(model, ' '); + assert.strictEqual(result.logEntries, model.logEntries); + assert.strictEqual(result.channels, model.channels); +}); diff --git a/web/public/assets/js/app/chat-search.js b/web/public/assets/js/app/chat-search.js new file mode 100644 index 0000000..ef4b9e8 --- /dev/null +++ b/web/public/assets/js/app/chat-search.js @@ -0,0 +1,220 @@ +/* + * Copyright (C) 2025 l5yth + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CHAT_LOG_ENTRY_TYPES } from './chat-log-tabs.js'; +import { formatPositionHighlights, formatTelemetryHighlights } from './chat-log-highlights.js'; + +const BASE_SEARCH_KEYS = Object.freeze([ + 'node_id', + 'nodeId', + 'id', + 'node_num', + 'nodeNum', + 'num', + 'short_name', + 'shortName', + 'long_name', + 'longName', + 'name', + 'role', + 'hw_model', + 'hwModel', + 'neighbor_id', + 'neighborId' +]); + +const MESSAGE_EXTRA_KEYS = Object.freeze([ + 'text', + 'emoji', + 'channel', + 'channel_index', + 'channelIndex', + 'channel_name', + 'channelName', + 'channel_display', + 'channelDisplay', + 'from_id', + 'fromId', + 'to_id', + 'toId', + 'reply_id', + 'replyId' +]); + +/** + * Normalise arbitrary input into a comparable, lower-cased string. + * + * @param {*} value User-supplied input. + * @returns {string} Trimmed, lower-cased query string. + */ +export function normaliseChatFilterQuery(value) { + if (value == null) { + return ''; + } + const text = String(value).trim().toLowerCase(); + return text; +} + +/** + * Apply chat filtering to log entries and channel buckets. + * + * @param {{ logEntries?: Array, channels?: Array }} model Chat tab model. + * @param {*} query Filter query supplied by the user. + * @returns {{ logEntries: Array, channels: Array }} Filtered model. + */ +export function filterChatModel(model = {}, query) { + const logEntries = Array.isArray(model.logEntries) ? model.logEntries : []; + const channels = Array.isArray(model.channels) ? model.channels : []; + const normalisedQuery = normaliseChatFilterQuery(query); + if (!normalisedQuery) { + return { logEntries, channels }; + } + const filteredLogs = logEntries.filter(entry => chatLogEntryMatchesQuery(entry, normalisedQuery)); + const filteredChannels = channels.map(channel => ({ + ...channel, + entries: Array.isArray(channel.entries) + ? channel.entries.filter(item => chatMessageMatchesQuery(item?.message, normalisedQuery)) + : [] + })); + return { logEntries: filteredLogs, channels: filteredChannels }; +} + +/** + * Determine whether a structured chat log entry matches the query. + * + * @param {?Object} entry Chat log entry. + * @param {string} query Normalised filter query. + * @returns {boolean} True when the entry should remain visible. + */ +export function chatLogEntryMatchesQuery(entry, query) { + if (!query) return true; + if (!entry || typeof entry !== 'object') { + return false; + } + const candidates = []; + candidates.push(...collectSearchValues(entry.node)); + candidates.push(...collectSearchValues(entry.telemetry)); + candidates.push(...collectSearchValues(entry.position)); + candidates.push(...collectSearchValues(entry.neighbor)); + candidates.push(...collectSearchValues(entry.neighborNode)); + if (entry.nodeId) candidates.push(entry.nodeId); + if (entry.nodeNum != null && entry.nodeNum !== '') candidates.push(entry.nodeNum); + if (entry.neighborId) candidates.push(entry.neighborId); + if (entry.type) candidates.push(entry.type); + + if (entry.type === CHAT_LOG_ENTRY_TYPES.MESSAGE_ENCRYPTED) { + if (entry.message && chatMessageMatchesQuery(entry.message, query)) { + return true; + } + } else if (entry.type === CHAT_LOG_ENTRY_TYPES.TELEMETRY) { + const telemetryHighlights = formatTelemetryHighlights(entry.telemetry || {}); + candidates.push(...highlightsToStrings(telemetryHighlights)); + } else if (entry.type === CHAT_LOG_ENTRY_TYPES.POSITION) { + const positionHighlights = formatPositionHighlights(entry.position || {}); + candidates.push(...highlightsToStrings(positionHighlights)); + } else if (entry.type === CHAT_LOG_ENTRY_TYPES.NEIGHBOR) { + if (entry.neighbor && entry.neighbor.neighbor_id) { + candidates.push(entry.neighbor.neighbor_id); + } + } + + return candidates.some(value => valueIncludesQuery(value, query)); +} + +/** + * Determine whether a mesh message matches the active query. + * + * @param {?Object} message Chat message payload. + * @param {string} query Normalised filter query. + * @returns {boolean} True when the message should be shown. + */ +export function chatMessageMatchesQuery(message, query) { + if (!query) return true; + if (!message || typeof message !== 'object') { + return false; + } + const candidates = [ + ...collectSearchValues(message, MESSAGE_EXTRA_KEYS), + ...collectSearchValues(message.node), + ]; + return candidates.some(value => valueIncludesQuery(value, query)); +} + +function highlightsToStrings(highlights) { + if (!Array.isArray(highlights)) { + return []; + } + return highlights + .map(entry => { + if (!entry || typeof entry !== 'object') { + return null; + } + const label = entry.label != null ? String(entry.label).trim() : ''; + const value = entry.value != null ? String(entry.value).trim() : ''; + return `${label} ${value}`.trim(); + }) + .filter(Boolean); +} + +function collectSearchValues(source, extraKeys = []) { + if (!source || typeof source !== 'object') { + return []; + } + const values = []; + const keys = extraKeys.length ? [...BASE_SEARCH_KEYS, ...extraKeys] : BASE_SEARCH_KEYS; + for (const key of keys) { + if (!Object.prototype.hasOwnProperty.call(source, key)) { + continue; + } + const value = source[key]; + if (value == null || value === '') { + continue; + } + if (typeof value === 'object') { + continue; + } + values.push(value); + } + return values; +} + +function valueIncludesQuery(value, query) { + if (!query) return true; + if (value == null) { + return false; + } + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + return false; + } + return String(value).toLowerCase().includes(query); + } + if (typeof value === 'boolean') { + return (value ? 'true' : 'false').includes(query); + } + const text = String(value).trim(); + if (!text) { + return false; + } + return text.toLowerCase().includes(query); +} + +export default { + normaliseChatFilterQuery, + filterChatModel, + chatLogEntryMatchesQuery, + chatMessageMatchesQuery +}; diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js index e388c51..8f6319f 100644 --- a/web/public/assets/js/app/main.js +++ b/web/public/assets/js/app/main.js @@ -40,6 +40,7 @@ import { initializeInstanceSelector } from './instance-selector.js'; import { CHAT_LOG_ENTRY_TYPES, buildChatTabModel, MAX_CHANNEL_INDEX } from './chat-log-tabs.js'; import { renderChatTabs } from './chat-tabs.js'; import { formatPositionHighlights, formatTelemetryHighlights } from './chat-log-highlights.js'; +import { filterChatModel, normaliseChatFilterQuery } from './chat-search.js'; import { buildMessageBody, buildMessageIndex, resolveReplyPrefix } from './message-replies.js'; /** @@ -124,6 +125,12 @@ export function initializeApp(config) { let allNodes = []; /** @type {Array} */ let allNeighbors = []; + /** @type {Array} */ + let allMessages = []; + /** @type {Array} */ + let allTelemetryEntries = []; + /** @type {Array} */ + let allPositionEntries = []; /** @type {Map} */ let nodesById = new Map(); let messagesById = new Map(); @@ -2617,11 +2624,85 @@ let messagesById = new Map(); return div; } + /** + * Attach node context to chat log entries when identifier metadata exists. + * + * @param {Array} entries Chat log entries. + * @returns {Array} Enriched entries. + */ + function attachNodeContextToLogEntries(entries) { + if (!Array.isArray(entries) || entries.length === 0) { + return Array.isArray(entries) ? entries : []; + } + return entries.map(entry => { + if (!entry || typeof entry !== 'object') { + return entry; + } + const hasNode = entry.node && typeof entry.node === 'object'; + const hasNeighborNode = entry.neighborNode && typeof entry.neighborNode === 'object'; + const resolvedNode = hasNode ? entry.node : resolveNodeForLogEntryContext(entry); + const resolvedNeighbor = hasNeighborNode ? entry.neighborNode : resolveNeighborForLogEntry(entry); + if (resolvedNode === entry.node && resolvedNeighbor === entry.neighborNode) { + return entry; + } + const enriched = { ...entry }; + if (resolvedNode && !hasNode) { + enriched.node = resolvedNode; + } + if (resolvedNeighbor && !hasNeighborNode) { + enriched.neighborNode = resolvedNeighbor; + } + return enriched; + }); + } + + /** + * Locate the canonical node associated with a chat log entry for filtering. + * + * @param {Object} entry Chat log entry. + * @returns {?Object} Node payload when available. + */ + function resolveNodeForLogEntryContext(entry) { + if (!entry || typeof entry !== 'object') { + return null; + } + if (nodesById instanceof Map && typeof entry.nodeId === 'string' && nodesById.has(entry.nodeId)) { + return nodesById.get(entry.nodeId); + } + if (nodesByNum instanceof Map && Number.isFinite(entry.nodeNum) && nodesByNum.has(entry.nodeNum)) { + return nodesByNum.get(entry.nodeNum); + } + return null; + } + + /** + * Locate the neighbor node metadata for a chat log entry when available. + * + * @param {Object} entry Chat log entry. + * @returns {?Object} Neighbor node payload when available. + */ + function resolveNeighborForLogEntry(entry) { + if (!entry || typeof entry !== 'object' || !(nodesById instanceof Map)) { + return null; + } + const neighborId = typeof entry.neighborId === 'string' ? entry.neighborId : null; + if (neighborId && nodesById.has(neighborId)) { + return nodesById.get(neighborId); + } + return null; + } + /** * Render the chat history panel with nodes and messages. * - * @param {Array} nodes Collection of node payloads. - * @param {Array} messages Collection of message payloads. + * @param {{ + * nodes?: Array, + * messages?: Array, + * telemetryEntries?: Array, + * positionEntries?: Array, + * neighborEntries?: Array, + * filterQuery?: string + * }} params Render inputs. * @returns {void} */ function renderChatLog({ @@ -2629,7 +2710,8 @@ let messagesById = new Map(); messages = [], telemetryEntries = [], positionEntries = [], - neighborEntries = [] + neighborEntries = [], + filterQuery = '' }) { if (!CHAT_ENABLED || !chatEl) return; messagesById = buildMessageIndex(messages); @@ -2645,13 +2727,19 @@ let messagesById = new Map(); maxChannelIndex: MAX_CHANNEL_INDEX }); + const enrichedLogEntries = attachNodeContextToLogEntries(logEntries); + const { logEntries: filteredLogEntries, channels: filteredChannels } = filterChatModel( + { logEntries: enrichedLogEntries, channels }, + filterQuery + ); + const logContent = buildChatFragment({ - entries: logEntries, + entries: filteredLogEntries, renderEntry: createChatLogEntry, emptyLabel: 'No recent mesh activity.' }); - const channelTabs = channels.map(channel => ({ + const channelTabs = filteredChannels.map(channel => ({ id: `channel-${channel.index}`, label: channel.label, content: buildChatFragment({ @@ -3472,8 +3560,8 @@ let messagesById = new Map(); */ function applyFilter() { updateFilterClearVisibility(); - const rawQuery = filterInput ? filterInput.value : ''; - const q = rawQuery.trim().toLowerCase(); + const filterQuery = filterInput ? filterInput.value : ''; + const q = normaliseChatFilterQuery(filterQuery); const filteredNodes = allNodes.filter(n => matchesTextFilter(n, q) && matchesRoleFilter(n)); const sortedNodes = sortNodes(filteredNodes); const nowSec = Date.now()/1000; @@ -3482,6 +3570,14 @@ let messagesById = new Map(); updateCount(sortedNodes, nowSec); updateRefreshInfo(sortedNodes, nowSec); updateSortIndicators(); + renderChatLog({ + nodes: allNodes, + messages: allMessages, + telemetryEntries: allTelemetryEntries, + positionEntries: allPositionEntries, + neighborEntries: allNeighbors, + filterQuery + }); } if (filterInput) { @@ -3540,13 +3636,9 @@ let messagesById = new Map(); allNodes = nodes; rebuildNodeIndex(allNodes); const chatMessages = await messageNodeHydrator.hydrate(messages, nodesById); - renderChatLog({ - nodes, - messages: chatMessages, - telemetryEntries, - positionEntries: positions, - neighborEntries: neighborTuples - }); + allMessages = Array.isArray(chatMessages) ? chatMessages : []; + allTelemetryEntries = Array.isArray(telemetryEntries) ? telemetryEntries : []; + allPositionEntries = Array.isArray(positions) ? positions : []; allNeighbors = Array.isArray(neighborTuples) ? neighborTuples : []; applyFilter(); statusEl.textContent = 'updated ' + new Date().toLocaleTimeString();