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:
Chuck
2026-02-25 18:19:32 -05:00
committed by GitHub
parent 3e50fa5b1d
commit fe5c1d0d5e
5 changed files with 286 additions and 1 deletions

View File

@@ -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"""

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
console.log('[GoogleCalendarPickerWidget] registered');
})();

View File

@@ -4992,6 +4992,7 @@
<script src="{{ url_for('static', filename='v3/js/widgets/checkbox-group.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/custom-feeds.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/array-table.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/google-calendar-picker.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/day-selector.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/time-range.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/schedule-picker.js') }}" defer></script>

View File

@@ -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 #}
<input type="hidden" name="{{ full_key }}[]" value="">
{% 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 %}
<div id="{{ field_id }}_container" class="google-calendar-picker-container mt-1"></div>
<script>
(function() {
var MAX_RETRIES = 40;
var retries = 0;
function initWidget() {
if (retries >= MAX_RETRIES) { return; }
retries++;
if (!window.LEDMatrixWidgets) { setTimeout(initWidget, 50); return; }
var widget = window.LEDMatrixWidgets.get('google-calendar-picker');
if (!widget) { setTimeout(initWidget, 50); return; }
var container = document.getElementById('{{ field_id }}_container');
if (!container) return;
var value = {{ array_value|tojson|safe }};
widget.render(container, {}, value, { fieldId: '{{ field_id }}', pluginId: '{{ plugin_id }}', name: '{{ full_key }}' });
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initWidget);
} else {
setTimeout(initWidget, 50);
}
})();
</script>
{% 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 []) %}