8687d72d0b
- 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)
204 lines
6.2 KiB
JavaScript
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, '<')
|
|
.replace(/>/g, '>');
|
|
}
|
|
|
|
/**
|
|
* 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 += '<';
|
|
else if (ch === '>') out += '>';
|
|
else if (ch === '&') out += '&';
|
|
else if (ch === '"') out += '"';
|
|
else out += ch;
|
|
i++;
|
|
}
|
|
while (spans > 0) { out += '</span>'; spans--; }
|
|
return out;
|
|
} |