From fe5c1d0d5ebe1b8497639363b8f8ec250c8d7d6b Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Wed, 25 Feb 2026 18:19:32 -0500 Subject: [PATCH] feat(web): add Google Calendar picker widget for dynamic multi-calendar selection (#274) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 * 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 * 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 --------- Co-authored-by: Chuck Co-authored-by: Claude Sonnet 4.6 --- scripts/install_dependencies_apt.py | 8 +- web_interface/blueprints/api_v3.py | 44 ++++ .../v3/js/widgets/google-calendar-picker.js | 197 ++++++++++++++++++ web_interface/templates/v3/base.html | 1 + .../templates/v3/partials/plugin_config.html | 37 ++++ 5 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 web_interface/static/v3/js/widgets/google-calendar-picker.js diff --git a/scripts/install_dependencies_apt.py b/scripts/install_dependencies_apt.py index 481b4bf6..ef019118 100644 --- a/scripts/install_dependencies_apt.py +++ b/scripts/install_dependencies_apt.py @@ -48,7 +48,13 @@ def install_via_apt(package_name): return False def install_via_pip(package_name): - """Install a package via pip with --break-system-packages.""" + """Install a package via pip with --break-system-packages and --prefer-binary. + + --break-system-packages allows pip to install into the system Python on + Debian/Ubuntu-based systems without a virtual environment. + --prefer-binary prefers pre-built wheels over source distributions to avoid + exhausting /tmp space during compilation. + """ try: print(f"Installing {package_name} via pip...") subprocess.check_call([ diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index 5ee11ba9..c49afc24 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -6468,6 +6468,50 @@ def upload_calendar_credentials(): print(error_details) return jsonify({'status': 'error', 'message': str(e)}), 500 +@api_v3.route('/plugins/calendar/list-calendars', methods=['GET']) +def list_calendar_calendars(): + """Return Google Calendars accessible with the currently authenticated credentials.""" + if not api_v3.plugin_manager: + return jsonify({'status': 'error', 'message': 'Plugin manager not available'}), 500 + plugin = api_v3.plugin_manager.get_plugin('calendar') + if not plugin: + return jsonify({'status': 'error', 'message': 'Calendar plugin is not running. Enable it and save config first.'}), 404 + if not hasattr(plugin, 'get_calendars'): + return jsonify({'status': 'error', 'message': 'Installed plugin version does not support calendar listing — update the plugin.'}), 400 + try: + raw = plugin.get_calendars() + import collections.abc + if not isinstance(raw, (list, tuple)): + logger.error('list_calendar_calendars: get_calendars() returned non-sequence type %r', type(raw)) + return jsonify({'status': 'error', 'message': 'Unable to load calendars from the plugin. Please check plugin configuration and try again.'}), 500 + calendars = [] + for cal in raw: + if not isinstance(cal, collections.abc.Mapping): + logger.warning('list_calendar_calendars: skipping malformed calendar entry (type=%r): %r', type(cal), cal) + continue + cal_id = cal.get('id') or cal.get('calendarId', '') + if not isinstance(cal_id, str): + cal_id = str(cal_id) if cal_id else '' + if not cal_id: + logger.warning('list_calendar_calendars: skipping calendar entry with empty id: %r', cal) + continue + summary = cal.get('summary', '') + if not isinstance(summary, str): + summary = str(summary) if summary else '' + calendars.append({ + 'id': cal_id, + 'summary': summary, + 'primary': bool(cal.get('primary', False)), + }) + return jsonify({'status': 'success', 'calendars': calendars}) + except (ValueError, TypeError, KeyError): + logger.exception('list_calendar_calendars: error normalising calendar data for plugin=calendar') + return jsonify({'status': 'error', 'message': 'Unable to load calendars from the plugin. Please check plugin configuration and try again.'}), 500 + except Exception: + logger.exception('list_calendar_calendars: unexpected error for plugin=calendar') + return jsonify({'status': 'error', 'message': 'Unable to load calendars from the plugin. Please check plugin configuration and try again.'}), 500 + + @api_v3.route('/plugins/assets/delete', methods=['POST']) def delete_plugin_asset(): """Delete an asset file for a plugin""" diff --git a/web_interface/static/v3/js/widgets/google-calendar-picker.js b/web_interface/static/v3/js/widgets/google-calendar-picker.js new file mode 100644 index 00000000..21e772b9 --- /dev/null +++ b/web_interface/static/v3/js/widgets/google-calendar-picker.js @@ -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 = ' 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 = ' 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 = ' Load My Calendars'; + showError(listContainer, data.message || 'Failed to load calendars.'); + return; + } + btn.innerHTML = ' Refresh Calendars'; + renderCheckboxes(fieldId, data.calendars, hiddenInput, listContainer, summary); + }) + .catch(function (err) { + btn.disabled = false; + btn.innerHTML = ' 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 = ' ' + escapeHtml(message); + container.appendChild(p); + } + + function escapeHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + console.log('[GoogleCalendarPickerWidget] registered'); +})(); diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index 26849e2d..8e161a86 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -4992,6 +4992,7 @@ + diff --git a/web_interface/templates/v3/partials/plugin_config.html b/web_interface/templates/v3/partials/plugin_config.html index 6dae3061..64607d6f 100644 --- a/web_interface/templates/v3/partials/plugin_config.html +++ b/web_interface/templates/v3/partials/plugin_config.html @@ -304,6 +304,43 @@ {# Sentinel hidden input with bracket notation to allow clearing array to [] when all unchecked #} {# This ensures the field is always submitted, even when all checkboxes are unchecked #} + {% elif x_widget == 'google-calendar-picker' %} + {# Google Calendar picker — dynamically loads calendars from the API #} + {# Normalise: if value is a string (legacy comma-separated), split it; otherwise fall back to default or [] #} + {% if value is not none and value is string and value %} + {% set array_value = value.split(',') | map('trim') | list %} + {% elif value is not none and value is iterable and value is not string %} + {% set array_value = value %} + {% elif prop.default is defined and prop.default is string and prop.default %} + {% set array_value = prop.default.split(',') | map('trim') | list %} + {% elif prop.default is defined and prop.default is iterable and prop.default is not string %} + {% set array_value = prop.default %} + {% else %} + {% set array_value = [] %} + {% endif %} +
+ {% elif x_widget == 'day-selector' %} {# Day selector widget for selecting days of the week #} {% set array_value = value if value is not none and value is iterable and value is not string else (prop.default if prop.default is defined and prop.default is iterable and prop.default is not string else []) %}