Files
LEDMatrix/web_interface/static/v3/plugins_manager.js
T
ChuckandClaude Opus 5.5 f6afbdbb15 fix(web): Cache/Logs error mix-up, store errors, tab fallbacks; remove ~2.5k lines of dead JS (#639)
* fix(web): keep Cache and Logs helpers out of each other's way

Both partials declared top-level showError and escapeHtml. Their scripts
run at global scope after every HTMX swap, so whichever tab was opened
last owned window.showError, and a Cache failure after visiting Logs
rendered into the Logs panel (and the other way round). Each script is
now an IIFE; Cache still exports deleteCacheFile for its row buttons.

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

* fix(web): make the HTMX-failure fallbacks for tab panels actually run

- The "HTMX never loaded" fallback read appElement.__x.$data, which is
  Alpine 2. The page ships Alpine 3, so the check was always false and
  the Overview never loaded without HTMX. It now reads Alpine.$data().
- The Overview and WiFi panels used hx-on::htmx:response-error, which
  htmx expands to "htmx:htmx:response-error", an event that never fires.
- loadTabContent sent requests with <body> as the source, so htmx fired
  its events on <body> and no panel's hx-on handler ran at all. The
  panel is now the source. htmx also resolves its promise on a 4xx/5xx,
  and the panel was stamped data-loaded anyway, leaving a skeleton that
  never retried; it is now stamped only when no responseError fired.

loadPluginsDirect, loadOverviewDirect and loadWifiDirect are merged into
one window.loadPartialDirect(id, url), which also runs the partial's
inline scripts before Alpine sees the markup (as htmx-config.js does on
htmx:afterSwap). The ~10 s "htmx never arrived" path in loadTabContent
uses it for every tab instead of four hard-coded ones.

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

* fix(web): store and registry failures no longer wipe the Plugin Manager

showError replaced the whole #plugins-content with an error message, so
one failed store search, custom-registry install or saved-repository
call took the installed list, the store and every control with it, with
no way back short of reloading the tab. Those failures are now error
notifications. The full-panel message is kept only for a first load of
the installed list that failed (nothing to show yet); a failed refresh
of an already-rendered list is a notification too. showSuccess's
fallback branch, which wrote the message into innerHTML unescaped, is
gone: showNotification always exists.

The "Please try refreshing your browser" hint tested for the text
"Failed to Fetch", which no browser produces (Chrome says "Failed to
fetch", Firefox "NetworkError..."), so it never appeared. It now keys on
the failure itself: a TypeError from fetch(), or PluginAPI's
NETWORK_ERROR wrapper around one.

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

* fix(web): escape plugin action and install output on every path

executePluginAction escaped data.message and data.output when an action
failed but put data.message straight into innerHTML when it succeeded,
and set the OAuth step-2 button's innerHTML from the manifest's
step2_button_text. Plugin actions run plugin code, so that is plugin- or
server-controlled markup in the page. Both paths now escape, and the
button label is set with textContent.

The same pattern sat in the install-from-GitHub-URL status lines
(plugin_id, the server's message, and error.message, which can echo a
repository URL) and the custom-registry load error; those are escaped
too.

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

* fix(web): file-upload widget owns the image list and schedule editor

plugins_manager.js loads after the widget bundle, so its older copies of
deleteUploadedFile, updateImageList, hideUploadProgress, formatDate,
openImageSchedule, toggleImageScheduleEnabled, updateImageSchedule{Mode,
Time,Day} and updateCheckboxGroupData replaced the widget's. They are
deleted; the widget files are the only definitions.

Before switching over, the two sets were diffed and fixed so nothing
regresses:

- The old copy labelled the schedule/delete buttons for screen readers
  and lazy-loaded thumbnails; the widget now does both.
- The schedule button did nothing on a card rendered by
  plugin_config.html whenever the image id is a UUID (every upload): the
  template turns "-" into "_" in the editor's id, and neither JS copy
  did. Both now use the template's rule.
- The widget's "keep the open editor open" copied the editor's innerHTML
  into the new list. That dropped its event listeners and showed the old
  values, so after the first change the editor looked live but ignored
  input. A schedule edit now saves to the hidden input and updates the
  card's summary in place without re-rendering the list; a list re-render
  (upload, delete) rebuilds an open editor from the data. Editor controls
  are routed by one delegated change listener, so there are no
  per-element listeners to lose.
- The old deleteUploadedFile had a JSON branch that removed a
  #file_<id> element and skipped the re-render. No template or script
  renders such an element, and JSON uploads are listed through
  updateImageList like images, so re-rendering (the widget's behaviour) is
  the consistent one; the branch was not carried over.
- The template always renders the summary line (".image-schedule-summary",
  "Always shown" when unscheduled) so an edit has a line to update.

The inline-handler test evaluated plugins_manager.js's updateImageList;
test_file_upload_widget.js now covers the widget's list and editor.

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

* refactor(web): delete the unused handleCredentialsUpload

Its last caller went when plugin_config.html switched credential uploads
to the file-upload widget's handleSingleFileSelect. Nothing in the web
UI, the tests or the plugin monorepo references it.

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

* refactor(web): delete dead and shadowed front-end code

Nothing calls any of these (checked across web_interface/, test/ and the
ledmatrix-plugins monorepo, including hx-*/x-*/onclick attributes):

- app-shell.js: the Alpine methods refreshPlugins (it called a
  nonexistent this.searchPluginStore), loadPluginConfig,
  savePluginConfig, getSchemaPropertyType, escapeCssSelector,
  formatCommitInfo and formatDateInfo, and the top-level copies of
  savePluginConfig, getSchemaPropertyType, escapeCssSelector,
  formatCommitInfo, formatDateInfo and togglePluginFromTab. Plugin config
  forms save through hx-post in plugin_config.html.
- window.reconnectSSE (app-shell.js); window.updateArrayTableAddButtonState
  (array-table.js).
- toggleNestedSection, defined twice (app-shell.js and
  plugins_manager.js) and called from nowhere.
- plugins_manager.js: the window.initializePlugins wrapper around an
  IIFE-local origInit that was always undefined, and __pluginDomReady,
  which was written but never read.
- display.html's fixInvalidNumberInputs fallback: app-shell.js defines it
  before any partial loads.
- base.html's window.loadCodeMirror and the two CodeMirror stylesheet
  preloads, and the .CodeMirror rules in plugins.html. The raw JSON
  editor is a plain textarea.

Also deleted: app-shell.js definitions that a later script always
replaced, so they never ran: executePluginAction (plugins_manager.js
assigns its own), uninstallPlugin and its pollUninstallOperation
(plugins_manager.js), and updateAllPlugins (install_manager.js).

vendor/codemirror stays: test/test_web_smoke.py still requests
codemirror.min.js as a sample static asset.

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

* refactor(web): call showNotification without checking it exists

app-shell.js defines window.showNotification (a stand-in that queues
until the notification widget loads) and base.html runs it, deferred,
before every other script that notifies: app.js, the utilities, the
widget bundle, plugins_manager.js, and all partials, which HTMX loads
after the page. The 81 `typeof showNotification === 'function'` /
`!== 'undefined'` checks, the `window.showNotification || console.log`
and `|| alert` fallbacks, and their else branches (alert(), console
output, and schedule.html's own hand-built toast) could never take the
fallback path. They are removed, as is fonts.html's second copy of the
queueing stand-in.

The stand-in in app-shell.js keeps its guard (it must not replace the
widget's implementation if load order ever changes), and BaseWidget's
public notify()/getNotificationFunction() keep their shape for widgets
that plugins ship.

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

* refactor(web): one HTML escaper, window.LEDEscape

About 30 files each carried their own escapeHtml / escapeAttr / escHtml /
_esc / escapeJs. They disagreed: several (notification.js, display.html's
escapeHtml, operation_history.html, the app() stub) did not escape
quotes, google-calendar-picker.js and tools.html's escHtml left ' alone,
and some turned 0 into ''. Most were fine only because the quote-safe
widget copies were preferred at runtime.

window.LEDEscape now lives at the top of app-early.js, a blocking script
in <head>, so it exists before any other script runs:

  html(v)          & < > " ' as entities, null/undefined as ''
  attr(v)          the same, for call sites that want to say "attribute"
  jsStringAttr(v)  a JS string literal safe inside an inline handler

Every former copy is now a one-line name for it (kept so call sites do
not change), widgets included, with no fallback. plugins_manager.js
loses its four escapeJs wrappers (callers use jsStringAttr), the
duplicate escapeAttr and escapeHtml inside renderInstalledCards and
renderCustomRegistryPlugins, and the window.escapeHtml /
window.escapeAttribute exports, which nothing read.
addArrayObjectItem's fallback markup (with a sixth hand-written escape
chain) is gone too: window.renderArrayObjectItem is defined earlier in
the same file, so the fallback could not run. The unused escapeHtml
methods on the Alpine app (app-early.js stub and app-shell.js) are
deleted.

test_html_escaping.js now runs LEDEscape and every remaining name for it,
and fails if a hand-rolled escaper reappears anywhere in web_interface/.
Suites that evaluate slices of plugins_manager.js or widget files load
LEDEscape from app-early.js through test/js/led_escape.js.

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

* fix(web): stop htmx re-running partial scripts after every tab load

htmx-config.js runs each swapped-in <script> itself on htmx:afterSwap
and meant to turn htmx's own script handling off with
htmx.config.allowScriptTags = false. It did that once, while setting up,
but base.html loads htmx with a dynamic <script>, so htmx was not defined
yet and the setting never applied. On every tab load htmx then tried to
run each script again in its settle phase, found it already replaced
(no parent node) and threw "Cannot read properties of null (reading
'insertBefore')" into the console, which also skipped the rest of that
swap's settle tasks.

The setting is now applied in the afterSwap handler, which always runs
after htmx exists and before htmx settles the same swap.

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

* fix(web): show "--" for a system stat the server could not read

The stats stream and /system/status now send null for a metric they
cannot read (cpu_temp off a Pi, for one) instead of 0. updateSystemStats
built the header and Overview text as value + unit, so a null showed as
"null°C". CPU, memory and temperature, in the header and on the
Overview, now render "--" plus the unit for null or a missing field --
the same placeholder the page starts with, and what tools.html already
shows.

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

* refactor(web): one Alpine accessor and one plugin-list signal

window.getApp() (app-early.js) returns the root <body x-data="app()">
component through Alpine's public Alpine.$data, or null before Alpine
has initialised it. It replaces the private el._x_dataStack[0] reads in
app.js, app-early.js, app-shell.js, settings-search.js, overview.html and
plugins_manager.js, the three local getAppComponent/appData/getAppData
copies, and the Alpine 2 el.__x.$data fallbacks, which Alpine 3 never
provides.

Publishing the installed-plugin list: one load set window.installedPlugins
and dispatched pluginsUpdated twice (loadInstalledPlugins, then
renderInstalledPlugins), then wrote into the Alpine component through
_x_dataStack[0] and called its updatePluginTabs() directly, and
app-early.js's global listener set window.installedPlugins a third time
and called updatePluginTabs() again. Now renderInstalledPlugins is the
one publisher: it sets window.installedPlugins and dispatches
pluginsUpdated once, and the full app()'s listener (app-shell.js) is the
receiver. The app-early.js listener only builds the tab row while the app
is not the full implementation yet. The "grid not loaded yet" case is a
normal state (Plugin Manager tab not opened), so it logs through
pluginLog instead of console.warn.

updatePluginTabs had a "Debounce" comment and clearTimeout over a timer
nothing ever set, and two identical branches; it now just calls
_doUpdatePluginTabs (app-early.js detects the full implementation by
that name in its source, which the new comment says).

app()'s unused baseComponent lookup is removed.

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

* fix(web): reload the plugin list after installs and failed toggles

Several callers refreshed the installed list with

    if (typeof loadInstalledPlugins === 'function') loadInstalledPlugins();
    else if (typeof window.loadInstalledPlugins === 'function') ...

but loadInstalledPlugins is local to the plugin-manager IIFE and
window.loadInstalledPlugins is never defined, so from outside that IIFE
both tests were false and nothing reloaded:

- A failed plugin toggle left the switch drawn in the new state while
  the data said the old one. It now re-renders from the reverted data.
  The optimistic in-place edit also has to forget the grid's
  last-rendered markup, or setGridHtmlIfChanged sees identical HTML and
  skips the revert. A successful toggle still keeps the switch (and
  focus) as drawn.
- Installing from a GitHub URL (the early handleGitHubPluginInstall),
  installing or uploading a Starlark app, and toggling a Starlark app on
  its config tab never refreshed the list, so the new app had no tab or
  Installed badge until the page was reloaded. They now force a reload
  through window.pluginManager.loadInstalledPlugins(true), and the
  Starlark grid redraws when that finishes instead of after a fixed
  500 ms.
- The Starlark uninstall inside the IIFE reloaded from the 3 s cache,
  which could still hold the app; it now forces a reload.

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

* refactor(web): route debug output through debugLog

base.html defines window.debugLog, gated on localStorage.pluginDebug.
plugins_manager.js read the same key twice more into its own flags
(_PLUGIN_DEBUG_EARLY, and PLUGIN_DEBUG behind a pluginLog() wrapper), and
api_client.js's RequestThrottler had a separate `debug` property with a
setDebug() that nothing called. All of it now goes through debugLog. The
"functions defined" dumps with their ✓ lines, and two per-plugin
"enabled=" loops that ran on every render, are dropped; "[PLUGINS STUB]"
labels on code that has not been a stub for a long time read
"[PLUGINS]".

Ungated console.log calls that announced normal events on every page
load or action (settings search and tooltips registering, every toast
repeated to the console, the schedule pickers initialising, widget
registry unregister/clear) go through debugLog too. What remains on
console.log is the widget registry's on-demand LEDMatrixWidgets.debug()
dump and BaseWidget.notify's no-notifier fallback.

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

* refactor(web): drop waits and guards that could never fire

- handlePluginAction polled up to 10 x 50 ms for window.togglePlugin,
  configurePlugin, updatePlugin and uninstallPlugin before calling them.
  All four are defined when the scripts load, before any card can be
  clicked, so the poll always succeeded at once; it now calls them.
  The long thinking-aloud comment over the toggle state is replaced by
  two lines on why the stored state, not the checkbox, decides.
- initializePlugins checked typeof on setupGitHubInstallHandlers and
  applyStoreFiltersAndSort, function declarations in the same IIFE, and
  wrapped window.checkGitHubAuthStatus(), which returns a promise with
  its own .catch, in try/catch.
- searchPluginStore wrapped each "#store-count" update (a getElementById
  and an innerHTML assignment) in try/catch four times; one
  setStoreCount() helper does it. The store's post-render re-attach of
  the GitHub token handler dropped its try/catch and existence checks
  for the same reason.
- The load-time fallback outside the IIFE tested typeof
  initializePluginPageWhenReady, which is IIFE-local and so always
  undefined there; it calls window.initPluginsPage directly.

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

* refactor(web): delete two unused plugin-manager helpers

stopOnDemand (IIFE-local; the page's stop button calls window.stopOnDemand
from app-shell.js) and debounce had no callers.

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

* refactor(web): document the plugin-config handlers templates call

validatePluginConfigForm, handleConfigSave, handleToggleResponse,
handlePluginUpdate and refreshPluginConfig each get a JSDoc naming the
attribute in partials/plugin_config.html that calls it and what the
return value means (only validatePluginConfigForm's matters: false
cancels the submit).

- The `if (!window.__pluginConfigHandlersInitialized)` wrapper is gone:
  app-shell.js runs once per page, so it was never false. The block is
  dedented one level; `git diff -w` shows the real change.
- The three handlers read xhr.responseJSON first. XMLHttpRequest has no
  such property (it is jQuery's), so that branch never ran; one
  xhrJson(xhr) helper parses responseText for all of them, with the same
  fallbacks as before.
- runPluginOnDemand and stopOnDemand checked that plugins_manager.js's
  openOnDemandModal/requestOnDemandStop exist; plugins_manager.js is on
  every page, so they call them.
- fixInvalidNumberInputs had a stray "Notification helper function"
  comment on top of its own; a leftover "section toggle ... duplicate
  definition removed" note is gone.

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

* fix(web): one toast per save, and a failed durations save says so

app.js's global htmx:afterRequest listener showed the server's message
for every htmx request, and every form and button that posts through
htmx (plugin config save/toggle/update, Display, Durations, General,
Schedule, Dim schedule, the Overview actions) also reports its own result
from hx-on after-request. Each save showed two toasts. The global
listener now stays quiet for a request whose element, or its form, has
its own after-request handler.

That exposed the Rotation & Durations form's handler, which read
xhr.responseJSON: XMLHttpRequest has no such property, so it always said
"Durations saved" in green, even when the save failed (the global toast
had been the only place the error showed). display.html already had a
correct version (2xx only counts as saved; the server's message wins;
its status may refine success but never overturn failure). That is now
window.showSaveResult(xhr, savedText, failedText) in app.js, used by the
Display, Durations and General forms; General's inline copy of the same
logic is gone.

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

* docs(web): file headers and comments that say what the code does now

- plugins_manager.js, app-shell.js, app.js and app-early.js open with a
  header: what the file owns, how base.html loads it and in what order
  relative to the others, and the globals it defines. app-early.js's
  app() stub also says why it exists and that, with app-shell.js now
  loaded before Alpine, it does not run in practice.
- base.html's note on plugins_manager.js said it must load last to win
  over same-named functions in app.js/app-shell.js; there are none left,
  so it now gives the real reason (it uses everything loaded before it).
- Change-narration and "already defined at the top, no need to redefine"
  notes are gone or rewritten as present-tense reasons; comments that
  were wrong are fixed ("Toggle password visibility" over the function
  that opens the token panel, "Insert before the closing </nav>" over an
  appendChild, "(from v2)", the export note that still listed
  escapeHtml). About forty comments that restated the line below them
  are removed, and a second window.currentPluginConfig = null outside the
  IIFE is dropped (the IIFE sets it).
- The file-upload, checkbox-group and custom-feeds widgets' render()
  stubs say plainly that the widget is rendered server-side, instead of
  "for now" / "placeholder for future client-side rendering".

test_plugin_action_delegation.js sliced the source up to one of the
removed notes; it now ends the slice at the next section header.

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

* fix(web): keep the escapeHtml/escapeAttribute globals for plugin pages

6da77363 removed window.escapeHtml and window.escapeAttribute because nothing in core or the plugin monorepo read them. Plugin web UIs served through serve_plugin_web_ui and third-party plugin pages may still call them, so they come back as aliases of window.LEDEscape.html and .attr, defined in app-early.js before any other script runs. test_html_escaping.js checks the aliases exist.

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

* docs(changelog): web-frontend

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

* fix(web): encode the image thumbnail path; match script tags case-insensitively

CodeQL flagged the upload widget building an <img> src from a stored path,
and the escaper test extracting inline scripts with a case-sensitive regex.
Each path segment is now URL-encoded (still a same-origin path, and correct
for names with spaces or

* fix(web): clear Codacy findings in the escaper, app shell and upload widget

- LEDEscape looks entities up in a Map instead of indexing an object.
- showNotification is declared as a global for app-shell.js.
- openImageSchedule checks the index is a non-negative integer and reads
  the image with Array.prototype.at.
- The schedule editor calls escapeHtml directly and documents why its
  innerHTML template is safe: every value is escaped or constrained.
  The remaining rule hits are suppressed on that line with the reason.

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

* fix(web): build the image schedule editor with DOM calls

Codacy does not honour inline suppressions, and the editor's innerHTML
template kept tripping its XSS rules even though every value was escaped.
The editor is now built with a small element helper (createElement and
setAttribute), so no value is ever parsed as HTML, and the file's own
escapeHtml goes away.

Also for Codacy:
- LEDEscape.attr is its own function rather than a second name for html.
- The tab loader records a failed load on the panel (data-load-failed)
  from a named handler, instead of a closure over a local flag.

The fake DOM in test_file_upload_widget.js gains append/replaceChildren,
its hostile-id check now asserts the id arrives as attribute data with no
innerHTML anywhere in the editor, and test_html_escaping.js drops the
file-upload.js escaper it no longer has.

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

* refactor(web): schedule editor helpers as plain functions

Codacy's lint flags arrow functions held in local constants and a forEach
callback that returns a value. The editor's pieces are now named function
declarations (displayStyle, scheduleModeOption, scheduleRangeTime,
scheduleDayTime, scheduleDayRow) taking what they need as arguments, and
the element helper loops with for...of. htmx is declared as a global in
app-shell.js. Output is unchanged; test_file_upload_widget.js passes.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:36:53 -04:00

4574 lines
202 KiB
JavaScript

/* global debugLog */
/*
* plugins_manager.js -- the Plugin Manager tab and everything that acts on
* installed plugins: the installed grid (toggle, configure, update,
* uninstall), the plugin store and custom registries, GitHub-URL installs
* and the GitHub token panel, on-demand runs, the array-of-objects and
* key/value config fields, plugin web-UI actions, and the Starlark apps
* section.
*
* Deferred and loaded last, after the widget bundle, so everything it uses
* (PluginAPI, ListFilter, LEDDialog, the widgets, app-shell.js's
* updatePlugin) already exists.
*
* Load order (templates/v3/base.html):
* <head>, blocking: debugLog and theme inline scripts; the htmx loader
* (injects htmx.min.js with a dynamic <script>);
* js/htmx-config.js; the loadPartialDirect fallback;
* js/app-early.js
* <head>, defer: js/app-shell.js, then js/alpinejs.min.js (Alpine
* starts as soon as it runs, so app-shell.js's app()
* is the one Alpine uses)
* end of <body>, defer, in this order: app.js, js/tooltips.js,
* js/settings-search.js, js/utils/dialog.js,
* js/utils/error_handler.js, js/plugins/api_client.js,
* state_manager.js, install_manager.js, list_filter.js,
* the widget bundle (web_interface/widget_bundle.py),
* plugins_manager.js
* Tab partials arrive later through htmx; their inline scripts run on
* htmx:afterSwap (js/htmx-config.js).
*
* Layout: a few handlers defined up front, outside any IIFE, because the
* cards and other scripts call them through window (configurePlugin,
* togglePlugin, the GitHub token helpers, handleGitHubPluginInstall,
* checkGitHubAuthStatus); then the plugin-manager IIFE (private state:
* installedPlugins, the store cache, the on-demand poller); then the
* array-of-objects field helpers; then the Starlark IIFE.
*
* The installed list is published only by renderInstalledPlugins: it sets
* window.installedPlugins and dispatches one `pluginsUpdated` event, which
* app-shell.js's app() uses to draw the plugin tabs.
*
* Globals include: window.pluginManager (loadInstalledPlugins,
* searchPluginStore, init flags), initPluginsPage, handlePluginAction,
* configurePlugin, togglePlugin, uninstallPlugin, installPlugin,
* installFromCustomRegistry, removeSavedRepository, executePluginAction,
* openOnDemandModal, requestOnDemandStop, loadOnDemandStatus,
* renderArrayObjectItem, addArrayObjectItem, removeArrayObjectItem,
* updateArrayObjectData, handleArrayObjectFileUpload, removeArrayObjectFile,
* removeKeyValuePair, updateKeyValuePairData, getSchemaProperty,
* installStarlarkApp, installPixlet, the GitHub token functions,
* window.installedPlugins and window.currentPluginConfig.
*/
// ─── LocalStorage Safety Wrappers ────────────────────────────────────────────
// Handles environments where localStorage is unavailable or restricted (private browsing, etc.)
const safeLocalStorage = {
getItem(key) {
try {
if (typeof localStorage !== 'undefined') {
return localStorage.getItem(key);
}
} catch (e) {
console.warn(`safeLocalStorage.getItem failed for key "${key}":`, e.message);
}
return null;
},
setItem(key, value) {
try {
if (typeof localStorage !== 'undefined') {
localStorage.setItem(key, value);
return true;
}
} catch (e) {
console.warn(`safeLocalStorage.setItem failed for key "${key}":`, e.message);
}
return false;
},
removeItem(key) {
try {
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(key);
return true;
}
} catch (e) {
console.warn(`localStorage.removeItem failed for key "${key}":`, e.message);
}
return false;
}
};
// Define configurePlugin early to ensure it's always available
window.configurePlugin = window.configurePlugin || async function(pluginId) {
debugLog('[PLUGINS] configurePlugin called for', pluginId);
// Opens the plugin's own tab, the same as clicking it in the tab row.
const appComponent = window.getApp();
if (appComponent) {
// Set the active tab to the plugin ID
appComponent.activeTab = pluginId;
debugLog('[PLUGINS] Switched to plugin tab:', pluginId);
// Scroll to top of page to ensure the tab is visible
window.scrollTo({ top: 0, behavior: 'smooth' });
} else {
console.error('Alpine.js app instance not found');
showNotification('Unable to switch to plugin configuration. Please refresh the page.', 'error');
}
};
// Latest toggle request per plugin, so a slow response to an earlier click
// cannot overwrite the outcome of a later one.
if (!window._pluginToggleRequests) {
window._pluginToggleRequests = {};
}
// Define togglePlugin early to ensure it's always available
window.togglePlugin = window.togglePlugin || function(pluginId, enabled) {
debugLog('[PLUGINS] togglePlugin called for', pluginId, 'enabled:', enabled);
const plugin = (window.installedPlugins || []).find(p => p.id === pluginId);
const pluginName = plugin ? (plugin.name || pluginId) : pluginId;
const action = enabled ? 'enabling' : 'disabling';
// Generate unique token for this toggle request to prevent race conditions
const requestToken = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
window._pluginToggleRequests[pluginId] = requestToken;
// Update UI immediately for better UX
const toggleCheckbox = document.getElementById(`toggle-${pluginId}`);
const toggleLabel = document.getElementById(`toggle-label-${pluginId}`);
const wrapperDiv = toggleCheckbox?.parentElement?.querySelector('.flex.items-center.gap-2');
const toggleTrack = wrapperDiv?.querySelector('.relative.w-14');
const toggleHandle = toggleTrack?.querySelector('.absolute');
// Disable checkbox and add disabled class to prevent overlapping requests
if (toggleCheckbox) {
toggleCheckbox.checked = enabled;
toggleCheckbox.disabled = true;
toggleCheckbox.classList.add('opacity-50', 'cursor-not-allowed');
}
if (wrapperDiv) {
wrapperDiv.classList.add('opacity-50', 'pointer-events-none');
}
if (wrapperDiv) {
if (enabled) {
wrapperDiv.classList.remove('bg-gray-50', 'border-gray-300');
wrapperDiv.classList.add('bg-green-50', 'border-green-500');
} else {
wrapperDiv.classList.remove('bg-green-50', 'border-green-500');
wrapperDiv.classList.add('bg-gray-50', 'border-gray-300');
}
}
if (toggleTrack) {
if (enabled) {
toggleTrack.classList.remove('bg-gray-300');
toggleTrack.classList.add('bg-green-500');
} else {
toggleTrack.classList.remove('bg-green-500');
toggleTrack.classList.add('bg-gray-300');
}
}
if (toggleHandle) {
if (enabled) {
toggleHandle.classList.add('translate-x-full', 'border-green-500');
toggleHandle.classList.remove('border-gray-400');
toggleHandle.innerHTML = '<i class="fas fa-check text-green-600 text-xs"></i>';
} else {
toggleHandle.classList.remove('translate-x-full', 'border-green-500');
toggleHandle.classList.add('border-gray-400');
toggleHandle.innerHTML = '<i class="fas fa-times text-gray-400 text-xs"></i>';
}
}
if (toggleLabel) {
if (enabled) {
toggleLabel.className = 'text-sm font-semibold text-green-700 flex items-center gap-1.5';
toggleLabel.innerHTML = '<i class="fas fa-toggle-on text-green-600"></i><span>Enabled</span>';
} else {
toggleLabel.className = 'text-sm font-semibold text-gray-600 flex items-center gap-1.5';
toggleLabel.innerHTML = '<i class="fas fa-toggle-off text-gray-400"></i><span>Disabled</span>';
}
}
// The card was just edited in place, so the grid no longer matches the
// markup setGridHtmlIfChanged last wrote; forget that so the next render
// (a failed toggle's revert, below) rebuilds it instead of skipping.
const installedGrid = document.getElementById('installed-plugins-grid');
if (installedGrid) installedGrid._lastRenderedHtml = null;
showNotification(`${action.charAt(0).toUpperCase() + action.slice(1)} ${pluginName}...`, 'info');
return fetch('/api/v3/plugins/toggle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plugin_id: pluginId, enabled: enabled })
})
.then(response => response.json())
.then(data => {
// Verify this response is for the latest request (prevent race conditions)
if (window._pluginToggleRequests[pluginId] !== requestToken) {
debugLog(`[togglePlugin] Ignoring out-of-order response for ${pluginId}`);
return;
}
showNotification(data.message, data.status);
if (data.status === 'success') {
// The switch was already drawn in its new state above; keep it
// (and keyboard focus) rather than re-rendering the grid.
if (plugin) {
plugin.enabled = enabled;
}
} else {
// Re-render so the switch goes back to the state the server kept.
if (plugin) {
plugin.enabled = !enabled;
}
window.pluginManager.loadInstalledPlugins();
}
// Clear token and re-enable UI
delete window._pluginToggleRequests[pluginId];
if (toggleCheckbox) {
toggleCheckbox.disabled = false;
toggleCheckbox.classList.remove('opacity-50', 'cursor-not-allowed');
}
if (wrapperDiv) {
wrapperDiv.classList.remove('opacity-50', 'pointer-events-none');
}
// Resolve the outcome so callers (e.g. the install flow) can chain
// on whether enabling actually succeeded.
return data;
})
.catch(error => {
// Verify this error is for the latest request (prevent race conditions)
if (window._pluginToggleRequests[pluginId] !== requestToken) {
debugLog(`[togglePlugin] Ignoring out-of-order error for ${pluginId}`);
return;
}
showNotification('Error toggling plugin: ' + error.message, 'error');
// Re-render so the switch goes back to the state the server kept.
if (plugin) {
plugin.enabled = !enabled;
}
window.pluginManager.loadInstalledPlugins();
// Clear token and re-enable UI
delete window._pluginToggleRequests[pluginId];
if (toggleCheckbox) {
toggleCheckbox.disabled = false;
toggleCheckbox.classList.remove('opacity-50', 'cursor-not-allowed');
}
if (wrapperDiv) {
wrapperDiv.classList.remove('opacity-50', 'pointer-events-none');
}
});
};
// Track pending render data for when DOM isn't ready yet
window.__pendingInstalledPlugins = window.__pendingInstalledPlugins || null;
window.__pendingStorePlugins = window.__pendingStorePlugins || null;
// Document-level delegation for plugin card actions, so a card works even if
// it was rendered before the grid's own listener was attached. It hands the
// event to handlePluginAction, which the plugin-manager IIFE below exposes on
// window (the function itself is IIFE-scoped and not visible from here).
(function setupGlobalEventDelegation() {
const handleGlobalPluginAction = function(event) {
const target = event.target;
if (!target || typeof target.closest !== 'function') return;
const el = target.closest('button[data-action][data-plugin-id]') ||
target.closest('input[data-action][data-plugin-id]');
if (!el || typeof window.handlePluginAction !== 'function') return;
window.handlePluginAction(event);
};
// Capture phase, so this runs before the grid's own listener;
// handlePluginAction stops propagation, so an action is handled once.
document.addEventListener('click', handleGlobalPluginAction, true);
document.addEventListener('change', handleGlobalPluginAction, true);
debugLog('[PLUGINS SCRIPT] Global event delegation set up');
})();
// GitHub Token Collapse Handler - Define early so it's available before IIFE
debugLog('[DEFINE] Defining attachGithubTokenCollapseHandler function...');
window.attachGithubTokenCollapseHandler = function() {
debugLog('[attachGithubTokenCollapseHandler] Starting...');
const toggleTokenCollapseBtn = document.getElementById('toggle-github-token-collapse');
debugLog('[attachGithubTokenCollapseHandler] Button found:', !!toggleTokenCollapseBtn);
if (!toggleTokenCollapseBtn) {
console.warn('[attachGithubTokenCollapseHandler] GitHub token collapse button not found');
return;
}
debugLog('[attachGithubTokenCollapseHandler] Checking toggleGithubTokenContent...', {
exists: typeof window.toggleGithubTokenContent
});
if (!window.toggleGithubTokenContent) {
console.warn('[attachGithubTokenCollapseHandler] toggleGithubTokenContent function not defined');
return;
}
// Remove any existing listeners by cloning the button
const parent = toggleTokenCollapseBtn.parentNode;
if (!parent) {
console.warn('[attachGithubTokenCollapseHandler] Button parent not found');
return;
}
const newBtn = toggleTokenCollapseBtn.cloneNode(true);
parent.replaceChild(newBtn, toggleTokenCollapseBtn);
// Attach listener to the new button
newBtn.addEventListener('click', function(e) {
debugLog('[attachGithubTokenCollapseHandler] Button clicked, calling toggleGithubTokenContent');
window.toggleGithubTokenContent(e);
});
debugLog('[attachGithubTokenCollapseHandler] Handler attached to button:', newBtn.id);
};
// Toggle GitHub Token Settings section
debugLog('[DEFINE] Defining toggleGithubTokenContent function...');
window.toggleGithubTokenContent = function(e) {
debugLog('[toggleGithubTokenContent] called', e);
if (e) {
e.stopPropagation();
e.preventDefault();
}
const tokenContent = document.getElementById('github-token-content');
const tokenIconCollapse = document.getElementById('github-token-icon-collapse');
const toggleTokenCollapseBtn = document.getElementById('toggle-github-token-collapse');
debugLog('[toggleGithubTokenContent] Elements found:', {
tokenContent: !!tokenContent,
tokenIconCollapse: !!tokenIconCollapse,
toggleTokenCollapseBtn: !!toggleTokenCollapseBtn
});
if (!tokenContent || !toggleTokenCollapseBtn) {
console.warn('[toggleGithubTokenContent] GitHub token content or button not found');
return;
}
const hasHiddenClass = tokenContent.classList.contains('hidden');
const computedDisplay = window.getComputedStyle(tokenContent).display;
debugLog('[toggleGithubTokenContent] Current state:', {
hasHiddenClass,
computedDisplay,
buttonText: toggleTokenCollapseBtn.querySelector('span')?.textContent
});
if (hasHiddenClass || computedDisplay === 'none') {
// Show content - remove hidden class, add block class, remove inline display
tokenContent.classList.remove('hidden');
tokenContent.classList.add('block');
tokenContent.style.removeProperty('display');
if (tokenIconCollapse) {
tokenIconCollapse.classList.remove('fa-chevron-down');
tokenIconCollapse.classList.add('fa-chevron-up');
}
const span = toggleTokenCollapseBtn.querySelector('span');
if (span) span.textContent = 'Collapse';
debugLog('[toggleGithubTokenContent] Content shown - removed hidden, added block');
} else {
// Hide content - add hidden class, remove block class, ensure display is none
tokenContent.classList.add('hidden');
tokenContent.classList.remove('block');
tokenContent.style.display = 'none';
if (tokenIconCollapse) {
tokenIconCollapse.classList.remove('fa-chevron-up');
tokenIconCollapse.classList.add('fa-chevron-down');
}
const span = toggleTokenCollapseBtn.querySelector('span');
if (span) span.textContent = 'Expand';
debugLog('[toggleGithubTokenContent] Content hidden - added hidden, removed block, set display:none');
}
};
// Simple standalone handler for GitHub plugin installation
// Defined early and globally to ensure it's always available
debugLog('[DEFINE] Defining handleGitHubPluginInstall function...');
window.handleGitHubPluginInstall = function() {
debugLog('[handleGitHubPluginInstall] Function called!');
const urlInput = document.getElementById('github-plugin-url');
const statusDiv = document.getElementById('github-plugin-status');
const branchInput = document.getElementById('plugin-branch-input');
const installBtn = document.getElementById('install-plugin-from-url');
if (!urlInput) {
console.error('[handleGitHubPluginInstall] URL input not found');
alert('Error: Could not find URL input field');
return;
}
const repoUrl = urlInput.value.trim();
debugLog('[handleGitHubPluginInstall] Repo URL:', repoUrl);
if (!repoUrl) {
if (statusDiv) {
statusDiv.innerHTML = '<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Please enter a GitHub URL</span>';
}
return;
}
if (!isGithubUrl(repoUrl)) {
if (statusDiv) {
statusDiv.innerHTML = '<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Please enter a valid GitHub URL</span>';
}
return;
}
// Disable button and show loading
if (installBtn) {
installBtn.disabled = true;
installBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Installing...';
}
if (statusDiv) {
statusDiv.innerHTML = '<span class="text-blue-600"><i class="fas fa-spinner fa-spin mr-1"></i>Installing plugin...</span>';
}
const branch = branchInput?.value?.trim() || null;
const requestBody = { repo_url: repoUrl };
if (branch) {
requestBody.branch = branch;
}
debugLog('[handleGitHubPluginInstall] Sending request:', requestBody);
fetch('/api/v3/plugins/install-from-url', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBody)
})
.then(response => {
debugLog('[handleGitHubPluginInstall] Response status:', response.status);
return response.json();
})
.then(data => {
debugLog('[handleGitHubPluginInstall] Response data:', data);
if (data.status === 'success') {
if (statusDiv) {
statusDiv.innerHTML = `<span class="text-green-600"><i class="fas fa-check-circle mr-1"></i>Successfully installed: ${window.LEDEscape.html(data.plugin_id)}</span>`;
}
urlInput.value = '';
// Show notification if available
showNotification(`Plugin ${data.plugin_id} installed successfully`, 'success');
setTimeout(() => window.pluginManager.loadInstalledPlugins(true), 1000);
} else {
if (statusDiv) {
statusDiv.innerHTML = `<span class="text-red-600"><i class="fas fa-times-circle mr-1"></i>${window.LEDEscape.html(data.message || 'Installation failed')}</span>`;
}
showNotification(data.message || 'Installation failed', 'error');
}
})
.catch(error => {
console.error('[handleGitHubPluginInstall] Error:', error);
if (statusDiv) {
statusDiv.innerHTML = `<span class="text-red-600"><i class="fas fa-times-circle mr-1"></i>Error: ${window.LEDEscape.html(error.message)}</span>`;
}
showNotification('Error installing plugin: ' + error.message, 'error');
})
.finally(() => {
if (installBtn) {
installBtn.disabled = false;
installBtn.innerHTML = '<i class="fas fa-download mr-2"></i>Install';
}
});
};
debugLog('[DEFINE] handleGitHubPluginInstall defined and ready');
// GitHub Authentication Status - Define early so it's available in IIFE
// Shows warning banner only when token is missing or invalid
// The token itself is never exposed to the frontend for security
// Returns a Promise so it can be awaited
debugLog('[DEFINE] Defining checkGitHubAuthStatus function...');
window.checkGitHubAuthStatus = function checkGitHubAuthStatus() {
debugLog('[checkGitHubAuthStatus] Starting...');
return fetch('/api/v3/plugins/store/github-status')
.then(response => {
debugLog('checkGitHubAuthStatus: Response status:', response.status);
return response.json();
})
.then(data => {
debugLog('checkGitHubAuthStatus: Data received:', data);
if (data.status === 'success') {
const authData = data.data;
const tokenStatus = authData.token_status || (authData.authenticated ? 'valid' : 'none');
debugLog('checkGitHubAuthStatus: Token status:', tokenStatus);
const warning = document.getElementById('github-auth-warning');
const settings = document.getElementById('github-token-settings');
const rateLimit = document.getElementById('rate-limit-count');
debugLog('checkGitHubAuthStatus: Elements found:', {
warning: !!warning,
settings: !!settings,
rateLimit: !!rateLimit
});
// Show warning only when token is missing ('none') or invalid ('invalid')
if (tokenStatus === 'none' || tokenStatus === 'invalid') {
// Check if user has dismissed the warning (stored in session storage)
const dismissed = sessionStorage.getItem('github-auth-warning-dismissed');
if (!dismissed) {
if (warning && rateLimit) {
rateLimit.textContent = authData.rate_limit;
// Update warning message for invalid tokens
if (tokenStatus === 'invalid' && authData.error) {
const warningText = warning.querySelector('p.text-sm.text-yellow-700');
if (warningText) {
// Clear existing content
warningText.textContent = '';
// Create safe error message with fallback
const errorMsg = (authData.message || authData.error || 'Unknown error').toString();
// Create <strong> element for "Token Invalid:" label
const strong = document.createElement('strong');
strong.textContent = 'Token Invalid:';
// Create text node for error message and suffix
const errorText = document.createTextNode(` ${errorMsg}. Please update your GitHub token to increase API rate limits to 5,000 requests/hour.`);
// Append elements safely (no innerHTML)
warningText.appendChild(strong);
warningText.appendChild(errorText);
}
}
// For 'none' status, use the default message from HTML template
// Show warning using both classList and style.display
warning.classList.remove('hidden');
warning.style.display = '';
debugLog(`GitHub token status: ${tokenStatus} - showing API limit warning`);
}
}
// Ensure settings panel is accessible when token is missing or invalid
// Panel can be opened via "Configure Token" link in warning
// Don't force it to be visible, but don't prevent it from being shown
} else if (tokenStatus === 'valid') {
// Token is valid - hide warning and ensure settings panel is visible but collapsed
if (warning) {
// Hide warning using both classList and style.display
warning.classList.add('hidden');
warning.style.display = 'none';
debugLog('GitHub token is valid - hiding API limit warning');
}
// Make settings panel visible but collapsed (accessible for token management)
if (settings) {
// Remove hidden class from panel itself - make it visible using both methods
settings.classList.remove('hidden');
settings.style.display = '';
// Always collapse the content when token is valid (user must click expand)
const tokenContent = document.getElementById('github-token-content');
if (tokenContent) {
// Collapse the content - add hidden, remove block, set display none
tokenContent.classList.add('hidden');
tokenContent.classList.remove('block');
tokenContent.style.display = 'none';
}
// Update collapse button state to show "Expand"
const tokenIconCollapse = document.getElementById('github-token-icon-collapse');
if (tokenIconCollapse) {
tokenIconCollapse.classList.remove('fa-chevron-up');
tokenIconCollapse.classList.add('fa-chevron-down');
}
const toggleTokenCollapseBtn = document.getElementById('toggle-github-token-collapse');
if (toggleTokenCollapseBtn) {
const span = toggleTokenCollapseBtn.querySelector('span');
if (span) span.textContent = 'Expand';
// Ensure event listener is attached
if (window.attachGithubTokenCollapseHandler) {
window.attachGithubTokenCollapseHandler();
}
}
}
// Clear dismissal flag when token becomes valid
sessionStorage.removeItem('github-auth-warning-dismissed');
}
}
})
.catch(error => {
console.error('Error checking GitHub auth status:', error);
console.error('Error stack:', error.stack || 'No stack trace');
});
};
(function() {
'use strict';
debugLog('Plugin manager script starting...');
// Local variables for this instance
let installedPlugins = [];
window.currentPluginConfig = null;
let pluginStoreCache = null; // Cache for plugin store to speed up subsequent loads
let cacheTimestamp = null;
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds
function storeCacheExpired() {
return !cacheTimestamp || (Date.now() - cacheTimestamp >= CACHE_DURATION);
}
let onDemandStatusInterval = null;
let currentOnDemandPluginId = null;
let hasLoadedOnDemandStatus = false;
// Shared on-demand status store (mirrors Alpine store when available)
window.__onDemandStore = window.__onDemandStore || {
loading: true,
state: {},
service: {},
error: null,
lastUpdated: null
};
function ensureOnDemandStore() {
if (window.Alpine && typeof Alpine.store === 'function') {
if (!Alpine.store('onDemand')) {
Alpine.store('onDemand', {
loading: window.__onDemandStore.loading,
state: window.__onDemandStore.state,
service: window.__onDemandStore.service,
error: window.__onDemandStore.error,
lastUpdated: window.__onDemandStore.lastUpdated
});
}
const store = Alpine.store('onDemand');
window.__onDemandStore = store;
return store;
}
return window.__onDemandStore;
}
function markOnDemandLoading() {
const store = ensureOnDemandStore();
store.loading = true;
store.error = null;
}
function updateOnDemandSnapshot(store) {
if (!window.__onDemandStore) {
window.__onDemandStore = {};
}
window.__onDemandStore.loading = store.loading;
window.__onDemandStore.state = store.state;
window.__onDemandStore.service = store.service;
window.__onDemandStore.error = store.error;
window.__onDemandStore.lastUpdated = store.lastUpdated;
}
function updateOnDemandStore(data) {
const store = ensureOnDemandStore();
store.loading = false;
store.state = data?.state || {};
store.service = data?.service || {};
store.error = (data?.state?.status === 'error') ? (data.state.error || data.message || 'On-demand error') : null;
store.lastUpdated = Date.now();
updateOnDemandSnapshot(store);
document.dispatchEvent(new CustomEvent('onDemand:updated', {
detail: {
state: store.state,
service: store.service,
error: store.error,
lastUpdated: store.lastUpdated
}
}));
}
function setOnDemandError(message) {
const store = ensureOnDemandStore();
store.loading = false;
store.state = {};
store.service = {};
store.error = message || 'Failed to load on-demand status';
store.lastUpdated = Date.now();
updateOnDemandSnapshot(store);
document.dispatchEvent(new CustomEvent('onDemand:updated', {
detail: {
state: store.state,
service: store.service,
error: store.error,
lastUpdated: store.lastUpdated
}
}));
}
// Track initialization state
window.pluginManager = window.pluginManager || {};
window.pluginManager.initialized = false;
window.pluginManager.initializing = false; // Track if initialization is in progress
// Initialize when DOM is ready or when HTMX loads content
window.initPluginsPage = function() {
// Prevent duplicate initialization
if (window.pluginManager.initialized || window.pluginManager.initializing) {
debugLog('Plugin page already initialized or initializing, skipping...');
return;
}
const installedGrid = document.getElementById('installed-plugins-grid');
if (!installedGrid) {
debugLog('Plugin elements not ready yet');
return false;
}
window.pluginManager.initializing = true;
// Check GitHub auth status immediately (don't wait for full initialization)
// This can run in parallel with other initialization
if (window.checkGitHubAuthStatus) {
debugLog('[INIT] Checking GitHub auth status immediately...');
window.checkGitHubAuthStatus();
}
// If we fetched data before the DOM existed, render it now
if (window.__pendingInstalledPlugins) {
debugLog('[RENDER] Applying pending installed plugins data');
window.__pendingInstalledPlugins = null;
applyInstalledFiltersAndRender();
}
if (window.__pendingStorePlugins) {
debugLog('[RENDER] Applying pending plugin store data');
pluginStoreCache = window.__pendingStorePlugins;
cacheTimestamp = Date.now();
window.__pendingStorePlugins = null;
applyStoreFiltersAndSort();
}
initializePlugins();
// Event listeners (remove old ones first to prevent duplicates)
const refreshBtn = document.getElementById('refresh-plugins-btn');
const updateAllBtn = document.getElementById('update-all-plugins-btn');
const restartBtn = document.getElementById('restart-display-btn');
const closeOnDemandModalBtn = document.getElementById('close-on-demand-modal');
const cancelOnDemandBtn = document.getElementById('cancel-on-demand');
const onDemandForm = document.getElementById('on-demand-form');
const onDemandModal = document.getElementById('on-demand-modal');
if (refreshBtn) {
refreshBtn.replaceWith(refreshBtn.cloneNode(true));
document.getElementById('refresh-plugins-btn').addEventListener('click', refreshPlugins);
}
if (updateAllBtn) {
updateAllBtn.replaceWith(updateAllBtn.cloneNode(true));
document.getElementById('update-all-plugins-btn').addEventListener('click', runUpdateAllPlugins);
}
if (restartBtn) {
restartBtn.replaceWith(restartBtn.cloneNode(true));
document.getElementById('restart-display-btn').addEventListener('click', restartDisplay);
}
// Persisted store sort/perPage are restored by the controller's syncControls().
setupInstalledFilterListeners();
setupStoreFilterListeners();
// The on-demand modal lives in base.html and survives tab swaps, so bind it
// through the one guarded initializer instead of cloning its controls here
// (cloning copied data-initialized and left two code paths owning listeners).
if (closeOnDemandModalBtn || cancelOnDemandBtn || onDemandForm || onDemandModal) {
initializeOnDemandModal();
}
// Load on-demand status silently (false = don't show notification)
loadOnDemandStatus(false);
startOnDemandStatusPolling();
window.pluginManager.initialized = true;
window.pluginManager.initializing = false;
return true;
}
function initializePluginPageWhenReady() {
return window.initPluginsPage();
}
// Single initialization entry point
(function() {
let initTimer = null;
function attemptInit() {
if (initTimer) {
clearTimeout(initTimer);
initTimer = null;
}
initializePluginPageWhenReady();
}
// Strategy 1: Immediate check (for direct page loads)
if (document.readyState === 'complete' || document.readyState === 'interactive') {
// DOM is already ready, try immediately with a small delay to ensure scripts are loaded
initTimer = setTimeout(attemptInit, 50);
} else {
// Strategy 2: DOMContentLoaded (for direct page loads)
document.addEventListener('DOMContentLoaded', function() {
initTimer = setTimeout(attemptInit, 50);
});
}
// Strategy 3: HTMX afterSwap event (for HTMX-loaded content)
// This is the primary way plugins content is loaded
// Register unconditionally — HTMX may load after this script (loaded dynamically from CDN)
// CustomEvent listeners work even before HTMX is available
document.body.addEventListener('htmx:afterSwap', function(event) {
const target = event.detail.target;
// Check if plugins content was swapped in (only match direct plugins content targets)
if (target.id === 'plugins-content' ||
target.querySelector('#installed-plugins-grid')) {
debugLog('HTMX swap detected for plugins, initializing...');
// Reset all initialization flags so the fresh empty DOM gets populated
window.pluginManager.initialized = false;
window.pluginManager.initializing = false;
window.pluginManager._reswap = true; // signal: use cached store, don't re-fetch GitHub
pluginsInitialized = false;
initTimer = setTimeout(attemptInit, 100);
}
}, { once: false }); // Allow multiple swaps
})();
let pluginsInitialized = false;
function initializePlugins() {
debugLog('[initializePlugins] FUNCTION CALLED, pluginsInitialized:', pluginsInitialized);
// Guard against multiple initializations
if (pluginsInitialized) {
debugLog('[initializePlugins] Already initialized, skipping (but still setting up handlers)');
// Still set up handlers even if already initialized (in case page was HTMX swapped)
setupGitHubInstallHandlers();
return;
}
pluginsInitialized = true;
debugLog('[initializePlugins] Starting initialization...');
debugLog('[INIT] Initializing plugins...');
// Returns a promise and handles its own errors.
window.checkGitHubAuthStatus();
// Load both installed plugins and plugin store.
// On HTMX re-swaps with a still-warm cache, skip GitHub metadata to avoid
// re-hitting the API on every tab switch. If the cache TTL has expired even
// during a re-swap, fetch fresh data including GitHub commit/version info.
const isReswapWarm = !!window.pluginManager._reswap && !storeCacheExpired();
window.pluginManager._reswap = false;
// Fire both requests in parallel so the store doesn't wait for installed plugins.
// The store renders install/update badges using window.installedPlugins || [] so
// it works with an empty list. When installed plugins finish loading we do a
// lightweight re-render from the already-cached store data to refresh the badges.
searchPluginStore(!isReswapWarm);
loadInstalledPlugins()
.catch(err => console.error('[PluginStore] loadInstalledPlugins failed:', err))
.then(() => {
// Re-render store from cache to update install/update/reinstall badges now
// that window.installedPlugins is populated. No network call — instant.
applyStoreFiltersAndSort(true);
});
// #plugin-search and #plugin-category belong to the store's ListFilter
// controller (setupStoreFilterListeners), which filters the cached list.
// Do not bind searchPluginStore to them as well: it would receive the DOM
// event as `fetchCommitInfo` and refetch the whole store on every keystroke.
setupGitHubInstallHandlers();
setupCollapsibleSections();
loadSavedRepositories();
debugLog('[INIT] Plugins initialized');
}
// ===== PLUGIN LOADING WITH REQUEST DEDUPLICATION & CACHING =====
// Prevents redundant API calls by caching results for a short time
const pluginLoadCache = {
promise: null, // Current in-flight request
data: null, // Cached plugin data
timestamp: 0, // When cache was last updated
TTL: 3000, // Cache valid for 3 seconds
isValid() {
return this.data && (Date.now() - this.timestamp < this.TTL);
},
invalidate() {
this.data = null;
this.timestamp = 0;
}
};
function loadInstalledPlugins(forceRefresh = false) {
// Return cached data if valid and not forcing refresh
if (!forceRefresh && pluginLoadCache.isValid()) {
debugLog('[CACHE] Returning cached plugin data');
renderInstalledPlugins(pluginLoadCache.data);
return Promise.resolve(pluginLoadCache.data);
}
// If a request is already in progress, return the existing promise
if (pluginLoadCache.promise) {
debugLog('[CACHE] Request in progress, returning existing promise');
return pluginLoadCache.promise;
}
debugLog('[FETCH] Loading installed plugins...');
// Use PluginAPI if available, otherwise fall back to direct fetch
const fetchPromise = (window.PluginAPI && window.PluginAPI.getInstalledPlugins) ?
window.PluginAPI.getInstalledPlugins().then(plugins => {
const pluginsArray = Array.isArray(plugins) ? plugins : [];
return { status: 'success', data: { plugins: pluginsArray } };
}) :
fetch('/api/v3/plugins/installed').then(response => response.json());
pluginLoadCache.promise = fetchPromise
.then(data => {
if (data.status === 'success') {
const pluginsData = data.data?.plugins;
installedPlugins = Array.isArray(pluginsData) ? pluginsData : [];
pluginLoadCache.data = installedPlugins;
pluginLoadCache.timestamp = Date.now();
debugLog('[FETCH] Loaded', installedPlugins.length, 'plugins');
// Also refreshes the '#installed-count' text via the filter controller.
renderInstalledPlugins(installedPlugins);
return installedPlugins;
} else {
throw new Error('Failed to load installed plugins: ' + data.message);
}
})
.catch(error => {
console.error('Error loading installed plugins:', error);
let errorMsg = 'Error loading plugins: ' + error.message;
if (isNetworkFailure(error)) {
errorMsg += ' - Please try refreshing your browser.';
}
// Replace the whole panel only when there is nothing to show yet; a
// failed refresh keeps the grid and the store the user is looking at.
if (pluginLoadCache.data === null) {
showInstalledLoadError(errorMsg);
} else {
showNotification(errorMsg, 'error');
}
throw error;
})
.finally(() => {
pluginLoadCache.promise = null;
});
return pluginLoadCache.promise;
}
// Force refresh function for explicit user actions
function refreshInstalledPlugins() {
pluginLoadCache.invalidate();
return loadInstalledPlugins(true);
}
// Expose loadInstalledPlugins on window.pluginManager for Alpine.js integration
window.pluginManager.loadInstalledPlugins = loadInstalledPlugins;
// Note: searchPluginStore will be exposed after its definition (see below)
// ── Installed Plugins: search / filter / sort ───────────────────────────
// Sort comparators for the installed-plugins toolbar. Kept beside the render
// code they drive rather than inside the ListFilter helper, which stays
// section-agnostic.
function installedSortName(plugin) {
return String((plugin && (plugin.name || plugin.id)) || '').toLowerCase();
}
// last_updated is the plugin's local git commit date (date_iso from
// _get_local_git_info, see api_v3.py). It can be absent or unparseable.
function installedUpdatedAt(plugin) {
const raw = plugin ? plugin.last_updated : null;
if (!raw) return null;
const t = Date.parse(raw);
return Number.isNaN(t) ? null : t;
}
const INSTALLED_COMPARATORS = {
'a-z': (a, b) => installedSortName(a).localeCompare(installedSortName(b)),
'z-a': (a, b) => installedSortName(b).localeCompare(installedSortName(a)),
'status': (a, b) => {
const rank = p => (p.update_available ? 0 : (p.enabled ? 1 : 2));
const diff = rank(a) - rank(b);
return diff !== 0 ? diff : installedSortName(a).localeCompare(installedSortName(b));
},
'recent': (a, b) => {
const ta = installedUpdatedAt(a);
const tb = installedUpdatedAt(b);
// Plugins with no usable timestamp sort to the end, never to the top.
if (ta === null && tb === null) return installedSortName(a).localeCompare(installedSortName(b));
if (ta === null) return 1;
if (tb === null) return -1;
return tb - ta;
},
'category': (a, b) => {
const catCmp = String(a.category || '').localeCompare(String(b.category || ''));
return catCmp !== 0 ? catCmp : installedSortName(a).localeCompare(installedSortName(b));
},
};
let _installedFilter = null;
// Created lazily so a script load-order problem degrades to "no filtering"
// instead of throwing while this file is still being evaluated.
function getInstalledFilter() {
if (_installedFilter) return _installedFilter;
if (!window.ListFilter || typeof window.ListFilter.create !== 'function') {
console.warn('[PLUGINS] ListFilter helper unavailable — installed plugin filters disabled');
return null;
}
_installedFilter = window.ListFilter.create({
// Always read the canonical list, never a captured snapshot.
getItems: () => window.installedPlugins || installedPlugins || [],
render: renderInstalledCards,
search: {
el: 'installed-search',
clearEl: 'installed-search-clear',
fields: ['name', 'id', 'description', 'author', 'category', 'tags'],
debounceMs: 150,
},
sort: {
el: 'installed-sort',
default: 'a-z',
comparators: INSTALLED_COMPARATORS,
},
controls: [{
type: 'pills',
el: '#installed-filter-pills',
attr: 'data-installed-filter',
key: 'status',
default: 'all',
test: (plugin, value) => {
if (value === 'enabled') return Boolean(plugin.enabled);
if (value === 'disabled') return !plugin.enabled;
// update_available is computed server-side (api_v3.py) — never
// recompute it from version strings here.
if (value === 'updates') return Boolean(plugin.update_available);
return true;
},
}],
clearEl: 'installed-clear-filters',
countEl: 'installed-count',
countFormat: (shown, total, filtered) =>
filtered ? `${shown} of ${total} shown` : `${total} installed`,
onChrome: updateInstalledUpdatesBadge,
});
return _installedFilter;
}
// The Updates pill counts against the *full* list, not the filtered view, so
// the badge keeps telling you how much work is outstanding while you browse.
function updateInstalledUpdatesBadge() {
const all = window.installedPlugins || installedPlugins || [];
const n = all.filter(p => p && p.update_available).length;
const badge = document.getElementById('installed-updates-count');
if (badge) {
badge.textContent = String(n);
badge.classList.toggle('hidden', n === 0);
}
const pill = document.querySelector('#installed-filter-pills [data-installed-filter="updates"]');
if (pill) {
pill.classList.toggle('opacity-50', n === 0);
pill.title = n === 0
? 'No plugins have updates available'
: `Show only the ${n} plugin${n !== 1 ? 's' : ''} with a newer version available`;
}
}
function applyInstalledFiltersAndRender() {
const ctl = getInstalledFilter();
if (ctl) {
ctl.apply();
return;
}
// Fallback: render everything, so the section still works without the helper.
const all = window.installedPlugins || installedPlugins || [];
renderInstalledCards(all, all.length);
const countEl = document.getElementById('installed-count');
if (countEl) countEl.textContent = all.length + ' installed';
}
function setupInstalledFilterListeners() {
const ctl = getInstalledFilter();
if (!ctl) return;
// Both are idempotent — the plugins partial is HTMX-swapped, so this runs
// again on every return to the tab.
ctl.bind();
ctl.syncControls();
}
// The one place the installed-plugin list is published, then rendered
// through the active filters. It sets window.installedPlugins, which the
// toggle handler, isStorePluginInstalled and runUpdateAllPlugins read, and
// dispatches one pluginsUpdated event, on which the Alpine app (app-shell.js)
// takes the list and rebuilds the plugin tab row. Both must get the FULL
// list, never a filtered subset.
function renderInstalledPlugins(plugins) {
window.installedPlugins = plugins;
document.dispatchEvent(new CustomEvent('pluginsUpdated', { detail: { plugins: plugins } }));
// The Plugin Manager tab may not be loaded yet; initPluginsPage renders
// when it is.
if (!document.getElementById('installed-plugins-grid')) {
debugLog('[RENDER] installed-plugins-grid not loaded yet, rendering when the tab loads');
window.__pendingInstalledPlugins = plugins;
return;
}
applyInstalledFiltersAndRender();
}
// Renders the card grid only. `plugins` is the visible (filtered) subset and
// `total` the full installed count — this must NOT touch window.installedPlugins.
function renderInstalledCards(plugins, total) {
const container = document.getElementById('installed-plugins-grid');
if (!container) return;
const totalCount = (typeof total === 'number') ? total : plugins.length;
// Remove skeleton cards before rendering real content
container.querySelectorAll('.installed-skeleton').forEach(el => el.remove());
// Attached before the early returns so the empty state's Clear button works.
setupInstalledEventDelegation();
if (plugins.length === 0) {
container.innerHTML = totalCount === 0 ? `
<div class="col-span-full empty-state">
<div class="empty-state-icon">
<i class="fas fa-plug"></i>
</div>
<p class="text-lg font-medium text-gray-700 mb-1">No plugins installed</p>
<p class="text-sm text-gray-500">Install plugins from the store to get started</p>
</div>
` : `
<div class="col-span-full empty-state">
<div class="empty-state-icon">
<i class="fas fa-filter"></i>
</div>
<p class="text-lg font-medium text-gray-700 mb-1">No plugins match your filters</p>
<p class="text-sm text-gray-500 mb-4">${totalCount} plugin${totalCount !== 1 ? 's' : ''} installed, none matching the current search or filters.</p>
<button class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-semibold"
data-action="clear-installed-filters">
<i class="fas fa-times mr-2"></i>Clear filters
</button>
</div>
`;
return;
}
setGridHtmlIfChanged(container, plugins.map(plugin => {
// Convert enabled to boolean for consistent rendering
const enabledBool = Boolean(plugin.enabled);
// Escape plugin ID for use in HTML attributes and JavaScript
const escapedPluginId = escapeAttribute(plugin.id);
return `
<div class="plugin-card">
<div class="mb-4">
<!-- Header row: only the name and badges share a line with the
toggle, so the metadata and description below get the
card's full width. -->
<div class="flex items-start justify-between gap-3 mb-2">
<div class="flex items-center flex-wrap gap-2 min-w-0">
<h4 class="font-semibold text-gray-900 text-base">${escapeHtml(plugin.name || plugin.id)}</h4>
${plugin.is_starlark_app ? '<span class="badge badge-warning"><i class="fas fa-star mr-1"></i>Starlark</span>' : ''}
${plugin.verified ? '<span class="badge badge-success"><i class="fas fa-check-circle mr-1"></i>Verified</span>' : ''}
</div>
<!-- Toggle Switch in Top Right -->
<div class="flex-shrink-0">
<label class="relative inline-flex items-center cursor-pointer group">
<!-- input.peer must stay immediately before the visible pill:
peer-focus:* only reaches a following sibling. -->
<input type="checkbox"
class="sr-only peer"
role="switch"
aria-label="Enable ${escapeAttribute(plugin.name || plugin.id)}"
id="toggle-${escapedPluginId}"
${enabledBool ? 'checked' : ''}
data-plugin-id="${escapedPluginId}"
data-action="toggle">
<div class="flex items-center gap-2 px-3 py-1.5 rounded-lg border-2 transition-all duration-200 ${enabledBool ? 'bg-green-50 border-green-500' : 'bg-gray-50 border-gray-300'} hover:shadow-md group-hover:scale-105 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300" aria-hidden="true">
<!-- Toggle Switch -->
<div class="relative w-14 h-7 ${enabledBool ? 'bg-green-500' : 'bg-gray-300'} rounded-full transition-colors duration-200 ease-in-out shadow-inner">
<div class="absolute top-[3px] left-[3px] bg-white ${enabledBool ? 'translate-x-full' : ''} border-2 ${enabledBool ? 'border-green-500' : 'border-gray-400'} rounded-full h-5 w-5 transition-all duration-200 ease-in-out shadow-sm flex items-center justify-center">
${enabledBool ? '<i class="fas fa-check text-green-600 text-xs"></i>' : '<i class="fas fa-times text-gray-400 text-xs"></i>'}
</div>
</div>
<!-- Label with Icon -->
<span class="text-sm font-semibold ${enabledBool ? 'text-green-700' : 'text-gray-600'} flex items-center gap-1.5" id="toggle-label-${escapedPluginId}">
${enabledBool ? '<i class="fas fa-toggle-on text-green-600"></i>' : '<i class="fas fa-toggle-off text-gray-400"></i>'}
<span>${enabledBool ? 'Enabled' : 'Disabled'}</span>
</span>
</div>
</label>
</div>
</div>
<div class="text-sm text-gray-600 space-y-1.5 mb-3">
<p class="flex items-center"><i class="fas fa-user mr-2 text-gray-400 w-4"></i>${escapeHtml(plugin.author || 'Unknown')}</p>
${plugin.version ? `<p class="flex items-center flex-wrap gap-1.5"><i class="fas fa-tag mr-2 text-gray-400 w-4"></i>v${escapeHtml(plugin.version)}${plugin.update_available && plugin.latest_version ? `<span class="badge badge-info" title="Installed v${escapeAttribute(plugin.version)} → latest v${escapeAttribute(plugin.latest_version)}"><i class="fas fa-arrow-circle-up mr-1"></i>v${escapeHtml(plugin.latest_version)} available</span>` : ''}</p>` : ''}
<p class="flex items-center"><i class="fas fa-folder mr-2 text-gray-400 w-4"></i>${escapeHtml(plugin.category || 'General')}</p>
</div>
<p class="text-sm text-gray-700 leading-relaxed">${escapeHtml(plugin.description || 'No description available')}</p>
</div>
<!-- Plugin Tags -->
${plugin.tags && plugin.tags.length > 0 ? `
<div class="flex flex-wrap gap-1.5 mb-4">
${plugin.tags.map(tag => `<span class="badge badge-info">${escapeHtml(tag)}</span>`).join('')}
</div>
` : ''}
<!-- Plugin Actions -->
<div style="display: flex; flex-direction: column; gap: 0.5rem; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #e5e7eb;">
<button class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-semibold"
style="display: flex; width: 100%; justify-content: center;"
data-plugin-id="${escapedPluginId}"
data-action="configure">
<i class="fas fa-cog mr-2"></i>Configure
</button>
<div style="display: flex; gap: 0.5rem;">
<button class="btn ${plugin.update_available ? 'bg-blue-600 hover:bg-blue-700 plugin-update-available' : 'bg-yellow-600 hover:bg-yellow-700'} text-white px-4 py-2 rounded-md text-sm font-semibold"
style="flex: 1;"
data-plugin-id="${escapedPluginId}"
data-action="update"
title="${plugin.update_available && plugin.latest_version ? 'Update to v' + escapeAttribute(plugin.latest_version) : 'Reinstall the latest published version'}">
<i class="fas ${plugin.update_available ? 'fa-arrow-circle-up' : 'fa-sync'} mr-2"></i>${plugin.update_available && plugin.latest_version ? 'Update to v' + escapeHtml(plugin.latest_version) : 'Update'}
</button>
<button class="btn bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md text-sm font-semibold"
style="flex: 1;"
data-plugin-id="${escapedPluginId}"
data-action="uninstall">
<i class="fas fa-trash mr-2"></i>Uninstall
</button>
</div>
</div>
</div>
`;
}).join(''));
}
// Filter/search changes re-run the card renderers. When the markup they
// produce is identical to what is already in the grid (same visible set, same
// state), skip the innerHTML rebuild: it saves layout work on small Pis and
// keeps focus and any typed branch names in place. The cache lives on the
// element, so an HTMX swap that replaces the grid starts fresh, and it is
// tied to the first node it produced, so any other write to the grid (empty
// state, loading skeleton, error message) invalidates it automatically.
function setGridHtmlIfChanged(container, html) {
if (container._lastRenderedHtml === html &&
container._lastRenderedFirst &&
container._lastRenderedFirst === container.firstElementChild) {
return false;
}
container.innerHTML = html;
container._lastRenderedHtml = html;
container._lastRenderedFirst = container.firstElementChild;
return true;
}
// Shared with the Starlark section, which lives in a separate IIFE below.
window.__pmSetGridHtmlIfChanged = setGridHtmlIfChanged;
// Set up event delegation for plugin action buttons (fallback if onclick doesn't work)
// Only set up once per container to avoid redundant listeners, which is what
// makes it safe to rebuild the grid's innerHTML on every keystroke.
function setupInstalledEventDelegation() {
const container = document.getElementById('installed-plugins-grid');
if (!container) {
debugLog('[RENDER] installed-plugins-grid not found for event delegation');
return;
}
if (container._eventDelegationSetup) {
return;
}
container._eventDelegationSetup = true;
container._pluginActionHandler = handlePluginAction;
container.addEventListener('click', handlePluginAction, true);
container.addEventListener('change', handlePluginAction, true);
debugLog('[RENDER] Event delegation set up for installed-plugins-grid');
}
function handlePluginAction(event) {
const button = event.target.closest('button[data-action]') || event.target.closest('input[data-action]');
if (!button) return;
const action = button.getAttribute('data-action');
const pluginId = button.getAttribute('data-plugin-id');
// Grid-level actions have no plugin id (the empty state's Clear button).
if (action === 'clear-installed-filters') {
event.preventDefault();
event.stopPropagation();
const ctl = getInstalledFilter();
if (ctl) ctl.reset();
return;
}
if (!pluginId) return;
event.preventDefault();
event.stopPropagation();
debugLog('[EVENT DELEGATION] Plugin action:', action, 'Plugin ID:', pluginId);
switch(action) {
case 'toggle':
// Toggling under an Enabled/Disabled filter would otherwise make the
// card vanish the moment the server confirms. Pin it until the user
// next touches the toolbar.
{
const ctl = getInstalledFilter();
if (ctl) ctl.sticky.add(pluginId);
}
// The new state is the opposite of the stored one. The plugin data
// is the source of truth; the checkbox is only a fallback, and
// because the click was preventDefault()ed it still shows the old
// state too.
const plugin = (window.installedPlugins || []).find(p => p.id === pluginId);
let currentEnabled;
if (plugin) {
currentEnabled = Boolean(plugin.enabled);
} else if (button.type === 'checkbox') {
currentEnabled = button.checked;
} else {
currentEnabled = false;
}
const isChecked = !currentEnabled;
debugLog('[DEBUG toggle] Plugin:', pluginId, 'Current enabled (from data):', currentEnabled, 'New state:', isChecked, 'Event type:', event.type);
window.togglePlugin(pluginId, isChecked);
break;
case 'configure':
window.configurePlugin(pluginId);
break;
case 'update':
window.updatePlugin(pluginId);
break;
case 'uninstall':
if (pluginId.startsWith('starlark:')) {
// Starlark app uninstall uses dedicated endpoint
const starlarkAppId = pluginId.slice('starlark:'.length);
if (!confirm(`Uninstall Starlark app "${starlarkAppId}"?`)) break;
fetch(`/api/v3/starlark/apps/${encodeURIComponent(starlarkAppId)}`, {method: 'DELETE'})
.then(r => r.json())
.then(data => {
if (data.status === 'success') {
showNotification('Starlark app uninstalled', 'success');
loadInstalledPlugins(true);
} else {
alert('Uninstall failed: ' + (data.message || 'Unknown error'));
}
})
.catch(err => alert('Uninstall failed: ' + err.message));
} else {
window.uninstallPlugin(pluginId);
}
break;
}
}
window.handlePluginAction = handlePluginAction;
function findInstalledPlugin(pluginId) {
const plugins = window.installedPlugins || installedPlugins || [];
if (!plugins || plugins.length === 0) {
return undefined;
}
return plugins.find(plugin => plugin.id === pluginId);
}
function resolvePluginDisplayName(pluginId) {
const plugin = findInstalledPlugin(pluginId);
if (!plugin) {
return pluginId;
}
return plugin.name || pluginId;
}
function loadOnDemandStatus(fromRefreshButton = false) {
if (!hasLoadedOnDemandStatus || fromRefreshButton) {
markOnDemandLoading();
}
return fetch('/api/v3/display/on-demand/status')
.then(response => response.json())
.then(result => {
if (result.status === 'success') {
updateOnDemandStore(result.data);
hasLoadedOnDemandStatus = true;
if (fromRefreshButton) {
showNotification('On-demand status refreshed', 'success');
}
} else {
const message = result.message || 'Failed to load on-demand status';
setOnDemandError(message);
showNotification(message, 'error');
}
})
.catch(error => {
console.error('Error fetching on-demand status:', error);
setOnDemandError(error?.message || 'Error fetching on-demand status');
showNotification('Error fetching on-demand status: ' + error.message, 'error');
});
}
// The 15s on-demand status poll only runs while someone can see it: the page
// is visible and the Plugin Manager tab is the active one. On a Pi Zero 2 W a
// forgotten background tab should cost nothing. When it becomes visible again
// it refreshes immediately, then resumes the interval.
const ON_DEMAND_POLL_MS = 15000;
function isPluginManagerTabVisible() {
if (document.hidden) return false;
try {
if (window.Alpine && typeof window.Alpine.$data === 'function') {
const data = window.Alpine.$data(document.body);
if (data && typeof data.activeTab === 'string') {
return data.activeTab === 'plugins';
}
}
} catch (e) {
// Alpine not initialised on body yet; fall through to the DOM check.
}
const content = document.getElementById('plugins-content');
return !!(content && content.offsetParent !== null);
}
function syncOnDemandStatusPolling() {
const shouldRun = isPluginManagerTabVisible();
if (shouldRun && !onDemandStatusInterval) {
loadOnDemandStatus(false);
onDemandStatusInterval = setInterval(() => {
if (!isPluginManagerTabVisible()) {
clearInterval(onDemandStatusInterval);
onDemandStatusInterval = null;
return;
}
loadOnDemandStatus(false);
}, ON_DEMAND_POLL_MS);
} else if (!shouldRun && onDemandStatusInterval) {
clearInterval(onDemandStatusInterval);
onDemandStatusInterval = null;
}
}
function startOnDemandStatusPolling() {
if (onDemandStatusInterval) {
clearInterval(onDemandStatusInterval);
onDemandStatusInterval = null;
}
// The caller has just loaded status, so start the interval without a
// second immediate fetch; later resumes go through syncOnDemandStatusPolling.
if (isPluginManagerTabVisible()) {
onDemandStatusInterval = setInterval(() => {
if (!isPluginManagerTabVisible()) {
clearInterval(onDemandStatusInterval);
onDemandStatusInterval = null;
return;
}
loadOnDemandStatus(false);
}, ON_DEMAND_POLL_MS);
}
if (!window.__onDemandPollingListenersBound) {
window.__onDemandPollingListenersBound = true;
document.addEventListener('visibilitychange', syncOnDemandStatusPolling);
// Tab switches: nav clicks change Alpine's activeTab, and HTMX swaps
// tab content in. Defer so Alpine has applied x-show first.
document.addEventListener('click', () => setTimeout(syncOnDemandStatusPolling, 0));
document.addEventListener('htmx:afterSettle', syncOnDemandStatusPolling);
window.addEventListener('popstate', () => setTimeout(syncOnDemandStatusPolling, 0));
}
}
window.loadOnDemandStatus = loadOnDemandStatus;
function runUpdateAllPlugins() {
const button = document.getElementById('update-all-plugins-btn');
if (!button) {
showNotification('Unable to locate bulk update controls. Refresh the Plugin Manager tab.', 'error');
return;
}
if (button.dataset.running === 'true') {
return;
}
const plugins = Array.isArray(window.installedPlugins) ? window.installedPlugins : [];
if (!plugins.length) {
showNotification('No installed plugins to update.', 'warning');
return;
}
const originalContent = button.innerHTML;
button.dataset.running = 'true';
button.disabled = true;
button.classList.add('opacity-60', 'cursor-wait');
button.innerHTML = '<i class="fas fa-sync fa-spin mr-2"></i>Checking...';
const onProgress = (current, total, pluginId) => {
button.innerHTML = `<i class="fas fa-sync fa-spin mr-2"></i>Updating ${current}/${total}...`;
};
Promise.resolve(window.updateAllPlugins(onProgress))
.then(results => {
if (!results || !results.length) {
showNotification('No plugins to update.', 'info');
return;
}
// Counted by install_manager.js from each answer's update_status:
// a no-op update is "already up to date", not "updated". A cached
// install_manager.js from before that helper gets a plain count.
const manager = window.PluginInstallManager;
const summary = (manager && typeof manager.summarizeUpdateResults === 'function')
? manager.summarizeUpdateResults(results)
: (() => {
const failed = results.filter(r => !r.success).length;
const checked = results.length - failed;
return {
text: `${checked} checked` + (failed ? `, ${failed} failed` : ''),
type: failed ? (checked ? 'warning' : 'error') : 'success'
};
})();
showNotification(summary.text, summary.type);
})
.catch(error => {
console.error('Error updating all plugins:', error);
showNotification('Error updating all plugins: ' + error.message, 'error');
})
.finally(() => {
button.innerHTML = originalContent;
button.disabled = false;
button.classList.remove('opacity-60', 'cursor-wait');
button.dataset.running = 'false';
});
}
// Initialize on-demand modal setup (runs unconditionally since modal is in base.html)
function initializeOnDemandModal() {
const closeOnDemandModalBtn = document.getElementById('close-on-demand-modal');
const cancelOnDemandBtn = document.getElementById('cancel-on-demand');
const onDemandForm = document.getElementById('on-demand-form');
const onDemandModal = document.getElementById('on-demand-modal');
if (closeOnDemandModalBtn && !closeOnDemandModalBtn.dataset.initialized) {
closeOnDemandModalBtn.replaceWith(closeOnDemandModalBtn.cloneNode(true));
const newBtn = document.getElementById('close-on-demand-modal');
if (newBtn) {
newBtn.dataset.initialized = 'true';
newBtn.addEventListener('click', closeOnDemandModal);
}
}
if (cancelOnDemandBtn && !cancelOnDemandBtn.dataset.initialized) {
cancelOnDemandBtn.replaceWith(cancelOnDemandBtn.cloneNode(true));
const newBtn = document.getElementById('cancel-on-demand');
if (newBtn) {
newBtn.dataset.initialized = 'true';
newBtn.addEventListener('click', closeOnDemandModal);
}
}
if (onDemandForm && !onDemandForm.dataset.initialized) {
onDemandForm.replaceWith(onDemandForm.cloneNode(true));
const newForm = document.getElementById('on-demand-form');
if (newForm) {
newForm.dataset.initialized = 'true';
newForm.addEventListener('submit', submitOnDemandRequest);
}
}
if (onDemandModal && !onDemandModal.dataset.initialized) {
onDemandModal.dataset.initialized = 'true';
onDemandModal.onclick = closeOnDemandModalOnBackdrop;
}
}
// Store the real implementation and replace the stub
window.__openOnDemandModalImpl = function(pluginId) {
debugLog('[__openOnDemandModalImpl] Called with pluginId:', pluginId);
const plugin = findInstalledPlugin(pluginId);
debugLog('[__openOnDemandModalImpl] Found plugin:', plugin ? plugin.id : 'NOT FOUND');
if (!plugin) {
console.warn('[__openOnDemandModalImpl] Plugin not found, installedPlugins:', window.installedPlugins?.length || 0);
showNotification(`Plugin ${pluginId} not found`, 'error');
return;
}
// Note: On-demand can work with disabled plugins - the backend will temporarily enable them
// We still log it for debugging but don't block the modal
if (!plugin.enabled) {
debugLog('[__openOnDemandModalImpl] Plugin is disabled, but on-demand will temporarily enable it');
}
currentOnDemandPluginId = pluginId;
debugLog('[__openOnDemandModalImpl] Setting currentOnDemandPluginId to:', pluginId);
// Ensure modal is initialized
debugLog('[__openOnDemandModalImpl] Initializing modal...');
initializeOnDemandModal();
const modal = document.getElementById('on-demand-modal');
const modeSelect = document.getElementById('on-demand-mode');
const modeHint = document.getElementById('on-demand-mode-hint');
const durationInput = document.getElementById('on-demand-duration');
const pinnedCheckbox = document.getElementById('on-demand-pinned');
const startServiceCheckbox = document.getElementById('on-demand-start-service');
const modalTitle = document.getElementById('on-demand-modal-title');
debugLog('[__openOnDemandModalImpl] Modal elements check:', {
modal: !!modal,
modeSelect: !!modeSelect,
modeHint: !!modeHint,
durationInput: !!durationInput,
pinnedCheckbox: !!pinnedCheckbox,
startServiceCheckbox: !!startServiceCheckbox,
modalTitle: !!modalTitle
});
if (!modal || !modeSelect || !modeHint || !durationInput || !pinnedCheckbox || !startServiceCheckbox || !modalTitle) {
console.error('On-demand modal elements not found', {
modal: !!modal,
modeSelect: !!modeSelect,
modeHint: !!modeHint,
durationInput: !!durationInput,
pinnedCheckbox: !!pinnedCheckbox,
startServiceCheckbox: !!startServiceCheckbox,
modalTitle: !!modalTitle
});
return;
}
debugLog('[__openOnDemandModalImpl] All elements found, opening modal...');
modalTitle.textContent = `Run ${resolvePluginDisplayName(pluginId)} On-Demand`;
modeSelect.innerHTML = '';
const displayModes = Array.isArray(plugin.display_modes) && plugin.display_modes.length > 0
? plugin.display_modes
: [pluginId];
displayModes.forEach(mode => {
const option = document.createElement('option');
option.value = mode;
option.textContent = mode;
modeSelect.appendChild(option);
});
if (displayModes.length > 1) {
modeHint.textContent = 'Select the display mode to show on the matrix.';
} else {
modeHint.textContent = 'This plugin exposes a single display mode.';
}
durationInput.value = '';
pinnedCheckbox.checked = false;
startServiceCheckbox.checked = true;
// Check service status and show warning if needed
fetch('/api/v3/display/on-demand/status')
.then(response => response.json())
.then(data => {
const serviceWarning = document.getElementById('on-demand-service-warning');
const serviceActive = data?.data?.service?.active || false;
if (serviceWarning) {
if (!serviceActive) {
serviceWarning.classList.remove('hidden');
// Auto-check the start service checkbox
startServiceCheckbox.checked = true;
} else {
serviceWarning.classList.add('hidden');
}
}
})
.catch(error => {
console.error('Error checking service status:', error);
});
debugLog('[__openOnDemandModalImpl] Setting modal display to flex');
// Force modal to be visible and properly positioned
// Remove all inline styles that might interfere
modal.removeAttribute('style');
// Set explicit positioning to ensure it's visible
modal.style.cssText = 'position: fixed !important; top: 0 !important; left: 0 !important; right: 0 !important; bottom: 0 !important; display: flex !important; visibility: visible !important; opacity: 1 !important; z-index: 9999 !important; margin: 0 !important; padding: 0 !important;';
// Ensure modal content is centered
const modalContent = modal.querySelector('.modal-content');
if (modalContent) {
modalContent.style.margin = 'auto';
modalContent.style.maxHeight = '90vh';
modalContent.style.overflowY = 'auto';
}
// Dialog semantics, focus trap, Escape, and focus return. Opening again
// while already open (e.g. a double click) keeps the existing trap so
// focus still returns to the control that first opened it.
if (modalContent && window.LEDDialog && !onDemandDialogRelease) {
onDemandDialogRelease = window.LEDDialog.trap(modalContent, {
labelledBy: 'on-demand-modal-title',
initialFocus: modeSelect,
onEscape: closeOnDemandModal
});
}
// Scroll to top of page to ensure modal is visible
window.scrollTo({ top: 0, behavior: 'smooth' });
// Force a reflow to ensure styles are applied
modal.offsetHeight;
debugLog('[__openOnDemandModalImpl] Modal display set, should be visible now. Modal element:', modal);
debugLog('[__openOnDemandModalImpl] Modal computed styles:', {
display: window.getComputedStyle(modal).display,
visibility: window.getComputedStyle(modal).visibility,
opacity: window.getComputedStyle(modal).opacity,
zIndex: window.getComputedStyle(modal).zIndex,
position: window.getComputedStyle(modal).position
});
// Also check if modal is actually in the viewport
const rect = modal.getBoundingClientRect();
debugLog('[__openOnDemandModalImpl] Modal bounding rect:', {
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
visible: rect.width > 0 && rect.height > 0
});
};
window.openOnDemandModal = window.__openOnDemandModalImpl;
// Release handle for the on-demand modal's focus trap (window.LEDDialog).
// Every close path funnels through closeOnDemandModal, which releases it once.
let onDemandDialogRelease = null;
function closeOnDemandModal() {
const modal = document.getElementById('on-demand-modal');
if (modal) {
modal.style.display = 'none';
}
currentOnDemandPluginId = null;
if (onDemandDialogRelease) {
const release = onDemandDialogRelease;
onDemandDialogRelease = null;
release();
}
}
function submitOnDemandRequest(event) {
event.preventDefault();
debugLog('[submitOnDemandRequest] Form submitted, currentOnDemandPluginId:', currentOnDemandPluginId);
if (!currentOnDemandPluginId) {
console.error('[submitOnDemandRequest] No plugin ID set');
showNotification('Select a plugin before starting on-demand mode.', 'error');
return;
}
const form = document.getElementById('on-demand-form');
if (!form) {
console.error('[submitOnDemandRequest] Form not found');
return;
}
debugLog('[submitOnDemandRequest] Form found, processing...');
const formData = new FormData(form);
const mode = formData.get('mode');
const pinned = formData.get('pinned') === 'on';
const startService = formData.get('start_service') === 'on';
const durationValue = formData.get('duration');
const payload = {
plugin_id: currentOnDemandPluginId,
mode,
pinned,
start_service: startService
};
if (durationValue !== null && durationValue !== '') {
const parsedDuration = parseInt(durationValue, 10);
if (!Number.isNaN(parsedDuration) && parsedDuration >= 0) {
payload.duration = parsedDuration;
}
}
debugLog('[submitOnDemandRequest] Payload:', payload);
markOnDemandLoading();
fetch('/api/v3/display/on-demand/start', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
})
.then(response => {
debugLog('[submitOnDemandRequest] Response status:', response.status);
return response.json();
})
.then(result => {
debugLog('[submitOnDemandRequest] Response data:', result);
if (result.status === 'success') {
const pluginName = resolvePluginDisplayName(currentOnDemandPluginId);
showNotification(`Requested on-demand mode for ${pluginName}`, 'success');
closeOnDemandModal();
setTimeout(() => loadOnDemandStatus(true), 700);
} else {
console.error('[submitOnDemandRequest] Request failed:', result);
showNotification(result.message || 'Failed to start on-demand mode', 'error');
}
})
.catch(error => {
console.error('[submitOnDemandRequest] Error starting on-demand mode:', error);
showNotification('Error starting on-demand mode: ' + error.message, 'error');
});
}
function requestOnDemandStop({ stopService = false } = {}) {
markOnDemandLoading();
return fetch('/api/v3/display/on-demand/stop', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
stop_service: stopService
})
})
.then(response => response.json())
.then(result => {
if (result.status === 'success') {
const message = stopService
? 'On-demand mode stop requested and display service will be stopped.'
: 'On-demand mode stop requested';
showNotification(message, 'success');
setTimeout(() => loadOnDemandStatus(true), 700);
} else {
showNotification(result.message || 'Failed to stop on-demand mode', 'error');
}
})
.catch(error => {
console.error('Error stopping on-demand mode:', error);
showNotification('Error stopping on-demand mode: ' + error.message, 'error');
});
}
window.requestOnDemandStop = requestOnDemandStop;
function closeOnDemandModalOnBackdrop(event) {
if (event.target === event.currentTarget) {
closeOnDemandModal();
}
}
// Helper function to get the full property object from schema
// Uses greedy longest-match to handle schema keys containing dots (e.g., "eng.1")
function getSchemaProperty(schema, path) {
if (!schema || !schema.properties) return null;
const parts = path.split('.');
let current = schema.properties;
let i = 0;
while (i < parts.length) {
let matched = false;
// Try progressively longer candidates, longest first
for (let j = parts.length; j > i; j--) {
const candidate = parts.slice(i, j).join('.');
if (current && current[candidate]) {
if (j === parts.length) {
// Consumed all remaining parts — done
return current[candidate];
}
if (current[candidate].properties) {
current = current[candidate].properties;
i = j;
matched = true;
break;
} else {
return null; // Can't navigate deeper
}
}
}
if (!matched) {
return null;
}
}
return null;
}
// "x-display": "hidden" (or an object of nothing but hidden children).
// Mirrors prop_is_hidden in plugin_config.html.
function isHiddenSchemaProp(schema) {
if (!schema || typeof schema !== 'object') return false;
if (schema['x-display'] === 'hidden') return true;
const children = schema.properties;
if (children && typeof children === 'object') {
const values = Object.values(children);
return values.length > 0 && values.every(isHiddenSchemaProp);
}
return false;
}
// Helper function to render a single item in an array of objects
function renderArrayObjectItem(fieldId, fullKey, itemProperties, itemValue, index, itemsSchema) {
const item = itemValue || {};
const itemId = `${escapeAttribute(fieldId)}_item_${index}`;
// Store original item data in data attribute to preserve non-editable properties after reindexing
const itemDataJson = JSON.stringify(item);
const itemDataBase64 = btoa(unescape(encodeURIComponent(itemDataJson)));
let html = `<div id="${itemId}" class="border border-gray-300 rounded-lg p-4 bg-gray-50 array-object-item" data-index="${index}" data-item-data="${escapeAttribute(itemDataBase64)}">`;
// Render each property of the object
const propertyOrder = itemsSchema['x-propertyOrder'] || Object.keys(itemProperties);
propertyOrder.forEach(propKey => {
if (!itemProperties[propKey]) return;
// "x-display": "hidden": no control. The stored value survives through
// data-item-data, which updateArrayObjectData overlays edits onto.
if (isHiddenSchemaProp(itemProperties[propKey])) return;
const propSchema = itemProperties[propKey];
const propValue = item[propKey] !== undefined ? item[propKey] : propSchema.default;
const propLabel = propSchema.title || propKey.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
const propDescription = propSchema.description || '';
const propFullKey = `${fullKey}[${index}].${propKey}`;
html += `<div class="mb-3">`;
// Handle file-upload widget (for logo field)
if (propSchema['x-widget'] === 'file-upload') {
html += `<label class="block text-sm font-medium text-gray-700 mb-1">${escapeHtml(propLabel)}</label>`;
if (propDescription) {
html += `<p class="text-xs text-gray-500 mb-2">${escapeHtml(propDescription)}</p>`;
}
const uploadConfig = propSchema['x-upload-config'] || {};
// Derive pluginId strictly from uploadConfig or currentPluginConfig, no hard-coded fallback
const pluginId = uploadConfig.plugin_id || (typeof currentPluginConfig !== 'undefined' ? currentPluginConfig?.pluginId : null) || (typeof window.currentPluginConfig !== 'undefined' ? window.currentPluginConfig?.pluginId : null) || null;
const logoValue = propValue || {};
// Use base64 encoding for JSON in data attributes to safely handle all characters
const logoDataJson = logoValue && Object.keys(logoValue).length > 0 ? JSON.stringify(logoValue) : '';
const logoDataBase64 = logoDataJson ? btoa(unescape(encodeURIComponent(logoDataJson))) : '';
const allowedTypes = uploadConfig.allowed_types || ['image/png', 'image/jpeg', 'image/bmp'];
const maxSizeMB = uploadConfig.max_size_mb || 5;
const pluginIdParam = pluginId ? `'${escapeAttribute(pluginId)}'` : 'null';
const uploadConfigJson = JSON.stringify({ allowed_types: allowedTypes, max_size_mb: maxSizeMB });
const uploadConfigBase64 = btoa(unescape(encodeURIComponent(uploadConfigJson)));
html += `
<div class="file-upload-widget-inline"${logoDataBase64 ? ` data-file-data="${escapeAttribute(logoDataBase64)}" data-prop-key="${escapeAttribute(propKey)}"` : ` data-prop-key="${escapeAttribute(propKey)}"`} data-upload-config="${escapeAttribute(uploadConfigBase64)}">
<input type="file"
id="${escapeAttribute(itemId)}_logo_file"
accept="${escapeAttribute(allowedTypes.join(','))}"
style="display: none;"
onchange="handleArrayObjectFileUpload(event, '${escapeAttribute(fieldId)}', ${index}, '${escapeAttribute(propKey)}', ${pluginIdParam})">
<button type="button"
onclick="document.getElementById('${escapeAttribute(itemId)}_logo_file').click()"
class="px-3 py-2 text-sm bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-md transition-colors">
<i class="fas fa-upload mr-1"></i> Upload Logo
</button>
`;
if (logoValue.path) {
html += `
<div class="mt-2 flex items-center space-x-2 uploaded-image-container">
<img src="/${escapeAttribute(logoValue.path.replace(/^\/+/, ''))}" alt="Logo" loading="lazy" decoding="async" class="w-16 h-16 object-cover rounded border">
<button type="button"
onclick="removeArrayObjectFile('${escapeAttribute(fieldId)}', ${index}, '${escapeAttribute(propKey)}')"
class="text-red-600 hover:text-red-800">
<i class="fas fa-trash"></i> Remove
</button>
</div>
`;
}
html += `</div>`;
} else if (propSchema.type === 'boolean') {
// Boolean checkbox
html += `
<label class="flex items-center">
<input type="checkbox"
id="${escapeAttribute(itemId)}_${escapeAttribute(propKey)}"
data-prop-key="${escapeAttribute(propKey)}"
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
${propValue ? 'checked' : ''}
onchange="updateArrayObjectData('${escapeAttribute(fieldId)}')">
<span class="ml-2 text-sm text-gray-700">${escapeHtml(propLabel)}</span>
</label>
`;
} else {
// Regular text/string input
html += `
<label for="${escapeAttribute(itemId)}_${escapeAttribute(propKey)}" class="block text-sm font-medium text-gray-700 mb-1">
${escapeHtml(propLabel)}
</label>
`;
if (propDescription) {
html += `<p class="text-xs text-gray-500 mb-1">${escapeHtml(propDescription)}</p>`;
}
const placeholder = propSchema.format === 'uri' ? 'https://example.com/feed' : '';
html += `
<input type="${propSchema.format === 'uri' ? 'url' : 'text'}"
id="${escapeAttribute(itemId)}_${escapeAttribute(propKey)}"
data-prop-key="${escapeAttribute(propKey)}"
class="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-white text-black"
value="${escapeAttribute(propValue || '')}"
placeholder="${escapeAttribute(placeholder)}"
onchange="updateArrayObjectData('${escapeAttribute(fieldId)}')">
`;
}
html += `</div>`;
});
// Use schema-driven label for remove button, fallback to generic "Remove item"
const removeLabel = itemsSchema['x-removeLabel'] || 'Remove item';
html += `
<button type="button"
onclick="removeArrayObjectItem('${escapeAttribute(fieldId)}', ${index})"
class="mt-2 px-3 py-2 text-sm text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md transition-colors">
<i class="fas fa-trash mr-1"></i> ${escapeHtml(removeLabel)}
</button>
</div>`;
return html;
}
// Functions to handle patternProperties key-value pairs
window.removeKeyValuePair = function(fieldId, index) {
const pairsContainer = document.getElementById(fieldId + '_pairs');
if (!pairsContainer) return;
const pair = pairsContainer.querySelector(`.key-value-pair[data-index="${index}"]`);
if (pair) {
pair.remove();
// Re-index remaining pairs
const remainingPairs = pairsContainer.querySelectorAll('.key-value-pair');
remainingPairs.forEach((p, newIndex) => {
p.setAttribute('data-index', newIndex);
const keyInput = p.querySelector('[data-key-index]');
const valueInput = p.querySelector('[data-value-index]');
if (keyInput) {
keyInput.setAttribute('name', keyInput.getAttribute('name').replace(/\[key_\d+\]/, `[key_${newIndex}]`));
keyInput.setAttribute('data-key-index', newIndex);
keyInput.setAttribute('onchange', `updateKeyValuePairData('${fieldId}', '${keyInput.getAttribute('name').split('[')[0]}')`);
}
if (valueInput) {
valueInput.setAttribute('name', valueInput.getAttribute('name').replace(/\[value_\d+\]/, `[value_${newIndex}]`));
valueInput.setAttribute('data-value-index', newIndex);
valueInput.setAttribute('onchange', `updateKeyValuePairData('${fieldId}', '${valueInput.getAttribute('name').split('[')[0]}')`);
}
const removeButton = p.querySelector('button[onclick*="removeKeyValuePair"]');
if (removeButton) {
removeButton.setAttribute('onclick', `removeKeyValuePair('${fieldId}', ${newIndex})`);
}
});
const hiddenInput = pairsContainer.closest('.key-value-pairs-container').querySelector('input[type="hidden"]');
if (hiddenInput) {
const hiddenName = hiddenInput.getAttribute('name').replace(/_data$/, '');
updateKeyValuePairData(fieldId, hiddenName);
}
// Update add button state
const addButton = pairsContainer.nextElementSibling;
if (addButton) {
const maxProperties = parseInt(addButton.getAttribute('onclick').match(/\d+/)[0]);
if (remainingPairs.length < maxProperties) {
addButton.disabled = false;
addButton.style.opacity = '1';
addButton.style.cursor = 'pointer';
}
}
}
};
window.updateKeyValuePairData = function(fieldId, fullKey) {
const pairsContainer = document.getElementById(fieldId + '_pairs');
const hiddenInput = document.getElementById(fieldId + '_data');
if (!pairsContainer || !hiddenInput) return;
const pairs = {};
const keyInputs = pairsContainer.querySelectorAll('[data-key-index]');
const valueInputs = pairsContainer.querySelectorAll('[data-value-index]');
keyInputs.forEach((keyInput, idx) => {
const key = keyInput.value.trim();
const valueInput = Array.from(valueInputs).find(v => v.getAttribute('data-value-index') === keyInput.getAttribute('data-key-index'));
if (key && valueInput) {
const value = valueInput.value.trim();
if (value) {
pairs[key] = value;
}
}
});
hiddenInput.value = JSON.stringify(pairs);
};
window.updateArrayObjectData = function(fieldId) {
const itemsContainer = document.getElementById(fieldId + '_items');
const hiddenInput = document.getElementById(fieldId + '_data');
if (!itemsContainer || !hiddenInput) return;
// Get existing items from hidden input to preserve non-editable properties
let existingItems = [];
try {
const existingData = hiddenInput.value.trim();
if (existingData) {
existingItems = JSON.parse(existingData);
}
} catch (e) {
console.error('Error parsing existing items data:', e);
}
const items = [];
const itemElements = itemsContainer.querySelectorAll('.array-object-item');
itemElements.forEach((itemEl, index) => {
// Start with original item data from data attribute to preserve non-editable properties
// This avoids index-based corruption after deletions/reindexing
let existingItem = {};
const itemDataBase64 = itemEl.getAttribute('data-item-data');
if (itemDataBase64) {
try {
const itemDataJson = decodeURIComponent(escape(atob(itemDataBase64)));
existingItem = JSON.parse(itemDataJson);
} catch (e) {
console.error('Error parsing item data from data attribute:', e);
// Fallback to index-based lookup if data attribute is missing/corrupt
if (index < existingItems.length && existingItems[index]) {
existingItem = existingItems[index];
}
}
} else {
// Fallback to index-based lookup if data attribute is missing
if (index < existingItems.length && existingItems[index]) {
existingItem = existingItems[index];
}
}
const item = Object.assign({}, existingItem); // Copy existing item
// Get all text inputs in this item and overlay their values with type coercion
itemEl.querySelectorAll('input[type="text"], input[type="url"], input[type="number"]').forEach(input => {
const propKey = input.getAttribute('data-prop-key');
if (propKey && propKey !== 'logo_file') {
let value = input.value.trim();
// Type coercion: check input type or data-prop-type attribute
const inputType = input.type;
const propType = input.getAttribute('data-prop-type');
if (inputType === 'number' || propType === 'number') {
// Use valueAsNumber if available, fallback to Number()
const numValue = input.valueAsNumber !== undefined && !isNaN(input.valueAsNumber)
? input.valueAsNumber
: Number(value);
item[propKey] = isNaN(numValue) ? value : numValue;
} else if (propType === 'array' || input.getAttribute('data-prop-is-list') === 'true') {
// Try to parse as JSON array, fallback to comma splitting
try {
const parsed = JSON.parse(value);
item[propKey] = Array.isArray(parsed) ? parsed : value;
} catch (e) {
// Fallback to comma-splitting for arrays
item[propKey] = value ? value.split(',').map(v => v.trim()).filter(v => v) : [];
}
} else {
// String value - keep as-is
item[propKey] = value;
}
}
});
// Handle checkboxes
itemEl.querySelectorAll('input[type="checkbox"]').forEach(checkbox => {
const propKey = checkbox.getAttribute('data-prop-key');
if (propKey) {
item[propKey] = checkbox.checked;
}
});
// Handle file upload data (stored in data attributes, base64-encoded)
itemEl.querySelectorAll('[data-file-data]').forEach(fileEl => {
const fileDataBase64 = fileEl.getAttribute('data-file-data');
if (fileDataBase64) {
try {
// Decode base64-encoded JSON
const fileDataJson = decodeURIComponent(escape(atob(fileDataBase64)));
const data = JSON.parse(fileDataJson);
const propKey = fileEl.getAttribute('data-prop-key');
if (propKey) {
item[propKey] = data;
}
} catch (e) {
console.error('Error parsing file data:', e);
}
}
});
items.push(item);
// Update data-item-data attribute with the merged item to keep it in sync
try {
const itemDataJson = JSON.stringify(item);
const itemDataBase64 = btoa(unescape(encodeURIComponent(itemDataJson)));
itemEl.setAttribute('data-item-data', itemDataBase64);
} catch (e) {
console.error('Error updating data-item-data attribute:', e);
}
});
hiddenInput.value = JSON.stringify(items);
};
window.handleArrayObjectFileUpload = async function(event, fieldId, itemIndex, propKey, pluginId) {
const file = event.target.files[0];
if (!file) return;
// Derive item element from event instead of constructing ID (works after reindexing)
const itemEl = event.target.closest('.array-object-item');
if (!itemEl) {
console.error('Array object item element not found');
return;
}
// Find file upload container within the item element, scoped to propKey
const fileUploadContainer = itemEl.querySelector(`.file-upload-widget-inline[data-prop-key="${propKey}"]`);
if (!fileUploadContainer) {
console.error('File upload container not found for propKey:', propKey);
return;
}
// Get upload config from data attribute
let uploadConfig = { allowed_types: ['image/png', 'image/jpeg', 'image/jpg', 'image/bmp'], max_size_mb: 5 };
const uploadConfigBase64 = fileUploadContainer.getAttribute('data-upload-config');
if (uploadConfigBase64) {
try {
const uploadConfigJson = decodeURIComponent(escape(atob(uploadConfigBase64)));
uploadConfig = JSON.parse(uploadConfigJson);
} catch (e) {
console.error('Error parsing upload config from data attribute:', e);
}
}
// Validate file type using uploadConfig
const allowedTypes = uploadConfig.allowed_types || ['image/png', 'image/jpeg', 'image/jpg', 'image/bmp'];
if (!allowedTypes.includes(file.type)) {
showNotification(`File ${file.name} is not a valid image type`, 'error');
return;
}
// Validate file size using uploadConfig
const maxSizeMB = uploadConfig.max_size_mb || 5;
if (file.size > maxSizeMB * 1024 * 1024) {
showNotification(`File ${file.name} exceeds ${maxSizeMB}MB limit`, 'error');
return;
}
// Validate pluginId before upload (fail fast)
if (!pluginId || pluginId === 'null' || pluginId === 'undefined' || (typeof pluginId === 'string' && pluginId.trim() === '')) {
showNotification('Plugin ID is required for file upload', 'error');
console.error('File upload failed: pluginId is required');
return;
}
// Upload file
const formData = new FormData();
formData.append('plugin_id', pluginId);
formData.append('files', file);
try {
const response = await fetch('/api/v3/plugins/assets/upload', {
method: 'POST',
body: formData
});
// Check response.ok before parsing JSON to avoid parsing errors on HTTP errors
if (!response.ok) {
const errorText = await response.text();
let errorMessage = `Upload failed: HTTP ${response.status}`;
try {
const errorData = JSON.parse(errorText);
errorMessage = errorData.message || errorMessage;
} catch (e) {
// If response isn't JSON, use the text or status
if (errorText) {
errorMessage = `Upload failed: ${errorText}`;
}
}
showNotification(errorMessage, 'error');
return;
}
const data = await response.json();
if (data.status === 'success' && data.uploaded_files && data.uploaded_files.length > 0) {
const uploadedFile = data.uploaded_files[0];
// Store file data in data-file-data attribute on the container (base64-encoded)
const fileDataJson = JSON.stringify(uploadedFile);
const fileDataBase64 = btoa(unescape(encodeURIComponent(fileDataJson)));
fileUploadContainer.setAttribute('data-file-data', fileDataBase64);
fileUploadContainer.setAttribute('data-prop-key', propKey);
// Update the display to show the uploaded image
const existingImage = fileUploadContainer.querySelector('.uploaded-image-container');
if (existingImage) {
existingImage.remove();
}
const imageContainer = document.createElement('div');
imageContainer.className = 'mt-2 flex items-center space-x-2 uploaded-image-container';
const escapedPath = escapeAttribute(uploadedFile.path.replace(/^\/+/, ''));
const escapedFieldId = escapeAttribute(fieldId);
const escapedPropKey = escapeAttribute(propKey);
// Get current item index from data-index attribute for remove button
const currentItemIndex = itemEl.getAttribute('data-index') || itemIndex;
imageContainer.innerHTML = `
<img src="/${escapedPath}" alt="Logo" loading="lazy" decoding="async" class="w-16 h-16 object-cover rounded border">
<button type="button"
onclick="removeArrayObjectFile('${escapedFieldId}', ${currentItemIndex}, '${escapedPropKey}')"
class="text-red-600 hover:text-red-800">
<i class="fas fa-trash"></i> Remove
</button>
`;
fileUploadContainer.appendChild(imageContainer);
// Update the hidden input with the new file data
updateArrayObjectData(fieldId);
showNotification('Logo uploaded successfully', 'success');
} else {
showNotification(`Upload failed: ${data.message || 'Unknown error'}`, 'error');
}
} catch (error) {
console.error('Upload error:', error);
showNotification(`Upload error: ${error.message}`, 'error');
}
// Clear file input
event.target.value = '';
};
window.removeArrayObjectFile = function(fieldId, itemIndex, propKey) {
const itemId = `${fieldId}_item_${itemIndex}`;
const fileUploadContainer = document.querySelector(`#${itemId} .file-upload-widget-inline`);
if (!fileUploadContainer) {
console.error('File upload container not found');
return;
}
// Remove file data from data attribute
fileUploadContainer.removeAttribute('data-file-data');
// Remove the image display
const imageContainer = fileUploadContainer.querySelector('.uploaded-image-container');
if (imageContainer) {
imageContainer.remove();
}
// Update the hidden input to remove the file data
updateArrayObjectData(fieldId);
showNotification('Logo removed', 'success');
};
// Generic Plugin Action Handler
window.executePluginAction = function(actionId, actionIndex, pluginIdParam = null) {
debugLog('[DEBUG] executePluginAction called - actionId:', actionId, 'actionIndex:', actionIndex, 'pluginIdParam:', pluginIdParam);
// Construct button ID first (we have actionId and actionIndex)
const actionIdFull = `action-${actionId}-${actionIndex}`;
const statusId = `action-status-${actionId}-${actionIndex}`;
const btn = document.getElementById(actionIdFull);
const statusDiv = document.getElementById(statusId);
// Get plugin ID from multiple sources with comprehensive fallback logic
let pluginId = pluginIdParam;
// Fallback 1: Try to get from button's data-plugin-id attribute
if (!pluginId && btn) {
pluginId = btn.getAttribute('data-plugin-id');
if (pluginId) {
debugLog('[DEBUG] Got pluginId from button data attribute:', pluginId);
}
}
// Fallback 2: Try to get from closest parent with data-plugin-id
if (!pluginId && btn) {
const parentWithPluginId = btn.closest('[data-plugin-id]');
if (parentWithPluginId) {
pluginId = parentWithPluginId.getAttribute('data-plugin-id');
if (pluginId) {
debugLog('[DEBUG] Got pluginId from parent element:', pluginId);
}
}
}
// Fallback 3: Try to get from plugin-config-container or plugin-config-tab
if (!pluginId && btn) {
const container = btn.closest('.plugin-config-container, .plugin-config-tab, [id^="plugin-config-"]');
if (container) {
// Try data-plugin-id first
pluginId = container.getAttribute('data-plugin-id');
if (!pluginId) {
// Try to extract from ID like "plugin-config-{pluginId}"
const idMatch = container.id.match(/plugin-config-(.+)/);
if (idMatch) {
pluginId = idMatch[1];
}
}
if (pluginId) {
debugLog('[DEBUG] Got pluginId from container:', pluginId);
}
}
}
// Fallback 4: Try to get from currentPluginConfig
if (!pluginId) {
pluginId = currentPluginConfig?.pluginId;
if (pluginId) {
debugLog('[DEBUG] Got pluginId from currentPluginConfig:', pluginId);
}
}
// Fallback 5: the active tab, when it is a plugin's tab
if (!pluginId) {
const appData = window.getApp();
if (appData && appData.activeTab && appData.activeTab !== 'overview' && appData.activeTab !== 'plugins' && appData.activeTab !== 'wifi') {
pluginId = appData.activeTab;
debugLog('[DEBUG] Got pluginId from Alpine activeTab:', pluginId);
}
}
// Fallback 6: Try to find from plugin tab elements (scoped to button context)
if (!pluginId && btn) {
try {
// Search within the button's Alpine.js context (closest x-data element)
const buttonContext = btn.closest('[x-data]');
if (buttonContext) {
const pluginTab = buttonContext.querySelector('[x-show*="activeTab === plugin.id"]');
if (pluginTab && window.Alpine) {
try {
const pluginData = Alpine.$data(buttonContext);
if (pluginData && pluginData.plugin) {
pluginId = pluginData.plugin.id;
if (pluginId) {
debugLog('[DEBUG] Got pluginId from Alpine plugin data (scoped to button context):', pluginId);
}
}
} catch (e) {
console.warn('[DEBUG] Error accessing Alpine plugin data:', e);
}
}
}
// If not found in button context, try container element
if (!pluginId) {
const container = btn.closest('.plugin-config-container, .plugin-config-tab, [id^="plugin-config-"]');
if (container) {
const containerContext = container.querySelector('[x-show*="activeTab === plugin.id"]');
if (containerContext && window.Alpine) {
try {
const containerData = Alpine.$data(container.closest('[x-data]'));
if (containerData && containerData.plugin) {
pluginId = containerData.plugin.id;
if (pluginId) {
debugLog('[DEBUG] Got pluginId from Alpine plugin data (scoped to container):', pluginId);
}
}
} catch (e) {
console.warn('[DEBUG] Error accessing Alpine plugin data from container:', e);
}
}
}
}
} catch (e) {
console.warn('[DEBUG] Error in fallback 6 DOM lookup:', e);
}
}
// Final check - if still no pluginId, show error
if (!pluginId) {
console.error('No plugin ID available after all fallbacks. actionId:', actionId, 'actionIndex:', actionIndex);
console.error('[DEBUG] Button found:', !!btn);
console.error('[DEBUG] currentPluginConfig:', currentPluginConfig);
showNotification('Unable to determine plugin ID. Please refresh the page.', 'error');
return;
}
debugLog('[DEBUG] executePluginAction - Final pluginId:', pluginId, 'actionId:', actionId, 'actionIndex:', actionIndex);
if (!btn || !statusDiv) {
console.error(`Action elements not found: ${actionIdFull}`);
return;
}
// Get action definition - try currentPluginConfig first, then fetch from API
let action = currentPluginConfig?.webUiActions?.[actionIndex];
if (!action) {
// Try to get from installed plugins
if (window.installedPlugins) {
const plugin = window.installedPlugins.find(p => p.id === pluginId);
if (plugin && plugin.web_ui_actions) {
action = plugin.web_ui_actions[actionIndex];
}
}
}
if (!action) {
console.error(`Action not found: ${actionId} for plugin ${pluginId}`);
debugLog('[DEBUG] currentPluginConfig:', currentPluginConfig);
debugLog('[DEBUG] installedPlugins:', window.installedPlugins);
showNotification(`Action ${actionId} not found. Please refresh the page.`, 'error');
return;
}
debugLog('[DEBUG] Found action:', action);
// Check if we're in step 2 (completing OAuth flow)
if (btn.dataset.step === '2') {
const redirectUrl = prompt(action.step2_prompt || 'Please paste the full redirect URL:');
if (!redirectUrl || !redirectUrl.trim()) {
return;
}
// Complete authentication
btn.disabled = true;
const originalText = btn.innerHTML;
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Completing...';
statusDiv.classList.remove('hidden');
statusDiv.innerHTML = '<div class="text-blue-600"><i class="fas fa-spinner fa-spin mr-2"></i>Completing authentication...</div>';
fetch('/api/v3/plugins/action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
plugin_id: pluginId,
action_id: actionId,
params: {step: '2', redirect_url: redirectUrl.trim()}
})
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
statusDiv.innerHTML = `<div class="text-green-600"><i class="fas fa-check-circle mr-2"></i>${escapeHtml(data.message || 'Action completed successfully')}</div>`;
btn.innerHTML = originalText;
btn.disabled = false;
delete btn.dataset.step;
showNotification(data.message || 'Action completed successfully!', 'success');
} else {
statusDiv.innerHTML = `<div class="text-red-600"><i class="fas fa-exclamation-circle mr-2"></i>${escapeHtml(data.message || 'Error')}</div>`;
if (data.output) {
statusDiv.innerHTML += `<pre class="mt-2 text-xs bg-red-50 p-2 rounded overflow-auto max-h-32">${escapeHtml(data.output)}</pre>`;
}
btn.innerHTML = originalText;
btn.disabled = false;
delete btn.dataset.step;
}
})
.catch(error => {
statusDiv.innerHTML = `<div class="text-red-600"><i class="fas fa-exclamation-circle mr-2"></i>Error: ${escapeHtml(error.message)}</div>`;
btn.innerHTML = originalText;
btn.disabled = false;
delete btn.dataset.step;
});
return;
}
// Step 1: Execute action
btn.disabled = true;
const originalText = btn.innerHTML;
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Executing...';
statusDiv.classList.remove('hidden');
statusDiv.innerHTML = '<div class="text-blue-600"><i class="fas fa-spinner fa-spin mr-2"></i>Executing action...</div>';
fetch('/api/v3/plugins/action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
plugin_id: pluginId,
action_id: actionId,
params: {}
})
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
if (data.requires_step2 && data.auth_url) {
// OAuth flow - show auth URL
statusDiv.innerHTML = `
<div class="bg-blue-50 border border-blue-200 rounded p-3">
<div class="text-blue-900 font-medium mb-2">
<i class="fas fa-link mr-2"></i>${escapeHtml(data.message || 'Authorization URL Generated')}
</div>
<div class="mb-3">
<p class="text-sm text-blue-700 mb-2">1. Click the link below to authorize:</p>
<a href="${data.auth_url && data.auth_url.startsWith('http') ? escapeHtml(data.auth_url) : '#'}" target="_blank" class="text-blue-600 hover:text-blue-800 underline break-all">
${escapeHtml(data.auth_url || '')}
</a>
</div>
<div class="mb-2">
<p class="text-sm text-blue-700 mb-2">2. After authorization, copy the FULL redirect URL from your browser.</p>
<p class="text-sm text-blue-600">3. Click the button again and paste the redirect URL when prompted.</p>
</div>
</div>
`;
btn.textContent = action.step2_button_text || 'Complete Authentication';
btn.dataset.step = '2';
btn.disabled = false;
showNotification(data.message || 'Authorization URL generated. Please authorize and paste the redirect URL.', 'info');
} else {
// Simple success
statusDiv.innerHTML = `
<div class="bg-green-50 border border-green-200 rounded p-3">
<div class="text-green-900 font-medium mb-2">
<i class="fas fa-check-circle mr-2"></i>${escapeHtml(data.message || 'Action completed successfully')}
</div>
${data.output ? `<pre class="mt-2 text-xs bg-green-50 p-2 rounded overflow-auto max-h-32">${escapeHtml(data.output)}</pre>` : ''}
</div>
`;
btn.innerHTML = originalText;
btn.disabled = false;
showNotification(data.message || 'Action completed successfully!', 'success');
}
} else {
statusDiv.innerHTML = `
<div class="bg-red-50 border border-red-200 rounded p-3">
<div class="text-red-900 font-medium mb-2">
<i class="fas fa-exclamation-circle mr-2"></i>${escapeHtml(data.message || 'Action failed')}
</div>
${data.output ? `<pre class="mt-2 text-xs bg-red-50 p-2 rounded overflow-auto max-h-32">${escapeHtml(data.output)}</pre>` : ''}
</div>
`;
btn.innerHTML = originalText;
btn.disabled = false;
}
})
.catch(error => {
statusDiv.innerHTML = `<div class="text-red-600"><i class="fas fa-exclamation-circle mr-2"></i>Error: ${escapeHtml(error.message)}</div>`;
btn.innerHTML = originalText;
btn.disabled = false;
});
}
window.uninstallPlugin = function(pluginId) {
const plugin = (window.installedPlugins || installedPlugins || []).find(p => p.id === pluginId);
const pluginName = plugin ? (plugin.name || pluginId) : pluginId;
if (!confirm(`Are you sure you want to uninstall ${pluginName}?`)) {
return;
}
showNotification(`Uninstalling ${pluginName}...`, 'info');
fetch('/api/v3/plugins/uninstall', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plugin_id: pluginId })
})
.then(response => response.json())
.then(data => {
debugLog('Uninstall response:', data);
// Check if operation was queued
if (data.status === 'success' && data.data && data.data.operation_id) {
// Operation was queued, poll for completion
const operationId = data.data.operation_id;
showNotification(`Uninstall queued for ${pluginName}...`, 'info');
pollOperationStatus(operationId, pluginId, pluginName);
} else if (data.status === 'success') {
// Direct uninstall completed immediately
handleUninstallSuccess(pluginId);
} else {
// Error response
showNotification(data.message || 'Failed to uninstall plugin', data.status || 'error');
}
})
.catch(error => {
console.error('Error uninstalling plugin:', error);
showNotification('Error uninstalling plugin: ' + error.message, 'error');
});
}
function pollOperationStatus(operationId, pluginId, pluginName, options = {}) {
const maxAttempts = options.maxAttempts || 60;
const attempt = options.attempt || 0;
const onComplete = options.onComplete || (() => handleUninstallSuccess(pluginId));
const onFailed = options.onFailed || ((errorMsg) => {
showNotification(errorMsg || `Operation failed for ${pluginName}`, 'error');
setTimeout(() => loadInstalledPlugins(), 1000);
});
const onTimeout = options.onTimeout || (() => {
showNotification(`Operation timed out for ${pluginName}`, 'error');
setTimeout(() => loadInstalledPlugins(), 1000);
});
if (attempt >= maxAttempts) {
onTimeout();
return;
}
const pollAgain = () => setTimeout(() => {
pollOperationStatus(operationId, pluginId, pluginName, { ...options, attempt: attempt + 1 });
}, 1000);
fetch(`/api/v3/plugins/operation/${operationId}`)
.then(response => response.json())
.then(data => {
if (data.status === 'success' && data.data) {
const operation = data.data;
const status = operation.status;
if (status === 'completed') {
onComplete();
} else if (status === 'failed') {
onFailed(operation.error || operation.message);
} else {
// 'pending', 'in_progress', or unknown - poll again
pollAgain();
}
} else {
// Error getting operation status, try again
pollAgain();
}
})
.catch(error => {
console.error('Error polling operation status:', error);
// On error, refresh plugin list to see actual state
setTimeout(() => {
loadInstalledPlugins();
}, 1000);
});
}
function handleUninstallSuccess(pluginId) {
// Remove from local array immediately for better UX
const currentPlugins = window.installedPlugins || installedPlugins || [];
const updatedPlugins = currentPlugins.filter(p => p.id !== pluginId);
installedPlugins = updatedPlugins;
renderInstalledPlugins(updatedPlugins);
showNotification(`Plugin uninstalled successfully`, 'success');
// Also refresh from server to ensure consistency
setTimeout(() => {
loadInstalledPlugins();
}, 1000);
}
function refreshPlugins() {
debugLog('[refreshPlugins] Button clicked, refreshing plugins...');
// Clear cache to force fresh data
pluginStoreCache = null;
cacheTimestamp = null;
// refreshInstalledPlugins() is async (returns a Promise via loadInstalledPlugins).
// Only search the store and notify after window.installedPlugins is updated so
// that Installed/Reinstall badges reflect the freshly fetched state.
refreshInstalledPlugins().then(() => {
searchPluginStore(true);
showNotification('Plugins refreshed with latest metadata from GitHub', 'success');
});
}
function restartDisplay() {
debugLog('[restartDisplay] Button clicked, restarting display service...');
showNotification('Restarting display service...', 'info');
fetch('/api/v3/system/action', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'restart_display_service' })
})
.then(response => response.json())
.then(data => {
showNotification(data.message, data.status);
})
.catch(error => {
showNotification('Error restarting display: ' + error.message, 'error');
});
}
function searchPluginStore(fetchCommitInfo = true) {
debugLog('[STORE] Searching plugin store...', { fetchCommitInfo });
const now = Date.now();
const isCacheValid = pluginStoreCache && cacheTimestamp && (now - cacheTimestamp < CACHE_DURATION);
// If cache is valid and we don't need fresh commit info, just re-filter
if (isCacheValid && !fetchCommitInfo) {
debugLog('Using cached plugin store data');
const storeGrid = document.getElementById('plugin-store-grid');
if (storeGrid) {
applyStoreFiltersAndSort();
return;
}
}
setStoreCount('<i class="fas fa-spinner fa-spin mr-1"></i>Loading...');
showStoreLoading(true);
let url = '/api/v3/plugins/store/list';
if (!fetchCommitInfo) {
url += '?fetch_commit_info=false';
}
debugLog('Store URL:', url);
fetch(url)
.then(response => response.json())
.then(data => {
showStoreLoading(false);
if (data.status === 'success') {
const plugins = data.data.plugins || [];
debugLog('Store plugins count:', plugins.length);
pluginStoreCache = plugins;
cacheTimestamp = Date.now();
const storeGrid = document.getElementById('plugin-store-grid');
if (!storeGrid) {
debugLog('[STORE] plugin-store-grid not ready, deferring render');
window.__pendingStorePlugins = plugins;
return;
}
// Update total count
setStoreCount(`${plugins.length} available`);
applyStoreFiltersAndSort();
// Re-attach GitHub token collapse handler after store render
requestAnimationFrame(() => {
window.attachGithubTokenCollapseHandler();
window.checkGitHubAuthStatus();
});
} else {
showNotification('Failed to search plugin store: ' + data.message, 'error');
setStoreCount('Error loading');
}
})
.catch(error => {
console.error('Error searching plugin store:', error);
showStoreLoading(false);
showNotification('Error searching plugin store: ' + error.message, 'error');
setStoreCount('Error loading');
});
}
function setStoreCount(html) {
const countEl = document.getElementById('store-count');
if (countEl) countEl.innerHTML = html;
}
function showStoreLoading(show) {
const loading = document.querySelector('.store-loading');
if (loading) {
loading.style.display = show ? 'block' : 'none';
}
}
// ── Plugin Store: Client-Side Filter/Sort/Pagination ────────────────────────
function isStorePluginInstalled(pluginIdOrPlugin) {
const installed = window.installedPlugins || installedPlugins || [];
// Accept either a plain ID string or a store plugin object (which may have plugin_path)
if (typeof pluginIdOrPlugin === 'string') {
return installed.some(p => p.id === pluginIdOrPlugin);
}
const storeId = pluginIdOrPlugin.id;
// Derive the actual installed directory name from plugin_path (e.g. "plugins/ledmatrix-weather" → "ledmatrix-weather")
const pluginPath = pluginIdOrPlugin.plugin_path || '';
const pathDerivedId = pluginPath ? pluginPath.split('/').pop() : null;
return installed.some(p => p.id === storeId || (pathDerivedId && p.id === pathDerivedId));
}
// ── Plugin Store: search / filter / sort ────────────────────────────────
// Driven by the shared ListFilter controller (js/plugins/list_filter.js).
const STORE_COMPARATORS = {
'a-z': (a, b) => storeSortName(a).localeCompare(storeSortName(b)),
'z-a': (a, b) => storeSortName(b).localeCompare(storeSortName(a)),
'category': (a, b) => {
const catCmp = (a.category || '').localeCompare(b.category || '');
return catCmp !== 0 ? catCmp : storeSortName(a).localeCompare(storeSortName(b));
},
'author': (a, b) => {
const authCmp = (a.author || '').localeCompare(b.author || '');
return authCmp !== 0 ? authCmp : storeSortName(a).localeCompare(storeSortName(b));
},
'newest': (a, b) => {
// Missing dates count as epoch 0, so they land last under a descending
// sort — same as before.
const dateA = a.last_updated ? new Date(a.last_updated).getTime() : 0;
const dateB = b.last_updated ? new Date(b.last_updated).getTime() : 0;
return dateB - dateA; // newest first
},
};
function storeSortName(plugin) {
return (plugin.name || plugin.id || '').toLowerCase();
}
let _storeFilter = null;
function getStoreFilter() {
if (_storeFilter) return _storeFilter;
if (!window.ListFilter || typeof window.ListFilter.create !== 'function') {
console.warn('[PLUGINS] ListFilter helper unavailable — store filters disabled');
return null;
}
_storeFilter = window.ListFilter.create({
getItems: () => pluginStoreCache || [],
render: renderPluginStore,
search: {
el: 'plugin-search',
fields: ['name', 'description', 'author', 'id', 'category', 'tags'],
debounceMs: 150,
},
sort: {
el: 'store-sort',
default: 'a-z',
comparators: STORE_COMPARATORS,
},
controls: [
{
type: 'select',
el: 'plugin-category',
key: 'filterCategory',
default: '',
test: (plugin, value) => (plugin.category || '').toLowerCase() === value.toLowerCase(),
},
{
// Cycles All → Installed → Not Installed → All.
type: 'cycle',
el: 'store-filter-installed',
key: 'filterInstalled',
default: null,
values: [null, true, false],
test: (plugin, value) => (value === true ? isStorePluginInstalled(plugin) : !isStorePluginInstalled(plugin)),
render: (btn, value) => {
if (value === true) {
btn.innerHTML = '<i class="fas fa-check-circle mr-1 text-green-500"></i>Installed';
btn.classList.add('border-green-400', 'bg-green-50');
btn.classList.remove('border-gray-300', 'bg-white', 'border-red-400', 'bg-red-50');
} else if (value === false) {
btn.innerHTML = '<i class="fas fa-times-circle mr-1 text-red-500"></i>Not Installed';
btn.classList.add('border-red-400', 'bg-red-50');
btn.classList.remove('border-gray-300', 'bg-white', 'border-green-400', 'bg-green-50');
} else {
btn.innerHTML = '<i class="fas fa-filter mr-1 text-gray-400"></i>All';
btn.classList.add('border-gray-300', 'bg-white');
btn.classList.remove('border-green-400', 'bg-green-50', 'border-red-400', 'bg-red-50');
}
},
},
],
pagination: {
perPageEl: 'store-per-page',
defaultPerPage: 12,
topEl: 'store-pagination-top',
bottomEl: 'store-pagination-bottom',
infoEl: 'store-results-info',
infoBottomEl: 'store-results-info-bottom',
scrollToEl: 'plugin-store-grid',
infoFormat: (start, end, total) => `Showing ${start}\u2013${end} of ${total} plugins`,
emptyText: 'No plugins match your filters',
},
persist: {
read: () => ({
sort: safeLocalStorage.getItem('storeSort') || undefined,
perPage: parseInt(safeLocalStorage.getItem('storePerPage')) || undefined,
}),
write: (state) => {
safeLocalStorage.setItem('storeSort', state.sort);
safeLocalStorage.setItem('storePerPage', state.perPage);
},
},
clearEl: 'store-clear-filters',
activeCountEl: 'store-active-filters',
});
return _storeFilter;
}
function applyStoreFiltersAndSort(skipPageReset) {
if (!pluginStoreCache) return;
const ctl = getStoreFilter();
if (ctl) {
ctl.apply(skipPageReset);
return;
}
// Fallback: no helper, render the cache unfiltered rather than nothing.
renderPluginStore(pluginStoreCache);
}
function setupStoreFilterListeners() {
const ctl = getStoreFilter();
if (!ctl) return;
ctl.bind();
ctl.syncControls();
}
// Expose searchPluginStore on window.pluginManager for Alpine.js integration
window.searchPluginStore = searchPluginStore;
window.pluginManager.searchPluginStore = searchPluginStore;
function renderPluginStore(plugins) {
const container = document.getElementById('plugin-store-grid');
if (!container) {
debugLog('[RENDER] plugin-store-grid not yet available, deferring render');
window.__pendingStorePlugins = plugins;
return;
}
if (plugins.length === 0) {
container.innerHTML = `
<div class="col-span-full empty-state">
<div class="empty-state-icon">
<i class="fas fa-store"></i>
</div>
<p class="text-lg font-medium text-gray-700 mb-1">No plugins found</p>
<p class="text-sm text-gray-500">Try adjusting your search criteria</p>
</div>
`;
return;
}
setGridHtmlIfChanged(container, plugins.map(plugin => {
const installed = isStorePluginInstalled(plugin);
// Registry data: only open real web links, never javascript: URLs.
const repoLink = plugin.repo && /^https?:\/\//i.test(plugin.repo) ? plugin.repo : '';
return `
<div class="plugin-card">
<div class="flex items-start justify-between mb-4">
<div class="flex-1 min-w-0">
<div class="flex items-center flex-wrap gap-1.5 mb-2">
<h4 class="font-semibold text-gray-900 text-base">${escapeHtml(plugin.name || plugin.id)}</h4>
${plugin.verified ? '<span class="badge badge-success"><i class="fas fa-check-circle mr-1"></i>Verified</span>' : ''}
${installed ? '<span class="badge badge-success"><i class="fas fa-check mr-1"></i>Installed</span>' : ''}
${isNewPlugin(plugin.last_updated) ? '<span class="badge badge-info"><i class="fas fa-sparkles mr-1"></i>New</span>' : ''}
${plugin._source === 'custom_repository' ? `<span class="badge badge-accent" title="From: ${escapeHtml(plugin._repository_name || plugin._repository_url || 'Custom Repository')}"><i class="fas fa-bookmark mr-1"></i>Custom</span>` : ''}
</div>
<div class="text-sm text-gray-600 space-y-1.5 mb-3">
<p class="flex items-center"><i class="fas fa-user mr-2 text-gray-400 w-4"></i>${escapeHtml(plugin.author || 'Unknown')}</p>
${plugin.version ? `<p class="flex items-center"><i class="fas fa-tag mr-2 text-gray-400 w-4"></i>v${escapeHtml(plugin.version)}</p>` : ''}
<p class="flex items-center"><i class="fas fa-folder mr-2 text-gray-400 w-4"></i>${escapeHtml(plugin.category || 'General')}</p>
</div>
<p class="text-sm text-gray-700 leading-relaxed">${escapeHtml(plugin.description || 'No description available')}</p>
</div>
</div>
<!-- Plugin Tags -->
${plugin.tags && plugin.tags.length > 0 ? `
<div class="flex flex-wrap gap-1.5 mb-4">
${plugin.tags.map(tag => `<span class="badge badge-info">${escapeHtml(tag)}</span>`).join('')}
</div>
` : ''}
<!-- Store Actions -->
<div class="mt-auto pt-4 border-t border-gray-200 space-y-2">
<div class="flex items-center gap-2">
<label for="branch-input-${plugin.id.replace(/[^a-zA-Z0-9]/g, '-')}" class="text-xs text-gray-600 whitespace-nowrap">
<i class="fas fa-code-branch mr-1"></i>Branch:
</label>
<input type="text" id="branch-input-${plugin.id.replace(/[^a-zA-Z0-9]/g, '-')}"
placeholder="main (default)"
class="flex-1 px-2 py-1 text-xs border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
</div>
<div class="flex gap-2">
<button onclick='if(window.installPlugin){const branchInput = document.getElementById("branch-input-${plugin.id.replace(/[^a-zA-Z0-9]/g, '-')}"); window.installPlugin(${jsStringAttr(plugin.id)}, branchInput?.value?.trim() || null)}else{console.error("installPlugin not available")}' class="btn ${installed ? 'bg-gray-500 hover:bg-gray-600' : 'bg-green-600 hover:bg-green-700'} text-white px-4 py-2 rounded-md text-sm flex-1 font-semibold">
<i class="fas ${installed ? 'fa-redo' : 'fa-download'} mr-2"></i>${installed ? 'Reinstall' : 'Install'}
</button>
<button onclick='${repoLink ? `window.open(${jsStringAttr(plugin.plugin_path ? repoLink + "/tree/" + encodeURIComponent(plugin.default_branch || plugin.branch || "main") + "/" + plugin.plugin_path.split("/").map(encodeURIComponent).join("/") : repoLink)}, "_blank")` : `void(0)`}' ${repoLink ? '' : 'disabled'} class="btn bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm flex-1 font-semibold${repoLink ? '' : ' opacity-50 cursor-not-allowed'}">
<i class="fas fa-external-link-alt mr-2"></i>View
</button>
</div>
</div>
</div>`;
}).join(''));
}
// Expose functions to window for onclick handlers
window.installPlugin = function(pluginId, branch = null) {
showNotification(`Installing ${pluginId}${branch ? ` (branch: ${branch})` : ''}...`, 'info');
const requestBody = { plugin_id: pluginId };
if (branch) {
requestBody.branch = branch;
}
function enableAfterInstall() {
// Enable immediately so install -> enable is one step; only nudge
// for a restart once enablement actually succeeded (persistent
// toast; duration 0 = stays until dismissed).
Promise.resolve(window.togglePlugin(pluginId, true)).then(toggleResult => {
if (toggleResult && toggleResult.status === 'success') {
showNotification(
`${pluginId} installed and enabled — restart the display to show it`,
{
type: 'success',
duration: 0,
actionLabel: 'Restart Now',
onAction: () => restartDisplay()
}
);
} else {
showNotification(
`${pluginId} installed, but enabling it failed — use its toggle in the plugin list`,
'warning'
);
}
});
// Refresh installed plugins list, then re-render store to update badges
loadInstalledPlugins();
setTimeout(() => applyStoreFiltersAndSort(true), 500);
}
fetch('/api/v3/plugins/install', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
})
.then(response => response.json())
.then(data => {
showNotification(data.message, data.status);
if (data.status !== 'success') return;
if (data.data && data.data.operation_id) {
// Install runs async via the operation queue - this response only
// means "queued", not "installed". Enabling immediately here would
// 404 with "Plugin not found" against the toggle endpoint, since
// the plugin manager hasn't discovered the new plugin yet (seen
// live: "installation queued" followed immediately by a failed
// enable). Wait for the operation to actually finish first.
pollOperationStatus(data.data.operation_id, pluginId, pluginId, {
onComplete: enableAfterInstall,
onFailed: (errorMsg) => showNotification(errorMsg || `Failed to install ${pluginId}`, 'error'),
onTimeout: () => showNotification(`Install operation timed out for ${pluginId}`, 'error')
});
} else {
// No operation queue configured - install already completed synchronously.
enableAfterInstall();
}
})
.catch(error => {
showNotification('Error installing plugin: ' + error.message, 'error');
});
}
window.installFromCustomRegistry = function(pluginId, registryUrl, pluginPath, branch = null) {
const repoUrl = registryUrl;
const requestBody = {
repo_url: repoUrl,
plugin_id: pluginId,
plugin_path: pluginPath
};
if (branch) {
requestBody.branch = branch;
}
fetch('/api/v3/plugins/install-from-url', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBody)
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
showNotification(`Plugin ${data.plugin_id} installed successfully`, 'success');
// Refresh installed plugins and re-render custom registry
loadInstalledPlugins();
// Re-render custom registry to update install buttons
const registryUrlInput = document.getElementById('github-registry-url');
if (registryUrlInput && registryUrlInput.value.trim()) {
document.getElementById('load-registry-from-url').click();
}
} else {
showNotification(data.message || 'Installation failed', 'error');
}
})
.catch(error => {
let errorMsg = 'Error installing plugin: ' + error.message;
if (isNetworkFailure(error)) {
errorMsg += ' - Please try refreshing your browser.';
}
showNotification(errorMsg, 'error');
});
}
function setupCollapsibleSections() {
debugLog('[setupCollapsibleSections] Setting up collapsible sections...');
// The GitHub token panel is the only collapsible section left.
window.attachGithubTokenCollapseHandler();
debugLog('[setupCollapsibleSections] Collapsible sections setup complete');
}
function loadSavedRepositories() {
fetch('/api/v3/plugins/saved-repositories')
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
renderSavedRepositories(data.data.repositories || []);
}
})
.catch(error => {
console.error('Error loading saved repositories:', error);
});
}
function renderSavedRepositories(repositories) {
const container = document.getElementById('saved-repositories-list');
const countEl = document.getElementById('saved-repos-count');
if (!container) return;
if (countEl) {
countEl.textContent = `${repositories.length} saved`;
}
if (repositories.length === 0) {
container.innerHTML = '<p class="text-xs text-gray-500 italic">No saved repositories yet. Save a repository URL to see it here.</p>';
return;
}
container.innerHTML = repositories.map(repo => {
const repoUrl = repo.url || '';
const repoName = repo.name || repoUrl;
const repoType = repo.type || 'single';
return `
<div class="bg-white border border-gray-200 rounded p-2 flex items-center justify-between">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<i class="fas ${repoType === 'registry' ? 'fa-folder-open' : 'fa-code-branch'} text-gray-400 text-xs"></i>
<span class="text-sm font-medium text-gray-900 truncate" title="${escapeAttribute(repoUrl)}">${escapeHtml(repoName)}</span>
</div>
<p class="text-xs text-gray-500 truncate" title="${escapeAttribute(repoUrl)}">${escapeHtml(repoUrl)}</p>
</div>
<button onclick='if(window.removeSavedRepository){window.removeSavedRepository(${jsStringAttr(repoUrl)})}else{console.error("removeSavedRepository not available")}' class="ml-2 text-red-600 hover:text-red-800 text-xs px-2 py-1" title="Remove repository" aria-label="Remove saved repository ${escapeAttribute(repoName)}">
<i class="fas fa-trash" aria-hidden="true"></i>
</button>
</div>
`;
}).join('');
}
window.removeSavedRepository = function(repoUrl) {
if (!confirm('Remove this saved repository? Its plugins will no longer appear in the store.')) {
return;
}
fetch('/api/v3/plugins/saved-repositories', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ repo_url: repoUrl })
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
showNotification('Repository removed successfully', 'success');
renderSavedRepositories(data.data.repositories || []);
// Refresh plugin store to remove plugins from deleted repo
searchPluginStore();
} else {
showNotification(data.message || 'Failed to remove repository', 'error');
}
})
.catch(error => {
showNotification('Error removing repository: ' + error.message, 'error');
});
}
// Separate function to attach install button handler (can be called multiple times)
function attachInstallButtonHandler() {
debugLog('[attachInstallButtonHandler] ===== FUNCTION CALLED =====');
const installBtn = document.getElementById('install-plugin-from-url');
const pluginUrlInput = document.getElementById('github-plugin-url');
const pluginStatusDiv = document.getElementById('github-plugin-status');
debugLog('[attachInstallButtonHandler] Looking for install button elements:', {
installBtn: !!installBtn,
pluginUrlInput: !!pluginUrlInput,
pluginStatusDiv: !!pluginStatusDiv
});
if (installBtn && pluginUrlInput) {
// Check if handler already attached (prevent duplicates)
if (installBtn.hasAttribute('data-handler-attached')) {
debugLog('[attachInstallButtonHandler] Handler already attached, skipping');
return;
}
// Clone button to remove any existing listeners (prevents duplicate handlers)
const parent = installBtn.parentNode;
if (parent) {
const newBtn = installBtn.cloneNode(true);
// Ensure button type is set to prevent form submission
newBtn.type = 'button';
// Mark as having handler attached
newBtn.setAttribute('data-handler-attached', 'true');
parent.replaceChild(newBtn, installBtn);
debugLog('[attachInstallButtonHandler] Install button cloned and replaced, type:', newBtn.type);
newBtn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
debugLog('[attachInstallButtonHandler] Install button clicked!');
const repoUrl = pluginUrlInput.value.trim();
if (!repoUrl) {
if (pluginStatusDiv) {
pluginStatusDiv.innerHTML = '<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Please enter a GitHub URL</span>';
}
return;
}
if (!isGithubUrl(repoUrl)) {
if (pluginStatusDiv) {
pluginStatusDiv.innerHTML = '<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Please enter a valid GitHub URL</span>';
}
return;
}
newBtn.disabled = true;
newBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Installing...';
if (pluginStatusDiv) {
pluginStatusDiv.innerHTML = '<span class="text-blue-600"><i class="fas fa-spinner fa-spin mr-1"></i>Installing plugin...</span>';
}
const branch = document.getElementById('plugin-branch-input')?.value?.trim() || null;
const requestBody = { repo_url: repoUrl };
if (branch) {
requestBody.branch = branch;
}
debugLog('[attachInstallButtonHandler] Sending install request:', requestBody);
fetch('/api/v3/plugins/install-from-url', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBody)
})
.then(response => {
debugLog('[attachInstallButtonHandler] Response status:', response.status);
return response.json();
})
.then(data => {
debugLog('[attachInstallButtonHandler] Response data:', data);
if (data.status === 'success') {
if (pluginStatusDiv) {
pluginStatusDiv.innerHTML = `<span class="text-green-600"><i class="fas fa-check-circle mr-1"></i>Successfully installed: ${escapeHtml(data.plugin_id)}</span>`;
}
pluginUrlInput.value = '';
// Refresh installed plugins list
setTimeout(() => {
loadInstalledPlugins();
}, 1000);
} else {
if (pluginStatusDiv) {
pluginStatusDiv.innerHTML = `<span class="text-red-600"><i class="fas fa-times-circle mr-1"></i>${escapeHtml(data.message || 'Installation failed')}</span>`;
}
}
})
.catch(error => {
console.error('[attachInstallButtonHandler] Error:', error);
if (pluginStatusDiv) {
pluginStatusDiv.innerHTML = `<span class="text-red-600"><i class="fas fa-times-circle mr-1"></i>Error: ${escapeHtml(error.message)}</span>`;
}
})
.finally(() => {
newBtn.disabled = false;
newBtn.innerHTML = '<i class="fas fa-download mr-2"></i>Install';
});
});
// Allow Enter key to trigger install
pluginUrlInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
debugLog('[attachInstallButtonHandler] Enter key pressed, triggering install');
newBtn.click();
}
});
debugLog('[attachInstallButtonHandler] Install button handler attached successfully');
} else {
console.error('[attachInstallButtonHandler] Install button parent not found!');
}
} else {
console.warn('[attachInstallButtonHandler] Install button or URL input not found:', {
installBtn: !!installBtn,
pluginUrlInput: !!pluginUrlInput
});
}
}
function setupGitHubInstallHandlers() {
debugLog('[setupGitHubInstallHandlers] ===== FUNCTION CALLED ===== Setting up GitHub install handlers...');
// Toggle GitHub install section visibility
const toggleBtn = document.getElementById('toggle-github-install');
const installSection = document.getElementById('github-install-section');
const icon = document.getElementById('github-install-icon');
debugLog('[setupGitHubInstallHandlers] Elements found:', {
button: !!toggleBtn,
section: !!installSection,
icon: !!icon
});
if (toggleBtn && installSection) {
// Clone button to remove any existing listeners
const parent = toggleBtn.parentNode;
if (parent) {
const newBtn = toggleBtn.cloneNode(true);
parent.replaceChild(newBtn, toggleBtn);
newBtn.addEventListener('click', function(e) {
e.stopPropagation();
e.preventDefault();
debugLog('[setupGitHubInstallHandlers] GitHub install toggle clicked');
const section = document.getElementById('github-install-section');
const iconEl = document.getElementById('github-install-icon');
const btn = document.getElementById('toggle-github-install');
if (!section || !btn) return;
const hasHiddenClass = section.classList.contains('hidden');
const computedDisplay = window.getComputedStyle(section).display;
if (hasHiddenClass || computedDisplay === 'none') {
// Show section - remove hidden, ensure visible
section.classList.remove('hidden');
section.style.removeProperty('display');
if (iconEl) {
iconEl.classList.remove('fa-chevron-down');
iconEl.classList.add('fa-chevron-up');
}
const span = btn.querySelector('span');
if (span) span.textContent = 'Hide';
// Re-attach install button handler when section is shown (in case elements weren't ready before)
debugLog('[setupGitHubInstallHandlers] Section shown, will re-attach install button handler in 100ms');
setTimeout(() => {
debugLog('[setupGitHubInstallHandlers] Re-attaching install button handler now');
attachInstallButtonHandler();
}, 100);
} else {
// Hide section - add hidden, set display none
section.classList.add('hidden');
section.style.display = 'none';
if (iconEl) {
iconEl.classList.remove('fa-chevron-up');
iconEl.classList.add('fa-chevron-down');
}
const span = btn.querySelector('span');
if (span) span.textContent = 'Show';
}
});
debugLog('[setupGitHubInstallHandlers] Handler attached');
}
} else {
console.warn('[setupGitHubInstallHandlers] Required elements not found');
}
// Install single plugin from URL - use separate function so we can re-call it
debugLog('[setupGitHubInstallHandlers] About to call attachInstallButtonHandler...');
attachInstallButtonHandler();
debugLog('[setupGitHubInstallHandlers] Called attachInstallButtonHandler');
// Load registry from URL
const loadRegistryBtn = document.getElementById('load-registry-from-url');
const registryUrlInput = document.getElementById('github-registry-url');
const registryStatusDiv = document.getElementById('registry-status');
const customRegistryPlugins = document.getElementById('custom-registry-plugins');
const customRegistryGrid = document.getElementById('custom-registry-grid');
if (loadRegistryBtn && registryUrlInput) {
loadRegistryBtn.addEventListener('click', function() {
const repoUrl = registryUrlInput.value.trim();
if (!repoUrl) {
registryStatusDiv.innerHTML = '<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Please enter a GitHub URL</span>';
return;
}
if (!isGithubUrl(repoUrl)) {
registryStatusDiv.innerHTML = '<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Please enter a valid GitHub URL</span>';
return;
}
loadRegistryBtn.disabled = true;
loadRegistryBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Loading...';
registryStatusDiv.innerHTML = '<span class="text-blue-600"><i class="fas fa-spinner fa-spin mr-1"></i>Loading registry...</span>';
customRegistryPlugins.classList.add('hidden');
fetch('/api/v3/plugins/registry-from-url', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ repo_url: repoUrl })
})
.then(response => response.json())
.then(data => {
if (data.status === 'success' && data.plugins && data.plugins.length > 0) {
registryStatusDiv.innerHTML = `<span class="text-green-600"><i class="fas fa-check-circle mr-1"></i>Found ${data.plugins.length} plugins</span>`;
renderCustomRegistryPlugins(data.plugins, repoUrl);
customRegistryPlugins.classList.remove('hidden');
} else {
registryStatusDiv.innerHTML = '<span class="text-red-600"><i class="fas fa-times-circle mr-1"></i>No valid registry found or registry is empty</span>';
customRegistryPlugins.classList.add('hidden');
}
})
.catch(error => {
registryStatusDiv.innerHTML = `<span class="text-red-600"><i class="fas fa-times-circle mr-1"></i>Error: ${escapeHtml(error.message)}</span>`;
customRegistryPlugins.classList.add('hidden');
})
.finally(() => {
loadRegistryBtn.disabled = false;
loadRegistryBtn.innerHTML = '<i class="fas fa-search mr-2"></i>Load Registry';
});
});
// Allow Enter key to trigger load
registryUrlInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
loadRegistryBtn.click();
}
});
}
// Save registry URL button
const saveRegistryBtn = document.getElementById('save-registry-url');
if (saveRegistryBtn && registryUrlInput) {
saveRegistryBtn.addEventListener('click', function() {
const repoUrl = registryUrlInput.value.trim();
if (!repoUrl) {
showNotification('Please enter a repository URL first', 'error');
return;
}
if (!isGithubUrl(repoUrl)) {
showNotification('Please enter a valid GitHub URL', 'error');
return;
}
saveRegistryBtn.disabled = true;
saveRegistryBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Saving...';
fetch('/api/v3/plugins/saved-repositories', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ repo_url: repoUrl })
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
showNotification('Repository saved successfully! Its plugins will appear in the Plugin Store.', 'success');
renderSavedRepositories(data.data.repositories || []);
// Refresh plugin store to include new repo
searchPluginStore();
} else {
showNotification(data.message || 'Failed to save repository', 'error');
}
})
.catch(error => {
showNotification('Error saving repository: ' + error.message, 'error');
})
.finally(() => {
saveRegistryBtn.disabled = false;
saveRegistryBtn.innerHTML = '<i class="fas fa-bookmark mr-2"></i>Save Repository';
});
});
}
// Refresh saved repos button
const refreshSavedReposBtn = document.getElementById('refresh-saved-repos');
if (refreshSavedReposBtn) {
refreshSavedReposBtn.addEventListener('click', function() {
loadSavedRepositories();
searchPluginStore(); // Also refresh plugin store
showNotification('Repositories refreshed', 'success');
});
}
}
function renderCustomRegistryPlugins(plugins, registryUrl) {
const container = document.getElementById('custom-registry-grid');
if (!container) return;
if (plugins.length === 0) {
container.innerHTML = '<p class="text-sm text-gray-500 col-span-full">No plugins found in this registry</p>';
return;
}
container.innerHTML = plugins.map(plugin => {
const isInstalled = isStorePluginInstalled(plugin);
const pluginIdJs = jsStringAttr(plugin.id);
const escapedUrlJs = jsStringAttr(registryUrl);
const pluginPathJs = jsStringAttr(plugin.plugin_path || '');
const branchInputId = `branch-input-custom-${plugin.id.replace(/[^a-zA-Z0-9]/g, '-')}`;
const installBtn = isInstalled
? '<button class="px-3 py-1 text-xs bg-gray-400 text-white rounded cursor-not-allowed" disabled><i class="fas fa-check mr-1"></i>Installed</button>'
: `<button onclick='if(window.installFromCustomRegistry){const branchInput = document.getElementById("${branchInputId}"); window.installFromCustomRegistry(${pluginIdJs}, ${escapedUrlJs}, ${pluginPathJs}, branchInput?.value?.trim() || null)}else{console.error("installFromCustomRegistry not available")}' class="px-3 py-1 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded"><i class="fas fa-download mr-1"></i>Install</button>`;
return `
<div class="bg-white border border-gray-200 rounded-lg p-3">
<div class="flex items-start justify-between mb-2">
<div class="flex-1">
<h5 class="font-semibold text-sm text-gray-900">${escapeHtml(plugin.name || plugin.id)}</h5>
<p class="text-xs text-gray-600 mt-1 line-clamp-2">${escapeHtml(plugin.description || 'No description')}</p>
</div>
</div>
<div class="space-y-2 mt-2 pt-2 border-t border-gray-100">
<div class="flex items-center gap-2">
<label for="${branchInputId}" class="text-xs text-gray-600 whitespace-nowrap">
<i class="fas fa-code-branch mr-1"></i>Branch:
</label>
<input type="text" id="${branchInputId}"
placeholder="main (default)"
class="flex-1 px-2 py-1 text-xs border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500">Last updated ${formatDate(plugin.last_updated)}</span>
${installBtn}
</div>
</div>
</div>
`;
}).join('');
}
// Replaces the whole Plugin Manager panel. Only for a first load of the
// installed list that failed, when there is nothing else worth keeping on
// screen; every other failure is a notification.
function showInstalledLoadError(message) {
const content = document.getElementById('plugins-content');
if (!content) {
showNotification(message, 'error');
return;
}
content.innerHTML = `
<div class="text-center py-8">
<i class="fas fa-exclamation-triangle text-4xl text-red-400 mb-2"></i>
<p class="text-red-600">${escapeHtml(message)}</p>
</div>
`;
}
// fetch() rejects with a TypeError when no HTTP answer arrives at all
// (connection refused, offline); PluginAPI wraps that as NETWORK_ERROR.
function isNetworkFailure(error) {
return error instanceof TypeError || (error && error.error_code === 'NETWORK_ERROR');
}
// Validate that a URL's actual host is github.com (not just a substring
// match, which 'evil.com/github.com' or 'github.com.evil.com' would pass).
// This is only a UX nicety pointing users at a valid URL - the server does
// its own proper hostname validation before actually acting on the URL.
function isGithubUrl(url) {
try {
const hostname = new URL(url).hostname.toLowerCase();
return hostname === 'github.com' || hostname === 'www.github.com';
} catch {
return false;
}
}
// Short local names for window.LEDEscape (app-early.js), which says what each
// one is for. Function declarations, so they are usable anywhere in this IIFE.
function escapeHtml(text) { return window.LEDEscape.html(text); }
function escapeAttribute(text) { return window.LEDEscape.attr(text); }
function jsStringAttr(value) { return window.LEDEscape.jsStringAttr(value); }
function isNewPlugin(lastUpdated) {
if (!lastUpdated) return false;
try {
const date = new Date(lastUpdated);
const now = new Date();
const diffTime = Math.abs(now - date);
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays <= 7;
} catch (e) {
return false;
}
}
// Opens the GitHub token panel (the warning banner's "Configure Token" link),
// expanded, and reports whether a token is saved.
window.openGithubTokenSettings = function() {
const settings = document.getElementById('github-token-settings');
const warning = document.getElementById('github-auth-warning');
const tokenContent = document.getElementById('github-token-content');
if (settings) {
// Show settings panel using both methods
settings.classList.remove('hidden');
settings.style.display = '';
// Expand the content when opening
if (tokenContent) {
tokenContent.style.removeProperty('display');
tokenContent.classList.remove('hidden');
// Update collapse button state
const tokenIconCollapse = document.getElementById('github-token-icon-collapse');
const toggleTokenCollapseBtn = document.getElementById('toggle-github-token-collapse');
if (tokenIconCollapse) {
tokenIconCollapse.classList.remove('fa-chevron-down');
tokenIconCollapse.classList.add('fa-chevron-up');
}
if (toggleTokenCollapseBtn) {
const span = toggleTokenCollapseBtn.querySelector('span');
if (span) span.textContent = 'Collapse';
}
}
// When opening settings, hide the warning banner
if (warning) {
warning.classList.add('hidden');
warning.style.display = 'none';
// Clear any dismissal state since user is actively configuring
sessionStorage.removeItem('github-auth-warning-dismissed');
}
// Load token when opening the panel
loadGithubToken();
}
}
window.toggleGithubTokenVisibility = function() {
const input = document.getElementById('github-token-input');
const icon = document.getElementById('github-token-icon');
if (input && icon) {
const btn = icon.closest('button');
if (input.type === 'password') {
input.type = 'text';
icon.classList.remove('fa-eye');
icon.classList.add('fa-eye-slash');
if (btn) btn.setAttribute('aria-label', 'Hide token');
} else {
input.type = 'password';
icon.classList.remove('fa-eye-slash');
icon.classList.add('fa-eye');
if (btn) btn.setAttribute('aria-label', 'Show token');
}
}
}
window.loadGithubToken = function() {
const input = document.getElementById('github-token-input');
const loadButton = document.querySelector('button[onclick="loadGithubToken()"]');
if (!input) return;
// Set loading state on load button
const originalButtonContent = loadButton ? loadButton.innerHTML : '';
if (loadButton) {
loadButton.disabled = true;
loadButton.classList.add('opacity-50', 'cursor-not-allowed');
loadButton.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i>Loading...';
}
fetch('/api/v3/config/secrets')
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.then(data => {
if (data.status === 'success') {
// Handle empty data (secrets file doesn't exist) - API returns {} in this case
const secrets = data.data || {};
const token = secrets.github?.api_token || '';
const configured = token && token !== 'YOUR_GITHUB_PERSONAL_ACCESS_TOKEN';
if (input) {
// The endpoint masks what it returns, so this never holds
// the real token -- and the field is deliberately left
// empty rather than filled with the mask, which would be
// saved verbatim the next time the user pressed Save.
input.value = '';
if (configured) {
showNotification('A GitHub token is saved. Enter a new one to replace it.', 'success');
} else {
showNotification('No GitHub token configured. Enter a new token to save.', 'info');
}
}
} else {
throw new Error(data.message || 'Failed to load secrets configuration');
}
})
.catch(error => {
console.error('Error loading GitHub token:', error);
if (input) {
input.value = '';
}
// If it's a 404 or file doesn't exist, that's okay - just inform the user
if (error.message.includes('404') || error.message.includes('not found')) {
showNotification('No secrets file found. You can create one by saving a token.', 'info');
} else {
showNotification('Error loading GitHub token: ' + error.message, 'error');
}
})
.finally(() => {
// Restore button state
if (loadButton) {
loadButton.disabled = false;
loadButton.classList.remove('opacity-50', 'cursor-not-allowed');
loadButton.innerHTML = originalButtonContent;
}
});
}
window.saveGithubToken = function() {
const input = document.getElementById('github-token-input');
const saveButton = document.querySelector('button[onclick="saveGithubToken()"]');
if (!input) return;
const token = input.value.trim();
if (!token) {
showNotification('Please enter a GitHub token', 'error');
return;
}
// Client-side token validation
if (!token.startsWith('ghp_') && !token.startsWith('github_pat_')) {
if (!confirm('Token format looks invalid. GitHub tokens should start with "ghp_" or "github_pat_". Continue anyway?')) {
return;
}
}
// Set loading state on save button
const originalButtonContent = saveButton ? saveButton.innerHTML : '';
if (saveButton) {
saveButton.disabled = true;
saveButton.classList.add('opacity-50', 'cursor-not-allowed');
saveButton.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i>Saving...';
}
// Load current secrets config
fetch('/api/v3/config/secrets')
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.then(data => {
if (data.status === 'success') {
const secrets = data.data || {};
// Update GitHub token
if (!secrets.github) {
secrets.github = {};
}
secrets.github.api_token = token;
// Save updated secrets
return fetch('/api/v3/config/raw/secrets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(secrets)
});
} else {
throw new Error(data.message || 'Failed to load current secrets');
}
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.then(data => {
if (data.status === 'success') {
showNotification('GitHub token saved successfully! Rate limit increased to 5,000/hour', 'success');
// Clear input field for security (user can reload if needed)
input.value = '';
// Clear the dismissal flag so warning can properly hide/show based on token status
sessionStorage.removeItem('github-auth-warning-dismissed');
// Small delay to ensure backend has reloaded the token, then refresh status
// checkGitHubAuthStatus() will handle collapsing the panel automatically
// Reduced delay from 300ms to 100ms - backend should reload quickly
setTimeout(() => {
if (window.checkGitHubAuthStatus) {
window.checkGitHubAuthStatus();
}
}, 100);
} else {
throw new Error(data.message || 'Failed to save token');
}
})
.catch(error => {
console.error('Error saving GitHub token:', error);
showNotification('Error saving GitHub token: ' + error.message, 'error');
})
.finally(() => {
// Restore button state
if (saveButton) {
saveButton.disabled = false;
saveButton.classList.remove('opacity-50', 'cursor-not-allowed');
saveButton.innerHTML = originalButtonContent;
}
});
}
window.dismissGithubWarning = function() {
const warning = document.getElementById('github-auth-warning');
const settings = document.getElementById('github-token-settings');
if (warning) {
// Hide warning using both classList and style.display
warning.classList.add('hidden');
warning.style.display = 'none';
// Also hide settings if it's open (since they're combined now)
if (settings && !settings.classList.contains('hidden')) {
settings.classList.add('hidden');
settings.style.display = 'none';
}
// Remember dismissal for this session
sessionStorage.setItem('github-auth-warning-dismissed', 'true');
}
}
// Used by the array-of-objects helpers below, which live outside this IIFE.
window.renderArrayObjectItem = renderArrayObjectItem;
window.getSchemaProperty = getSchemaProperty;
// Assigned here because the functions are IIFE-local; the load-time and
// htmx:afterSettle wiring below, outside the IIFE, reaches them through window.
window.attachInstallButtonHandler = attachInstallButtonHandler;
window.setupGitHubInstallHandlers = setupGitHubInstallHandlers;
})(); // End IIFE
// Array-of-objects config fields: add and remove items (the inline onclick
// handlers that renderArrayObjectItem writes call these).
if (typeof window !== 'undefined') {
window.addArrayObjectItem = function(fieldId, fullKey, maxItems) {
const itemsContainer = document.getElementById(fieldId + '_items');
const hiddenInput = document.getElementById(fieldId + '_data');
if (!itemsContainer || !hiddenInput) return;
const currentItems = itemsContainer.querySelectorAll('.array-object-item');
if (currentItems.length >= maxItems) {
alert(`Maximum ${maxItems} items allowed`);
return;
}
// Get schema for item properties - ensure currentPluginConfig is available
// Try window.currentPluginConfig first (most reliable), then currentPluginConfig
const schema = (typeof window.currentPluginConfig !== 'undefined' && window.currentPluginConfig?.schema) ||
(typeof currentPluginConfig !== 'undefined' && currentPluginConfig?.schema);
if (!schema) {
console.error('addArrayObjectItem: Schema not available. currentPluginConfig may not be set.');
return;
}
// Use getSchemaProperty to properly handle nested schemas (e.g., news.custom_feeds)
const arraySchema = window.getSchemaProperty(schema, fullKey);
if (!arraySchema || arraySchema.type !== 'array' || !arraySchema.items) {
return;
}
const itemsSchema = arraySchema.items;
if (!itemsSchema || !itemsSchema.properties) return;
const newIndex = currentItems.length;
// renderArrayObjectItem is exported by the plugin-manager IIFE above.
const itemHtml = window.renderArrayObjectItem(fieldId, fullKey, itemsSchema.properties, {}, newIndex, itemsSchema);
itemsContainer.insertAdjacentHTML('beforeend', itemHtml);
window.updateArrayObjectData(fieldId);
// Update add button state
const addButton = itemsContainer.nextElementSibling;
if (addButton && currentItems.length + 1 >= maxItems) {
addButton.disabled = true;
addButton.style.opacity = '0.5';
addButton.style.cursor = 'not-allowed';
}
};
window.removeArrayObjectItem = function(fieldId, index) {
const itemsContainer = document.getElementById(fieldId + '_items');
if (!itemsContainer) return;
const item = itemsContainer.querySelector(`.array-object-item[data-index="${index}"]`);
if (item) {
item.remove();
// Re-index remaining items
// Use data-index for index storage - no need to encode index in onclick strings or IDs
const remainingItems = itemsContainer.querySelectorAll('.array-object-item');
remainingItems.forEach((itemEl, newIndex) => {
itemEl.setAttribute('data-index', newIndex);
// Update all inputs within this item - only update index in array bracket notation
itemEl.querySelectorAll('input, select, textarea').forEach(input => {
const name = input.getAttribute('name');
const id = input.id;
if (name) {
// Only replace index in bracket notation like [0], [1], etc.
// Match pattern: field_name[index] but not field_name123
const newName = name.replace(/\[(\d+)\]/, `[${newIndex}]`);
input.setAttribute('name', newName);
}
if (id) {
// Only update index in specific patterns like _item_0, _item_1
// Match pattern: _item_<digits> but be careful not to break other numeric IDs
const newId = id.replace(/_item_(\d+)/, `_item_${newIndex}`);
input.id = newId;
}
});
// The buttons' inline onclick handlers carry the item index, so
// rewrite it in each one.
itemEl.querySelectorAll('button[onclick]').forEach(button => {
const onclick = button.getAttribute('onclick');
if (onclick) {
// Match patterns like:
// removeArrayObjectItem('fieldId', 0)
// handleArrayObjectFileUpload(event, 'fieldId', 0, 'propKey', 'pluginId')
// removeArrayObjectFile('fieldId', 0, 'propKey')
// Only replace the numeric index parameter (second or third argument depending on function)
let newOnclick = onclick;
// For removeArrayObjectItem('fieldId', index) - second param
newOnclick = newOnclick.replace(
/removeArrayObjectItem\s*\(\s*['"]([^'"]+)['"]\s*,\s*\d+\s*\)/g,
`removeArrayObjectItem('$1', ${newIndex})`
);
// For handleArrayObjectFileUpload(event, 'fieldId', index, ...) - third param
newOnclick = newOnclick.replace(
/handleArrayObjectFileUpload\s*\(\s*event\s*,\s*['"]([^'"]+)['"]\s*,\s*\d+\s*,/g,
`handleArrayObjectFileUpload(event, '$1', ${newIndex},`
);
// For removeArrayObjectFile('fieldId', index, ...) - second param
newOnclick = newOnclick.replace(
/removeArrayObjectFile\s*\(\s*['"]([^'"]+)['"]\s*,\s*\d+\s*,/g,
`removeArrayObjectFile('$1', ${newIndex},`
);
button.setAttribute('onclick', newOnclick);
}
});
});
window.updateArrayObjectData(fieldId);
// Update add button state
const addButton = itemsContainer.nextElementSibling;
if (addButton && addButton.getAttribute('onclick')) {
// Extract maxItems from onclick attribute more safely
// Pattern: addArrayObjectItem('fieldId', 'fullKey', maxItems)
const onclickMatch = addButton.getAttribute('onclick').match(/addArrayObjectItem\s*\([^,]+,\s*[^,]+,\s*(\d+)\)/);
if (onclickMatch && onclickMatch[1]) {
const maxItems = parseInt(onclickMatch[1]);
if (remainingItems.length < maxItems) {
addButton.disabled = false;
addButton.style.opacity = '1';
addButton.style.cursor = 'pointer';
}
}
}
}
};
}
// Force initialization immediately when script loads (for HTMX swapped content)
debugLog('Plugins script loaded, checking for elements...');
// Check GitHub auth status immediately if elements exist (don't wait for full initialization)
if (window.checkGitHubAuthStatus && document.getElementById('github-auth-warning')) {
debugLog('[EARLY] Checking GitHub auth status immediately on script load...');
window.checkGitHubAuthStatus();
}
setTimeout(function() {
const installedGrid = document.getElementById('installed-plugins-grid');
if (installedGrid) {
debugLog('Found installed-plugins-grid, forcing initialization...');
window.pluginManager.initialized = false;
window.initPluginsPage();
} else {
debugLog('installed-plugins-grid not found yet, will retry via event listeners');
}
// Also try to attach install button handler after a delay (fallback).
// Only run if the install button element is already in the DOM (i.e. the
// plugins partial has been loaded); otherwise the htmx:afterSettle listener
// below handles it when the tab is first visited.
setTimeout(() => {
if (typeof window.attachInstallButtonHandler === 'function' &&
document.getElementById('install-plugin-from-url')) {
window.attachInstallButtonHandler();
}
}, 500);
}, 200);
// Re-run install button wiring after HTMX settles the plugins tab content.
// Guard with element check so it only fires when the plugins partial is in the DOM,
// preventing spurious warnings on other tab loads.
document.addEventListener('htmx:afterSettle', function() {
if (document.getElementById('install-plugin-from-url') &&
typeof window.attachInstallButtonHandler === 'function') {
window.attachInstallButtonHandler();
}
});
// ─── Starlark Apps Integration ──────────────────────────────────────────────
(function() {
'use strict';
let starlarkSectionVisible = false;
let starlarkFullCache = null; // All apps from server
let starlarkDataLoaded = false;
// ── Helpers ─────────────────────────────────────────────────────────────
function escapeHtml(str) { return window.LEDEscape.html(str); }
function isStarlarkInstalled(appId) {
// Check window.installedPlugins (populated by loadInstalledPlugins)
if (window.installedPlugins && Array.isArray(window.installedPlugins)) {
return window.installedPlugins.some(p => p.id === 'starlark:' + appId);
}
return false;
}
// ── Section Toggle + Init ───────────────────────────────────────────────
function initStarlarkSection() {
const toggleBtn = document.getElementById('toggle-starlark-section');
if (toggleBtn && !toggleBtn._starlarkInit) {
toggleBtn._starlarkInit = true;
toggleBtn.addEventListener('click', function() {
starlarkSectionVisible = !starlarkSectionVisible;
const content = document.getElementById('starlark-section-content');
const icon = document.getElementById('starlark-section-icon');
if (content) content.classList.toggle('hidden', !starlarkSectionVisible);
if (icon) {
icon.classList.toggle('fa-chevron-down', !starlarkSectionVisible);
icon.classList.toggle('fa-chevron-up', starlarkSectionVisible);
}
this.querySelector('span').textContent = starlarkSectionVisible ? 'Hide' : 'Show';
if (starlarkSectionVisible) {
loadStarlarkStatus();
if (!starlarkDataLoaded) fetchStarlarkApps();
}
});
}
// Persisted sort/perPage are restored by the controller's syncControls().
setupStarlarkFilterListeners();
const uploadBtn = document.getElementById('starlark-upload-btn');
if (uploadBtn && !uploadBtn._starlarkInit) {
uploadBtn._starlarkInit = true;
uploadBtn.addEventListener('click', function() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.star';
input.onchange = function(e) {
if (e.target.files.length > 0) uploadStarlarkFile(e.target.files[0]);
};
input.click();
});
}
}
// ── Status ──────────────────────────────────────────────────────────────
function loadStarlarkStatus() {
fetch('/api/v3/starlark/status')
.then(r => r.json())
.then(data => {
const banner = document.getElementById('starlark-pixlet-status');
if (!banner) return;
if (data.pixlet_available) {
banner.innerHTML = `<div class="bg-green-50 border border-green-200 rounded-lg p-3 text-sm text-green-800">
<i class="fas fa-check-circle mr-2"></i>Pixlet available${data.pixlet_version ? ' (' + escapeHtml(data.pixlet_version) + ')' : ''} &mdash; ${data.installed_apps || 0} app(s) installed
</div>`;
} else {
banner.innerHTML = `<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-3 text-sm text-yellow-800">
<i class="fas fa-exclamation-triangle mr-2"></i>Pixlet not installed.
<button onclick="window.installPixlet()" class="ml-2 px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white rounded text-xs font-semibold">Install Pixlet</button>
</div>`;
}
})
.catch(err => console.error('Starlark status error:', err));
}
// ── Bulk Fetch All Apps ─────────────────────────────────────────────────
function fetchStarlarkApps() {
const grid = document.getElementById('starlark-apps-grid');
if (grid) {
grid.innerHTML = `<div class="col-span-full">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
${Array(10).fill('<div class="bg-gray-200 rounded-lg p-4 h-48 animate-pulse"></div>').join('')}
</div>
</div>`;
}
fetch('/api/v3/starlark/repository/browse')
.then(r => r.json())
.then(data => {
if (data.status !== 'success') {
if (grid) grid.innerHTML = `<div class="col-span-full text-center py-8 text-red-500"><i class="fas fa-exclamation-circle mr-2"></i>${escapeHtml(data.message || 'Failed to load')}</div>`;
return;
}
starlarkFullCache = data.apps || [];
starlarkDataLoaded = true;
// Populate category dropdown
const catSelect = document.getElementById('starlark-category');
if (catSelect) {
catSelect.innerHTML = '<option value="">All Categories</option>';
(data.categories || []).forEach(cat => {
const opt = document.createElement('option');
opt.value = cat;
opt.textContent = cat;
catSelect.appendChild(opt);
});
}
// Populate author dropdown
const authSelect = document.getElementById('starlark-filter-author');
if (authSelect) {
authSelect.innerHTML = '<option value="">All Authors</option>';
(data.authors || []).forEach(author => {
const opt = document.createElement('option');
opt.value = author;
opt.textContent = author;
authSelect.appendChild(opt);
});
}
const countEl = document.getElementById('starlark-apps-count');
if (countEl) countEl.textContent = `${data.count} apps`;
if (data.rate_limit) {
debugLog(`[Starlark] GitHub rate limit: ${data.rate_limit.remaining}/${data.rate_limit.limit} remaining` + (data.cached ? ' (cached)' : ''));
}
applyStarlarkFiltersAndSort();
})
.catch(err => {
console.error('Starlark browse error:', err);
if (grid) grid.innerHTML = '<div class="col-span-full text-center py-8 text-red-500"><i class="fas fa-exclamation-circle mr-2"></i>Error loading apps</div>';
});
}
// ── Filter / Sort / Paginate ────────────────────────────────────────────
// The same ListFilter controller as the store and the installed list.
function starlarkSortName(app) {
return (app.name || app.id || '').toLowerCase();
}
const STARLARK_COMPARATORS = {
'a-z': (a, b) => starlarkSortName(a).localeCompare(starlarkSortName(b)),
'z-a': (a, b) => starlarkSortName(b).localeCompare(starlarkSortName(a)),
'category': (a, b) => {
const catCmp = (a.category || '').localeCompare(b.category || '');
return catCmp !== 0 ? catCmp : starlarkSortName(a).localeCompare(starlarkSortName(b));
},
'author': (a, b) => {
const authCmp = (a.author || '').localeCompare(b.author || '');
return authCmp !== 0 ? authCmp : starlarkSortName(a).localeCompare(starlarkSortName(b));
},
};
function renderInstalledCycleButton(btn, value) {
if (value === true) {
btn.innerHTML = '<i class="fas fa-check-circle mr-1 text-green-500"></i>Installed';
btn.classList.add('border-green-400', 'bg-green-50');
btn.classList.remove('border-gray-300', 'bg-white', 'border-red-400', 'bg-red-50');
} else if (value === false) {
btn.innerHTML = '<i class="fas fa-times-circle mr-1 text-red-500"></i>Not Installed';
btn.classList.add('border-red-400', 'bg-red-50');
btn.classList.remove('border-gray-300', 'bg-white', 'border-green-400', 'bg-green-50');
} else {
btn.innerHTML = '<i class="fas fa-filter mr-1 text-gray-400"></i>All';
btn.classList.add('border-gray-300', 'bg-white');
btn.classList.remove('border-green-400', 'bg-green-50', 'border-red-400', 'bg-red-50');
}
}
let _starlarkFilter = null;
function getStarlarkFilter() {
if (_starlarkFilter) return _starlarkFilter;
if (!window.ListFilter || typeof window.ListFilter.create !== 'function') {
console.warn('[STARLARK] ListFilter helper unavailable — filters disabled');
return null;
}
_starlarkFilter = window.ListFilter.create({
getItems: () => starlarkFullCache || [],
render: (pageApps) => renderStarlarkApps(pageApps, document.getElementById('starlark-apps-grid')),
search: {
el: 'starlark-search',
fields: ['name', 'summary', 'desc', 'author', 'id', 'category'],
debounceMs: 150,
},
sort: {
el: 'starlark-sort',
default: 'a-z',
comparators: STARLARK_COMPARATORS,
},
controls: [
{
type: 'select',
el: 'starlark-category',
key: 'filterCategory',
default: '',
test: (app, value) => (app.category || '').toLowerCase() === value.toLowerCase(),
},
{
// Author matching is exact/case-sensitive — the options come
// straight from the server's author list.
type: 'select',
el: 'starlark-filter-author',
key: 'filterAuthor',
default: '',
test: (app, value) => app.author === value,
},
{
type: 'cycle',
el: 'starlark-filter-installed',
key: 'filterInstalled',
default: null,
values: [null, true, false],
test: (app, value) => (value === true ? isStarlarkInstalled(app.id) : !isStarlarkInstalled(app.id)),
render: renderInstalledCycleButton,
},
],
pagination: {
perPageEl: 'starlark-per-page',
defaultPerPage: 24,
topEl: 'starlark-pagination-top',
bottomEl: 'starlark-pagination-bottom',
infoEl: 'starlark-results-info',
infoBottomEl: 'starlark-results-info-bottom',
scrollToEl: 'starlark-apps-grid',
infoFormat: (start, end, total) => `Showing ${start}\u2013${end} of ${total} apps`,
emptyText: 'No apps match your filters',
},
persist: {
read: () => ({
sort: safeLocalStorage.getItem('starlarkSort') || undefined,
perPage: parseInt(safeLocalStorage.getItem('starlarkPerPage')) || undefined,
}),
write: (state) => {
safeLocalStorage.setItem('starlarkSort', state.sort);
safeLocalStorage.setItem('starlarkPerPage', state.perPage);
},
},
clearEl: 'starlark-clear-filters',
activeCountEl: 'starlark-active-filters',
});
return _starlarkFilter;
}
function applyStarlarkFiltersAndSort(skipPageReset) {
if (!starlarkFullCache) return;
const ctl = getStarlarkFilter();
if (ctl) {
ctl.apply(skipPageReset);
return;
}
renderStarlarkApps(starlarkFullCache, document.getElementById('starlark-apps-grid'));
}
// ── Card Rendering ──────────────────────────────────────────────────────
function renderStarlarkApps(apps, grid) {
if (!grid) return;
if (!apps || apps.length === 0) {
grid.innerHTML = '<div class="col-span-full empty-state"><div class="empty-state-icon"><i class="fas fa-star"></i></div><p>No Starlark apps found</p></div>';
return;
}
const setGridHtmlIfChanged = window.__pmSetGridHtmlIfChanged ||
((el, html) => { el.innerHTML = html; return true; });
setGridHtmlIfChanged(grid, apps.map(app => {
const installed = isStarlarkInstalled(app.id);
return `
<div class="plugin-card" data-app-id="${escapeHtml(app.id)}">
<div class="flex items-start justify-between mb-4">
<div class="flex-1 min-w-0">
<div class="flex items-center flex-wrap gap-1.5 mb-2">
<!-- break-words: Starlark app names come from the
community repo and some are long single tokens,
which overflowed the card instead of wrapping. -->
<h4 class="font-semibold text-gray-900 text-base break-words">${escapeHtml(app.name || app.id)}</h4>
<span class="badge badge-warning"><i class="fas fa-star mr-1"></i>Starlark</span>
${installed ? '<span class="badge badge-success"><i class="fas fa-check mr-1"></i>Installed</span>' : ''}
</div>
<div class="text-sm text-gray-600 space-y-1.5 mb-3">
${app.author ? `<p class="flex items-center"><i class="fas fa-user mr-2 text-gray-400 w-4"></i>${escapeHtml(app.author)}</p>` : ''}
${app.category ? `<p class="flex items-center"><i class="fas fa-folder mr-2 text-gray-400 w-4"></i>${escapeHtml(app.category)}</p>` : ''}
</div>
<p class="text-sm text-gray-700 leading-relaxed">${escapeHtml(app.summary || app.desc || 'No description')}</p>
</div>
</div>
<div class="flex gap-2 mt-auto pt-3 border-t border-gray-200">
<button data-action="install" class="btn ${installed ? 'bg-gray-500 hover:bg-gray-600' : 'bg-green-600 hover:bg-green-700'} text-white px-4 py-2 rounded-md text-sm font-semibold flex-1 flex justify-center items-center">
<i class="fas ${installed ? 'fa-redo' : 'fa-download'} mr-2"></i>${installed ? 'Reinstall' : 'Install'}
</button>
<button data-action="view" class="btn bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-semibold flex justify-center items-center">
<i class="fas fa-external-link-alt mr-1"></i>View
</button>
</div>
</div>`;
}).join(''));
// Add delegated event listener only once (prevent duplicate handlers)
if (!grid.dataset.starlarkHandlerAttached) {
grid.addEventListener('click', function handleStarlarkGridClick(e) {
const button = e.target.closest('button[data-action]');
if (!button) return;
const card = button.closest('.plugin-card');
if (!card) return;
const appId = card.dataset.appId;
if (!appId) return;
const action = button.dataset.action;
if (action === 'install') {
window.installStarlarkApp(appId);
} else if (action === 'view') {
window.open('https://github.com/tronbyt/apps/tree/main/apps/' + encodeURIComponent(appId), '_blank');
}
});
grid.dataset.starlarkHandlerAttached = 'true';
}
}
// ── Event Listeners ─────────────────────────────────────────────────────
function setupStarlarkFilterListeners() {
const ctl = getStarlarkFilter();
if (!ctl) return;
ctl.bind();
ctl.syncControls();
}
// ── Install / Upload / Pixlet ───────────────────────────────────────────
// Reload the installed list (the new app joins it as starlark:<id>), then
// redraw the current page of apps so its Installed badge shows. A failed
// reload has already been reported by loadInstalledPlugins.
function refreshAfterStarlarkChange() {
window.pluginManager.loadInstalledPlugins(true)
.catch(() => {})
.then(() => applyStarlarkFiltersAndSort(true));
}
window.installStarlarkApp = function(appId) {
if (!confirm(`Install Starlark app "${appId}" from Tronbyte repository?`)) return;
fetch('/api/v3/starlark/repository/install', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({app_id: appId})
})
.then(r => r.json())
.then(data => {
if (data.status === 'success') {
alert(`Installed: ${data.message || appId}`);
refreshAfterStarlarkChange();
} else {
alert(`Install failed: ${data.message || 'Unknown error'}`);
}
})
.catch(err => {
console.error('Install error:', err);
alert('Install failed: ' + err.message);
});
};
window.installPixlet = function() {
if (!confirm('Download and install Pixlet binary? This may take a few minutes.')) return;
fetch('/api/v3/starlark/install-pixlet', {method: 'POST'})
.then(r => r.json())
.then(data => {
if (data.status === 'success') {
alert(data.message || 'Pixlet installed!');
loadStarlarkStatus();
} else {
alert('Pixlet install failed: ' + (data.message || 'Unknown error'));
}
})
.catch(err => alert('Pixlet install failed: ' + err.message));
};
function uploadStarlarkFile(file) {
const formData = new FormData();
formData.append('file', file);
const appId = file.name.replace('.star', '');
formData.append('app_id', appId);
formData.append('name', appId.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()));
fetch('/api/v3/starlark/upload', {method: 'POST', body: formData})
.then(r => r.json())
.then(data => {
if (data.status === 'success') {
alert(`Uploaded: ${data.app_id}`);
refreshAfterStarlarkChange();
} else {
alert('Upload failed: ' + (data.message || 'Unknown error'));
}
})
.catch(err => alert('Upload failed: ' + err.message));
}
// ── Bootstrap ───────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', initStarlarkSection);
document.addEventListener('htmx:afterSwap', function(e) {
if (e.detail && e.detail.target && e.detail.target.id === 'plugins-content') {
initStarlarkSection();
}
});
})();