173 lines
6.8 KiB
JavaScript
173 lines
6.8 KiB
JavaScript
/* Alfred V4+V5: visual activity dock. Does not alter the existing interface. */
|
||
(() => {
|
||
'use strict';
|
||
const CSS = 'alfred-feature-';
|
||
const state = { cards: [], collapsed: false, lastStatus: new Map(), receivedOnce: false };
|
||
const el = (tag, className, text) => {
|
||
const node = document.createElement(tag);
|
||
if (className) node.className = CSS + className;
|
||
if (text != null) node.textContent = String(text);
|
||
return node;
|
||
};
|
||
const root = el('aside', 'dock');
|
||
root.setAttribute('aria-label', 'Activité et informations d’Alfred');
|
||
const header = el('header', 'header');
|
||
const toggle = el('button', 'toggle', 'Activité Hermes');
|
||
toggle.type = 'button';
|
||
toggle.setAttribute('aria-expanded', 'true');
|
||
const count = el('span', 'count', '0');
|
||
const contents = el('section', 'contents');
|
||
contents.setAttribute('aria-live', 'polite');
|
||
contents.setAttribute('aria-relevant', 'additions text');
|
||
header.append(toggle, count);
|
||
root.append(header, contents);
|
||
document.body.append(root);
|
||
|
||
const toggleDock = (collapse) => {
|
||
state.collapsed = collapse;
|
||
root.classList.toggle(CSS + 'collapsed', collapse);
|
||
toggle.setAttribute('aria-expanded', String(!collapse));
|
||
toggle.textContent = collapse ? '☰ Activité Hermes' : '⌄ Activité Hermes';
|
||
};
|
||
toggle.addEventListener('click', () => toggleDock(!state.collapsed));
|
||
|
||
const addText = (parent, cls, text, maxlength=1600) => {
|
||
if (text === undefined || text === null || String(text).trim() === '') return;
|
||
const value = String(text);
|
||
if (value.length <= maxlength) {
|
||
parent.append(el('p', cls, value));
|
||
return;
|
||
}
|
||
const p = el('p', cls, value.slice(0, maxlength) + '…');
|
||
const button = el('button', 'more', 'Afficher tout');
|
||
button.type = 'button';
|
||
let open = false;
|
||
button.addEventListener('click', () => {
|
||
open = !open;
|
||
p.textContent = open ? value : value.slice(0, maxlength) + '…';
|
||
button.textContent = open ? 'Réduire' : 'Afficher tout';
|
||
});
|
||
parent.append(p, button);
|
||
};
|
||
|
||
const safeImage = url => {
|
||
if (typeof url !== 'string') return null;
|
||
try {
|
||
const parsed = new URL(url, location.origin);
|
||
return ['http:', 'https:'].includes(parsed.protocol) ? parsed.href : null;
|
||
} catch { return null; }
|
||
};
|
||
const displayExtra = (item, display) => {
|
||
if (!display || typeof display !== 'object') return;
|
||
const kind = display.type;
|
||
if (kind === 'image' && display.url) {
|
||
const src = safeImage(display.url);
|
||
if (src) {
|
||
const img = el('img', 'image');
|
||
img.src = src;
|
||
img.alt = display.title || 'Illustration Alfred';
|
||
img.loading = 'lazy';
|
||
item.append(img);
|
||
}
|
||
} else if (kind === 'weather') {
|
||
const line = [display.temperature, display.condition || display.description].filter(Boolean).join(' · ');
|
||
addText(item, 'weather', line, 300);
|
||
} else if (kind === 'calendar' && Array.isArray(display.events)) {
|
||
for (const event of display.events.slice(0, 6)) {
|
||
addText(item, 'event', [event.start, event.title].filter(Boolean).join(' — '), 300);
|
||
}
|
||
}
|
||
if (display.text && display.text !== item.dataset.body) addText(item, 'detail', display.text);
|
||
};
|
||
const STATUS_LABELS = {
|
||
queued: 'En attente', running: 'En cours', succeeded: 'Terminé',
|
||
failed: 'Erreur', interrupted: 'Interrompu'
|
||
};
|
||
|
||
function render() {
|
||
contents.replaceChildren();
|
||
count.textContent = String(state.cards.length);
|
||
if (!state.cards.length) {
|
||
contents.append(el('p', 'empty', 'Aucune activité pour le moment.'));
|
||
return;
|
||
}
|
||
state.cards.forEach(card => {
|
||
const item = el('article', 'card');
|
||
item.dataset.body = card.body || '';
|
||
const heading = el('div', 'card-header');
|
||
const title = el('strong', 'card-title', card.title || 'Information');
|
||
heading.append(title);
|
||
if (card.status) {
|
||
const status = el('span', 'status ' + (card.status || ''), STATUS_LABELS[card.status] || card.status);
|
||
heading.append(status);
|
||
}
|
||
item.append(heading);
|
||
const body = card.body || '';
|
||
if (body && (!card.display?.text || card.display.text !== body)) addText(item, 'body', body);
|
||
displayExtra(item, card.display);
|
||
if (body && card.display?.text === body) addText(item, 'body', body);
|
||
const footer = el('div', 'footer');
|
||
if (card.updated_at) {
|
||
const date = new Date(card.updated_at);
|
||
footer.append(el('time', 'date', isNaN(date.getTime()) ? '' : date.toLocaleTimeString('fr-FR', {hour:'2-digit', minute:'2-digit'})));
|
||
}
|
||
if (card.status === 'succeeded' && body) {
|
||
const say = el('button', 'speak', 'Lire');
|
||
say.type = 'button';
|
||
say.title = 'Faire lire le résultat par la voix Piper configurée';
|
||
say.addEventListener('click', async () => {
|
||
say.disabled = true;
|
||
try {
|
||
const response = await fetch('/api/tts/say', {
|
||
method:'POST', headers:{'Content-Type':'application/json'},
|
||
body:JSON.stringify({text:body.slice(0,1800)})
|
||
});
|
||
if (!response.ok) throw new Error('TTS indisponible');
|
||
} catch (error) { say.title = String(error); }
|
||
finally { say.disabled = false; }
|
||
});
|
||
footer.append(say);
|
||
}
|
||
item.append(footer);
|
||
contents.append(item);
|
||
});
|
||
}
|
||
let fetching = false;
|
||
async function refresh() {
|
||
if (fetching) return;
|
||
fetching = true;
|
||
try {
|
||
const response = await fetch('/api/alfred/cards', {cache:'no-store'});
|
||
if (!response.ok) throw new Error('Cartes indisponibles');
|
||
const payload = await response.json();
|
||
const cards = Array.isArray(payload.cards) ? payload.cards : [];
|
||
const sig = JSON.stringify(cards);
|
||
if (sig === state.signature) return;
|
||
state.signature = sig;
|
||
const hasNewCompletion = cards.some(card =>
|
||
card.status === 'succeeded' && state.lastStatus.has(card.id) &&
|
||
state.lastStatus.get(card.id) !== 'succeeded');
|
||
const hasNewCard = cards.some(card => !state.lastStatus.has(card.id));
|
||
state.lastStatus = new Map(cards.map(c => [c.id, c.status]));
|
||
state.cards = cards;
|
||
render();
|
||
if (!state.receivedOnce) {
|
||
state.receivedOnce = true;
|
||
toggleDock(cards.length === 0);
|
||
} else if (hasNewCompletion || hasNewCard) {
|
||
toggleDock(false);
|
||
}
|
||
} catch (error) {
|
||
if (!state.receivedOnce) contents.replaceChildren(el('p', 'empty', 'Connexion aux cartes indisponible.'));
|
||
} finally { fetching = false; }
|
||
}
|
||
refresh();
|
||
setInterval(refresh, 10000); // safety fallback; Socket.IO wakes immediately
|
||
if (typeof window.io === 'function') {
|
||
const socket = window.io();
|
||
socket.on('alfred_cards_changed', refresh);
|
||
socket.on('alfred_task', refresh);
|
||
socket.on('connect', refresh);
|
||
}
|
||
})();
|