Spaces:
Build error
Build error
| /** | |
| * Backend Plugins Integration Plugin | |
| * | |
| * Discovers and executes backend plugins through a toolbar dropdown. | |
| * Organizes plugins by category and manages execution UI. | |
| */ | |
| import { Plugin } from '../modules/plugin-base.js'; | |
| import ep from '../extension-points.js'; | |
| import { notify } from '../modules/sl-utils.js'; | |
| import { PluginSandbox } from '../modules/backend-plugin-sandbox.js'; | |
| import { registerTemplate, createSingleFromTemplate } from '../modules/ui-system.js'; | |
| /** | |
| * @import { ApplicationState } from '../state.js' | |
| * @import { PluginContext } from '../modules/plugin-context.js' | |
| * @import { BackendPlugin } from './client.js' | |
| * @import { SlDialog } from '../ui.js' | |
| * @import { pluginResultDialogPart } from '../templates/backend-plugins-result-dialog.types.js' | |
| */ | |
| // Register templates | |
| await registerTemplate('backend-plugins-result-dialog', 'backend-plugins-result-dialog.html'); | |
| await registerTemplate('backend-plugins-dropdown', 'backend-plugins-dropdown.html'); | |
| export class BackendPluginsPlugin extends Plugin { | |
| /** | |
| * Constructor | |
| * @param {PluginContext} context | |
| */ | |
| constructor(context) { | |
| super(context, { | |
| name: 'backend-plugins', | |
| deps: ['client', 'extraction', 'tools'] | |
| }); | |
| /** @type {BackendPlugin[]} */ | |
| this.plugins = []; | |
| /** @type {PluginSandbox|null} */ | |
| this.pluginSandbox = null; | |
| /** @type {HTMLElement|null} The split button added to the tools button group */ | |
| this._dropdown = null; | |
| } | |
| get #logger() { return this.getDependency('logger') } | |
| get #client() { return this.getDependency('client') } | |
| get #tools() { return this.getDependency('tools') } | |
| /** @type {SlDialog & pluginResultDialogPart} */ | |
| #dialogUi = null | |
| /** | |
| * Installs the plugin | |
| * @param {ApplicationState} initialState | |
| */ | |
| async install(initialState) { | |
| await super.install(initialState); | |
| const dialogElement = createSingleFromTemplate('backend-plugins-result-dialog', document.body) | |
| this.#dialogUi = this.createUi(dialogElement) | |
| // Create plugin sandbox once | |
| this.pluginSandbox = new PluginSandbox(this.context, this.#dialogUi); | |
| window.pluginSandbox = this.pluginSandbox; | |
| this.#dialogUi.closeBtn.addEventListener('click', () => this.#dialogUi.hide()); | |
| // Clean up SSE subscriptions when dialog is hidden — only for the iframe, | |
| // not for popup windows that may still be using their subscriptions | |
| this.#dialogUi.addEventListener('sl-hide', () => { | |
| const iframe = this.#dialogUi.content.querySelector('iframe'); | |
| if (iframe?.contentWindow) { | |
| this.pluginSandbox._cleanupSSESubscriptions(iframe.contentWindow); | |
| } | |
| }); | |
| } | |
| async start() { | |
| // Create the backend-plugins split button and add it to the tools button group | |
| this._dropdown = createSingleFromTemplate('backend-plugins-dropdown'); | |
| this.#tools.addButton(this._dropdown); | |
| // Attach menu selection handler once on this plugin's own menu | |
| this._dropdown.querySelector('[name="pluginsMenu"]').addEventListener('sl-select', (event) => { | |
| this.handlePluginSelection(event); | |
| }); | |
| // Discover available plugins and populate menu | |
| await this.discoverPlugins(); | |
| this._updateDropdownVisibility(); | |
| if (this.plugins.length > 0) { | |
| this.populateMenu(); | |
| } | |
| } | |
| async onSessionIdChange() { | |
| await this.discoverPlugins(); | |
| this._updateDropdownVisibility(); | |
| this.populateMenu(); | |
| } | |
| /** | |
| * Show the split button when backend plugins exist; hide it otherwise. | |
| * Notifies the tools group to recalculate its own visibility. | |
| */ | |
| _updateDropdownVisibility() { | |
| if (!this._dropdown) return; | |
| this._dropdown.style.display = this.plugins.length > 0 ? '' : 'none'; | |
| this.#tools.updateVisibility(); | |
| } | |
| /** | |
| * Discover available backend plugins from the server | |
| */ | |
| async discoverPlugins() { | |
| try { | |
| this.plugins = await this.#client.getBackendPlugins(); | |
| } catch (error) { | |
| console.error('Failed to discover backend plugins:', error); | |
| this.plugins = []; | |
| } | |
| } | |
| /** | |
| * Populate the backend plugins dropdown with endpoints organized by category. | |
| */ | |
| populateMenu() { | |
| const pluginsMenu = this._dropdown.querySelector('[name="pluginsMenu"]'); | |
| pluginsMenu.innerHTML = ''; | |
| const endpointsByCategory = this.groupEndpointsByCategory(); | |
| const categories = Object.keys(endpointsByCategory).sort(); | |
| categories.forEach((category, index) => { | |
| const items = endpointsByCategory[category].map(({ plugin, endpoint }) => { | |
| const menuItem = document.createElement('sl-menu-item'); | |
| menuItem.dataset.pluginId = plugin.id; | |
| menuItem.dataset.endpointName = endpoint.name; | |
| menuItem.dataset.stateParams = JSON.stringify(endpoint.state_params); | |
| menuItem.textContent = endpoint.label; | |
| if (endpoint.description) { | |
| menuItem.title = endpoint.description; | |
| } | |
| return menuItem; | |
| }); | |
| if (items.length === 0) { | |
| return; | |
| } | |
| const categoryLabel = document.createElement('small'); | |
| categoryLabel.textContent = this.formatCategoryName(category); | |
| pluginsMenu.appendChild(categoryLabel); | |
| items.forEach(item => pluginsMenu.appendChild(item)); | |
| if (index < categories.length - 1) { | |
| pluginsMenu.appendChild(document.createElement('sl-divider')); | |
| } | |
| }); | |
| } | |
| /** | |
| * Group all plugin endpoints by their effective category. | |
| * An endpoint can override the plugin-level category via its own `category` field. | |
| * @returns {Record<string, Array<{plugin: BackendPlugin, endpoint: object}>>} | |
| */ | |
| groupEndpointsByCategory() { | |
| const grouped = {}; | |
| this.plugins.forEach(plugin => { | |
| const endpoints = plugin.endpoints || []; | |
| endpoints.forEach(endpoint => { | |
| const category = endpoint.category || plugin.category || 'other'; | |
| if (!grouped[category]) { | |
| grouped[category] = []; | |
| } | |
| grouped[category].push({ plugin, endpoint }); | |
| }); | |
| }); | |
| // Sort entries within each category by label | |
| Object.keys(grouped).forEach(category => { | |
| grouped[category].sort((a, b) => a.endpoint.label.localeCompare(b.endpoint.label)); | |
| }); | |
| return grouped; | |
| } | |
| /** | |
| * Format category name for display | |
| * @param {string} category | |
| * @returns {string} | |
| */ | |
| formatCategoryName(category) { | |
| // Capitalize first letter and replace hyphens with spaces | |
| return category | |
| .split('-') | |
| .map(word => word.charAt(0).toUpperCase() + word.slice(1)) | |
| .join(' '); | |
| } | |
| /** | |
| * Call a plugin API endpoint with authentication | |
| * @param {string} endpoint - Plugin endpoint path (e.g., '/api/plugins/my-plugin/custom') | |
| * @param {string} method - HTTP method (GET, POST, etc.) | |
| * @param {object|null} params - Query params (for GET) or request body (for POST/PUT/etc) | |
| * @returns {Promise<Response>} Fetch Response object | |
| */ | |
| async callPluginApi(endpoint, method = 'GET', params = null) { | |
| const url = new URL(endpoint, window.location.origin); | |
| const options = { | |
| method, | |
| headers: { | |
| 'X-Session-ID': this.state.sessionId || '', | |
| } | |
| }; | |
| // Handle params based on method | |
| if (params) { | |
| if (method === 'GET') { | |
| // Add as query parameters | |
| Object.entries(params).forEach(([key, value]) => { | |
| if (value !== null && value !== undefined) { | |
| url.searchParams.append(key, String(value)); | |
| } | |
| }); | |
| } else { | |
| // Add as JSON body for POST/PUT/etc | |
| options.headers['Content-Type'] = 'application/json'; | |
| options.body = JSON.stringify(params); | |
| } | |
| } | |
| const response = await fetch(url.toString(), options); | |
| if (!response.ok) { | |
| const errorText = await response.text(); | |
| throw new Error(`Plugin API error (${response.status}): ${errorText}`); | |
| } | |
| return response; | |
| } | |
| /** | |
| * Handle plugin selection from dropdown | |
| * @param {CustomEvent} event | |
| */ | |
| async handlePluginSelection(event) { | |
| const menuItem = event.detail.item; | |
| const pluginId = menuItem.dataset.pluginId; | |
| const endpointName = menuItem.dataset.endpointName; | |
| const stateParams = JSON.parse(menuItem.dataset.stateParams || '[]'); | |
| if (!pluginId || !endpointName) { | |
| return; | |
| } | |
| // Find the plugin | |
| const plugin = this.plugins.find(p => p.id === pluginId); | |
| if (!plugin) { | |
| notify('Plugin not found', 'danger', 'exclamation-octagon'); | |
| return; | |
| } | |
| // Extract required state values | |
| const params = {}; | |
| stateParams.forEach(param => { | |
| if (this.state[param] !== undefined) { | |
| params[param] = this.state[param]; | |
| } else { | |
| console.warn(`Required state parameter '${param}' not available`); | |
| } | |
| }); | |
| // Execute the plugin with the specified endpoint | |
| await this.executePlugin(plugin, endpointName, params); | |
| } | |
| /** | |
| * Execute a backend plugin | |
| * @param {BackendPlugin} plugin | |
| * @param {string} endpointName - Endpoint to execute | |
| * @param {Record<string, any>} params - Parameters to pass to the endpoint | |
| */ | |
| async executePlugin(plugin, endpointName, params) { | |
| try { | |
| // Execute the specified endpoint with provided params | |
| const result = await this.#client.executeBackendPlugin(plugin.id, endpointName, params); | |
| // Display the result according to content | |
| this.displayResult(plugin, result); | |
| } catch (error) { | |
| console.error(`Error executing plugin ${plugin.id}.${endpointName}:`, error); | |
| notify(`Failed to execute endopoint "${endpointName}" of plugin "${plugin.name}", : ${error.message}`, 'danger', 'exclamation-octagon'); | |
| } | |
| } | |
| /** | |
| * Display plugin result in an iframe | |
| * @param {BackendPlugin} plugin | |
| * @param {any} result - Must include outputUrl property | |
| */ | |
| displayResultInIframe(plugin, result) { | |
| this.#dialogUi.setAttribute("label", plugin.name); | |
| this.#dialogUi.style.setProperty("--width", "90vw"); | |
| this.#dialogUi.icon.innerHTML = ''; | |
| // Create iframe with authentication | |
| const iframe = document.createElement('iframe'); | |
| const outputUrl = new URL(result.outputUrl, window.location.origin); | |
| // Add session ID if not already in URL | |
| if (!outputUrl.searchParams.has('session_id') && this.state.sessionId) { | |
| outputUrl.searchParams.set('session_id', this.state.sessionId); | |
| } | |
| const fullUrl = outputUrl.toString(); | |
| iframe.src = fullUrl; | |
| iframe.style.width = '100%'; | |
| iframe.style.height = '60vh'; | |
| iframe.style.border = 'none'; | |
| this.#dialogUi.content.innerHTML = ''; | |
| this.#dialogUi.content.appendChild(iframe); | |
| // Replace button to remove stale listeners, then reassign property | |
| this.#dialogUi.openWindowBtn.style.display = 'inline-flex'; | |
| const newOpenWindowBtn = this.#dialogUi.openWindowBtn.cloneNode(true); | |
| this.#dialogUi.openWindowBtn.replaceWith(newOpenWindowBtn); | |
| this.#dialogUi.openWindowBtn = newOpenWindowBtn; | |
| this.#dialogUi.openWindowBtn.addEventListener('click', () => { | |
| const childWindow = window.open(fullUrl, '_blank', 'width=1200,height=800'); | |
| const closeChild = () => { | |
| if (childWindow && !childWindow.closed) { | |
| childWindow.close(); | |
| } | |
| }; | |
| window.addEventListener('beforeunload', closeChild); | |
| // Clean up iframe SSE subscriptions and remove iframe before closing dialog | |
| const currentIframe = this.#dialogUi.content.querySelector('iframe'); | |
| if (currentIframe?.contentWindow) { | |
| this.pluginSandbox._cleanupSSESubscriptions(currentIframe.contentWindow); | |
| } | |
| this.#dialogUi.content.innerHTML = ''; | |
| this.#dialogUi.hide(); | |
| }); | |
| this.configureExportButton(this.#dialogUi, plugin, result); | |
| this.configureExecuteButton(this.#dialogUi, result); | |
| this.#dialogUi.show(); | |
| } | |
| /** | |
| * Configure export button for plugin result | |
| * @param {SlDialog} dialog | |
| * @param {BackendPlugin} plugin | |
| * @param {any} result | |
| */ | |
| configureExportButton(dialog, plugin, result) { | |
| if (result.exportUrl || result.pdf) { | |
| dialog.exportBtn.style.display = 'inline-flex'; | |
| const newExportBtn = dialog.exportBtn.cloneNode(true); | |
| dialog.exportBtn.replaceWith(newExportBtn); | |
| dialog.exportBtn = newExportBtn; | |
| dialog.exportBtn.addEventListener('click', async () => { | |
| try { | |
| let exportUrl; | |
| if (result.exportUrl) { | |
| exportUrl = result.exportUrl; | |
| } else if (result.pdf) { | |
| const variant = result.variant || 'all'; | |
| const endpoint = `/api/plugins/${plugin.id}/export`; | |
| const params = { pdf: result.pdf, variant: variant }; | |
| const url = new URL(endpoint, window.location.origin); | |
| Object.entries(params).forEach(([key, value]) => { | |
| if (value !== null && value !== undefined) { | |
| url.searchParams.append(key, String(value)); | |
| } | |
| }); | |
| exportUrl = url.toString(); | |
| } | |
| const response = await fetch(exportUrl, { | |
| headers: { 'X-Session-ID': this.state.sessionId || '' } | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`Export failed: ${response.statusText}`); | |
| } | |
| const blob = await response.blob(); | |
| const contentDisposition = response.headers.get('Content-Disposition'); | |
| let filename = `${plugin.id}_export.csv`; | |
| if (contentDisposition) { | |
| const match = contentDisposition.match(/filename="?(.+?)"?$/); | |
| if (match) filename = match[1]; | |
| } | |
| const downloadUrl = window.URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = downloadUrl; | |
| a.download = filename; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| window.URL.revokeObjectURL(downloadUrl); | |
| notify('Export successful', 'success', 'check-circle'); | |
| } catch (error) { | |
| console.error('Export failed:', error); | |
| notify(`Export failed: ${error.message}`, 'danger', 'exclamation-octagon'); | |
| } | |
| }); | |
| } else { | |
| dialog.exportBtn.style.display = 'none'; | |
| } | |
| } | |
| /** | |
| * Configure execute button for plugin result | |
| * @param {SlDialog} dialog | |
| * @param {any} result | |
| */ | |
| configureExecuteButton(dialog, result) { | |
| if (result.executeUrl) { | |
| dialog.executeBtn.style.display = 'inline-flex'; | |
| const newExecuteBtn = dialog.executeBtn.cloneNode(true); | |
| dialog.executeBtn.replaceWith(newExecuteBtn); | |
| dialog.executeBtn = newExecuteBtn; | |
| dialog.executeBtn.addEventListener('click', async () => { | |
| try { | |
| // Build execute URL with authentication | |
| const executeUrl = new URL(result.executeUrl, window.location.origin); | |
| // Add session ID if not already in URL | |
| if (!executeUrl.searchParams.has('session_id') && this.state.sessionId) { | |
| executeUrl.searchParams.set('session_id', this.state.sessionId); | |
| } | |
| // Load execute result in the same iframe | |
| const iframe = dialog.content.querySelector('iframe'); | |
| if (iframe) { | |
| iframe.src = executeUrl.toString(); | |
| } | |
| // Hide execute button after clicking | |
| dialog.executeBtn.style.display = 'none'; | |
| notify('Executing...', 'primary', 'info-circle'); | |
| } catch (error) { | |
| console.error('Execute failed:', error); | |
| notify(`Execute failed: ${error.message}`, 'danger', 'exclamation-octagon'); | |
| } | |
| }); | |
| } else { | |
| dialog.executeBtn.style.display = 'none'; | |
| } | |
| } | |
| /** | |
| * Trigger a file download from a URL | |
| * @param {BackendPlugin} plugin | |
| * @param {string} downloadUrl - URL to download from | |
| */ | |
| async triggerDownload(plugin, downloadUrl) { | |
| try { | |
| const url = new URL(downloadUrl, window.location.origin); | |
| // Add session ID if not already in URL | |
| if (!url.searchParams.has('session_id') && this.state.sessionId) { | |
| url.searchParams.set('session_id', this.state.sessionId); | |
| } | |
| const response = await fetch(url.toString(), { | |
| headers: { 'X-Session-ID': this.state.sessionId || '' } | |
| }); | |
| if (!response.ok) { | |
| // Status 499 = cancelled by user, don't show error (notification already sent via SSE) | |
| if (response.status === 499) { | |
| this.#logger.info('Download cancelled by user'); | |
| return; | |
| } | |
| throw new Error(`Download failed: ${response.statusText}`); | |
| } | |
| const blob = await response.blob(); | |
| const contentDisposition = response.headers.get('Content-Disposition'); | |
| let filename = `${plugin.id}_download`; | |
| if (contentDisposition) { | |
| const match = contentDisposition.match(/filename="?(.+?)"?$/); | |
| if (match) filename = match[1]; | |
| } | |
| const blobUrl = window.URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = blobUrl; | |
| a.download = filename; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| window.URL.revokeObjectURL(blobUrl); | |
| notify('Download started', 'success', 'check-circle'); | |
| } catch (error) { | |
| console.error('Download failed:', error); | |
| notify(`Download failed: ${error.message}`, 'danger', 'exclamation-octagon'); | |
| } | |
| } | |
| /** | |
| * Display plugin execution result | |
| * @param {BackendPlugin} plugin | |
| * @param {any} result | |
| */ | |
| displayResult(plugin, result) { | |
| const dialog = this.#dialogUi; | |
| // Check if result signals a notification-only error (no dialog) | |
| if (result && result.error && result.notify) { | |
| notify(result.error, 'warning', 'exclamation-triangle'); | |
| return; | |
| } | |
| // Check if result contains downloadUrl for direct file download | |
| if (result && result.downloadUrl) { | |
| this.triggerDownload(plugin, result.downloadUrl); | |
| return; | |
| } | |
| // Check if result contains outputUrl for iframe rendering | |
| if (result && result.outputUrl) { | |
| this.displayResultInIframe(plugin, result); | |
| return; | |
| } | |
| // Check if result contains HTML content | |
| if (result && result.html) { | |
| // Display HTML in dialog with larger width for tables | |
| dialog.setAttribute("label", plugin.name); | |
| dialog.style.setProperty("--width", "80vw"); | |
| dialog.icon.innerHTML = ''; | |
| dialog.content.innerHTML = result.html; | |
| // Execute any scripts that were in the HTML (innerHTML doesn't execute them) | |
| const scripts = dialog.content.querySelectorAll('script'); | |
| scripts.forEach(oldScript => { | |
| const newScript = document.createElement('script'); | |
| // Copy attributes | |
| Array.from(oldScript.attributes).forEach(attr => { | |
| newScript.setAttribute(attr.name, attr.value); | |
| }); | |
| // Copy script content | |
| newScript.textContent = oldScript.textContent; | |
| // Replace old script with new one to trigger execution | |
| oldScript.parentNode.replaceChild(newScript, oldScript); | |
| }); | |
| dialog.openWindowBtn.style.display = 'none'; | |
| this.configureExportButton(dialog, plugin, result); | |
| dialog.executeBtn.style.display = 'none'; | |
| dialog.show(); | |
| } else if (result && result.error) { | |
| // Show error in dialog | |
| dialog.setAttribute("label", "Plugin Error"); | |
| dialog.style.setProperty("--width", "50vw"); | |
| dialog.icon.innerHTML = `<sl-icon name="exclamation-triangle" style="color: var(--sl-color-danger-500);"></sl-icon>`; | |
| dialog.content.innerHTML = `<p>${result.error}</p>`; | |
| dialog.openWindowBtn.style.display = 'none'; | |
| dialog.exportBtn.style.display = 'none'; | |
| dialog.executeBtn.style.display = 'none'; | |
| dialog.show(); | |
| } else { | |
| // Show JSON result in dialog | |
| const resultText = JSON.stringify(result, null, 2); | |
| dialog.setAttribute("label", plugin.name + " Result"); | |
| dialog.style.setProperty("--width", "50vw"); | |
| dialog.icon.innerHTML = `<sl-icon name="info-circle" style="color: var(--sl-color-primary-500);"></sl-icon>`; | |
| dialog.content.innerHTML = `<pre style="overflow: auto; max-height: 60vh;">${resultText}</pre>`; | |
| dialog.openWindowBtn.style.display = 'none'; | |
| dialog.exportBtn.style.display = 'none'; | |
| dialog.executeBtn.style.display = 'none'; | |
| dialog.show(); | |
| } | |
| } | |
| static extensionPoints = [ep.backendPlugins.execute]; | |
| /** | |
| * Extension point handler for `ep.backendPlugins.execute`. | |
| * Called by frontend sandbox extensions to trigger execution of a specific | |
| * backend plugin endpoint by ID. Delegates to {@link BackendPluginsPlugin#execute}. | |
| * @param {string} pluginId | |
| * @param {string} endpointName | |
| * @param {Object} params | |
| * @returns {Promise<void>} | |
| */ | |
| [ep.backendPlugins.execute](...args) { return this.execute(...args) } | |
| /** | |
| * Execute a backend plugin by ID. Exposed as an extension point so | |
| * frontend extensions can trigger plugin execution via sandbox.invoke(). | |
| * @param {string} pluginId | |
| * @param {string} endpointName | |
| * @param {Object} params | |
| */ | |
| async execute(pluginId, endpointName, params) { | |
| const plugin = this.plugins.find(p => p.id === pluginId); | |
| if (!plugin) { | |
| this.#logger.warn(`execute: plugin '${pluginId}' not found`); | |
| return; | |
| } | |
| await this.executePlugin(plugin, endpointName, params); | |
| } | |
| } | |
| export default BackendPluginsPlugin; | |