/* global debugLog */
// ─── LocalStorage Safety Wrappers ────────────────────────────────────────────
// Handles environments where localStorage is unavailable or restricted (private browsing, etc.)
const safeLocalStorage = {
getItem(key) {
try {
if (typeof localStorage !== 'undefined') {
return localStorage.getItem(key);
}
} catch (e) {
console.warn(`safeLocalStorage.getItem failed for key "${key}":`, e.message);
}
return null;
},
setItem(key, value) {
try {
if (typeof localStorage !== 'undefined') {
localStorage.setItem(key, value);
return true;
}
} catch (e) {
console.warn(`safeLocalStorage.setItem failed for key "${key}":`, e.message);
}
return false;
},
removeItem(key) {
try {
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(key);
return true;
}
} catch (e) {
console.warn(`localStorage.removeItem failed for key "${key}":`, e.message);
}
return false;
}
};
// Define critical functions immediately so they're available before any HTML is rendered
// Debug logging controlled by safeLocalStorage.setItem('pluginDebug', 'true')
const _PLUGIN_DEBUG_EARLY = safeLocalStorage.getItem('pluginDebug') === 'true';
if (_PLUGIN_DEBUG_EARLY) debugLog('[PLUGINS SCRIPT] Defining configurePlugin and togglePlugin at top level...');
// Expose on-demand functions early as stubs (will be replaced when IIFE runs)
window.openOnDemandModal = function(pluginId) {
console.warn('openOnDemandModal called before initialization, waiting...');
// Wait for the real function to be available
let attempts = 0;
const maxAttempts = 50; // 2.5 seconds
const checkInterval = setInterval(() => {
attempts++;
if (window.__openOnDemandModalImpl) {
clearInterval(checkInterval);
window.__openOnDemandModalImpl(pluginId);
} else if (attempts >= maxAttempts) {
clearInterval(checkInterval);
console.error('openOnDemandModal not available after waiting');
if (typeof showNotification === 'function') {
showNotification('On-demand modal unavailable. Please refresh the page.', 'error');
}
}
}, 50);
};
window.requestOnDemandStop = function({ stopService = false } = {}) {
console.warn('requestOnDemandStop called before initialization, waiting...');
// Wait for the real function to be available
let attempts = 0;
const maxAttempts = 50; // 2.5 seconds
const checkInterval = setInterval(() => {
attempts++;
if (window.__requestOnDemandStopImpl) {
clearInterval(checkInterval);
return window.__requestOnDemandStopImpl({ stopService });
} else if (attempts >= maxAttempts) {
clearInterval(checkInterval);
console.error('requestOnDemandStop not available after waiting');
if (typeof showNotification === 'function') {
showNotification('On-demand stop unavailable. Please refresh the page.', 'error');
}
return Promise.reject(new Error('Function not available'));
}
}, 50);
return Promise.resolve();
};
// Define updatePlugin early as a stub to ensure it's always available
window.updatePlugin = window.updatePlugin || function(pluginId) {
if (_PLUGIN_DEBUG_EARLY) debugLog('[PLUGINS STUB] updatePlugin called for', pluginId);
// Validate pluginId
if (!pluginId || typeof pluginId !== 'string') {
console.error('Invalid pluginId:', pluginId);
if (typeof showNotification === 'function') {
showNotification('Invalid plugin ID', 'error');
}
return Promise.reject(new Error('Invalid plugin ID'));
}
// Show immediate feedback
if (typeof showNotification === 'function') {
showNotification(`Updating ${pluginId}...`, 'info');
}
// Prepare request body
const requestBody = { plugin_id: pluginId };
const requestBodyJson = JSON.stringify(requestBody);
debugLog('[UPDATE] Sending request:', { url: '/api/v3/plugins/update', body: requestBodyJson });
// Make the API call directly
return fetch('/api/v3/plugins/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: requestBodyJson
})
.then(async response => {
// Check if response is OK before parsing
if (!response.ok) {
// Try to parse error response
let errorData;
try {
const text = await response.text();
console.error('[UPDATE] Error response:', { status: response.status, statusText: response.statusText, body: text });
errorData = JSON.parse(text);
} catch (e) {
errorData = { message: `Server error: ${response.status} ${response.statusText}` };
}
if (typeof showNotification === 'function') {
showNotification(errorData.message || `Update failed: ${response.status}`, 'error');
}
throw new Error(errorData.message || `Update failed: ${response.status}`);
}
// Parse successful response
return response.json();
})
.then(data => {
if (typeof showNotification === 'function') {
showNotification(data.message || 'Update initiated', data.status || 'info');
}
// Refresh installed plugins if available
if (typeof loadInstalledPlugins === 'function') {
loadInstalledPlugins();
} else if (typeof window.pluginManager?.loadInstalledPlugins === 'function') {
window.pluginManager.loadInstalledPlugins();
}
return data;
})
.catch(error => {
console.error('[UPDATE] Error updating plugin:', error);
if (typeof showNotification === 'function') {
showNotification('Error updating plugin: ' + error.message, 'error');
}
throw error;
});
};
// Define uninstallPlugin early as a stub
window.uninstallPlugin = window.uninstallPlugin || function(pluginId) {
if (_PLUGIN_DEBUG_EARLY) debugLog('[PLUGINS STUB] uninstallPlugin called for', pluginId);
if (!confirm(`Are you sure you want to uninstall ${pluginId}?`)) {
return Promise.resolve({ cancelled: true });
}
if (typeof showNotification === 'function') {
showNotification(`Uninstalling ${pluginId}...`, 'info');
}
return fetch('/api/v3/plugins/uninstall', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plugin_id: pluginId })
})
.then(response => response.json())
.then(data => {
if (typeof showNotification === 'function') {
showNotification(data.message || 'Uninstall initiated', data.status || 'info');
}
// Refresh installed plugins if available
if (typeof loadInstalledPlugins === 'function') {
loadInstalledPlugins();
} else if (typeof window.pluginManager?.loadInstalledPlugins === 'function') {
window.pluginManager.loadInstalledPlugins();
}
return data;
})
.catch(error => {
console.error('Error uninstalling plugin:', error);
if (typeof showNotification === 'function') {
showNotification('Error uninstalling plugin: ' + error.message, 'error');
}
throw error;
});
};
// Define configurePlugin early to ensure it's always available
window.configurePlugin = window.configurePlugin || async function(pluginId) {
if (_PLUGIN_DEBUG_EARLY) debugLog('[PLUGINS STUB] configurePlugin called for', pluginId);
// Switch to the plugin's configuration tab instead of opening a modal
// This matches the behavior of clicking the plugin tab at the top
function getAppComponent() {
if (window.Alpine) {
const appElement = document.querySelector('[x-data="app()"]');
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
return appElement._x_dataStack[0];
}
}
return null;
}
const appComponent = getAppComponent();
if (appComponent) {
// Set the active tab to the plugin ID
appComponent.activeTab = pluginId;
if (_PLUGIN_DEBUG_EARLY) debugLog('[PLUGINS STUB] Switched to plugin tab:', pluginId);
// Scroll to top of page to ensure the tab is visible
window.scrollTo({ top: 0, behavior: 'smooth' });
} else {
console.error('Alpine.js app instance not found');
if (typeof showNotification === 'function') {
showNotification('Unable to switch to plugin configuration. Please refresh the page.', 'error');
}
}
};
// Initialize per-plugin toggle request token map for race condition protection
if (!window._pluginToggleRequests) {
window._pluginToggleRequests = {};
}
// Define togglePlugin early to ensure it's always available
window.togglePlugin = window.togglePlugin || function(pluginId, enabled) {
if (_PLUGIN_DEBUG_EARLY) debugLog('[PLUGINS STUB] togglePlugin called for', pluginId, 'enabled:', enabled);
const plugin = (window.installedPlugins || []).find(p => p.id === pluginId);
const pluginName = plugin ? (plugin.name || pluginId) : pluginId;
const action = enabled ? 'enabling' : 'disabling';
// Generate unique token for this toggle request to prevent race conditions
const requestToken = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
window._pluginToggleRequests[pluginId] = requestToken;
// Update UI immediately for better UX
const toggleCheckbox = document.getElementById(`toggle-${pluginId}`);
const toggleLabel = document.getElementById(`toggle-label-${pluginId}`);
const wrapperDiv = toggleCheckbox?.parentElement?.querySelector('.flex.items-center.gap-2');
const toggleTrack = wrapperDiv?.querySelector('.relative.w-14');
const toggleHandle = toggleTrack?.querySelector('.absolute');
// Disable checkbox and add disabled class to prevent overlapping requests
if (toggleCheckbox) {
toggleCheckbox.checked = enabled;
toggleCheckbox.disabled = true;
toggleCheckbox.classList.add('opacity-50', 'cursor-not-allowed');
}
// Disable wrapper to provide visual feedback
if (wrapperDiv) {
wrapperDiv.classList.add('opacity-50', 'pointer-events-none');
}
// Update wrapper background and border
if (wrapperDiv) {
if (enabled) {
wrapperDiv.classList.remove('bg-gray-50', 'border-gray-300');
wrapperDiv.classList.add('bg-green-50', 'border-green-500');
} else {
wrapperDiv.classList.remove('bg-green-50', 'border-green-500');
wrapperDiv.classList.add('bg-gray-50', 'border-gray-300');
}
}
// Update toggle track
if (toggleTrack) {
if (enabled) {
toggleTrack.classList.remove('bg-gray-300');
toggleTrack.classList.add('bg-green-500');
} else {
toggleTrack.classList.remove('bg-green-500');
toggleTrack.classList.add('bg-gray-300');
}
}
// Update toggle handle
if (toggleHandle) {
if (enabled) {
toggleHandle.classList.add('translate-x-full', 'border-green-500');
toggleHandle.classList.remove('border-gray-400');
toggleHandle.innerHTML = '';
} else {
toggleHandle.classList.remove('translate-x-full', 'border-green-500');
toggleHandle.classList.add('border-gray-400');
toggleHandle.innerHTML = '';
}
}
// Update label with icon and text
if (toggleLabel) {
if (enabled) {
toggleLabel.className = 'text-sm font-semibold text-green-700 flex items-center gap-1.5';
toggleLabel.innerHTML = 'Enabled';
} else {
toggleLabel.className = 'text-sm font-semibold text-gray-600 flex items-center gap-1.5';
toggleLabel.innerHTML = 'Disabled';
}
}
if (typeof showNotification === 'function') {
showNotification(`${action.charAt(0).toUpperCase() + action.slice(1)} ${pluginName}...`, 'info');
}
return fetch('/api/v3/plugins/toggle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plugin_id: pluginId, enabled: enabled })
})
.then(response => response.json())
.then(data => {
// Verify this response is for the latest request (prevent race conditions)
if (window._pluginToggleRequests[pluginId] !== requestToken) {
debugLog(`[togglePlugin] Ignoring out-of-order response for ${pluginId}`);
return;
}
if (typeof showNotification === 'function') {
showNotification(data.message, data.status);
}
if (data.status === 'success') {
// Update local state
if (plugin) {
plugin.enabled = enabled;
}
// Refresh the list to ensure consistency
if (typeof loadInstalledPlugins === 'function') {
loadInstalledPlugins();
}
} else {
// Revert the toggle if API call failed
if (plugin) {
plugin.enabled = !enabled;
}
if (typeof loadInstalledPlugins === 'function') {
loadInstalledPlugins();
}
}
// Clear token and re-enable UI
delete window._pluginToggleRequests[pluginId];
if (toggleCheckbox) {
toggleCheckbox.disabled = false;
toggleCheckbox.classList.remove('opacity-50', 'cursor-not-allowed');
}
if (wrapperDiv) {
wrapperDiv.classList.remove('opacity-50', 'pointer-events-none');
}
// Resolve the outcome so callers (e.g. the install flow) can chain
// on whether enabling actually succeeded.
return data;
})
.catch(error => {
// Verify this error is for the latest request (prevent race conditions)
if (window._pluginToggleRequests[pluginId] !== requestToken) {
debugLog(`[togglePlugin] Ignoring out-of-order error for ${pluginId}`);
return;
}
if (typeof showNotification === 'function') {
showNotification('Error toggling plugin: ' + error.message, 'error');
}
// Revert the toggle if API call failed
if (plugin) {
plugin.enabled = !enabled;
}
if (typeof loadInstalledPlugins === 'function') {
loadInstalledPlugins();
}
// Clear token and re-enable UI
delete window._pluginToggleRequests[pluginId];
if (toggleCheckbox) {
toggleCheckbox.disabled = false;
toggleCheckbox.classList.remove('opacity-50', 'cursor-not-allowed');
}
if (wrapperDiv) {
wrapperDiv.classList.remove('opacity-50', 'pointer-events-none');
}
});
};
// Track pending render data for when DOM isn't ready yet
window.__pendingInstalledPlugins = window.__pendingInstalledPlugins || null;
window.__pendingStorePlugins = window.__pendingStorePlugins || null;
window.__pluginDomReady = window.__pluginDomReady || false;
// Set up global event delegation for plugin actions (works even before plugins are loaded)
(function setupGlobalEventDelegation() {
// Use document-level delegation so it works for dynamically added content
const handleGlobalPluginAction = function(event) {
// Only handle if it's a plugin action
const button = event.target.closest('button[data-action][data-plugin-id]') ||
event.target.closest('input[data-action][data-plugin-id]');
if (!button) return;
const action = button.getAttribute('data-action');
const pluginId = button.getAttribute('data-plugin-id');
// For toggle and configure, ensure functions are available
if (action === 'toggle' || action === 'configure') {
const funcName = action === 'toggle' ? 'togglePlugin' : 'configurePlugin';
if (!window[funcName] || typeof window[funcName] !== 'function') {
// Prevent default and stop propagation immediately to avoid double handling
event.preventDefault();
event.stopPropagation();
console.warn(`[GLOBAL DELEGATION] ${funcName} not available yet, waiting...`);
// Capture state synchronously from plugin data (source of truth)
let targetChecked = false;
if (action === 'toggle') {
const plugin = (window.installedPlugins || []).find(p => p.id === pluginId);
let currentEnabled;
if (plugin) {
currentEnabled = Boolean(plugin.enabled);
} else if (button.type === 'checkbox') {
currentEnabled = button.checked;
} else {
currentEnabled = false;
}
targetChecked = !currentEnabled; // Toggle to opposite state
}
// Wait for function to be available
let attempts = 0;
const maxAttempts = 20; // 1 second total
const checkInterval = setInterval(() => {
attempts++;
if (window[funcName] && typeof window[funcName] === 'function') {
clearInterval(checkInterval);
// Call the function directly
if (action === 'toggle') {
window.togglePlugin(pluginId, targetChecked);
} else {
window.configurePlugin(pluginId);
}
} else if (attempts >= maxAttempts) {
clearInterval(checkInterval);
console.error(`[GLOBAL DELEGATION] ${funcName} not available after ${maxAttempts} attempts`);
if (typeof showNotification === 'function') {
showNotification(`${funcName} not loaded. Please refresh the page.`, 'error');
}
}
}, 50);
return; // Don't proceed with normal handling
}
}
// Prevent default and stop propagation to avoid double handling
event.preventDefault();
event.stopPropagation();
// If handlePluginAction exists, use it; otherwise handle directly
if (typeof handlePluginAction === 'function') {
handlePluginAction(event);
} else {
// Fallback: handle directly if functions are available
if (action === 'toggle' && window.togglePlugin) {
// Get the current enabled state from plugin data (source of truth)
const plugin = (window.installedPlugins || []).find(p => p.id === pluginId);
let currentEnabled;
if (plugin) {
currentEnabled = Boolean(plugin.enabled);
} else if (button.type === 'checkbox') {
currentEnabled = button.checked;
} else {
currentEnabled = false;
}
// Toggle the state - we want the opposite of current state
const isChecked = !currentEnabled;
// Prevent default behavior to avoid double-toggling and change event
// (Already done at start of function, but safe to repeat)
event.preventDefault();
event.stopPropagation();
debugLog('[DEBUG toggle fallback] Plugin:', pluginId, 'Current enabled (from data):', currentEnabled, 'New state:', isChecked);
window.togglePlugin(pluginId, isChecked);
} else if (action === 'configure' && window.configurePlugin) {
event.preventDefault();
event.stopPropagation();
window.configurePlugin(pluginId);
} else if (action === 'update' && window.updatePlugin) {
event.preventDefault();
event.stopPropagation();
debugLog('[DEBUG update fallback] Updating plugin:', pluginId);
window.updatePlugin(pluginId);
} else if (action === 'uninstall' && window.uninstallPlugin) {
event.preventDefault();
event.stopPropagation();
debugLog('[DEBUG uninstall fallback] Uninstalling plugin:', pluginId);
if (confirm(`Are you sure you want to uninstall ${pluginId}?`)) {
window.uninstallPlugin(pluginId);
}
}
}
};
// Set up delegation on document (capture phase for better reliability)
document.addEventListener('click', handleGlobalPluginAction, true);
document.addEventListener('change', handleGlobalPluginAction, true);
debugLog('[PLUGINS SCRIPT] Global event delegation set up');
})();
// Note: configurePlugin and togglePlugin are now defined at the top of the file (after uninstallPlugin)
// to ensure they're available immediately when the script loads
// Verify functions are defined (debug only)
if (_PLUGIN_DEBUG_EARLY) {
debugLog('[PLUGINS SCRIPT] Functions defined:', {
configurePlugin: typeof window.configurePlugin,
togglePlugin: typeof window.togglePlugin
});
if (typeof window.configurePlugin === 'function') {
debugLog('[PLUGINS SCRIPT] ✓ configurePlugin ready');
}
if (typeof window.togglePlugin === 'function') {
debugLog('[PLUGINS SCRIPT] ✓ togglePlugin ready');
}
}
// GitHub Token Collapse Handler - Define early so it's available before IIFE
debugLog('[DEFINE] Defining attachGithubTokenCollapseHandler function...');
window.attachGithubTokenCollapseHandler = function() {
debugLog('[attachGithubTokenCollapseHandler] Starting...');
const toggleTokenCollapseBtn = document.getElementById('toggle-github-token-collapse');
debugLog('[attachGithubTokenCollapseHandler] Button found:', !!toggleTokenCollapseBtn);
if (!toggleTokenCollapseBtn) {
console.warn('[attachGithubTokenCollapseHandler] GitHub token collapse button not found');
return;
}
debugLog('[attachGithubTokenCollapseHandler] Checking toggleGithubTokenContent...', {
exists: typeof window.toggleGithubTokenContent
});
if (!window.toggleGithubTokenContent) {
console.warn('[attachGithubTokenCollapseHandler] toggleGithubTokenContent function not defined');
return;
}
// Remove any existing listeners by cloning the button
const parent = toggleTokenCollapseBtn.parentNode;
if (!parent) {
console.warn('[attachGithubTokenCollapseHandler] Button parent not found');
return;
}
const newBtn = toggleTokenCollapseBtn.cloneNode(true);
parent.replaceChild(newBtn, toggleTokenCollapseBtn);
// Attach listener to the new button
newBtn.addEventListener('click', function(e) {
debugLog('[attachGithubTokenCollapseHandler] Button clicked, calling toggleGithubTokenContent');
window.toggleGithubTokenContent(e);
});
debugLog('[attachGithubTokenCollapseHandler] Handler attached to button:', newBtn.id);
};
// Toggle GitHub Token Settings section
debugLog('[DEFINE] Defining toggleGithubTokenContent function...');
window.toggleGithubTokenContent = function(e) {
debugLog('[toggleGithubTokenContent] called', e);
if (e) {
e.stopPropagation();
e.preventDefault();
}
const tokenContent = document.getElementById('github-token-content');
const tokenIconCollapse = document.getElementById('github-token-icon-collapse');
const toggleTokenCollapseBtn = document.getElementById('toggle-github-token-collapse');
debugLog('[toggleGithubTokenContent] Elements found:', {
tokenContent: !!tokenContent,
tokenIconCollapse: !!tokenIconCollapse,
toggleTokenCollapseBtn: !!toggleTokenCollapseBtn
});
if (!tokenContent || !toggleTokenCollapseBtn) {
console.warn('[toggleGithubTokenContent] GitHub token content or button not found');
return;
}
const hasHiddenClass = tokenContent.classList.contains('hidden');
const computedDisplay = window.getComputedStyle(tokenContent).display;
debugLog('[toggleGithubTokenContent] Current state:', {
hasHiddenClass,
computedDisplay,
buttonText: toggleTokenCollapseBtn.querySelector('span')?.textContent
});
if (hasHiddenClass || computedDisplay === 'none') {
// Show content - remove hidden class, add block class, remove inline display
tokenContent.classList.remove('hidden');
tokenContent.classList.add('block');
tokenContent.style.removeProperty('display');
if (tokenIconCollapse) {
tokenIconCollapse.classList.remove('fa-chevron-down');
tokenIconCollapse.classList.add('fa-chevron-up');
}
const span = toggleTokenCollapseBtn.querySelector('span');
if (span) span.textContent = 'Collapse';
debugLog('[toggleGithubTokenContent] Content shown - removed hidden, added block');
} else {
// Hide content - add hidden class, remove block class, ensure display is none
tokenContent.classList.add('hidden');
tokenContent.classList.remove('block');
tokenContent.style.display = 'none';
if (tokenIconCollapse) {
tokenIconCollapse.classList.remove('fa-chevron-up');
tokenIconCollapse.classList.add('fa-chevron-down');
}
const span = toggleTokenCollapseBtn.querySelector('span');
if (span) span.textContent = 'Expand';
debugLog('[toggleGithubTokenContent] Content hidden - added hidden, removed block, set display:none');
}
};
// Simple standalone handler for GitHub plugin installation
// Defined early and globally to ensure it's always available
debugLog('[DEFINE] Defining handleGitHubPluginInstall function...');
window.handleGitHubPluginInstall = function() {
debugLog('[handleGitHubPluginInstall] Function called!');
const urlInput = document.getElementById('github-plugin-url');
const statusDiv = document.getElementById('github-plugin-status');
const branchInput = document.getElementById('plugin-branch-input');
const installBtn = document.getElementById('install-plugin-from-url');
if (!urlInput) {
console.error('[handleGitHubPluginInstall] URL input not found');
alert('Error: Could not find URL input field');
return;
}
const repoUrl = urlInput.value.trim();
debugLog('[handleGitHubPluginInstall] Repo URL:', repoUrl);
if (!repoUrl) {
if (statusDiv) {
statusDiv.innerHTML = 'Please enter a GitHub URL';
}
return;
}
if (!isGithubUrl(repoUrl)) {
if (statusDiv) {
statusDiv.innerHTML = 'Please enter a valid GitHub URL';
}
return;
}
// Disable button and show loading
if (installBtn) {
installBtn.disabled = true;
installBtn.innerHTML = 'Installing...';
}
if (statusDiv) {
statusDiv.innerHTML = 'Installing plugin...';
}
const branch = branchInput?.value?.trim() || null;
const requestBody = { repo_url: repoUrl };
if (branch) {
requestBody.branch = branch;
}
debugLog('[handleGitHubPluginInstall] Sending request:', requestBody);
fetch('/api/v3/plugins/install-from-url', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBody)
})
.then(response => {
debugLog('[handleGitHubPluginInstall] Response status:', response.status);
return response.json();
})
.then(data => {
debugLog('[handleGitHubPluginInstall] Response data:', data);
if (data.status === 'success') {
if (statusDiv) {
statusDiv.innerHTML = `Successfully installed: ${data.plugin_id}`;
}
urlInput.value = '';
// Show notification if available
if (typeof showNotification === 'function') {
showNotification(`Plugin ${data.plugin_id} installed successfully`, 'success');
}
// Refresh installed plugins list if function available
setTimeout(() => {
if (typeof loadInstalledPlugins === 'function') {
loadInstalledPlugins();
} else if (typeof window.loadInstalledPlugins === 'function') {
window.loadInstalledPlugins();
}
}, 1000);
} else {
if (statusDiv) {
statusDiv.innerHTML = `${data.message || 'Installation failed'}`;
}
if (typeof showNotification === 'function') {
showNotification(data.message || 'Installation failed', 'error');
}
}
})
.catch(error => {
console.error('[handleGitHubPluginInstall] Error:', error);
if (statusDiv) {
statusDiv.innerHTML = `Error: ${error.message}`;
}
if (typeof showNotification === 'function') {
showNotification('Error installing plugin: ' + error.message, 'error');
}
})
.finally(() => {
if (installBtn) {
installBtn.disabled = false;
installBtn.innerHTML = 'Install';
}
});
};
debugLog('[DEFINE] handleGitHubPluginInstall defined and ready');
// GitHub Authentication Status - Define early so it's available in IIFE
// Shows warning banner only when token is missing or invalid
// The token itself is never exposed to the frontend for security
// Returns a Promise so it can be awaited
debugLog('[DEFINE] Defining checkGitHubAuthStatus function...');
window.checkGitHubAuthStatus = function checkGitHubAuthStatus() {
debugLog('[checkGitHubAuthStatus] Starting...');
return fetch('/api/v3/plugins/store/github-status')
.then(response => {
debugLog('checkGitHubAuthStatus: Response status:', response.status);
return response.json();
})
.then(data => {
debugLog('checkGitHubAuthStatus: Data received:', data);
if (data.status === 'success') {
const authData = data.data;
const tokenStatus = authData.token_status || (authData.authenticated ? 'valid' : 'none');
debugLog('checkGitHubAuthStatus: Token status:', tokenStatus);
const warning = document.getElementById('github-auth-warning');
const settings = document.getElementById('github-token-settings');
const rateLimit = document.getElementById('rate-limit-count');
debugLog('checkGitHubAuthStatus: Elements found:', {
warning: !!warning,
settings: !!settings,
rateLimit: !!rateLimit
});
// Show warning only when token is missing ('none') or invalid ('invalid')
if (tokenStatus === 'none' || tokenStatus === 'invalid') {
// Check if user has dismissed the warning (stored in session storage)
const dismissed = sessionStorage.getItem('github-auth-warning-dismissed');
if (!dismissed) {
if (warning && rateLimit) {
rateLimit.textContent = authData.rate_limit;
// Update warning message for invalid tokens
if (tokenStatus === 'invalid' && authData.error) {
const warningText = warning.querySelector('p.text-sm.text-yellow-700');
if (warningText) {
// Clear existing content
warningText.textContent = '';
// Create safe error message with fallback
const errorMsg = (authData.message || authData.error || 'Unknown error').toString();
// Create element for "Token Invalid:" label
const strong = document.createElement('strong');
strong.textContent = 'Token Invalid:';
// Create text node for error message and suffix
const errorText = document.createTextNode(` ${errorMsg}. Please update your GitHub token to increase API rate limits to 5,000 requests/hour.`);
// Append elements safely (no innerHTML)
warningText.appendChild(strong);
warningText.appendChild(errorText);
}
}
// For 'none' status, use the default message from HTML template
// Show warning using both classList and style.display
warning.classList.remove('hidden');
warning.style.display = '';
debugLog(`GitHub token status: ${tokenStatus} - showing API limit warning`);
}
}
// Ensure settings panel is accessible when token is missing or invalid
// Panel can be opened via "Configure Token" link in warning
// Don't force it to be visible, but don't prevent it from being shown
} else if (tokenStatus === 'valid') {
// Token is valid - hide warning and ensure settings panel is visible but collapsed
if (warning) {
// Hide warning using both classList and style.display
warning.classList.add('hidden');
warning.style.display = 'none';
debugLog('GitHub token is valid - hiding API limit warning');
}
// Make settings panel visible but collapsed (accessible for token management)
if (settings) {
// Remove hidden class from panel itself - make it visible using both methods
settings.classList.remove('hidden');
settings.style.display = '';
// Always collapse the content when token is valid (user must click expand)
const tokenContent = document.getElementById('github-token-content');
if (tokenContent) {
// Collapse the content - add hidden, remove block, set display none
tokenContent.classList.add('hidden');
tokenContent.classList.remove('block');
tokenContent.style.display = 'none';
}
// Update collapse button state to show "Expand"
const tokenIconCollapse = document.getElementById('github-token-icon-collapse');
if (tokenIconCollapse) {
tokenIconCollapse.classList.remove('fa-chevron-up');
tokenIconCollapse.classList.add('fa-chevron-down');
}
const toggleTokenCollapseBtn = document.getElementById('toggle-github-token-collapse');
if (toggleTokenCollapseBtn) {
const span = toggleTokenCollapseBtn.querySelector('span');
if (span) span.textContent = 'Expand';
// Ensure event listener is attached
if (window.attachGithubTokenCollapseHandler) {
window.attachGithubTokenCollapseHandler();
}
}
}
// Clear dismissal flag when token becomes valid
sessionStorage.removeItem('github-auth-warning-dismissed');
}
}
})
.catch(error => {
console.error('Error checking GitHub auth status:', error);
console.error('Error stack:', error.stack || 'No stack trace');
});
};
(function() {
'use strict';
if (_PLUGIN_DEBUG_EARLY) debugLog('Plugin manager script starting...');
// Local variables for this instance
let installedPlugins = [];
window.currentPluginConfig = null;
let pluginStoreCache = null; // Cache for plugin store to speed up subsequent loads
let cacheTimestamp = null;
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds
let storeFilteredList = [];
function storeCacheExpired() {
return !cacheTimestamp || (Date.now() - cacheTimestamp >= CACHE_DURATION);
}
// ── 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) {
debugLog('Plugin page already initialized or initializing, skipping...');
return;
}
// Check if required elements exist
const installedGrid = document.getElementById('installed-plugins-grid');
if (!installedGrid) {
debugLog('Plugin elements not ready yet');
return false;
}
window.pluginManager.initializing = true;
window.__pluginDomReady = true;
// Check GitHub auth status immediately (don't wait for full initialization)
// This can run in parallel with other initialization
if (window.checkGitHubAuthStatus) {
debugLog('[INIT] Checking GitHub auth status immediately...');
window.checkGitHubAuthStatus();
}
// If we fetched data before the DOM existed, render it now
if (window.__pendingInstalledPlugins) {
debugLog('[RENDER] Applying pending installed plugins data');
renderInstalledPlugins(window.__pendingInstalledPlugins);
window.__pendingInstalledPlugins = null;
}
if (window.__pendingStorePlugins) {
debugLog('[RENDER] Applying pending plugin store data');
pluginStoreCache = window.__pendingStorePlugins;
cacheTimestamp = Date.now();
window.__pendingStorePlugins = null;
applyStoreFiltersAndSort();
}
initializePlugins();
// Event listeners (remove old ones first to prevent duplicates)
const refreshBtn = document.getElementById('refresh-plugins-btn');
const updateAllBtn = document.getElementById('update-all-plugins-btn');
const restartBtn = document.getElementById('restart-display-btn');
const closeOnDemandModalBtn = document.getElementById('close-on-demand-modal');
const cancelOnDemandBtn = document.getElementById('cancel-on-demand');
const onDemandForm = document.getElementById('on-demand-form');
const onDemandModal = document.getElementById('on-demand-modal');
if (refreshBtn) {
refreshBtn.replaceWith(refreshBtn.cloneNode(true));
document.getElementById('refresh-plugins-btn').addEventListener('click', refreshPlugins);
}
if (updateAllBtn) {
updateAllBtn.replaceWith(updateAllBtn.cloneNode(true));
document.getElementById('update-all-plugins-btn').addEventListener('click', runUpdateAllPlugins);
}
if (restartBtn) {
restartBtn.replaceWith(restartBtn.cloneNode(true));
document.getElementById('restart-display-btn').addEventListener('click', restartDisplay);
}
// 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 (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')) {
debugLog('HTMX swap detected for plugins, initializing...');
// Reset all initialization flags so the fresh empty DOM gets populated
window.pluginManager.initialized = false;
window.pluginManager.initializing = false;
window.pluginManager._reswap = true; // signal: use cached store, don't re-fetch GitHub
pluginsInitialized = false;
initTimer = setTimeout(attemptInit, 100);
}
}, { once: false }); // Allow multiple swaps
})();
// Initialization guard to prevent multiple initializations
let pluginsInitialized = false;
function initializePlugins() {
debugLog('[initializePlugins] FUNCTION CALLED, pluginsInitialized:', pluginsInitialized);
// Guard against multiple initializations
if (pluginsInitialized) {
debugLog('[initializePlugins] Already initialized, skipping (but still setting up handlers)');
// Still set up handlers even if already initialized (in case page was HTMX swapped)
debugLog('[initializePlugins] Force setting up GitHub handlers anyway...');
if (typeof setupGitHubInstallHandlers === 'function') {
setupGitHubInstallHandlers();
} else {
console.error('[initializePlugins] setupGitHubInstallHandlers not found!');
}
return;
}
pluginsInitialized = true;
debugLog('[initializePlugins] Starting initialization...');
pluginLog('[INIT] Initializing plugins...');
// Check GitHub authentication status
debugLog('[INIT] Checking for checkGitHubAuthStatus function...', {
exists: typeof window.checkGitHubAuthStatus,
type: typeof window.checkGitHubAuthStatus
});
if (window.checkGitHubAuthStatus) {
debugLog('[INIT] Calling checkGitHubAuthStatus...');
try {
window.checkGitHubAuthStatus();
} catch (error) {
console.error('[INIT] Error calling checkGitHubAuthStatus:', error);
}
} else {
console.warn('[INIT] checkGitHubAuthStatus not available yet');
}
// Load both installed plugins and plugin store.
// On HTMX re-swaps with a still-warm cache, skip GitHub metadata to avoid
// re-hitting the API on every tab switch. If the cache TTL has expired even
// during a re-swap, fetch fresh data including GitHub commit/version info.
const isReswapWarm = !!window.pluginManager._reswap && !storeCacheExpired();
window.pluginManager._reswap = false;
// Fire both requests in parallel so the store doesn't wait for installed plugins.
// The store renders install/update badges using window.installedPlugins || [] so
// it works with an empty list. When installed plugins finish loading we do a
// lightweight re-render from the already-cached store data to refresh the badges.
searchPluginStore(!isReswapWarm);
loadInstalledPlugins()
.catch(err => console.error('[PluginStore] loadInstalledPlugins failed:', err))
.then(() => {
// Re-render store from cache to update install/update/reinstall badges now
// that window.installedPlugins is populated. No network call — instant.
if (typeof applyStoreFiltersAndSort === 'function') {
applyStoreFiltersAndSort(true);
}
});
// 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
debugLog('[initializePlugins] About to call setupGitHubInstallHandlers...');
if (typeof setupGitHubInstallHandlers === 'function') {
debugLog('[initializePlugins] setupGitHubInstallHandlers is a function, calling it...');
setupGitHubInstallHandlers();
debugLog('[initializePlugins] setupGitHubInstallHandlers called');
} else {
console.error('[initializePlugins] ERROR: setupGitHubInstallHandlers is not a function! Type:', typeof setupGitHubInstallHandlers);
}
// Setup collapsible section handlers
setupCollapsibleSections();
// Load saved repositories
loadSavedRepositories();
pluginLog('[INIT] Plugins initialized');
}
// Track in-flight requests to prevent duplicates
// ===== PLUGIN LOADING WITH REQUEST DEDUPLICATION & CACHING =====
// Prevents redundant API calls by caching results for a short time
const pluginLoadCache = {
promise: null, // Current in-flight request
data: null, // Cached plugin data
timestamp: 0, // When cache was last updated
TTL: 3000, // Cache valid for 3 seconds
isValid() {
return this.data && (Date.now() - this.timestamp < this.TTL);
},
invalidate() {
this.data = null;
this.timestamp = 0;
}
};
// Debug flag - set via safeLocalStorage.setItem('pluginDebug', 'true')
const PLUGIN_DEBUG = typeof localStorage !== 'undefined' && safeLocalStorage.getItem('pluginDebug') === 'true';
function pluginLog(...args) {
if (PLUGIN_DEBUG) debugLog(...args);
}
function loadInstalledPlugins(forceRefresh = false) {
// Return cached data if valid and not forcing refresh
if (!forceRefresh && pluginLoadCache.isValid()) {
pluginLog('[CACHE] Returning cached plugin data');
// Update window.installedPlugins from cache
window.installedPlugins = pluginLoadCache.data;
// Dispatch event to notify Alpine component
document.dispatchEvent(new CustomEvent('pluginsUpdated', {
detail: { plugins: pluginLoadCache.data }
}));
pluginLog('[CACHE] Dispatched pluginsUpdated event from cache');
// Still render to ensure UI is updated
renderInstalledPlugins(pluginLoadCache.data);
return Promise.resolve(pluginLoadCache.data);
}
// If a request is already in progress, return the existing promise
if (pluginLoadCache.promise) {
pluginLog('[CACHE] Request in progress, returning existing promise');
return pluginLoadCache.promise;
}
pluginLog('[FETCH] Loading installed plugins...');
// Use PluginAPI if available, otherwise fall back to direct fetch
const fetchPromise = (window.PluginAPI && window.PluginAPI.getInstalledPlugins) ?
window.PluginAPI.getInstalledPlugins().then(plugins => {
const pluginsArray = Array.isArray(plugins) ? plugins : [];
return { status: 'success', data: { plugins: pluginsArray } };
}) :
fetch('/api/v3/plugins/installed').then(response => response.json());
// Store the promise
pluginLoadCache.promise = fetchPromise
.then(data => {
if (data.status === 'success') {
const pluginsData = data.data?.plugins;
installedPlugins = Array.isArray(pluginsData) ? pluginsData : [];
// Update cache
pluginLoadCache.data = installedPlugins;
pluginLoadCache.timestamp = Date.now();
// Always update window.installedPlugins to ensure Alpine component can detect changes
window.installedPlugins = installedPlugins;
// Dispatch event to notify Alpine component to update tabs
document.dispatchEvent(new CustomEvent('pluginsUpdated', {
detail: { plugins: installedPlugins }
}));
pluginLog('[FETCH] Dispatched pluginsUpdated event with', installedPlugins.length, 'plugins');
pluginLog('[FETCH] Loaded', installedPlugins.length, 'plugins');
// Debug logging only when enabled
if (PLUGIN_DEBUG) {
installedPlugins.forEach(plugin => {
debugLog(`[DEBUG] Plugin ${plugin.id}: enabled=${plugin.enabled}`);
});
}
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');
}
}
}
// Remove skeleton cards before rendering real content
container.querySelectorAll('.installed-skeleton').forEach(el => el.remove());
if (plugins.length === 0) {
container.innerHTML = `
No plugins installed
Install plugins from the store to get started
`;
return;
}
// Helper function to escape values for use in HTML attributes
const escapeAttr = (text) => {
return (text || '')
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/'/g, ''')
.replace(//g, '>');
};
// Helper function to escape for JavaScript strings (use JSON.stringify for proper escaping)
// JSON.stringify returns a quoted string, so we can use it directly in JavaScript
const escapeJs = (text) => {
return JSON.stringify(text || '');
};
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) {
debugLog(`[DEBUG RENDER] Plugin ${plugin.id}: enabled=${enabledBool}`);
}
// Escape plugin ID for use in HTML attributes and JavaScript
const escapedPluginId = escapeAttr(plugin.id);
return `
`;
if (propSchema.type === 'boolean') {
const checked = propValue ? 'checked' : '';
// No name attribute - rely solely on _data field to prevent key leakage
itemHtml += ``;
} else {
// Escape HTML to prevent XSS
// No name attribute - rely solely on _data field to prevent key leakage
const escapedValue = typeof propValue === 'string' ? propValue.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''') : (propValue || '');
itemHtml += ``;
}
itemHtml += `
`;
});
itemHtml += `
`;
}
itemsContainer.insertAdjacentHTML('beforeend', itemHtml);
window.updateArrayObjectData(fieldId);
// Update add button state
const addButton = itemsContainer.nextElementSibling;
if (addButton && currentItems.length + 1 >= maxItems) {
addButton.disabled = true;
addButton.style.opacity = '0.5';
addButton.style.cursor = 'not-allowed';
}
};
window.removeArrayObjectItem = function(fieldId, index) {
const itemsContainer = document.getElementById(fieldId + '_items');
if (!itemsContainer) return;
const item = itemsContainer.querySelector(`.array-object-item[data-index="${index}"]`);
if (item) {
item.remove();
// Re-index remaining items
// Use data-index for index storage - no need to encode index in onclick strings or IDs
const remainingItems = itemsContainer.querySelectorAll('.array-object-item');
remainingItems.forEach((itemEl, newIndex) => {
itemEl.setAttribute('data-index', newIndex);
// Update all inputs within this item - only update index in array bracket notation
itemEl.querySelectorAll('input, select, textarea').forEach(input => {
const name = input.getAttribute('name');
const id = input.id;
if (name) {
// Only replace index in bracket notation like [0], [1], etc.
// Match pattern: field_name[index] but not field_name123
const newName = name.replace(/\[(\d+)\]/, `[${newIndex}]`);
input.setAttribute('name', newName);
}
if (id) {
// Only update index in specific patterns like _item_0, _item_1
// Match pattern: _item_ but be careful not to break other numeric IDs
const newId = id.replace(/_item_(\d+)/, `_item_${newIndex}`);
input.id = newId;
}
});
// Update button onclick attributes - only update the index parameter
// Since we use data-index for tracking, we can compute index from closest('.array-object-item')
// For now, update onclick strings but be more careful with the regex
itemEl.querySelectorAll('button[onclick]').forEach(button => {
const onclick = button.getAttribute('onclick');
if (onclick) {
// Match patterns like:
// removeArrayObjectItem('fieldId', 0)
// handleArrayObjectFileUpload(event, 'fieldId', 0, 'propKey', 'pluginId')
// removeArrayObjectFile('fieldId', 0, 'propKey')
// Only replace the numeric index parameter (second or third argument depending on function)
let newOnclick = onclick;
// For removeArrayObjectItem('fieldId', index) - second param
newOnclick = newOnclick.replace(
/removeArrayObjectItem\s*\(\s*['"]([^'"]+)['"]\s*,\s*\d+\s*\)/g,
`removeArrayObjectItem('$1', ${newIndex})`
);
// For handleArrayObjectFileUpload(event, 'fieldId', index, ...) - third param
newOnclick = newOnclick.replace(
/handleArrayObjectFileUpload\s*\(\s*event\s*,\s*['"]([^'"]+)['"]\s*,\s*\d+\s*,/g,
`handleArrayObjectFileUpload(event, '$1', ${newIndex},`
);
// For removeArrayObjectFile('fieldId', index, ...) - second param
newOnclick = newOnclick.replace(
/removeArrayObjectFile\s*\(\s*['"]([^'"]+)['"]\s*,\s*\d+\s*,/g,
`removeArrayObjectFile('$1', ${newIndex},`
);
button.setAttribute('onclick', newOnclick);
}
});
});
window.updateArrayObjectData(fieldId);
// Update add button state
const addButton = itemsContainer.nextElementSibling;
if (addButton && addButton.getAttribute('onclick')) {
// Extract maxItems from onclick attribute more safely
// Pattern: addArrayObjectItem('fieldId', 'fullKey', maxItems)
const onclickMatch = addButton.getAttribute('onclick').match(/addArrayObjectItem\s*\([^,]+,\s*[^,]+,\s*(\d+)\)/);
if (onclickMatch && onclickMatch[1]) {
const maxItems = parseInt(onclickMatch[1]);
if (remainingItems.length < maxItems) {
addButton.disabled = false;
addButton.style.opacity = '1';
addButton.style.cursor = 'pointer';
}
}
}
}
};
// updateArrayObjectData is defined earlier in the file (line ~3596)
// Only define stub if it doesn't already exist (defensive fallback)
if (typeof window.updateArrayObjectData === 'undefined') {
window.updateArrayObjectData = function(fieldId) {
console.warn('updateArrayObjectData stub called - implementation should be defined earlier');
};
}
window.updateCheckboxGroupData = function(fieldId) {
// Update hidden _data input with currently checked values
const hiddenInput = document.getElementById(fieldId + '_data');
if (!hiddenInput) return;
const checkboxes = document.querySelectorAll(`input[type="checkbox"][data-checkbox-group="${fieldId}"]`);
const selectedValues = [];
checkboxes.forEach(checkbox => {
if (checkbox.checked) {
const optionValue = checkbox.getAttribute('data-option-value') || checkbox.value;
selectedValues.push(optionValue);
}
});
hiddenInput.value = JSON.stringify(selectedValues);
};
// handleArrayObjectFileUpload and removeArrayObjectFile are defined earlier in the file
// Only define stubs if they don't already exist (defensive fallback)
if (typeof window.handleArrayObjectFileUpload === 'undefined') {
window.handleArrayObjectFileUpload = function(event, fieldId, itemIndex, propKey, pluginId) {
console.warn('handleArrayObjectFileUpload stub called - implementation should be defined earlier');
window.updateArrayObjectData(fieldId);
};
}
if (typeof window.removeArrayObjectFile === 'undefined') {
window.removeArrayObjectFile = function(fieldId, itemIndex, propKey) {
console.warn('removeArrayObjectFile stub called - implementation should be defined earlier');
window.updateArrayObjectData(fieldId);
};
}
// Debug logging (only if pluginDebug is enabled)
if (_PLUGIN_DEBUG_EARLY) {
debugLog('[ARRAY-OBJECTS] Functions defined on window:', {
addArrayObjectItem: typeof window.addArrayObjectItem,
removeArrayObjectItem: typeof window.removeArrayObjectItem,
updateArrayObjectData: typeof window.updateArrayObjectData,
handleArrayObjectFileUpload: typeof window.handleArrayObjectFileUpload,
removeArrayObjectFile: typeof window.removeArrayObjectFile
});
}
}
// Make currentPluginConfig globally accessible (outside IIFE)
window.currentPluginConfig = null;
// Force initialization immediately when script loads (for HTMX swapped content)
debugLog('Plugins script loaded, checking for elements...');
// Ensure all functions are globally available (in case IIFE didn't expose them properly)
// These should already be set inside the IIFE, but this ensures they're available
if (typeof initializePluginPageWhenReady !== 'undefined') {
window.initializePluginPageWhenReady = initializePluginPageWhenReady;
}
if (typeof initializePlugins !== 'undefined') {
window.initializePlugins = initializePlugins;
}
if (typeof loadInstalledPlugins !== 'undefined') {
window.loadInstalledPlugins = loadInstalledPlugins;
}
if (typeof renderInstalledPlugins !== 'undefined') {
window.renderInstalledPlugins = renderInstalledPlugins;
}
// GitHub install handlers are now exposed inside the IIFE (see above).
// searchPluginStore is also exposed inside the IIFE after its definition.
// Verify critical functions are available
if (_PLUGIN_DEBUG_EARLY) {
debugLog('Plugin functions available:', {
configurePlugin: typeof window.configurePlugin,
togglePlugin: typeof window.togglePlugin,
initializePlugins: typeof window.initializePlugins,
loadInstalledPlugins: typeof window.loadInstalledPlugins,
searchPluginStore: typeof window.searchPluginStore
});
}
// Check GitHub auth status immediately if elements exist (don't wait for full initialization)
if (window.checkGitHubAuthStatus && document.getElementById('github-auth-warning')) {
debugLog('[EARLY] Checking GitHub auth status immediately on script load...');
window.checkGitHubAuthStatus();
}
// Initialize on-demand modal immediately since it's in base.html
if (typeof initializeOnDemandModal === 'function') {
// Run immediately and also after DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeOnDemandModal);
} else {
initializeOnDemandModal();
}
// Also try after a short delay to ensure elements are available
setTimeout(initializeOnDemandModal, 100);
}
setTimeout(function() {
const installedGrid = document.getElementById('installed-plugins-grid');
if (installedGrid) {
debugLog('Found installed-plugins-grid, forcing initialization...');
window.pluginManager.initialized = false;
if (typeof initializePluginPageWhenReady === 'function') {
initializePluginPageWhenReady();
} else if (typeof window.initPluginsPage === 'function') {
window.initPluginsPage();
}
} else {
debugLog('installed-plugins-grid not found yet, will retry via event listeners');
}
// Also try to attach install button handler after a delay (fallback).
// Only run if the install button element is already in the DOM (i.e. the
// plugins partial has been loaded); otherwise the htmx:afterSettle listener
// below handles it when the tab is first visited.
setTimeout(() => {
if (typeof window.attachInstallButtonHandler === 'function' &&
document.getElementById('install-plugin-from-url')) {
window.attachInstallButtonHandler();
}
}, 500);
}, 200);
// Re-run install button wiring after HTMX settles the plugins tab content.
// Guard with element check so it only fires when the plugins partial is in the DOM,
// preventing spurious warnings on other tab loads.
document.addEventListener('htmx:afterSettle', function() {
if (document.getElementById('install-plugin-from-url') &&
typeof window.attachInstallButtonHandler === 'function') {
window.attachInstallButtonHandler();
}
});
// ─── Starlark Apps Integration ──────────────────────────────────────────────
(function() {
'use strict';
let starlarkSectionVisible = false;
let starlarkFullCache = null; // All apps from server
let 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 = `