diff --git a/web_interface/static/v3/js/app-shell.js b/web_interface/static/v3/js/app-shell.js index 70aff559..6e367d5c 100644 --- a/web_interface/static/v3/js/app-shell.js +++ b/web_interface/static/v3/js/app-shell.js @@ -747,586 +747,6 @@ } }, - generateConfigForm(pluginId, config, schema, webUiActions = []) { - // Safety check - if schema/config not ready, return empty - if (!pluginId || !config) { - return '
Loading configuration...
'; - } - - // Only log once per plugin to avoid spam (Alpine.js may call this multiple times during rendering) - if (!this._configFormLogged || this._configFormLogged !== pluginId) { - debugLog('[DEBUG] generateConfigForm called for', pluginId, 'with', webUiActions?.length || 0, 'actions'); - // Debug: Check if image_config.images has x-widget in schema - if (schema && schema.properties && schema.properties.image_config) { - const imgConfig = schema.properties.image_config; - if (imgConfig.properties && imgConfig.properties.images) { - const imagesProp = imgConfig.properties.images; - debugLog('[DEBUG] Schema check - image_config.images:', { - type: imagesProp.type, - 'x-widget': imagesProp['x-widget'], - 'has x-widget': 'x-widget' in imagesProp, - keys: Object.keys(imagesProp) - }); - } - } - this._configFormLogged = pluginId; - } - if (!schema || !schema.properties) { - return this.generateSimpleConfigForm(config, webUiActions, pluginId); - } - - // Helper function to get schema property by full key path - const getSchemaProperty = (schemaObj, keyPath) => { - if (!schemaObj || !schemaObj.properties) return null; - const keys = keyPath.split('.'); - let current = schemaObj.properties; - for (let i = 0; i < keys.length; i++) { - const k = keys[i]; - if (!current || !current[k]) { - return null; - } - - const prop = current[k]; - // If this is the last key, return the property - if (i === keys.length - 1) { - return prop; - } - - // If this property has nested properties, navigate deeper - if (prop && typeof prop === 'object' && prop.properties) { - current = prop.properties; - } else { - // Can't navigate deeper - return null; - } - } - return null; - }; - - const 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: Log property structure for arrays to help diagnose file-upload widget issues - if (prop.type === 'array') { - // Also check schema directly as fallback - const schemaProp = getSchemaProperty(schema, fullKey); - const xWidgetFromSchema = schemaProp ? (schemaProp['x-widget'] || schemaProp['x_widget']) : null; - - debugLog('[DEBUG generateFieldHtml] Array property:', fullKey, { - 'prop.x-widget': prop['x-widget'], - 'prop.x_widget': prop['x_widget'], - 'schema.x-widget': xWidgetFromSchema, - 'hasOwnProperty(x-widget)': prop.hasOwnProperty('x-widget'), - 'x-widget in prop': 'x-widget' in prop, - 'all prop keys': Object.keys(prop), - 'schemaProp keys': schemaProp ? Object.keys(schemaProp) : 'null' - }); - } - - // Handle nested objects - 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 - if (nestingDepth > 0) { - html += `
`; - } - - return html; - } - - // Regular (non-nested) field - html += `
`; - html += ``; - - if (description) { - html += `

${description}

`; - } - - // Generate appropriate input based on type - if (prop.type === 'boolean') { - html += ``; - } else if (prop.type === 'number' || prop.type === 'integer' || - (Array.isArray(prop.type) && (prop.type.includes('number') || prop.type.includes('integer')))) { - // Handle union types like ["integer", "null"] - const isUnionType = Array.isArray(prop.type); - const allowsNull = isUnionType && prop.type.includes('null'); - const isInteger = prop.type === 'integer' || (isUnionType && prop.type.includes('integer')); - const isNumber = prop.type === 'number' || (isUnionType && prop.type.includes('number')); - const min = prop.minimum !== undefined ? `min="${prop.minimum}"` : ''; - const max = prop.maximum !== undefined ? `max="${prop.maximum}"` : ''; - const step = isInteger ? 'step="1"' : 'step="any"'; - - // For union types with null, don't show default if value is null (leave empty) - // This allows users to explicitly set null by leaving it empty - let fieldValue = ''; - if (value !== undefined && value !== null) { - fieldValue = value; - } else if (!allowsNull && prop.default !== undefined) { - // Only use default if null is not allowed - fieldValue = prop.default; - } - - // Ensure value respects min/max constraints - 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; - } - } - } - - // Add placeholder/help text for null-able fields - const placeholder = allowsNull ? 'Leave empty to use current time (random)' : ''; - const helpText = allowsNull && description && description.includes('null') ? - `

${description}

` : ''; - - html += ``; - if (helpText) { - html += helpText; - } - } else if (prop.type === 'array') { - // AGGRESSIVE file upload widget detection - // For 'images' field in static-image plugin, always check schema directly - let isFileUpload = false; - let uploadConfig = {}; - - // Direct check: if this is the 'images' field and schema has it with x-widget - if (fullKey === 'images' && schema && schema.properties && schema.properties.images) { - const imagesSchema = schema.properties.images; - if (imagesSchema['x-widget'] === 'file-upload' || imagesSchema['x_widget'] === 'file-upload') { - isFileUpload = true; - uploadConfig = imagesSchema['x-upload-config'] || imagesSchema['x_upload_config'] || {}; - debugLog('[DEBUG] ✅ Direct detection: images field has file-upload widget', uploadConfig); - } - } - - // Fallback: check prop object (should have x-widget if schema loaded correctly) - if (!isFileUpload) { - const xWidgetFromProp = prop['x-widget'] || prop['x_widget'] || prop.xWidget; - if (xWidgetFromProp === 'file-upload') { - isFileUpload = true; - uploadConfig = prop['x-upload-config'] || prop['x_upload_config'] || {}; - debugLog('[DEBUG] ✅ Detection via prop object'); - } - } - - // Fallback: schema property lookup - if (!isFileUpload) { - let schemaProp = getSchemaProperty(schema, fullKey); - if (!schemaProp && fullKey === 'images' && schema && schema.properties && schema.properties.images) { - schemaProp = schema.properties.images; - } - const xWidgetFromSchema = schemaProp ? (schemaProp['x-widget'] || schemaProp['x_widget']) : null; - if (xWidgetFromSchema === 'file-upload') { - isFileUpload = true; - uploadConfig = schemaProp['x-upload-config'] || schemaProp['x_upload_config'] || {}; - debugLog('[DEBUG] ✅ Detection via schema lookup'); - } - } - - // Debug logging for ALL array fields to diagnose - debugLog('[DEBUG] Array field check:', fullKey, { - 'isFileUpload': isFileUpload, - 'prop keys': Object.keys(prop), - 'prop.x-widget': prop['x-widget'], - 'schema.properties.images exists': !!(schema && schema.properties && schema.properties.images), - 'schema.properties.images.x-widget': (schema && schema.properties && schema.properties.images) ? schema.properties.images['x-widget'] : null, - 'uploadConfig': uploadConfig - }); - - if (isFileUpload) { - debugLog('[DEBUG] ✅ Rendering file-upload widget for', fullKey, 'with config:', uploadConfig); - // Use the file upload widget from plugins.html - // We'll need to call a function that exists in the global scope - const maxFiles = uploadConfig.max_files || 10; - const allowedTypes = uploadConfig.allowed_types || ['image/png', 'image/jpeg', 'image/bmp', 'image/gif']; - const maxSizeMB = uploadConfig.max_size_mb || 5; - - const currentImages = Array.isArray(value) ? value : []; - const fieldId = fullKey.replace(/\./g, '_'); - const safePluginId = (uploadConfig.plugin_id || pluginId || 'static-image').toString().replace(/[^a-zA-Z0-9_-]/g, '_'); - - html += ` -
- -
- - -

Drag and drop images here or click to browse

-

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

-
- - -
- ${currentImages.map((img, idx) => { - const imgSchedule = img.schedule || {}; - const hasSchedule = imgSchedule.enabled && imgSchedule.mode && imgSchedule.mode !== 'always'; - let scheduleSummary = 'Always shown'; - if (hasSchedule && window.getScheduleSummary) { - try { - scheduleSummary = window.getScheduleSummary(imgSchedule) || 'Scheduled'; - } catch (e) { - scheduleSummary = 'Scheduled'; - } - } else if (hasSchedule) { - scheduleSummary = 'Scheduled'; - } - // Escape the summary for HTML - scheduleSummary = String(scheduleSummary).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); - - return ` -
-
-
- ${(img.filename || '').replace(/ - -
-

${String(img.original_filename || img.filename || 'Image').replace(/&/g, '&').replace(//g, '>')}

-

${img.size ? (Math.round(img.size / 1024) + ' KB') : ''} • ${(img.uploaded_at || '').replace(/&/g, '&')}

-

- ${scheduleSummary} -

-
-
-
- - -
-
- - -
- `; - }).join('')} -
- - - -
- `; - } else { - // Regular array input - const arrayValue = Array.isArray(value) ? value.join(', ') : ''; - html += ``; - html += `

Enter values separated by commas

`; - } - } else if (prop.enum) { - html += ``; - } else if (prop.type === 'string' && prop['x-widget'] === 'file-upload') { - // File upload widget for string fields (e.g., credentials.json) - const uploadConfig = prop['x-upload-config'] || {}; - const uploadEndpoint = uploadConfig.upload_endpoint || '/api/v3/plugins/assets/upload'; - const maxSizeMB = uploadConfig.max_size_mb || 1; - const allowedExtensions = uploadConfig.allowed_extensions || ['.json']; - const targetFilename = uploadConfig.target_filename || 'file.json'; - const fieldId = fullKey.replace(/\./g, '_'); - const hasFile = value && value !== ''; - - html += ` -
-
- - -

- ${hasFile ? `Current file: ${value}` : 'Click to upload ' + targetFilename} -

-

Max ${maxSizeMB}MB (${allowedExtensions.join(', ')})

-
- -
- `; - } else { - // Default to text input - const maxLength = prop.maxLength || ''; - const maxLengthAttr = maxLength ? `maxlength="${maxLength}"` : ''; - html += ``; - } - - html += `
`; - return html; - }; - - let formHtml = ''; - // 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; - // Use config value if key exists and is not null/undefined, otherwise use schema default - // Check if key exists in config and value is not null/undefined - const hasValue = key in config && config[key] !== null && config[key] !== undefined; - // For nested objects, if the value is an empty object, still use it (don't fall back to default) - const isNestedObject = prop.type === 'object' && prop.properties; - const value = hasValue ? config[key] : - (prop.default !== undefined ? prop.default : - (isNestedObject ? {} : (prop.type === 'array' ? [] : (prop.type === 'boolean' ? false : '')))); - formHtml += generateFieldHtml(key, prop, value); - }); - - // Add web UI actions section if plugin defines any - if (webUiActions && webUiActions.length > 0) { - debugLog('[DEBUG] Rendering', webUiActions.length, 'actions in tab form'); - - // 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' } - }; - - 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'; - const colors = colorMap[bgColor] || colorMap['blue']; - // Ensure pluginId is valid for template interpolation - const safePluginId = pluginId || ''; - - formHtml += ` -
-
-
-

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

-

${action.description || ''}

-
- -
- -
- `; - }); - - formHtml += ` -
-
- `; - } - - return formHtml; - }, - - generateSimpleConfigForm(config, webUiActions = [], pluginId = '') { - let actionsHtml = ''; - if (webUiActions && webUiActions.length > 0) { - 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' } - }; - - actionsHtml = ` -
-

Actions

-
- `; - 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']; - // Ensure pluginId is valid for template interpolation - const safePluginId = pluginId || ''; - actionsHtml += ` -
-
-
-

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

-

${action.description || ''}

-
- -
- -
- `; - }); - actionsHtml += ` -
-
- `; - } - - return ` -
- - -

How long to show this plugin's content

-
- ${actionsHtml} - `; - }, - // Helper function to get schema property type for a field path getSchemaPropertyType(schema, path) { if (!schema || !schema.properties) return null; diff --git a/web_interface/static/v3/js/plugins/config_manager.js b/web_interface/static/v3/js/plugins/config_manager.js deleted file mode 100644 index 0182bd16..00000000 --- a/web_interface/static/v3/js/plugins/config_manager.js +++ /dev/null @@ -1,133 +0,0 @@ -/** - * Plugin configuration form management. - * - * Handles configuration form generation, validation, and submission. - */ - -const PluginConfigManager = { - /** - * Current plugin configuration state. - */ - currentState: { - pluginId: null, - config: null, - schema: null, - jsonEditor: null - }, - - /** - * Initialize configuration for a plugin. - * - * @param {string} pluginId - Plugin identifier - * @returns {Promise} Configuration and schema - */ - async initialize(pluginId) { - try { - const [config, schema] = await Promise.all([ - window.PluginAPI.getPluginConfig(pluginId), - window.PluginAPI.getPluginSchema(pluginId) - ]); - - this.currentState = { - pluginId, - config, - schema, - jsonEditor: null - }; - - return { config, schema }; - } catch (error) { - if (window.errorHandler) { - window.errorHandler.displayError(error, `Failed to initialize config for ${pluginId}`); - } - throw error; - } - }, - - /** - * Reset configuration to defaults. - * - * @param {string} pluginId - Plugin identifier - * @returns {Promise} Default configuration - */ - async resetToDefaults(pluginId) { - try { - const result = await window.PluginAPI.resetPluginConfig(pluginId); - - // Reload configuration - if (this.currentState.pluginId === pluginId) { - await this.initialize(pluginId); - } - - return result; - } catch (error) { - if (window.errorHandler) { - window.errorHandler.displayError(error, `Failed to reset config for ${pluginId}`); - } - throw error; - } - }, - - /** - * Save configuration. - * - * @param {string} pluginId - Plugin identifier - * @param {Object} config - Configuration data - * @returns {Promise} Save result - */ - async save(pluginId, config) { - try { - const result = await window.PluginAPI.savePluginConfig(pluginId, config); - - // Update local state - if (this.currentState.pluginId === pluginId) { - this.currentState.config = config; - } - - return result; - } catch (error) { - if (window.errorHandler) { - window.errorHandler.displayError(error, `Failed to save config for ${pluginId}`); - } - throw error; - } - }, - - /** - * Validate configuration against schema. - * - * @param {Object} config - Configuration data - * @param {Object} schema - JSON schema - * @returns {Object} Validation result with errors - */ - validate(config, schema) { - // Basic validation - full validation happens on server - const errors = []; - - if (!schema || !schema.properties) { - return { valid: true, errors: [] }; - } - - // Check required fields - if (schema.required) { - for (const field of schema.required) { - if (!(field in config)) { - errors.push(`Required field '${field}' is missing`); - } - } - } - - return { - valid: errors.length === 0, - errors: errors - }; - } -}; - -// Export -if (typeof module !== 'undefined' && module.exports) { - module.exports = PluginConfigManager; -} else { - window.PluginConfigManager = PluginConfigManager; -} - diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index 87cd0a34..33db68c3 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -972,7 +972,6 @@ -