From be469637449a0f38c78a3c104b2beedea1585451 Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Tue, 18 Nov 2025 18:33:02 +0100 Subject: [PATCH] merge secondary channels by name (#468) * merge secondary channels by name * cover missing unit test vectors --- .../js/app/__tests__/chat-log-tabs.test.js | 79 ++++++++++++++++++- web/public/assets/js/app/chat-log-tabs.js | 65 +++++++++++++-- 2 files changed, 138 insertions(+), 6 deletions(-) diff --git a/web/public/assets/js/app/__tests__/chat-log-tabs.test.js b/web/public/assets/js/app/__tests__/chat-log-tabs.test.js index 0896bef..b41b3e0 100644 --- a/web/public/assets/js/app/__tests__/chat-log-tabs.test.js +++ b/web/public/assets/js/app/__tests__/chat-log-tabs.test.js @@ -108,7 +108,7 @@ test('buildChatTabModel returns sorted nodes and channel buckets', () => { const secondaryChannel = channelByLabel.BerlinMesh; assert.equal(secondaryChannel.index, 1); - assert.equal(secondaryChannel.id, 'channel-1'); + assert.equal(secondaryChannel.id, 'channel-secondary-berlinmesh'); assert.equal(secondaryChannel.entries.length, 2); assert.deepEqual(secondaryChannel.entries.map(entry => entry.message.id), ['iso-ts', 'recent-alt']); }); @@ -249,3 +249,80 @@ test('buildChatTabModel ignores plaintext log-only entries', () => { assert.equal(encryptedEntries.length, 1); assert.equal(encryptedEntries[0]?.message?.id, 'enc'); }); + +test('buildChatTabModel merges secondary channels with matching labels regardless of index', () => { + const primaryId = 'primary'; + const secondaryFirstId = 'secondary-one'; + const secondarySecondId = 'secondary-two'; + const label = 'MeshTown'; + const model = buildChatTabModel({ + nodes: [], + messages: [ + { id: secondaryFirstId, rx_time: NOW - 12, channel: 7, channel_name: label }, + { id: primaryId, rx_time: NOW - 10, channel: 0, channel_name: label }, + { id: secondarySecondId, rx_time: NOW - 8, channel: 3, channel_name: ` ${label} ` } + ], + nowSeconds: NOW, + windowSeconds: WINDOW + }); + + const meshChannels = model.channels.filter(channel => channel.label === label); + assert.equal(meshChannels.length, 2); + + const primaryChannel = meshChannels.find(channel => channel.index === 0); + assert.ok(primaryChannel); + assert.equal(primaryChannel.entries.length, 1); + assert.equal(primaryChannel.entries[0]?.message?.id, primaryId); + + const secondaryChannel = meshChannels.find(channel => channel.index > 0); + assert.ok(secondaryChannel); + assert.equal(secondaryChannel.id, 'channel-secondary-meshtown'); + assert.equal(secondaryChannel.index, 3); + assert.deepEqual(secondaryChannel.entries.map(entry => entry.message.id), [secondaryFirstId, secondarySecondId]); +}); + +test('buildChatTabModel rekeys unnamed secondary buckets when a label later arrives', () => { + const unnamedId = 'unnamed'; + const namedId = 'named'; + const label = 'SideMesh'; + const index = 4; + const model = buildChatTabModel({ + nodes: [], + messages: [ + { id: unnamedId, rx_time: NOW - 15, channel: index }, + { id: namedId, rx_time: NOW - 10, channel: index, channel_name: label } + ], + nowSeconds: NOW, + windowSeconds: WINDOW + }); + + const secondaryChannels = model.channels.filter(channel => channel.index === index); + assert.equal(secondaryChannels.length, 1); + const [secondaryChannel] = secondaryChannels; + assert.equal(secondaryChannel.id, 'channel-secondary-sidemesh'); + assert.equal(secondaryChannel.label, label); + assert.deepEqual(secondaryChannel.entries.map(entry => entry.message.id), [unnamedId, namedId]); +}); + +test('buildChatTabModel merges unlabeled secondary messages into existing named buckets by index', () => { + const namedId = 'named'; + const unlabeledId = 'unlabeled'; + const label = 'MeshNorth'; + const index = 5; + const model = buildChatTabModel({ + nodes: [], + messages: [ + { id: namedId, rx_time: NOW - 12, channel: index, channel_name: label }, + { id: unlabeledId, rx_time: NOW - 8, channel: index } + ], + nowSeconds: NOW, + windowSeconds: WINDOW + }); + + const secondaryChannels = model.channels.filter(channel => channel.index === index); + assert.equal(secondaryChannels.length, 1); + const [secondaryChannel] = secondaryChannels; + assert.equal(secondaryChannel.id, 'channel-secondary-meshnorth'); + assert.equal(secondaryChannel.label, label); + assert.deepEqual(secondaryChannel.entries.map(entry => entry.message.id), [namedId, unlabeledId]); +}); diff --git a/web/public/assets/js/app/chat-log-tabs.js b/web/public/assets/js/app/chat-log-tabs.js index 7a40069..15bcdc8 100644 --- a/web/public/assets/js/app/chat-log-tabs.js +++ b/web/public/assets/js/app/chat-log-tabs.js @@ -189,8 +189,28 @@ export function buildChatTabModel({ modemPreset, envFallbackLabel: primaryChannelEnvLabel }); - const bucketKey = buildChannelBucketKey(safeIndex, safeIndex === 0 && labelInfo.label !== '0' ? labelInfo.label : null); + const nameBucketKey = safeIndex > 0 ? buildSecondaryNameBucketKey(labelInfo) : null; + const primaryBucketKey = safeIndex === 0 && labelInfo.label !== '0' ? buildPrimaryBucketKey(labelInfo.label) : '0'; + + let bucketKey = safeIndex === 0 ? primaryBucketKey : nameBucketKey ?? String(safeIndex); let bucket = channelBuckets.get(bucketKey); + + if (!bucket && safeIndex > 0) { + const existingBucketKey = findExistingBucketKeyByIndex(channelBuckets, safeIndex); + if (existingBucketKey) { + bucketKey = existingBucketKey; + bucket = channelBuckets.get(existingBucketKey); + } + } + + if (bucket && nameBucketKey && bucket.key !== nameBucketKey) { + channelBuckets.delete(bucket.key); + bucket.key = nameBucketKey; + bucket.id = buildChannelTabId(nameBucketKey); + channelBuckets.set(nameBucketKey, bucket); + bucketKey = nameBucketKey; + } + if (!bucket) { bucket = { key: bucketKey, @@ -208,6 +228,9 @@ export function buildChatTabModel({ bucket.label = labelInfo.label; bucket.labelPriority = labelInfo.priority; } + if (Number.isFinite(safeIndex)) { + bucket.index = Math.min(bucket.index ?? safeIndex, safeIndex); + } } bucket.entries.push({ ts, message }); @@ -425,15 +448,47 @@ export function normaliseChannelName(value) { return null; } -function buildChannelBucketKey(index, primaryChannelLabel) { - const safeIndex = Number.isFinite(index) ? Math.max(0, Math.trunc(index)) : 0; - if (safeIndex === 0 && primaryChannelLabel) { +function buildPrimaryBucketKey(primaryChannelLabel) { + if (primaryChannelLabel) { const trimmed = primaryChannelLabel.trim(); if (trimmed.length > 0 && trimmed !== '0') { return `0::${trimmed.toLowerCase()}`; } } - return String(safeIndex); + return '0'; +} + +function buildSecondaryNameBucketKey(labelInfo) { + const label = labelInfo?.label ?? null; + const priority = labelInfo?.priority ?? CHANNEL_LABEL_PRIORITY.INDEX; + if (priority !== CHANNEL_LABEL_PRIORITY.NAME || !label) { + return null; + } + const trimmedLabel = label.trim().toLowerCase(); + if (!trimmedLabel.length) { + return null; + } + return `secondary::${trimmedLabel}`; +} + +function findExistingBucketKeyByIndex(channelBuckets, targetIndex) { + if (!channelBuckets || !Number.isFinite(targetIndex) || targetIndex <= 0) { + return null; + } + const normalizedTarget = Math.trunc(targetIndex); + for (const [key, bucket] of channelBuckets.entries()) { + if (!bucket || !Number.isFinite(bucket.index)) { + continue; + } + if (Math.trunc(bucket.index) !== normalizedTarget) { + continue; + } + if (bucket.index === 0) { + continue; + } + return key; + } + return null; } function buildChannelTabId(bucketKey) {