Files
muxplex/muxplex/frontend/components/utils.js
T
Ken 8687d72d0b
CI / test (3.13) (push) Failing after 11m50s
CI / test (3.12) (push) Failing after 11m52s
CI / test (3.11) (push) Failing after 11m54s
feat: introduce Lit web components framework for frontend (Phase 1)
- Vendored Lit 3 (21KB ESM bundle, no build step required)
- New components: toast-notification, connection-status, session-pill, session-tile
- Extracted utilities: escapeHtml, formatTimestamp, sessionPriority, ansiToHtml
- SVG icons registry for reuse
- Updated app.js and index.html to use new components
- All 1306 tests pass (incremental migration, vanilla JS still supported)
2026-05-27 05:39:57 +00:00

204 lines
6.2 KiB
JavaScript

// Pure utility functions extracted from app.js
// These have zero DOM coupling and zero global state.
/**
* Escape HTML special characters to safe entities.
* @param {string} str
* @returns {string}
*/
export function escapeHtml(str) {
return String(str)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
/**
* Format a Unix timestamp (seconds) into a relative time string.
* @param {number|null|undefined} ts
* @returns {string}
*/
export function formatTimestamp(ts) {
if (ts == null) return '';
const diff = Math.floor(Date.now() / 1000 - ts);
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
return `${Math.floor(diff / 3600)}h ago`;
}
/**
* Return the priority label for a session object.
* @param {object} session
* @returns {'bell'|'idle'}
*/
export function sessionPriority(session) {
const bell = session && session.bell;
if (bell && bell.unseen_count > 0 && (bell.seen_at === null || bell.last_fired_at > bell.seen_at)) {
return 'bell';
}
return 'idle';
}
/** Priority rank map. Lower = higher priority. */
export const PRIORITY_RANK = { bell: 0, active: 1, idle: 2 };
/**
* Sort sessions by priority (ascending rank). Returns new array.
* @param {object[]} sessions
* @returns {object[]}
*/
export function sortByPriority(sessions) {
return sessions.slice().sort((a, b) => {
const rankA = PRIORITY_RANK[sessionPriority(a)] ?? 2;
const rankB = PRIORITY_RANK[sessionPriority(b)] ?? 2;
return rankA - rankB;
});
}
/**
* Filter sessions by search query (case-insensitive substring on name).
* @param {object[]} sessions
* @param {string|null} query
* @returns {object[]}
*/
export function filterByQuery(sessions, query) {
if (!query) return sessions;
const q = query.toLowerCase();
return sessions.filter((s) => (s.name || '').toLowerCase().includes(q));
}
/**
* Detect sessions that transitioned to new/increased bell state.
* @param {object[]} prev
* @param {object[]} next
* @returns {string[]}
*/
export function detectBellTransitions(prev, next) {
const prevMap = new Map(
(prev || []).map((s) => [s.sessionKey || s.name, (s.bell && s.bell.unseen_count) || 0]),
);
return (next || [])
.filter((s) => {
const unseen = s.bell && s.bell.unseen_count;
if (!unseen || unseen <= 0) return false;
const key = s.sessionKey || s.name;
const prevCount = prevMap.has(key) ? prevMap.get(key) : 0;
return unseen > prevCount;
})
.map((s) => s.name);
}
/**
* Generate a pseudo-random device ID string.
* @returns {string}
*/
export function generateDeviceId() {
return 'd-' + Math.random().toString(36).padEnd(10, '0').slice(2, 10);
}
/**
* Build a heartbeat payload object.
* @param {string} device_id
* @param {string|null} viewing_session
* @param {string} view_mode
* @param {number} last_interaction_at
* @returns {object}
*/
export function buildHeartbeatPayload(device_id, viewing_session, view_mode, last_interaction_at) {
const label =
typeof navigator !== 'undefined' && navigator.userAgent
? navigator.userAgent.slice(0, 50)
: 'unknown';
return { device_id, label, viewing_session, view_mode, last_interaction_at };
}
// ---------------------------------------------------------------------------
// ANSI escape -> HTML span converter (SGR codes only)
// ---------------------------------------------------------------------------
const ANSI_COLORS = [
'#2e3436','#cc0000','#4e9a06','#c4a000','#3465a4','#75507b','#06989a','#d3d7cf',
'#555753','#ef2929','#8ae234','#fce94f','#729fcf','#ad7fa8','#34e2e2','#eeeeec'
];
function ansi256Color(n) {
if (n < 16) return ANSI_COLORS[n];
if (n >= 232) { const g = 8 + (n - 232) * 10; return `rgb(${g},${g},${g})`; }
n -= 16;
const r = Math.floor(n / 36) * 51;
const g2 = Math.floor((n % 36) / 6) * 51;
const b = (n % 6) * 51;
return `rgb(${r},${g2},${b})`;
}
function ansiParamsToStyle(params) {
const styles = [];
let k = 0;
while (k < params.length) {
const p = parseInt(params[k], 10) || 0;
if (p === 0) return 'reset';
if (p === 1) styles.push('font-weight:bold');
else if (p === 2) styles.push('opacity:0.7');
else if (p === 3) styles.push('font-style:italic');
else if (p === 4) styles.push('text-decoration:underline');
else if (p === 7) styles.push('filter:invert(1)');
else if (p === 9) styles.push('text-decoration:line-through');
else if (p >= 30 && p <= 37) styles.push('color:' + ANSI_COLORS[p - 30]);
else if (p === 38 && params[k + 1] === '5') {
styles.push('color:' + ansi256Color(parseInt(params[k + 2], 10) || 0));
k += 2;
}
else if (p === 39) styles.push('color:inherit');
else if (p >= 40 && p <= 47) styles.push('background:' + ANSI_COLORS[p - 40]);
else if (p === 48 && params[k + 1] === '5') {
styles.push('background:' + ansi256Color(parseInt(params[k + 2], 10) || 0));
k += 2;
}
else if (p === 49) styles.push('background:inherit');
else if (p >= 90 && p <= 97) styles.push('color:' + ANSI_COLORS[p - 90 + 8]);
else if (p >= 100 && p <= 107) styles.push('background:' + ANSI_COLORS[p - 100 + 8]);
k++;
}
return styles.length ? styles.join(';') : '';
}
/**
* Convert ANSI SGR escape sequences to HTML spans with inline styles.
* @param {string} raw
* @returns {string}
*/
export function ansiToHtml(raw) {
if (!raw) return '';
let out = '';
let spans = 0;
let i = 0;
const len = raw.length;
while (i < len) {
if (raw[i] === '\x1b' && raw[i + 1] === '[') {
let j = i + 2;
while (j < len && raw[j] !== 'm' && j - i < 20) j++;
if (j < len && raw[j] === 'm') {
const params = raw.substring(i + 2, j).split(';');
const style = ansiParamsToStyle(params);
if (style === 'reset') {
while (spans > 0) { out += '</span>'; spans--; }
} else if (style) {
out += '<span style="' + style + '">';
spans++;
}
i = j + 1;
continue;
}
}
const ch = raw[i];
if (ch === '<') out += '&lt;';
else if (ch === '>') out += '&gt;';
else if (ch === '&') out += '&amp;';
else if (ch === '"') out += '&quot;';
else out += ch;
i++;
}
while (spans > 0) { out += '</span>'; spans--; }
return out;
}