// ─── 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) console.log('[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) console.log('[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); console.log('[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) console.log('[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) console.log('[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) console.log('[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) console.log('[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'); } 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) { console.log(`[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'); } }) .catch(error => { // Verify this error is for the latest request (prevent race conditions) if (window._pluginToggleRequests[pluginId] !== requestToken) { console.log(`[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'); } }); }; // Cleanup orphaned modals from previous executions to prevent duplicates when moving to body try { const existingModals = document.querySelectorAll('#plugin-config-modal'); if (existingModals.length > 0) { existingModals.forEach(el => { // Only remove modals that were moved to body (orphaned from previous loads) // The new modal in the current content should be inside a container, not direct body child if (el.parentElement === document.body) { console.log('[PLUGINS SCRIPT] Cleaning up orphaned plugin modal'); el.remove(); } }); } } catch (e) { console.warn('[PLUGINS SCRIPT] Error cleaning up modals:', e); } // 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(); console.log('[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(); console.log('[DEBUG update fallback] Updating plugin:', pluginId); window.updatePlugin(pluginId); } else if (action === 'uninstall' && window.uninstallPlugin) { event.preventDefault(); event.stopPropagation(); console.log('[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); console.log('[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) { console.log('[PLUGINS SCRIPT] Functions defined:', { configurePlugin: typeof window.configurePlugin, togglePlugin: typeof window.togglePlugin }); if (typeof window.configurePlugin === 'function') { console.log('[PLUGINS SCRIPT] ✓ configurePlugin ready'); } if (typeof window.togglePlugin === 'function') { console.log('[PLUGINS SCRIPT] ✓ togglePlugin ready'); } } // GitHub Token Collapse Handler - Define early so it's available before IIFE console.log('[DEFINE] Defining attachGithubTokenCollapseHandler function...'); window.attachGithubTokenCollapseHandler = function() { console.log('[attachGithubTokenCollapseHandler] Starting...'); const toggleTokenCollapseBtn = document.getElementById('toggle-github-token-collapse'); console.log('[attachGithubTokenCollapseHandler] Button found:', !!toggleTokenCollapseBtn); if (!toggleTokenCollapseBtn) { console.warn('[attachGithubTokenCollapseHandler] GitHub token collapse button not found'); return; } console.log('[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) { console.log('[attachGithubTokenCollapseHandler] Button clicked, calling toggleGithubTokenContent'); window.toggleGithubTokenContent(e); }); console.log('[attachGithubTokenCollapseHandler] Handler attached to button:', newBtn.id); }; // Toggle GitHub Token Settings section console.log('[DEFINE] Defining toggleGithubTokenContent function...'); window.toggleGithubTokenContent = function(e) { console.log('[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'); console.log('[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; console.log('[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'; console.log('[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'; console.log('[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 console.log('[DEFINE] Defining handleGitHubPluginInstall function...'); window.handleGitHubPluginInstall = function() { console.log('[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(); console.log('[handleGitHubPluginInstall] Repo URL:', repoUrl); if (!repoUrl) { if (statusDiv) { statusDiv.innerHTML = 'Please enter a GitHub URL'; } return; } if (!repoUrl.includes('github.com')) { 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; } console.log('[handleGitHubPluginInstall] Sending request:', requestBody); fetch('/api/v3/plugins/install-from-url', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) }) .then(response => { console.log('[handleGitHubPluginInstall] Response status:', response.status); return response.json(); }) .then(data => { console.log('[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'; } }); }; console.log('[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 console.log('[DEFINE] Defining checkGitHubAuthStatus function...'); window.checkGitHubAuthStatus = function checkGitHubAuthStatus() { console.log('[checkGitHubAuthStatus] Starting...'); return fetch('/api/v3/plugins/store/github-status') .then(response => { console.log('checkGitHubAuthStatus: Response status:', response.status); return response.json(); }) .then(data => { console.log('checkGitHubAuthStatus: Data received:', data); if (data.status === 'success') { const authData = data.data; const tokenStatus = authData.token_status || (authData.authenticated ? 'valid' : 'none'); console.log('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'); console.log('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 = ''; console.log(`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'; console.log('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) console.log('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 let storeFilteredList = []; // ── Plugin Store Filter State ─────────────────────────────────────────── const storeFilterState = { sort: safeLocalStorage.getItem('storeSort') || 'a-z', filterCategory: '', filterInstalled: null, // null=all, true=installed, false=not-installed searchQuery: '', page: 1, perPage: parseInt(safeLocalStorage.getItem('storePerPage')) || 12, persist() { safeLocalStorage.setItem('storeSort', this.sort); safeLocalStorage.setItem('storePerPage', this.perPage); }, reset() { this.sort = 'a-z'; this.filterCategory = ''; this.filterInstalled = null; this.searchQuery = ''; this.page = 1; }, activeCount() { let n = 0; if (this.searchQuery) n++; if (this.filterInstalled !== null) n++; if (this.filterCategory) n++; if (this.sort !== 'a-z') n++; return n; } }; 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) { console.log('Plugin page already initialized or initializing, skipping...'); return; } // Check if required elements exist const installedGrid = document.getElementById('installed-plugins-grid'); if (!installedGrid) { console.log('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) { console.log('[INIT] Checking GitHub auth status immediately...'); window.checkGitHubAuthStatus(); } // If we fetched data before the DOM existed, render it now if (window.__pendingInstalledPlugins) { console.log('[RENDER] Applying pending installed plugins data'); renderInstalledPlugins(window.__pendingInstalledPlugins); window.__pendingInstalledPlugins = null; } if (window.__pendingStorePlugins) { console.log('[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 closeBtn = document.getElementById('close-plugin-config'); 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); } // Restore persisted store sort/perPage const storeSortEl = document.getElementById('store-sort'); if (storeSortEl) storeSortEl.value = storeFilterState.sort; const storePpEl = document.getElementById('store-per-page'); if (storePpEl) storePpEl.value = storeFilterState.perPage; setupStoreFilterListeners(); if (closeBtn) { closeBtn.replaceWith(closeBtn.cloneNode(true)); document.getElementById('close-plugin-config').addEventListener('click', closePluginConfigModal); // View toggle buttons document.getElementById('view-toggle-form')?.addEventListener('click', () => switchPluginConfigView('form')); document.getElementById('view-toggle-json')?.addEventListener('click', () => switchPluginConfigView('json')); // Reset to defaults button document.getElementById('reset-to-defaults-btn')?.addEventListener('click', resetPluginConfigToDefaults); // JSON editor save button document.getElementById('save-json-config-btn')?.addEventListener('click', saveConfigFromJsonEditor); } if (closeOnDemandModalBtn) { closeOnDemandModalBtn.replaceWith(closeOnDemandModalBtn.cloneNode(true)); document.getElementById('close-on-demand-modal').addEventListener('click', closeOnDemandModal); } if (cancelOnDemandBtn) { cancelOnDemandBtn.replaceWith(cancelOnDemandBtn.cloneNode(true)); document.getElementById('cancel-on-demand').addEventListener('click', closeOnDemandModal); } if (onDemandForm) { onDemandForm.replaceWith(onDemandForm.cloneNode(true)); document.getElementById('on-demand-form').addEventListener('submit', submitOnDemandRequest); } if (onDemandModal) { onDemandModal.onclick = closeOnDemandModalOnBackdrop; } // 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')) { console.log('HTMX swap detected for plugins, initializing...'); // Reset initialization flag to allow re-initialization after HTMX swap window.pluginManager.initialized = false; window.pluginManager.initializing = false; initTimer = setTimeout(attemptInit, 100); } }, { once: false }); // Allow multiple swaps })(); // Initialization guard to prevent multiple initializations let pluginsInitialized = false; function initializePlugins() { console.log('[initializePlugins] FUNCTION CALLED, pluginsInitialized:', pluginsInitialized); // Guard against multiple initializations if (pluginsInitialized) { console.log('[initializePlugins] Already initialized, skipping (but still setting up handlers)'); // Still set up handlers even if already initialized (in case page was HTMX swapped) console.log('[initializePlugins] Force setting up GitHub handlers anyway...'); if (typeof setupGitHubInstallHandlers === 'function') { setupGitHubInstallHandlers(); } else { console.error('[initializePlugins] setupGitHubInstallHandlers not found!'); } return; } pluginsInitialized = true; console.log('[initializePlugins] Starting initialization...'); pluginLog('[INIT] Initializing plugins...'); // Check GitHub authentication status console.log('[INIT] Checking for checkGitHubAuthStatus function...', { exists: typeof window.checkGitHubAuthStatus, type: typeof window.checkGitHubAuthStatus }); if (window.checkGitHubAuthStatus) { console.log('[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 loadInstalledPlugins(); searchPluginStore(true); // Load plugin store with fresh metadata from GitHub // Setup search functionality (with guard against duplicate listeners) const searchInput = document.getElementById('plugin-search'); const categorySelect = document.getElementById('plugin-category'); if (searchInput && !searchInput._listenerSetup) { searchInput._listenerSetup = true; searchInput.addEventListener('input', debounce(searchPluginStore, 300)); } if (categorySelect && !categorySelect._listenerSetup) { categorySelect._listenerSetup = true; categorySelect.addEventListener('change', searchPluginStore); } // Setup GitHub installation handlers console.log('[initializePlugins] About to call setupGitHubInstallHandlers...'); if (typeof setupGitHubInstallHandlers === 'function') { console.log('[initializePlugins] setupGitHubInstallHandlers is a function, calling it...'); setupGitHubInstallHandlers(); console.log('[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) console.log(...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 => { console.log(`[DEBUG] Plugin ${plugin.id}: enabled=${plugin.enabled}`); }); } renderInstalledPlugins(installedPlugins); // Update count const countEl = document.getElementById('installed-count'); if (countEl) { countEl.textContent = installedPlugins.length + ' installed'; } 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) 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'); } } } if (plugins.length === 0) { container.innerHTML = `

No plugins installed

Install plugins from the store to get started

`; return; } // Helper function to escape attributes for use in HTML const escapeAttr = (text) => { return (text || '').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 || ''); }; container.innerHTML = 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) { console.log(`[DEBUG RENDER] Plugin ${plugin.id}: enabled=${enabledBool}`); } // Escape plugin ID for use in HTML attributes and JavaScript const escapedPluginId = escapeAttr(plugin.id); return `

${escapeHtml(plugin.name || plugin.id)}

${plugin.is_starlark_app ? 'Starlark' : ''} ${plugin.verified ? 'Verified' : ''}

${escapeHtml(plugin.author || 'Unknown')}

${plugin.version ? `

v${escapeHtml(plugin.version)}

` : ''}

${escapeHtml(plugin.category || 'General')}

${escapeHtml(plugin.description || 'No description available')}

