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 []) %}