mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-06 17:03:27 +02:00
Display message frequency and channel in chat log (#339)
* Display message frequency and channel in chat log * Ensure chat prefixes display consistent metadata brackets * Ensure chat prefixes show non-breaking frequency placeholder * Adjust chat channel tag placement
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* 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 {
|
||||
extractChatMessageMetadata,
|
||||
formatChatMessagePrefix,
|
||||
formatChatChannelTag,
|
||||
formatNodeAnnouncementPrefix,
|
||||
__test__
|
||||
} from '../chat-format.js';
|
||||
|
||||
const {
|
||||
firstNonNull,
|
||||
normalizeString,
|
||||
normalizeFrequency,
|
||||
normalizeFrequencySlot,
|
||||
FREQUENCY_PLACEHOLDER
|
||||
} = __test__;
|
||||
|
||||
test('extractChatMessageMetadata prefers explicit region_frequency and channel_name', () => {
|
||||
const payload = {
|
||||
region_frequency: 868,
|
||||
channel_name: ' Test Channel ',
|
||||
lora_freq: 915,
|
||||
channelName: 'Ignored'
|
||||
};
|
||||
const result = extractChatMessageMetadata(payload);
|
||||
assert.deepEqual(result, { frequency: '868', channelName: 'Test Channel' });
|
||||
});
|
||||
|
||||
test('extractChatMessageMetadata falls back to LoRa metadata', () => {
|
||||
const payload = {
|
||||
lora_freq: 915,
|
||||
channelName: 'SpecChannel'
|
||||
};
|
||||
const result = extractChatMessageMetadata(payload);
|
||||
assert.deepEqual(result, { frequency: '915', channelName: 'SpecChannel' });
|
||||
});
|
||||
|
||||
test('extractChatMessageMetadata returns null metadata for invalid input', () => {
|
||||
assert.deepEqual(extractChatMessageMetadata(null), { frequency: null, channelName: null });
|
||||
assert.deepEqual(extractChatMessageMetadata(undefined), { frequency: null, channelName: null });
|
||||
});
|
||||
|
||||
test('firstNonNull returns the first non-null candidate', () => {
|
||||
assert.equal(firstNonNull(null, undefined, '', 'value'), '');
|
||||
assert.equal(firstNonNull(undefined, null), null);
|
||||
});
|
||||
|
||||
test('normalizeString trims strings and rejects empties', () => {
|
||||
assert.equal(normalizeString(' Spec '), 'Spec');
|
||||
assert.equal(normalizeString(' '), null);
|
||||
assert.equal(normalizeString(123), '123');
|
||||
assert.equal(normalizeString(Number.POSITIVE_INFINITY), null);
|
||||
});
|
||||
|
||||
test('normalizeFrequency handles numeric and string inputs', () => {
|
||||
assert.equal(normalizeFrequency(915), '915');
|
||||
assert.equal(normalizeFrequency(868.125), '868.125');
|
||||
assert.equal(normalizeFrequency(' 868MHz '), '868');
|
||||
assert.equal(normalizeFrequency('n/a'), 'n/a');
|
||||
assert.equal(normalizeFrequency(-5), null);
|
||||
assert.equal(normalizeFrequency(null), null);
|
||||
});
|
||||
|
||||
test('formatChatMessagePrefix preserves bracket placeholders', () => {
|
||||
assert.equal(
|
||||
formatChatMessagePrefix({ timestamp: '11:46:48', frequency: '868' }),
|
||||
'[11:46:48][868]'
|
||||
);
|
||||
assert.equal(
|
||||
formatChatMessagePrefix({ timestamp: '16:19:19', frequency: null }),
|
||||
`[16:19:19][${FREQUENCY_PLACEHOLDER}]`
|
||||
);
|
||||
assert.equal(
|
||||
formatChatMessagePrefix({ timestamp: '09:00:00', frequency: '' }),
|
||||
`[09:00:00][${FREQUENCY_PLACEHOLDER}]`
|
||||
);
|
||||
});
|
||||
|
||||
test('formatChatChannelTag wraps channel names after the short name slot', () => {
|
||||
assert.equal(
|
||||
formatChatChannelTag({ channelName: 'TEST' }),
|
||||
'[TEST]'
|
||||
);
|
||||
assert.equal(
|
||||
formatChatChannelTag({ channelName: '' }),
|
||||
'[]'
|
||||
);
|
||||
assert.equal(
|
||||
formatChatChannelTag({ channelName: null }),
|
||||
'[]'
|
||||
);
|
||||
});
|
||||
|
||||
test('formatNodeAnnouncementPrefix includes optional frequency bracket', () => {
|
||||
assert.equal(
|
||||
formatNodeAnnouncementPrefix({ timestamp: '12:34:56', frequency: '868' }),
|
||||
'[12:34:56][868]'
|
||||
);
|
||||
assert.equal(
|
||||
formatNodeAnnouncementPrefix({ timestamp: '01:02:03', frequency: null }),
|
||||
`[01:02:03][${FREQUENCY_PLACEHOLDER}]`
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeFrequencySlot returns placeholder when frequency is missing', () => {
|
||||
assert.equal(normalizeFrequencySlot(null), FREQUENCY_PLACEHOLDER);
|
||||
assert.equal(normalizeFrequencySlot(''), FREQUENCY_PLACEHOLDER);
|
||||
assert.equal(normalizeFrequencySlot(undefined), FREQUENCY_PLACEHOLDER);
|
||||
assert.equal(normalizeFrequencySlot('915'), '915');
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extract channel metadata from a message payload for chat display.
|
||||
*
|
||||
* @param {Object} message Raw message payload from the API.
|
||||
* @returns {{ frequency: string|null, channelName: string|null }}
|
||||
* Normalized metadata values.
|
||||
*/
|
||||
export function extractChatMessageMetadata(message) {
|
||||
if (!message || typeof message !== 'object') {
|
||||
return { frequency: null, channelName: null };
|
||||
}
|
||||
|
||||
const frequency = normalizeFrequency(
|
||||
firstNonNull(
|
||||
message.region_frequency,
|
||||
message.regionFrequency,
|
||||
message.lora_freq,
|
||||
message.loraFreq,
|
||||
message.frequency
|
||||
)
|
||||
);
|
||||
|
||||
const channelName = normalizeString(
|
||||
firstNonNull(message.channel_name, message.channelName)
|
||||
);
|
||||
|
||||
return { frequency, channelName };
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce the formatted prefix for a chat message entry.
|
||||
*
|
||||
* Timestamp and frequency will each be wrapped in square brackets. Missing
|
||||
* metadata values result in empty brackets (with the frequency replaced by the
|
||||
* configured placeholder) to preserve the positional layout expected by
|
||||
* operators.
|
||||
*
|
||||
* @param {{
|
||||
* timestamp: string,
|
||||
* frequency: string|null
|
||||
* }} params Normalised and escaped display strings.
|
||||
* @returns {string} Prefix string suitable for HTML insertion.
|
||||
*/
|
||||
export function formatChatMessagePrefix({ timestamp, frequency }) {
|
||||
const ts = typeof timestamp === 'string' ? timestamp : '';
|
||||
const freq = normalizeFrequencySlot(frequency);
|
||||
return `[${ts}][${freq}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the channel tag that follows the short name in a chat message entry.
|
||||
*
|
||||
* Empty channel names remain blank within the brackets, mirroring the original
|
||||
* UI behaviour that reserves the slot without introducing placeholder text.
|
||||
*
|
||||
* @param {{ channelName: string|null }} params Normalised and escaped display strings.
|
||||
* @returns {string} Channel tag suitable for HTML insertion.
|
||||
*/
|
||||
export function formatChatChannelTag({ channelName }) {
|
||||
const channel = typeof channelName === 'string' ? channelName : channelName == null ? '' : String(channelName);
|
||||
return `[${channel}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the formatted prefix for node announcements in the chat log.
|
||||
*
|
||||
* Both the timestamp and the optional frequency will be wrapped in brackets,
|
||||
* mirroring the chat message display while omitting the channel indicator.
|
||||
*
|
||||
* @param {{ timestamp: string, frequency: string|null }} params Display strings.
|
||||
* @returns {string} Prefix string suitable for HTML insertion.
|
||||
*/
|
||||
export function formatNodeAnnouncementPrefix({ timestamp, frequency }) {
|
||||
const ts = typeof timestamp === 'string' ? timestamp : '';
|
||||
const freq = normalizeFrequencySlot(frequency);
|
||||
return `[${ts}][${freq}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce a consistently formatted frequency slot for chat prefixes.
|
||||
*
|
||||
* A missing or empty frequency is rendered as three HTML non-breaking spaces to
|
||||
* ensure the UI maintains its expected alignment while clearly indicating the
|
||||
* absence of data.
|
||||
*
|
||||
* @param {*} value Frequency value that has already been escaped for HTML.
|
||||
* @returns {string} Frequency slot suitable for prefix rendering.
|
||||
*/
|
||||
function normalizeFrequencySlot(value) {
|
||||
if (value == null) {
|
||||
return FREQUENCY_PLACEHOLDER;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value.length > 0 ? value : FREQUENCY_PLACEHOLDER;
|
||||
}
|
||||
const strValue = String(value);
|
||||
return strValue.length > 0 ? strValue : FREQUENCY_PLACEHOLDER;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML entity sequence inserted when a frequency is unavailable.
|
||||
* @type {string}
|
||||
*/
|
||||
const FREQUENCY_PLACEHOLDER = ' ';
|
||||
|
||||
/**
|
||||
* Return the first value in ``candidates`` that is not ``null`` or ``undefined``.
|
||||
*
|
||||
* @param {...*} candidates Candidate values.
|
||||
* @returns {*} First present value or ``null`` when missing.
|
||||
*/
|
||||
function firstNonNull(...candidates) {
|
||||
for (const value of candidates) {
|
||||
if (value !== null && value !== undefined) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise potential channel name values to trimmed strings.
|
||||
*
|
||||
* @param {*} value Raw value.
|
||||
* @returns {string|null} Sanitised channel name.
|
||||
*/
|
||||
function normalizeString(value) {
|
||||
if (value == null) return null;
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value)) return null;
|
||||
return String(value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert various frequency representations into clean strings.
|
||||
*
|
||||
* @param {*} value Raw frequency value.
|
||||
* @returns {string|null} Frequency in MHz as a string, when available.
|
||||
*/
|
||||
function normalizeFrequency(value) {
|
||||
if (value == null) return null;
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(3)));
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const numericMatch = trimmed.match(/\d+(?:\.\d+)?/);
|
||||
if (numericMatch) {
|
||||
const parsed = Number(numericMatch[0]);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return Number.isInteger(parsed) ? String(Math.trunc(parsed)) : String(parsed);
|
||||
}
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const __test__ = {
|
||||
firstNonNull,
|
||||
normalizeString,
|
||||
normalizeFrequency,
|
||||
formatChatMessagePrefix,
|
||||
formatNodeAnnouncementPrefix,
|
||||
normalizeFrequencySlot,
|
||||
FREQUENCY_PLACEHOLDER,
|
||||
formatChatChannelTag
|
||||
};
|
||||
@@ -19,6 +19,12 @@ import { createMapAutoFitController } from './map-auto-fit-controller.js';
|
||||
import { attachNodeInfoRefreshToMarker, overlayToPopupNode } from './map-marker-node-info.js';
|
||||
import { createShortInfoOverlayStack } from './short-info-overlay-manager.js';
|
||||
import { refreshNodeInformation } from './node-details.js';
|
||||
import {
|
||||
extractChatMessageMetadata,
|
||||
formatChatMessagePrefix,
|
||||
formatChatChannelTag,
|
||||
formatNodeAnnouncementPrefix
|
||||
} from './chat-format.js';
|
||||
|
||||
/**
|
||||
* Entry point for the interactive dashboard. Wires up event listeners,
|
||||
@@ -2069,7 +2075,12 @@ export function initializeApp(config) {
|
||||
div.className = 'chat-entry-node';
|
||||
const short = renderShortHtml(n.short_name, n.role, n.long_name, n);
|
||||
const longName = escapeHtml(n.long_name || '');
|
||||
div.innerHTML = `[${ts}] ${short} <em>New node: ${longName}</em>`;
|
||||
const metadata = extractChatMessageMetadata(n);
|
||||
const prefix = formatNodeAnnouncementPrefix({
|
||||
timestamp: escapeHtml(ts),
|
||||
frequency: metadata.frequency ? escapeHtml(metadata.frequency) : ''
|
||||
});
|
||||
div.innerHTML = `${prefix} ${short} <em>New node: ${longName}</em>`;
|
||||
return div;
|
||||
}
|
||||
|
||||
@@ -2084,8 +2095,16 @@ export function initializeApp(config) {
|
||||
const ts = formatTime(new Date(m.rx_time * 1000));
|
||||
const short = renderShortHtml(m.node?.short_name, m.node?.role, m.node?.long_name, m.node);
|
||||
const text = escapeHtml(m.text || '');
|
||||
const metadata = extractChatMessageMetadata(m);
|
||||
const prefix = formatChatMessagePrefix({
|
||||
timestamp: escapeHtml(ts),
|
||||
frequency: metadata.frequency ? escapeHtml(metadata.frequency) : ''
|
||||
});
|
||||
const channelTag = formatChatChannelTag({
|
||||
channelName: metadata.channelName ? escapeHtml(metadata.channelName) : ''
|
||||
});
|
||||
div.className = 'chat-entry-msg';
|
||||
div.innerHTML = `[${ts}] ${short} ${text}`;
|
||||
div.innerHTML = `${prefix} ${short} ${channelTag} ${text}`;
|
||||
return div;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user