/* global debugLog */
// ─── 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 critical functions immediately so they're available before any HTML is rendered
// Debug logging controlled by safeLocalStorage.setItem('pluginDebug', 'true')
const _PLUGIN_DEBUG_EARLY = safeLocalStorage.getItem('pluginDebug') === 'true';
if (_PLUGIN_DEBUG_EARLY) debugLog('[PLUGINS SCRIPT] Defining configurePlugin and togglePlugin at top level...');
// Define configurePlugin early to ensure it's always available
window.configurePlugin = window.configurePlugin || async function(pluginId) {
if (_PLUGIN_DEBUG_EARLY) debugLog('[PLUGINS STUB] configurePlugin called for', pluginId);
// Switch to the plugin's configuration tab instead of opening a modal
// This matches the behavior of clicking the plugin tab at the top
function getAppComponent() {
if (window.Alpine) {
const appElement = document.querySelector('[x-data="app()"]');
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
return appElement._x_dataStack[0];
}
}
return null;
}
const appComponent = getAppComponent();
if (appComponent) {
// Set the active tab to the plugin ID
appComponent.activeTab = pluginId;
if (_PLUGIN_DEBUG_EARLY) debugLog('[PLUGINS STUB] 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');
if (typeof showNotification === 'function') {
showNotification('Unable to switch to plugin configuration. Please refresh the page.', 'error');
}
}
};
// Initialize per-plugin toggle request token map for race condition protection
if (!window._pluginToggleRequests) {
window._pluginToggleRequests = {};
}
// Define togglePlugin early to ensure it's always available
window.togglePlugin = window.togglePlugin || function(pluginId, enabled) {
if (_PLUGIN_DEBUG_EARLY) debugLog('[PLUGINS STUB] 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');
}
// Disable wrapper to provide visual feedback
if (wrapperDiv) {
wrapperDiv.classList.add('opacity-50', 'pointer-events-none');
}
// Update wrapper background and border
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');
}
}
// Update toggle track
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');
}
}
// Update toggle handle
if (toggleHandle) {
if (enabled) {
toggleHandle.classList.add('translate-x-full', 'border-green-500');
toggleHandle.classList.remove('border-gray-400');
toggleHandle.innerHTML = '';
} else {
toggleHandle.classList.remove('translate-x-full', 'border-green-500');
toggleHandle.classList.add('border-gray-400');
toggleHandle.innerHTML = '';
}
}
// Update label with icon and text
if (toggleLabel) {
if (enabled) {
toggleLabel.className = 'text-sm font-semibold text-green-700 flex items-center gap-1.5';
toggleLabel.innerHTML = 'Enabled';
} else {
toggleLabel.className = 'text-sm font-semibold text-gray-600 flex items-center gap-1.5';
toggleLabel.innerHTML = 'Disabled';
}
}
if (typeof showNotification === 'function') {
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;
}
if (typeof showNotification === 'function') {
showNotification(data.message, data.status);
}
if (data.status === 'success') {
// Update local state
if (plugin) {
plugin.enabled = enabled;
}
// Refresh the list to ensure consistency
if (typeof loadInstalledPlugins === 'function') {
loadInstalledPlugins();
}
} else {
// Revert the toggle if API call failed
if (plugin) {
plugin.enabled = !enabled;
}
if (typeof loadInstalledPlugins === 'function') {
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;
}
if (typeof showNotification === 'function') {
showNotification('Error toggling plugin: ' + error.message, 'error');
}
// Revert the toggle if API call failed
if (plugin) {
plugin.enabled = !enabled;
}
if (typeof loadInstalledPlugins === 'function') {
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;
window.__pluginDomReady = window.__pluginDomReady || false;
// 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. (It used to test `typeof handlePluginAction`, which is IIFE-scoped
// and so never visible here: every click took a copied fallback instead, which
// asked to confirm an uninstall twice and sent Starlark app uninstalls to the
// plugin endpoint.)
(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');
})();
// Note: configurePlugin and togglePlugin are now defined at the top of the file (after uninstallPlugin)
// to ensure they're available immediately when the script loads
// Verify functions are defined (debug only)
if (_PLUGIN_DEBUG_EARLY) {
debugLog('[PLUGINS SCRIPT] Functions defined:', {
configurePlugin: typeof window.configurePlugin,
togglePlugin: typeof window.togglePlugin
});
if (typeof window.configurePlugin === 'function') {
debugLog('[PLUGINS SCRIPT] ✓ configurePlugin ready');
}
if (typeof window.togglePlugin === 'function') {
debugLog('[PLUGINS SCRIPT] ✓ togglePlugin ready');
}
}
// 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 = 'Please enter a GitHub URL';
}
return;
}
if (!isGithubUrl(repoUrl)) {
if (statusDiv) {
statusDiv.innerHTML = 'Please enter a valid GitHub URL';
}
return;
}
// Disable button and show loading
if (installBtn) {
installBtn.disabled = true;
installBtn.innerHTML = 'Installing...';
}
if (statusDiv) {
statusDiv.innerHTML = 'Installing plugin...';
}
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 = `Successfully installed: ${data.plugin_id}`;
}
urlInput.value = '';
// Show notification if available
if (typeof showNotification === 'function') {
showNotification(`Plugin ${data.plugin_id} installed successfully`, 'success');
}
// Refresh installed plugins list if function available
setTimeout(() => {
if (typeof loadInstalledPlugins === 'function') {
loadInstalledPlugins();
} else if (typeof window.loadInstalledPlugins === 'function') {
window.loadInstalledPlugins();
}
}, 1000);
} else {
if (statusDiv) {
statusDiv.innerHTML = `${data.message || 'Installation failed'}`;
}
if (typeof showNotification === 'function') {
showNotification(data.message || 'Installation failed', 'error');
}
}
})
.catch(error => {
console.error('[handleGitHubPluginInstall] Error:', error);
if (statusDiv) {
statusDiv.innerHTML = `Error: ${error.message}`;
}
if (typeof showNotification === 'function') {
showNotification('Error installing plugin: ' + error.message, 'error');
}
})
.finally(() => {
if (installBtn) {
installBtn.disabled = false;
installBtn.innerHTML = '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 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';
if (_PLUGIN_DEBUG_EARLY) 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;
}
// Check if required elements exist
const installedGrid = document.getElementById('installed-plugins-grid');
if (!installedGrid) {
debugLog('Plugin elements not ready yet');
return false;
}
window.pluginManager.initializing = true;
window.__pluginDomReady = 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');
renderInstalledPlugins(window.__pendingInstalledPlugins);
window.__pendingInstalledPlugins = null;
}
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;
}
// Consolidated initialization function
function initializePluginPageWhenReady() {
return window.initPluginsPage();
}
// Single initialization entry point
(function() {
let initTimer = null;
function attemptInit() {
// Clear any pending timer
if (initTimer) {
clearTimeout(initTimer);
initTimer = null;
}
// Try immediate initialization
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
})();
// Initialization guard to prevent multiple initializations
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)
debugLog('[initializePlugins] Force setting up GitHub handlers anyway...');
if (typeof setupGitHubInstallHandlers === 'function') {
setupGitHubInstallHandlers();
} else {
console.error('[initializePlugins] setupGitHubInstallHandlers not found!');
}
return;
}
pluginsInitialized = true;
debugLog('[initializePlugins] Starting initialization...');
pluginLog('[INIT] Initializing plugins...');
// Check GitHub authentication status
debugLog('[INIT] Checking for checkGitHubAuthStatus function...', {
exists: typeof window.checkGitHubAuthStatus,
type: typeof window.checkGitHubAuthStatus
});
if (window.checkGitHubAuthStatus) {
debugLog('[INIT] Calling checkGitHubAuthStatus...');
try {
window.checkGitHubAuthStatus();
} catch (error) {
console.error('[INIT] Error calling checkGitHubAuthStatus:', error);
}
} else {
console.warn('[INIT] checkGitHubAuthStatus not available yet');
}
// 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.
if (typeof applyStoreFiltersAndSort === 'function') {
applyStoreFiltersAndSort(true);
}
});
// #plugin-search and #plugin-category are wired by the store's ListFilter
// controller (setupStoreFilterListeners). They used to ALSO be bound here to
// searchPluginStore; because that binding passed the DOM event as the
// `fetchCommitInfo` argument, every keystroke and category change skipped the
// cached-filter fast path and refetched /api/v3/plugins/store/list with commit
// info. Filtering the cached list is the controller's job — leave it to it.
// Setup GitHub installation handlers
debugLog('[initializePlugins] About to call setupGitHubInstallHandlers...');
if (typeof setupGitHubInstallHandlers === 'function') {
debugLog('[initializePlugins] setupGitHubInstallHandlers is a function, calling it...');
setupGitHubInstallHandlers();
debugLog('[initializePlugins] setupGitHubInstallHandlers called');
} else {
console.error('[initializePlugins] ERROR: setupGitHubInstallHandlers is not a function! Type:', typeof setupGitHubInstallHandlers);
}
// Setup collapsible section handlers
setupCollapsibleSections();
// Load saved repositories
loadSavedRepositories();
pluginLog('[INIT] Plugins initialized');
}
// Track in-flight requests to prevent duplicates
// ===== 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;
}
};
// Debug flag - set via safeLocalStorage.setItem('pluginDebug', 'true')
const PLUGIN_DEBUG = typeof localStorage !== 'undefined' && safeLocalStorage.getItem('pluginDebug') === 'true';
function pluginLog(...args) {
if (PLUGIN_DEBUG) debugLog(...args);
}
function loadInstalledPlugins(forceRefresh = false) {
// Return cached data if valid and not forcing refresh
if (!forceRefresh && pluginLoadCache.isValid()) {
pluginLog('[CACHE] Returning cached plugin data');
// Update window.installedPlugins from cache
window.installedPlugins = pluginLoadCache.data;
// Dispatch event to notify Alpine component
document.dispatchEvent(new CustomEvent('pluginsUpdated', {
detail: { plugins: pluginLoadCache.data }
}));
pluginLog('[CACHE] Dispatched pluginsUpdated event from cache');
// Still render to ensure UI is updated
renderInstalledPlugins(pluginLoadCache.data);
return Promise.resolve(pluginLoadCache.data);
}
// If a request is already in progress, return the existing promise
if (pluginLoadCache.promise) {
pluginLog('[CACHE] Request in progress, returning existing promise');
return pluginLoadCache.promise;
}
pluginLog('[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());
// Store the promise
pluginLoadCache.promise = fetchPromise
.then(data => {
if (data.status === 'success') {
const pluginsData = data.data?.plugins;
installedPlugins = Array.isArray(pluginsData) ? pluginsData : [];
// Update cache
pluginLoadCache.data = installedPlugins;
pluginLoadCache.timestamp = Date.now();
// Always update window.installedPlugins to ensure Alpine component can detect changes
window.installedPlugins = installedPlugins;
// Dispatch event to notify Alpine component to update tabs
document.dispatchEvent(new CustomEvent('pluginsUpdated', {
detail: { plugins: installedPlugins }
}));
pluginLog('[FETCH] Dispatched pluginsUpdated event with', installedPlugins.length, 'plugins');
pluginLog('[FETCH] Loaded', installedPlugins.length, 'plugins');
// Debug logging only when enabled
if (PLUGIN_DEBUG) {
installedPlugins.forEach(plugin => {
debugLog(`[DEBUG] Plugin ${plugin.id}: enabled=${plugin.enabled}`);
});
}
// Also refreshes the '#installed-count' text via the filter controller.
renderInstalledPlugins(installedPlugins);
return installedPlugins;
} else {
const errorMsg = 'Failed to load installed plugins: ' + data.message;
showError(errorMsg);
throw new Error(errorMsg);
}
})
.catch(error => {
console.error('Error loading installed plugins:', error);
let errorMsg = 'Error loading plugins: ' + error.message;
if (error.message && error.message.includes('Failed to Fetch')) {
errorMsg += ' - Please try refreshing your browser.';
}
showError(errorMsg);
throw error;
})
.finally(() => {
// Clear the in-flight promise (but keep cache data)
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();
}
// Publishes the canonical installed-plugin list, then renders through the
// active filters. Everything that reads window.installedPlugins (the toggle
// handler, isStorePluginInstalled, runUpdateAllPlugins, the Alpine config tabs)
// depends on this receiving the FULL list — never a filtered subset.
function renderInstalledPlugins(plugins) {
const container = document.getElementById('installed-plugins-grid');
if (!container) {
console.warn('[RENDER] installed-plugins-grid not yet available, deferring render until plugin tab loads');
window.__pendingInstalledPlugins = plugins;
return;
}
// Always update window.installedPlugins to ensure Alpine component reactivity
window.installedPlugins = plugins;
pluginLog('[RENDER] Set window.installedPlugins to:', plugins.length, 'plugins');
// Dispatch event to notify Alpine component to update tabs
document.dispatchEvent(new CustomEvent('pluginsUpdated', {
detail: { plugins: plugins }
}));
pluginLog('[RENDER] Dispatched pluginsUpdated event');
// Also try direct Alpine update as fallback
if (window.Alpine && document.querySelector('[x-data="app()"]')) {
const appElement = document.querySelector('[x-data="app()"]');
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
appElement._x_dataStack[0].installedPlugins = plugins;
if (typeof appElement._x_dataStack[0].updatePluginTabs === 'function') {
appElement._x_dataStack[0].updatePluginTabs();
pluginLog('[RENDER] Triggered Alpine.js to update plugin tabs directly');
}
}
}
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 ? `
No plugins installed
Install plugins from the store to get started
` : `
No plugins match your filters
${totalCount} plugin${totalCount !== 1 ? 's' : ''} installed, none matching the current search or filters.
`;
return;
}
// Helper function to escape values for use in HTML attributes
const escapeAttr = (text) => {
return (text || '')
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/'/g, ''')
.replace(//g, '>');
};
// Helper function to escape for JavaScript strings (use JSON.stringify for proper escaping)
// JSON.stringify returns a quoted string, so we can use it directly in JavaScript
const escapeJs = (text) => {
return JSON.stringify(text || '');
};
setGridHtmlIfChanged(container, plugins.map(plugin => {
// Convert enabled to boolean for consistent rendering
const enabledBool = Boolean(plugin.enabled);
// Debug: Log enabled status during rendering (only when debug enabled)
if (PLUGIN_DEBUG) {
debugLog(`[DEBUG RENDER] Plugin ${plugin.id}: enabled=${enabledBool}`);
}
// Escape plugin ID for use in HTML attributes and JavaScript
const escapedPluginId = escapeAttr(plugin.id);
return `
`;
}).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) {
pluginLog('[RENDER] installed-plugins-grid not found for event delegation');
return;
}
// Skip if already set up (guard against multiple calls)
if (container._eventDelegationSetup) {
return;
}
// Mark as set up
container._eventDelegationSetup = true;
container._pluginActionHandler = handlePluginAction;
// Add listeners for both click and change events
container.addEventListener('click', handlePluginAction, true);
container.addEventListener('change', handlePluginAction, true);
pluginLog('[RENDER] Event delegation set up for installed-plugins-grid');
}
function handlePluginAction(event) {
// Check for both button and input (for toggle)
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);
// Helper function to wait for a function to be available
const waitForFunction = (funcName, maxAttempts = 10, delay = 50) => {
return new Promise((resolve, reject) => {
let attempts = 0;
const check = () => {
attempts++;
if (window[funcName] && typeof window[funcName] === 'function') {
resolve(window[funcName]);
} else if (attempts >= maxAttempts) {
reject(new Error(`${funcName} not available after ${maxAttempts} attempts`));
} else {
setTimeout(check, delay);
}
};
check();
});
};
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);
}
// Get the current enabled state from plugin data (source of truth)
// rather than from the checkbox DOM which might be out of sync
const plugin = (window.installedPlugins || []).find(p => p.id === pluginId);
// Special handling: If plugin data isn't found or is stale, fallback to DOM but be careful
// If the user clicked the checkbox, the 'checked' property has *already* toggled in the DOM
// (even though we preventDefault later, sometimes it's too late for the property read)
// However, we used preventDefault() in the global handler, so the checkbox state *should* be reliable if we didn't touch it.
// BUT: The issue is that 'currentEnabled' calculation might be wrong if window.installedPlugins is outdated.
// If the user toggles ON, enabled becomes true. If they click again, we want enabled=false.
// Let's try a simpler approach: Use the checkbox state as the source of truth for the *desired* state
// Since we preventDefault(), the checkbox state reflects the *old* state (before the click)
// wait... if we preventDefault() on 'click', the checkbox does NOT change visually or internally.
// So button.checked is the OLD state.
// We want the NEW state to be !button.checked.
let currentEnabled;
if (plugin) {
currentEnabled = Boolean(plugin.enabled);
} else if (button.type === 'checkbox') {
currentEnabled = button.checked;
} else {
currentEnabled = false;
}
// Toggle the state - we want the opposite of current state
const isChecked = !currentEnabled;
debugLog('[DEBUG toggle] Plugin:', pluginId, 'Current enabled (from data):', currentEnabled, 'New state:', isChecked, 'Event type:', event.type);
waitForFunction('togglePlugin', 10, 50)
.then(toggleFunc => {
toggleFunc(pluginId, isChecked);
})
.catch(error => {
console.error('[EVENT DELEGATION]', error.message);
if (typeof showNotification === 'function') {
showNotification('Toggle function not loaded. Please refresh the page.', 'error');
} else {
alert('Toggle function not loaded. Please refresh the page.');
}
});
break;
case 'configure':
waitForFunction('configurePlugin', 10, 50)
.then(configureFunc => {
configureFunc(pluginId);
})
.catch(error => {
console.error('[EVENT DELEGATION]', error.message);
if (typeof showNotification === 'function') {
showNotification('Configure function not loaded. Please refresh the page.', 'error');
} else {
alert('Configure function not loaded. Please refresh the page.');
}
});
break;
case 'update':
waitForFunction('updatePlugin', 10, 50)
.then(updateFunc => {
updateFunc(pluginId);
})
.catch(error => {
console.error('[EVENT DELEGATION]', error.message);
if (typeof showNotification === 'function') {
showNotification('Update function not loaded. Please refresh the page.', 'error');
} else {
alert('Update function not loaded. Please refresh the page.');
}
});
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') {
if (typeof showNotification === 'function') showNotification('Starlark app uninstalled', 'success');
else alert('Starlark app uninstalled');
if (typeof loadInstalledPlugins === 'function') loadInstalledPlugins();
else if (typeof window.loadInstalledPlugins === 'function') window.loadInstalledPlugins();
} else {
alert('Uninstall failed: ' + (data.message || 'Unknown error'));
}
})
.catch(err => alert('Uninstall failed: ' + err.message));
} else {
waitForFunction('uninstallPlugin', 10, 50)
.then(uninstallFunc => {
uninstallFunc(pluginId);
})
.catch(error => {
console.error('[EVENT DELEGATION]', error.message);
if (typeof showNotification === 'function') {
showNotification('Uninstall function not loaded. Please refresh the page.', 'error');
} else {
alert('Uninstall function not loaded. Please refresh the page.');
}
});
}
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 && typeof showNotification === 'function') {
showNotification('On-demand status refreshed', 'success');
}
} else {
const message = result.message || 'Failed to load on-demand status';
setOnDemandError(message);
if (typeof showNotification === 'function') {
showNotification(message, 'error');
}
}
})
.catch(error => {
console.error('Error fetching on-demand status:', error);
setOnDemandError(error?.message || 'Error fetching on-demand status');
if (typeof showNotification === 'function') {
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 = 'Checking...';
const onProgress = (current, total, pluginId) => {
button.innerHTML = `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);
if (typeof showNotification === 'function') {
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);
if (typeof showNotification === 'function') {
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');
if (typeof showNotification === 'function') {
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') {
if (typeof showNotification === 'function') {
const pluginName = resolvePluginDisplayName(currentOnDemandPluginId);
showNotification(`Requested on-demand mode for ${pluginName}`, 'success');
}
closeOnDemandModal();
setTimeout(() => loadOnDemandStatus(true), 700);
} else {
console.error('[submitOnDemandRequest] Request failed:', result);
if (typeof showNotification === 'function') {
showNotification(result.message || 'Failed to start on-demand mode', 'error');
}
}
})
.catch(error => {
console.error('[submitOnDemandRequest] Error starting on-demand mode:', error);
if (typeof showNotification === 'function') {
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') {
if (typeof showNotification === 'function') {
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 {
if (typeof showNotification === 'function') {
showNotification(result.message || 'Failed to stop on-demand mode', 'error');
}
}
})
.catch(error => {
console.error('Error stopping on-demand mode:', error);
if (typeof showNotification === 'function') {
showNotification('Error stopping on-demand mode: ' + error.message, 'error');
}
});
}
function stopOnDemand(event) {
const stopService = event && event.shiftKey;
requestOnDemandStop({ stopService });
}
window.requestOnDemandStop = requestOnDemandStop;
function closeOnDemandModalOnBackdrop(event) {
if (event.target === event.currentTarget) {
closeOnDemandModal();
}
}
// configurePlugin is already defined at the top of the script - no need to redefine
// 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 = `
`;
// 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 += `
`;
// Handle file-upload widget (for logo field)
if (propSchema['x-widget'] === 'file-upload') {
html += ``;
if (propDescription) {
html += `