264 lines
11 KiB
JavaScript
264 lines
11 KiB
JavaScript
|
|
export function mount({ root, state, host }) {
|
||
|
|
state = state || {};
|
||
|
|
const __cleanup = [];
|
||
|
|
const __setInterval = (...args) => {
|
||
|
|
const id = window.setInterval(...args);
|
||
|
|
__cleanup.push(() => window.clearInterval(id));
|
||
|
|
return id;
|
||
|
|
};
|
||
|
|
const __setTimeout = (...args) => {
|
||
|
|
const id = window.setTimeout(...args);
|
||
|
|
__cleanup.push(() => window.clearTimeout(id));
|
||
|
|
return id;
|
||
|
|
};
|
||
|
|
if (!(root instanceof HTMLElement)) return;
|
||
|
|
|
||
|
|
const subtitleEl = root.querySelector('[data-calendar-subtitle]');
|
||
|
|
const statusEl = root.querySelector('[data-calendar-status]');
|
||
|
|
const rangeEl = root.querySelector('[data-calendar-range]');
|
||
|
|
const emptyEl = root.querySelector('[data-calendar-empty]');
|
||
|
|
const listEl = root.querySelector('[data-calendar-list]');
|
||
|
|
const updatedEl = root.querySelector('[data-calendar-updated]');
|
||
|
|
if (!(subtitleEl instanceof HTMLElement) || !(rangeEl instanceof HTMLElement) || !(emptyEl instanceof HTMLElement) || !(listEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement)) return;
|
||
|
|
|
||
|
|
const subtitle = typeof state.subtitle === 'string' ? state.subtitle : '';
|
||
|
|
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
|
||
|
|
const calendarNames = Array.isArray(state.calendar_names)
|
||
|
|
? state.calendar_names.map((value) => String(value || '').trim()).filter(Boolean)
|
||
|
|
: [];
|
||
|
|
const rangeDaysRaw = Number(state.range_days);
|
||
|
|
const rangeDays = Number.isFinite(rangeDaysRaw) && rangeDaysRaw >= 1 ? Math.min(rangeDaysRaw, 7) : 1;
|
||
|
|
const maxEventsRaw = Number(state.max_events);
|
||
|
|
const maxEvents = Number.isFinite(maxEventsRaw) && maxEventsRaw >= 1 ? Math.min(maxEventsRaw, 30) : 8;
|
||
|
|
const refreshMsRaw = Number(state.refresh_ms);
|
||
|
|
const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 15 * 60 * 1000;
|
||
|
|
const emptyText = typeof state.empty_text === 'string' && state.empty_text.trim() ? state.empty_text.trim() : 'No events scheduled.';
|
||
|
|
|
||
|
|
subtitleEl.textContent = subtitle || (calendarNames.length > 0 ? calendarNames.join(', ') : 'Loading calendars');
|
||
|
|
emptyEl.textContent = emptyText;
|
||
|
|
const updateLiveContent = (snapshot) => {
|
||
|
|
host.setLiveContent(snapshot);
|
||
|
|
};
|
||
|
|
|
||
|
|
const setStatus = (label, color) => {
|
||
|
|
if (!(statusEl instanceof HTMLElement)) return;
|
||
|
|
statusEl.textContent = label;
|
||
|
|
statusEl.style.color = color;
|
||
|
|
};
|
||
|
|
|
||
|
|
const normalizeDateValue = (value) => {
|
||
|
|
if (typeof value === 'string') return value;
|
||
|
|
if (value && typeof value === 'object') {
|
||
|
|
if (typeof value.dateTime === 'string') return value.dateTime;
|
||
|
|
if (typeof value.date === 'string') return value.date;
|
||
|
|
}
|
||
|
|
return '';
|
||
|
|
};
|
||
|
|
|
||
|
|
const isAllDay = (start, end) => /^\d{4}-\d{2}-\d{2}$/.test(normalizeDateValue(start)) || /^\d{4}-\d{2}-\d{2}$/.test(normalizeDateValue(end));
|
||
|
|
const eventSortKey = (event) => {
|
||
|
|
const raw = normalizeDateValue(event && event.start);
|
||
|
|
const time = new Date(raw).getTime();
|
||
|
|
return Number.isFinite(time) ? time : Number.MAX_SAFE_INTEGER;
|
||
|
|
};
|
||
|
|
const formatTime = (value) => {
|
||
|
|
const raw = normalizeDateValue(value);
|
||
|
|
if (!raw) return '--:--';
|
||
|
|
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return 'All day';
|
||
|
|
const date = new Date(raw);
|
||
|
|
if (Number.isNaN(date.getTime())) return '--:--';
|
||
|
|
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||
|
|
};
|
||
|
|
const formatDay = (value) => {
|
||
|
|
const raw = normalizeDateValue(value);
|
||
|
|
if (!raw) return '--';
|
||
|
|
const date = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? new Date(`${raw}T00:00:00`) : new Date(raw);
|
||
|
|
if (Number.isNaN(date.getTime())) return '--';
|
||
|
|
return date.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' });
|
||
|
|
};
|
||
|
|
const formatRange = (start, end) => `${start.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' })} to ${end.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' })}`;
|
||
|
|
|
||
|
|
const extractEvents = (toolResult) => {
|
||
|
|
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
|
||
|
|
const parsed = toolResult.parsed;
|
||
|
|
if (Array.isArray(parsed.result)) return parsed.result;
|
||
|
|
}
|
||
|
|
if (typeof toolResult?.content === 'string') {
|
||
|
|
try {
|
||
|
|
const parsed = JSON.parse(toolResult.content);
|
||
|
|
if (parsed && typeof parsed === 'object' && Array.isArray(parsed.result)) {
|
||
|
|
return parsed.result;
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return [];
|
||
|
|
};
|
||
|
|
|
||
|
|
const eventTime = (value) => {
|
||
|
|
const raw = normalizeDateValue(value);
|
||
|
|
if (!raw) return Number.MAX_SAFE_INTEGER;
|
||
|
|
const normalized = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? `${raw}T00:00:00` : raw;
|
||
|
|
const time = new Date(normalized).getTime();
|
||
|
|
return Number.isFinite(time) ? time : Number.MAX_SAFE_INTEGER;
|
||
|
|
};
|
||
|
|
|
||
|
|
const resolveToolConfig = async () => {
|
||
|
|
const fallbackName = configuredToolName || 'mcp_home_assistant_calendar_get_events';
|
||
|
|
if (!host.listTools) {
|
||
|
|
return { name: fallbackName, availableCalendars: calendarNames };
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
const tools = await host.listTools();
|
||
|
|
const tool = Array.isArray(tools)
|
||
|
|
? tools.find((item) => /(^|_)calendar_get_events$/i.test(String(item?.name || '')))
|
||
|
|
: null;
|
||
|
|
const enumValues = Array.isArray(tool?.parameters?.properties?.calendar?.enum)
|
||
|
|
? tool.parameters.properties.calendar.enum.map((value) => String(value || '').trim()).filter(Boolean)
|
||
|
|
: [];
|
||
|
|
return {
|
||
|
|
name: tool?.name || fallbackName,
|
||
|
|
availableCalendars: enumValues,
|
||
|
|
};
|
||
|
|
} catch {
|
||
|
|
return { name: fallbackName, availableCalendars: calendarNames };
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const renderEvents = (events) => {
|
||
|
|
listEl.innerHTML = '';
|
||
|
|
if (!Array.isArray(events) || events.length === 0) {
|
||
|
|
emptyEl.style.display = 'block';
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
emptyEl.style.display = 'none';
|
||
|
|
|
||
|
|
for (const [index, event] of events.slice(0, maxEvents).entries()) {
|
||
|
|
const item = document.createElement('li');
|
||
|
|
item.style.padding = index === 0 ? '10px 0 8px' : '10px 0 8px';
|
||
|
|
item.style.borderTop = '1px solid var(--theme-card-neutral-border)';
|
||
|
|
|
||
|
|
const summary = document.createElement('div');
|
||
|
|
summary.style.fontSize = '0.98rem';
|
||
|
|
summary.style.lineHeight = '1.3';
|
||
|
|
summary.style.fontWeight = '700';
|
||
|
|
summary.style.color = 'var(--theme-card-neutral-text)';
|
||
|
|
summary.textContent = String(event.summary || '(No title)');
|
||
|
|
item.appendChild(summary);
|
||
|
|
|
||
|
|
const timing = document.createElement('div');
|
||
|
|
timing.style.marginTop = '4px';
|
||
|
|
timing.style.fontSize = '0.9rem';
|
||
|
|
timing.style.lineHeight = '1.35';
|
||
|
|
timing.style.color = 'var(--theme-card-neutral-subtle)';
|
||
|
|
const dayLabel = formatDay(event.start);
|
||
|
|
const timeLabel = isAllDay(event.start, event.end) ? 'All day' : `${formatTime(event.start)} - ${formatTime(event.end)}`;
|
||
|
|
timing.textContent = dayLabel === '--' ? timeLabel : `${dayLabel} · ${timeLabel}`;
|
||
|
|
item.appendChild(timing);
|
||
|
|
|
||
|
|
listEl.appendChild(item);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const refresh = async () => {
|
||
|
|
setStatus('Refreshing', 'var(--theme-status-muted)');
|
||
|
|
const start = new Date();
|
||
|
|
start.setHours(0, 0, 0, 0);
|
||
|
|
const end = new Date(start);
|
||
|
|
end.setDate(end.getDate() + Math.max(rangeDays - 1, 0));
|
||
|
|
end.setHours(23, 59, 59, 999);
|
||
|
|
rangeEl.textContent = formatRange(start, end);
|
||
|
|
|
||
|
|
try {
|
||
|
|
const toolConfig = await resolveToolConfig();
|
||
|
|
const selectedCalendars = calendarNames.length > 0 ? calendarNames : toolConfig.availableCalendars;
|
||
|
|
if (!toolConfig.name) throw new Error('Calendar tool unavailable');
|
||
|
|
if (!Array.isArray(selectedCalendars) || selectedCalendars.length === 0) {
|
||
|
|
throw new Error('No calendars configured');
|
||
|
|
}
|
||
|
|
|
||
|
|
const resolvedSubtitle = subtitle || selectedCalendars.join(', ');
|
||
|
|
subtitleEl.textContent = resolvedSubtitle;
|
||
|
|
|
||
|
|
const allEvents = [];
|
||
|
|
const rangeMode = rangeDays > 1 ? 'week' : 'today';
|
||
|
|
const endExclusiveTime = end.getTime() + 1;
|
||
|
|
for (const calendarName of selectedCalendars) {
|
||
|
|
const toolResult = await host.callTool(toolConfig.name, {
|
||
|
|
calendar: calendarName,
|
||
|
|
range: rangeMode,
|
||
|
|
});
|
||
|
|
const events = extractEvents(toolResult);
|
||
|
|
for (const event of events) {
|
||
|
|
const startTime = eventTime(event?.start);
|
||
|
|
if (startTime < start.getTime() || startTime >= endExclusiveTime) continue;
|
||
|
|
allEvents.push({ ...event, _calendarName: calendarName });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
allEvents.sort((left, right) => eventSortKey(left) - eventSortKey(right));
|
||
|
|
renderEvents(allEvents);
|
||
|
|
const updatedText = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||
|
|
updatedEl.textContent = updatedText;
|
||
|
|
const statusLabel = rangeDays > 1 ? `${rangeDays}-day` : 'Today';
|
||
|
|
setStatus(statusLabel, 'var(--theme-status-live)');
|
||
|
|
const snapshotEvents = allEvents.slice(0, maxEvents).map((event) => {
|
||
|
|
const dayLabel = formatDay(event.start);
|
||
|
|
const timeLabel = isAllDay(event.start, event.end) ? 'All day' : `${formatTime(event.start)} - ${formatTime(event.end)}`;
|
||
|
|
return {
|
||
|
|
summary: String(event.summary || '(No title)'),
|
||
|
|
start: normalizeDateValue(event.start) || null,
|
||
|
|
end: normalizeDateValue(event.end) || null,
|
||
|
|
day_label: dayLabel === '--' ? null : dayLabel,
|
||
|
|
time_label: timeLabel,
|
||
|
|
all_day: isAllDay(event.start, event.end),
|
||
|
|
};
|
||
|
|
});
|
||
|
|
updateLiveContent({
|
||
|
|
kind: 'calendar_agenda',
|
||
|
|
subtitle: resolvedSubtitle || null,
|
||
|
|
tool_name: toolConfig.name,
|
||
|
|
calendar_names: selectedCalendars,
|
||
|
|
range_label: rangeEl.textContent || null,
|
||
|
|
status: statusLabel,
|
||
|
|
updated_at: updatedText,
|
||
|
|
event_count: snapshotEvents.length,
|
||
|
|
events: snapshotEvents,
|
||
|
|
});
|
||
|
|
} catch (error) {
|
||
|
|
const errorText = String(error);
|
||
|
|
renderEvents([]);
|
||
|
|
updatedEl.textContent = errorText;
|
||
|
|
setStatus('Unavailable', 'var(--theme-status-danger)');
|
||
|
|
updateLiveContent({
|
||
|
|
kind: 'calendar_agenda',
|
||
|
|
subtitle: subtitleEl.textContent || null,
|
||
|
|
tool_name: configuredToolName || 'mcp_home_assistant_calendar_get_events',
|
||
|
|
calendar_names: calendarNames,
|
||
|
|
range_label: rangeEl.textContent || null,
|
||
|
|
status: 'Unavailable',
|
||
|
|
updated_at: errorText,
|
||
|
|
event_count: 0,
|
||
|
|
events: [],
|
||
|
|
error: errorText,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
host.setRefreshHandler(() => {
|
||
|
|
void refresh();
|
||
|
|
});
|
||
|
|
void refresh();
|
||
|
|
__setInterval(() => { void refresh(); }, refreshMs);
|
||
|
|
|
||
|
|
return {
|
||
|
|
destroy() {
|
||
|
|
host.setRefreshHandler(null);
|
||
|
|
host.setLiveContent(null);
|
||
|
|
host.clearSelection();
|
||
|
|
for (const cleanup of __cleanup.splice(0)) cleanup();
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|