${plugin.tags && plugin.tags.length > 0 ? `
${plugin.tags.map(tag => `${escapeHtml(tag)}`).join('')}
` : ''}
`; }).join(''); // Set up event delegation for plugin action buttons (fallback if onclick doesn't work) // Only set up once per container to avoid redundant listeners const setupEventDelegation = () => { 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) { pluginLog('[RENDER] Event delegation already set up, skipping'); 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'); }; // Set up immediately setupEventDelegation(); // Also retry after a short delay to ensure it's attached even if container wasn't ready setTimeout(setupEventDelegation, 100); } 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'); if (!pluginId) return; event.preventDefault(); event.stopPropagation(); console.log('[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': // 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; console.log('[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'); } }); } function startOnDemandStatusPolling() { if (onDemandStatusInterval) { clearInterval(onDemandStatusInterval); } onDemandStatusInterval = setInterval(() => loadOnDemandStatus(false), 15000); } 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) { console.log('[__openOnDemandModalImpl] Called with pluginId:', pluginId); const plugin = findInstalledPlugin(pluginId); console.log('[__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) { console.log('[__openOnDemandModalImpl] Plugin is disabled, but on-demand will temporarily enable it'); } currentOnDemandPluginId = pluginId; console.log('[__openOnDemandModalImpl] Setting currentOnDemandPluginId to:', pluginId); // Ensure modal is initialized console.log('[__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'); console.log('[__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; } console.log('[__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); }); console.log('[__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'; } // 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; console.log('[__openOnDemandModalImpl] Modal display set, should be visible now. Modal element:', modal); console.log('[__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(); console.log('[__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; function closeOnDemandModal() { const modal = document.getElementById('on-demand-modal'); if (modal) { modal.style.display = 'none'; } currentOnDemandPluginId = null; } function submitOnDemandRequest(event) { event.preventDefault(); console.log('[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; } console.log('[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; } } console.log('[submitOnDemandRequest] Payload:', payload); markOnDemandLoading(); fetch('/api/v3/display/on-demand/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) .then(response => { console.log('[submitOnDemandRequest] Response status:', response.status); return response.json(); }) .then(result => { console.log('[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 window.showPluginConfigModal = function(pluginId, config) { const modal = document.getElementById('plugin-config-modal'); const title = document.getElementById('plugin-config-title'); const content = document.getElementById('plugin-config-content'); if (!modal) { console.error('[DEBUG] Plugin config modal element not found'); if (typeof showError === 'function') { showError('Plugin configuration modal not found. Please refresh the page.'); } else if (typeof showNotification === 'function') { showNotification('Plugin configuration modal not found. Please refresh the page.', 'error'); } return; } console.log('[DEBUG] ===== Opening plugin config modal ====='); console.log('[DEBUG] Plugin ID:', pluginId); console.log('[DEBUG] Config:', config); // Check if modal elements exist (already checked above, but double-check for safety) if (!title) { console.error('[DEBUG] Plugin config title element not found'); if (typeof showError === 'function') { showError('Plugin configuration title element not found.'); } else if (typeof showNotification === 'function') { showNotification('Plugin configuration title element not found.', 'error'); } return; } if (!content) { console.error('[DEBUG] Plugin config content element not found'); if (typeof showError === 'function') { showError('Plugin configuration content element not found.'); } else if (typeof showNotification === 'function') { showNotification('Plugin configuration content element not found.', 'error'); } return; } // Initialize state currentPluginConfigState.pluginId = pluginId; currentPluginConfigState.config = config || {}; currentPluginConfigState.jsonEditor = null; // Reset view to form switchPluginConfigView('form'); // Hide validation errors displayValidationErrors([]); title.textContent = `Configure ${pluginId}`; // Show loading state while form is generated content.innerHTML = '
'; // Move modal to body to avoid z-index/overflow issues if (modal.parentElement !== document.body) { document.body.appendChild(modal); } // Remove any inline display:none that might be in the HTML FIRST // This is critical because the HTML template has style="display: none;" inline // We need to remove it before setting new styles let currentStyle = modal.getAttribute('style') || ''; if (currentStyle.includes('display: none') || currentStyle.includes('display:none')) { currentStyle = currentStyle.replace(/display:\s*none[;]?/gi, '').trim(); // Clean up any double semicolons or trailing semicolons currentStyle = currentStyle.replace(/;;+/g, ';').replace(/^;|;$/g, ''); if (currentStyle) { modal.setAttribute('style', currentStyle); } else { modal.removeAttribute('style'); } } // Show modal immediately - use important to override any other styles // Also ensure visibility, opacity, and z-index are set correctly modal.style.setProperty('display', 'flex', 'important'); modal.style.setProperty('visibility', 'visible', 'important'); modal.style.setProperty('opacity', '1', 'important'); modal.style.setProperty('z-index', '9999', 'important'); modal.style.setProperty('position', 'fixed', 'important'); // Ensure modal content is also visible const modalContent = modal.querySelector('.modal-content'); if (modalContent) { modalContent.style.setProperty('display', 'block', 'important'); modalContent.style.setProperty('visibility', 'visible', 'important'); modalContent.style.setProperty('opacity', '1', 'important'); } console.log('[DEBUG] Modal display set to flex'); console.log('[DEBUG] Modal computed style:', window.getComputedStyle(modal).display); console.log('[DEBUG] Modal z-index:', window.getComputedStyle(modal).zIndex); console.log('[DEBUG] Modal visibility:', window.getComputedStyle(modal).visibility); console.log('[DEBUG] Modal opacity:', window.getComputedStyle(modal).opacity); console.log('[DEBUG] Modal in DOM:', document.body.contains(modal)); console.log('[DEBUG] Modal parent:', modal.parentElement?.tagName); console.log('[DEBUG] Modal rect:', modal.getBoundingClientRect()); // Load schema for validation fetch(`/api/v3/plugins/schema?plugin_id=${pluginId}`) .then(r => r.json()) .then(schemaData => { if (schemaData.status === 'success' && schemaData.data?.schema) { currentPluginConfigState.schema = schemaData.data.schema; } }) .catch(err => console.warn('Could not load schema:', err)); // Generate form asynchronously generatePluginConfigForm(pluginId, config) .then(formHtml => { console.log('[DEBUG] Form generated, setting content. HTML length:', formHtml.length); content.innerHTML = formHtml; // Attach form submit handler after form is inserted const form = document.getElementById('plugin-config-form'); if (form) { form.addEventListener('submit', handlePluginConfigSubmit); console.log('Form submit handler attached'); } }) .catch(error => { console.error('Error generating config form:', error); content.innerHTML = '

Error loading configuration form

'; }); } // Helper function to get the full property object from schema function getSchemaProperty(schema, path) { if (!schema || !schema.properties) return null; const parts = path.split('.'); let current = schema.properties; for (let i = 0; i < parts.length; i++) { const part = parts[i]; if (current && current[part]) { if (i === parts.length - 1) { // Last part - return the property return current[part]; } else if (current[part].properties) { // Navigate into nested object current = current[part].properties; } else { return null; } } else { return null; } } return null; } // Helper function to find property type in nested schema using dot notation function getSchemaPropertyType(schema, path) { const prop = getSchemaProperty(schema, path); return prop; // Return the full property object (was returning just type, but callers expect object) } // Helper function to escape CSS selector special characters function escapeCssSelector(str) { if (typeof str !== 'string') { str = String(str); } // Use CSS.escape() when available (handles unicode, leading digits, and edge cases) if (typeof CSS !== 'undefined' && CSS.escape) { return CSS.escape(str); } // Fallback to regex-based escaping for older browsers return str.replace(/[!"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~]/g, '\\$&'); } // Helper function to convert dot notation to nested object function dotToNested(obj) { const result = {}; for (const key in obj) { const parts = key.split('.'); let current = result; for (let i = 0; i < parts.length - 1; i++) { if (!current[parts[i]]) { current[parts[i]] = {}; } current = current[parts[i]]; } current[parts[parts.length - 1]] = obj[key]; } return result; } // Helper function to collect all boolean fields from schema (including nested) function collectBooleanFields(schema, prefix = '') { const boolFields = []; if (!schema || !schema.properties) return boolFields; Object.entries(schema.properties).forEach(([key, prop]) => { const fullKey = prefix ? `${prefix}.${key}` : key; if (prop.type === 'boolean') { boolFields.push(fullKey); } else if (prop.type === 'object' && prop.properties) { boolFields.push(...collectBooleanFields(prop, fullKey)); } }); return boolFields; } function handlePluginConfigSubmit(e) { e.preventDefault(); console.log('Form submitted'); if (!currentPluginConfig) { showNotification('Plugin configuration not loaded', 'error'); return; } const pluginId = currentPluginConfig.pluginId; const schema = currentPluginConfig.schema; const form = e.target; // Fix invalid hidden fields before submission // This prevents "invalid form control is not focusable" errors const allInputs = form.querySelectorAll('input[type="number"]'); allInputs.forEach(input => { const min = parseFloat(input.getAttribute('min')); const max = parseFloat(input.getAttribute('max')); const value = parseFloat(input.value); if (!isNaN(value)) { if (!isNaN(min) && value < min) { input.value = min; } else if (!isNaN(max) && value > max) { input.value = max; } } }); const formData = new FormData(form); const flatConfig = {}; console.log('Schema loaded:', schema ? 'Yes' : 'No'); // Process form data with type conversion (using dot notation for nested fields) for (const [key, value] of formData.entries()) { // Check if this is a patternProperties or array-of-objects hidden input (contains JSON data) // Only match keys ending with '_data' to avoid false positives like 'meta_data_field' if (key.endsWith('_data')) { try { const baseKey = key.replace(/_data$/, ''); const jsonValue = JSON.parse(value); // Handle both objects (patternProperties) and arrays (array-of-objects) // Only treat as JSON-backed when it's a non-null object (null is typeof 'object' in JavaScript) if (jsonValue !== null && typeof jsonValue === 'object') { flatConfig[baseKey] = jsonValue; console.log(`JSON data field ${baseKey}: parsed ${Array.isArray(jsonValue) ? 'array' : 'object'}`, jsonValue); continue; // Skip normal processing for JSON data fields } } catch (e) { // Not valid JSON, continue with normal processing } } // Skip checkbox-group inputs with bracket notation (they're handled by the hidden _data input) // Pattern: fieldName[] - these are individual checkboxes, actual data is in fieldName_data if (key.endsWith('[]')) { continue; } // Skip key_value pair inputs (they're handled by the hidden _data input) if (key.includes('[key_') || key.includes('[value_')) { continue; } // Skip array-of-objects per-item inputs (they're handled by the hidden _data input) // Pattern: feeds_item_0_name, feeds_item_1_url, etc. if (key.includes('_item_') && /_item_\d+_/.test(key)) { continue; } // Try to get schema property - handle both dot notation and underscore notation let propSchema = getSchemaPropertyType(schema, key); let actualKey = key; let actualValue = value; // If not found with dots, try converting underscores to dots (for nested fields) if (!propSchema && key.includes('_')) { const dotKey = key.replace(/_/g, '.'); propSchema = getSchemaPropertyType(schema, dotKey); if (propSchema) { // Use the dot notation key for consistency actualKey = dotKey; actualValue = value; } } if (propSchema) { const propType = propSchema.type; if (propType === 'array') { // Check if this is a file upload widget (JSON array) if (propSchema['x-widget'] === 'file-upload') { // Try to parse as JSON first (for file uploads) try { // Handle HTML entity encoding (from hidden input) let decodedValue = actualValue; if (typeof actualValue === 'string') { // Decode HTML entities if present const tempDiv = document.createElement('div'); tempDiv.innerHTML = actualValue; decodedValue = tempDiv.textContent || tempDiv.innerText || actualValue; } const jsonValue = JSON.parse(decodedValue); if (Array.isArray(jsonValue)) { flatConfig[actualKey] = jsonValue; console.log(`File upload array field ${actualKey}: parsed JSON array with ${jsonValue.length} items`); } else { // Fallback to comma-separated const arrayValue = decodedValue ? decodedValue.split(',').map(v => v.trim()).filter(v => v) : []; flatConfig[actualKey] = arrayValue; } } catch (e) { // Not JSON, use comma-separated const arrayValue = actualValue ? actualValue.split(',').map(v => v.trim()).filter(v => v) : []; flatConfig[actualKey] = arrayValue; console.log(`Array field ${actualKey}: "${actualValue}" -> `, arrayValue); } } else { // Regular array: convert comma-separated string to array const arrayValue = actualValue ? actualValue.split(',').map(v => v.trim()).filter(v => v) : []; flatConfig[actualKey] = arrayValue; console.log(`Array field ${actualKey}: "${actualValue}" -> `, arrayValue); } } else if (propType === 'integer') { flatConfig[actualKey] = parseInt(actualValue, 10); } else if (propType === 'number') { flatConfig[actualKey] = parseFloat(actualValue); } else if (propType === 'boolean') { // Use querySelector to reliably find checkbox by name attribute // Escape special CSS selector characters in the name const escapedKey = escapeCssSelector(key); const formElement = form.querySelector(`input[type="checkbox"][name="${escapedKey}"]`); if (formElement) { // Element found - use its checked state flatConfig[actualKey] = formElement.checked; } else { // Element not found - normalize string booleans and check FormData value // Checkboxes send "on" when checked, nothing when unchecked // Normalize string representations of booleans if (typeof actualValue === 'string') { const lowerValue = actualValue.toLowerCase().trim(); if (lowerValue === 'true' || lowerValue === '1' || lowerValue === 'on') { flatConfig[actualKey] = true; } else if (lowerValue === 'false' || lowerValue === '0' || lowerValue === 'off' || lowerValue === '') { flatConfig[actualKey] = false; } else { // Non-empty string that's not a boolean representation - treat as truthy flatConfig[actualKey] = true; } } else if (actualValue === undefined || actualValue === null) { flatConfig[actualKey] = false; } else { // Non-string value - coerce to boolean flatConfig[actualKey] = Boolean(actualValue); } } } else { flatConfig[actualKey] = actualValue; } } else { // No schema, try to infer type // Check if value looks like a JSON string (starts with [ or {) if (typeof actualValue === 'string' && (actualValue.trim().startsWith('[') || actualValue.trim().startsWith('{'))) { try { // Handle HTML entity encoding let decodedValue = actualValue; const tempDiv = document.createElement('div'); tempDiv.innerHTML = actualValue; decodedValue = tempDiv.textContent || tempDiv.innerText || actualValue; const parsed = JSON.parse(decodedValue); flatConfig[actualKey] = parsed; console.log(`No schema for ${actualKey}, but parsed as JSON:`, parsed); } catch (e) { // Not valid JSON, save as string flatConfig[actualKey] = actualValue; } } else { // No schema - try to detect checkbox by finding the element const escapedKey = escapeCssSelector(key); const formElement = form.querySelector(`input[type="checkbox"][name="${escapedKey}"]`); if (formElement && formElement.type === 'checkbox') { // Found checkbox element - use its checked state flatConfig[actualKey] = formElement.checked; } else { // Not a checkbox or element not found - normalize string booleans if (typeof actualValue === 'string') { const lowerValue = actualValue.toLowerCase().trim(); if (lowerValue === 'true' || lowerValue === '1' || lowerValue === 'on') { flatConfig[actualKey] = true; } else if (lowerValue === 'false' || lowerValue === '0' || lowerValue === 'off' || lowerValue === '') { flatConfig[actualKey] = false; } else { // Non-empty string that's not a boolean representation - keep as string flatConfig[actualKey] = actualValue; } } else { // Non-string value - use as-is flatConfig[actualKey] = actualValue; } } } } } // Handle unchecked checkboxes (not in FormData) - including nested ones if (schema && schema.properties) { const allBoolFields = collectBooleanFields(schema); allBoolFields.forEach(key => { if (!(key in flatConfig)) { flatConfig[key] = false; } }); } // Convert dot notation to nested object const config = dotToNested(flatConfig); console.log('Flat config:', flatConfig); console.log('Nested config to save:', config); // Save the configuration fetch('/api/v3/plugins/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ plugin_id: pluginId, config: config }) }) .then(response => response.json()) .then(data => { if (data.status === 'success') { // Hide validation errors on success displayValidationErrors([]); showNotification('Configuration saved successfully', 'success'); closePluginConfigModal(); loadInstalledPlugins(); // Refresh to show updated config } else { // Display validation errors if present if (data.validation_errors && Array.isArray(data.validation_errors)) { displayValidationErrors(data.validation_errors); } showNotification('Error saving configuration: ' + data.message, 'error'); } }) .catch(error => { console.error('Error saving plugin config:', error); showNotification('Error saving configuration: ' + error.message, 'error'); }); } function generatePluginConfigForm(pluginId, config) { console.log('[DEBUG] ===== Generating plugin config form ====='); console.log('[DEBUG] Plugin ID:', pluginId); // Load plugin schema and actions for dynamic form generation const installedPluginsPromise = (window.PluginAPI && window.PluginAPI.getInstalledPlugins) ? window.PluginAPI.getInstalledPlugins().then(plugins => ({ status: 'success', data: { plugins: plugins } })) : fetch(`/api/v3/plugins/installed`).then(r => r.json()); return Promise.all([ fetch(`/api/v3/plugins/schema?plugin_id=${pluginId}`).then(r => r.json()), installedPluginsPromise ]) .then(([schemaData, pluginsData]) => { console.log('[DEBUG] Schema data received:', schemaData.status); // Get plugin info including web_ui_actions let pluginInfo = null; if (pluginsData.status === 'success' && pluginsData.data && pluginsData.data.plugins) { pluginInfo = pluginsData.data.plugins.find(p => p.id === pluginId); console.log('[DEBUG] Plugin info found:', pluginInfo ? 'yes' : 'no'); if (pluginInfo) { console.log('[DEBUG] Plugin info keys:', Object.keys(pluginInfo)); console.log('[DEBUG] web_ui_actions in pluginInfo:', 'web_ui_actions' in pluginInfo); console.log('[DEBUG] web_ui_actions value:', pluginInfo.web_ui_actions); } } else { console.log('[DEBUG] pluginsData status:', pluginsData.status); } const webUiActions = pluginInfo ? (pluginInfo.web_ui_actions || []) : []; console.log('[DEBUG] Final webUiActions:', webUiActions, 'length:', webUiActions.length); if (schemaData.status === 'success' && schemaData.data.schema) { console.log('[DEBUG] Schema has properties:', Object.keys(schemaData.data.schema.properties || {})); // Store plugin ID, schema, and actions for form submission currentPluginConfig = { pluginId: pluginId, schema: schemaData.data.schema, webUiActions: webUiActions }; // Also assign to window for global access in template interpolations window.currentPluginConfig = currentPluginConfig; // Also update state currentPluginConfigState.schema = schemaData.data.schema; console.log('[DEBUG] Calling generateFormFromSchema...'); return generateFormFromSchema(schemaData.data.schema, config, webUiActions); } else { // Fallback to simple form if no schema currentPluginConfig = { pluginId: pluginId, schema: null, webUiActions: webUiActions }; // Also assign to window for global access in template interpolations window.currentPluginConfig = currentPluginConfig; return generateSimpleConfigForm(config, webUiActions); } }) .catch(error => { console.error('Error loading schema:', error); currentPluginConfig = { pluginId: pluginId, schema: null, webUiActions: [] }; // Also assign to window for global access in template interpolations window.currentPluginConfig = currentPluginConfig; return generateSimpleConfigForm(config, []); }); } // Helper to flatten nested config for form display (converts {nfl: {enabled: true}} to {'nfl.enabled': true}) function flattenConfig(obj, prefix = '') { let result = {}; for (const key in obj) { const value = obj[key]; const fullKey = prefix ? `${prefix}.${key}` : key; if (value !== null && typeof value === 'object' && !Array.isArray(value)) { // Recursively flatten nested objects Object.assign(result, flattenConfig(value, fullKey)); } else { result[fullKey] = value; } } return result; } // Generate field HTML for a single property (used recursively) // 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; 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 += `

${escapeHtml(propDescription)}

`; } const uploadConfig = propSchema['x-upload-config'] || {}; // Derive pluginId strictly from uploadConfig or currentPluginConfig, no hard-coded fallback const pluginId = uploadConfig.plugin_id || (typeof currentPluginConfig !== 'undefined' ? currentPluginConfig?.pluginId : null) || (typeof window.currentPluginConfig !== 'undefined' ? window.currentPluginConfig?.pluginId : null) || null; const logoValue = propValue || {}; // Use base64 encoding for JSON in data attributes to safely handle all characters const logoDataJson = logoValue && Object.keys(logoValue).length > 0 ? JSON.stringify(logoValue) : ''; const logoDataBase64 = logoDataJson ? btoa(unescape(encodeURIComponent(logoDataJson))) : ''; const allowedTypes = uploadConfig.allowed_types || ['image/png', 'image/jpeg', 'image/bmp']; const maxSizeMB = uploadConfig.max_size_mb || 5; const pluginIdParam = pluginId ? `'${escapeAttribute(pluginId)}'` : 'null'; const uploadConfigJson = JSON.stringify({ allowed_types: allowedTypes, max_size_mb: maxSizeMB }); const uploadConfigBase64 = btoa(unescape(encodeURIComponent(uploadConfigJson))); html += `
`; if (logoValue.path) { html += `
Logo
`; } html += `
`; } else if (propSchema.type === 'boolean') { // Boolean checkbox html += ` `; } else { // Regular text/string input html += ` `; if (propDescription) { html += `

${escapeHtml(propDescription)}

`; } const placeholder = propSchema.format === 'uri' ? 'https://example.com/feed' : ''; html += ` `; } html += `
`; }); // Use schema-driven label for remove button, fallback to generic "Remove item" const removeLabel = itemsSchema['x-removeLabel'] || 'Remove item'; html += `
`; return html; } function generateFieldHtml(key, prop, value, prefix = '') { const fullKey = prefix ? `${prefix}.${key}` : key; const label = prop.title || key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); const description = prop.description || ''; let html = ''; // Debug logging for categories field if (key === 'categories') { console.log(`[DEBUG] Processing categories field:`, { type: prop.type, hasAdditionalProperties: !!(prop.additionalProperties), additionalPropertiesType: prop.additionalProperties?.type, hasProperties: !!(prop.properties), allKeys: Object.keys(prop) }); } // Handle patternProperties objects (dynamic key-value pairs like custom_feeds, feed_logo_map) if (prop.type === 'object' && prop.patternProperties && !prop.properties) { const fieldId = fullKey.replace(/\./g, '_'); const currentValue = value || {}; const patternProp = Object.values(prop.patternProperties)[0]; // Get the pattern property schema const valueType = patternProp.type || 'string'; const maxProperties = prop.maxProperties || 50; const entries = Object.entries(currentValue); html += `

${description || 'Add key-value pairs'}

`; // Render existing pairs entries.forEach(([pairKey, pairValue], index) => { html += `
`; }); html += `
`; return html; } // Handle objects with additionalProperties (dynamic keys with object values, like categories) // Must have additionalProperties, no top-level properties, and additionalProperties must be an object type const hasAdditionalProperties = prop.type === 'object' && (prop.properties === undefined || prop.properties === null) && // Explicitly exclude objects with properties (those use nested handler) prop.additionalProperties && typeof prop.additionalProperties === 'object' && prop.additionalProperties !== null && prop.additionalProperties.type === 'object' && !prop.patternProperties; // Also exclude patternProperties objects // Debug logging for categories field specifically if (key === 'categories') { console.log(`[DEBUG] Categories field check:`, { type: prop.type, hasProperties: !!prop.properties, hasAdditionalProperties: !!prop.additionalProperties, additionalPropertiesType: prop.additionalProperties?.type, additionalPropertiesIsObject: typeof prop.additionalProperties === 'object', matchesCondition: hasAdditionalProperties, allPropKeys: Object.keys(prop) }); } if (hasAdditionalProperties) { const fieldId = fullKey.replace(/\./g, '_'); const currentValue = value || {}; const categorySchema = prop.additionalProperties; const entries = Object.entries(currentValue); console.log(`[DEBUG] Rendering additionalProperties object for ${fullKey}:`, { entries: entries.length, keys: Object.keys(currentValue) }); html += `

${label}

${description ? `

${description}

` : ''}
`; // Render each category entries.forEach(([categoryKey, categoryValue]) => { const categoryId = `${fieldId}_${categoryKey}`; // Ensure categoryValue is an object const catValue = typeof categoryValue === 'object' && categoryValue !== null ? categoryValue : {}; const enabled = catValue.enabled !== undefined ? catValue.enabled : (categorySchema.properties?.enabled?.default !== undefined ? categorySchema.properties.enabled.default : true); // Safely extract string values, ensuring they're strings const dataFile = (typeof catValue.data_file === 'string' ? catValue.data_file : '') || ''; const displayName = (typeof catValue.display_name === 'string' ? catValue.display_name : '') || categoryKey.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); html += `
${escapeHtml(categoryKey)}
`; }); if (entries.length === 0) { html += `
No categories configured. Use the File Manager below to add JSON files.
`; } html += `
`; return html; } // Handle nested objects with known properties if (prop.type === 'object' && prop.properties) { const sectionId = `section-${fullKey.replace(/\./g, '-')}`; const nestedConfig = value || {}; const sectionLabel = prop.title || key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); // Calculate nesting depth for better spacing const nestingDepth = (fullKey.match(/\./g) || []).length; const marginClass = nestingDepth > 1 ? 'mb-6' : 'mb-4'; html += `
`; // Add extra spacing after nested sections to prevent overlap with next section html += `
`; return html; } // Regular (non-nested) field html += `
`; if (description) { html += `

${description}

`; } // Generate appropriate input based on type if (prop.type === 'boolean') { html += ` `; } else if (prop.type === 'number' || prop.type === 'integer') { const min = prop.minimum !== undefined ? `min="${prop.minimum}"` : ''; const max = prop.maximum !== undefined ? `max="${prop.maximum}"` : ''; const step = prop.type === 'integer' ? 'step="1"' : 'step="any"'; // Ensure value respects min/max constraints let fieldValue = value !== undefined ? value : (prop.default !== undefined ? prop.default : ''); if (fieldValue !== '' && fieldValue !== undefined && fieldValue !== null) { const numValue = typeof fieldValue === 'string' ? parseFloat(fieldValue) : fieldValue; if (!isNaN(numValue)) { // Clamp value to min/max if constraints exist if (prop.minimum !== undefined && numValue < prop.minimum) { fieldValue = prop.minimum; } else if (prop.maximum !== undefined && numValue > prop.maximum) { fieldValue = prop.maximum; } else { fieldValue = numValue; } } } // If still empty and we have a default, use it if (fieldValue === '' && prop.default !== undefined) { fieldValue = prop.default; } html += ` `; } else if (prop.type === 'array') { // Check if this is an array of objects FIRST (before other checks) if (prop.items && prop.items.type === 'object' && prop.items.properties) { // Array of objects widget (like custom_feeds with name, url, enabled, logo) console.log(`[DEBUG] ✅ Detected array-of-objects widget for ${fullKey}`); const fieldId = fullKey.replace(/\./g, '_'); const itemsSchema = prop.items; const itemProperties = itemsSchema.properties || {}; const maxItems = prop.maxItems || 50; const currentItems = Array.isArray(value) ? value : []; html += `
`; // Render existing items currentItems.forEach((item, index) => { html += renderArrayObjectItem(fieldId, fullKey, itemProperties, item, index, itemsSchema); }); html += `
`; } else { // Array - check for file upload widget first (to avoid breaking static-image plugin), // then checkbox-group, then custom-feeds const hasXWidget = prop.hasOwnProperty('x-widget'); const xWidgetValue = prop['x-widget']; const xWidgetValue2 = prop['x-widget'] || prop['x_widget'] || prop.xWidget; console.log(`[DEBUG] Array field ${fullKey}:`, { type: prop.type, hasItems: !!prop.items, itemsType: prop.items?.type, itemsHasProperties: !!prop.items?.properties, hasXWidget: hasXWidget, 'x-widget': xWidgetValue, 'x-widget (alt)': xWidgetValue2, 'x-upload-config': prop['x-upload-config'], propKeys: Object.keys(prop), value: value }); // Check for file-upload widget FIRST (to avoid breaking static-image plugin) if (xWidgetValue === 'file-upload' || xWidgetValue2 === 'file-upload') { console.log(`[DEBUG] ✅ Detected file-upload widget for ${fullKey} - rendering upload zone`); const uploadConfig = prop['x-upload-config'] || {}; const pluginId = uploadConfig.plugin_id || currentPluginConfig?.pluginId || 'static-image'; const maxFiles = uploadConfig.max_files || 10; const fileType = uploadConfig.file_type || 'image'; // 'image' or 'json' const allowedTypes = uploadConfig.allowed_types || (fileType === 'json' ? ['application/json'] : ['image/png', 'image/jpeg', 'image/bmp', 'image/gif']); const maxSizeMB = uploadConfig.max_size_mb || 5; const customUploadEndpoint = uploadConfig.endpoint; // Custom endpoint if specified const customDeleteEndpoint = uploadConfig.delete_endpoint; // Custom delete endpoint if specified const currentFiles = Array.isArray(value) ? value : []; const fieldId = fullKey.replace(/\./g, '_'); html += `

Drag and drop ${fileType === 'json' ? 'JSON files' : 'images'} here or click to browse

Max ${maxFiles} files, ${maxSizeMB}MB each ${fileType === 'json' ? '(JSON)' : '(PNG, JPG, GIF, BMP)'}

