mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-10 21:03:01 +00:00
feat(web): add Google Calendar picker widget for dynamic multi-calendar selection (#274)
* fix(install): add --prefer-binary to pip installs to avoid /tmp exhaustion timezonefinder (~54 MB) includes large timezone polygon data files that pip unpacks into /tmp during installation. On Raspberry Pi, the default tmpfs /tmp size (often ~half of RAM) can be too small, causing the install to fail with an out-of-space error. Adding --prefer-binary tells pip to prefer pre-built binary wheels over source distributions. Since timezonefinder and most other packages publish wheels on PyPI (and piwheels.org has ARM wheels), this avoids the large temporary /tmp extraction and speeds up installs generally. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(timezone): use America/New_York instead of EST for ESPN API date queries EST is a fixed UTC-5 offset that does not observe daylight saving time, causing the ESPN API date to be off by one hour during EDT (March–November). America/New_York correctly handles DST transitions. The ESPN scoreboard API anchors its schedule calendar to Eastern US time, so this Eastern timezone is intentionally kept for the API date — it is not user-configurable. Game time display is converted separately to the user's configured timezone. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(web): add Google Calendar picker widget for dynamic calendar selection Adds a new google-calendar-picker widget and API endpoint that lets users load their available Google Calendars by name and check the ones they want, instead of manually typing calendar IDs. - GET /api/v3/plugins/calendar/list-calendars — calls plugin.get_calendars() and returns all accessible calendars with id, summary, and primary flag - google-calendar-picker.js — new widget: "Load My Calendars" button renders a checklist; selections update a hidden comma-separated input for form submit - plugin_config.html — handles x-widget: google-calendar-picker in array branch - base.html — loads the new widget script Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(web): address PR review findings in google-calendar-picker - api_v3.py: replace broad except block with specific exception handling, log full traceback via module logger, normalize/validate get_calendars() output to stable {id,summary,primary} objects, return opaque user-friendly error message instead of leaking str(e) - google-calendar-picker.js: fix button label only updating to "Refresh Calendars" on success (restore original label on error); update summary paragraph via syncHiddenAndSummary() on every checkbox change so UI stays in sync with hidden input; pass summary element through loadCalendars and renderCheckboxes instead of re-querying DOM - plugin_config.html: bound initWidget retry loop with MAX_RETRIES=40 to prevent infinite timers; normalize legacy comma-separated string values to arrays before passing to widget.render so pre-existing config populates correctly - install_dependencies_apt.py: update install_via_pip docstring to document both --break-system-packages and --prefer-binary flags Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(web): harden list_calendar_calendars input validation - Remove unused `as e` binding from ValueError/TypeError/KeyError except clause - Replace hasattr(__iter__) with isinstance(list|tuple) so non-sequence returns are rejected before iteration - Validate each calendar entry is a collections.abc.Mapping; skip and warn on malformed items rather than propagating a TypeError - Coerce id/summary to str safely if not already strings Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(web): skip calendar entries with empty id in list_calendar_calendars After coercing cal_id to str, check it is non-empty before appending to the calendars list so entries with no usable id are never forwarded to the client. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
197
web_interface/static/v3/js/widgets/google-calendar-picker.js
Normal file
197
web_interface/static/v3/js/widgets/google-calendar-picker.js
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Google Calendar Picker Widget
|
||||
*
|
||||
* Renders a dynamic multi-select checklist of Google Calendars fetched from
|
||||
* /api/v3/plugins/calendar/list-calendars. Selected IDs are stored in a hidden
|
||||
* comma-separated input so the existing backend parser works unchanged.
|
||||
*
|
||||
* @module GoogleCalendarPickerWidget
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
if (typeof window.LEDMatrixWidgets === 'undefined') {
|
||||
console.error('[GoogleCalendarPickerWidget] LEDMatrixWidgets registry not found. Load registry.js first.');
|
||||
return;
|
||||
}
|
||||
|
||||
window.LEDMatrixWidgets.register('google-calendar-picker', {
|
||||
name: 'Google Calendar Picker Widget',
|
||||
version: '1.0.0',
|
||||
|
||||
/**
|
||||
* Render the widget into container.
|
||||
* @param {HTMLElement} container
|
||||
* @param {Object} config - schema config (unused)
|
||||
* @param {Array} value - current array of selected calendar IDs
|
||||
* @param {Object} options - { fieldId, pluginId, name }
|
||||
*/
|
||||
render: function (container, config, value, options) {
|
||||
const fieldId = options.fieldId;
|
||||
const name = options.name;
|
||||
const currentIds = Array.isArray(value) ? value : ['primary'];
|
||||
|
||||
// Hidden input — this is what the form submits
|
||||
const hiddenInput = document.createElement('input');
|
||||
hiddenInput.type = 'hidden';
|
||||
hiddenInput.id = fieldId + '_hidden';
|
||||
hiddenInput.name = name;
|
||||
hiddenInput.value = currentIds.join(', ');
|
||||
|
||||
// Current selection summary — kept in sync whenever the hidden value changes
|
||||
const summary = document.createElement('p');
|
||||
summary.id = fieldId + '_summary';
|
||||
summary.className = 'text-xs text-gray-400 mt-1';
|
||||
summary.textContent = 'Currently selected: ' + currentIds.join(', ');
|
||||
|
||||
// "Load My Calendars" button
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.id = fieldId + '_load_btn';
|
||||
btn.className = 'px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md flex items-center gap-1.5';
|
||||
btn.innerHTML = '<i class="fas fa-calendar-alt"></i> Load My Calendars';
|
||||
btn.addEventListener('click', function () {
|
||||
loadCalendars(fieldId, hiddenInput, listContainer, btn, summary);
|
||||
});
|
||||
|
||||
// Status / list area
|
||||
const listContainer = document.createElement('div');
|
||||
listContainer.id = fieldId + '_list';
|
||||
listContainer.className = 'mt-2';
|
||||
|
||||
container.appendChild(btn);
|
||||
container.appendChild(summary);
|
||||
container.appendChild(listContainer);
|
||||
container.appendChild(hiddenInput);
|
||||
},
|
||||
|
||||
getValue: function (fieldId) {
|
||||
const hidden = document.getElementById(fieldId + '_hidden');
|
||||
if (!hidden || !hidden.value.trim()) return [];
|
||||
return hidden.value.split(',').map(s => s.trim()).filter(Boolean);
|
||||
},
|
||||
|
||||
setValue: function (fieldId, values) {
|
||||
const hidden = document.getElementById(fieldId + '_hidden');
|
||||
if (hidden) {
|
||||
hidden.value = (Array.isArray(values) ? values : []).join(', ');
|
||||
}
|
||||
},
|
||||
|
||||
handlers: {}
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetch calendar list from backend and render checkboxes.
|
||||
*/
|
||||
function loadCalendars(fieldId, hiddenInput, listContainer, btn, summary) {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Loading...';
|
||||
listContainer.innerHTML = '';
|
||||
|
||||
fetch('/api/v3/plugins/calendar/list-calendars')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
btn.disabled = false;
|
||||
if (data.status !== 'success') {
|
||||
btn.innerHTML = '<i class="fas fa-calendar-alt"></i> Load My Calendars';
|
||||
showError(listContainer, data.message || 'Failed to load calendars.');
|
||||
return;
|
||||
}
|
||||
btn.innerHTML = '<i class="fas fa-sync-alt"></i> Refresh Calendars';
|
||||
renderCheckboxes(fieldId, data.calendars, hiddenInput, listContainer, summary);
|
||||
})
|
||||
.catch(function (err) {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-calendar-alt"></i> Load My Calendars';
|
||||
showError(listContainer, 'Request failed: ' + err.message);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a checklist of calendars, pre-checking those already in the hidden input.
|
||||
*/
|
||||
function renderCheckboxes(fieldId, calendars, hiddenInput, listContainer, summary) {
|
||||
listContainer.innerHTML = '';
|
||||
|
||||
if (!calendars || calendars.length === 0) {
|
||||
showError(listContainer, 'No calendars found on this account.');
|
||||
return;
|
||||
}
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'mt-2 space-y-1.5 border border-gray-700 rounded-md p-3 bg-gray-800';
|
||||
|
||||
// Track selected IDs — seed from the hidden input so manually-typed IDs are preserved
|
||||
let selectedIds = hiddenInput.value.split(',').map(function (s) { return s.trim(); }).filter(Boolean);
|
||||
|
||||
function syncHiddenAndSummary() {
|
||||
hiddenInput.value = selectedIds.join(', ');
|
||||
summary.textContent = 'Currently selected: ' + (selectedIds.length ? selectedIds.join(', ') : '(none)');
|
||||
}
|
||||
|
||||
calendars.forEach(function (cal) {
|
||||
const isChecked = selectedIds.includes(cal.id);
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.className = 'flex items-center gap-2 cursor-pointer';
|
||||
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.className = 'h-4 w-4 text-blue-600 border-gray-300 rounded';
|
||||
checkbox.value = cal.id;
|
||||
checkbox.checked = isChecked;
|
||||
checkbox.addEventListener('change', function () {
|
||||
if (checkbox.checked) {
|
||||
if (!selectedIds.includes(cal.id)) selectedIds.push(cal.id);
|
||||
} else {
|
||||
selectedIds = selectedIds.filter(function (id) { return id !== cal.id; });
|
||||
}
|
||||
// Ensure at least one calendar is selected
|
||||
if (selectedIds.length === 0) {
|
||||
checkbox.checked = true;
|
||||
selectedIds.push(cal.id);
|
||||
if (window.showNotification) {
|
||||
window.showNotification('At least one calendar must be selected.', 'warning');
|
||||
}
|
||||
}
|
||||
syncHiddenAndSummary();
|
||||
});
|
||||
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'text-sm text-gray-200 flex-1';
|
||||
nameSpan.textContent = cal.summary + (cal.primary ? ' (primary)' : '');
|
||||
|
||||
const idSpan = document.createElement('span');
|
||||
idSpan.className = 'text-xs text-gray-500 font-mono truncate max-w-xs';
|
||||
idSpan.textContent = cal.id;
|
||||
idSpan.title = cal.id;
|
||||
|
||||
label.appendChild(checkbox);
|
||||
label.appendChild(nameSpan);
|
||||
label.appendChild(idSpan);
|
||||
wrapper.appendChild(label);
|
||||
});
|
||||
|
||||
listContainer.appendChild(wrapper);
|
||||
}
|
||||
|
||||
function showError(container, message) {
|
||||
container.innerHTML = '';
|
||||
const p = document.createElement('p');
|
||||
p.className = 'text-xs text-red-400 mt-1 flex items-center gap-1';
|
||||
p.innerHTML = '<i class="fas fa-exclamation-triangle"></i> ' + escapeHtml(message);
|
||||
container.appendChild(p);
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
console.log('[GoogleCalendarPickerWidget] registered');
|
||||
})();
|
||||
Reference in New Issue
Block a user