refactor(web): delete dead routes, JS files and duplicate definitions (#609)

* refactor(web): drop validators nothing calls

escape_html, validate_image_url, validate_font_awesome_class,
validate_mime_type, validate_numeric_range, validate_string_length and
sanitize_plugin_config had no callers outside their own tests. Only
validate_file_upload (fonts upload) is imported by the web interface.

dedup_unique_arrays is kept: its one caller in save_plugin_config was
removed by the unrelated sync PR (#330), which looks accidental.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(api): remove the music-auth and of-the-day JSON routes

POST /plugins/authenticate/spotify and /plugins/authenticate/ytm had no
caller but their tests: the music plugin authenticates through its
web_ui_actions (authenticate_spotify.py / authenticate_ytm.py) via
/plugins/action.

POST /plugins/of-the-day/json/upload and /json/delete looked the plugin
up by the id ledmatrix-of-the-day (its manifest id is of-the-day), were
reachable only from a file_type "json" upload field that no schema
declares, and put the plugin directory on sys.path per request to
import scripts.update_config. of-the-day manages its files through
plugin-file-manager and its own web_ui_actions.

The of-the-day branch of GET /plugins/config stays: it matches the real
manifest id and still merges the on-disk category files into the form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(api): read managers only from the blueprints

api_v3/__init__.py and pages_v3.py declared module globals
(plugin_store_manager, saved_repositories_manager, schema_manager,
operation_queue, plugin_state_manager, operation_history, sync_manager,
config_manager, plugin_manager) that nothing assigns: app.py sets the
managers as attributes on the Blueprint objects, and every route reads
them there. The one reader, backup restore's fallback to the module
plugin_store_manager, could only ever fall back to None.

_ensure_cache_manager() built a second CacheManager in the web process
instead of using the one app.py puts on api_v3. The display routes now
read api_v3.cache_manager, creating it on the blueprint only when
nothing set it (the same None handling as the /cache routes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(web): drop run.sh and the unused log_config_change

web_interface/run.sh was referenced only by web_interface/README.md;
the service starts the UI through scripts/utils/start_web_conditionally.py
and the README already documents `python3 web_interface/start.py`.
log_config_change() in web_interface/logging_config.py was never called.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(web): delete unreferenced store_manager.js, diff_viewer.js, htmx-sse.js

- js/plugins/store_manager.js (window.PluginStoreManager) and
  js/config/diff_viewer.js (window.ConfigDiffViewer) were loaded on every
  page but nothing reads either global.
- htmx-sse.js (plus its CDN fallback) was loaded after HTMX, but no
  template or plugin page uses sse-connect / hx-ext="sse": the live
  streams run through LEDStreams in app-shell.js.

js/plugins/state_manager.js stays: install_manager.js's updateAll()
reads and refreshes window.PluginStateManager.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(web): remove app.js helpers nothing calls

- hexToRgb, rgbToHex, validateForm, uploadFont and switchTab (whose
  'switch-tab' event had no listener) have no caller in the templates,
  static JS or the plugin monorepo.
- installPlugin: plugins_manager.js (loaded last) assigns
  window.installPlugin, and its own store cards are the only callers.
- The showNotification fallback could never install: app-shell.js is
  deferred ahead of app.js and defines the same fallback at top level.
- performanceMonitor only logged with ?debug=perf and read an unset
  this.measures; the marks it took on every load had no reader.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(web): drop app-shell.js refreshPlugin

A top-level function in app-shell.js, so a window global, but nothing
calls it (no inline handler, no window lookup, no string-built name).

The other plugin actions in that block stay. updatePlugin is the live
window.updatePlugin: plugins_manager.js only installs its own copy when
none exists. uninstallPlugin/pollUninstallOperation, updateAllPlugins,
executePluginAction and toggleNestedSection are replaced by later
deferred scripts, but a click that lands while those scripts are still
downloading reaches the app-shell copies, so removing them is not a
pure no-op.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(web): remove definitions plugins_manager.js always overrides

All of these are replaced before anything can call them, checked
against the load order in base.html and the live window.* values:

- openOnDemandModal/requestOnDemandStop stubs: the IIFE later in the
  same script assigns the real functions synchronously.
- updatePlugin and uninstallPlugin stubs (`window.X || stub`): app-shell.js
  already defined both, so the fallback never installed. Same for the
  later updatePlugin override, gated on the live function containing
  '[UPDATE]', which app-shell.js's never does.
- The first addArrayObjectItem/removeArrayObjectItem: reassigned by the
  top-level copies after the IIFE.
- The first `function formatDate` in the IIFE: a later declaration of
  the same name in the same scope wins.
- deleteUploadedImage, getCurrentImages, showUploadProgress,
  formatFileSize and getScheduleSummary: character-for-character
  copies of js/widgets/file-upload.js, which stays the owner.
- `typeof X === 'undefined'` fallbacks and `typeof X !== 'undefined'`
  re-exports after the IIFE: always false, or a self-assignment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(web): render the shell directly and delete index.html

index.html extended base.html with {% block content %}, but base.html
defines no blocks, so none of index.html ever rendered: rendering both
with jinja2 gives byte-identical output. index() still loaded the config,
read config.json and config_secrets.json raw and json.dumps'd them on
every page load for variables base.html never reads, and flashed errors
that base.html never shows. It now renders base.html with no context.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): stop htmx-config.js replacing console.error and console.warn

It swapped both globals for filters that dropped any error mentioning
insertBefore / "Cannot read properties of null" when "htmx" appeared in
the message or stack, and a list of Permissions-Policy warnings. That
hid real errors from every script on the page, and made every logged
error and warning report htmx-config.js as its source. The beforeSwap
target validation above it, which prevents the insertBefore errors in
the first place, stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(web): quiet the widget load announcements and debug logs

About 30 lines hit the console on every page load: one "... widget
registered" per widget file, one "[WidgetRegistry] Registered widget: X"
per registration, plus the registry, base widget and plugin loader
announcing themselves. The load-time announcements are removed; the
per-call ones (registry register, plugin widget loads, "Render called")
now go through the page's debugLog switch (localStorage.pluginDebug),
guarded because the widgets also load in node tests without it.
fonts.html and wifi.html debug logging goes through debugLog as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(api): drop the removed music-auth and of-the-day JSON routes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-23 12:36:53 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent a231d4dbc7
commit a8b3e86775
62 changed files with 73 additions and 3149 deletions
-17
View File
@@ -2572,23 +2572,6 @@
// Assign to window for global access
window.uninstallPlugin = uninstallPlugin;
async function refreshPlugin(pluginId) {
try {
// Switch to the plugin manager tab briefly to refresh
const originalTab = app.activeTab;
app.activeTab = 'plugins';
// Wait a moment then switch back
setTimeout(() => {
app.activeTab = originalTab;
app.showNotification(`Refreshed ${pluginId}`, 'success');
}, 100);
} catch (error) {
app.showNotification('Error refreshing plugin: ' + error.message, 'error');
}
}
// Format commit information for display
function formatCommitInfo(commit, branch) {
if (!commit && !branch) return 'Unknown';
@@ -1,300 +0,0 @@
/**
* Configuration diff viewer.
*
* Shows what changed in configuration before saving.
*/
const ConfigDiffViewer = {
/**
* Original configuration state (before changes).
*/
originalConfigs: new Map(),
/**
* Store original configuration for a plugin.
*
* @param {string} pluginId - Plugin identifier
* @param {Object} config - Original configuration
*/
storeOriginal(pluginId, config) {
this.originalConfigs.set(pluginId, JSON.parse(JSON.stringify(config)));
},
/**
* Get original configuration for a plugin.
*
* @param {string} pluginId - Plugin identifier
* @returns {Object|null} Original configuration
*/
getOriginal(pluginId) {
return this.originalConfigs.get(pluginId) || null;
},
/**
* Clear stored original configuration.
*
* @param {string} pluginId - Plugin identifier
*/
clearOriginal(pluginId) {
this.originalConfigs.delete(pluginId);
},
/**
* Compare two configuration objects and return differences.
*
* @param {Object} oldConfig - Old configuration
* @param {Object} newConfig - New configuration
* @returns {Object} Differences object with added, removed, and changed keys
*/
compare(oldConfig, newConfig) {
const differences = {
added: {},
removed: {},
changed: {},
unchanged: {}
};
// Get all keys from both configs
const allKeys = new Set([
...Object.keys(oldConfig || {}),
...Object.keys(newConfig || {})
]);
for (const key of allKeys) {
const oldValue = oldConfig?.[key];
const newValue = newConfig?.[key];
if (!(key in (oldConfig || {}))) {
// Key was added
differences.added[key] = newValue;
} else if (!(key in (newConfig || {}))) {
// Key was removed
differences.removed[key] = oldValue;
} else if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) {
// Key was changed
differences.changed[key] = {
old: oldValue,
new: newValue
};
} else {
// Key unchanged
differences.unchanged[key] = oldValue;
}
}
return differences;
},
/**
* Check if there are any differences.
*
* @param {Object} differences - Differences object from compare()
* @returns {boolean} True if there are changes
*/
hasChanges(differences) {
return Object.keys(differences.added).length > 0 ||
Object.keys(differences.removed).length > 0 ||
Object.keys(differences.changed).length > 0;
},
/**
* Format differences for display.
*
* @param {Object} differences - Differences object
* @returns {string} HTML formatted diff
*/
formatDiff(differences) {
const parts = [];
// Added keys
if (Object.keys(differences.added).length > 0) {
parts.push(`
<div class="mb-4">
<h4 class="text-sm font-semibold text-green-800 mb-2">
<i class="fas fa-plus-circle mr-1"></i>Added
</h4>
<div class="bg-green-50 border border-green-200 rounded p-3">
${Object.entries(differences.added).map(([key, value]) => `
<div class="mb-2">
<code class="text-xs font-mono bg-green-100 px-1 py-0.5 rounded">${this.escapeHtml(key)}</code>
<span class="text-sm text-gray-700 ml-2">=</span>
<pre class="mt-1 text-xs bg-white p-2 rounded border border-green-200 overflow-auto">${this.escapeHtml(JSON.stringify(value, null, 2))}</pre>
</div>
`).join('')}
</div>
</div>
`);
}
// Removed keys
if (Object.keys(differences.removed).length > 0) {
parts.push(`
<div class="mb-4">
<h4 class="text-sm font-semibold text-red-800 mb-2">
<i class="fas fa-minus-circle mr-1"></i>Removed
</h4>
<div class="bg-red-50 border border-red-200 rounded p-3">
${Object.entries(differences.removed).map(([key, value]) => `
<div class="mb-2">
<code class="text-xs font-mono bg-red-100 px-1 py-0.5 rounded">${this.escapeHtml(key)}</code>
<span class="text-sm text-gray-700 ml-2">=</span>
<pre class="mt-1 text-xs bg-white p-2 rounded border border-red-200 overflow-auto line-through text-gray-500">${this.escapeHtml(JSON.stringify(value, null, 2))}</pre>
</div>
`).join('')}
</div>
</div>
`);
}
// Changed keys
if (Object.keys(differences.changed).length > 0) {
parts.push(`
<div class="mb-4">
<h4 class="text-sm font-semibold text-yellow-800 mb-2">
<i class="fas fa-edit mr-1"></i>Changed
</h4>
<div class="bg-yellow-50 border border-yellow-200 rounded p-3">
${Object.entries(differences.changed).map(([key, change]) => `
<div class="mb-3">
<code class="text-xs font-mono bg-yellow-100 px-1 py-0.5 rounded">${this.escapeHtml(key)}</code>
<div class="mt-2 grid grid-cols-2 gap-2">
<div>
<div class="text-xs font-medium text-red-700 mb-1">Old Value:</div>
<pre class="text-xs bg-white p-2 rounded border border-red-200 overflow-auto">${this.escapeHtml(JSON.stringify(change.old, null, 2))}</pre>
</div>
<div>
<div class="text-xs font-medium text-green-700 mb-1">New Value:</div>
<pre class="text-xs bg-white p-2 rounded border border-green-200 overflow-auto">${this.escapeHtml(JSON.stringify(change.new, null, 2))}</pre>
</div>
</div>
</div>
`).join('')}
</div>
</div>
`);
}
if (parts.length === 0) {
return '<div class="text-sm text-gray-600 text-center py-4">No changes detected</div>';
}
return parts.join('');
},
/**
* Show diff modal before saving.
*
* @param {string} pluginId - Plugin identifier
* @param {Object} newConfig - New configuration
* @returns {Promise<boolean>} Promise resolving to true if user confirms, false if cancelled
*/
async showDiffModal(pluginId, newConfig) {
return new Promise((resolve) => {
const original = this.getOriginal(pluginId);
if (!original) {
// No original to compare, proceed without diff
resolve(true);
return;
}
const differences = this.compare(original, newConfig);
if (!this.hasChanges(differences)) {
// No changes, proceed without showing modal
resolve(true);
return;
}
// Create modal
const modalContainer = document.createElement('div');
modalContainer.id = 'config-diff-modal-container';
modalContainer.className = 'fixed inset-0 z-50 overflow-y-auto';
modalContainer.innerHTML = `
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" data-diff-backdrop aria-hidden="true"></div>
<div id="config-diff-modal-panel" class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full" style="position:relative;">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10" aria-hidden="true">
<i class="fas fa-code-branch text-blue-600"></i>
</div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1">
<h3 id="config-diff-modal-title" class="text-lg leading-6 font-medium text-gray-900">Review Configuration Changes</h3>
<div class="mt-4 max-h-96 overflow-y-auto" tabindex="0" role="region" aria-label="Changes to save">
${this.formatDiff(differences)}
</div>
</div>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button type="button" id="config-diff-confirm-btn"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm">
<i class="fas fa-check mr-2" aria-hidden="true"></i>Save Changes
</button>
<button type="button" id="config-diff-cancel-btn"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm">
Cancel
</button>
</div>
</div>
</div>
`;
document.body.appendChild(modalContainer);
let release = null;
let settled = false;
// Single close path: remove the modal, release the focus trap
// (returning focus to where the user was) and settle the promise.
function finish(result) {
if (settled) return;
settled = true;
modalContainer.remove();
if (window.__configDiffResolve === finish) window.__configDiffResolve = undefined;
if (release) release();
resolve(result);
}
// Kept for backwards compatibility with code that calls it directly.
window.__configDiffResolve = finish;
// Attach event listeners
const confirmBtn = modalContainer.querySelector('#config-diff-confirm-btn');
const cancelBtn = modalContainer.querySelector('#config-diff-cancel-btn');
const backdrop = modalContainer.querySelector('[data-diff-backdrop]');
confirmBtn.addEventListener('click', () => finish(true));
cancelBtn.addEventListener('click', () => finish(false));
if (backdrop) backdrop.addEventListener('click', () => finish(false));
if (window.LEDDialog) {
release = window.LEDDialog.trap(modalContainer.querySelector('#config-diff-modal-panel'), {
labelledBy: 'config-diff-modal-title',
initialFocus: confirmBtn,
onEscape: () => finish(false)
});
}
});
},
/**
* Escape HTML to prevent XSS.
*/
escapeHtml(text) {
if (typeof text !== 'string') {
text = String(text);
}
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
};
// Export
if (typeof module !== 'undefined' && module.exports) {
module.exports = ConfigDiffViewer;
} else {
window.ConfigDiffViewer = ConfigDiffViewer;
}
-61
View File
@@ -53,67 +53,6 @@
}
});
// Suppress HTMX insertBefore errors and other noisy errors - they're harmless but noisy
const originalError = console.error;
const originalWarn = console.warn;
console.error = function(...args) {
const errorStr = args.join(' ');
const errorStack = args.find(arg => arg && typeof arg === 'string' && arg.includes('htmx')) || '';
// Suppress HTMX insertBefore errors (comprehensive check)
// These occur when HTMX tries to swap content but the target element is null
// Usually happens due to timing/race conditions and is harmless
if (errorStr.includes("insertBefore") ||
errorStr.includes("Cannot read properties of null") ||
errorStr.includes("reading 'insertBefore'")) {
// Check if it's from HTMX by looking at stack trace or error string
// Also check the call stack if available
const isHtmxError = errorStr.includes('htmx') ||
errorStack.includes('htmx') ||
args.some(arg => {
if (typeof arg === 'string') {
return arg.includes('htmx');
}
// Check error objects for stack traces
if (arg && typeof arg === 'object' && arg.stack) {
return arg.stack.includes('htmx');
}
return false;
});
if (isHtmxError) {
return; // Suppress - this is a harmless HTMX timing/race condition issue
}
}
// Suppress script execution errors from malformed HTML
if (errorStr.includes("Failed to execute 'appendChild' on 'Node'") ||
errorStr.includes("Failed to execute 'insertBefore' on 'Node'")) {
if (errorStr.includes('Unexpected token')) {
return; // Suppress malformed HTML errors
}
}
originalError.apply(console, args);
};
console.warn = function(...args) {
const warnStr = args.join(' ');
// Suppress Permissions-Policy warnings (harmless browser warnings)
if (warnStr.includes('Permissions-Policy header') ||
warnStr.includes('Unrecognized feature') ||
warnStr.includes('Origin trial controlled feature') ||
warnStr.includes('browsing-topics') ||
warnStr.includes('run-ad-auction') ||
warnStr.includes('join-ad-interest-group') ||
warnStr.includes('private-state-token') ||
warnStr.includes('private-aggregation') ||
warnStr.includes('attribution-reporting')) {
return; // Suppress - these are harmless browser feature warnings
}
originalWarn.apply(console, args);
};
// Handle HTMX errors gracefully with detailed logging
document.body.addEventListener('htmx:responseError', function(event) {
const detail = event.detail;
-356
View File
@@ -1,356 +0,0 @@
/* global htmx */
/*
Server Sent Events Extension
============================
This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions.
*/
(function() {
/** @type {import("../htmx").HtmxInternalApi} */
var api;
htmx.defineExtension("sse", {
/**
* Init saves the provided reference to the internal HTMX API.
*
* @param {import("../htmx").HtmxInternalApi} api
* @returns void
*/
init: function(apiRef) {
// store a reference to the internal API.
api = apiRef;
// set a function in the public API for creating new EventSource objects
if (htmx.createEventSource == undefined) {
htmx.createEventSource = createEventSource;
}
},
/**
* onEvent handles all events passed to this extension.
*
* @param {string} name
* @param {Event} evt
* @returns void
*/
onEvent: function(name, evt) {
switch (name) {
case "htmx:beforeCleanupElement":
var internalData = api.getInternalData(evt.target)
// Try to remove remove an EventSource when elements are removed
if (internalData.sseEventSource) {
internalData.sseEventSource.close();
}
return;
// Try to create EventSources when elements are processed
case "htmx:afterProcessNode":
ensureEventSourceOnElement(evt.target);
registerSSE(evt.target);
}
}
});
///////////////////////////////////////////////
// HELPER FUNCTIONS
///////////////////////////////////////////////
/**
* createEventSource is the default method for creating new EventSource objects.
* it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed.
*
* @param {string} url
* @returns EventSource
*/
function createEventSource(url) {
return new EventSource(url, { withCredentials: true });
}
function splitOnWhitespace(trigger) {
return trigger.trim().split(/\s+/);
}
function getLegacySSEURL(elt) {
var legacySSEValue = api.getAttributeValue(elt, "hx-sse");
if (legacySSEValue) {
var values = splitOnWhitespace(legacySSEValue);
for (var i = 0; i < values.length; i++) {
var value = values[i].split(/:(.+)/);
if (value[0] === "connect") {
return value[1];
}
}
}
}
function getLegacySSESwaps(elt) {
var legacySSEValue = api.getAttributeValue(elt, "hx-sse");
var returnArr = [];
if (legacySSEValue != null) {
var values = splitOnWhitespace(legacySSEValue);
for (var i = 0; i < values.length; i++) {
var value = values[i].split(/:(.+)/);
if (value[0] === "swap") {
returnArr.push(value[1]);
}
}
}
return returnArr;
}
/**
* registerSSE looks for attributes that can contain sse events, right
* now hx-trigger and sse-swap and adds listeners based on these attributes too
* the closest event source
*
* @param {HTMLElement} elt
*/
function registerSSE(elt) {
// Find closest existing event source
var sourceElement = api.getClosestMatch(elt, hasEventSource);
if (sourceElement == null) {
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
return null; // no eventsource in parentage, orphaned element
}
// Set internalData and source
var internalData = api.getInternalData(sourceElement);
var source = internalData.sseEventSource;
// Add message handlers for every `sse-swap` attribute
queryAttributeOnThisOrChildren(elt, "sse-swap").forEach(function(child) {
var sseSwapAttr = api.getAttributeValue(child, "sse-swap");
if (sseSwapAttr) {
var sseEventNames = sseSwapAttr.split(",");
} else {
var sseEventNames = getLegacySSESwaps(child);
}
for (var i = 0; i < sseEventNames.length; i++) {
var sseEventName = sseEventNames[i].trim();
var listener = function(event) {
// If the source is missing then close SSE
if (maybeCloseSSESource(sourceElement)) {
return;
}
// If the body no longer contains the element, remove the listener
if (!api.bodyContains(child)) {
source.removeEventListener(sseEventName, listener);
}
// swap the response into the DOM and trigger a notification
swap(child, event.data);
api.triggerEvent(elt, "htmx:sseMessage", event);
};
// Register the new listener
api.getInternalData(child).sseEventListener = listener;
source.addEventListener(sseEventName, listener);
}
});
// Add message handlers for every `hx-trigger="sse:*"` attribute
queryAttributeOnThisOrChildren(elt, "hx-trigger").forEach(function(child) {
var sseEventName = api.getAttributeValue(child, "hx-trigger");
if (sseEventName == null) {
return;
}
// Only process hx-triggers for events with the "sse:" prefix
if (sseEventName.slice(0, 4) != "sse:") {
return;
}
// remove the sse: prefix from here on out
sseEventName = sseEventName.substr(4);
var listener = function() {
if (maybeCloseSSESource(sourceElement)) {
return
}
if (!api.bodyContains(child)) {
source.removeEventListener(sseEventName, listener);
}
}
});
}
/**
* ensureEventSourceOnElement creates a new EventSource connection on the provided element.
* If a usable EventSource already exists, then it is returned. If not, then a new EventSource
* is created and stored in the element's internalData.
* @param {HTMLElement} elt
* @param {number} retryCount
* @returns {EventSource | null}
*/
function ensureEventSourceOnElement(elt, retryCount) {
if (elt == null) {
return null;
}
// handle extension source creation attribute
queryAttributeOnThisOrChildren(elt, "sse-connect").forEach(function(child) {
var sseURL = api.getAttributeValue(child, "sse-connect");
if (sseURL == null) {
return;
}
ensureEventSource(child, sseURL, retryCount);
});
// handle legacy sse, remove for HTMX2
queryAttributeOnThisOrChildren(elt, "hx-sse").forEach(function(child) {
var sseURL = getLegacySSEURL(child);
if (sseURL == null) {
return;
}
ensureEventSource(child, sseURL, retryCount);
});
}
function ensureEventSource(elt, url, retryCount) {
var source = htmx.createEventSource(url);
source.onerror = function(err) {
// Log an error event
api.triggerErrorEvent(elt, "htmx:sseError", { error: err, source: source });
// If parent no longer exists in the document, then clean up this EventSource
if (maybeCloseSSESource(elt)) {
return;
}
// Otherwise, try to reconnect the EventSource
if (source.readyState === EventSource.CLOSED) {
retryCount = retryCount || 0;
var timeout = Math.random() * (2 ^ retryCount) * 500;
window.setTimeout(function() {
ensureEventSourceOnElement(elt, Math.min(7, retryCount + 1));
}, timeout);
}
};
source.onopen = function(evt) {
api.triggerEvent(elt, "htmx:sseOpen", { source: source });
}
api.getInternalData(elt).sseEventSource = source;
}
/**
* maybeCloseSSESource confirms that the parent element still exists.
* If not, then any associated SSE source is closed and the function returns true.
*
* @param {HTMLElement} elt
* @returns boolean
*/
function maybeCloseSSESource(elt) {
if (!api.bodyContains(elt)) {
var source = api.getInternalData(elt).sseEventSource;
if (source != undefined) {
source.close();
// source = null
return true;
}
}
return false;
}
/**
* queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT.
*
* @param {HTMLElement} elt
* @param {string} attributeName
*/
function queryAttributeOnThisOrChildren(elt, attributeName) {
var result = [];
// If the parent element also contains the requested attribute, then add it to the results too.
if (api.hasAttribute(elt, attributeName)) {
result.push(elt);
}
// Search all child nodes that match the requested attribute
elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "]").forEach(function(node) {
result.push(node);
});
return result;
}
/**
* @param {HTMLElement} elt
* @param {string} content
*/
function swap(elt, content) {
api.withExtensions(elt, function(extension) {
content = extension.transformResponse(content, null, elt);
});
var swapSpec = api.getSwapSpecification(elt);
var target = api.getTarget(elt);
var settleInfo = api.makeSettleInfo(elt);
api.selectAndSwap(swapSpec.swapStyle, target, elt, content, settleInfo);
settleInfo.elts.forEach(function(elt) {
if (elt.classList) {
elt.classList.add(htmx.config.settlingClass);
}
api.triggerEvent(elt, 'htmx:beforeSettle');
});
// Handle settle tasks (with delay if requested)
if (swapSpec.settleDelay > 0) {
setTimeout(doSettle(settleInfo), swapSpec.settleDelay);
} else {
doSettle(settleInfo)();
}
}
/**
* doSettle mirrors much of the functionality in htmx that
* settles elements after their content has been swapped.
* TODO: this should be published by htmx, and not duplicated here
* @param {import("../htmx").HtmxSettleInfo} settleInfo
* @returns () => void
*/
function doSettle(settleInfo) {
return function() {
settleInfo.tasks.forEach(function(task) {
task.call();
});
settleInfo.elts.forEach(function(elt) {
if (elt.classList) {
elt.classList.remove(htmx.config.settlingClass);
}
api.triggerEvent(elt, 'htmx:afterSettle');
});
}
}
function hasEventSource(node) {
return api.getInternalData(node).sseEventSource != null;
}
})();
@@ -1,101 +0,0 @@
/**
* Plugin store management.
*
* Handles plugin store browsing, searching, and installation.
*/
const PluginStoreManager = {
/**
* Cache for plugin store data.
*/
cache: null,
cacheTimestamp: null,
CACHE_DURATION: 5 * 60 * 1000, // 5 minutes
/**
* Load plugin store.
*
* @param {boolean} useCache - Whether to use cached data
* @returns {Promise<Array>} List of plugins
*/
async loadStore(useCache = true) {
// Check cache
if (useCache && this.cache && this.cacheTimestamp) {
const age = Date.now() - this.cacheTimestamp;
if (age < this.CACHE_DURATION) {
return this.cache;
}
}
try {
const plugins = await window.PluginAPI.getPluginStore();
this.cache = plugins;
this.cacheTimestamp = Date.now();
return plugins;
} catch (error) {
if (window.errorHandler) {
window.errorHandler.displayError(error, 'Failed to load plugin store');
}
throw error;
}
},
/**
* Search plugin store.
*
* @param {string} query - Search query
* @returns {Promise<Array>} Filtered list of plugins
*/
async searchStore(query) {
const plugins = await this.loadStore();
if (!query || query.trim() === '') {
return plugins;
}
const lowerQuery = query.toLowerCase();
return plugins.filter(plugin => {
const name = (plugin.name || '').toLowerCase();
const description = (plugin.description || '').toLowerCase();
const author = (plugin.author || '').toLowerCase();
const category = (plugin.category || '').toLowerCase();
return name.includes(lowerQuery) ||
description.includes(lowerQuery) ||
author.includes(lowerQuery) ||
category.includes(lowerQuery);
});
},
/**
* Install plugin from store.
*
* @param {string} pluginId - Plugin identifier
* @param {string} branch - Optional branch name to install from
* @returns {Promise<Object>} Installation result
*/
async installPlugin(pluginId, branch = null) {
try {
const result = await window.PluginAPI.installPlugin(pluginId, branch);
// Clear cache
this.cache = null;
this.cacheTimestamp = null;
return result;
} catch (error) {
if (window.errorHandler) {
window.errorHandler.displayError(error, `Failed to install plugin ${pluginId}`);
}
throw error;
}
}
};
// Export
if (typeof module !== 'undefined' && module.exports) {
module.exports = PluginStoreManager;
} else {
window.PluginStoreManager = PluginStoreManager;
}
@@ -32,7 +32,7 @@
version: '2.0.0',
render: function(container, config, value, options) {
console.log('[ArrayTableWidget] Render called (server-side rendered)');
if (window.debugLog) window.debugLog('[ArrayTableWidget] Render called (server-side rendered)');
},
getValue: function(fieldId) {
@@ -918,6 +918,4 @@
} else {
initArrayTableButtons();
}
console.log('[ArrayTableWidget] Array table widget registered (v2.0.0)');
})();
@@ -201,6 +201,4 @@
if (typeof window !== 'undefined') {
window.BaseWidget = BaseWidget;
}
console.log('[BaseWidget] Base widget class loaded');
})();
@@ -31,7 +31,7 @@
render: function(container, config, value, options) {
// For now, widgets are server-side rendered
// This function is a placeholder for future client-side rendering
console.log('[CheckboxGroupWidget] Render called (server-side rendered)');
if (window.debugLog) window.debugLog('[CheckboxGroupWidget] Render called (server-side rendered)');
},
/**
@@ -116,6 +116,4 @@
});
hiddenInput.dispatchEvent(event);
};
console.log('[CheckboxGroupWidget] Checkbox group widget registered');
})();
@@ -258,6 +258,4 @@
}
}
});
console.log('[ColorPickerWidget] Color picker widget registered');
})();
@@ -31,7 +31,7 @@
render: function(container, config, value, options) {
// For now, widgets are server-side rendered
// This function is a placeholder for future client-side rendering
console.log('[CustomFeedsWidget] Render called (server-side rendered)');
if (window.debugLog) window.debugLog('[CustomFeedsWidget] Render called (server-side rendered)');
},
/**
@@ -523,6 +523,4 @@
event.target.value = '';
});
};
console.log('[CustomFeedsWidget] Custom feeds widget registered');
})();
@@ -189,6 +189,4 @@
}
}
});
console.log('[DatePickerWidget] Date picker widget registered');
})();
@@ -254,6 +254,4 @@
// Expose DAYS constant for external use
window.LEDMatrixWidgets.get('day-selector').DAYS = DAYS;
window.LEDMatrixWidgets.get('day-selector').DAY_LABELS = DAY_LABELS;
console.log('[DaySelectorWidget] Day selector widget registered');
})();
@@ -167,6 +167,4 @@
}
}
});
console.log('[EmailInputWidget] Email input widget registered');
})();
@@ -197,6 +197,4 @@
}
}
});
console.log('[ColorPickerWidget] Color picker widget registered (example)');
})();
@@ -286,6 +286,4 @@
}
}
});
console.log('[FileUploadSingleWidget] File upload single widget registered');
})();
@@ -32,7 +32,7 @@
render: function(container, config, value, options) {
// For now, widgets are server-side rendered
// This function is a placeholder for future client-side rendering
console.log('[FileUploadWidget] Render called (server-side rendered)');
if (window.debugLog) window.debugLog('[FileUploadWidget] Render called (server-side rendered)');
},
/**
@@ -1136,6 +1136,4 @@
window.updateImageList(fieldId, currentImages);
}
};
console.log('[FileUploadWidget] File upload widget registered');
})();
@@ -311,6 +311,4 @@
generateDisplayName: generateDisplayName
}
});
console.log('[FontSelectorWidget] Font selector widget registered');
})();
@@ -192,6 +192,4 @@
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
console.log('[GoogleCalendarPickerWidget] registered');
})();
@@ -828,8 +828,5 @@
getValue() { return null; },
setValue() {}
});
console.log('[JsonFileManager] Registered with LEDMatrixWidgets');
} else {
console.log('[JsonFileManager] Loaded (LEDMatrixWidgets registry not available)');
}
})();
@@ -472,6 +472,4 @@
} else {
flushPending();
}
console.log('[NotificationWidget] Notification widget registered');
})();
@@ -239,6 +239,4 @@
}
}
});
console.log('[NumberInputWidget] Number input widget registered');
})();
@@ -316,6 +316,4 @@
}
}
});
console.log('[PasswordInputWidget] Password input widget registered');
})();
@@ -866,6 +866,4 @@
getValue: function () { return null; }, // file ops are immediate; nothing to submit
setValue: function (fieldId) { loadFiles(fieldId); }
});
console.log('[PluginFileManager] plugin-file-manager widget registered');
})();
@@ -29,7 +29,7 @@
// Check if widget is already registered
if (this.has(widgetName)) {
console.log(`[PluginWidgetLoader] Widget ${widgetName} already registered`);
if (window.debugLog) window.debugLog(`[PluginWidgetLoader] Widget ${widgetName} already registered`);
return;
}
@@ -45,7 +45,7 @@
try {
// Dynamic import of plugin widget
await import(widgetPath);
console.log(`[PluginWidgetLoader] Loaded plugin widget: ${pluginId}/${widgetName} from ${widgetPath}`);
if (window.debugLog) window.debugLog(`[PluginWidgetLoader] Loaded plugin widget: ${pluginId}/${widgetName} from ${widgetPath}`);
// Verify widget was registered
if (this.has(widgetName)) {
@@ -124,6 +124,4 @@
return loadedWidgets;
};
console.log('[PluginWidgetLoader] Plugin widget loader initialized');
})();
@@ -144,6 +144,4 @@
}
}
});
console.log('[RadioGroupWidget] Radio group widget registered');
})();
@@ -50,7 +50,7 @@
this._handlers.set(widgetName, definition.handlers);
}
console.log(`[WidgetRegistry] Registered widget: ${widgetName}`);
if (window.debugLog) window.debugLog(`[WidgetRegistry] Registered widget: ${widgetName}`);
return true;
},
@@ -212,6 +212,4 @@
})));
};
}
console.log('[WidgetRegistry] Widget registry initialized');
})();
@@ -128,6 +128,4 @@
}
}
});
console.log('[SelectDropdownWidget] Select dropdown widget registered');
})();
@@ -173,6 +173,4 @@
}
}
});
console.log('[SliderWidget] Slider widget registered');
})();
@@ -768,6 +768,4 @@
return null;
}
});
console.log('[StyleEditor] widget registered');
})();
@@ -239,6 +239,4 @@
}
}
});
console.log('[TextInputWidget] Text input widget registered');
})();
@@ -175,6 +175,4 @@
}
}
});
console.log('[TextareaWidget] Textarea widget registered');
})();
@@ -166,6 +166,4 @@
}
}
});
console.log('[TimePickerWidget] Time picker widget registered');
})();
@@ -370,6 +370,4 @@
// Expose utility functions for external use
window.LEDMatrixWidgets.get('time-range').parseTimeToMinutes = parseTimeToMinutes;
window.LEDMatrixWidgets.get('time-range').calculateDuration = calculateDuration;
console.log('[TimeRangeWidget] Time range widget registered');
})();
@@ -414,6 +414,4 @@
}, 50);
});
})();
console.log('[TimezoneSelectorWidget] Timezone selector widget registered');
})();
@@ -220,6 +220,4 @@
}
}
});
console.log('[ToggleSwitchWidget] Toggle switch widget registered');
})();
@@ -284,6 +284,4 @@
}
}
});
console.log('[UrlInputWidget] URL input widget registered');
})();