${currentFiles.map((file, idx) => { const fileId = file.id || file.category_name || idx; const fileName = file.original_filename || file.filename || (fileType === 'json' ? 'JSON File' : 'Image'); const entryCount = file.entry_count ? `${file.entry_count} entries` : ''; return `
${fileType === 'json' ? `
` : ` ${fileName} `}

${escapeHtml(fileName)}

${formatFileSize(file.size || 0)} • ${formatDate(file.uploaded_at)}

${entryCount ? `

${entryCount}

` : ''} ${fileType === 'image' && file.schedule ? `

${file.schedule.enabled && file.schedule.mode !== 'always' ? (window.getScheduleSummary ? window.getScheduleSummary(file.schedule) : 'Scheduled') : 'Always shown'}

` : ''}
${fileType === 'image' ? ` ` : ''}
${fileType === 'image' ? ` ` : ''}
`; }).join('')}
`; } else if (xWidgetValue === 'checkbox-group' || xWidgetValue2 === 'checkbox-group') { // Checkbox group widget for multi-select arrays with enum items // Use _data hidden input pattern to serialize selected values correctly console.log(`[DEBUG] ✅ Detected checkbox-group widget for ${fullKey} - rendering checkboxes`); const arrayValue = Array.isArray(value) ? value : (prop.default || []); const enumItems = prop.items && prop.items.enum ? prop.items.enum : []; const xOptions = prop['x-options'] || {}; const labels = xOptions.labels || {}; const fieldId = fullKey.replace(/\./g, '_'); html += `
`; enumItems.forEach((option) => { const isChecked = arrayValue.includes(option); const label = labels[option] || option.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); const checkboxId = `${fieldId}_${escapeHtml(option)}`; html += ` `; }); html += `
`; // Hidden input to store selected values as JSON array (like array-of-objects pattern) html += ``; // Sentinel hidden input with bracket notation to allow clearing array to [] when all unchecked // This ensures the field is always submitted, even when all checkboxes are unchecked html += ``; } else if (xWidgetValue === 'custom-feeds' || xWidgetValue2 === 'custom-feeds') { // Custom feeds widget - check schema validation first const itemsSchema = prop.items || {}; const itemProperties = itemsSchema.properties || {}; if (!itemProperties.name || !itemProperties.url) { // Schema doesn't match expected structure - fallback to regular array input console.log(`[DEBUG] ⚠️ Custom feeds widget requires 'name' and 'url' properties for ${fullKey}, using regular array input`); let arrayValue = ''; if (value === null || value === undefined) { arrayValue = Array.isArray(prop.default) ? prop.default.join(', ') : ''; } else if (Array.isArray(value)) { arrayValue = value.join(', '); } else { arrayValue = ''; } html += `

Enter values separated by commas

`; } else { // Custom feeds table interface - widget-specific implementation // Note: This is handled by the template, but we include it here for consistency // The template renders the custom feeds table, so JS-rendered forms should match console.log(`[DEBUG] ✅ Detected custom-feeds widget for ${fullKey} - note: custom feeds table is typically rendered server-side`); let arrayValue = ''; if (value === null || value === undefined) { arrayValue = Array.isArray(prop.default) ? prop.default.join(', ') : ''; } else if (Array.isArray(value)) { arrayValue = value.join(', '); } else { arrayValue = ''; } html += `

Enter values separated by commas (custom feeds table rendered server-side)

`; } } else { // Regular array input (comma-separated) console.log(`[DEBUG] ❌ No special widget detected for ${fullKey}, using regular array input`); // Handle null/undefined values - use default if available let arrayValue = ''; if (value === null || value === undefined) { arrayValue = Array.isArray(prop.default) ? prop.default.join(', ') : ''; } else if (Array.isArray(value)) { arrayValue = value.join(', '); } else { arrayValue = ''; } html += `

Enter values separated by commas

`; } } } else if (prop.enum) { html += ``; } else if (prop['x-widget'] === 'custom-html') { // Custom HTML widget - load HTML from plugin directory const htmlFile = prop['x-html-file']; const pluginId = currentPluginConfig?.pluginId || window.currentPluginConfig?.pluginId || ''; const fieldId = fullKey.replace(/\./g, '_'); console.log(`[Custom HTML Widget] Generating widget for ${fullKey}:`, { htmlFile, pluginId, fieldId, hasPluginId: !!pluginId }); if (htmlFile && pluginId) { html += `

Loading file manager...

`; // Load HTML asynchronously setTimeout(() => { loadCustomHtmlWidget(fieldId, pluginId, htmlFile); }, 100); } else { console.error(`[Custom HTML Widget] Missing configuration for ${fullKey}:`, { htmlFile, pluginId, currentPluginConfig: currentPluginConfig?.pluginId, windowPluginConfig: window.currentPluginConfig?.pluginId }); html += `
Custom HTML widget configuration error: missing html-file or plugin-id
htmlFile: ${htmlFile || 'missing'}, pluginId: ${pluginId || 'missing'}
`; } } else if (prop.type === 'object') { // Fallback for objects that don't match any special case - render as JSON textarea console.warn(`[DEBUG] Object field ${fullKey} doesn't match any special handler, rendering as JSON textarea`); const jsonValue = typeof value === 'object' && value !== null ? JSON.stringify(value, null, 2) : (value || '{}'); html += `

Edit as JSON object

`; } else { // Check if this is a secret field const isSecret = prop['x-secret'] === true; const inputType = isSecret ? 'password' : 'text'; const maxLength = prop.maxLength || ''; const maxLengthAttr = maxLength ? `maxlength="${maxLength}"` : ''; const secretClass = isSecret ? 'pr-10' : ''; html += `
`; if (isSecret) { html += ` `; } html += `
`; } html += `
`; return html; } // Load custom HTML widget from plugin directory async function loadCustomHtmlWidget(fieldId, pluginId, htmlFile) { try { const container = document.getElementById(`${fieldId}_custom_html`); if (!container) { console.warn(`[Custom HTML Widget] Container not found: ${fieldId}_custom_html`); return; } // Fetch HTML from plugin static files endpoint const response = await fetch(`/api/v3/plugins/${pluginId}/static/${htmlFile}`); if (!response.ok) { throw new Error(`Failed to load custom HTML: ${response.statusText}`); } const html = await response.text(); // Inject HTML into container container.innerHTML = html; // Execute any script tags in the loaded HTML const scripts = container.querySelectorAll('script'); scripts.forEach(oldScript => { const newScript = document.createElement('script'); Array.from(oldScript.attributes).forEach(attr => { newScript.setAttribute(attr.name, attr.value); }); newScript.appendChild(document.createTextNode(oldScript.innerHTML)); oldScript.parentNode.replaceChild(newScript, oldScript); }); console.log(`[Custom HTML Widget] Loaded ${htmlFile} for plugin ${pluginId}`); } catch (error) { console.error(`[Custom HTML Widget] Error loading ${htmlFile} for plugin ${pluginId}:`, error); const container = document.getElementById(`${fieldId}_custom_html`); if (container) { container.innerHTML = `
Failed to load custom HTML: ${error.message}
`; } } } function generateFormFromSchema(schema, config, webUiActions = []) { console.log('[DEBUG] ===== generateFormFromSchema called ====='); console.log('[DEBUG] Schema properties:', Object.keys(schema.properties || {})); console.log('[DEBUG] Web UI Actions:', webUiActions.length); let formHtml = '
'; if (schema.properties) { // Get ordered properties if x-propertyOrder is defined let propertyEntries = Object.entries(schema.properties); if (schema['x-propertyOrder'] && Array.isArray(schema['x-propertyOrder'])) { const order = schema['x-propertyOrder']; const orderedEntries = []; const unorderedEntries = []; // Separate ordered and unordered properties propertyEntries.forEach(([key, prop]) => { const index = order.indexOf(key); if (index !== -1) { orderedEntries[index] = [key, prop]; } else { unorderedEntries.push([key, prop]); } }); // Combine ordered entries (filter out undefined from sparse array) with unordered entries propertyEntries = orderedEntries.filter(entry => entry !== undefined).concat(unorderedEntries); } propertyEntries.forEach(([key, prop]) => { // Skip the 'enabled' property - it's managed separately via the header toggle if (key === 'enabled') return; let value = config[key] !== undefined ? config[key] : prop.default; // Special handling: use uploaded_files from config if available (populated by backend from disk) // No need to populate from categories here since backend does it formHtml += generateFieldHtml(key, prop, value); }); } // Add web UI actions section if plugin defines any console.log('[DEBUG] webUiActions:', webUiActions, 'length:', webUiActions ? webUiActions.length : 0); if (webUiActions && webUiActions.length > 0) { console.log('[DEBUG] Rendering', webUiActions.length, 'actions'); formHtml += `

Actions

${webUiActions[0].section_description || 'Perform actions for this plugin'}

`; webUiActions.forEach((action, index) => { const actionId = `action-${action.id}-${index}`; const statusId = `action-status-${action.id}-${index}`; const bgColor = action.color || 'blue'; // Map color names to explicit Tailwind classes to ensure they're included const colorMap = { 'blue': { bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-900', textLight: 'text-blue-700', btn: 'bg-blue-600 hover:bg-blue-700' }, 'green': { bg: 'bg-green-50', border: 'border-green-200', text: 'text-green-900', textLight: 'text-green-700', btn: 'bg-green-600 hover:bg-green-700' }, 'red': { bg: 'bg-red-50', border: 'border-red-200', text: 'text-red-900', textLight: 'text-red-700', btn: 'bg-red-600 hover:bg-red-700' }, 'yellow': { bg: 'bg-yellow-50', border: 'border-yellow-200', text: 'text-yellow-900', textLight: 'text-yellow-700', btn: 'bg-yellow-600 hover:bg-yellow-700' }, 'purple': { bg: 'bg-purple-50', border: 'border-purple-200', text: 'text-purple-900', textLight: 'text-purple-700', btn: 'bg-purple-600 hover:bg-purple-700' } }; const colors = colorMap[bgColor] || colorMap['blue']; formHtml += `

${action.icon ? `` : ''}${action.title || action.id}

${action.description || ''}

