/* 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...');
// Expose on-demand functions early as stubs (will be replaced when IIFE runs)
window.openOnDemandModal = function(pluginId) {
console.warn('openOnDemandModal called before initialization, waiting...');
// Wait for the real function to be available
let attempts = 0;
const maxAttempts = 50; // 2.5 seconds
const checkInterval = setInterval(() => {
attempts++;
if (window.__openOnDemandModalImpl) {
clearInterval(checkInterval);
window.__openOnDemandModalImpl(pluginId);
} else if (attempts >= maxAttempts) {
clearInterval(checkInterval);
console.error('openOnDemandModal not available after waiting');
if (typeof showNotification === 'function') {
showNotification('On-demand modal unavailable. Please refresh the page.', 'error');
}
}
}, 50);
};
window.requestOnDemandStop = function({ stopService = false } = {}) {
console.warn('requestOnDemandStop called before initialization, waiting...');
// Wait for the real function to be available
let attempts = 0;
const maxAttempts = 50; // 2.5 seconds
const checkInterval = setInterval(() => {
attempts++;
if (window.__requestOnDemandStopImpl) {
clearInterval(checkInterval);
return window.__requestOnDemandStopImpl({ stopService });
} else if (attempts >= maxAttempts) {
clearInterval(checkInterval);
console.error('requestOnDemandStop not available after waiting');
if (typeof showNotification === 'function') {
showNotification('On-demand stop unavailable. Please refresh the page.', 'error');
}
return Promise.reject(new Error('Function not available'));
}
}, 50);
return Promise.resolve();
};
// Define updatePlugin early as a stub to ensure it's always available
window.updatePlugin = window.updatePlugin || function(pluginId) {
if (_PLUGIN_DEBUG_EARLY) debugLog('[PLUGINS STUB] updatePlugin called for', pluginId);
// Validate pluginId
if (!pluginId || typeof pluginId !== 'string') {
console.error('Invalid pluginId:', pluginId);
if (typeof showNotification === 'function') {
showNotification('Invalid plugin ID', 'error');
}
return Promise.reject(new Error('Invalid plugin ID'));
}
// Show immediate feedback
if (typeof showNotification === 'function') {
showNotification(`Updating ${pluginId}...`, 'info');
}
// Prepare request body
const requestBody = { plugin_id: pluginId };
const requestBodyJson = JSON.stringify(requestBody);
debugLog('[UPDATE] Sending request:', { url: '/api/v3/plugins/update', body: requestBodyJson });
// Make the API call directly
return fetch('/api/v3/plugins/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: requestBodyJson
})
.then(async response => {
// Check if response is OK before parsing
if (!response.ok) {
// Try to parse error response
let errorData;
try {
const text = await response.text();
console.error('[UPDATE] Error response:', { status: response.status, statusText: response.statusText, body: text });
errorData = JSON.parse(text);
} catch (e) {
errorData = { message: `Server error: ${response.status} ${response.statusText}` };
}
if (typeof showNotification === 'function') {
showNotification(errorData.message || `Update failed: ${response.status}`, 'error');
}
throw new Error(errorData.message || `Update failed: ${response.status}`);
}
// Parse successful response
return response.json();
})
.then(data => {
if (typeof showNotification === 'function') {
showNotification(data.message || 'Update initiated', data.status || 'info');
}
// Refresh installed plugins if available
if (typeof loadInstalledPlugins === 'function') {
loadInstalledPlugins();
} else if (typeof window.pluginManager?.loadInstalledPlugins === 'function') {
window.pluginManager.loadInstalledPlugins();
}
return data;
})
.catch(error => {
console.error('[UPDATE] Error updating plugin:', error);
if (typeof showNotification === 'function') {
showNotification('Error updating plugin: ' + error.message, 'error');
}
throw error;
});
};
// Define uninstallPlugin early as a stub
window.uninstallPlugin = window.uninstallPlugin || function(pluginId) {
if (_PLUGIN_DEBUG_EARLY) debugLog('[PLUGINS STUB] uninstallPlugin called for', pluginId);
if (!confirm(`Are you sure you want to uninstall ${pluginId}?`)) {
return Promise.resolve({ cancelled: true });
}
if (typeof showNotification === 'function') {
showNotification(`Uninstalling ${pluginId}...`, 'info');
}
return fetch('/api/v3/plugins/uninstall', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plugin_id: pluginId })
})
.then(response => response.json())
.then(data => {
if (typeof showNotification === 'function') {
showNotification(data.message || 'Uninstall initiated', data.status || 'info');
}
// Refresh installed plugins if available
if (typeof loadInstalledPlugins === 'function') {
loadInstalledPlugins();
} else if (typeof window.pluginManager?.loadInstalledPlugins === 'function') {
window.pluginManager.loadInstalledPlugins();
}
return data;
})
.catch(error => {
console.error('Error uninstalling plugin:', error);
if (typeof showNotification === 'function') {
showNotification('Error uninstalling plugin: ' + error.message, 'error');
}
throw error;
});
};
// 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;
// Set up global event delegation for plugin actions (works even before plugins are loaded)
(function setupGlobalEventDelegation() {
// Use document-level delegation so it works for dynamically added content
const handleGlobalPluginAction = function(event) {
// Only handle if it's a plugin action
const button = event.target.closest('button[data-action][data-plugin-id]') ||
event.target.closest('input[data-action][data-plugin-id]');
if (!button) return;
const action = button.getAttribute('data-action');
const pluginId = button.getAttribute('data-plugin-id');
// For toggle and configure, ensure functions are available
if (action === 'toggle' || action === 'configure') {
const funcName = action === 'toggle' ? 'togglePlugin' : 'configurePlugin';
if (!window[funcName] || typeof window[funcName] !== 'function') {
// Prevent default and stop propagation immediately to avoid double handling
event.preventDefault();
event.stopPropagation();
console.warn(`[GLOBAL DELEGATION] ${funcName} not available yet, waiting...`);
// Capture state synchronously from plugin data (source of truth)
let targetChecked = false;
if (action === 'toggle') {
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;
}
targetChecked = !currentEnabled; // Toggle to opposite state
}
// Wait for function to be available
let attempts = 0;
const maxAttempts = 20; // 1 second total
const checkInterval = setInterval(() => {
attempts++;
if (window[funcName] && typeof window[funcName] === 'function') {
clearInterval(checkInterval);
// Call the function directly
if (action === 'toggle') {
window.togglePlugin(pluginId, targetChecked);
} else {
window.configurePlugin(pluginId);
}
} else if (attempts >= maxAttempts) {
clearInterval(checkInterval);
console.error(`[GLOBAL DELEGATION] ${funcName} not available after ${maxAttempts} attempts`);
if (typeof showNotification === 'function') {
showNotification(`${funcName} not loaded. Please refresh the page.`, 'error');
}
}
}, 50);
return; // Don't proceed with normal handling
}
}
// Prevent default and stop propagation to avoid double handling
event.preventDefault();
event.stopPropagation();
// If handlePluginAction exists, use it; otherwise handle directly
if (typeof handlePluginAction === 'function') {
handlePluginAction(event);
} else {
// Fallback: handle directly if functions are available
if (action === 'toggle' && window.togglePlugin) {
// Get the current enabled state from plugin data (source of truth)
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;
}
// Toggle the state - we want the opposite of current state
const isChecked = !currentEnabled;
// Prevent default behavior to avoid double-toggling and change event
// (Already done at start of function, but safe to repeat)
event.preventDefault();
event.stopPropagation();
debugLog('[DEBUG toggle fallback] Plugin:', pluginId, 'Current enabled (from data):', currentEnabled, 'New state:', isChecked);
window.togglePlugin(pluginId, isChecked);
} else if (action === 'configure' && window.configurePlugin) {
event.preventDefault();
event.stopPropagation();
window.configurePlugin(pluginId);
} else if (action === 'update' && window.updatePlugin) {
event.preventDefault();
event.stopPropagation();
debugLog('[DEBUG update fallback] Updating plugin:', pluginId);
window.updatePlugin(pluginId);
} else if (action === 'uninstall' && window.uninstallPlugin) {
event.preventDefault();
event.stopPropagation();
debugLog('[DEBUG uninstall fallback] Uninstalling plugin:', pluginId);
if (confirm(`Are you sure you want to uninstall ${pluginId}?`)) {
window.uninstallPlugin(pluginId);
}
}
}
};
// Set up delegation on document (capture phase for better reliability)
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;
}
}
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;
}
let updated = 0, upToDate = 0, failed = 0;
for (const r of results) {
if (!r.success) {
failed++;
} else if (r.result && r.result.message && r.result.message.includes('already up to date')) {
upToDate++;
} else {
updated++;
}
}
const parts = [];
if (updated > 0) parts.push(`${updated} updated`);
if (upToDate > 0) parts.push(`${upToDate} already up to date`);
if (failed > 0) parts.push(`${failed} failed`);
const type = failed > 0 ? (updated > 0 ? 'warning' : 'error') : 'success';
showNotification(parts.join(', '), 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
});
};
// Replace the stub with the real implementation
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 });
}
// Store the real implementation and replace the stub
window.__requestOnDemandStopImpl = requestOnDemandStop;
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 += `
`;
if (propSchema.type === 'boolean') {
const checked = propValue ? 'checked' : '';
// No name attribute - rely solely on _data field to prevent key leakage
itemHtml += ``;
} else {
// Escape HTML to prevent XSS
// No name attribute - rely solely on _data field to prevent key leakage
const escapedValue = typeof propValue === 'string' ? propValue.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''') : (propValue || '');
itemHtml += ``;
}
itemHtml += `
`;
});
itemHtml += `
`;
}
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_ but be careful not to break other numeric IDs
const newId = id.replace(/_item_(\d+)/, `_item_${newIndex}`);
input.id = newId;
}
});
// Update button onclick attributes - only update the index parameter
// Since we use data-index for tracking, we can compute index from closest('.array-object-item')
// For now, update onclick strings but be more careful with the regex
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';
}
}
}
}
};
// updateArrayObjectData is defined earlier in the file (line ~3596)
// Only define stub if it doesn't already exist (defensive fallback)
if (typeof window.updateArrayObjectData === 'undefined') {
window.updateArrayObjectData = function(fieldId) {
console.warn('updateArrayObjectData stub called - implementation should be defined earlier');
};
}
window.updateCheckboxGroupData = function(fieldId) {
// Update hidden _data input with currently checked values
const hiddenInput = document.getElementById(fieldId + '_data');
if (!hiddenInput) return;
const checkboxes = document.querySelectorAll(`input[type="checkbox"][data-checkbox-group="${fieldId}"]`);
const selectedValues = [];
checkboxes.forEach(checkbox => {
if (checkbox.checked) {
const optionValue = checkbox.getAttribute('data-option-value') || checkbox.value;
selectedValues.push(optionValue);
}
});
hiddenInput.value = JSON.stringify(selectedValues);
};
// handleArrayObjectFileUpload and removeArrayObjectFile are defined earlier in the file
// Only define stubs if they don't already exist (defensive fallback)
if (typeof window.handleArrayObjectFileUpload === 'undefined') {
window.handleArrayObjectFileUpload = function(event, fieldId, itemIndex, propKey, pluginId) {
console.warn('handleArrayObjectFileUpload stub called - implementation should be defined earlier');
window.updateArrayObjectData(fieldId);
};
}
if (typeof window.removeArrayObjectFile === 'undefined') {
window.removeArrayObjectFile = function(fieldId, itemIndex, propKey) {
console.warn('removeArrayObjectFile stub called - implementation should be defined earlier');
window.updateArrayObjectData(fieldId);
};
}
// Debug logging (only if pluginDebug is enabled)
if (_PLUGIN_DEBUG_EARLY) {
debugLog('[ARRAY-OBJECTS] Functions defined on window:', {
addArrayObjectItem: typeof window.addArrayObjectItem,
removeArrayObjectItem: typeof window.removeArrayObjectItem,
updateArrayObjectData: typeof window.updateArrayObjectData,
handleArrayObjectFileUpload: typeof window.handleArrayObjectFileUpload,
removeArrayObjectFile: typeof window.removeArrayObjectFile
});
}
}
// Make currentPluginConfig globally accessible (outside IIFE)
window.currentPluginConfig = null;
// Force initialization immediately when script loads (for HTMX swapped content)
debugLog('Plugins script loaded, checking for elements...');
// Ensure all functions are globally available (in case IIFE didn't expose them properly)
// These should already be set inside the IIFE, but this ensures they're available
if (typeof initializePluginPageWhenReady !== 'undefined') {
window.initializePluginPageWhenReady = initializePluginPageWhenReady;
}
if (typeof initializePlugins !== 'undefined') {
window.initializePlugins = initializePlugins;
}
if (typeof loadInstalledPlugins !== 'undefined') {
window.loadInstalledPlugins = loadInstalledPlugins;
}
if (typeof renderInstalledPlugins !== 'undefined') {
window.renderInstalledPlugins = renderInstalledPlugins;
}
// GitHub install handlers are now exposed inside the IIFE (see above).
// searchPluginStore is also exposed inside the IIFE after its definition.
// Verify critical functions are available
if (_PLUGIN_DEBUG_EARLY) {
debugLog('Plugin functions available:', {
configurePlugin: typeof window.configurePlugin,
togglePlugin: typeof window.togglePlugin,
initializePlugins: typeof window.initializePlugins,
loadInstalledPlugins: typeof window.loadInstalledPlugins,
searchPluginStore: typeof window.searchPluginStore
});
}
// 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();
}
// Initialize on-demand modal immediately since it's in base.html
if (typeof initializeOnDemandModal === 'function') {
// Run immediately and also after DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeOnDemandModal);
} else {
initializeOnDemandModal();
}
// Also try after a short delay to ensure elements are available
setTimeout(initializeOnDemandModal, 100);
}
setTimeout(function() {
const installedGrid = document.getElementById('installed-plugins-grid');
if (installedGrid) {
debugLog('Found installed-plugins-grid, forcing initialization...');
window.pluginManager.initialized = false;
if (typeof initializePluginPageWhenReady === 'function') {
initializePluginPageWhenReady();
} else if (typeof window.initPluginsPage === 'function') {
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 ─────────────────────────────────────────────────────────────
// Quotes too: the result lands in quoted attribute values (data-app-id=,
// title=), and the textContent/innerHTML round-trip only escapes &, < and >.
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML
.replace(/"/g, '"')
.replace(/'/g, ''');
}
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 = `