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