`; }); formHtml += `
`; } else { console.log('[DEBUG] No webUiActions to render'); } formHtml += `
`; return Promise.resolve(formHtml); } // Functions to handle patternProperties key-value pairs window.addKeyValuePair = function(fieldId, fullKey, maxProperties) { const pairsContainer = document.getElementById(fieldId + '_pairs'); if (!pairsContainer) return; const currentPairs = pairsContainer.querySelectorAll('.key-value-pair'); if (currentPairs.length >= maxProperties) { alert(`Maximum ${maxProperties} entries allowed`); return; } const newIndex = currentPairs.length; const valueType = 'string'; // Default to string, could be determined from schema const pairHtml = `
`; pairsContainer.insertAdjacentHTML('beforeend', pairHtml); updateKeyValuePairData(fieldId, fullKey); // Update add button state const addButton = pairsContainer.nextElementSibling; if (addButton && currentPairs.length + 1 >= maxProperties) { addButton.disabled = true; addButton.style.opacity = '0.5'; addButton.style.cursor = 'not-allowed'; } }; window.removeKeyValuePair = function(fieldId, index) { const pairsContainer = document.getElementById(fieldId + '_pairs'); if (!pairsContainer) return; const pair = pairsContainer.querySelector(`.key-value-pair[data-index="${index}"]`); if (pair) { pair.remove(); // Re-index remaining pairs const remainingPairs = pairsContainer.querySelectorAll('.key-value-pair'); remainingPairs.forEach((p, newIndex) => { p.setAttribute('data-index', newIndex); const keyInput = p.querySelector('[data-key-index]'); const valueInput = p.querySelector('[data-value-index]'); if (keyInput) { keyInput.setAttribute('name', keyInput.getAttribute('name').replace(/\[key_\d+\]/, `[key_${newIndex}]`)); keyInput.setAttribute('data-key-index', newIndex); keyInput.setAttribute('onchange', `updateKeyValuePairData('${fieldId}', '${keyInput.getAttribute('name').split('[')[0]}')`); } if (valueInput) { valueInput.setAttribute('name', valueInput.getAttribute('name').replace(/\[value_\d+\]/, `[value_${newIndex}]`)); valueInput.setAttribute('data-value-index', newIndex); valueInput.setAttribute('onchange', `updateKeyValuePairData('${fieldId}', '${valueInput.getAttribute('name').split('[')[0]}')`); } const removeButton = p.querySelector('button[onclick*="removeKeyValuePair"]'); if (removeButton) { removeButton.setAttribute('onclick', `removeKeyValuePair('${fieldId}', ${newIndex})`); } }); const hiddenInput = pairsContainer.closest('.key-value-pairs-container').querySelector('input[type="hidden"]'); if (hiddenInput) { const hiddenName = hiddenInput.getAttribute('name').replace(/_data$/, ''); updateKeyValuePairData(fieldId, hiddenName); } // Update add button state const addButton = pairsContainer.nextElementSibling; if (addButton) { const maxProperties = parseInt(addButton.getAttribute('onclick').match(/\d+/)[0]); if (remainingPairs.length < maxProperties) { addButton.disabled = false; addButton.style.opacity = '1'; addButton.style.cursor = 'pointer'; } } } }; window.updateKeyValuePairData = function(fieldId, fullKey) { const pairsContainer = document.getElementById(fieldId + '_pairs'); const hiddenInput = document.getElementById(fieldId + '_data'); if (!pairsContainer || !hiddenInput) return; const pairs = {}; const keyInputs = pairsContainer.querySelectorAll('[data-key-index]'); const valueInputs = pairsContainer.querySelectorAll('[data-value-index]'); keyInputs.forEach((keyInput, idx) => { const key = keyInput.value.trim(); const valueInput = Array.from(valueInputs).find(v => v.getAttribute('data-value-index') === keyInput.getAttribute('data-key-index')); if (key && valueInput) { const value = valueInput.value.trim(); if (value) { pairs[key] = value; } } }); hiddenInput.value = JSON.stringify(pairs); }; // Functions to handle array-of-objects window.addArrayObjectItem = function(fieldId, fullKey, maxItems) { const itemsContainer = document.getElementById(fieldId + '_items'); const hiddenInput = document.getElementById(fieldId + '_data'); if (!itemsContainer || !hiddenInput) return; const currentItems = itemsContainer.querySelectorAll('.array-object-item'); if (currentItems.length >= maxItems) { alert(`Maximum ${maxItems} items allowed`); return; } // Get schema for item properties from the hidden input's data attribute or currentPluginConfig const schema = (typeof currentPluginConfig !== 'undefined' && currentPluginConfig?.schema) || (typeof window.currentPluginConfig !== 'undefined' && window.currentPluginConfig?.schema); if (!schema) return; // Navigate to the items schema const keys = fullKey.split('.'); let itemsSchema = schema.properties; for (const key of keys) { if (itemsSchema && itemsSchema[key]) { itemsSchema = itemsSchema[key]; if (itemsSchema.type === 'array' && itemsSchema.items) { itemsSchema = itemsSchema.items; break; } } } if (!itemsSchema || !itemsSchema.properties) return; const newIndex = currentItems.length; const itemHtml = renderArrayObjectItem(fieldId, fullKey, itemsSchema.properties, {}, newIndex, itemsSchema); itemsContainer.insertAdjacentHTML('beforeend', itemHtml); 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 const remainingItems = itemsContainer.querySelectorAll('.array-object-item'); remainingItems.forEach((itemEl, newIndex) => { itemEl.setAttribute('data-index', newIndex); // Update the id attribute to match new index (used by file upload selectors) const newItemId = `${fieldId}_item_${newIndex}`; itemEl.id = newItemId; // Update all inputs within this item - need to update name/id attributes itemEl.querySelectorAll('input, select, textarea').forEach(input => { const name = input.getAttribute('name') || input.id; if (name) { // Update name/id attribute with new index const newName = name.replace(/\[\d+\]/, `[${newIndex}]`); if (input.getAttribute('name')) input.setAttribute('name', newName); if (input.id) input.id = input.id.replace(/\d+/, newIndex); } }); // Update button onclick attributes itemEl.querySelectorAll('button[onclick]').forEach(button => { const onclick = button.getAttribute('onclick'); if (onclick) { button.setAttribute('onclick', onclick.replace(/\d+/, newIndex)); } }); }); updateArrayObjectData(fieldId); // Update add button state const addButton = itemsContainer.nextElementSibling; if (addButton) { const maxItems = parseInt(addButton.getAttribute('onclick').match(/\d+/)[0]); if (remainingItems.length < maxItems) { addButton.disabled = false; addButton.style.opacity = '1'; addButton.style.cursor = 'pointer'; } } } }; window.updateArrayObjectData = function(fieldId) { const itemsContainer = document.getElementById(fieldId + '_items'); const hiddenInput = document.getElementById(fieldId + '_data'); if (!itemsContainer || !hiddenInput) return; // Get existing items from hidden input to preserve non-editable properties let existingItems = []; try { const existingData = hiddenInput.value.trim(); if (existingData) { existingItems = JSON.parse(existingData); } } catch (e) { console.error('Error parsing existing items data:', e); } const items = []; const itemElements = itemsContainer.querySelectorAll('.array-object-item'); itemElements.forEach((itemEl, index) => { // Start with original item data from data attribute to preserve non-editable properties // This avoids index-based corruption after deletions/reindexing let existingItem = {}; const itemDataBase64 = itemEl.getAttribute('data-item-data'); if (itemDataBase64) { try { const itemDataJson = decodeURIComponent(escape(atob(itemDataBase64))); existingItem = JSON.parse(itemDataJson); } catch (e) { console.error('Error parsing item data from data attribute:', e); // Fallback to index-based lookup if data attribute is missing/corrupt if (index < existingItems.length && existingItems[index]) { existingItem = existingItems[index]; } } } else { // Fallback to index-based lookup if data attribute is missing if (index < existingItems.length && existingItems[index]) { existingItem = existingItems[index]; } } const item = Object.assign({}, existingItem); // Copy existing item // Get all text inputs in this item and overlay their values with type coercion itemEl.querySelectorAll('input[type="text"], input[type="url"], input[type="number"]').forEach(input => { const propKey = input.getAttribute('data-prop-key'); if (propKey && propKey !== 'logo_file') { let value = input.value.trim(); // Type coercion: check input type or data-prop-type attribute const inputType = input.type; const propType = input.getAttribute('data-prop-type'); if (inputType === 'number' || propType === 'number') { // Use valueAsNumber if available, fallback to Number() const numValue = input.valueAsNumber !== undefined && !isNaN(input.valueAsNumber) ? input.valueAsNumber : Number(value); item[propKey] = isNaN(numValue) ? value : numValue; } else if (propType === 'array' || input.getAttribute('data-prop-is-list') === 'true') { // Try to parse as JSON array, fallback to comma splitting try { const parsed = JSON.parse(value); item[propKey] = Array.isArray(parsed) ? parsed : value; } catch (e) { // Fallback to comma-splitting for arrays item[propKey] = value ? value.split(',').map(v => v.trim()).filter(v => v) : []; } } else { // String value - keep as-is item[propKey] = value; } } }); // Handle checkboxes itemEl.querySelectorAll('input[type="checkbox"]').forEach(checkbox => { const propKey = checkbox.getAttribute('data-prop-key'); if (propKey) { item[propKey] = checkbox.checked; } }); // Handle file upload data (stored in data attributes, base64-encoded) itemEl.querySelectorAll('[data-file-data]').forEach(fileEl => { const fileDataBase64 = fileEl.getAttribute('data-file-data'); if (fileDataBase64) { try { // Decode base64-encoded JSON const fileDataJson = decodeURIComponent(escape(atob(fileDataBase64))); const data = JSON.parse(fileDataJson); const propKey = fileEl.getAttribute('data-prop-key'); if (propKey) { item[propKey] = data; } } catch (e) { console.error('Error parsing file data:', e); } } }); items.push(item); // Update data-item-data attribute with the merged item to keep it in sync try { const itemDataJson = JSON.stringify(item); const itemDataBase64 = btoa(unescape(encodeURIComponent(itemDataJson))); itemEl.setAttribute('data-item-data', itemDataBase64); } catch (e) { console.error('Error updating data-item-data attribute:', e); } }); hiddenInput.value = JSON.stringify(items); }; window.handleArrayObjectFileUpload = async function(event, fieldId, itemIndex, propKey, pluginId) { const file = event.target.files[0]; if (!file) return; // Derive item element from event instead of constructing ID (works after reindexing) const itemEl = event.target.closest('.array-object-item'); if (!itemEl) { console.error('Array object item element not found'); return; } // Find file upload container within the item element, scoped to propKey const fileUploadContainer = itemEl.querySelector(`.file-upload-widget-inline[data-prop-key="${propKey}"]`); if (!fileUploadContainer) { console.error('File upload container not found for propKey:', propKey); return; } // Get upload config from data attribute let uploadConfig = { allowed_types: ['image/png', 'image/jpeg', 'image/jpg', 'image/bmp'], max_size_mb: 5 }; const uploadConfigBase64 = fileUploadContainer.getAttribute('data-upload-config'); if (uploadConfigBase64) { try { const uploadConfigJson = decodeURIComponent(escape(atob(uploadConfigBase64))); uploadConfig = JSON.parse(uploadConfigJson); } catch (e) { console.error('Error parsing upload config from data attribute:', e); } } // Validate file type using uploadConfig const allowedTypes = uploadConfig.allowed_types || ['image/png', 'image/jpeg', 'image/jpg', 'image/bmp']; if (!allowedTypes.includes(file.type)) { if (typeof showNotification === 'function') { showNotification(`File ${file.name} is not a valid image type`, 'error'); } return; } // Validate file size using uploadConfig const maxSizeMB = uploadConfig.max_size_mb || 5; if (file.size > maxSizeMB * 1024 * 1024) { if (typeof showNotification === 'function') { showNotification(`File ${file.name} exceeds ${maxSizeMB}MB limit`, 'error'); } return; } // Validate pluginId before upload (fail fast) if (!pluginId || pluginId === 'null' || pluginId === 'undefined' || (typeof pluginId === 'string' && pluginId.trim() === '')) { if (typeof showNotification === 'function') { showNotification('Plugin ID is required for file upload', 'error'); } console.error('File upload failed: pluginId is required'); return; } // Upload file const formData = new FormData(); formData.append('plugin_id', pluginId); formData.append('files', file); try { const response = await fetch('/api/v3/plugins/assets/upload', { method: 'POST', body: formData }); // Check response.ok before parsing JSON to avoid parsing errors on HTTP errors if (!response.ok) { const errorText = await response.text(); let errorMessage = `Upload failed: HTTP ${response.status}`; try { const errorData = JSON.parse(errorText); errorMessage = errorData.message || errorMessage; } catch (e) { // If response isn't JSON, use the text or status if (errorText) { errorMessage = `Upload failed: ${errorText}`; } } if (typeof showNotification === 'function') { showNotification(errorMessage, 'error'); } return; } const data = await response.json(); if (data.status === 'success' && data.uploaded_files && data.uploaded_files.length > 0) { const uploadedFile = data.uploaded_files[0]; // Store file data in data-file-data attribute on the container (base64-encoded) const fileDataJson = JSON.stringify(uploadedFile); const fileDataBase64 = btoa(unescape(encodeURIComponent(fileDataJson))); fileUploadContainer.setAttribute('data-file-data', fileDataBase64); fileUploadContainer.setAttribute('data-prop-key', propKey); // Update the display to show the uploaded image const existingImage = fileUploadContainer.querySelector('.uploaded-image-container'); if (existingImage) { existingImage.remove(); } const imageContainer = document.createElement('div'); imageContainer.className = 'mt-2 flex items-center space-x-2 uploaded-image-container'; const escapedPath = escapeAttribute(uploadedFile.path.replace(/^\/+/, '')); const escapedFieldId = escapeAttribute(fieldId); const escapedPropKey = escapeAttribute(propKey); // Get current item index from data-index attribute for remove button const currentItemIndex = itemEl.getAttribute('data-index') || itemIndex; imageContainer.innerHTML = ` Logo `; fileUploadContainer.appendChild(imageContainer); // Update the hidden input with the new file data updateArrayObjectData(fieldId); if (typeof showNotification === 'function') { showNotification('Logo uploaded successfully', 'success'); } } else { if (typeof showNotification === 'function') { showNotification(`Upload failed: ${data.message || 'Unknown error'}`, 'error'); } } } catch (error) { console.error('Upload error:', error); if (typeof showNotification === 'function') { showNotification(`Upload error: ${error.message}`, 'error'); } } // Clear file input event.target.value = ''; }; window.removeArrayObjectFile = function(fieldId, itemIndex, propKey) { const itemId = `${fieldId}_item_${itemIndex}`; const fileUploadContainer = document.querySelector(`#${itemId} .file-upload-widget-inline`); if (!fileUploadContainer) { console.error('File upload container not found'); return; } // Remove file data from data attribute fileUploadContainer.removeAttribute('data-file-data'); // Remove the image display const imageContainer = fileUploadContainer.querySelector('.uploaded-image-container'); if (imageContainer) { imageContainer.remove(); } // Update the hidden input to remove the file data updateArrayObjectData(fieldId); if (typeof showNotification === 'function') { showNotification('Logo removed', 'success'); } }; // Function to toggle nested sections window.toggleNestedSection = function(sectionId, event) { // Prevent event bubbling if event is provided if (event) { event.stopPropagation(); event.preventDefault(); } const content = document.getElementById(sectionId); const icon = document.getElementById(sectionId + '-icon'); if (!content || !icon) return; // Prevent multiple simultaneous toggles if (content.dataset.toggling === 'true') { return; } // Mark as toggling content.dataset.toggling = 'true'; // Check current state before making changes const hasCollapsed = content.classList.contains('collapsed'); const hasExpanded = content.classList.contains('expanded'); const displayStyle = content.style.display; const computedDisplay = window.getComputedStyle(content).display; // Check if content is currently collapsed - prioritize class over display style const isCollapsed = hasCollapsed || (!hasExpanded && (displayStyle === 'none' || computedDisplay === 'none')); if (isCollapsed) { // Expand the section content.classList.remove('collapsed'); content.classList.add('expanded'); content.style.display = 'block'; content.style.overflow = 'hidden'; // Prevent content jumping during animation // CRITICAL FIX: Use setTimeout to ensure browser has time to layout the element // When element goes from display:none to display:block, scrollHeight might be 0 // We need to wait for the browser to calculate the layout setTimeout(() => { // Force reflow to ensure transition works void content.offsetHeight; // Now measure the actual content height after layout const scrollHeight = content.scrollHeight; if (scrollHeight > 0) { content.style.maxHeight = scrollHeight + 'px'; } else { // Fallback: if scrollHeight is still 0, try measuring again after a brief delay setTimeout(() => { const retryHeight = content.scrollHeight; content.style.maxHeight = retryHeight > 0 ? retryHeight + 'px' : '500px'; }, 10); } }, 10); icon.classList.remove('fa-chevron-right'); icon.classList.add('fa-chevron-down'); // Allow parent section to show overflow when expanded const sectionElement = content.closest('.nested-section'); if (sectionElement) { sectionElement.style.overflow = 'visible'; } // After animation completes, remove max-height constraint to allow natural expansion // This allows parent sections to automatically expand setTimeout(() => { // Only set to none if still expanded (prevent race condition) if (content.classList.contains('expanded') && !content.classList.contains('collapsed')) { content.style.maxHeight = 'none'; content.style.overflow = ''; } // Clear toggling flag content.dataset.toggling = 'false'; }, 320); // Slightly longer than transition duration // Scroll the expanded content into view after a short delay to allow animation setTimeout(() => { if (sectionElement) { // Find the modal container const modalContent = sectionElement.closest('.modal-content'); if (modalContent) { // Scroll the section header into view within the modal const headerButton = sectionElement.querySelector('button'); if (headerButton) { headerButton.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' }); } } else { // If not in a modal, just scroll the section sectionElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } } }, 350); // Wait for animation to complete } else { // Collapse the section content.classList.add('collapsed'); content.classList.remove('expanded'); content.style.overflow = 'hidden'; // Prevent content jumping during animation // Set max-height to current scroll height first (required for smooth animation) const currentHeight = content.scrollHeight; content.style.maxHeight = currentHeight + 'px'; // Force reflow to apply the height void content.offsetHeight; // Then animate to 0 setTimeout(() => { content.style.maxHeight = '0'; }, 10); // Restore parent section overflow when collapsed const sectionElement = content.closest('.nested-section'); if (sectionElement) { sectionElement.style.overflow = 'hidden'; } // Use setTimeout to set display:none after transition completes setTimeout(() => { if (content.classList.contains('collapsed')) { content.style.display = 'none'; content.style.overflow = ''; } // Clear toggling flag content.dataset.toggling = 'false'; }, 320); // Match the CSS transition duration + small buffer icon.classList.remove('fa-chevron-down'); icon.classList.add('fa-chevron-right'); } } function generateSimpleConfigForm(config, webUiActions = []) { console.log('[DEBUG] generateSimpleConfigForm - webUiActions:', webUiActions, 'length:', webUiActions ? webUiActions.length : 0); let actionsHtml = ''; if (webUiActions && webUiActions.length > 0) { console.log('[DEBUG] Rendering', webUiActions.length, 'actions in simple form'); actionsHtml = `

Actions

`; // Map color names to explicit Tailwind classes const colorMap = { 'blue': { bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-900', textLight: 'text-blue-700', btn: 'bg-blue-600 hover:bg-blue-700' }, 'green': { bg: 'bg-green-50', border: 'border-green-200', text: 'text-green-900', textLight: 'text-green-700', btn: 'bg-green-600 hover:bg-green-700' }, 'red': { bg: 'bg-red-50', border: 'border-red-200', text: 'text-red-900', textLight: 'text-red-700', btn: 'bg-red-600 hover:bg-red-700' }, 'yellow': { bg: 'bg-yellow-50', border: 'border-yellow-200', text: 'text-yellow-900', textLight: 'text-yellow-700', btn: 'bg-yellow-600 hover:bg-yellow-700' }, 'purple': { bg: 'bg-purple-50', border: 'border-purple-200', text: 'text-purple-900', textLight: 'text-purple-700', btn: 'bg-purple-600 hover:bg-purple-700' } }; webUiActions.forEach((action, index) => { const actionId = `action-${action.id}-${index}`; const statusId = `action-status-${action.id}-${index}`; const bgColor = action.color || 'blue'; const colors = colorMap[bgColor] || colorMap['blue']; actionsHtml += `

${action.icon ? `` : ''}${action.title || action.id}

${action.description || ''}

`; }); actionsHtml += `
`; } return `
${actionsHtml}
`; } // Plugin config modal state let currentPluginConfigState = { pluginId: null, config: {}, schema: null, jsonEditor: null, formData: {} }; // Initialize JSON editor async function initJsonEditor() { const textarea = document.getElementById('plugin-config-json-editor'); if (!textarea) return null; // Lazy load CodeMirror if needed if (typeof CodeMirror === 'undefined') { if (typeof window.loadCodeMirror === 'function') { try { await window.loadCodeMirror(); } catch (error) { console.error('Failed to load CodeMirror:', error); showNotification('JSON editor not available. Please refresh the page.', 'error'); return null; } } else { console.error('CodeMirror not loaded and loadCodeMirror not available. Please refresh the page.'); showNotification('JSON editor not available. Please refresh the page.', 'error'); return null; } } if (currentPluginConfigState.jsonEditor) { currentPluginConfigState.jsonEditor.toTextArea(); currentPluginConfigState.jsonEditor = null; } const editor = CodeMirror.fromTextArea(textarea, { mode: 'application/json', theme: 'monokai', lineNumbers: true, lineWrapping: true, indentUnit: 2, tabSize: 2, autoCloseBrackets: true, matchBrackets: true, foldGutter: true, gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'] }); // Validate JSON on change editor.on('change', function() { const value = editor.getValue(); try { JSON.parse(value); editor.setOption('class', ''); } catch (e) { editor.setOption('class', 'cm-error'); } }); return editor; } // Switch between form and JSON views function switchPluginConfigView(view) { const formView = document.getElementById('plugin-config-form-view'); const jsonView = document.getElementById('plugin-config-json-view'); const formBtn = document.getElementById('view-toggle-form'); const jsonBtn = document.getElementById('view-toggle-json'); if (view === 'json') { formView.classList.add('hidden'); jsonView.classList.remove('hidden'); formBtn.classList.remove('active', 'bg-blue-600', 'text-white'); formBtn.classList.add('text-gray-700', 'hover:bg-gray-200'); jsonBtn.classList.add('active', 'bg-blue-600', 'text-white'); jsonBtn.classList.remove('text-gray-700', 'hover:bg-gray-200'); // Sync form data to JSON editor syncFormToJson(); // Initialize editor if not already done if (!currentPluginConfigState.jsonEditor) { // Small delay to ensure textarea is visible, then load CodeMirror and initialize setTimeout(async () => { currentPluginConfigState.jsonEditor = await initJsonEditor(); if (currentPluginConfigState.jsonEditor) { const jsonText = JSON.stringify(currentPluginConfigState.config, null, 2); currentPluginConfigState.jsonEditor.setValue(jsonText); currentPluginConfigState.jsonEditor.refresh(); } }, 50); } else { // Update editor content if already initialized const jsonText = JSON.stringify(currentPluginConfigState.config, null, 2); currentPluginConfigState.jsonEditor.setValue(jsonText); currentPluginConfigState.jsonEditor.refresh(); } } else { jsonView.classList.add('hidden'); formView.classList.remove('hidden'); jsonBtn.classList.remove('active', 'bg-blue-600', 'text-white'); jsonBtn.classList.add('text-gray-700', 'hover:bg-gray-200'); formBtn.classList.add('active', 'bg-blue-600', 'text-white'); formBtn.classList.remove('text-gray-700', 'hover:bg-gray-200'); // Sync JSON to form if JSON was edited syncJsonToForm(); } } // Sync form data to JSON config function syncFormToJson() { const form = document.getElementById('plugin-config-form'); if (!form) return; const formData = new FormData(form); const config = {}; // Get schema for type conversion const schema = currentPluginConfigState.schema; for (let [key, value] of formData.entries()) { if (key === 'enabled') continue; // Skip enabled, managed separately // Handle nested keys (dot notation) const keys = key.split('.'); let current = config; for (let i = 0; i < keys.length - 1; i++) { if (!current[keys[i]]) { current[keys[i]] = {}; } current = current[keys[i]]; } const finalKey = keys[keys.length - 1]; const prop = schema?.properties?.[finalKey] || (keys.length > 1 ? null : schema?.properties?.[key]); // Type conversion based on schema if (prop?.type === 'array') { current[finalKey] = value.split(',').map(item => item.trim()).filter(item => item.length > 0); } else if (prop?.type === 'integer' || key === 'display_duration') { current[finalKey] = parseInt(value) || 0; } else if (prop?.type === 'number') { current[finalKey] = parseFloat(value) || 0; } else if (prop?.type === 'boolean') { current[finalKey] = value === 'true' || value === true; } else { current[finalKey] = value; } } // Deep merge with existing config to preserve nested structures function deepMerge(target, source) { for (const key in source) { if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) { if (!target[key] || typeof target[key] !== 'object' || Array.isArray(target[key])) { target[key] = {}; } deepMerge(target[key], source[key]); } else { target[key] = source[key]; } } return target; } // Deep merge new form data into existing config currentPluginConfigState.config = deepMerge( JSON.parse(JSON.stringify(currentPluginConfigState.config)), // Deep clone config ); } // Sync JSON editor content to form function syncJsonToForm() { if (!currentPluginConfigState.jsonEditor) return; try { const jsonText = currentPluginConfigState.jsonEditor.getValue(); const config = JSON.parse(jsonText); currentPluginConfigState.config = config; // Update form fields (this is complex, so we'll reload the form) // For now, just update the config state - form will be regenerated on next open console.log('JSON synced to config state'); } catch (e) { console.error('Invalid JSON in editor:', e); showNotification('Invalid JSON in editor. Please fix errors before switching views.', 'error'); } } // Reset plugin config to defaults async function resetPluginConfigToDefaults() { if (!currentPluginConfigState.pluginId) { showNotification('No plugin selected', 'error'); return; } if (!confirm('Are you sure you want to reset this plugin configuration to defaults? This will replace all current settings.')) { return; } try { const response = await fetch('/api/v3/plugins/config/reset', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ plugin_id: currentPluginConfigState.pluginId, preserve_secrets: true }) }); const data = await response.json(); if (data.status === 'success') { showNotification(data.message, 'success'); // Reload the config form with defaults const newConfig = data.data?.config || {}; currentPluginConfigState.config = newConfig; // Regenerate form const content = document.getElementById('plugin-config-content'); if (content) { content.innerHTML = '
'; generatePluginConfigForm(currentPluginConfigState.pluginId, newConfig) .then(formHtml => { content.innerHTML = formHtml; const form = document.getElementById('plugin-config-form'); if (form) { form.addEventListener('submit', handlePluginConfigSubmit); } }); } // Update JSON editor if it's visible if (currentPluginConfigState.jsonEditor) { const jsonText = JSON.stringify(newConfig, null, 2); currentPluginConfigState.jsonEditor.setValue(jsonText); } } else { showNotification(data.message || 'Failed to reset configuration', 'error'); } } catch (error) { console.error('Error resetting config:', error); showNotification('Error resetting configuration: ' + error.message, 'error'); } } // Display validation errors function displayValidationErrors(errors) { const errorContainer = document.getElementById('plugin-config-validation-errors'); const errorList = document.getElementById('validation-errors-list'); if (!errorContainer || !errorList) return; if (errors && errors.length > 0) { errorContainer.classList.remove('hidden'); errorList.innerHTML = errors.map(error => `
  • ${escapeHtml(error)}
  • `).join(''); } else { errorContainer.classList.add('hidden'); errorList.innerHTML = ''; } } // Save configuration from JSON editor async function saveConfigFromJsonEditor() { if (!currentPluginConfigState.jsonEditor || !currentPluginConfigState.pluginId) { return; } try { const jsonText = currentPluginConfigState.jsonEditor.getValue(); const config = JSON.parse(jsonText); // Update state currentPluginConfigState.config = config; // Save the configuration (will handle validation errors) savePluginConfiguration(currentPluginConfigState.pluginId, config); } catch (e) { console.error('Error saving JSON config:', e); if (e instanceof SyntaxError) { showNotification('Invalid JSON. Please fix syntax errors before saving.', 'error'); displayValidationErrors([`JSON Syntax Error: ${e.message}`]); } else { showNotification('Error saving configuration: ' + e.message, 'error'); } } } window.closePluginConfigModal = function() { const modal = document.getElementById('plugin-config-modal'); modal.style.display = 'none'; // Clean up JSON editor if (currentPluginConfigState.jsonEditor) { currentPluginConfigState.jsonEditor.toTextArea(); currentPluginConfigState.jsonEditor = null; } // Reset state currentPluginConfig = null; currentPluginConfigState.pluginId = null; currentPluginConfigState.config = {}; currentPluginConfigState.schema = null; // Hide validation errors displayValidationErrors([]); console.log('Modal closed'); } // Generic Plugin Action Handler window.executePluginAction = function(actionId, actionIndex, pluginIdParam = null) { console.log('[DEBUG] executePluginAction called - actionId:', actionId, 'actionIndex:', actionIndex, 'pluginIdParam:', pluginIdParam); // Construct button ID first (we have actionId and actionIndex) const actionIdFull = `action-${actionId}-${actionIndex}`; const statusId = `action-status-${actionId}-${actionIndex}`; const btn = document.getElementById(actionIdFull); const statusDiv = document.getElementById(statusId); // Get plugin ID from multiple sources with comprehensive fallback logic let pluginId = pluginIdParam; // Fallback 1: Try to get from button's data-plugin-id attribute if (!pluginId && btn) { pluginId = btn.getAttribute('data-plugin-id'); if (pluginId) { console.log('[DEBUG] Got pluginId from button data attribute:', pluginId); } } // Fallback 2: Try to get from closest parent with data-plugin-id if (!pluginId && btn) { const parentWithPluginId = btn.closest('[data-plugin-id]'); if (parentWithPluginId) { pluginId = parentWithPluginId.getAttribute('data-plugin-id'); if (pluginId) { console.log('[DEBUG] Got pluginId from parent element:', pluginId); } } } // Fallback 3: Try to get from plugin-config-container or plugin-config-tab if (!pluginId && btn) { const container = btn.closest('.plugin-config-container, .plugin-config-tab, [id^="plugin-config-"]'); if (container) { // Try data-plugin-id first pluginId = container.getAttribute('data-plugin-id'); if (!pluginId) { // Try to extract from ID like "plugin-config-{pluginId}" const idMatch = container.id.match(/plugin-config-(.+)/); if (idMatch) { pluginId = idMatch[1]; } } if (pluginId) { console.log('[DEBUG] Got pluginId from container:', pluginId); } } } // Fallback 4: Try to get from currentPluginConfig if (!pluginId) { pluginId = currentPluginConfig?.pluginId; if (pluginId) { console.log('[DEBUG] Got pluginId from currentPluginConfig:', pluginId); } } // Fallback 5: Try to get from Alpine.js context (activeTab) if (!pluginId && window.Alpine) { try { const appElement = document.querySelector('[x-data="app()"]'); if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) { const appData = appElement._x_dataStack[0]; if (appData.activeTab && appData.activeTab !== 'overview' && appData.activeTab !== 'plugins' && appData.activeTab !== 'wifi') { pluginId = appData.activeTab; console.log('[DEBUG] Got pluginId from Alpine activeTab:', pluginId); } } } catch (e) { console.warn('[DEBUG] Error accessing Alpine context:', e); } } // Fallback 6: Try to find from plugin tab elements (scoped to button context) if (!pluginId && btn) { try { // Search within the button's Alpine.js context (closest x-data element) const buttonContext = btn.closest('[x-data]'); if (buttonContext) { const pluginTab = buttonContext.querySelector('[x-show*="activeTab === plugin.id"]'); if (pluginTab && window.Alpine) { try { const pluginData = Alpine.$data(buttonContext); if (pluginData && pluginData.plugin) { pluginId = pluginData.plugin.id; if (pluginId) { console.log('[DEBUG] Got pluginId from Alpine plugin data (scoped to button context):', pluginId); } } } catch (e) { console.warn('[DEBUG] Error accessing Alpine plugin data:', e); } } } // If not found in button context, try container element if (!pluginId) { const container = btn.closest('.plugin-config-container, .plugin-config-tab, [id^="plugin-config-"]'); if (container) { const containerContext = container.querySelector('[x-show*="activeTab === plugin.id"]'); if (containerContext && window.Alpine) { try { const containerData = Alpine.$data(container.closest('[x-data]')); if (containerData && containerData.plugin) { pluginId = containerData.plugin.id; if (pluginId) { console.log('[DEBUG] Got pluginId from Alpine plugin data (scoped to container):', pluginId); } } } catch (e) { console.warn('[DEBUG] Error accessing Alpine plugin data from container:', e); } } } } } catch (e) { console.warn('[DEBUG] Error in fallback 6 DOM lookup:', e); } } // Final check - if still no pluginId, show error if (!pluginId) { console.error('No plugin ID available after all fallbacks. actionId:', actionId, 'actionIndex:', actionIndex); console.error('[DEBUG] Button found:', !!btn); console.error('[DEBUG] currentPluginConfig:', currentPluginConfig); if (typeof showNotification === 'function') { showNotification('Unable to determine plugin ID. Please refresh the page.', 'error'); } return; } console.log('[DEBUG] executePluginAction - Final pluginId:', pluginId, 'actionId:', actionId, 'actionIndex:', actionIndex); if (!btn || !statusDiv) { console.error(`Action elements not found: ${actionIdFull}`); return; } // Get action definition - try currentPluginConfig first, then fetch from API let action = currentPluginConfig?.webUiActions?.[actionIndex]; if (!action) { // Try to get from installed plugins if (window.installedPlugins) { const plugin = window.installedPlugins.find(p => p.id === pluginId); if (plugin && plugin.web_ui_actions) { action = plugin.web_ui_actions[actionIndex]; } } } if (!action) { console.error(`Action not found: ${actionId} for plugin ${pluginId}`); console.log('[DEBUG] currentPluginConfig:', currentPluginConfig); console.log('[DEBUG] installedPlugins:', window.installedPlugins); if (typeof showNotification === 'function') { showNotification(`Action ${actionId} not found. Please refresh the page.`, 'error'); } return; } console.log('[DEBUG] Found action:', action); // Check if we're in step 2 (completing OAuth flow) if (btn.dataset.step === '2') { const redirectUrl = prompt(action.step2_prompt || 'Please paste the full redirect URL:'); if (!redirectUrl || !redirectUrl.trim()) { return; } // Complete authentication btn.disabled = true; const originalText = btn.innerHTML; btn.innerHTML = 'Completing...'; statusDiv.classList.remove('hidden'); statusDiv.innerHTML = '
    Completing authentication...
    '; fetch('/api/v3/plugins/action', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ plugin_id: pluginId, action_id: actionId, params: {step: '2', redirect_url: redirectUrl.trim()} }) }) .then(response => response.json()) .then(data => { if (data.status === 'success') { statusDiv.innerHTML = `
    ${data.message}
    `; btn.innerHTML = originalText; btn.disabled = false; delete btn.dataset.step; if (typeof showNotification === 'function') { showNotification(data.message || 'Action completed successfully!', 'success'); } } else { statusDiv.innerHTML = `
    ${data.message}
    `; if (data.output) { statusDiv.innerHTML += `
    ${data.output}
    `; } btn.innerHTML = originalText; btn.disabled = false; delete btn.dataset.step; } }) .catch(error => { statusDiv.innerHTML = `
    Error: ${error.message}
    `; btn.innerHTML = originalText; btn.disabled = false; delete btn.dataset.step; }); return; } // Step 1: Execute action btn.disabled = true; const originalText = btn.innerHTML; btn.innerHTML = 'Executing...'; statusDiv.classList.remove('hidden'); statusDiv.innerHTML = '
    Executing action...
    '; fetch('/api/v3/plugins/action', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ plugin_id: pluginId, action_id: actionId, params: {} }) }) .then(response => response.json()) .then(data => { if (data.status === 'success') { if (data.requires_step2 && data.auth_url) { // OAuth flow - show auth URL statusDiv.innerHTML = `
    ${data.message || 'Authorization URL Generated'}

    1. Click the link below to authorize:

    ${data.auth_url}

    2. After authorization, copy the FULL redirect URL from your browser.

    3. Click the button again and paste the redirect URL when prompted.

    `; btn.innerHTML = action.step2_button_text || 'Complete Authentication'; btn.dataset.step = '2'; btn.disabled = false; if (typeof showNotification === 'function') { showNotification(data.message || 'Authorization URL generated. Please authorize and paste the redirect URL.', 'info'); } } else { // Simple success statusDiv.innerHTML = `
    ${data.message || 'Action completed successfully'}
    ${data.output ? `
    ${data.output}
    ` : ''}
    `; btn.innerHTML = originalText; btn.disabled = false; if (typeof showNotification === 'function') { showNotification(data.message || 'Action completed successfully!', 'success'); } } } else { statusDiv.innerHTML = `
    ${data.message || 'Action failed'}
    ${data.output ? `
    ${data.output}
    ` : ''}
    `; btn.innerHTML = originalText; btn.disabled = false; } }) .catch(error => { statusDiv.innerHTML = `
    Error: ${error.message}
    `; btn.innerHTML = originalText; btn.disabled = false; }); } // togglePlugin is already defined at the top of the script - no need to redefine // Only override updatePlugin if it doesn't already have improved error handling if (!window.updatePlugin || window.updatePlugin.toString().includes('[UPDATE]')) { window.updatePlugin = function(pluginId) { // Validate pluginId if (!pluginId || typeof pluginId !== 'string') { console.error('[UPDATE] Invalid pluginId:', pluginId); if (typeof showNotification === 'function') { showNotification('Invalid plugin ID', 'error'); } return Promise.reject(new Error('Invalid plugin ID')); } showNotification(`Updating ${pluginId}...`, 'info'); // Prepare request body const requestBody = { plugin_id: pluginId }; const requestBodyJson = JSON.stringify(requestBody); console.log('[UPDATE] Sending request:', { url: '/api/v3/plugins/update', body: requestBodyJson }); 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 => { showNotification(data.message || 'Update initiated', data.status || 'info'); if (data.status === 'success') { // Refresh the list 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; }); }; } window.uninstallPlugin = function(pluginId) { const plugin = (window.installedPlugins || installedPlugins || []).find(p => p.id === pluginId); const pluginName = plugin ? (plugin.name || pluginId) : pluginId; if (!confirm(`Are you sure you want to uninstall ${pluginName}?`)) { return; } showNotification(`Uninstalling ${pluginName}...`, 'info'); fetch('/api/v3/plugins/uninstall', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ plugin_id: pluginId }) }) .then(response => response.json()) .then(data => { console.log('Uninstall response:', data); // Check if operation was queued if (data.status === 'success' && data.data && data.data.operation_id) { // Operation was queued, poll for completion const operationId = data.data.operation_id; showNotification(`Uninstall queued for ${pluginName}...`, 'info'); pollOperationStatus(operationId, pluginId, pluginName); } else if (data.status === 'success') { // Direct uninstall completed immediately handleUninstallSuccess(pluginId); } else { // Error response showNotification(data.message || 'Failed to uninstall plugin', data.status || 'error'); } }) .catch(error => { console.error('Error uninstalling plugin:', error); showNotification('Error uninstalling plugin: ' + error.message, 'error'); }); } function pollOperationStatus(operationId, pluginId, pluginName, maxAttempts = 60, attempt = 0) { if (attempt >= maxAttempts) { showNotification(`Uninstall operation timed out for ${pluginName}`, 'error'); // Refresh plugin list to see actual state setTimeout(() => { loadInstalledPlugins(); }, 1000); return; } fetch(`/api/v3/plugins/operation/${operationId}`) .then(response => response.json()) .then(data => { if (data.status === 'success' && data.data) { const operation = data.data; const status = operation.status; if (status === 'completed') { // Operation completed successfully handleUninstallSuccess(pluginId); } else if (status === 'failed') { // Operation failed const errorMsg = operation.error || operation.message || `Failed to uninstall ${pluginName}`; showNotification(errorMsg, 'error'); // Refresh plugin list to see actual state setTimeout(() => { loadInstalledPlugins(); }, 1000); } else if (status === 'pending' || status === 'in_progress') { // Still in progress, poll again setTimeout(() => { pollOperationStatus(operationId, pluginId, pluginName, maxAttempts, attempt + 1); }, 1000); // Poll every second } else { // Unknown status, poll again setTimeout(() => { pollOperationStatus(operationId, pluginId, pluginName, maxAttempts, attempt + 1); }, 1000); } } else { // Error getting operation status, try again setTimeout(() => { pollOperationStatus(operationId, pluginId, pluginName, maxAttempts, attempt + 1); }, 1000); } }) .catch(error => { console.error('Error polling operation status:', error); // On error, refresh plugin list to see actual state setTimeout(() => { loadInstalledPlugins(); }, 1000); }); } function handleUninstallSuccess(pluginId) { // Remove from local array immediately for better UX const currentPlugins = window.installedPlugins || installedPlugins || []; const updatedPlugins = currentPlugins.filter(p => p.id !== pluginId); // Only update if list actually changed (setter will check, but we know it changed here) window.installedPlugins = updatedPlugins; if (typeof installedPlugins !== 'undefined') { installedPlugins = updatedPlugins; } renderInstalledPlugins(updatedPlugins); showNotification(`Plugin uninstalled successfully`, 'success'); // Also refresh from server to ensure consistency setTimeout(() => { loadInstalledPlugins(); }, 1000); } function refreshPlugins() { console.log('[refreshPlugins] Button clicked, refreshing plugins...'); // Clear cache to force fresh data pluginStoreCache = null; cacheTimestamp = null; loadInstalledPlugins(); // Fetch latest metadata from GitHub when refreshing searchPluginStore(true); showNotification('Plugins refreshed with latest metadata from GitHub', 'success'); } function restartDisplay() { console.log('[restartDisplay] Button clicked, restarting display service...'); showNotification('Restarting display service...', 'info'); fetch('/api/v3/system/action', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'restart_display_service' }) }) .then(response => response.json()) .then(data => { showNotification(data.message, data.status); }) .catch(error => { showNotification('Error restarting display: ' + error.message, 'error'); }); } function searchPluginStore(fetchCommitInfo = true) { pluginLog('[STORE] Searching plugin store...', { fetchCommitInfo }); const now = Date.now(); const isCacheValid = pluginStoreCache && cacheTimestamp && (now - cacheTimestamp < CACHE_DURATION); // If cache is valid and we don't need fresh commit info, just re-filter if (isCacheValid && !fetchCommitInfo) { console.log('Using cached plugin store data'); const storeGrid = document.getElementById('plugin-store-grid'); if (storeGrid) { applyStoreFiltersAndSort(); return; } } // Show loading state try { const countEl = document.getElementById('store-count'); if (countEl) countEl.innerHTML = 'Loading...'; } catch (e) { /* ignore */ } showStoreLoading(true); let url = '/api/v3/plugins/store/list'; if (!fetchCommitInfo) { url += '?fetch_commit_info=false'; } console.log('Store URL:', url); fetch(url) .then(response => response.json()) .then(data => { showStoreLoading(false); if (data.status === 'success') { const plugins = data.data.plugins || []; console.log('Store plugins count:', plugins.length); pluginStoreCache = plugins; cacheTimestamp = Date.now(); const storeGrid = document.getElementById('plugin-store-grid'); if (!storeGrid) { pluginLog('[STORE] plugin-store-grid not ready, deferring render'); window.__pendingStorePlugins = plugins; return; } // Update total count try { const countEl = document.getElementById('store-count'); if (countEl) countEl.innerHTML = `${plugins.length} available`; } catch (e) { /* ignore */ } applyStoreFiltersAndSort(); // Re-attach GitHub token collapse handler after store render if (window.attachGithubTokenCollapseHandler) { requestAnimationFrame(() => { try { window.attachGithubTokenCollapseHandler(); } catch (e) { /* ignore */ } if (window.checkGitHubAuthStatus) { try { window.checkGitHubAuthStatus(); } catch (e) { /* ignore */ } } }); } } else { showError('Failed to search plugin store: ' + data.message); try { const countEl = document.getElementById('store-count'); if (countEl) countEl.innerHTML = 'Error loading'; } catch (e) { /* ignore */ } } }) .catch(error => { console.error('Error searching plugin store:', error); showStoreLoading(false); showError('Error searching plugin store: ' + error.message); try { const countEl = document.getElementById('store-count'); if (countEl) countEl.innerHTML = 'Error loading'; } catch (e) { /* ignore */ } }); } function showStoreLoading(show) { const loading = document.querySelector('.store-loading'); if (loading) { loading.style.display = show ? 'block' : 'none'; } } // ── Plugin Store: Client-Side Filter/Sort/Pagination ──────────────────────── function isStorePluginInstalled(pluginIdOrPlugin) { const installed = window.installedPlugins || installedPlugins || []; // Accept either a plain ID string or a store plugin object (which may have plugin_path) if (typeof pluginIdOrPlugin === 'string') { return installed.some(p => p.id === pluginIdOrPlugin); } const storeId = pluginIdOrPlugin.id; // Derive the actual installed directory name from plugin_path (e.g. "plugins/ledmatrix-weather" → "ledmatrix-weather") const pluginPath = pluginIdOrPlugin.plugin_path || ''; const pathDerivedId = pluginPath ? pluginPath.split('/').pop() : null; return installed.some(p => p.id === storeId || (pathDerivedId && p.id === pathDerivedId)); } function applyStoreFiltersAndSort(skipPageReset) { if (!pluginStoreCache) return; const st = storeFilterState; let list = pluginStoreCache.slice(); // Text search if (st.searchQuery) { const q = st.searchQuery.toLowerCase(); list = list.filter(plugin => { const hay = [ plugin.name, plugin.description, plugin.author, plugin.id, plugin.category, ...(plugin.tags || []) ].filter(Boolean).join(' ').toLowerCase(); return hay.includes(q); }); } // Category filter if (st.filterCategory) { const cat = st.filterCategory.toLowerCase(); list = list.filter(plugin => (plugin.category || '').toLowerCase() === cat); } // Installed filter if (st.filterInstalled === true) { list = list.filter(plugin => isStorePluginInstalled(plugin)); } else if (st.filterInstalled === false) { list = list.filter(plugin => !isStorePluginInstalled(plugin)); } // Sort list.sort((a, b) => { const nameA = (a.name || a.id || '').toLowerCase(); const nameB = (b.name || b.id || '').toLowerCase(); switch (st.sort) { case 'z-a': return nameB.localeCompare(nameA); case 'category': { const catCmp = (a.category || '').localeCompare(b.category || ''); return catCmp !== 0 ? catCmp : nameA.localeCompare(nameB); } case 'author': { const authCmp = (a.author || '').localeCompare(b.author || ''); return authCmp !== 0 ? authCmp : nameA.localeCompare(nameB); } case 'newest': { const dateA = a.last_updated ? new Date(a.last_updated).getTime() : 0; const dateB = b.last_updated ? new Date(b.last_updated).getTime() : 0; return dateB - dateA; // newest first } default: return nameA.localeCompare(nameB); } }); storeFilteredList = list; if (!skipPageReset) st.page = 1; renderStorePage(); updateStoreFilterUI(); } function renderStorePage() { const st = storeFilterState; const total = storeFilteredList.length; const totalPages = Math.max(1, Math.ceil(total / st.perPage)); if (st.page > totalPages) st.page = totalPages; const start = (st.page - 1) * st.perPage; const end = Math.min(start + st.perPage, total); const pagePlugins = storeFilteredList.slice(start, end); // Results info const info = total > 0 ? `Showing ${start + 1}\u2013${end} of ${total} plugins` : 'No plugins match your filters'; const infoEl = document.getElementById('store-results-info'); const infoElBot = document.getElementById('store-results-info-bottom'); if (infoEl) infoEl.textContent = info; if (infoElBot) infoElBot.textContent = info; // Pagination renderStorePagination('store-pagination-top', totalPages, st.page); renderStorePagination('store-pagination-bottom', totalPages, st.page); // Grid renderPluginStore(pagePlugins); } function renderStorePagination(containerId, totalPages, currentPage) { const container = document.getElementById(containerId); if (!container) return; if (totalPages <= 1) { container.innerHTML = ''; return; } const btnClass = 'px-3 py-1 text-sm rounded-md border transition-colors'; const activeClass = 'bg-blue-600 text-white border-blue-600'; const normalClass = 'bg-white text-gray-700 border-gray-300 hover:bg-gray-100 cursor-pointer'; const disabledClass = 'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed'; let html = ''; html += ``; const pages = []; pages.push(1); if (currentPage > 3) pages.push('...'); for (let i = Math.max(2, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) { pages.push(i); } if (currentPage < totalPages - 2) pages.push('...'); if (totalPages > 1) pages.push(totalPages); pages.forEach(p => { if (p === '...') { html += ``; } else { html += ``; } }); html += ``; container.innerHTML = html; container.querySelectorAll('[data-store-page]').forEach(btn => { btn.addEventListener('click', function() { const p = parseInt(this.getAttribute('data-store-page')); if (p >= 1 && p <= totalPages && p !== currentPage) { storeFilterState.page = p; renderStorePage(); const grid = document.getElementById('plugin-store-grid'); if (grid) grid.scrollIntoView({ behavior: 'smooth', block: 'start' }); } }); }); } function updateStoreFilterUI() { const st = storeFilterState; const count = st.activeCount(); const badge = document.getElementById('store-active-filters'); const clearBtn = document.getElementById('store-clear-filters'); if (badge) { badge.classList.toggle('hidden', count === 0); badge.textContent = count + ' filter' + (count !== 1 ? 's' : '') + ' active'; } if (clearBtn) clearBtn.classList.toggle('hidden', count === 0); const instBtn = document.getElementById('store-filter-installed'); if (instBtn) { if (st.filterInstalled === true) { instBtn.innerHTML = 'Installed'; instBtn.classList.add('border-green-400', 'bg-green-50'); instBtn.classList.remove('border-gray-300', 'bg-white', 'border-red-400', 'bg-red-50'); } else if (st.filterInstalled === false) { instBtn.innerHTML = 'Not Installed'; instBtn.classList.add('border-red-400', 'bg-red-50'); instBtn.classList.remove('border-gray-300', 'bg-white', 'border-green-400', 'bg-green-50'); } else { instBtn.innerHTML = 'All'; instBtn.classList.add('border-gray-300', 'bg-white'); instBtn.classList.remove('border-green-400', 'bg-green-50', 'border-red-400', 'bg-red-50'); } } } function setupStoreFilterListeners() { // Search with debounce const searchEl = document.getElementById('plugin-search'); if (searchEl && !searchEl._storeFilterInit) { searchEl._storeFilterInit = true; let debounce = null; searchEl.addEventListener('input', function() { clearTimeout(debounce); debounce = setTimeout(() => { storeFilterState.searchQuery = this.value.trim(); applyStoreFiltersAndSort(); }, 300); }); } // Category dropdown const catEl = document.getElementById('plugin-category'); if (catEl && !catEl._storeFilterInit) { catEl._storeFilterInit = true; catEl.addEventListener('change', function() { storeFilterState.filterCategory = this.value; applyStoreFiltersAndSort(); }); } // Sort dropdown const sortEl = document.getElementById('store-sort'); if (sortEl && !sortEl._storeFilterInit) { sortEl._storeFilterInit = true; sortEl.addEventListener('change', function() { storeFilterState.sort = this.value; storeFilterState.persist(); applyStoreFiltersAndSort(); }); } // Installed toggle (cycle: all → installed → not-installed → all) const instBtn = document.getElementById('store-filter-installed'); if (instBtn && !instBtn._storeFilterInit) { instBtn._storeFilterInit = true; instBtn.addEventListener('click', function() { const st = storeFilterState; if (st.filterInstalled === null) st.filterInstalled = true; else if (st.filterInstalled === true) st.filterInstalled = false; else st.filterInstalled = null; applyStoreFiltersAndSort(); }); } // Clear filters const clearBtn = document.getElementById('store-clear-filters'); if (clearBtn && !clearBtn._storeFilterInit) { clearBtn._storeFilterInit = true; clearBtn.addEventListener('click', function() { storeFilterState.reset(); const searchEl = document.getElementById('plugin-search'); if (searchEl) searchEl.value = ''; const catEl = document.getElementById('plugin-category'); if (catEl) catEl.value = ''; const sortEl = document.getElementById('store-sort'); if (sortEl) sortEl.value = 'a-z'; storeFilterState.persist(); applyStoreFiltersAndSort(); }); } // Per-page selector const ppEl = document.getElementById('store-per-page'); if (ppEl && !ppEl._storeFilterInit) { ppEl._storeFilterInit = true; ppEl.addEventListener('change', function() { storeFilterState.perPage = parseInt(this.value) || 12; storeFilterState.persist(); applyStoreFiltersAndSort(); }); } } // Expose searchPluginStore on window.pluginManager for Alpine.js integration window.searchPluginStore = searchPluginStore; window.pluginManager.searchPluginStore = searchPluginStore; function renderPluginStore(plugins) { const container = document.getElementById('plugin-store-grid'); if (!container) { pluginLog('[RENDER] plugin-store-grid not yet available, deferring render'); window.__pendingStorePlugins = plugins; return; } if (plugins.length === 0) { container.innerHTML = `

    No plugins found

    Try adjusting your search criteria

    `; return; } // Helper function to escape for JavaScript strings const escapeJs = (text) => { return JSON.stringify(text || ''); }; container.innerHTML = plugins.map(plugin => { const installed = isStorePluginInstalled(plugin); return `

    ${escapeHtml(plugin.name || plugin.id)}

    ${plugin.verified ? 'Verified' : ''} ${installed ? 'Installed' : ''} ${isNewPlugin(plugin.last_updated) ? 'New' : ''} ${plugin._source === 'custom_repository' ? `Custom` : ''}

    ${escapeHtml(plugin.author || 'Unknown')}

    ${plugin.version ? `

    v${escapeHtml(plugin.version)}

    ` : ''}

    ${escapeHtml(plugin.category || 'General')}

    ${escapeHtml(plugin.description || 'No description available')}

    ${plugin.tags && plugin.tags.length > 0 ? `
    ${plugin.tags.map(tag => `${escapeHtml(tag)}`).join('')}
    ` : ''}
    `; }).join(''); } // Expose functions to window for onclick handlers window.installPlugin = function(pluginId, branch = null) { showNotification(`Installing ${pluginId}${branch ? ` (branch: ${branch})` : ''}...`, 'info'); const requestBody = { plugin_id: pluginId }; if (branch) { requestBody.branch = branch; } fetch('/api/v3/plugins/install', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) }) .then(response => response.json()) .then(data => { showNotification(data.message, data.status); if (data.status === 'success') { // Refresh installed plugins list, then re-render store to update badges loadInstalledPlugins(); setTimeout(() => applyStoreFiltersAndSort(true), 500); } }) .catch(error => { showNotification('Error installing plugin: ' + error.message, 'error'); }); } window.installFromCustomRegistry = function(pluginId, registryUrl, pluginPath, branch = null) { const repoUrl = registryUrl; const requestBody = { repo_url: repoUrl, plugin_id: pluginId, plugin_path: pluginPath }; if (branch) { requestBody.branch = branch; } fetch('/api/v3/plugins/install-from-url', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) }) .then(response => response.json()) .then(data => { if (data.status === 'success') { showSuccess(`Plugin ${data.plugin_id} installed successfully`); // Refresh installed plugins and re-render custom registry loadInstalledPlugins(); // Re-render custom registry to update install buttons const registryUrlInput = document.getElementById('github-registry-url'); if (registryUrlInput && registryUrlInput.value.trim()) { document.getElementById('load-registry-from-url').click(); } } else { showError(data.message || 'Installation failed'); } }) .catch(error => { let errorMsg = 'Error installing plugin: ' + error.message; if (error.message && error.message.includes('Failed to Fetch')) { errorMsg += ' - Please try refreshing your browser.'; } showError(errorMsg); }); } function setupCollapsibleSections() { console.log('[setupCollapsibleSections] Setting up collapsible sections...'); // Installed Plugins and Plugin Store sections no longer have collapse buttons // They are always visible // Functions are now defined outside IIFE, just attach the handler if (window.attachGithubTokenCollapseHandler) { window.attachGithubTokenCollapseHandler(); } console.log('[setupCollapsibleSections] Collapsible sections setup complete'); } function loadSavedRepositories() { fetch('/api/v3/plugins/saved-repositories') .then(response => response.json()) .then(data => { if (data.status === 'success') { renderSavedRepositories(data.data.repositories || []); } }) .catch(error => { console.error('Error loading saved repositories:', error); }); } function renderSavedRepositories(repositories) { const container = document.getElementById('saved-repositories-list'); const countEl = document.getElementById('saved-repos-count'); if (!container) return; if (countEl) { countEl.textContent = `${repositories.length} saved`; } if (repositories.length === 0) { container.innerHTML = '

    No saved repositories yet. Save a repository URL to see it here.

    '; return; } // Helper function to escape for JavaScript strings const escapeJs = (text) => { return JSON.stringify(text || ''); }; container.innerHTML = repositories.map(repo => { const repoUrl = repo.url || ''; const repoName = repo.name || repoUrl; const repoType = repo.type || 'single'; return `
    ${escapeHtml(repoName)}

    ${escapeHtml(repoUrl)}

    `; }).join(''); } window.removeSavedRepository = function(repoUrl) { if (!confirm('Remove this saved repository? Its plugins will no longer appear in the store.')) { return; } fetch('/api/v3/plugins/saved-repositories', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ repo_url: repoUrl }) }) .then(response => response.json()) .then(data => { if (data.status === 'success') { showSuccess('Repository removed successfully'); renderSavedRepositories(data.data.repositories || []); // Refresh plugin store to remove plugins from deleted repo searchPluginStore(); } else { showError(data.message || 'Failed to remove repository'); } }) .catch(error => { showError('Error removing repository: ' + error.message); }); } // Separate function to attach install button handler (can be called multiple times) function attachInstallButtonHandler() { console.log('[attachInstallButtonHandler] ===== FUNCTION CALLED ====='); const installBtn = document.getElementById('install-plugin-from-url'); const pluginUrlInput = document.getElementById('github-plugin-url'); const pluginStatusDiv = document.getElementById('github-plugin-status'); console.log('[attachInstallButtonHandler] Looking for install button elements:', { installBtn: !!installBtn, pluginUrlInput: !!pluginUrlInput, pluginStatusDiv: !!pluginStatusDiv }); if (installBtn && pluginUrlInput) { // Check if handler already attached (prevent duplicates) if (installBtn.hasAttribute('data-handler-attached')) { console.log('[attachInstallButtonHandler] Handler already attached, skipping'); return; } // Clone button to remove any existing listeners (prevents duplicate handlers) const parent = installBtn.parentNode; if (parent) { const newBtn = installBtn.cloneNode(true); // Ensure button type is set to prevent form submission newBtn.type = 'button'; // Mark as having handler attached newBtn.setAttribute('data-handler-attached', 'true'); parent.replaceChild(newBtn, installBtn); console.log('[attachInstallButtonHandler] Install button cloned and replaced, type:', newBtn.type); newBtn.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); console.log('[attachInstallButtonHandler] Install button clicked!'); const repoUrl = pluginUrlInput.value.trim(); if (!repoUrl) { if (pluginStatusDiv) { pluginStatusDiv.innerHTML = 'Please enter a GitHub URL'; } return; } if (!repoUrl.includes('github.com')) { if (pluginStatusDiv) { pluginStatusDiv.innerHTML = 'Please enter a valid GitHub URL'; } return; } newBtn.disabled = true; newBtn.innerHTML = 'Installing...'; if (pluginStatusDiv) { pluginStatusDiv.innerHTML = 'Installing plugin...'; } const branch = document.getElementById('plugin-branch-input')?.value?.trim() || null; const requestBody = { repo_url: repoUrl }; if (branch) { requestBody.branch = branch; } console.log('[attachInstallButtonHandler] Sending install request:', requestBody); fetch('/api/v3/plugins/install-from-url', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) }) .then(response => { console.log('[attachInstallButtonHandler] Response status:', response.status); return response.json(); }) .then(data => { console.log('[attachInstallButtonHandler] Response data:', data); if (data.status === 'success') { if (pluginStatusDiv) { pluginStatusDiv.innerHTML = `Successfully installed: ${data.plugin_id}`; } pluginUrlInput.value = ''; // Refresh installed plugins list setTimeout(() => { loadInstalledPlugins(); }, 1000); } else { if (pluginStatusDiv) { pluginStatusDiv.innerHTML = `${data.message || 'Installation failed'}`; } } }) .catch(error => { console.error('[attachInstallButtonHandler] Error:', error); if (pluginStatusDiv) { pluginStatusDiv.innerHTML = `Error: ${error.message}`; } }) .finally(() => { newBtn.disabled = false; newBtn.innerHTML = 'Install'; }); }); // Allow Enter key to trigger install pluginUrlInput.addEventListener('keypress', function(e) { if (e.key === 'Enter') { e.preventDefault(); console.log('[attachInstallButtonHandler] Enter key pressed, triggering install'); newBtn.click(); } }); console.log('[attachInstallButtonHandler] Install button handler attached successfully'); } else { console.error('[attachInstallButtonHandler] Install button parent not found!'); } } else { console.warn('[attachInstallButtonHandler] Install button or URL input not found:', { installBtn: !!installBtn, pluginUrlInput: !!pluginUrlInput }); } } function setupGitHubInstallHandlers() { console.log('[setupGitHubInstallHandlers] ===== FUNCTION CALLED ===== Setting up GitHub install handlers...'); // Toggle GitHub install section visibility const toggleBtn = document.getElementById('toggle-github-install'); const installSection = document.getElementById('github-install-section'); const icon = document.getElementById('github-install-icon'); console.log('[setupGitHubInstallHandlers] Elements found:', { button: !!toggleBtn, section: !!installSection, icon: !!icon }); if (toggleBtn && installSection) { // Clone button to remove any existing listeners const parent = toggleBtn.parentNode; if (parent) { const newBtn = toggleBtn.cloneNode(true); parent.replaceChild(newBtn, toggleBtn); newBtn.addEventListener('click', function(e) { e.stopPropagation(); e.preventDefault(); console.log('[setupGitHubInstallHandlers] GitHub install toggle clicked'); const section = document.getElementById('github-install-section'); const iconEl = document.getElementById('github-install-icon'); const btn = document.getElementById('toggle-github-install'); if (!section || !btn) return; const hasHiddenClass = section.classList.contains('hidden'); const computedDisplay = window.getComputedStyle(section).display; if (hasHiddenClass || computedDisplay === 'none') { // Show section - remove hidden, ensure visible section.classList.remove('hidden'); section.style.removeProperty('display'); if (iconEl) { iconEl.classList.remove('fa-chevron-down'); iconEl.classList.add('fa-chevron-up'); } const span = btn.querySelector('span'); if (span) span.textContent = 'Hide'; // Re-attach install button handler when section is shown (in case elements weren't ready before) console.log('[setupGitHubInstallHandlers] Section shown, will re-attach install button handler in 100ms'); setTimeout(() => { console.log('[setupGitHubInstallHandlers] Re-attaching install button handler now'); attachInstallButtonHandler(); }, 100); } else { // Hide section - add hidden, set display none section.classList.add('hidden'); section.style.display = 'none'; if (iconEl) { iconEl.classList.remove('fa-chevron-up'); iconEl.classList.add('fa-chevron-down'); } const span = btn.querySelector('span'); if (span) span.textContent = 'Show'; } }); console.log('[setupGitHubInstallHandlers] Handler attached'); } } else { console.warn('[setupGitHubInstallHandlers] Required elements not found'); } // Install single plugin from URL - use separate function so we can re-call it console.log('[setupGitHubInstallHandlers] About to call attachInstallButtonHandler...'); attachInstallButtonHandler(); console.log('[setupGitHubInstallHandlers] Called attachInstallButtonHandler'); // Load registry from URL const loadRegistryBtn = document.getElementById('load-registry-from-url'); const registryUrlInput = document.getElementById('github-registry-url'); const registryStatusDiv = document.getElementById('registry-status'); const customRegistryPlugins = document.getElementById('custom-registry-plugins'); const customRegistryGrid = document.getElementById('custom-registry-grid'); if (loadRegistryBtn && registryUrlInput) { loadRegistryBtn.addEventListener('click', function() { const repoUrl = registryUrlInput.value.trim(); if (!repoUrl) { registryStatusDiv.innerHTML = 'Please enter a GitHub URL'; return; } if (!repoUrl.includes('github.com')) { registryStatusDiv.innerHTML = 'Please enter a valid GitHub URL'; return; } loadRegistryBtn.disabled = true; loadRegistryBtn.innerHTML = 'Loading...'; registryStatusDiv.innerHTML = 'Loading registry...'; customRegistryPlugins.classList.add('hidden'); fetch('/api/v3/plugins/registry-from-url', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ repo_url: repoUrl }) }) .then(response => response.json()) .then(data => { if (data.status === 'success' && data.plugins && data.plugins.length > 0) { registryStatusDiv.innerHTML = `Found ${data.plugins.length} plugins`; renderCustomRegistryPlugins(data.plugins, repoUrl); customRegistryPlugins.classList.remove('hidden'); } else { registryStatusDiv.innerHTML = 'No valid registry found or registry is empty'; customRegistryPlugins.classList.add('hidden'); } }) .catch(error => { registryStatusDiv.innerHTML = `Error: ${error.message}`; customRegistryPlugins.classList.add('hidden'); }) .finally(() => { loadRegistryBtn.disabled = false; loadRegistryBtn.innerHTML = 'Load Registry'; }); }); // Allow Enter key to trigger load registryUrlInput.addEventListener('keypress', function(e) { if (e.key === 'Enter') { loadRegistryBtn.click(); } }); } // Save registry URL button const saveRegistryBtn = document.getElementById('save-registry-url'); if (saveRegistryBtn && registryUrlInput) { saveRegistryBtn.addEventListener('click', function() { const repoUrl = registryUrlInput.value.trim(); if (!repoUrl) { showError('Please enter a repository URL first'); return; } if (!repoUrl.includes('github.com')) { showError('Please enter a valid GitHub URL'); return; } saveRegistryBtn.disabled = true; saveRegistryBtn.innerHTML = 'Saving...'; fetch('/api/v3/plugins/saved-repositories', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ repo_url: repoUrl }) }) .then(response => response.json()) .then(data => { if (data.status === 'success') { showSuccess('Repository saved successfully! Its plugins will appear in the Plugin Store.'); renderSavedRepositories(data.data.repositories || []); // Refresh plugin store to include new repo searchPluginStore(); } else { showError(data.message || 'Failed to save repository'); } }) .catch(error => { showError('Error saving repository: ' + error.message); }) .finally(() => { saveRegistryBtn.disabled = false; saveRegistryBtn.innerHTML = 'Save Repository'; }); }); } // Refresh saved repos button const refreshSavedReposBtn = document.getElementById('refresh-saved-repos'); if (refreshSavedReposBtn) { refreshSavedReposBtn.addEventListener('click', function() { loadSavedRepositories(); searchPluginStore(); // Also refresh plugin store showSuccess('Repositories refreshed'); }); } } function renderCustomRegistryPlugins(plugins, registryUrl) { const container = document.getElementById('custom-registry-grid'); if (!container) return; if (plugins.length === 0) { container.innerHTML = '

    No plugins found in this registry

    '; return; } // Escape HTML helper const escapeHtml = (text) => { if (!text) return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; }; // Helper function to escape for JavaScript strings const escapeJs = (text) => { return JSON.stringify(text || ''); }; container.innerHTML = plugins.map(plugin => { const isInstalled = isStorePluginInstalled(plugin); const pluginIdJs = escapeJs(plugin.id); const escapedUrlJs = escapeJs(registryUrl); const pluginPathJs = escapeJs(plugin.plugin_path || ''); const branchInputId = `branch-input-custom-${plugin.id.replace(/[^a-zA-Z0-9]/g, '-')}`; const installBtn = isInstalled ? '' : ``; return `
    ${escapeHtml(plugin.name || plugin.id)}

    ${escapeHtml(plugin.description || 'No description')}

    Last updated ${formatDate(plugin.last_updated)} ${installBtn}
    `; }).join(''); } function showSuccess(message) { // Try to use notification system if available, otherwise use alert if (typeof showNotification === 'function') { showNotification(message, 'success'); } else { console.log('Success: ' + message); // Show a temporary success message const statusDiv = document.getElementById('github-plugin-status') || document.getElementById('registry-status'); if (statusDiv) { statusDiv.innerHTML = `${message}`; setTimeout(() => { if (statusDiv) statusDiv.innerHTML = ''; }, 5000); } } } function showError(message) { const content = document.getElementById('plugins-content'); if (!content) { console.error('plugins-content element not found'); if (typeof showNotification === 'function') { showNotification(message, 'error'); } else { console.error('Error: ' + message); } return; } content.innerHTML = `

    ${escapeHtml(message)}

    `; } // Plugin configuration form submission is handled by handlePluginConfigSubmit // which is attached directly to the form. The document-level listener has been removed // to avoid duplicate submissions and to ensure proper handling of _data fields. function savePluginConfiguration(pluginId, config) { // Update the plugin configuration in the backend fetch('/api/v3/plugins/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ plugin_id: pluginId, config }) }) .then(response => { if (!response.ok) { // Try to parse error response return response.json().then(data => { // Return error data with status return { error: true, status: response.status, ...data }; }).catch(() => { // If JSON parsing fails, return generic error return { error: true, status: response.status, message: `Server error: ${response.status} ${response.statusText}` }; }); } return response.json(); }) .then(data => { if (data.error || data.status !== 'success') { // Display validation errors if present if (data.validation_errors && Array.isArray(data.validation_errors)) { displayValidationErrors(data.validation_errors); } let errorMessage = data.message || 'Error saving configuration'; if (data.validation_errors && Array.isArray(data.validation_errors) && data.validation_errors.length > 0) { errorMessage += '\n\nValidation errors:\n' + data.validation_errors.join('\n'); } showNotification(errorMessage, 'error'); console.error('Config save failed:', data); } else { // Hide validation errors on success displayValidationErrors([]); showNotification(data.message || 'Configuration saved successfully', data.status); closePluginConfigModal(); // Refresh the installed plugins to update the UI loadInstalledPlugins(); } }) .catch(error => { console.error('Error saving plugin config:', error); showNotification('Error saving plugin configuration: ' + error.message, 'error'); }); } // Utility function to escape HTML function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // Utility function to escape text for use in HTML attributes // Escapes quotes, ampersands, and other special characters that could break attributes function escapeAttribute(text) { if (text == null) { return ''; } const str = String(text); return str .replace(/&/g, '&') .replace(/"/g, '"') .replace(/'/g, ''') .replace(//g, '>'); } // Format date for display function formatDate(dateString) { if (!dateString) return 'Unknown'; try { const date = new Date(dateString); const now = new Date(); const diffTime = Math.abs(now - date); const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); if (diffDays < 1) { return 'Today'; } else if (diffDays < 2) { return 'Yesterday'; } else if (diffDays < 7) { return `${diffDays} days ago`; } else if (diffDays < 30) { const weeks = Math.floor(diffDays / 7); return `${weeks} ${weeks === 1 ? 'week' : 'weeks'} ago`; } else { // Return formatted date for older items return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); } } catch (e) { return dateString; } } function formatCommit(commit, branch) { const shortCommit = commit ? String(commit).substring(0, 7) : ''; const branchText = branch ? String(branch) : ''; if (branchText && shortCommit) { return `${branchText} · ${shortCommit}`; } if (branchText) { return branchText; } if (shortCommit) { return shortCommit; } return 'Latest'; } // Check if plugin is new (updated within last 7 days) function isNewPlugin(lastUpdated) { if (!lastUpdated) return false; try { const date = new Date(lastUpdated); const now = new Date(); const diffTime = Math.abs(now - date); const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); return diffDays <= 7; } catch (e) { return false; } } // Debounce utility function debounce(func, wait) { let timeout; return function executedFunction(...args) { const later = () => { clearTimeout(timeout); func(...args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; } // Toggle password visibility for secret fields function togglePasswordVisibility(fieldId) { const input = document.getElementById(fieldId); const icon = document.getElementById(fieldId + '-icon'); if (input && icon) { if (input.type === 'password') { input.type = 'text'; icon.classList.remove('fa-eye'); icon.classList.add('fa-eye-slash'); } else { input.type = 'password'; icon.classList.remove('fa-eye-slash'); icon.classList.add('fa-eye'); } } } // GitHub Token Configuration Functions // Open GitHub Token Settings panel (only opens, doesn't close) // Used when user clicks "Configure Token" link window.openGithubTokenSettings = function() { const settings = document.getElementById('github-token-settings'); const warning = document.getElementById('github-auth-warning'); const tokenContent = document.getElementById('github-token-content'); if (settings) { // Show settings panel using both methods settings.classList.remove('hidden'); settings.style.display = ''; // Expand the content when opening if (tokenContent) { tokenContent.style.removeProperty('display'); tokenContent.classList.remove('hidden'); // Update collapse button state const tokenIconCollapse = document.getElementById('github-token-icon-collapse'); const toggleTokenCollapseBtn = document.getElementById('toggle-github-token-collapse'); if (tokenIconCollapse) { tokenIconCollapse.classList.remove('fa-chevron-down'); tokenIconCollapse.classList.add('fa-chevron-up'); } if (toggleTokenCollapseBtn) { const span = toggleTokenCollapseBtn.querySelector('span'); if (span) span.textContent = 'Collapse'; } } // When opening settings, hide the warning banner if (warning) { warning.classList.add('hidden'); warning.style.display = 'none'; // Clear any dismissal state since user is actively configuring sessionStorage.removeItem('github-auth-warning-dismissed'); } // Load token when opening the panel loadGithubToken(); } } window.toggleGithubTokenVisibility = function() { const input = document.getElementById('github-token-input'); const icon = document.getElementById('github-token-icon'); if (input && icon) { if (input.type === 'password') { input.type = 'text'; icon.classList.remove('fa-eye'); icon.classList.add('fa-eye-slash'); } else { input.type = 'password'; icon.classList.remove('fa-eye-slash'); icon.classList.add('fa-eye'); } } } window.loadGithubToken = function() { const input = document.getElementById('github-token-input'); const loadButton = document.querySelector('button[onclick="loadGithubToken()"]'); if (!input) return; // Set loading state on load button const originalButtonContent = loadButton ? loadButton.innerHTML : ''; if (loadButton) { loadButton.disabled = true; loadButton.classList.add('opacity-50', 'cursor-not-allowed'); loadButton.innerHTML = 'Loading...'; } fetch('/api/v3/config/secrets') .then(response => { if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); }) .then(data => { if (data.status === 'success') { // Handle empty data (secrets file doesn't exist) - API returns {} in this case const secrets = data.data || {}; const token = secrets.github?.api_token || ''; if (input) { if (token && token !== 'YOUR_GITHUB_PERSONAL_ACCESS_TOKEN') { // Token exists and is valid input.value = token; showNotification('GitHub token loaded successfully', 'success'); } else { // No token configured or placeholder value input.value = ''; showNotification('No GitHub token configured. Enter a new token to save.', 'info'); } } } else { throw new Error(data.message || 'Failed to load secrets configuration'); } }) .catch(error => { console.error('Error loading GitHub token:', error); if (input) { input.value = ''; } // If it's a 404 or file doesn't exist, that's okay - just inform the user if (error.message.includes('404') || error.message.includes('not found')) { showNotification('No secrets file found. You can create one by saving a token.', 'info'); } else { showNotification('Error loading GitHub token: ' + error.message, 'error'); } }) .finally(() => { // Restore button state if (loadButton) { loadButton.disabled = false; loadButton.classList.remove('opacity-50', 'cursor-not-allowed'); loadButton.innerHTML = originalButtonContent; } }); } window.saveGithubToken = function() { const input = document.getElementById('github-token-input'); const saveButton = document.querySelector('button[onclick="saveGithubToken()"]'); if (!input) return; const token = input.value.trim(); if (!token) { showNotification('Please enter a GitHub token', 'error'); return; } // Client-side token validation if (!token.startsWith('ghp_') && !token.startsWith('github_pat_')) { if (!confirm('Token format looks invalid. GitHub tokens should start with "ghp_" or "github_pat_". Continue anyway?')) { return; } } // Set loading state on save button const originalButtonContent = saveButton ? saveButton.innerHTML : ''; if (saveButton) { saveButton.disabled = true; saveButton.classList.add('opacity-50', 'cursor-not-allowed'); saveButton.innerHTML = 'Saving...'; } // Load current secrets config fetch('/api/v3/config/secrets') .then(response => { if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); }) .then(data => { if (data.status === 'success') { const secrets = data.data || {}; // Update GitHub token if (!secrets.github) { secrets.github = {}; } secrets.github.api_token = token; // Save updated secrets return fetch('/api/v3/config/raw/secrets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(secrets) }); } else { throw new Error(data.message || 'Failed to load current secrets'); } }) .then(response => { if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); }) .then(data => { if (data.status === 'success') { showNotification('GitHub token saved successfully! Rate limit increased to 5,000/hour', 'success'); // Clear input field for security (user can reload if needed) input.value = ''; // Clear the dismissal flag so warning can properly hide/show based on token status sessionStorage.removeItem('github-auth-warning-dismissed'); // Small delay to ensure backend has reloaded the token, then refresh status // checkGitHubAuthStatus() will handle collapsing the panel automatically // Reduced delay from 300ms to 100ms - backend should reload quickly setTimeout(() => { if (window.checkGitHubAuthStatus) { window.checkGitHubAuthStatus(); } }, 100); } else { throw new Error(data.message || 'Failed to save token'); } }) .catch(error => { console.error('Error saving GitHub token:', error); showNotification('Error saving GitHub token: ' + error.message, 'error'); }) .finally(() => { // Restore button state if (saveButton) { saveButton.disabled = false; saveButton.classList.remove('opacity-50', 'cursor-not-allowed'); saveButton.innerHTML = originalButtonContent; } }); } window.dismissGithubWarning = function() { const warning = document.getElementById('github-auth-warning'); const settings = document.getElementById('github-token-settings'); if (warning) { // Hide warning using both classList and style.display warning.classList.add('hidden'); warning.style.display = 'none'; // Also hide settings if it's open (since they're combined now) if (settings && !settings.classList.contains('hidden')) { settings.classList.add('hidden'); settings.style.display = 'none'; } // Remember dismissal for this session sessionStorage.setItem('github-auth-warning-dismissed', 'true'); } } window.showGithubTokenInstructions = function() { const instructions = `

    How to Add a GitHub Token

    Step 1: Create a GitHub Token
    1. Click the "Create a GitHub Token" link above (or click here)
    2. Give it a name like "LEDMatrix Plugin Manager"
    3. No special scopes/permissions are needed for public repositories
    4. Click "Generate token" at the bottom
    5. Copy the generated token (it starts with "ghp_")
    Step 2: Add Token to LEDMatrix
    1. SSH into your Raspberry Pi
    2. Edit the secrets file: nano ~/LEDMatrix/config/config_secrets.json
    3. Find the "github" section and add your token:
      "github": {
        "api_token": "ghp_your_token_here"
      }
    4. Save the file (Ctrl+O, Enter, Ctrl+X)
    5. Restart the web service: sudo systemctl restart ledmatrix-web

    Note: Your token is stored locally and never shared. It's only used to authenticate API requests to GitHub.

    `; // Use the existing plugin config modal for instructions const modal = document.getElementById('plugin-config-modal'); const title = document.getElementById('plugin-config-title'); const content = document.getElementById('plugin-config-content'); title.textContent = 'GitHub Token Setup'; content.innerHTML = instructions; modal.style.display = 'flex'; console.log('GitHub instructions modal opened'); } window.closeInstructionsModal = function() { const modal = document.getElementById('plugin-config-modal'); modal.style.display = 'none'; console.log('Instructions modal closed'); } // ==================== File Upload Functions ==================== // Note: handleFileDrop, handleFileSelect, and handleFiles are defined in // file-upload.js widget which loads first. We only define supplementary // functions here that file-upload.js doesn't provide. window.handleCredentialsUpload = async function(event, fieldId, uploadEndpoint, targetFilename) { const file = event.target.files[0]; if (!file) { return; } // Validate file extension const fileExt = '.' + file.name.split('.').pop().toLowerCase(); if (!fileExt || fileExt === '.') { showNotification('Please select a valid file', 'error'); return; } // Validate file size (1MB max) if (file.size > 1024 * 1024) { showNotification('File exceeds 1MB limit', 'error'); return; } // Show upload status const statusEl = document.getElementById(fieldId + '_status'); if (statusEl) { statusEl.textContent = ''; const spinner = document.createElement('i'); spinner.className = 'fas fa-spinner fa-spin mr-2'; statusEl.appendChild(spinner); statusEl.appendChild(document.createTextNode('Uploading...')); } // Create form data const formData = new FormData(); formData.append('file', file); try { const response = await fetch(uploadEndpoint, { method: 'POST', body: formData }); if (!response.ok) { const body = await response.text(); throw new Error(`Server error ${response.status}: ${body}`); } const data = await response.json(); if (data.status === 'success') { // Update hidden input with filename const hiddenInput = document.getElementById(fieldId + '_hidden'); if (hiddenInput) { hiddenInput.value = targetFilename || file.name; } // Update status if (statusEl) { statusEl.textContent = `✓ Uploaded: ${targetFilename || file.name}`; statusEl.className = 'text-sm text-green-600'; } showNotification('Credentials file uploaded successfully', 'success'); } else { if (statusEl) { statusEl.textContent = 'Upload failed - click to try again'; statusEl.className = 'text-sm text-gray-600'; } showNotification(data.message || 'Upload failed', 'error'); } } catch (error) { if (statusEl) { statusEl.textContent = 'Upload failed - click to try again'; statusEl.className = 'text-sm text-gray-600'; } showNotification('Error uploading file: ' + error.message, 'error'); } finally { // Allow re-selecting the same file on the next attempt event.target.value = ''; } } // handleFiles is now defined exclusively in file-upload.js widget window.deleteUploadedImage = async function(fieldId, imageId, pluginId) { return window.deleteUploadedFile(fieldId, imageId, pluginId, 'image', null); } window.deleteUploadedFile = async function(fieldId, fileId, pluginId, fileType, customDeleteEndpoint) { const fileTypeLabel = fileType === 'json' ? 'file' : 'image'; if (!confirm(`Are you sure you want to delete this ${fileTypeLabel}?`)) { return; } try { const deleteEndpoint = customDeleteEndpoint || (fileType === 'json' ? '/api/v3/plugins/of-the-day/json/delete' : '/api/v3/plugins/assets/delete'); const requestBody = fileType === 'json' ? { file_id: fileId } : { plugin_id: pluginId, image_id: fileId }; const response = await fetch(deleteEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) }); if (!response.ok) { const body = await response.text(); throw new Error(`Server error ${response.status}: ${body}`); } const data = await response.json(); if (data.status === 'success') { if (fileType === 'json') { // For JSON files, remove the item's DOM element directly since // updateImageList renders image-specific cards (thumbnails, scheduling). const fileEl = document.getElementById(`file_${fileId}`); if (fileEl) fileEl.remove(); // Update hidden data input — normalize identifiers to strings // since JSON files may use id, file_id, or category_name const currentFiles = window.getCurrentImages ? window.getCurrentImages(fieldId) : []; const fileIdStr = String(fileId); const newFiles = currentFiles.filter(f => { // Match the same identifier logic as the renderer: // file.id || file.category_name || idx (see renderArrayField) const fid = String(f.id || f.category_name || ''); return fid !== fileIdStr; }); const hiddenInput = document.getElementById(`${fieldId}_images_data`); if (hiddenInput) hiddenInput.value = JSON.stringify(newFiles); } else { // For images, use the full image list re-renderer — normalize to strings const currentFiles = window.getCurrentImages ? window.getCurrentImages(fieldId) : []; const fileIdStr = String(fileId); const newFiles = currentFiles.filter(file => { const fid = String(file.id || file.category_name || ''); return fid !== fileIdStr; }); window.updateImageList(fieldId, newFiles); } showNotification(`${fileType === 'json' ? 'File' : 'Image'} deleted successfully`, 'success'); } else { showNotification(`Delete failed: ${data.message}`, 'error'); } } catch (error) { console.error('Delete error:', error); showNotification(`Delete error: ${error.message}`, 'error'); } } // getUploadConfig is defined in file-upload.js widget which loads first. // No override needed here — file-upload.js owns this function. window.getCurrentImages = function(fieldId) { const hiddenInput = document.getElementById(`${fieldId}_images_data`); if (hiddenInput && hiddenInput.value) { try { return JSON.parse(hiddenInput.value); } catch (e) { console.error('Error parsing images data:', e); } } return []; } window.updateImageList = function(fieldId, images) { const hiddenInput = document.getElementById(`${fieldId}_images_data`); if (hiddenInput) { hiddenInput.value = JSON.stringify(images); } // Update the display const imageList = document.getElementById(`${fieldId}_image_list`); if (imageList) { const uploadConfig = window.getUploadConfig(fieldId); const pluginId = uploadConfig.plugin_id || window.currentPluginConfig?.pluginId || 'static-image'; imageList.innerHTML = images.map((img, idx) => { const imgSchedule = img.schedule || {}; const hasSchedule = imgSchedule.enabled && imgSchedule.mode && imgSchedule.mode !== 'always'; const scheduleSummary = hasSchedule ? (window.getScheduleSummary ? window.getScheduleSummary(imgSchedule) : 'Scheduled') : 'Always shown'; return `
    ${img.filename || ''}

    ${img.original_filename || img.filename || 'Image'}

    ${window.formatFileSize ? window.formatFileSize(img.size || 0) : (Math.round((img.size || 0) / 1024) + ' KB')} • ${window.formatDate ? window.formatDate(img.uploaded_at) : (img.uploaded_at || '')}

    ${scheduleSummary}

    `; }).join(''); } } window.showUploadProgress = function(fieldId, totalFiles) { const dropZone = document.getElementById(`${fieldId}_drop_zone`); if (dropZone) { dropZone.innerHTML = `

    Uploading ${totalFiles} file(s)...

    `; dropZone.style.pointerEvents = 'none'; } } window.hideUploadProgress = function(fieldId) { const uploadConfig = window.getUploadConfig(fieldId); const maxFiles = uploadConfig.max_files || 10; const maxSizeMB = uploadConfig.max_size_mb || 5; const allowedTypes = uploadConfig.allowed_types || ['image/png', 'image/jpeg', 'image/bmp', 'image/gif']; const dropZone = document.getElementById(`${fieldId}_drop_zone`); if (dropZone) { dropZone.innerHTML = `

    Drag and drop images here or click to browse

    Max ${maxFiles} files, ${maxSizeMB}MB each (PNG, JPG, GIF, BMP)

    `; dropZone.style.pointerEvents = 'auto'; } } window.formatFileSize = function(bytes) { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; } function formatDate(dateString) { if (!dateString) return 'Unknown date'; try { const date = new Date(dateString); return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } catch (e) { return dateString; } } window.getScheduleSummary = function(schedule) { if (!schedule || !schedule.enabled || schedule.mode === 'always') { return 'Always shown'; } if (schedule.mode === 'time_range') { return `${schedule.start_time || '08:00'} - ${schedule.end_time || '18:00'} (daily)`; } if (schedule.mode === 'per_day' && schedule.days) { const enabledDays = Object.entries(schedule.days) .filter(([day, config]) => config && config.enabled) .map(([day]) => day.charAt(0).toUpperCase() + day.slice(1, 3)); if (enabledDays.length === 0) { return 'Never shown'; } return enabledDays.join(', ') + ' only'; } return 'Scheduled'; } window.openImageSchedule = function(fieldId, imageId, imageIdx) { const currentImages = getCurrentImages(fieldId); const image = currentImages[imageIdx]; if (!image) return; const scheduleContainer = document.getElementById(`schedule_${imageId || imageIdx}`); if (!scheduleContainer) return; // Toggle visibility const isVisible = !scheduleContainer.classList.contains('hidden'); if (isVisible) { scheduleContainer.classList.add('hidden'); return; } scheduleContainer.classList.remove('hidden'); const schedule = image.schedule || { enabled: false, mode: 'always', start_time: '08:00', end_time: '18:00', days: {} }; scheduleContainer.innerHTML = `

    Schedule Settings

    When enabled, this image will only display during scheduled times

    ${['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'].map(day => { const dayConfig = (schedule.days && schedule.days[day]) || { enabled: true, start_time: '08:00', end_time: '18:00' }; return `
    `; }).join('')}
    `; } window.toggleImageScheduleEnabled = function(fieldId, imageId, imageIdx) { const currentImages = window.getCurrentImages(fieldId); const image = currentImages[imageIdx]; if (!image) return; const checkbox = document.getElementById(`schedule_enabled_${imageId}`); const enabled = checkbox.checked; if (!image.schedule) { image.schedule = { enabled: false, mode: 'always', start_time: '08:00', end_time: '18:00', days: {} }; } image.schedule.enabled = enabled; const optionsDiv = document.getElementById(`schedule_options_${imageId}`); if (optionsDiv) { optionsDiv.style.display = enabled ? 'block' : 'none'; } window.updateImageList(fieldId, currentImages); } window.updateImageScheduleMode = function(fieldId, imageId, imageIdx) { const currentImages = window.getCurrentImages(fieldId); const image = currentImages[imageIdx]; if (!image) return; if (!image.schedule) { image.schedule = { enabled: true, mode: 'always', start_time: '08:00', end_time: '18:00', days: {} }; } const modeSelect = document.getElementById(`schedule_mode_${imageId}`); const mode = modeSelect.value; image.schedule.mode = mode; const timeRangeDiv = document.getElementById(`time_range_${imageId}`); const perDayDiv = document.getElementById(`per_day_${imageId}`); if (timeRangeDiv) timeRangeDiv.style.display = mode === 'time_range' ? 'grid' : 'none'; if (perDayDiv) perDayDiv.style.display = mode === 'per_day' ? 'block' : 'none'; window.updateImageList(fieldId, currentImages); } window.updateImageScheduleTime = function(fieldId, imageId, imageIdx) { const currentImages = window.getCurrentImages(fieldId); const image = currentImages[imageIdx]; if (!image) return; if (!image.schedule) { image.schedule = { enabled: true, mode: 'time_range', start_time: '08:00', end_time: '18:00' }; } const startInput = document.getElementById(`schedule_start_${imageId}`); const endInput = document.getElementById(`schedule_end_${imageId}`); if (startInput) image.schedule.start_time = startInput.value || '08:00'; if (endInput) image.schedule.end_time = endInput.value || '18:00'; window.updateImageList(fieldId, currentImages); } window.updateImageScheduleDay = function(fieldId, imageId, imageIdx, day) { const currentImages = window.getCurrentImages(fieldId); const image = currentImages[imageIdx]; if (!image) return; if (!image.schedule) { image.schedule = { enabled: true, mode: 'per_day', days: {} }; } if (!image.schedule.days) { image.schedule.days = {}; } const checkbox = document.getElementById(`day_${day}_${imageId}`); const startInput = document.getElementById(`day_${day}_start_${imageId}`); const endInput = document.getElementById(`day_${day}_end_${imageId}`); const enabled = checkbox ? checkbox.checked : true; if (!image.schedule.days[day]) { image.schedule.days[day] = { enabled: true, start_time: '08:00', end_time: '18:00' }; } image.schedule.days[day].enabled = enabled; if (startInput) image.schedule.days[day].start_time = startInput.value || '08:00'; if (endInput) image.schedule.days[day].end_time = endInput.value || '18:00'; const timesDiv = document.getElementById(`day_times_${day}_${imageId}`); if (timesDiv) { timesDiv.style.display = enabled ? 'grid' : 'none'; if (startInput) startInput.disabled = !enabled; if (endInput) endInput.disabled = !enabled; } window.updateImageList(fieldId, currentImages); } // Expose renderArrayObjectItem, getSchemaProperty, and escapeHtml to window for use by global functions window.renderArrayObjectItem = renderArrayObjectItem; window.getSchemaProperty = getSchemaProperty; window.escapeHtml = escapeHtml; window.escapeAttribute = escapeAttribute; })(); // End IIFE // Functions to handle array-of-objects // Define these at the top level (outside any IIFE) to ensure they're always available if (typeof window !== 'undefined') { window.addArrayObjectItem = function(fieldId, fullKey, maxItems) { const itemsContainer = document.getElementById(fieldId + '_items'); const hiddenInput = document.getElementById(fieldId + '_data'); if (!itemsContainer || !hiddenInput) return; const currentItems = itemsContainer.querySelectorAll('.array-object-item'); if (currentItems.length >= maxItems) { alert(`Maximum ${maxItems} items allowed`); return; } // Get schema for item properties - ensure currentPluginConfig is available // Try window.currentPluginConfig first (most reliable), then currentPluginConfig const schema = (typeof window.currentPluginConfig !== 'undefined' && window.currentPluginConfig?.schema) || (typeof currentPluginConfig !== 'undefined' && currentPluginConfig?.schema); if (!schema) { console.error('addArrayObjectItem: Schema not available. currentPluginConfig may not be set.'); return; } // Use getSchemaProperty to properly handle nested schemas (e.g., news.custom_feeds) const arraySchema = window.getSchemaProperty(schema, fullKey); if (!arraySchema || arraySchema.type !== 'array' || !arraySchema.items) { return; } const itemsSchema = arraySchema.items; if (!itemsSchema || !itemsSchema.properties) return; const newIndex = currentItems.length; // Use renderArrayObjectItem if available, otherwise create basic HTML let itemHtml = ''; if (typeof window.renderArrayObjectItem === 'function') { itemHtml = window.renderArrayObjectItem(fieldId, fullKey, itemsSchema.properties, {}, newIndex, itemsSchema); } else { // Fallback: create basic HTML structure // Note: newItem is {} for newly added items, so this will use schema defaults const newItem = {}; itemHtml = `
    `; Object.keys(itemsSchema.properties || {}).forEach(propKey => { const propSchema = itemsSchema.properties[propKey]; const propValue = newItem[propKey] !== undefined ? newItem[propKey] : propSchema.default; const propLabel = propSchema.title || propKey.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); itemHtml += `
    `; 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) { console.log('[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) console.log('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; } // Expose GitHub install handlers for debugging and manual testing if (typeof setupGitHubInstallHandlers !== 'undefined') { window.setupGitHubInstallHandlers = setupGitHubInstallHandlers; console.log('[GLOBAL] setupGitHubInstallHandlers exposed to window'); } if (typeof attachInstallButtonHandler !== 'undefined') { window.attachInstallButtonHandler = attachInstallButtonHandler; console.log('[GLOBAL] attachInstallButtonHandler exposed to window'); } // searchPluginStore is now exposed inside the IIFE after its definition // Verify critical functions are available if (_PLUGIN_DEBUG_EARLY) { console.log('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')) { console.log('[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) { console.log('Found installed-plugins-grid, forcing initialization...'); window.pluginManager.initialized = false; if (typeof initializePluginPageWhenReady === 'function') { initializePluginPageWhenReady(); } else if (typeof window.initPluginsPage === 'function') { window.initPluginsPage(); } } else { console.log('installed-plugins-grid not found yet, will retry via event listeners'); } // Also try to attach install button handler after a delay (fallback) setTimeout(() => { if (typeof window.attachInstallButtonHandler === 'function') { console.log('[FALLBACK] Attempting to attach install button handler...'); window.attachInstallButtonHandler(); } else { console.warn('[FALLBACK] attachInstallButtonHandler not available on window'); } }, 500); }, 200); // ─── Starlark Apps Integration ────────────────────────────────────────────── (function() { 'use strict'; let starlarkSectionVisible = false; let starlarkFullCache = null; // All apps from server let starlarkFilteredList = []; // After filters applied let starlarkDataLoaded = false; // ── Filter State ──────────────────────────────────────────────────────── const starlarkFilterState = { sort: safeLocalStorage.getItem('starlarkSort') || 'a-z', filterInstalled: null, // null=all, true=installed, false=not-installed filterAuthor: '', filterCategory: '', searchQuery: '', page: 1, perPage: parseInt(safeLocalStorage.getItem('starlarkPerPage')) || 24, persist() { safeLocalStorage.setItem('starlarkSort', this.sort); safeLocalStorage.setItem('starlarkPerPage', this.perPage); }, reset() { this.sort = 'a-z'; this.filterInstalled = null; this.filterAuthor = ''; this.filterCategory = ''; this.searchQuery = ''; this.page = 1; }, activeCount() { let n = 0; if (this.searchQuery) n++; if (this.filterInstalled !== null) n++; if (this.filterAuthor) n++; if (this.filterCategory) n++; if (this.sort !== 'a-z') n++; return n; } }; // ── Helpers ───────────────────────────────────────────────────────────── function escapeHtml(str) { if (!str) return ''; const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } 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(); } }); } // Restore persisted sort/perPage const sortEl = document.getElementById('starlark-sort'); if (sortEl) sortEl.value = starlarkFilterState.sort; const ppEl = document.getElementById('starlark-per-page'); if (ppEl) ppEl.value = starlarkFilterState.perPage; 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 = `
    Pixlet available${data.pixlet_version ? ' (' + escapeHtml(data.pixlet_version) + ')' : ''} — ${data.installed_apps || 0} app(s) installed
    `; } else { banner.innerHTML = `
    Pixlet not installed.
    `; } }) .catch(err => console.error('Starlark status error:', err)); } // ── Bulk Fetch All Apps ───────────────────────────────────────────────── function fetchStarlarkApps() { const grid = document.getElementById('starlark-apps-grid'); if (grid) { grid.innerHTML = `
    ${Array(10).fill('
    ').join('')}
    `; } fetch('/api/v3/starlark/repository/browse') .then(r => r.json()) .then(data => { if (data.status !== 'success') { if (grid) grid.innerHTML = `
    ${escapeHtml(data.message || 'Failed to load')}
    `; return; } starlarkFullCache = data.apps || []; starlarkDataLoaded = true; // Populate category dropdown const catSelect = document.getElementById('starlark-category'); if (catSelect) { catSelect.innerHTML = ''; (data.categories || []).forEach(cat => { const opt = document.createElement('option'); opt.value = cat; opt.textContent = cat; catSelect.appendChild(opt); }); } // Populate author dropdown const authSelect = document.getElementById('starlark-filter-author'); if (authSelect) { authSelect.innerHTML = ''; (data.authors || []).forEach(author => { const opt = document.createElement('option'); opt.value = author; opt.textContent = author; authSelect.appendChild(opt); }); } const countEl = document.getElementById('starlark-apps-count'); if (countEl) countEl.textContent = `${data.count} apps`; if (data.rate_limit) { console.log(`[Starlark] GitHub rate limit: ${data.rate_limit.remaining}/${data.rate_limit.limit} remaining` + (data.cached ? ' (cached)' : '')); } applyStarlarkFiltersAndSort(); }) .catch(err => { console.error('Starlark browse error:', err); if (grid) grid.innerHTML = '
    Error loading apps
    '; }); } // ── Apply Filters + Sort ──────────────────────────────────────────────── function applyStarlarkFiltersAndSort(skipPageReset) { if (!starlarkFullCache) return; const st = starlarkFilterState; let list = starlarkFullCache.slice(); // Text search if (st.searchQuery) { const q = st.searchQuery.toLowerCase(); list = list.filter(app => { const hay = [app.name, app.summary, app.desc, app.author, app.id, app.category] .filter(Boolean).join(' ').toLowerCase(); return hay.includes(q); }); } // Category filter if (st.filterCategory) { const cat = st.filterCategory.toLowerCase(); list = list.filter(app => (app.category || '').toLowerCase() === cat); } // Author filter if (st.filterAuthor) { list = list.filter(app => app.author === st.filterAuthor); } // Installed filter if (st.filterInstalled === true) { list = list.filter(app => isStarlarkInstalled(app.id)); } else if (st.filterInstalled === false) { list = list.filter(app => !isStarlarkInstalled(app.id)); } // Sort list.sort((a, b) => { const nameA = (a.name || a.id || '').toLowerCase(); const nameB = (b.name || b.id || '').toLowerCase(); switch (st.sort) { case 'z-a': return nameB.localeCompare(nameA); case 'category': { const catCmp = (a.category || '').localeCompare(b.category || ''); return catCmp !== 0 ? catCmp : nameA.localeCompare(nameB); } case 'author': { const authCmp = (a.author || '').localeCompare(b.author || ''); return authCmp !== 0 ? authCmp : nameA.localeCompare(nameB); } default: return nameA.localeCompare(nameB); // a-z } }); starlarkFilteredList = list; if (!skipPageReset) st.page = 1; renderStarlarkPage(); updateStarlarkFilterUI(); } // ── Render Current Page ───────────────────────────────────────────────── function renderStarlarkPage() { const st = starlarkFilterState; const total = starlarkFilteredList.length; const totalPages = Math.max(1, Math.ceil(total / st.perPage)); if (st.page > totalPages) st.page = totalPages; const start = (st.page - 1) * st.perPage; const end = Math.min(start + st.perPage, total); const pageApps = starlarkFilteredList.slice(start, end); // Results info const info = total > 0 ? `Showing ${start + 1}\u2013${end} of ${total} apps` : 'No apps match your filters'; const infoEl = document.getElementById('starlark-results-info'); const infoElBot = document.getElementById('starlark-results-info-bottom'); if (infoEl) infoEl.textContent = info; if (infoElBot) infoElBot.textContent = info; // Pagination renderStarlarkPagination('starlark-pagination-top', totalPages, st.page); renderStarlarkPagination('starlark-pagination-bottom', totalPages, st.page); // Grid const grid = document.getElementById('starlark-apps-grid'); renderStarlarkApps(pageApps, grid); } // ── Pagination Controls ───────────────────────────────────────────────── function renderStarlarkPagination(containerId, totalPages, currentPage) { const container = document.getElementById(containerId); if (!container) return; if (totalPages <= 1) { container.innerHTML = ''; return; } const btnClass = 'px-3 py-1 text-sm rounded-md border transition-colors'; const activeClass = 'bg-blue-600 text-white border-blue-600'; const normalClass = 'bg-white text-gray-700 border-gray-300 hover:bg-gray-100 cursor-pointer'; const disabledClass = 'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed'; let html = ''; // Prev html += ``; // Page numbers with ellipsis const pages = []; pages.push(1); if (currentPage > 3) pages.push('...'); for (let i = Math.max(2, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) { pages.push(i); } if (currentPage < totalPages - 2) pages.push('...'); if (totalPages > 1) pages.push(totalPages); pages.forEach(p => { if (p === '...') { html += ``; } else { html += ``; } }); // Next html += ``; container.innerHTML = html; // Event delegation for page buttons container.querySelectorAll('[data-starlark-page]').forEach(btn => { btn.addEventListener('click', function() { const p = parseInt(this.getAttribute('data-starlark-page')); if (p >= 1 && p <= totalPages && p !== currentPage) { starlarkFilterState.page = p; renderStarlarkPage(); // Scroll to top of grid const grid = document.getElementById('starlark-apps-grid'); if (grid) grid.scrollIntoView({ behavior: 'smooth', block: 'start' }); } }); }); } // ── Card Rendering ────────────────────────────────────────────────────── function renderStarlarkApps(apps, grid) { if (!grid) return; if (!apps || apps.length === 0) { grid.innerHTML = '

    No Starlark apps found

    '; return; } grid.innerHTML = apps.map(app => { const installed = isStarlarkInstalled(app.id); return `

    ${escapeHtml(app.name || app.id)}

    Starlark ${installed ? 'Installed' : ''}
    ${app.author ? `

    ${escapeHtml(app.author)}

    ` : ''} ${app.category ? `

    ${escapeHtml(app.category)}

    ` : ''}

    ${escapeHtml(app.summary || app.desc || 'No description')}

    `; }).join(''); // Add delegated event listener only once (prevent duplicate handlers) if (!grid.dataset.starlarkHandlerAttached) { grid.addEventListener('click', function handleStarlarkGridClick(e) { const button = e.target.closest('button[data-action]'); if (!button) return; const card = button.closest('.plugin-card'); if (!card) return; const appId = card.dataset.appId; if (!appId) return; const action = button.dataset.action; if (action === 'install') { window.installStarlarkApp(appId); } else if (action === 'view') { window.open('https://github.com/tronbyt/apps/tree/main/apps/' + encodeURIComponent(appId), '_blank'); } }); grid.dataset.starlarkHandlerAttached = 'true'; } } // ── Filter UI Updates ─────────────────────────────────────────────────── function updateStarlarkFilterUI() { const st = starlarkFilterState; const count = st.activeCount(); const badge = document.getElementById('starlark-active-filters'); const clearBtn = document.getElementById('starlark-clear-filters'); if (badge) { badge.classList.toggle('hidden', count === 0); badge.textContent = count + ' filter' + (count !== 1 ? 's' : '') + ' active'; } if (clearBtn) clearBtn.classList.toggle('hidden', count === 0); // Update installed toggle button text const instBtn = document.getElementById('starlark-filter-installed'); if (instBtn) { if (st.filterInstalled === true) { instBtn.innerHTML = 'Installed'; instBtn.classList.add('border-green-400', 'bg-green-50'); instBtn.classList.remove('border-gray-300', 'bg-white', 'border-red-400', 'bg-red-50'); } else if (st.filterInstalled === false) { instBtn.innerHTML = 'Not Installed'; instBtn.classList.add('border-red-400', 'bg-red-50'); instBtn.classList.remove('border-gray-300', 'bg-white', 'border-green-400', 'bg-green-50'); } else { instBtn.innerHTML = 'All'; instBtn.classList.add('border-gray-300', 'bg-white'); instBtn.classList.remove('border-green-400', 'bg-green-50', 'border-red-400', 'bg-red-50'); } } } // ── Event Listeners ───────────────────────────────────────────────────── function setupStarlarkFilterListeners() { // Search with debounce const searchEl = document.getElementById('starlark-search'); if (searchEl && !searchEl._starlarkInit) { searchEl._starlarkInit = true; let debounce = null; searchEl.addEventListener('input', function() { clearTimeout(debounce); debounce = setTimeout(() => { starlarkFilterState.searchQuery = this.value.trim(); applyStarlarkFiltersAndSort(); }, 300); }); } // Category dropdown const catEl = document.getElementById('starlark-category'); if (catEl && !catEl._starlarkInit) { catEl._starlarkInit = true; catEl.addEventListener('change', function() { starlarkFilterState.filterCategory = this.value; applyStarlarkFiltersAndSort(); }); } // Sort dropdown const sortEl = document.getElementById('starlark-sort'); if (sortEl && !sortEl._starlarkInit) { sortEl._starlarkInit = true; sortEl.addEventListener('change', function() { starlarkFilterState.sort = this.value; starlarkFilterState.persist(); applyStarlarkFiltersAndSort(); }); } // Author dropdown const authEl = document.getElementById('starlark-filter-author'); if (authEl && !authEl._starlarkInit) { authEl._starlarkInit = true; authEl.addEventListener('change', function() { starlarkFilterState.filterAuthor = this.value; applyStarlarkFiltersAndSort(); }); } // Installed toggle (cycle: all → installed → not-installed → all) const instBtn = document.getElementById('starlark-filter-installed'); if (instBtn && !instBtn._starlarkInit) { instBtn._starlarkInit = true; instBtn.addEventListener('click', function() { const st = starlarkFilterState; if (st.filterInstalled === null) st.filterInstalled = true; else if (st.filterInstalled === true) st.filterInstalled = false; else st.filterInstalled = null; applyStarlarkFiltersAndSort(); }); } // Clear filters const clearBtn = document.getElementById('starlark-clear-filters'); if (clearBtn && !clearBtn._starlarkInit) { clearBtn._starlarkInit = true; clearBtn.addEventListener('click', function() { starlarkFilterState.reset(); // Reset UI elements const searchEl = document.getElementById('starlark-search'); if (searchEl) searchEl.value = ''; const catEl = document.getElementById('starlark-category'); if (catEl) catEl.value = ''; const sortEl = document.getElementById('starlark-sort'); if (sortEl) sortEl.value = 'a-z'; const authEl = document.getElementById('starlark-filter-author'); if (authEl) authEl.value = ''; starlarkFilterState.persist(); applyStarlarkFiltersAndSort(); }); } // Per-page selector const ppEl = document.getElementById('starlark-per-page'); if (ppEl && !ppEl._starlarkInit) { ppEl._starlarkInit = true; ppEl.addEventListener('change', function() { starlarkFilterState.perPage = parseInt(this.value) || 24; starlarkFilterState.persist(); applyStarlarkFiltersAndSort(); }); } } // ── Install / Upload / Pixlet ─────────────────────────────────────────── window.installStarlarkApp = function(appId) { if (!confirm(`Install Starlark app "${appId}" from Tronbyte repository?`)) return; fetch('/api/v3/starlark/repository/install', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({app_id: appId}) }) .then(r => r.json()) .then(data => { if (data.status === 'success') { alert(`Installed: ${data.message || appId}`); // Refresh installed plugins list if (typeof loadInstalledPlugins === 'function') loadInstalledPlugins(); else if (typeof window.loadInstalledPlugins === 'function') window.loadInstalledPlugins(); // Re-render current page to update installed badges setTimeout(() => applyStarlarkFiltersAndSort(true), 500); } else { alert(`Install failed: ${data.message || 'Unknown error'}`); } }) .catch(err => { console.error('Install error:', err); alert('Install failed: ' + err.message); }); }; window.installPixlet = function() { if (!confirm('Download and install Pixlet binary? This may take a few minutes.')) return; fetch('/api/v3/starlark/install-pixlet', {method: 'POST'}) .then(r => r.json()) .then(data => { if (data.status === 'success') { alert(data.message || 'Pixlet installed!'); loadStarlarkStatus(); } else { alert('Pixlet install failed: ' + (data.message || 'Unknown error')); } }) .catch(err => alert('Pixlet install failed: ' + err.message)); }; function uploadStarlarkFile(file) { const formData = new FormData(); formData.append('file', file); const appId = file.name.replace('.star', ''); formData.append('app_id', appId); formData.append('name', appId.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())); fetch('/api/v3/starlark/upload', {method: 'POST', body: formData}) .then(r => r.json()) .then(data => { if (data.status === 'success') { alert(`Uploaded: ${data.app_id}`); if (typeof loadInstalledPlugins === 'function') loadInstalledPlugins(); else if (typeof window.loadInstalledPlugins === 'function') window.loadInstalledPlugins(); setTimeout(() => applyStarlarkFiltersAndSort(true), 500); } else { alert('Upload failed: ' + (data.message || 'Unknown error')); } }) .catch(err => alert('Upload failed: ' + err.message)); } // ── Bootstrap ─────────────────────────────────────────────────────────── const origInit = window.initializePlugins; window.initializePlugins = function() { if (origInit) origInit(); initStarlarkSection(); }; document.addEventListener('DOMContentLoaded', initStarlarkSection); document.addEventListener('htmx:afterSwap', function(e) { if (e.detail && e.detail.target && e.detail.target.id === 'plugins-content') { initStarlarkSection(); } }); })();