From d543b4a85d165e96fc8510de168dba3618c213ac Mon Sep 17 00:00:00 2001 From: Dmitry R Date: Sat, 21 Dec 2024 09:08:52 -0500 Subject: [PATCH] luci-app-file-plug-manager: Full featured file manager with plugins - minimal working set: file-plug-manager + Navigation plugin. All other plugins are optional - Directory Navigation and Traversal - File and Directory Operations - Bulk Selection and Management - drug'n'drop to/from local computer - drug'n'drop to router local folders - copy/move mode for drug'n'drop switching with 'Alt' key - Editing text files with find/replace functionality - Hex editor - Save/load settings - Dumb Terminal Signed-off-by: Dmitry R luci-app-file-plug-manager: Fixed application name and monospace fonts usage across plugins Signed-off-by: Dmitry R --- .../luci-app-file-plug-manager/Makefile | 13 + .../view/system/file-plug-manager.js | 903 ++++++ .../system/file-plug-manager/plugins/Edit+.js | 864 +++++ .../file-plug-manager/plugins/Navigation.js | 2848 +++++++++++++++++ .../file-plug-manager/plugins/Settings.js | 697 ++++ .../file-plug-manager/plugins/hexEditor.js | 1531 +++++++++ .../system/file-plug-manager/plugins/term.js | 354 ++ .../po/templates/file-plug-manager.pot | 531 +++ .../menu.d/luci-app-file-plug-manager.json | 13 + .../acl.d/luci-app-file-plug-manager.json | 14 + 10 files changed, 7768 insertions(+) create mode 100644 applications/luci-app-file-plug-manager/Makefile create mode 100644 applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager.js create mode 100644 applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Edit+.js create mode 100644 applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js create mode 100644 applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Settings.js create mode 100644 applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/hexEditor.js create mode 100644 applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/term.js create mode 100644 applications/luci-app-file-plug-manager/po/templates/file-plug-manager.pot create mode 100644 applications/luci-app-file-plug-manager/root/usr/share/luci/menu.d/luci-app-file-plug-manager.json create mode 100644 applications/luci-app-file-plug-manager/root/usr/share/rpcd/acl.d/luci-app-file-plug-manager.json diff --git a/applications/luci-app-file-plug-manager/Makefile b/applications/luci-app-file-plug-manager/Makefile new file mode 100644 index 00000000000..321a5f6549c --- /dev/null +++ b/applications/luci-app-file-plug-manager/Makefile @@ -0,0 +1,13 @@ +# This is free software, licensed under the Apache License, Version 2.0 . + +include $(TOPDIR)/rules.mk + +LUCI_TITLE:=LuCI File Plug Manager module +LUCI_DEPENDS:=+luci-base + +PKG_LICENSE:=Apache-2.0 +PKG_MAINTAINER:=Dmitry R + +include ../../luci.mk + +# call BuildPackage - OpenWrt buildroot signature diff --git a/applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager.js b/applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager.js new file mode 100644 index 00000000000..dfbbe839866 --- /dev/null +++ b/applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager.js @@ -0,0 +1,903 @@ +/*************************************** + * Main Dispatcher Plugin (main.js) + * This is the main (Dispatcher) plugin that: + * - Loads and manages other plugins + * - Sets default plugins and mark the default plugin tabs with а green button + * - Initializes plugins in the correct order + * - Integrates with the default Settings plugin + * - sends personalised events to plugins + * - provides pop() for messaging + * - All comments and messages are in English + ***************************************/ +'use strict'; +'require view'; +'require ui'; +'require fs'; +'require dom'; +'require form'; + +const PN = 'Main'; + + +return view.extend({ + // Unique identifier counter for plugins + pluginUniqueIdCounter: 1, + + // Default plugins storage + default_plugins: { + 'Editor': null, + 'Navigation': null, + 'Settings': null, + 'Help': null, + 'Utility': null + }, + + // Registry of loaded plugins + pluginsRegistry: {}, + + // Supported plugin types + supportedPluginTypes: ['Editor', 'Navigation', 'Settings', 'Help', 'Utility'], + defaultTabsOrder: ['Navigation', 'Editor', 'Settings', 'Utility', 'Help'], + defaultStartPlugin: 'Navigation', + + // References to UI containers + buttonsContainer: null, + contentsContainer: null, + logsContainer: null, // Added for logs container + infoContainer: null, // Added for informational messages + + screen_log: false, // Initialization of screen_log variable + box_log: false, // Initialization of box_log variable + + /** + * pop(title, children, type) + * Display notifications to the user. + */ + pop: function(title, children, type) { + // Get current time + var timestamp = new Date().toLocaleString(); + + // Create message with timestamp + var message = E('div', { + 'class': 'log-entry' + }, [ + E('span', { + 'class': 'log-timestamp' + }, `[${timestamp}] `), + typeof children === 'string' ? children : children.outerHTML + ]); + + // Add message to Logs + if (this.logsContainer) { + this.logsContainer.appendChild(message); + // Scroll to the bottom to show the latest message + this.logsContainer.scrollTop = this.logsContainer.scrollHeight; + } else { + console.error(`[${PN}]: Logs container not found. Unable to display log message.`); + } + + // If screen_log is enabled, duplicate the message via ui.addNotification + if (String(this.screen_log) === 'true') { + ui.addNotification(title, children, type); + } + // If box_log is true, display the message in the informational box + if (String(this.box_log) === 'true') { + this.displayInfoMessage(title, children, type); + } + + }, + + /** + * info() + * Return metadata about this plugin. + */ + info: function() { + return { + name: PN, // Unique name + type: 'Dispatcher', // Plugin type + description: 'Main dispatcher module' + }; + }, + + /** + * start(container, pluginsRegistry, default_plugins) + * Initialize the dispatcher if needed. + */ + start: function(container, pluginsRegistry, default_plugins) { + // Initialize screen_log and box_log from settings or default to false + const settings = this.get_settings(); + this.screen_log = settings.screen_log || false; + this.box_log = settings.box_log || false; + }, + + /** + * get_settings() + * Return current settings for the dispatcher. + */ + get_settings: function() { + return { + screen_log: this.screen_log || false, // Default value: false + box_log: this.box_log || false // Default value: false + }; + }, + + /** + * set_settings(settings) + * Apply settings to the dispatcher. + */ + set_settings: function(settings) { + if (typeof settings.screen_log !== 'undefined') { + this.screen_log = settings.screen_log; + } + if (typeof settings.box_log !== 'undefined') { + this.box_log = settings.box_log; + } + }, + + /** + * render() + * Render the main view, load plugins, and set up the UI. + * Changed this function to async to await s.render(). + */ + render: async function() { + var m, s, o; + // Create the JSONMap for form + m = new form.JSONMap({}, _('File Plug Manager')); + + // Create informational container + this.infoContainer = E('div', { + 'class': 'info-container' + }); + + // Create tabs container + var tabs = E('div', { + 'class': 'cbi-tabs' + }); + + // Create containers for tab buttons and contents + this.buttonsContainer = E('div', { + 'class': 'cbi-tabs-buttons' + }); + this.contentsContainer = E('div', { + 'class': 'cbi-tabs-contents' + }); + + tabs.appendChild(this.buttonsContainer); + tabs.appendChild(this.contentsContainer); + + // Create Logs tab first to ensure logsContainer is available + this.createLogsTab(); + + // Load plugins + this.loadPlugins(this.buttonsContainer, this.contentsContainer); + + // Determine current theme + var isDarkTheme = document.body.classList.contains('dark-theme'); + if (isDarkTheme) { + tabs.classList.add('dark-theme'); + } else { + tabs.classList.add('light-theme'); + } + + // Custom CSS for styling + var customCSS = ` + /* Tabs container */ + .cbi-tabs { + margin-top: 20px; + } + + /* Tab buttons container */ + .cbi-tabs-buttons { + display: flex; + border: 2px solid #0078d7; + border-radius: 5px; + background-color: #f9f9f9; + padding: 5px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + } + + .dark-theme .cbi-tabs-buttons { + border: 2px solid #555; + background-color: #333; + box-shadow: 0 2px 4px rgba(255, 255, 255, 0.1); + } + + .cbi-tab-button { + padding: 10px 20px; + cursor: pointer; + border: none; + background: none; + outline: none; + transition: background-color 0.3s, border-bottom 0.3s; + font-size: 16px; + margin-right: 5px; + position: relative; + user-select: none; + color: #000; + } + + .dark-theme .cbi-tab-button { + color: #fff; + } + + .cbi-tab-button:hover { + background-color: #e0e0e0; + } + + .dark-theme .cbi-tab-button:hover { + background-color: #444; + } + + .cbi-tab-button.active { + border-bottom: 3px solid #0078d7; + font-weight: bold; + background-color: #fff; + color: #000; + } + + .dark-theme .cbi-tab-button.active { + border-bottom: 3px solid #1e90ff; + background-color: #555; + color: #fff; + } + + .cbi-tabs-contents { + padding: 10px; + background-color: #fff; + color: #000; + } + + .dark-theme .cbi-tabs-contents { + background-color: #2a2a2a; + color: #ddd; + } + + .cbi-tab-content { + display: none; + } + + .cbi-tab-content.active { + display: block; + } + + .default-marker { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + margin-left: 10px; + cursor: pointer; + background-color: gray; + } + + .default-marker.active { + background-color: green; + } + + .default-marker.inactive { + background-color: gray; + } + + .dark-theme .navigation-plugin-table-container { + background-color: #222; + color: #fff; + } + + .dark-theme .navigation-plugin-table th { + background-color: #333; + color: #fff; + } + + .dark-theme .navigation-plugin-table td { + background-color: #222; + color: #fff; + } + + .dark-theme .navigation-plugin-table tr:hover { + background-color: #444; + } + + .dark-theme .navigation-plugin-table td.name a { + color: #1e90ff; + text-decoration: none; + } + + /* Logs Tab */ + .cbi-tab-button.logs-tab { + font-weight: bold; + } + + .logs-container { + max-height: 400px; + overflow-y: auto; + background-color: #f1f1f1; + padding: 10px; + border: 1px solid #ccc; + border-radius: 5px; + } + + .dark-theme .logs-container { + background-color: #1e1e1e; + color: #dcdcdc; + border-color: #555; + } + + .log-entry { + margin-bottom: 5px; + } + + .log-timestamp { + color: #888; + margin-right: 10px; + } + + /* Informational Container */ + .info-container { + margin-bottom: 20px; + padding: 10px; + border: 1px solid #0078d7; + border-radius: 5px; + background-color: #e7f3fe; + color: #31708f; + display: none; /* Hidden by default */ + opacity: 0; + transition: opacity 0.5s ease-in-out; + } + + .dark-theme .info-container { + background-color: #333; + border-color: #1e90ff; + color: #fff; + } + + /* Blinking Animation */ + @keyframes blink { + 0% { opacity: 1; } + 50% { opacity: 0; } + 100% { opacity: 1; } + } + + .blink { + animation: blink 1s step-start 5; + } + + `; + + var style = document.createElement('style'); + style.type = 'text/css'; + style.innerHTML = customCSS; + document.head.appendChild(style); + // Return the combined DOM + return E([], [ + m.title ? E('h3', {}, m.title) : null, + this.infoContainer, // Add infoContainer above tabs + tabs + ]); + }, + + /** + * createLogsTab() + * Create the Logs tab in the UI. + */ + createLogsTab: function() { + var self = this; + + // Create Logs tab button with a green marker + var logButton = E('button', { + 'class': 'cbi-tab-button logs-tab' + }, 'Logs'); + var marker = E('span', { + 'class': 'default-marker active', + 'title': _('Logs are always active') + }); + logButton.appendChild(marker); + + // Create Logs container + this.logsContainer = E('div', { + 'class': 'logs-container cbi-tab-content', + 'id': 'tab-Logs' + }); + + // Append button and container to respective parents + this.buttonsContainer.appendChild(logButton); + this.contentsContainer.appendChild(this.logsContainer); + + // Click handler for Logs tab + logButton.onclick = function() { + var allButtons = self.buttonsContainer.querySelectorAll('.cbi-tab-button'); + allButtons.forEach(function(btn) { + btn.classList.remove('active'); + }); + + var allContents = self.contentsContainer.querySelectorAll('.cbi-tab-content'); + allContents.forEach(function(content) { + content.classList.remove('active'); + }); + + logButton.classList.add('active'); + self.logsContainer.classList.add('active'); + }; + }, + + /** + * displayInfoMessage(title, message, type) + * Display a message in the informational box with a blinking effect. + * @param {string} title - The title of the message. + * @param {string|HTMLElement} message - The message content. + * @param {string} type - The type of message (e.g., 'success', 'error'). + */ + displayInfoMessage: function(title, message, type) { + // Clear any existing messages + this.infoContainer.innerHTML = ''; + + // Create message element + var msg = E('div', { + 'class': 'info-message' + }, [ + title ? E('strong', {}, title + ': ') : ' ', + typeof message === 'string' ? message : message.outerHTML + ]); + + // Append message to infoContainer + this.infoContainer.appendChild(msg); + + // Show the infoContainer + this.infoContainer.style.display = 'block'; + // Trigger reflow to restart CSS animation + void this.infoContainer.offsetWidth; + // Add the blink class + msg.classList.add('blink'); + + // Show with opacity + this.infoContainer.style.opacity = '1'; + + // After 5 seconds, remove the blink class and hide the message + setTimeout(() => { + msg.classList.remove('blink'); + // Fade out the infoContainer + + /*** + this.infoContainer.style.opacity = '0'; + // After transition, hide the container + setTimeout(() => { + this.infoContainer.style.display = 'none'; + this.infoContainer.innerHTML = ''; + }, 500); // Match the CSS transition duration + ***/ + }, 3000); // 3 seconds + }, + + + /** + * Activate a plugin tab by plugin name. + */ + activatePlugin: function(pluginName) { + var self = this; + var pluginButton = Array.from(self.buttonsContainer.querySelectorAll('.cbi-tab-button')) + .find(btn => btn.firstChild.textContent === pluginName); + + if (pluginButton) { + pluginButton.click(); + self.pop(null, `[${PN}]: ` + _('Plugin "%s" has been activated.').format(pluginName), 'success'); + } else { + self.pop(null, `[${PN}]: ` + _('Plugin "%s" not found.').format(pluginName), 'error'); + console.warn('Plugin not found for activation:', pluginName); + } + }, + + + /** + * Load plugins from directory, initialize them, and set defaults. + */ + loadPlugins: function(buttonsContainer, contentsContainer) { + var self = this; + + var dispatcherInfo = self.info(); + self.pluginsRegistry[dispatcherInfo.name] = self; + self.default_plugins['Dispatcher'] = dispatcherInfo.name; + + var pluginsPath = '/www/luci-static/resources/view/system/file-plug-manager/plugins/'; + + fs.exec('/bin/ls', [pluginsPath]).then(function(result) { + var pluginFiles = result.stdout.trim().split('\n'); + var pluginTypes = { + 'Editor': [], + 'Navigation': [], + 'Settings': [], + 'Help': [], + 'Utility': [] + }; + + var loadPromises = pluginFiles.map(function(file) { + if (file.endsWith('.js')) { + var pluginName = file.slice(0, -3); + + // Check for duplicate names + if (self.pluginsRegistry[pluginName]) { + self.pop(null, `[${PN}]: ` + _('Duplicate plugin name "%s" found. Skipping.').format(pluginName)); + console.warn('Duplicate plugin name:', pluginName); + return Promise.resolve(); + } + + return L.require('view.system.file-plug-manager.plugins.' + pluginName).then(function(plugin) { + // Validate required functions + if (typeof plugin.info !== 'function' || + typeof plugin.get_settings !== 'function' || + typeof plugin.set_settings !== 'function') { + self.pop(null, `[${PN}]: ` + _('Plugin "%s" is missing required functions. Skipping.').format(pluginName)); + console.warn('Plugin missing required functions:', pluginName); + return; + } + + var info = plugin.info(); + if (!info.name || !info.type) { + self.pop(null, `[${PN}]: ` + _('Plugin "%s" has invalid info. Skipping.').format(pluginName)); + console.warn('Plugin has invalid info:', pluginName); + return; + } + + if (!self.supportedPluginTypes.includes(info.type)) { + self.pop(null, `[${PN}]: ` + _('Plugin "%s" has unsupported type "%s". Skipping.').format(info.name, info.type)); + console.warn('Unsupported plugin type for plugin:', info.name); + return; + } + + if (info.type === 'Navigation') { + if (typeof plugin.read_file !== 'function' || + typeof plugin.write_file !== 'function') { + self.pop(null, `[${PN}]: ` + _('Navigation plugin "%s" is missing required functions. Skipping.').format(info.name)); + console.warn('Navigation plugin missing required functions:', info.name); + return; + } + } + + if (info.type === 'Settings') { + if (typeof plugin.read_settings !== 'function') { + self.pop(null, `[${PN}]: ` + _('Settings plugin "%s" is missing read_settings. Skipping.').format(info.name)); + console.warn('Settings plugin missing read_settings:', info.name); + return; + } + } + + // Register plugin + self.pluginsRegistry[info.name] = plugin; + pluginTypes[info.type].push(info.name); + + // Load plugin CSS if provided + // if (plugin.css) { + // self.loadCSS(plugin.css); + // } + }).catch(function(err) { + self.pop(null, `[${PN}]: ` + _('Error loading plugin "%s".').format(pluginName)); + console.error('Error loading plugin:', pluginName, err); + }); + } else { + // Non-JS file + self.pop(null, `[${PN}]: ` + _('Ignored non-JS file "%s" in plugins directory.').format(file)); + return Promise.resolve(); + } + }); + + Promise.all(loadPromises).then(function() { + self.setDefaultPlugins(pluginTypes); + + // Organize plugins according to defaultTabsOrder + self.defaultTabsOrder.forEach(function(type) { + var pluginsOfType = pluginTypes[type]; + if (!pluginsOfType || pluginsOfType.length === 0) { + return; + } + + // Ensure default plugin is first + var defaultPlugin = self.default_plugins[type]; + if (defaultPlugin && pluginsOfType.includes(defaultPlugin)) { + pluginsOfType.sort(function(a, b) { + if (a === defaultPlugin) return -1; + if (b === defaultPlugin) return 1; + return 0; + }); + } + + // Create tabs for each plugin in the sorted order + pluginsOfType.forEach(function(pluginName) { + var plugin = self.pluginsRegistry[pluginName]; + if (plugin) { + var info = plugin.info(); + self.createTab(buttonsContainer, contentsContainer, info, plugin); + } + }); + }); + + // Start all plugins except Settings and Dispatcher + for (var pName in self.pluginsRegistry) { + if (self.pluginsRegistry.hasOwnProperty(pName)) { + var p = self.pluginsRegistry[pName]; + if (p && typeof p.info === 'function') { + var pInfo = p.info(); + if (pInfo.type !== 'Settings' && pInfo.type !== 'Dispatcher' && typeof p.start === 'function') { + var tabEl = document.getElementById('tab-' + pInfo.name); + if (tabEl) { + p.start(tabEl, self.pluginsRegistry, self.default_plugins, `${self.pluginUniqueIdCounter++}`); + } else { + console.warn(`[${PN}]: Tab element for plugin "${pInfo.name}" not found.`); + } + } + } + } + } + + // Start the default Settings plugin last + if (self.default_plugins['Settings']) { + var settingsPlugin = self.pluginsRegistry[self.default_plugins['Settings']]; + if (settingsPlugin && typeof settingsPlugin.start === 'function') { + var tabEl = document.getElementById('tab-' + self.default_plugins['Settings']); + if (tabEl) { + settingsPlugin.start(tabEl, self.pluginsRegistry, self.default_plugins, `${self.pluginUniqueIdCounter++}`); + + // Read settings after starting the settings plugin + if (typeof settingsPlugin.read_settings === 'function') { + settingsPlugin.read_settings().then(function() { + self.pop(null, `[${PN}]: ` + _('Settings loaded successfully.')); + }).catch(function(err) { + self.pop(null, `[${PN}]: ` + _('Error reading settings.'), 'error'); + console.error('Error reading settings:', err); + }); + } else { + self.pop(null, `[${PN}]: ` + _('Settings plugin does not implement read_settings.'), 'error'); + } + } else { + self.pop(null, `[${PN}]: ` + _('Tab for default Settings plugin not found.'), 'error'); + } + } else { + self.pop(null, `[${PN}]: ` + _('Default Settings plugin not found or cannot be started.'), 'error'); + } + } else { + self.pop(null, `[${PN}]: ` + _('No default Settings plugin available.'), 'error'); + } + + // Activate the default start plugin + if (self.defaultStartPlugin) { + self.activatePlugin(self.defaultStartPlugin); + } + + self.updateMarkers(); + }); + }).catch(function(err) { + self.pop(null, `[${PN}]: ` + _('Error executing ls to load plugins.')); + console.error('Error executing ls:', err); + }); + }, + + /** + * setDefaultPlugins(pluginTypes) + * Set default plugins for each type based on priority order. + */ + setDefaultPlugins: function(pluginTypes) { + var self = this; + + // Ensure the Dispatcher is registered as a plugin + pluginTypes['Dispatcher'] = ['Main Dispatcher']; + + var preferredDefaults = { + 'Editor': 'Text Editor', + 'Navigation': 'Navigation', + 'Settings': 'Settings Manager', + 'Help': 'Help Center', + 'Utility': 'Utility Tool', + 'Dispatcher': 'Main Dispatcher' + }; + + self.supportedPluginTypes.forEach(function(type) { + if (pluginTypes[type].includes(preferredDefaults[type])) { + self.default_plugins[type] = preferredDefaults[type]; + } else if (pluginTypes[type].length > 0) { + self.default_plugins[type] = pluginTypes[type][0]; + } else { + self.default_plugins[type] = null; + self.pop(null, `[${PN}]: ` + _('No plugins available for type "%s".').format(type)); + } + }); + }, + + /** + * loadCSS(cssContent) + * Load CSS from a plugin into the document head. + */ + loadCSS: function(cssContent) { + var style = document.createElement('style'); + style.type = 'text/css'; + style.innerHTML = cssContent; + document.head.appendChild(style); + }, + + /** + * createTab(buttonsContainer, contentsContainer, info, plugin) + * Create a tab for a plugin without starting it here. + */ + createTab: function(buttonsContainer, contentsContainer, info, plugin) { + var self = this; + + var tabButton = E('button', { + 'class': 'cbi-tab-button' + }, info.name); + var marker = E('span', { + 'class': 'default-marker inactive', + 'title': _('Set as default') + }); + tabButton.appendChild(marker); + + var tabContent = E('div', { + 'class': 'cbi-tab-content', + 'id': 'tab-' + info.name + }); + + buttonsContainer.appendChild(tabButton); + contentsContainer.appendChild(tabContent); + + // Tab button click + tabButton.onclick = function() { + var allButtons = buttonsContainer.querySelectorAll('.cbi-tab-button'); + allButtons.forEach(function(btn) { + btn.classList.remove('active'); + }); + + var allContents = contentsContainer.querySelectorAll('.cbi-tab-content'); + allContents.forEach(function(content) { + content.classList.remove('active'); + }); + + tabButton.classList.add('active'); + tabContent.classList.add('active'); + + var eventName = `tab-${info.name}`; + var event = new Event(eventName); + document.dispatchEvent(event); + console.log(`[Main Dispatcher] "${eventName}" Event sent.`); + }; + + // Marker click to set default + marker.onclick = function(e) { + e.stopPropagation(); + if (self.supportedPluginTypes.includes(info.type)) { + self.default_plugins[info.type] = info.name; + self.updateMarkers(); + self.pop(null, `[${PN}]: ` + _('Set "%s" as the default %s plugin.').format(info.name, info.type)); + } + }; + + // Drag and drop for Editor or Utility + if (info.type === 'Editor' || info.type === 'Utility') { + tabButton.setAttribute('draggable', 'true'); + + tabButton.addEventListener('dragover', function(e) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + }); + + // Modify the drop event handler to listen for 'application/myapp-files' + tabButton.addEventListener('drop', function(e) { + e.preventDefault(); + + // Attempt to retrieve the custom MIME type data + var data = e.dataTransfer.getData('application/myapp-files'); + + if (data) { + try { + // Parse the JSON string to get the array of file paths + var filePaths = JSON.parse(data); + + if (Array.isArray(filePaths)) { + // Handle multiple files + filePaths.forEach(function(filePath) { + self.openFileInPlugin(filePath, info.type, info.name); + }); + } else { + // Handle single file + self.openFileInPlugin(data, info.type, info.name); + } + } catch (err) { + // If parsing fails, log the error + self.pop(null, `[${PN}]: ` + _('Error parsing dropped data.')); + console.error('Error parsing dropped data:', err); + } + } else { + // If custom MIME type data is not present, you can handle other drop types or ignore + self.pop(null, `[${PN}]: ` + _('Unsupported drop data.')); + console.warn('Unsupported drop data received.'); + } + }); + } + + // Auto-activate first tab + if (buttonsContainer.querySelectorAll('.cbi-tab-button').length === 1) { + tabButton.click(); + } + }, + + /** + * updateMarkers() + * Update the default markers to show which plugins are default. + */ + updateMarkers: function() { + var self = this; + var buttons = self.buttonsContainer.querySelectorAll('.cbi-tab-button'); + + buttons.forEach(function(btn) { + var pluginName = btn.firstChild.textContent; + var marker = btn.querySelector('.default-marker'); + + // If marker does not exist, skip this button + if (!marker) { + return; + } + + // Special handling for Logs tab to keep its marker active + if (btn.classList.contains('logs-tab')) { + marker.classList.add('active'); + marker.classList.remove('inactive'); + return; + } + + var pluginType = null; + for (var type in self.default_plugins) { + if (self.default_plugins[type] === pluginName) { + pluginType = type; + break; + } + } + + if (pluginType && self.default_plugins[pluginType] === pluginName) { + marker.classList.add('active'); + marker.classList.remove('inactive'); + } else { + marker.classList.add('inactive'); + marker.classList.remove('active'); + } + }); + }, + + /** + * openFileInPlugin(filePath, pluginType, pluginName) + * Opens a file in the specified plugin. + */ + openFileInPlugin: function(filePath, pluginType, pluginName) { + var self = this; + + if (pluginType === 'Editor') { + var editorPlugin = self.pluginsRegistry[pluginName]; + if (!editorPlugin || typeof editorPlugin.edit !== 'function') { + self.pop(null, `[${PN}]: ` + _('Target editor plugin does not support editing files.')); + return; + } + + if (!self.default_plugins['Navigation']) { + self.pop(null, `[${PN}]: ` + _('No default Navigation plugin set.')); + return; + } + + var navigationPlugin = self.pluginsRegistry[self.default_plugins['Navigation']]; + if (!navigationPlugin || typeof navigationPlugin.read_file !== 'function') { + self.pop(null, `[${PN}]: ` + _('Default Navigation plugin does not support reading files.')); + return; + } + + var editorInfo = editorPlugin.info(); + var style = editorInfo.style || 'text'; + + navigationPlugin.read_file(filePath, style).then(function(fileData) { + editorPlugin.edit(filePath, fileData.content, style, fileData.permissions, fileData.GroupOwner); + self.activatePlugin(pluginName); + self.pop(null, `[${PN}]: ` + _('File "%s" opened in editor.').format(filePath), 'success'); + }).catch(function(err) { + self.pop(null, `[${PN}]: ` + _('Error reading file "%s".').format(filePath), 'error'); + console.error('Error reading file:', filePath, err); + }); + } else if (pluginType === 'Navigation') { + self.pop(null, `[${PN}]: ` + _('Navigation plugin does not handle direct file opening.')); + } + }, + + handleSave: null, + handleSaveApply: null, + handleReset: null +}); diff --git a/applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Edit+.js b/applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Edit+.js new file mode 100644 index 00000000000..832a68f79fd --- /dev/null +++ b/applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Edit+.js @@ -0,0 +1,864 @@ +'use strict'; +'require ui'; +'require dom'; + +/** + * Text Editor Plugin with Search & Replace functionality + * Provides a simple text editor with a resizable window, scrollbars, and save functionality. + * Supports loading external text content for editing and displays the filename. + * Includes configuration for window size. + * + * Enhancements: + * - Prevents line wrapping with a horizontal scrollbar. + * - Displays line numbers alongside the textarea. + * - Search and Replace interface + * - A search pattern input field and "Find Next" and "Find Previous" buttons. + * - A replace pattern input field and "Replace This" and "Replace All" buttons. + * - A toggle switch to select between Normal and RegExp search modes. + * - Global search highlights all occurrences of the pattern in the text. + * - The matched text is highlighted in orange. + * - Info field shows total matches found and the index of the currently selected match. + * - "Find Next" scrolls to the next pattern occurrence. + * - "Find Previous" scrolls to the previous pattern occurrence. + * - "Replace This" replaces the current match and moves to the next one. + * - "Replace All" replaces all occurrences and scrolls to the end. + */ + +// Define the plugin name as a constant +const PN = 'Text Editor+'; + +return Class.extend({ + /** + * Returns metadata about the plugin. + * @returns {Object} Plugin information. + */ + info: function() { + return { + name: PN, + type: 'Editor', + style: 'Text', + description: 'A text editor plugin with search & replace, resizable window, scrollbars, save functionality, and search mode toggle (Normal/RegExp).' + }; + }, + + /** + * Generates CSS styles for the Text Editor plugin with a unique suffix. + * @param {string} uniqueId - The unique identifier for this plugin instance. + * @returns {string} - The CSS styles as a string. + */ + generateCss: function(uniqueId) { + return ` + /* CSS for the Text Editor Plugin - Instance ${uniqueId} */ + .text-editor-plugin-${uniqueId} { + padding: 10px; + background-color: #ffffff; + border: 1px solid #ccc; + resize: both; + overflow: hidden; + box-shadow: 2px 2px 5px rgba(0,0,0,0.1); + font-family: Arial, sans-serif; + font-size: 14px; + position: relative; + display: flex; + flex-direction: column; + height: 100%; + } + + .text-editor-plugin-${uniqueId} .filename-display { + margin-bottom: 10px; + font-weight: bold; + color: #333; + font-size: 16px; + } + + .text-editor-plugin-${uniqueId} .editor-container { + display: flex; + flex: 1; + overflow: auto; /* Allow only one scrollbar */ + align-items: flex-start; + position: relative; + } + + .text-editor-plugin-${uniqueId} .line-numbers { + width: 50px; + background-color: #f0f0f0; + color: #888; + text-align: right; + user-select: none; + border-right: 1px solid #ccc; + box-sizing: border-box; + font-family: monospace; + font-size: 14px; + line-height: 1.5; + white-space: pre; + padding: 5px 0; + position: sticky; + top: 0; + left: 0; + } + + .text-editor-plugin-${uniqueId} .editable-content { + flex: 1; + font-family: monospace; + font-size: 14px; + line-height: 1.5; + padding: 5px 10px; + margin: 0; + border: none; + outline: none; + white-space: pre; + background-color: #ffffff; + color: #000000; + } + + .text-editor-plugin-${uniqueId} .highlight { + background: none; + color: orange; + font-weight: bold; + } + + .text-editor-plugin-${uniqueId} .highlight.current-highlight { + background: orange; + color: #ffffff; /* For better contrast */ + } + + .text-editor-plugin-${uniqueId} .controls-container { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 10px; + flex-wrap: wrap; + gap: 10px; + } + + .text-editor-plugin-${uniqueId} .search-container { + display: flex; + flex-direction: row; + gap: 5px; + flex-wrap: wrap; + align-items: center; + } + + .text-editor-plugin-${uniqueId} .search-info { + margin-top: 5px; + font-size: 14px; + color: #333; + } + + .text-editor-plugin-${uniqueId} .button { + padding: 8px 16px; + background-color: #0078d7; + color: #fff; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + } + + .text-editor-plugin-${uniqueId} .button:hover { + background-color: #005fa3; + } + + .text-editor-plugin-${uniqueId} .toggle-container { + display: flex; + align-items: center; + gap: 5px; + } + + .text-editor-plugin-${uniqueId} .switch { + position: relative; + display: inline-block; + width: 50px; + height: 24px; + } + + .text-editor-plugin-${uniqueId} .switch input { + opacity: 0; + width: 0; + height: 0; + } + + .text-editor-plugin-${uniqueId} .slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #ccc; + transition: 0.4s; + border-radius: 24px; + } + + .text-editor-plugin-${uniqueId} .slider:before { + position: absolute; + content: ""; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + background-color: white; + transition: 0.4s; + border-radius: 50%; + } + + .text-editor-plugin-${uniqueId} .switch input:checked + .slider { + background-color: #2196F3; + } + + .text-editor-plugin-${uniqueId} .switch input:checked + .slider:before { + transform: translateX(26px); + } + + .dark-theme .text-editor-plugin-${uniqueId} { + background-color: #2a2a2a; + border-color: #555; + color: #ddd; + } + + .dark-theme .text-editor-plugin-${uniqueId} .filename-display { + color: #fff; + } + + .dark-theme .text-editor-plugin-${uniqueId} .line-numbers { + background-color: #3a3a3a; + color: #ccc; + border-right: 1px solid #555; + } + + .dark-theme .text-editor-plugin-${uniqueId} .editable-content { + background-color: #1e1e1e; + color: #f1f1f1; + border: 1px solid #555; + } + + .dark-theme .text-editor-plugin-${uniqueId} .button { + background-color: #1e90ff; + } + + .dark-theme .text-editor-plugin-${uniqueId} .button:hover { + background-color: #1c7ed6; + } + + .dark-theme .text-editor-plugin-${uniqueId} .slider { + background-color: #555; + } + + .dark-theme .text-editor-plugin-${uniqueId} .switch input:checked + .slider { + background-color: #1e90ff; + } + `; + }, + + /** + * Escapes special characters in a string to be used in a regular expression. + * @param {string} string - The string to escape. + * @returns {string} - The escaped string. + */ + escapeRegExp: function(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + }, + + /** + * Initializes the plugin within the given container. + * @param {HTMLElement} container - The container element where the plugin will be rendered. + * @param {Object} pluginsRegistry - The registry of all loaded plugins. + * @param {Object} default_plugins - The default plugins for each type. + * @param {string} uniqueId - A unique identifier for this plugin instance. + */ + start: function(container, pluginsRegistry, default_plugins, uniqueId) { + var self = this; + + // Ensure initialization runs only once + if (self.initialized) { + return; + } + self.initialized = true; + + self.pluginsRegistry = pluginsRegistry; + self.default_plugins = default_plugins; + self.uniqueId = uniqueId; + + // Insert unique CSS + var styleTag = document.createElement('style'); + styleTag.type = 'text/css'; + styleTag.id = `text-editor-plugin-style-${uniqueId}`; + styleTag.innerHTML = self.generateCss(uniqueId); + document.head.appendChild(styleTag); + self.styleTag = styleTag; + + // Main container + self.editorDiv = document.createElement('div'); + self.editorDiv.className = `text-editor-plugin-${uniqueId}`; + + // Initial size + self.width = self.settings && self.settings.width ? self.settings.width : '600px'; + self.height = self.settings && self.settings.height ? self.settings.height : '400px'; + self.editorDiv.style.width = self.width; + self.editorDiv.style.height = self.height; + + // Filename display + self.filenameDisplay = document.createElement('div'); + self.filenameDisplay.className = 'filename-display'; + self.filenameDisplay.textContent = 'No file loaded.'; + + // Editor container + self.editorContainer = document.createElement('div'); + self.editorContainer.className = 'editor-container'; + + // Line numbers + self.lineNumbers = document.createElement('div'); + self.lineNumbers.className = 'line-numbers'; + self.lineNumbers.textContent = '1'; + + // Editable content area + self.editableContent = document.createElement('div'); + self.editableContent.className = 'editable-content'; + self.editableContent.contentEditable = 'true'; + + // Append to editor container + self.editorContainer.appendChild(self.lineNumbers); + self.editorContainer.appendChild(self.editableContent); + + // Controls container (Save button and Search controls) + self.controlsContainer = document.createElement('div'); + self.controlsContainer.className = 'controls-container'; + + // Save button + self.saveButton = document.createElement('button'); + self.saveButton.className = 'button'; + self.saveButton.textContent = 'Save'; + self.saveButton.onclick = self.saveFile.bind(this); + + // Search & Replace UI + self.searchContainer = document.createElement('div'); + self.searchContainer.className = 'search-container'; + + // Search input + self.searchInput = document.createElement('input'); + self.searchInput.type = 'text'; + self.searchInput.placeholder = 'Search pattern...'; + + // Find Next button + self.findNextButton = document.createElement('button'); + self.findNextButton.className = 'button'; + self.findNextButton.textContent = 'Find Next'; + self.findNextButton.onclick = self.findNext.bind(self); + + // Find Previous button + self.findPrevButton = document.createElement('button'); + self.findPrevButton.className = 'button'; + self.findPrevButton.textContent = 'Find Previous'; + self.findPrevButton.onclick = self.findPrevious.bind(self); + + // Replace input + self.replaceInput = document.createElement('input'); + self.replaceInput.type = 'text'; + self.replaceInput.placeholder = 'Replace with...'; + + // Replace This button + self.replaceThisButton = document.createElement('button'); + self.replaceThisButton.className = 'button'; + self.replaceThisButton.textContent = 'Replace This'; + self.replaceThisButton.onclick = self.replaceThis.bind(self); + + // Replace All button + self.replaceAllButton = document.createElement('button'); + self.replaceAllButton.className = 'button'; + self.replaceAllButton.textContent = 'Replace All'; + self.replaceAllButton.onclick = self.replaceAll.bind(self); + + // Toggle Search Type (Normal / RegExp) + self.toggleContainer = document.createElement('div'); + self.toggleContainer.className = 'toggle-container'; + + // Toggle Switch + self.switchLabel = document.createElement('label'); + self.switchLabel.className = 'switch'; + + self.switchInput = document.createElement('input'); + self.switchInput.type = 'checkbox'; + self.switchInput.id = `search-toggle-${uniqueId}`; + self.switchInput.onclick = self.toggleSearchType.bind(self); + + self.switchSlider = document.createElement('span'); + self.switchSlider.className = 'slider'; + + self.switchLabel.appendChild(self.switchInput); + self.switchLabel.appendChild(self.switchSlider); + + // Toggle Label Text + self.toggleLabelText = document.createElement('span'); + self.toggleLabelText.textContent = 'RegExp'; + + self.toggleContainer.appendChild(self.switchLabel); + self.toggleContainer.appendChild(self.toggleLabelText); + + // Append search and replace elements + self.searchContainer.appendChild(self.searchInput); + self.searchContainer.appendChild(self.findNextButton); + self.searchContainer.appendChild(self.findPrevButton); + self.searchContainer.appendChild(self.replaceInput); + self.searchContainer.appendChild(self.replaceThisButton); + self.searchContainer.appendChild(self.replaceAllButton); + self.searchContainer.appendChild(self.toggleContainer); + + // Append Save button and Search controls to controls container + self.controlsContainer.appendChild(self.searchContainer); + self.controlsContainer.appendChild(self.saveButton); + + // Info field for matches + self.infoField = document.createElement('div'); + self.infoField.className = 'search-info'; + + // Append elements to main editor div + self.editorDiv.appendChild(self.filenameDisplay); + self.editorDiv.appendChild(self.editorContainer); + self.editorDiv.appendChild(self.controlsContainer); + self.editorDiv.appendChild(self.infoField); + + container.appendChild(self.editorDiv); + + // Default dispatcher + var defaultDispatcherName = self.default_plugins['Dispatcher']; + if (defaultDispatcherName && self.pluginsRegistry[defaultDispatcherName]) { + var defaultDispatcher = self.pluginsRegistry[defaultDispatcherName]; + self.popm = defaultDispatcher.pop.bind(defaultDispatcher); + } + + // Navigation plugin for file operations + var navigationPluginName = self.default_plugins['Navigation']; + if (!navigationPluginName) { + self.popm(null, `[${PN}]: No default Navigation plugin set.`); + console.error('No default Navigation plugin set.'); + return; + } + + var navigationPlugin = self.pluginsRegistry[navigationPluginName]; + if (!navigationPlugin || typeof navigationPlugin.write_file !== 'function') { + self.popm(null, `[${PN}]: Navigation plugin does not support writing files.`); + console.error('Navigation plugin is unavailable or missing write_file function.'); + return; + } + + // Bind write_file + self.write_file = navigationPlugin.write_file.bind(navigationPlugin); + + // Set initial variables + self.textData = ''; + self.matches = []; + self.currentMatchIndex = -1; + self.lastSearchPattern = ''; + self.lastIsRegExp = false; + self.isRegExp = false; + + // Sync scroll for line numbers + self.editableContent.addEventListener('scroll', function() { + const scrollTop = self.editableContent.scrollTop; + self.lineNumbers.style.transform = `translateY(-${scrollTop}px)`; + }); + + self.updating = false; + + // Recalculate line numbers on input + self.editableContent.addEventListener('input', function() { + self.textData = self.getRawText(); + self.updateLineNumbers(); + }); + }, + + /** + * Opens a file in the editor. + * @param {string} filePath - The path to the file to edit. + * @param {string} content - The content of the file. + * @param {string} style - The style of the content ('text' or 'bin'). + */ + edit: function(filePath, content, style, permissions, ownerGroup) { + var self = this; + + if (style.toLowerCase() !== 'text') { + self.popm(null, `[${PN}]: Unsupported style "${style}". Only "Text" is supported.`); + console.warn('Unsupported style:', style); + self.filenameDisplay.textContent = 'Unsupported file style.'; + self.textData = ''; + self.render(); + return; + } + + self.currentFilePath = filePath; + self.permissions = permissions; + self.ownerGroup = ownerGroup; + + self.textData = content; + var parts = filePath.split('/'); + var filename = parts[parts.length - 1]; + self.filenameDisplay.textContent = `Editing: ${filename}`; + + self.updateLineNumbers(); + + // Reset search-related variables + self.lastSearchPattern = ''; + self.matches = []; + self.currentMatchIndex = -1; + self.lastIsRegExp = false; + self.isRegExp = false; + + // Reset the toggle switch to Normal search + self.switchInput.checked = false; + self.toggleLabelText.textContent = 'RegExp'; + + self.render(); + self.popm(null, `[${PN}]: Opened file "${filename}".`); + }, + + /** + * Save the file using the Navigation plugin. + */ + saveFile: function(ev) { + var self = this; + + if (!self.currentFilePath) { + self.popm(null, `[${PN}]: No file loaded to save.`); + return; + } + + var content = self.getRawText(); + + self.write_file(self.currentFilePath, self.permissions, self.ownerGroup, content, 'text') + .then(function() { + self.popm(null, `[${PN}]: File saved successfully.`); + }) + .catch(function(err) { + self.popm(null, `[${PN}]: Error saving file.`); + console.error('Error saving file:', err); + }); + }, + + /** + * Get current settings. + */ + get_settings: function() { + return { + width: this.editorDiv.style.width, + height: this.editorDiv.style.height + }; + }, + + /** + * Set plugin settings. + */ + set_settings: function(settings) { + if (settings.width) { + this.editorDiv.style.width = settings.width; + } + if (settings.height) { + this.editorDiv.style.height = settings.height; + } + }, + + /** + * Destroy the plugin instance. + */ + destroy: function() { + var self = this; + if (self.styleTag) { + self.styleTag.remove(); + } + if (self.editorDiv && self.editorDiv.parentNode) { + self.editorDiv.parentNode.removeChild(self.editorDiv); + } + self.initialized = false; + }, + + /** + * Update line numbers according to the current text. + */ + updateLineNumbers: function() { + var self = this; + var linesCount = self.textData.split('\n').length; + var lineNumbersContent = ''; + for (let i = 1; i <= linesCount; i++) { + lineNumbersContent += i + '\n'; + } + self.lineNumbers.textContent = lineNumbersContent; + }, + + /** + * Get the raw text without any highlighting from editable content. + */ + getRawText: function() { + return this.editableContent.textContent; + }, + + /** + * Render the content with highlights. + */ + render: function() { + var self = this; + self.updating = true; // Begin of inner update + + if (!self.matches || self.matches.length === 0) { + self.editableContent.innerHTML = self.escapeHtml(self.textData); + } else { + var htmlParts = []; + var lastIndex = 0; + for (var i = 0; i < self.matches.length; i++) { + var m = self.matches[i]; + htmlParts.push(self.escapeHtml(self.textData.substring(lastIndex, m.start))); + if (i === self.currentMatchIndex) { + htmlParts.push(''); + } else { + htmlParts.push(''); + } + htmlParts.push(self.escapeHtml(self.textData.substring(m.start, m.end))); + htmlParts.push(''); + lastIndex = m.end; + } + htmlParts.push(self.escapeHtml(self.textData.substring(lastIndex))); + self.editableContent.innerHTML = htmlParts.join(''); + } + self.updating = false; // End of inner update + self.updateLineNumbers(); + self.updateInfoField(); + self.scrollToCurrentMatch(); + }, + + /** + * Escape HTML to prevent issues. + */ + escapeHtml: function(str) { + return str.replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + }, + + /** + * Toggle between Normal and RegExp search modes. + */ + toggleSearchType: function() { + var self = this; + self.isRegExp = self.switchInput.checked; + self.toggleLabelText.textContent = self.isRegExp ? 'RegExp' : 'Normal'; + + self.lastIsRegExp = self.isRegExp; + // Re-run search to reflect the new mode + self.performSearch(); + self.render(); + }, + + /** + * Perform a global search on the textData and store matches. + */ + performSearch: function() { + var self = this; + var pattern = self.searchInput.value; + + if (!pattern) { + self.matches = []; + self.currentMatchIndex = -1; + self.render(); + return; + } + + var re; + if (self.isRegExp) { + try { + re = new RegExp(pattern, 'g'); + } catch (e) { + self.updateInfoField("Invalid RegExp pattern."); + return; + } + } else { + var escapedPattern = self.escapeRegExp(pattern); + re = new RegExp(escapedPattern, 'g'); + } + + self.matches = []; + var match; + while ((match = re.exec(self.textData)) !== null) { + self.matches.push({ + start: match.index, + end: match.index + match[0].length + }); + if (match.index === re.lastIndex) { + re.lastIndex++; + } + } + + // Корректировка currentMatchIndex + if (self.matches.length > 0) { + if (self.currentMatchIndex >= self.matches.length) { + self.currentMatchIndex = self.matches.length - 1; + } else if (self.currentMatchIndex === -1) { + self.currentMatchIndex = 0; + } + } else { + self.currentMatchIndex = -1; + } + + self.render(); + }, + + /** + * Move to the next match and re-render. + */ + findNext: function() { + var self = this; + + // Выполняем поиск, чтобы обновить matches + self.performSearch(); + + if (self.matches.length === 0) { + self.updateInfoField("No matches found."); + return; + } + + if (self.currentMatchIndex === -1) { + self.currentMatchIndex = 0; + } else if (self.currentMatchIndex < self.matches.length - 1) { + self.currentMatchIndex++; + } else { + self.currentMatchIndex = 0; + } + + self.render(); + }, + + /** + * Move to the previous match and re-render. + */ + findPrevious: function() { + var self = this; + + // Выполняем поиск, чтобы обновить matches + self.performSearch(); + + if (self.matches.length === 0) { + self.updateInfoField("No matches found."); + return; + } + + if (self.currentMatchIndex === -1) { + self.currentMatchIndex = self.matches.length - 1; + } else if (self.currentMatchIndex > 0) { + self.currentMatchIndex--; + } else { + self.currentMatchIndex = self.matches.length - 1; + } + + self.render(); + }, + + /** + * Replace the current match with the specified replacement text. + * Then move to the next match. + */ + replaceThis: function() { + var self = this; + + if (self.matches.length === 0 || self.currentMatchIndex === -1) { + self.updateInfoField("No matches available to replace."); + return; + } + + var replacement = self.replaceInput.value || ''; + var currentMatch = self.matches[self.currentMatchIndex]; + + // Выполняем замену в textData + self.textData = self.textData.substring(0, currentMatch.start) + replacement + self.textData.substring(currentMatch.end); + + // Выполняем поиск, чтобы обновить matches + self.performSearch(); + + // Если после замены текущий индекс выходит за пределы, устанавливаем его на последний индекс + if (self.currentMatchIndex >= self.matches.length) { + self.currentMatchIndex = self.matches.length - 1; + } + + self.render(); + }, + + /** + * Replace all occurrences of the search pattern with the replacement text. + */ + replaceAll: function() { + var self = this; + var pattern = self.searchInput.value; + if (!pattern) return; + var replacement = self.replaceInput.value || ''; + + var re; + if (self.isRegExp) { + try { + re = new RegExp(pattern, 'g'); + } catch (e) { + self.updateInfoField("Invalid RegExp pattern."); + return; + } + } else { + var escapedPattern = self.escapeRegExp(pattern); + re = new RegExp(escapedPattern, 'g'); + } + + try { + self.textData = self.textData.replace(re, replacement); + } catch (e) { + self.updateInfoField("Error during replacement."); + console.error('Error during replacement:', e); + return; + } + + // After replace all, re-search and scroll to the end + self.performSearch(); + self.editableContent.scrollTop = self.editableContent.scrollHeight; + }, + + /** + * Update the info field showing the total matches and current match index. + */ + updateInfoField: function(optionalMessage) { + var self = this; + if (optionalMessage) { + self.infoField.textContent = optionalMessage; + return; + } + + if (!self.matches || self.matches.length === 0) { + self.infoField.textContent = 'No matches found.'; + } else { + self.infoField.textContent = `Matches: ${self.matches.length}, Current: ${self.currentMatchIndex + 1}`; + } + }, + + /** + * Scroll to the current match in the editableContent. + */ + scrollToCurrentMatch: function() { + var self = this; + if (self.currentMatchIndex === -1 || self.matches.length === 0) return; + + var highlights = self.editableContent.querySelectorAll('.highlight'); + if (highlights.length === 0) return; + var target = highlights[self.currentMatchIndex]; + if (!target) return; + + target.scrollIntoView({ + block: 'center', + behavior: 'smooth' + }); + self.selectText(target); + }, + + /** + * Select the text within the target element. + * @param {HTMLElement} element - The element containing the text to select. + */ + selectText: function(element) { + var range = document.createRange(); + var sel = window.getSelection(); + range.selectNodeContents(element); + sel.removeAllRanges(); + sel.addRange(range); + }, +}); \ No newline at end of file diff --git a/applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js b/applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js new file mode 100644 index 00000000000..2bfe1fde771 --- /dev/null +++ b/applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js @@ -0,0 +1,2848 @@ +'use strict'; +'require fs'; +'require ui'; +'require rpc'; + +/*** +# Navigation Plugin: User Functionality Overview + +## 1. Directory Navigation and Traversal + +- **Path Input Field:** + Users can enter a specific directory path directly into the input field and navigate to it by pressing "Enter" or clicking the "Go" button. + +- **Breadcrumb Navigation:** + Displays the current directory path in a breadcrumb format, allowing users to quickly navigate to any parent directory by clicking on the respective breadcrumb link. + +- **Clickable Directory Names:** + Users can navigate into subdirectories by clicking on directory names listed in the table. + +## 2. File and Directory Operations + +- **Upload Files:** + - **Upload Button:** + Click to open a file dialog and select multiple files for upload. + - **Drag-and-Drop:** + Drag files from the local system and drop them into the navigation area to initiate uploads. + +- **Download Files:** + Click the download icon (⬇️) next to a file to download it to the local system. + +- **Create New Folder/File:** + - **Create Folder Button:** + Prompts the user to enter a folder name and creates it in the current directory. + - **Create File Button:** + Prompts the user to enter a file name and creates an empty file in the current directory. + +- **Delete Items:** + - **Select Items:** + Use checkboxes to select individual files or directories. + - **Delete Selected Button:** + Deletes all selected items after user confirmation. + +- **Copy and Move Items:** + - **Drag-and-Drop Within UI:** + Drag selected files or directories to a target directory to copy or move them. + - **Copy Operation:** + Hold the "Alt" key while dragging to copy items. + - **Move Operation:** + Drag without holding any modifier keys to move items. + +## 3. Bulk Selection and Management + +- **Select/Deselect All Checkbox:** + Located in the table header, allows users to select or deselect all items in the current directory view. Pressing with "Alt" inverses current selection + +- **Individual Selection:** + Checkboxes next to each item enable selective management of files and directories. + +## 4. Editing Files attributes + +- **Edit Button (✏️):** + Opens a window for file attributes editing. + - **File Attributes:** + Users can rename the file, change its owner and group, and modify permissions directly from the edit interface. + +## 5. User Interface Customization + +- **Resizable Columns:** + Users can adjust the width of table columns by dragging the resizers between column headers. The plugin enforces minimum widths to maintain usability. + +- **Themes:** + - **Light Theme:** + Default styling with a white background and dark text. + - **Dark Theme:** + Optional dark mode with dark backgrounds and light text for reduced eye strain. + +## 6. Drag-and-Drop Enhancements + +- **Internal Drag-and-Drop:** + - **Visual Indicators:** + Highlight target directories during drag-over events to indicate valid drop zones. + - **Action Icons:** + Displays a plus icon (➕) when holding the "Alt" key to signify a copy operation. + +- **External Drag-and-Drop:** + - **File Downloads:** + Dragging files out of the navigation UI initiates their download to the local system. + - **File Uploads:** + Dropping files into the navigation UI area uploads them to the current directory. + +## 7. Feedback and Status Indicators + +- **Loading Indicators:** + Display a "Loading..." message while fetching directory contents. + +- **Progress Bars:** + Show upload progress for individual files. + +- **Tooltips:** + Provides additional information when hovering over overflowing text in file names. + +- **Status Messages:** + Informs users of successful operations or errors through pop-up messages and inline notifications. + +## 8. Advanced Features + +- **Permissions and Ownership Management:** + Allows users to view and modify file permissions and ownership directly from the UI. + +- **Symbolic Link Handling:** + Properly displays and manages symbolic links, including their targets. + +- **Responsive Design:** + Adapts to different screen sizes and container dimensions, ensuring usability across various devices. + +## 9. Integration with Other Plugins + +- **Editor Plugin Integration:** + Seamlessly works with default editor plugins to provide in-browser file editing capabilities. + +- **Dispatcher Integration:** + Utilizes dispatcher plugins for executing file system commands and handling asynchronous operations. + +## 10. Error Handling and Validation + +- **User Prompts:** + Confirms critical actions like deletions to prevent accidental data loss. + +- **Error Notifications:** + Clearly communicates issues such as failed uploads, permission errors, or invalid paths to the user. +***/ + +// Define the plugin name as a constant +const PN = 'Navigation'; + +return Class.extend({ + /** + * Provides metadata about the plugin. + * @returns {Object} - Contains at least name, type, and description properties. + */ + info: function() { + return { + name: PN, + type: 'Navigation', + description: 'Enhanced file system navigator with additional functionalities' + }; + }, + + /** + * Retrieves the current configuration settings of the plugin. + * @returns {Object} - Key-value pairs of settings. + */ + get_settings: function() { + return this.settings || {}; + }, + + /** + * Default settings for the plugin. + */ + defaultSettings: { + currentDir: '/', + width: 900, // Number + height: 800, // Number + defaultFilePermissions: '644', + defaultDirPermissions: '755', + defaultOwner: 'root', + defaultGroup: 'root', + columnWidths: { + 'select': 30, + 'name': 200, + 'type': 100, + 'size': 100, + 'mtime': 150, + 'actions': 150 + }, + mincolumnWidths: { // Adding minimum column widths + 'select': 20, + 'name': 110, + 'type': 50, + 'size': 50, + 'mtime': 80, + 'actions': 100 + } + }, + + /** + * Applies settings to internal properties and UI elements. + */ + applySettingsToUI: function() { + var self = this; + + // Merging current settings with default settings + self.settings = Object.assign({}, self.defaultSettings, self.settings || {}); + + // Updating internal plugin properties + self.currentDir = self.settings.currentDir; + self.defaultFilePermissions = self.settings.defaultFilePermissions; + self.defaultDirPermissions = self.settings.defaultDirPermissions; + self.columnWidths = self.settings.columnWidths; + self.mincolumnWidths = self.settings.mincolumnWidths; + + // Setting fixed sizes for the navigation container + if (self.settings.width) { + self.navDiv.style.width = self.settings.width + 'px'; + } + if (self.settings.height) { + self.navDiv.style.height = self.settings.height + 'px'; + } + + // Setting sizes for tableContainer + self.tableContainer.style.width = '100%'; + self.tableContainer.style.height = '100%'; + + // Applying column widths and calculating total table width + var totalWidth = 0; + if (self.settings.columnWidths) { + Object.keys(self.settings.columnWidths).forEach(function(field) { + var newWidth = self.settings.columnWidths[field]; + var col = self.table.querySelector(`col[data-field="${field}"]`); + if (col) { + // Ensure that the width is not less than the minimum + var minWidth = self.mincolumnWidths[field] || 30; // If mincolumnWidths is not set, use 30px + if (newWidth < minWidth) { + newWidth = minWidth; + self.settings.columnWidths[field] = minWidth; // Update settings if width was reduced + } + col.style.width = newWidth + 'px'; + totalWidth += newWidth; + } + }); + } + + // Setting the total table width + self.table.style.width = totalWidth + 'px'; + + // Update the table to apply new widths + // You can also call a redraw or recalculate elements if necessary + + // console.log(`[Navigation Plugin] Applied settings to UI:`, self.settings); + }, + + /** + * Sets the plugin's settings. + * @param {Object} settings - Key-value pairs of settings to be applied. + */ + set_settings: function(settings) { + var self = this; + + // Update settings + for (let key in settings) { + if (settings.hasOwnProperty(key)) { + const value = settings[key]; + switch (key) { + case 'currentDir': + case 'defaultFilePermissions': + case 'defaultDirPermissions': + case 'defaultOwner': // Added + case 'defaultGroup': // Added + if (typeof value === 'string') { + self.settings[key] = value; + } + break; + case 'width': + case 'height': + // Convert strings to numbers + const numValue = parseInt(value, 10); + if (!isNaN(numValue)) { + self.settings[key] = numValue; + } else { + console.warn(`Invalid number for ${key}: ${value}`); + } + break; + case 'columnWidths': + if (typeof value === 'object' && value !== null) { + // Convert each value within columnWidths + let parsedColumnWidths = {}; + for (let cwKey in value) { + if (value.hasOwnProperty(cwKey)) { + const cwValue = parseInt(value[cwKey], 10); + if (!isNaN(cwValue)) { + parsedColumnWidths[cwKey] = cwValue; + } else { + console.warn(`Invalid number for columnWidths.${cwKey}: ${value[cwKey]}`); + } + } + } + self.settings.columnWidths = Object.assign({}, self.settings.columnWidths, parsedColumnWidths); + } + break; + case 'mincolumnWidths': + if (typeof value === 'object' && value !== null) { + // Convert each value within mincolumnWidths + let parsedMinColumnWidths = {}; + for (let mcwKey in value) { + if (value.hasOwnProperty(mcwKey)) { + const mcwValue = parseInt(value[mcwKey], 10); + if (!isNaN(mcwValue)) { + parsedMinColumnWidths[mcwKey] = mcwValue; + } else { + console.warn(`Invalid number for mincolumnWidths.${mcwKey}: ${value[cwKey]}`); + } + } + } + self.settings.mincolumnWidths = Object.assign({}, self.settings.mincolumnWidths, parsedMinColumnWidths); + } + break; + default: + // Handle unknown keys if necessary + console.warn(`Unknown setting key: ${key}`); + } + } + } + + console.log(`[Navigation Plugin] Updated settings:`, self.settings); + + // Apply settings to UI and internal properties + self.applySettingsToUI(); + + // If currentDir has changed, load the new directory + if (settings.hasOwnProperty('currentDir')) { + self.loadDirectory(self.currentDir); + } + }, + + // Helper method to get the file name from the path + basename: function(filePath) { + return filePath.split('/').pop(); + }, + + /** + * New method for requesting file content + * type: 'text' or 'bin' + * @param {string} filePath - The path to the file. + * @param {string} type - The type of data to retrieve ('text' or 'bin'). + * @returns {Promise} - A promise that resolves with the file content. + */ + requestFileData: function(filePath, type) { + var self = this; + + // Define the response type for read_direct + var responseType = (type === 'bin') ? 'blob' : 'text'; + + // Call read_direct to get the data + return fs.read_direct(filePath, responseType) + .then(function(response) { + if (type === 'bin') { + // If binary data is required, convert Blob to ArrayBuffer + return response.arrayBuffer(); + } else { + // If text data, return it directly + return response; + } + }) + .catch(function(error) { + // Handle errors + console.error('Failed to request file data:', error); + throw error; + }); + }, + + /** + * Reads the content of a file along with its permissions and ownership. + * @param {String} filePath - The path to the file to read. + * @param {String} type - The type of operation ('text' or 'bin'). + * @returns {Promise} - Resolves with an object containing content, permissions, owner, and group. + */ + read_file: function(filePath, type) { + var self = this; + + // Execute both file data retrieval and ls command concurrently + return Promise.all([ + self.requestFileData(filePath, type), // Retrieves the file content + fs.exec('/bin/ls', ['-lA', '--full-time', filePath]) // Executes ls to get file details + ]).then(function([content, lsOutput]) { + // Split the ls output into lines and filter out any empty lines + var lines = lsOutput.stdout.split('\n').filter(line => line.trim() !== ''); + + if (lines.length === 0) { + throw new Error('No output from ls command'); + } + + // Parse the first line of ls output to get file details + var fileInfo = self.parseLsLine(lines[0]); + + if (!fileInfo) { + throw new Error('Failed to parse ls output'); + } + + // Return the aggregated file information + return { + content: content, + permissions: fileInfo.permissions, // Numeric representation of permissions + GroupOwner: (fileInfo.owner + ':' + fileInfo.group) // Combined owner and group + }; + }).catch(function(error) { + console.error('Failed to read file data and ls:', error); + throw error; // Propagate the error to the caller + }); + }, + + /** + * Writes data to a specified file on the server. + * @param {String} filePath - The path to the file to write. + * @param {String|ArrayBuffer} data - The data to write to the file. + * @param {String} type - The type of operation ('text' or 'bin'). + * @returns {Promise} - Resolves when the write operation is complete. + */ + write_file: function(filePath, permissions, ownerGroup, data, type) { + + var self = this; + // Define permissions and ownership + // var permissions = self.settings.defaultFilePermissions; + // var ownerGroup = self.settings.defaultOwner + ':' + self.settings.defaultGroup; + + var blob; + if (type === 'text') { + blob = new Blob([data], { + type: 'text/plain' + }); + } else { + // Assume that data is either ArrayBuffer, Uint8Array, etc. + blob = new Blob([data]); + } + return this.uploadFile(filePath, blob, permissions, ownerGroup, null); + }, + + + /** + * Helper function to concatenate directory and file names with proper slashes. + * @param {string} dir - The directory path. + * @param {string} name - The file or subdirectory name. + * @returns {string} - The concatenated path. + */ + concatPath: function(dir, name) { + if (!dir.endsWith('/')) { + dir += '/'; + } + return dir + name; + }, + + /** + * Update the width of a specific column and persist the change in settings. + * @param {string} field - The field name of the column. + * @param {number} newWidth - The new width in pixels. + */ + updateColumnWidth: function(field, newWidth) { + var self = this; + // Update column element width + var col = self.table.querySelector(`col[data-field="${field}"]`); + if (col) { + col.style.width = newWidth + 'px'; + } else { + console.warn(`No col found for field: ${field}`); + } + + // Update settings and internal properties + self.columnWidths[field] = newWidth; + self.settings.columnWidths[field] = newWidth; + // IMPORTANT: Do not call applySettingsToUI() here. + // We'll call it once after column resizing finishes (on mouseup). + }, + + /** + * CSS styles for the plugin. + * Modified to include a unique suffix to prevent class name conflicts. + */ + css: function() { + var self = this; + var uniqueSuffix = self.uniqueSuffix; // e.g., '-123' + + return ` + /* Styles for the modal window */ + .navigation-plugin-modal${uniqueSuffix} { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); /* Semi-transparent black background */ + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; /* High z-index to appear on top */ + } + + /* Styles for the modal content */ + .navigation-plugin-modal-content${uniqueSuffix} { + background-color: #fff; /* White background for content */ + padding: 20px; + border-radius: 5px; + width: 400px; + position: relative; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + } + + /* Close button for the modal */ + .navigation-plugin-close-button${uniqueSuffix} { + position: absolute; + top: 10px; + right: 15px; + font-size: 24px; + font-weight: bold; + color: #aaa; + cursor: pointer; + transition: color 0.2s; + } + + .navigation-plugin-close-button${uniqueSuffix}:hover { + color: #000; + } + + /* Styles for elements inside the modal */ + .navigation-plugin-modal-content${uniqueSuffix} label { + display: block; + margin-top: 10px; + font-weight: bold; + } + + .navigation-plugin-modal-content${uniqueSuffix} input[type="text"] { + width: 100%; + padding: 8px; + margin-top: 5px; + box-sizing: border-box; + } + + .navigation-plugin-modal-content${uniqueSuffix} button { + margin-top: 15px; + padding: 10px 20px; + font-size: 16px; + cursor: pointer; + border: none; + border-radius: 4px; + } + + .navigation-plugin-modal-content${uniqueSuffix} button#edit-submit-button${uniqueSuffix} { + background-color: #4CAF50; /* Green background for "Submit" button */ + color: white; + } + + .navigation-plugin-modal-content${uniqueSuffix} button#edit-submit-button${uniqueSuffix}:hover { + background-color: #45a049; + } + + .navigation-plugin-modal-content${uniqueSuffix} button#edit-cancel-button${uniqueSuffix} { + background-color: #f44336; /* Red background for "Cancel" button */ + color: white; + margin-left: 10px; + } + + .navigation-plugin-modal-content${uniqueSuffix} button#edit-cancel-button${uniqueSuffix}:hover { + background-color: #da190b; + } + + /* Header styles */ + .navigation-plugin-header${uniqueSuffix} { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 10px; + } + + .navigation-plugin-header${uniqueSuffix} input[type="text"] { + flex-grow: 1; + padding: 5px; + font-size: 16px; + text-align: left; /* Align text to the left */ + background-color: #fff; + border: 1px solid #ccc; + border-radius: 4px; + } + + .navigation-plugin-header${uniqueSuffix} button { + padding: 12px 24px; + font-size: 18px; + cursor: pointer; + background-color: #007BFF; + color: white; + border: none; + border-radius: 4px; + transition: background-color 0.2s; + } + + .navigation-plugin-header${uniqueSuffix} button:hover { + background-color: #0056b3; + } + + /* Breadcrumb styles */ + .navigation-plugin-breadcrumb${uniqueSuffix} { + margin-bottom: 10px; + } + + /* Table container with fixed headers */ + .navigation-plugin-table-container${uniqueSuffix} { + border: 1px solid #ccc; + resize: both; + overflow: auto; + position: relative; + box-sizing: border-box; + } + + /* Table styles */ + .navigation-plugin-table${uniqueSuffix} { + width: 100%; /* Set table width to 100% of container */ + border-collapse: collapse; + table-layout: fixed; + min-width: 730px; /* Example: sum of column widths */ + } + + .navigation-plugin-table${uniqueSuffix} th, .navigation-plugin-table${uniqueSuffix} td { + box-sizing: border-box; /* Account for padding and border when calculating width */ + padding: 8px; /* Add padding to improve appearance */ + overflow: hidden; /* Hide overflow to prevent content from exceeding cell boundaries */ + white-space: nowrap; /* Prevent text wrapping */ + text-overflow: ellipsis; /* Add ellipsis if text is trimmed */ + border: 1px solid #ddd; /* Add borders for visual column separation */ + height: 40px; /* Fixed row height */ + min-height: 40px; /* Or minimum height */ + } + + .navigation-plugin-table${uniqueSuffix} col { + min-width: 50px; /* Example minimum width */ + } + + .navigation-plugin-table${uniqueSuffix} thead th { + position: sticky; + top: 0; + background-color: #f2f2f2; + padding: 8px; + text-align: left; + vertical-align: middle; + border-bottom: 2px solid #aaa; /* Thicker and brighter border */ + border-right: 1px solid #aaa; /* Added vertical border */ + z-index: 2; /* Ensure headers stay above body rows */ + } + + .navigation-plugin-table${uniqueSuffix} thead th:last-child { + border-right: none; + } + + /* Styles for table cells */ + .navigation-plugin-table${uniqueSuffix} tbody td { + overflow: hidden; /* Trim content that exceeds cell boundaries */ + white-space: nowrap; /* Prevent text from wrapping to a new line */ + text-overflow: ellipsis; /* Add ellipsis if text is trimmed */ + } + + /* Styles for links inside table cells */ + .navigation-plugin-table${uniqueSuffix} td .file-name${uniqueSuffix}, + .navigation-plugin-table${uniqueSuffix} td .directory-name${uniqueSuffix}, + .navigation-plugin-table${uniqueSuffix} td .symlink-name${uniqueSuffix} { + display: block; /* Ensure the element occupies the full width of the cell */ + width: 100%; /* Set the element's width to 100% of the cell */ + overflow: hidden; /* Trim content that exceeds element boundaries */ + white-space: nowrap; /* Prevent text from wrapping to a new line */ + text-overflow: ellipsis; /* Add ellipsis if text is trimmed */ + } + + .navigation-plugin-table${uniqueSuffix} tbody td:last-child { + border-right: none; + } + + /* Resizer styles */ + .resizer${uniqueSuffix}, + .navigation-plugin-table${uniqueSuffix} th .resizer { + position: absolute; + right: 0; + top: 0; + width: 5px; + height: 100%; + cursor: col-resize; + user-select: none; + background-color: transparent; + z-index: 10; + transition: background-color 0.2s; + } + + .resizer${uniqueSuffix}:hover, + .navigation-plugin-table${uniqueSuffix} th .resizer:hover { + background-color: rgba(0, 0, 0, 0.1); + } + + /* Overlay for drag and drop */ + .navigation-plugin-drag-overlay${uniqueSuffix} { + display: none; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + color: white; + align-items: center; + justify-content: center; + font-size: 24px; + z-index: 500; + flex-direction: column; + pointer-events: none; /* Ensure the overlay doesn't block interactions */ + } + + /* Actions bar below the scrollable area */ + .navigation-plugin-actions-bar${uniqueSuffix} { + display: flex; + align-items: center; + gap: 10px; + margin-top: 10px; + } + + .navigation-plugin-actions-bar${uniqueSuffix} button { + padding: 12px 24px; + font-size: 18px; + cursor: pointer; + background-color: #007BFF; + color: white; + border: none; + border-radius: 4px; + transition: background-color 0.2s; + } + + .navigation-plugin-actions-bar${uniqueSuffix} button:hover { + background-color: #0056b3; + } + + /* Actions column styles */ + .navigation-plugin-actions${uniqueSuffix} { + display: flex; /* Use flexbox for even distribution of icons */ + align-items: center; + justify-content: flex-start; /* Align icons to the left */ + gap: 8px; /* Even spacing between icons */ + padding: 4px 8px; /* Add padding for better appearance */ + box-sizing: border-box; /* Include padding in width calculation */ + } + + .navigation-plugin-actions${uniqueSuffix} .action-button${uniqueSuffix} { + cursor: pointer; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + background: none; + font-size: 18px; + transition: background-color 0.2s, border-radius 0.2s; + } + + .navigation-plugin-actions${uniqueSuffix} .action-button${uniqueSuffix}:hover { + background-color: #f0f0f0; + border-radius: 4px; + } + + /* Dark theme */ + .dark-theme .navigation-plugin${uniqueSuffix} { + background-color: #2a2a2a; + border: 1px solid #555555; + color: #ffffff; + } + + .dark-theme .navigation-plugin-header${uniqueSuffix} input[type="text"] { + background-color: #444444; + color: #ffffff; + border: 1px solid #666666; + } + + .dark-theme .navigation-plugin-header${uniqueSuffix} button { + background-color: #555555; + color: #ffffff; + border: 1px solid #666666; + } + + .dark-theme .navigation-plugin-header${uniqueSuffix} button:hover { + background-color: #666666; + } + + .dark-theme .navigation-plugin-table${uniqueSuffix} thead th { + background-color: #333333; + color: #ffffff; + border-bottom: 2px solid #888; /* Brighter color */ + border-right: 1px solid #888; /* Added vertical border */ + } + + .dark-theme .navigation-plugin-table-container${uniqueSuffix} { + border: 1px solid #555; + } + + .dark-theme .navigation-plugin-table${uniqueSuffix} tbody td { + background-color: #2a2a2a; + color: #ffffff; + border-bottom: 1px solid #888; /* Brighter color */ + border-right: 1px solid #888; /* Added vertical border */ + } + + /* Row highlighting */ + .navigation-plugin-table${uniqueSuffix} tbody tr:hover { + background-color: #f0f0f0; /* Lighter gray background */ + color: #000000; /* Black text color */ + cursor: pointer; /* Change cursor to pointer */ + transition: background-color 0.3s, color 0.3s; /* Smooth transition */ + } + + .dark-theme .navigation-plugin-table${uniqueSuffix} tbody tr:hover { + background-color: #555555; /* Lighter gray background for dark theme */ + color: #ffffff; /* White text color */ + cursor: pointer; + transition: background-color 0.3s, color 0.3s; + } + + .dark-theme .navigation-plugin-table${uniqueSuffix} .directory-name${uniqueSuffix} { + color: #1e90ff; + } + + .dark-theme .navigation-plugin-table${uniqueSuffix} .symlink-name${uniqueSuffix} { + color: #32cd32; + } + + .navigation-plugin-table${uniqueSuffix} .directory-name${uniqueSuffix} { + color: #1e90ff; /* Blue for directories */ + } + + .navigation-plugin-table${uniqueSuffix} .symlink-name${uniqueSuffix} { + color: #32cd32; /* Green for symbolic links */ + } + + .navigation-plugin-table${uniqueSuffix} .file-name${uniqueSuffix} { + color: #000000; /* Black for regular files */ + } + + /* Cursor styles for draggable and clickable elements */ + .navigation-plugin-table${uniqueSuffix} .file-name${uniqueSuffix}[draggable="true"], + .navigation-plugin-table${uniqueSuffix} .draggable${uniqueSuffix} { + cursor: grab; + } + + .navigation-plugin-table${uniqueSuffix} .file-name${uniqueSuffix}[draggable="true"]:active, + .navigation-plugin-table${uniqueSuffix} .draggable${uniqueSuffix}:active { + cursor: grabbing; + } + + .navigation-plugin-table${uniqueSuffix} .directory-name${uniqueSuffix}, + .navigation-plugin-table${uniqueSuffix} .symlink-name${uniqueSuffix}, + .navigation-plugin-table${uniqueSuffix} .file-name${uniqueSuffix} { + cursor: pointer; + } + + /* Styles for custom tooltip */ + .navigation-plugin-tooltip${uniqueSuffix} { + position: absolute; + background-color: rgba(0, 0, 0, 0.8); /* Dark background with transparency */ + color: #fff; /* White text */ + padding: 5px 10px; + border-radius: 4px; + font-size: 14px; + pointer-events: none; /* Tooltip does not interfere with interactions */ + white-space: nowrap; /* Prevent text wrapping */ + z-index: 1001; /* Above all other elements */ + opacity: 0; + transition: opacity 0.2s ease-in-out; + } + + .navigation-plugin-tooltip${uniqueSuffix}.visible { + opacity: 1; + } + + /* Highlight directory row on dragover */ + .navigation-plugin-table${uniqueSuffix} tbody tr.drag-over${uniqueSuffix} { + background-color: #d3d3d3; /* Light gray */ + } + + /* Highlight for copy action */ + .navigation-plugin-table${uniqueSuffix} tbody tr.drag-over-copy${uniqueSuffix} { + background-color: #add8e6; /* Light blue */ + cursor: copy; + } + + /* Highlight for move action */ + .navigation-plugin-table${uniqueSuffix} tbody tr.drag-over-move${uniqueSuffix} { + background-color: #90ee90; /* Light green */ + cursor: move; + } + + /* Cursor styles for copy and move */ + .navigation-plugin-table${uniqueSuffix} tbody tr.drag-over-copy${uniqueSuffix} td, + .navigation-plugin-table${uniqueSuffix} tbody tr.drag-over-move${uniqueSuffix} td { + cursor: inherit; /* Inherit cursor from parent tr */ + } + + /* Single table with fixed headers */ + .navigation-plugin-table${uniqueSuffix} { + /* Removed redundant width and table-layout properties */ + } + + /* Minimal width for navigation plugin */ + .navigation-plugin${uniqueSuffix} { + min-width: 780px; /* Sum of columnWidths */ + box-sizing: border-box; + } + + /* Fixed row height to prevent height changes on column resize */ + .navigation-plugin-table${uniqueSuffix} tbody tr { + height: 40px; /* Fixed height */ + min-height: 40px; /* Ensure minimum height */ + } + + /* Ensuring the table container maintains its height */ + .navigation-plugin-table-container${uniqueSuffix} { + height: 100%; /* Maintain full height */ + } + .cbi-progressbar${uniqueSuffix} { + width: 100%; + background-color: #f3f3f3; + border: 1px solid #ccc; + border-radius: 5px; + height: 20px; + overflow: hidden; + margin-top: 10px; + } + + .cbi-progressbar${uniqueSuffix} div { + height: 100%; + background-color: #4caf50; + width: 0%; + transition: width 0.2s; + } + + #status-info${uniqueSuffix} { + margin-bottom: 5px; + font-weight: bold; + } + + #status-progress${uniqueSuffix} { + margin-bottom: 10px; + } + + `; + }, + + + /** + * Refreshes the navigation by reloading the current directory. + */ + refresh: function() { + this.loadDirectory(this.currentDir); + }, + + /** + * Initializes the plugin within the provided container. + * @param {HTMLElement} container - The DOM element to contain the plugin UI. + * @param {Object} pluginsRegistry - The registry of available plugins. + * @param {Object} default_plugins - The default plugins for each type. + * @param {string} uniqueSuffix - The unique suffix to append to class names and IDs. + */ + start: function(container, pluginsRegistry, default_plugins, uniqueSuffix) { + var self = this; + self.default_plugins = default_plugins; + self.pluginsRegistry = pluginsRegistry; + self.uniqueSuffix = `-${uniqueSuffix}`; // Store the unique suffix with a preceding dash + + var defaultDispatcherName = self.default_plugins['Dispatcher']; + + if (defaultDispatcherName && self.pluginsRegistry[defaultDispatcherName]) { + var defaultDispatcher = self.pluginsRegistry[defaultDispatcherName]; + + if (typeof defaultDispatcher.pop === 'function') { + self.popm = defaultDispatcher.pop.bind(defaultDispatcher); + console.log(`Pop function successfully retrieved from Dispatcher: ${defaultDispatcherName}`); + } else { + console.error(`Default Dispatcher "${defaultDispatcherName}" does not implement pop().`); + self.popm = function() { + console.warn(`Fallback: pop function is not available because Default Dispatcher "${defaultDispatcherName}" does not implement pop().`); + }; + } + } else { + console.error('Default Dispatcher not found in pluginsRegistry.'); + self.popm = function() { + console.warn('Fallback: pop function is not available because Default Dispatcher not found in pluginsRegistry.'); + }; + } + + // Initialize settings by merging defaultSettings and existing settings + self.settings = Object.assign({}, self.defaultSettings, self.settings || {}); + + // Inject the modified CSS into the document + self.injectCSS(); + + // Create navDiv and other DOM elements with unique suffix + self.navDiv = document.createElement('div'); + self.navDiv.className = 'navigation-plugin' + self.uniqueSuffix; + + // Header with path input field and "Go" button + var headerDiv = document.createElement('div'); + headerDiv.className = 'navigation-plugin-header' + self.uniqueSuffix; + + var pathInput = document.createElement('input'); + pathInput.type = 'text'; + pathInput.value = self.currentDir; + pathInput.placeholder = 'Enter path...'; + + pathInput.addEventListener('keydown', function(event) { + if (event.key === 'Enter') { + self.navigateToPath(pathInput.value.trim()); + } + }); + + var goButton = document.createElement('button'); + goButton.textContent = 'Go'; + goButton.onclick = function() { + self.navigateToPath(pathInput.value.trim()); + }; + + headerDiv.appendChild(pathInput); + headerDiv.appendChild(goButton); + self.navDiv.appendChild(headerDiv); + + // Breadcrumb below the header + self.breadcrumb = document.createElement('div'); + self.breadcrumb.className = 'navigation-plugin-breadcrumb' + self.uniqueSuffix; + self.navDiv.appendChild(self.breadcrumb); + + // Create tableContainer and table + self.tableContainer = document.createElement('div'); + self.tableContainer.className = 'navigation-plugin-table-container' + self.uniqueSuffix; + + self.table = document.createElement('table'); + self.table.className = 'navigation-plugin-table' + self.uniqueSuffix; + + // Add colgroup to manage column widths + self.colGroup = document.createElement('colgroup'); + ['select', 'name', 'type', 'size', 'mtime', 'actions'].forEach(function(field) { + var col = document.createElement('col'); + // Set width from columnWidths or default value + col.style.width = (self.settings.columnWidths[field] || 100) + 'px'; + col.dataset.field = field; + self.colGroup.appendChild(col); + }); + self.table.appendChild(self.colGroup); + + // Create table header + self.thead = document.createElement('thead'); + var headerRow = document.createElement('tr'); + + // "Select All" column header + var selectAllHeader = document.createElement('th'); + selectAllHeader.dataset.field = 'select'; + selectAllHeader.style.width = (self.settings.columnWidths['select'] || 30) + 'px'; + + var selectAllCheckbox = document.createElement('input'); + selectAllCheckbox.type = 'checkbox'; + selectAllCheckbox.onclick = function(event) { + self.handleSelectAll(this.checked, event); + }; + selectAllHeader.appendChild(selectAllCheckbox); + + // Add resizer for the select column + var selectResizer = document.createElement('div'); + selectResizer.className = 'resizer' + self.uniqueSuffix; + selectAllHeader.appendChild(selectResizer); + selectResizer.addEventListener('mousedown', function(e) { + self.initColumnResize(e, 'select'); + }); + + headerRow.appendChild(selectAllHeader); + + self.selectedItems = new Set(); + self.sortField = 'name'; + self.sortDirection = 'asc'; + + // Create other column headers + ['Name', 'Type', 'Size', 'Modification Date'].forEach(function(title, index) { + var field = ['name', 'type', 'size', 'mtime'][index]; + var sortableHeader = self.createSortableHeader(title, field); + headerRow.appendChild(sortableHeader); + }); + + // "Actions" column header + var actionsHeader = document.createElement('th'); + actionsHeader.textContent = 'Actions'; // No sorting + actionsHeader.dataset.field = 'actions'; + actionsHeader.style.width = (self.settings.columnWidths['actions'] || 200) + 'px'; + + // Add resizer for the Actions column + var actionsResizer = document.createElement('div'); + actionsResizer.className = 'resizer' + self.uniqueSuffix; + actionsHeader.appendChild(actionsResizer); + actionsResizer.addEventListener('mousedown', function(e) { + self.initColumnResize(e, 'actions'); + }); + + headerRow.appendChild(actionsHeader); + + self.thead.appendChild(headerRow); + self.table.appendChild(self.thead); + + // Create table body + self.tbody = document.createElement('tbody'); + self.table.appendChild(self.tbody); + + // Add footer for drag-and-drop + self.dragOverlay = document.createElement('div'); + self.dragOverlay.className = 'navigation-plugin-drag-overlay' + self.uniqueSuffix; + self.dragOverlay.textContent = _('Drop files here to upload'); + self.tableContainer.appendChild(self.dragOverlay); + + self.tableContainer.appendChild(self.table); + self.navDiv.appendChild(self.tableContainer); + + // Actions bar below the table + self.actionsBar = document.createElement('div'); + self.actionsBar.className = 'navigation-plugin-actions-bar' + self.uniqueSuffix; + + self.uploadButton = document.createElement('button'); + self.uploadButton.textContent = _('Upload'); + self.uploadButton.onclick = function() { + self.handleUploadClick(); + }; + + self.createFolderButton = document.createElement('button'); + self.createFolderButton.textContent = _('Create Folder'); + self.createFolderButton.onclick = function() { + self.handleCreateFolderClick(); + }; + + self.createFileButton = document.createElement('button'); + self.createFileButton.textContent = _('Create File'); + self.createFileButton.onclick = function() { + self.handleCreateFileClick(); + }; + + self.deleteSelectedButton = document.createElement('button'); + self.deleteSelectedButton.textContent = _('Delete Selected'); + self.deleteSelectedButton.disabled = true; + self.deleteSelectedButton.onclick = function() { + self.handleDeleteSelectedClick(); + }; + + self.actionsBar.appendChild(self.uploadButton); + self.actionsBar.appendChild(self.createFolderButton); + self.actionsBar.appendChild(self.createFileButton); + self.actionsBar.appendChild(self.deleteSelectedButton); + + self.navDiv.appendChild(self.actionsBar); + container.appendChild(self.navDiv); + + // Add drag-and-drop event handlers + self.addDragAndDropEvents(); + + document.addEventListener(('tab-' + `${PN}`), function(e) { + self.refresh(); + }); + + // Now call applySettingsToUI after creating all DOM elements + self.applySettingsToUI(); + + // Load the current directory + self.loadDirectory(self.currentDir); + + // Adding ResizeObserver for navDiv observing + if (typeof ResizeObserver !== 'undefined') { + self.resizeObserver = new ResizeObserver(entries => { + for (let entry of entries) { + const { + width, + height + } = entry.contentRect; + const newWidth = Math.round(width); + const newHeight = Math.round(height); + + // Check if dimensions has changed noticably (> 10px) + const widthChanged = Math.abs(self.settings.width - newWidth) > 10; + const heightChanged = Math.abs(self.settings.height - newHeight) > 10; + + if (widthChanged || heightChanged) { + // Update settings + self.settings.width = newWidth; + self.settings.height = newHeight; + } + } + }); + + // Start observing of navDiv + self.resizeObserver.observe(self.tableContainer); + } else { + console.warn(`[${PN}]: ResizeObserver is not supported in this browser`); + } + + // Add the unique CSS to the document + self.injectCSS(); + }, + + /** + * Injects the CSS styles into the document. + */ + injectCSS: function() { + var self = this; + // Create a style element + var style = document.createElement('style'); + style.type = 'text/css'; + style.textContent = self.css(); + // Append the style to the head + document.head.appendChild(style); + }, + + /** + * Shows a tooltip with the specified text near the mouse cursor. + * @param {MouseEvent} event - The mouse event. + * @param {string} text - The tooltip text. + */ + showTooltip: function(event, text) { + var self = this; + + // Create the tooltip element if it doesn't exist + if (!self.tooltipElement) { + self.tooltipElement = document.createElement('div'); + self.tooltipElement.className = 'navigation-plugin-tooltip' + self.uniqueSuffix; + document.body.appendChild(self.tooltipElement); + } + + self.tooltipElement.textContent = text; + self.tooltipElement.classList.add('visible'); + self.positionTooltip(event); + self.currentTooltip = true; + }, + + /** + * Hides the currently visible tooltip. + */ + hideTooltip: function() { + var self = this; + if (self.tooltipElement) { + self.tooltipElement.classList.remove('visible'); + self.currentTooltip = false; + } + }, + + /** + * Positions the tooltip relative to the mouse cursor. + * @param {MouseEvent} event - The mouse event. + */ + positionTooltip: function(event) { + var self = this; + if (self.tooltipElement) { + var tooltip = self.tooltipElement; + var tooltipWidth = tooltip.offsetWidth; + var tooltipHeight = tooltip.offsetHeight; + var pageWidth = document.documentElement.clientWidth; + var pageHeight = document.documentElement.clientHeight; + + var x = event.pageX + 10; // Offset to the right of the cursor + var y = event.pageY + 10; // Offset below the cursor + + // Ensure the tooltip doesn't go beyond the right edge + if (x + tooltipWidth > pageWidth) { + x = event.pageX - tooltipWidth - 10; + } + + // Ensure the tooltip doesn't go beyond the bottom edge + if (y + tooltipHeight > pageHeight) { + y = event.pageY - tooltipHeight - 10; + } + + tooltip.style.left = x + 'px'; + tooltip.style.top = y + 'px'; + } + }, + + /** + * Creates a sortable table header. + * @param {string} title - The display title of the column. + * @param {string} field - The field name associated with the column. + * @returns {HTMLElement} - The created header element. + */ + createSortableHeader: function(title, field) { + var self = this; + var header = document.createElement('th'); + header.dataset.field = field; + // header.style.position = 'relative'; + header.style.textAlign = 'left'; // Align text to the left + + var headerContent = document.createElement('div'); + headerContent.style.display = 'inline-flex'; + headerContent.style.alignItems = 'center'; + headerContent.style.cursor = 'pointer'; + headerContent.style.userSelect = 'none'; // Prevent text selection + + var titleSpan = document.createElement('span'); + titleSpan.textContent = title; + + var sortIcon = document.createElement('span'); + sortIcon.style.marginLeft = '5px'; + + if (self.sortField === field) { + sortIcon.textContent = self.sortDirection === 'asc' ? '▲' : '▼'; + } else { + sortIcon.textContent = '⇅'; + } + + headerContent.appendChild(titleSpan); + headerContent.appendChild(sortIcon); + header.appendChild(headerContent); + + var resizer = document.createElement('div'); + resizer.className = 'resizer' + self.uniqueSuffix; + header.appendChild(resizer); + + // Add event listener for column resizing + resizer.addEventListener('mousedown', function(e) { + self.initColumnResize(e, field); + }); + + // Add event listener for sorting + header.addEventListener('click', function(e) { + if (e.target.classList.contains('resizer' + self.uniqueSuffix)) return; // Ignore clicks on resizer + var clickedField = header.dataset.field; + if (self.sortField === clickedField) { + self.sortDirection = self.sortDirection === 'asc' ? 'desc' : 'asc'; + } else { + self.sortField = clickedField; + self.sortDirection = 'asc'; + } + self.loadDirectory(self.currentDir); + }); + + return header; + }, + + /** + * Navigates to a specified path. + * @param {string} path - The path to navigate to. + */ + navigateToPath: function(path) { + var self = this; + fs.stat(path).then(function(stat) { + if (stat.type === 'directory') { + self.currentDir = path.endsWith('/') ? path : path + '/'; + self.settings.currentDir = self.currentDir; + // self.set_settings(self.settings); + self.loadDirectory(self.currentDir); + } else { + self.popm(null, `[${PN}]: ` + _('The specified path is not a directory.'), 'error'); + } + }).catch(function(err) { + self.popm(null, `[${PN}]: ` + _('Failed to access the specified path: %s').format(err.message), 'error'); + }); + }, + + /** + * Initializes column resizing. + * @param {MouseEvent} e - The mouse event. + * @param {string} field - The field name of the column being resized. + */ + initColumnResize: function(e, field) { + var self = this; + e.preventDefault(); + + self.resizingField = field; + self.startX = e.pageX; + // Get initial width from settings or from the col element + self.startWidth = self.columnWidths[field] || self.table.querySelector(`col[data-field="${field}"]`).offsetWidth; + + self.boundOnColumnResize = self.onColumnResize.bind(self); + self.boundStopColumnResize = self.stopColumnResize.bind(self); + + // Add listeners for mouse movement and mouse release + document.addEventListener('mousemove', self.boundOnColumnResize); + document.addEventListener('mouseup', self.boundStopColumnResize); + + // Disable text selection during column resizing for better UX + document.body.style.userSelect = 'none'; + }, + + /** + * Handles the column resize movement. + * @param {MouseEvent} e - The mouse event. + */ + onColumnResize: function(e) { + var self = this; + if (!self.resizingField) return; + + var diffX = e.pageX - self.startX; + var newWidth = self.startWidth + diffX; + + var minWidth = self.mincolumnWidths[self.resizingField] || 30; + if (newWidth < minWidth) { + newWidth = minWidth; + } + + // Just update the column width directly, do not re-apply entire UI settings yet. + self.updateColumnWidth(self.resizingField, newWidth); + }, + + /** + * Stops the column resizing process. + * @param {MouseEvent} e - The mouse event. + */ + stopColumnResize: function(e) { + var self = this; + document.removeEventListener('mousemove', self.boundOnColumnResize); + document.removeEventListener('mouseup', self.boundStopColumnResize); + self.resizingField = null; + + // Re-enable text selection + document.body.style.userSelect = ''; + + // After the user finishes resizing, apply settings to ensure all adjustments are correctly displayed. + self.applySettingsToUI(); + }, + + /** + * Adds drag and drop event listeners to the table container. + */ + addDragAndDropEvents: function() { + var self = this; + var counter = 0; + + self.tableContainer.addEventListener('dragenter', function(e) { + e.preventDefault(); + e.stopPropagation(); + counter++; + self.dragOverlay.style.display = 'flex'; + }); + + self.tableContainer.addEventListener('dragleave', function(e) { + e.preventDefault(); + e.stopPropagation(); + counter--; + if (counter === 0) { + self.dragOverlay.style.display = 'none'; + } + }); + + self.tableContainer.addEventListener('dragover', function(e) { + e.preventDefault(); + e.stopPropagation(); + }); + + self.tableContainer.addEventListener('drop', function(e) { + e.preventDefault(); + e.stopPropagation(); + self.dragOverlay.style.display = 'none'; + counter = 0; + var files = e.dataTransfer.files; + if (files.length > 0) { + self.uploadFiles(files); + } + }); + }, + + /** + * Handles the upload button click event. + */ + handleUploadClick: function() { + var self = this; + var fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.multiple = true; + fileInput.style.display = 'none'; + document.body.appendChild(fileInput); + fileInput.onchange = function(e) { + var files = e.target.files; + if (files.length > 0) { + self.uploadFiles(files); + } + document.body.removeChild(fileInput); + }; + fileInput.click(); + }, + + /** + * Uploads a single file. + * @param {string} filename - The name of the file. + * @param {File} filedata - The file data. + * @param {string} permissions - File permissions (e.g., '644'). + * @param {string} ownerGroup - Ownership in the format 'owner:group' (e.g., 'root:root'). + * @param {function} onProgress - Callback for upload progress. + * @returns {Promise} - Resolves on successful upload and setting permissions/ownership, rejects on failure. + */ + uploadFile: function(filename, filedata, permissions, ownerGroup, onProgress) { + var self = this; + + self.perm = String(permissions || self.defaultFilePermissions); + self.oG = ownerGroup || (self.settings.defaultOwner + ':' + self.settings.defaultGroup); + return new Promise(function(resolve, reject) { + console.log("UploadFile filename:", filename); + var formData = new FormData(); + formData.append('sessionid', rpc.getSessionID()); + formData.append('filename', filename); + formData.append('filedata', filedata); + + var xhr = new XMLHttpRequest(); + xhr.open('POST', L.env.cgi_base + '/cgi-upload', true); + + xhr.upload.onprogress = function(event) { + if (event.lengthComputable && onProgress) { + var percent = (event.loaded / event.total) * 100; + onProgress(percent); + } + }; + + xhr.onload = function() { + console.log("UploadFile Server response:", xhr.responseText); + + if (xhr.status === 200) { + // After successful upload, set permissions and ownership + var chmodPromise = self.perm ? fs.exec('/bin/chmod', [self.perm, filename]) : Promise.resolve(); + var chownPromise = self.oG ? fs.exec('/bin/chown', [self.oG, filename]) : Promise.resolve(); + Promise.all([chmodPromise, chownPromise]) + .then(function() { + resolve(xhr.responseText); + }) + .catch(function(err) { + console.error(`[${PN}]: ` + _('Failed to set permissions or ownership:'), err); + reject(err); + }); + } else { + reject(new Error(xhr.statusText)); + } + }; + + xhr.onerror = function() { + reject(new Error(`[${PN}]: ` + _('Network error'))); + }; + + xhr.send(formData); + }); + }, + + /** + * Uploads multiple files sequentially. + * @param {FileList} files - The list of files to upload. + */ + uploadFiles: function(files) { + var self = this; + var directoryPath = self.currentDir; + var totalFiles = files.length; + + var statusInfo = self.statusInfo; + var statusProgress = self.statusProgress; + + if (!statusInfo) { + statusInfo = document.createElement('div'); + statusInfo.id = 'status-info' + self.uniqueSuffix; + // Insert above tableContainer for visibility + self.tableContainer.parentNode.insertBefore(statusInfo, self.tableContainer); + self.statusInfo = statusInfo; + } + + if (!statusProgress) { + statusProgress = document.createElement('div'); + statusProgress.id = 'status-progress' + self.uniqueSuffix; + self.tableContainer.parentNode.insertBefore(statusProgress, self.tableContainer); + self.statusProgress = statusProgress; + } + + /** + * Uploads the next file in the queue. + * @param {number} index - The current file index. + */ + function uploadNextFile(index) { + if (index >= totalFiles) { + self.loadDirectory(self.currentDir); + return; + } + + var file = files[index]; + var fullFilePath = self.concatPath(directoryPath, file.name); + + if (statusInfo) { + statusInfo.textContent = `[${PN}]: ` + _('Uploading "%s"...').format(file.name); + } + if (statusProgress) { + statusProgress.innerHTML = ''; + var progressBarContainer = E('div', { + 'class': 'cbi-progressbar' + self.uniqueSuffix, + 'title': '0%' + }, [E('div', { + 'style': 'width:0%' + })]); + statusProgress.appendChild(progressBarContainer); + } + + // Define permissions and ownership + var permissions = self.settings.defaultFilePermissions; + var ownerGroup = (self.settings.defaultOwner + ':' + self.settings.defaultGroup); + + self.uploadFile(fullFilePath, file, permissions, ownerGroup, function(percent) { + if (statusProgress) { + var progressBar = statusProgress.querySelector('.cbi-progressbar' + self.uniqueSuffix + ' div'); + if (progressBar) { + progressBar.style.width = percent.toFixed(2) + '%'; + statusProgress.querySelector('.cbi-progressbar' + self.uniqueSuffix).setAttribute('title', percent.toFixed(2) + '%'); + } + } + }).then(function() { + if (statusProgress) { + statusProgress.innerHTML = ''; + } + if (statusInfo) { + statusInfo.textContent = `[${PN}]: ` + _('File "%s" uploaded successfully.').format(file.name); + } + self.popm(null, `[${PN}]: ` + _('File "%s" uploaded successfully.').format(file.name), 'info'); + uploadNextFile(index + 1); + }).catch(function(err) { + if (statusProgress) { + statusProgress.innerHTML = ''; + } + if (statusInfo) { + statusInfo.textContent = `[${PN}]: ` + _('Upload failed for file "%s".').format(file.name); + } + self.popm(null, `[${PN}]: ` + _('Error uploading file "%s".').format(file.name), 'error'); + uploadNextFile(index + 1); + }); + } + + // Start uploading files sequentially + uploadNextFile(0); + }, + + /** + * Handles the "Create Folder" button click event. + */ + handleCreateFolderClick: function() { + var self = this; + var folderName = prompt(`[${PN}]: ` + _('Enter folder name:')); + if (folderName) { + var folderPath = self.concatPath(self.currentDir, folderName); + fs.exec('/bin/mkdir', [folderPath]).then(function() { + return fs.exec('/bin/chmod', [self.settings.defaultDirPermissions, folderPath]); + }).then(function() { + self.popm(null, `[${PN}]: ` + _('Folder "%s" created successfully.').format(folderName), 'info'); + self.settings.currentDir = self.currentDir; + // self.set_settings(self.settings); + self.loadDirectory(self.currentDir); + }).catch(function(err) { + self.popm(null, `[${PN}]: ` + _('Failed to create folder "%s": %s').format(folderName, err.message), 'error'); + }); + } + }, + + /** + * Handles the "Create File" button click event. + */ + handleCreateFileClick: function() { + var self = this; + var fileName = prompt(`[${PN}]: ` + _('Enter file name:')); + if (fileName) { + var filePath = self.concatPath(self.currentDir, fileName); + fs.exec('/bin/touch', [filePath]).then(function() { + return fs.exec('/bin/chmod', [self.settings.defaultFilePermissions, filePath]); + }).then(function() { + self.popm(null, `[${PN}]: ` + _('File "%s" created successfully.').format(fileName), 'info'); + self.settings.currentDir = self.currentDir; + // self.set_settings(self.settings); + self.loadDirectory(self.currentDir); + }).catch(function(err) { + self.popm(null, `[${PN}]: ` + _('Failed to create file "%s": %s').format(fileName, err.message), 'error'); + }); + } + }, + + /** + * Handles the "Delete Selected" button click event. + */ + handleDeleteSelectedClick: function() { + var self = this; + if (self.selectedItems.size === 0) return; + + if (confirm(`[${PN}]: ` + _('Are you sure you want to delete the selected items?'))) { + var deletePromises = []; + self.selectedItems.forEach(function(filePath) { + deletePromises.push(fs.remove(filePath)); + }); + + Promise.allSettled(deletePromises).then(function(results) { + var successCount = 0; + var failureCount = 0; + var failedItems = []; + + results.forEach(function(result, index) { + if (result.status === 'fulfilled') { + successCount++; + } else { + failureCount++; + failedItems.push(Array.from(self.selectedItems)[index]); + } + }); + + if (successCount > 0) { + self.popm(null, `[${PN}]: ` + _('Successfully deleted %d items.').format(successCount), 'info'); + } + if (failureCount > 0) { + failedItems.forEach(function(item) { + self.popm(null, `[${PN}]: ` + _('Failed to delete "%s".').format(item), 'error'); + }); + } + + self.loadDirectory(self.currentDir); + self.updateDeleteSelectedButtonState(); + }); + } + }, + + /** + * Handles the "Select All" checkbox click event. + * @param {boolean} checked - Whether the checkbox is checked. + */ + handleSelectAll: function(checked, event) { + var self = this; + + // If Alt was pressed, invert selection + if (event && event.altKey) { + var checkboxes = self.tbody.querySelectorAll('.select-item' + self.uniqueSuffix); + checkboxes.forEach(function(checkbox) { + checkbox.checked = !checkbox.checked; // Invert current state + var filePath = checkbox.dataset.path; + if (checkbox.checked) { + self.selectedItems.add(filePath); + } else { + self.selectedItems.delete(filePath); + } + }); + } else { + // Regular "Select All" + var checkboxes = self.tbody.querySelectorAll('.select-item' + self.uniqueSuffix); + checkboxes.forEach(function(checkbox) { + checkbox.checked = checked; + var filePath = checkbox.dataset.path; + if (checked) { + self.selectedItems.add(filePath); + } else { + self.selectedItems.delete(filePath); + } + }); + } + + self.updateDeleteSelectedButtonState(); + }, + + /** + * Updates the state of the "Delete Selected" button based on selected items. + */ + updateDeleteSelectedButtonState: function() { + var self = this; + if (self.deleteSelectedButton) { + self.deleteSelectedButton.disabled = self.selectedItems.size === 0; + } + }, + + convertPermissionsToNumeric: function(permissions) { + const mapping = { + 'r': 4, + 'w': 2, + 'x': 1, + '-': 0 + }; + let specialBits = 0; + + // Handling "special" bits (setuid, setgid, sticky bit) + if (permissions[2] === 's') specialBits += 4000; // setuid with execute permissions + if (permissions[2] === 'S') specialBits += 4000; // setuid without execute permissions + if (permissions[5] === 's') specialBits += 2000; // setgid with execute permissions + if (permissions[5] === 'S') specialBits += 2000; // setgid without execute permissions + if (permissions[8] === 't') specialBits += 1000; // sticky bit with execute permissions + if (permissions[8] === 'T') specialBits += 1000; // sticky bit without execute permissions + + // Remove "s", "S", "t", "T" symbols before calculation + permissions = permissions + .replace(/s/g, 'x') // Replace `s` with `x` + .replace(/S/g, '-') // Replace `S` with `-` + .replace(/t/g, 'x') // Replace `t` with `x` + .replace(/T/g, '-'); // Replace `T` with `-` + + // Convert to numeric format + const numericPermissions = permissions + .slice(0, 9) // Take only access rights, excluding file type + .match(/.{1,3}/g) // Split into groups of three characters (e.g., `rwx`, `r-x`) + .map(group => group.split('').reduce((sum, char) => sum + mapping[char], 0)) // Convert to numbers + .join(''); + + return specialBits + parseInt(numericPermissions, 10); // Add "special" bits + }, + + /** + * Parses a single line of `ls -lA --full-time` output. + * @param {string} line - A single line from the `ls` command output. + * @returns {Object|null} - Returns an object with file information or null if parsing fails. + */ + + parseLsLine: function(line) { + const regex = /^([\-dl])[rwx\-]{2}[rwx\-Ss]{1}[rwx\-]{2}[rwx\-Ss]{1}[rwx\-]{2}[rwx\-Tt]{1}\s+\d+\s+(\S+)\s+(\S+)\s+(\d+)\s+([\d\-]+\s+[\d\:\.]{8,12}\s+\+\d{4})\s+(.+)$/; + const parts = line.match(regex); + if (!parts || parts.length < 7) { + console.warn('Failed to parse line:', line); + return null; + } + + const typeChar = parts[1]; // File type + const owner = parts[2]; // Owner + const group = parts[3]; // Group + const size = parseInt(parts[4], 10); // Size in bytes + const mtime = new Date(parts[5]).toLocaleString(); // Modification date + let nameField = parts[6].trim(); // File or symbolic link name + + const isDirectory = typeChar === 'd'; + const isSymlink = typeChar === 'l'; + let name = nameField; + let linkTarget = null; + + // Handling symbolic links + if (isSymlink) { + const arrowIndex = nameField.indexOf(' -> '); + if (arrowIndex !== -1) { + name = nameField.substring(0, arrowIndex).trim(); + linkTarget = nameField.substring(arrowIndex + 4).trim(); + } + } + + const type = isDirectory ? 'Directory' : isSymlink ? 'Symlink' : 'File'; + + return { + name: name, + size: size, + date: mtime, + type: type, + permissions: this.convertPermissionsToNumeric(line.slice(1, 10)), // Convert permissions + owner: owner, + group: group, + isDirectory: isDirectory, + isSymlink: isSymlink, + linkTarget: linkTarget, + mtime: new Date(parts[5]).getTime() + }; + }, + + /** + * Loads and displays the contents of a directory. + * @param {string} dir - The directory path to load. + */ + loadDirectory: function(dir) { + var self = this; + self.lastLoadId = (self.lastLoadId || 0) + 1; + var loadId = self.lastLoadId; + + + // Do not clear selectedItems set, as it should persist across sorting + // self.selectedItems.clear(); + self.updateDeleteSelectedButtonState(); + + if (!self.loadingIndicator) { + self.loadingIndicator = document.createElement('div'); + self.loadingIndicator.className = 'navigation-plugin-loading' + self.uniqueSuffix; + self.loadingIndicator.textContent = `[${PN}]: ` + _('Loading...'); + // Insert before breadcrumb for visibility + self.navDiv.insertBefore(self.loadingIndicator, self.breadcrumb); + } + self.loadingIndicator.style.display = 'block'; + + self.tbody.innerHTML = ''; + + var pathInput = self.navDiv.querySelector('.navigation-plugin-header' + self.uniqueSuffix + ' input[type="text"]'); + if (pathInput) { + pathInput.value = self.currentDir; + } + + fs.exec('/bin/ls', ['-lA', '--full-time', dir]).then(function(res) { + // Check load relevance + if (loadId !== self.lastLoadId) { + // Old result, ignore + return; + } + + self.loadingIndicator.style.display = 'none'; + + if (res.code !== 0) { + self.popm(null, `[${PN}]: ` + _('Failed to list directory: %s').format(res.stderr.trim()), 'error'); + self.tbody.innerHTML = '' + `[${PN}]: ` + _('Error loading directory.') + ''; + return; + } + + var lines = res.stdout.split('\n').filter(line => line.trim() !== ''); + var files = []; + lines.forEach(function(line) { + var file = self.parseLsLine(line); + if (file) { + files.push(file); + } + }); + + // Sort files based on current sort settings + files.sort(self.compareFiles.bind(self)); + + // Add parent directory entry if not in root + if (dir !== '/') { + self.addParentDirectoryEntry(); + } + + // Render each file row + files.forEach(function(file) { + self.renderFileRow(file); + }); + + // Update breadcrumb navigation + self.updateBreadcrumb(); + + // Update sort icons in headers + var headers = self.thead.querySelectorAll('th'); + headers.forEach(function(header) { + var field = header.dataset.field; + if (field) { // Only sortable columns + var sortIcon = header.querySelector('span:nth-child(2)'); + if (sortIcon) { // Check if sortIcon exists + if (self.sortField === field) { + sortIcon.textContent = self.sortDirection === 'asc' ? '▲' : '▼'; + } else { + sortIcon.textContent = '⇅'; + } + } + } + }); + // Restore selection state after loading directory + self.updateSelectionState(); + }).catch(function(err) { + if (loadId !== self.lastLoadId) return; // Old result, ignore + + self.loadingIndicator.style.display = 'none'; + console.error('Error listing directory:', err); + self.tbody.innerHTML = '' + `[${PN}]: ` + _('Error loading directory.') + ''; + }); + }, + + /** + * Renders a single file row in the table. + * @param {object} file - The file object containing its properties. + */ + renderFileRow: function(file) { + var self = this; + + // Create the table row element + var row = document.createElement('tr'); + row.className = (file.isDirectory ? 'directory' : file.isSymlink ? 'symlink' : 'file') + self.uniqueSuffix; + row.dataset.filePath = self.concatPath(self.currentDir, file.name); + + // Determine if this row represents the parent directory + var isParent = file.isParent || false; + + // Checkbox cell for selecting items + var checkboxCell = document.createElement('td'); + var checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.className = 'select-item' + self.uniqueSuffix; + checkbox.dataset.path = self.concatPath(self.currentDir, file.name); + checkbox.onclick = function() { + if (this.checked) { + self.selectedItems.add(this.dataset.path); + } else { + self.selectedItems.delete(this.dataset.path); + } + self.updateDeleteSelectedButtonState(); + }; + checkboxCell.appendChild(checkbox); + row.appendChild(checkboxCell); + + // Name cell with a clickable link + var nameCell = document.createElement('td'); + nameCell.className = 'name' + self.uniqueSuffix; + var nameLink = document.createElement('a'); + + if (file.isDirectory) { + nameLink.className = 'directory-name' + self.uniqueSuffix; + } else if (file.isSymlink) { + nameLink.className = 'symlink-name' + self.uniqueSuffix; + } else { + nameLink.className = 'file-name' + self.uniqueSuffix; + } + + nameLink.textContent = file.isSymlink ? `${file.name} -> ${file.linkTarget}` : file.name; + nameLink.onclick = function() { + if (file.isDirectory) { + self.enterDirectory(file.name); // Navigate into directory + } else { + self.openFileforEditing( + self.concatPath(self.currentDir, file.name), + file.permissions, + `${file.owner}:${file.group}` + ); // Open file for editing + } + }; + + // Make the link draggable if it's a regular file + if (!file.isDirectory && !file.isSymlink) { + nameLink.setAttribute('draggable', 'true'); + nameLink.classList.add('draggable' + self.uniqueSuffix); + + // Handle 'dragstart' event for files + nameLink.addEventListener('dragstart', function(ev) { + + // Get selected items or fallback to the current file + var selectedArray = Array.from(self.selectedItems); + if (selectedArray.length === 0) { + selectedArray = [self.concatPath(self.currentDir, file.name)]; + } + + // Set data in 'application/myapp-files' MIME types + var jsonData = JSON.stringify(selectedArray); + ev.dataTransfer.setData('application/myapp-files', jsonData); + + ev.dataTransfer.effectAllowed = 'copyMove'; + + self.draggedFiles = selectedArray; + + self.popm( + null, + `[${PN}]: ` + _('Dragging started. Drop onto a directory within this UI to copy/move files (Alt=copy), or drop outside the browser to download.'), + 'info' + ); + + }); + + // Handle 'dragend' event to manage fallback download + nameLink.addEventListener('dragend', function(ev) { + + // If dropEffect is 'none', initiate file download + if (self.draggedFiles && ev.dataTransfer.dropEffect === 'none') { + self.downloadFilesSequentially(self.draggedFiles); + } + self.draggedFiles = null; + }); + } + + // Tooltip handling for overflowing text (optional) + if (nameLink.scrollWidth > nameLink.clientWidth) { + nameLink.dataset.hasOverflow = 'true'; + + // Show tooltip on hover with a delay + let hoverTimer; + + nameLink.addEventListener('mouseenter', function(e) { + hoverTimer = setTimeout(function() { + self.showTooltip(e, file.isSymlink ? `${file.name} -> ${file.linkTarget}` : file.name); + }, 500); // 500ms delay + }); + + // Update tooltip position on mouse move + nameLink.addEventListener('mousemove', function(e) { + if (self.currentTooltip) { + self.positionTooltip(e); + } + }); + + // Hide tooltip on mouse leave + nameLink.addEventListener('mouseleave', function(e) { + clearTimeout(hoverTimer); + self.hideTooltip(); + }); + } + + nameCell.appendChild(nameLink); + row.appendChild(nameCell); + + // Type cell + var typeCell = document.createElement('td'); + typeCell.textContent = file.type; + row.appendChild(typeCell); + + // Size cell + var sizeCell = document.createElement('td'); + sizeCell.textContent = file.isDirectory ? '-' : self.formatSize(file.size); + row.appendChild(sizeCell); + + // Modification date cell + var dateCell = document.createElement('td'); + dateCell.textContent = file.date; + row.appendChild(dateCell); + + // Actions cell with buttons (excluded for parent directory) + var actionsCell = document.createElement('td'); + actionsCell.className = 'navigation-plugin-actions' + self.uniqueSuffix; + + if (!isParent && (file.isDirectory || file.isSymlink || !file.isDirectory)) { + // Edit Button + var editButton = document.createElement('span'); + editButton.className = 'action-button' + self.uniqueSuffix; + editButton.textContent = '✏️'; + editButton.title = `[${PN}]: ` + _('Edit'); + editButton.onclick = function() { + self.handleEditClick(file); + }; + actionsCell.appendChild(editButton); + + // Copy Button + var copyButton = document.createElement('span'); + copyButton.className = 'action-button' + self.uniqueSuffix; + copyButton.textContent = '📋'; + copyButton.title = `[${PN}]: ` + _('Copy'); + copyButton.onclick = function() { + self.handleCopyClick(file); + }; + actionsCell.appendChild(copyButton); + + // Delete Button + var deleteButton = document.createElement('span'); + deleteButton.className = 'action-button' + self.uniqueSuffix; + deleteButton.textContent = '🗑️'; + deleteButton.title = `[${PN}]: ` + _('Delete'); + deleteButton.onclick = function() { + self.handleDeleteClick(file.name); + }; + actionsCell.appendChild(deleteButton); + + // Download Button (only for regular files) + if (!file.isDirectory && !file.isSymlink) { + var downloadButton = document.createElement('span'); + downloadButton.className = 'action-button' + self.uniqueSuffix; + downloadButton.textContent = '⬇️'; + downloadButton.title = `[${PN}]: ` + _('Download'); + downloadButton.onclick = function() { + self.handleDownloadClick(file.name); + }; + actionsCell.appendChild(downloadButton); + } + } + + row.appendChild(actionsCell); + + // If the file is a directory, attach drag-and-drop handlers + if (file.isDirectory) { + var destinationDir = self.concatPath(self.currentDir, file.name); + self.attachDragDropHandlers(row, destinationDir); + } + + self.tbody.appendChild(row); + }, + + /** + * Compares two files based on the current sort field and direction. + * @param {object} a - The first file object. + * @param {object} b - The second file object. + * @returns {number} - Comparison result. + */ + compareFiles: function(a, b) { + var self = this; + var field = self.sortField; + var direction = self.sortDirection === 'asc' ? 1 : -1; + + var valueA = a[field]; + var valueB = b[field]; + + if (field === 'size') { + valueA = a.isDirectory ? 0 : a.size; + valueB = b.isDirectory ? 0 : b.size; + } else if (field === 'mtime') { + valueA = a.mtime; + valueB = b.mtime; + } else { + valueA = String(valueA).toLowerCase(); + valueB = String(valueB).toLowerCase(); + } + + if (valueA < valueB) return -1 * direction; + if (valueA > valueB) return 1 * direction; + return 0; + }, + + /** + * Updates selected items' states after sorting. + * This function restores checkboxes' states based on the preserved selection set. + */ + updateSelectionState: function() { + var self = this; + var checkboxes = self.tbody.querySelectorAll('.select-item' + self.uniqueSuffix); + + checkboxes.forEach(function(checkbox) { + var filePath = checkbox.dataset.path; + checkbox.checked = self.selectedItems.has(filePath); + }); + }, + + /** + * Enters a specified directory. + * @param {string} dirName - The name of the directory to enter. + */ + enterDirectory: function(dirName) { + var self = this; + var newDir = self.concatPath(self.currentDir, dirName); + self.currentDir = newDir.endsWith('/') ? newDir : newDir + '/'; + self.settings.currentDir = self.currentDir; + // self.set_settings(self.settings); + self.loadDirectory(self.currentDir); + }, + + /** + * Adds a parent directory entry ("..") to the table. + */ + addParentDirectoryEntry: function() { + var self = this; + + // Create the table row element for the parent directory + var row = document.createElement('tr'); + row.className = 'directory' + self.uniqueSuffix; + row.dataset.filePath = self.concatPath(self.currentDir, '..'); + row.dataset.isParent = 'true'; // Flag to identify as parent directory + + // Checkbox cell (empty for parent directory) + var checkboxCell = document.createElement('td'); + + // Name cell with a clickable link to navigate up + var nameCell = document.createElement('td'); + nameCell.className = 'name' + self.uniqueSuffix; + var nameLink = document.createElement('a'); + nameLink.className = 'directory-name' + self.uniqueSuffix; + nameLink.textContent = '.. (Parent Directory)'; + nameLink.onclick = function() { + self.navigateUp(); // Navigate to parent directory + }; + nameCell.appendChild(nameLink); + + // Type cell (always 'Directory' for parent directory) + var typeCell = document.createElement('td'); + typeCell.textContent = 'Directory'; + + // Size cell (empty for parent directory) + var sizeCell = document.createElement('td'); + sizeCell.textContent = '-'; + + // Modification date cell (empty for parent directory) + var dateCell = document.createElement('td'); + dateCell.textContent = '-'; + + // Actions cell (empty, no buttons) + var actionsCell = document.createElement('td'); + + // Append all cells to the row + row.appendChild(checkboxCell); + row.appendChild(nameCell); + row.appendChild(typeCell); + row.appendChild(sizeCell); + row.appendChild(dateCell); + row.appendChild(actionsCell); + + // Attach drag-and-drop handlers for the parent directory + var parentDir = self.getParentDirectory(self.currentDir); + self.attachDragDropHandlers(row, parentDir); + + // Append the row to the table body + self.tbody.appendChild(row); + }, + + /** + * Helper function to get the parent directory of a given path. + * @param {string} dir - The current directory path. + * @returns {string} - The parent directory path. + */ + getParentDirectory: function(dir) { + if (dir === '/') return '/'; // Root directory has no parent + + // Remove trailing slash and split the path + var pathParts = dir.slice(0, -1).split('/'); + pathParts.pop(); // Remove the last part to get the parent + + var parentDir = pathParts.join('/') || '/'; // Join back to form the parent path + parentDir = parentDir.endsWith('/') ? parentDir : parentDir + '/'; // Ensure trailing slash + + return parentDir; + }, + + /** + * Navigates up to the parent directory. + */ + navigateUp: function() { + var self = this; + if (self.currentDir === '/') return; + + var pathParts = self.currentDir.slice(0, -1).split('/'); + pathParts.pop(); + var parentDir = pathParts.join('/') || '/'; + self.currentDir = parentDir.endsWith('/') ? parentDir : parentDir + '/'; + self.settings.currentDir = self.currentDir; + // self.set_settings(self.settings); + self.loadDirectory(self.currentDir); + }, + + /** + * Handles the download button click event by sending a JSON request and downloading the file. + * @param {string} fileName - The name of the file to download. + */ + handleDownloadClick: function(fileName) { + var self = this; + var filePath = self.concatPath(self.currentDir, fileName); + + // Use the read_direct method to download the file + fs.read_direct(filePath, 'blob') + .then(function(blob) { + if (!(blob instanceof Blob)) { + throw new Error(`[${PN}]: ` + _('Response is not a Blob')); + } + var url = window.URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + a.remove(); + window.URL.revokeObjectURL(url); + }) + .catch(function(error) { + console.error(`[${PN}]: ` + _('Download failed:'), error); + alert(`[${PN}]: ` + _('Download failed: ') + error.message); + }); + }, + + /** + * Handles the delete button click event for a single file. + * @param {string} fileName - The name of the file to delete. + */ + handleDeleteClick: function(fileName) { + var self = this; + var filePath = self.concatPath(self.currentDir, fileName); + if (confirm(`[${PN}]: ` + _('Are you sure you want to delete "%s"?').format(fileName))) { + fs.remove(filePath).then(function() { + self.popm(null, `[${PN}]: ` + _('File "%s" deleted successfully.').format(fileName), 'info'); + self.loadDirectory(self.currentDir); + }).catch(function(err) { + self.popm(null, `[${PN}]: ` + _('Failed to delete file "%s": %s').format(fileName, err.message), 'error'); + }); + } + }, + + /** + * Handles the copy button click event for a file. + * @param {object} file - The file object to copy. + */ + handleCopyClick: function(file) { + var self = this; + + // Construct the original file path + var originalPath = self.concatPath(self.currentDir, file.name); + var baseName = file.name; + var extension = ''; + var nameWithoutExt = baseName; + + // Split the filename into name and extension + var lastDot = baseName.lastIndexOf('.'); + if (lastDot !== -1 && lastDot !== 0) { + nameWithoutExt = baseName.substring(0, lastDot); + extension = baseName.substring(lastDot); + } + + /** + * Recursively finds the next available copy number to avoid name conflicts. + * @param {number} n - The current copy number to try. + * @returns {Promise} - Resolves with the next available copy number. + */ + function findNextCopyNumber(n) { + var newName = `${nameWithoutExt} (copy ${n})${extension}`; + var newPath = self.concatPath(self.currentDir, newName); + + // Attempt to stat the new path to check if it exists + return fs.stat(newPath).then(function(stat) { + // If the path exists, try the next number + return findNextCopyNumber(n + 1); + }).catch(function(err) { + // Handle 'Resource not found' as the file does not exist + if (err.message === 'Resource not found') { // Adjust based on actual error message + return n; + } else { + // An unexpected error occurred + // Notify the user about the unexpected error + self.popm(null, `[${PN}]: ` + _('Error checking "%s": %s').format(newPath, err.message), 'error'); + throw err; + } + }); + } + + // Start finding the next available copy number + findNextCopyNumber(1).then(function(n) { + var newName = `${nameWithoutExt} (copy ${n})${extension}`; + var newPath = self.concatPath(self.currentDir, newName); + + if (file.isDirectory) { + // Use 'cp -r' to copy directories recursively + fs.exec('/bin/cp', ['-r', originalPath, newPath]).then(function(res) { + if (res.code === 0) { + self.popm(null, `[${PN}]: ` + _('Directory "%s" copied successfully as "%s".').format(file.name, newName), 'info'); + self.loadDirectory(self.currentDir); + } else { + self.popm(null, `[${PN}]: ` + _('Failed to copy directory "%s": %s').format(file.name, res.stderr.trim()), 'error'); + } + }).catch(function(err) { + self.popm(null, `[${PN}]: ` + _('Failed to copy directory "%s": %s').format(file.name, err.message), 'error'); + }); + } else if (file.isSymlink) { + // Use 'ln -s' to copy symbolic links + fs.exec('/bin/ln', ['-s', file.linkTarget, newPath]).then(function(res) { + if (res.code === 0) { + self.popm(null, `[${PN}]: ` + _('Symlink "%s" copied successfully as "%s".').format(file.name, newName), 'info'); + self.loadDirectory(self.currentDir); + } else { + self.popm(null, `[${PN}]: ` + _('Failed to copy symlink "%s": %s').format(file.name, res.stderr.trim()), 'error'); + } + }).catch(function(err) { + self.popm(null, `[${PN}]: ` + _('Failed to copy symlink "%s": %s').format(file.name, err.message), 'error'); + }); + } else { + // Use 'cp' to copy regular files + fs.exec('/bin/cp', [originalPath, newPath]).then(function(res) { + if (res.code === 0) { + self.popm(null, `[${PN}]: ` + _('File "%s" copied successfully as "%s".').format(file.name, newName), 'info'); + self.loadDirectory(self.currentDir); + } else { + self.popm(null, `[${PN}]: ` + _('Failed to copy file "%s": %s').format(file.name, res.stderr.trim()), 'error'); + } + }).catch(function(err) { + self.popm(null, `[${PN}]: ` + _('Failed to copy file "%s": %s').format(file.name, err.message), 'error'); + }); + } + }).catch(function(err) { + self.popm(null, `[${PN}]: ` + _('Failed to find copy number for "%s": %s').format(file.name, err.message), 'error'); + }); + }, + + /** + * Handles the edit button click event for a file. + * @param {object} file - The file object to edit. + */ + handleEditClick: function(file) { + var self = this; + var filePath = self.concatPath(self.currentDir, file.name); + var fileName = file.name; + + var modal = E('div', { + 'class': 'navigation-plugin-modal' + self.uniqueSuffix + }, [ + E('div', { + 'class': 'navigation-plugin-modal-content' + self.uniqueSuffix + }, [ + E('span', { + 'class': 'navigation-plugin-close-button' + self.uniqueSuffix, + 'innerHTML': '×' + }), + E('h2', `[${PN}]: ` + _('Edit "%s"').format(fileName)), + E('label', `[${PN}]: ` + _('New Name:')), + E('input', { + type: 'text', + id: 'edit-new-name' + self.uniqueSuffix, + value: fileName + }), + E('label', `[${PN}]: ` + _('Owner:Group:')), + E('input', { + type: 'text', + id: 'edit-owner' + self.uniqueSuffix, + value: (file.owner + ':' + file.group) + }), + E('label', `[${PN}]: ` + _('Permissions:')), + E('input', { + type: 'text', + id: 'edit-permissions' + self.uniqueSuffix, + value: file.permissions + }), + E('button', { + id: 'edit-submit-button' + self.uniqueSuffix + }, _('Submit')), + E('button', { + id: 'edit-cancel-button' + self.uniqueSuffix + }, _('Cancel')) + ]) + ]); + + document.body.appendChild(modal); + + var closeButton = modal.querySelector('.navigation-plugin-close-button' + self.uniqueSuffix); + var submitButton = modal.querySelector('#edit-submit-button' + self.uniqueSuffix); + var cancelButton = modal.querySelector('#edit-cancel-button' + self.uniqueSuffix); + + /** + * Closes the modal window. + */ + function closeModal() { + document.body.removeChild(modal); + } + + closeButton.onclick = closeModal; + cancelButton.onclick = closeModal; + + submitButton.onclick = function() { + var newName = modal.querySelector('#edit-new-name' + self.uniqueSuffix).value.trim(); + var newOwner = modal.querySelector('#edit-owner' + self.uniqueSuffix).value.trim(); + var newPermissions = modal.querySelector('#edit-permissions' + self.uniqueSuffix).value.trim(); + + if (newName === '') { + self.popm(null, `[${PN}]: ` + _('File name cannot be empty.'), 'error'); + return; + } + + var renamePromise = Promise.resolve(); + if (newName !== fileName) { + var newPath = self.concatPath(self.currentDir, newName); + renamePromise = fs.exec('/bin/mv', [filePath, newPath]).then(function() { + filePath = newPath; + }); + } + + var ownerPromise = fs.exec('/bin/chown', [newOwner, filePath]); + var permissionsPromise = fs.exec('/bin/chmod', [newPermissions, filePath]); + + renamePromise.then(function() { + return ownerPromise; + }).then(function() { + return permissionsPromise; + }).then(function() { + self.popm(null, `[${PN}]: ` + _('"%s" edited successfully.').format(newName), 'info'); + closeModal(); + self.loadDirectory(self.currentDir); + }).catch(function(err) { + self.popm(null, `[${PN}]: ` + _('Failed to edit "%s": %s').format(newName, err.message), 'error'); + }); + }; + }, + + /** + * Formats file size into a human-readable form. + * @param {number} size - The file size in bytes. + * @returns {string} - The formatted size string. + */ + formatSize: function(size) { + var bytes = parseInt(size, 10); + if (isNaN(bytes)) return size; + + var sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + if (bytes === 0) return '0 B'; + var i = Math.floor(Math.log(bytes) / Math.log(1024)); + return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + sizes[i]; + }, + + /** + * Updates the breadcrumb navigation based on the current directory. + */ + updateBreadcrumb: function() { + var self = this; + self.breadcrumb.innerHTML = ''; + + var pathParts = self.currentDir.split('/').filter(part => part.length > 0); + var accumulatedPath = '/'; + + var rootLink = document.createElement('a'); + rootLink.href = '#'; + rootLink.textContent = `[${PN}]: ` + _('Root'); + rootLink.onclick = function(e) { + e.preventDefault(); + self.currentDir = '/'; + self.settings.currentDir = self.currentDir; + // self.set_settings(self.settings); + self.loadDirectory(self.currentDir); + }; + self.breadcrumb.appendChild(rootLink); + + if (pathParts.length > 0) { + self.breadcrumb.appendChild(document.createTextNode(' / ')); + } + + pathParts.forEach(function(part, index) { + accumulatedPath = self.concatPath(accumulatedPath, part); + let currentPath = accumulatedPath; + + var link = document.createElement('a'); + link.href = '#'; + link.textContent = part; + link.onclick = function(e) { + e.preventDefault(); + self.currentDir = currentPath; + self.settings.currentDir = self.currentDir; + // self.set_settings(self.settings); + self.loadDirectory(self.currentDir); + }; + self.breadcrumb.appendChild(link); + + if (index < pathParts.length - 1) { + self.breadcrumb.appendChild(document.createTextNode(' / ')); + } + }); + }, + + // Store dragged files in an array when drag starts + handleDragStart: function(ev, fileName) { + var self = this; + ev.dataTransfer.effectAllowed = 'copy'; + + // Count selected files + var selectedArray = Array.from(self.selectedItems); + if (selectedArray.length === 0) { + // If no items are selected, consider the dragged file as the single target + selectedArray = [self.concatPath(self.currentDir, fileName)]; + } + + // Notify user that direct drag-and-drop isn't supported, but we'll handle it after drag ends + self.popm(null, `[${PN}]: Direct drag-and-drop to local storage is not supported. The file(s) will be downloaded when you release the mouse.`, 'info'); + + // Store these files for download after drag ends + self.draggedFiles = selectedArray; + }, + + // Download the stored files once the user ends the drag operation + handleDragEnd: function(ev) { + var self = this; + + if (self.draggedFiles && self.draggedFiles.length > 0) { + // Now that the drag operation has ended, start downloading the files + self.downloadFilesSequentially(self.draggedFiles); + // Clear draggedFiles after processing + self.draggedFiles = null; + } + }, + + // Download multiple files sequentially + downloadFilesSequentially: function(filePaths) { + var self = this; + + function downloadNext(index) { + if (index >= filePaths.length) { + return; + } + + var filePath = filePaths[index]; + var fileName = filePath.split('/').pop(); + + fs.read_direct(filePath, 'blob') + .then(function(blob) { + if (!(blob instanceof Blob)) { + throw new Error(`[${PN}]: ` + _('Response is not a Blob')); + } + var url = window.URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + a.remove(); + window.URL.revokeObjectURL(url); + + // Proceed to the next file + downloadNext(index + 1); + }) + .catch(function(error) { + console.error(`[${PN}]: Download failed:`, error); + self.popm(null, `[${PN}]: Download failed: ${error.message}`, 'error'); + // Continue with next file even if one fails + downloadNext(index + 1); + }); + } + + downloadNext(0); + }, + // Внутри класса Navigation Plugin + + /** + * Показывает иконку плюс рядом с целевой директорией при удерживании клавиши Alt. + * @param {MouseEvent} event - Событие мыши. + * @param {HTMLElement} targetRow - Строка таблицы, представляющая директорию. + */ + showAltIcon: function(event, targetRow) { + var self = this; + + // Удаляем существующую иконку, если она есть + self.hideAltIcon(); + + // Создаем элемент иконки плюс + var plusIcon = document.createElement('div'); + plusIcon.className = 'navigation-plugin-alt-icon' + self.uniqueSuffix; + plusIcon.innerHTML = '➕'; // Юникод иконка плюса + plusIcon.style.position = 'absolute'; + // Располагаем иконку по центру строки + plusIcon.style.top = '50%'; + plusIcon.style.left = '50%'; + plusIcon.style.transform = 'translate(-50%, -50%)'; + plusIcon.style.pointerEvents = 'none'; // Иконка не перехватывает события + plusIcon.style.fontSize = '24px'; + plusIcon.style.color = '#000'; // Цвет иконки + plusIcon.style.zIndex = '1001'; // Поверх других элементов + + // Добавляем иконку в строку таблицы + targetRow.appendChild(plusIcon); + self.altIcon = plusIcon; + }, + + /** + * Скрывает иконку плюс. + */ + hideAltIcon: function() { + var self = this; + if (self.altIcon) { + self.altIcon.remove(); + self.altIcon = null; + } + }, + + /** + * Attaches drag-and-drop event handlers to a directory row. + * @param {HTMLElement} row - The table row element representing a directory. + * @param {string} destinationDir - The directory path where files will be copied/moved. + */ + attachDragDropHandlers: function(row, destinationDir) { + var self = this; + + // Handle 'dragover' event to allow dropping + row.addEventListener('dragover', function(e) { + e.preventDefault(); + e.stopPropagation(); + var isCopy = e.altKey; // Determine if the operation is copy based on Alt key + e.dataTransfer.dropEffect = isCopy ? 'copy' : 'move'; + + // Add visual indicators for drag-over state + row.classList.add('drag-over' + self.uniqueSuffix); + if (isCopy) { + row.classList.add('drag-over-copy' + self.uniqueSuffix); + row.classList.remove('drag-over-move' + self.uniqueSuffix); + self.showAltIcon(e, row); // Show copy icon + } else { + row.classList.add('drag-over-move' + self.uniqueSuffix); + row.classList.remove('drag-over-copy' + self.uniqueSuffix); + self.hideAltIcon(); // Hide copy icon + } + }); + + // Handle 'dragenter' event similarly to 'dragover' + row.addEventListener('dragenter', function(e) { + e.preventDefault(); + e.stopPropagation(); + var isCopy = e.altKey; + e.dataTransfer.dropEffect = isCopy ? 'copy' : 'move'; + + row.classList.add('drag-over' + self.uniqueSuffix); + if (isCopy) { + row.classList.add('drag-over-copy' + self.uniqueSuffix); + row.classList.remove('drag-over-move' + self.uniqueSuffix); + self.showAltIcon(e, row); + } else { + row.classList.add('drag-over-move' + self.uniqueSuffix); + row.classList.remove('drag-over-copy' + self.uniqueSuffix); + self.hideAltIcon(); + } + }); + + // Handle 'dragleave' event to remove visual indicators + row.addEventListener('dragleave', function(e) { + e.preventDefault(); + e.stopPropagation(); + row.classList.remove('drag-over' + self.uniqueSuffix); + row.classList.remove('drag-over-copy' + self.uniqueSuffix); + row.classList.remove('drag-over-move' + self.uniqueSuffix); + self.hideAltIcon(); + }); + + // Handle 'drop' event to perform copy/move operations + row.addEventListener('drop', function(e) { + e.preventDefault(); + e.stopPropagation(); + + // Remove visual indicators + row.classList.remove('drag-over' + self.uniqueSuffix); + row.classList.remove('drag-over-copy' + self.uniqueSuffix); + row.classList.remove('drag-over-move' + self.uniqueSuffix); + self.hideAltIcon(); + + // Retrieve dragged files data + var draggedFilesJson = e.dataTransfer.getData('application/myapp-files'); + if (!draggedFilesJson) { + // Fallback to 'text/plain' if custom MIME type is not available + draggedFilesJson = e.dataTransfer.getData('text/plain'); + } + + if (!draggedFilesJson) { + self.popm(null, `[${PN}]: ` + _('No files were dragged.'), 'error'); + return; + } + + var draggedFiles; + try { + draggedFiles = JSON.parse(draggedFilesJson); + } catch (err) { + self.popm(null, `[${PN}]: ` + _('Failed to parse dragged files data.'), 'error'); + return; + } + + var isCopy = e.altKey; // Determine operation type + var cmd = isCopy ? 'cp' : 'mv'; // Command to execute + var args = isCopy ? ['-r'] : []; // Recursive flag for copy + + // Append source files and destination directory to arguments + args = args.concat(draggedFiles).concat([destinationDir]); + + + // Execute the command using fs.exec + fs.exec('/bin/' + cmd, args) + .then(function(res) { + if (res.code === 0) { + var action = isCopy ? 'copied' : 'moved'; + self.popm(null, `[${PN}]: Successfully ${action} files to "${destinationDir}".`, 'info'); + self.loadDirectory(self.currentDir); // Refresh directory view + } else { + self.popm(null, `[${PN}]: Failed to ${isCopy ? 'copy' : 'move'} files to "${destinationDir}": ${res.stderr.trim()}`, 'error'); + } + }) + .catch(function(err) { + self.popm(null, `[${PN}]: Failed to ${isCopy ? 'copy' : 'move'} files to "${destinationDir}": ${err.message}`, 'error'); + }); + }); + }, + + + /** + * Opens a file in the default editor based on the editor's style. + * @param {string} filePath - The path to the file to open. + */ + openFileforEditing: function(filePath, permissions, ownerGroup) { + var self = this; + + // Retrieve the default editor plugin + var defaultEditorName = self.default_plugins['Editor']; + var defaultEditor = self.pluginsRegistry[defaultEditorName]; + + if (!defaultEditor) { + self.popm(null, `[${PN}]: ` + _('No default editor plugin found.'), 'error'); + return; + } + + // Get the editor's style ('text' or 'bin') from its info + var editorInfo = defaultEditor.info(); + var style = (editorInfo.style || 'text').toLowerCase(); // Default to 'text' if not specified + + // Read the file content using the Navigation Plugin's read_file function + self.read_file(filePath, style).then(function(fileData) { + // Check if the default editor has an 'edit' function + if (typeof defaultEditor.edit === 'function') { + // Call the editor's edit function with the file path, content, style, permissions, and ownerGroup + defaultEditor.edit(filePath, fileData.content, style, permissions, ownerGroup); + if (self.pluginsRegistry['Main'] && typeof self.pluginsRegistry['Main'].activatePlugin === 'function') { + self.pluginsRegistry['Main'].activatePlugin(defaultEditorName); + self.popm(null, `[${PN}]: ` + _('File "%s" opened in editor.').format(filePath), 'success'); + } else { + self.popm(null, `[${PN}]: ` + _('Unable to activate editor plugin.'), 'error'); + console.error(`[${PN}]: ` + _('Main Dispatcher or activatePlugin method not found.')); + } + + } else { + // Notify the user if the default editor does not implement the 'edit' function + self.popm(null, `[${PN}]: ` + _('Default editor does not implement edit function.'), 'error'); + } + }).catch(function(err) { + // Notify the user if reading the file fails + self.popm(null, `[${PN}]: ` + _('Failed to read file "%s": %s').format(filePath, err.message), 'error'); + }); + } +}); diff --git a/applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Settings.js b/applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Settings.js new file mode 100644 index 00000000000..cccb4ba75a5 --- /dev/null +++ b/applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Settings.js @@ -0,0 +1,697 @@ +'use strict'; +'require ui'; +'require fs'; +'require dom'; +'require form'; + +// Define the plugin name as a constant +const PN = 'Settings'; + +return L.Class.extend({ + info: function() { + return { + name: PN, + type: 'Settings' + }; + }, + + /** + * flattenObject(obj, parentKey = '', separator = '.') + * Recursively converts a nested object into a flat object with keys separated by separator. + */ + flattenObject: function(obj, parentKey = '', separator = '.') { + let flatObj = {}; + for (let key in obj) { + if (!obj.hasOwnProperty(key)) continue; + let newKey = parentKey ? `${parentKey}${separator}${key}` : key; + if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) { + Object.assign(flatObj, this.flattenObject(obj[key], newKey, separator)); + } else { + flatObj[newKey] = obj[key]; + } + } + return flatObj; + }, + + /** + * unflattenObject(flatObj, separator = '.') + * Restores a nested object from a flat object with separated keys. + */ + unflattenObject: function(flatObj, separator = '.') { + let nestedObj = {}; + for (let flatKey in flatObj) { + if (!flatObj.hasOwnProperty(flatKey)) continue; + let keys = flatKey.split(separator); + keys.reduce((acc, key, index) => { + if (index === keys.length - 1) { + acc[key] = flatObj[flatKey]; + } else { + if (!acc[key] || typeof acc[key] !== 'object') { + acc[key] = {}; + } + } + return acc[key]; + }, nestedObj); + } + console.log(`[${PN}]: ` + `unflattenObject result:`, nestedObj); // Debug + return nestedObj; + }, + + /** + * serializeUCI(configObject) + * Serializes a settings object into UCI format. + */ + serializeUCI: function(configObject) { + let configStr = ''; + + for (let sectionName in configObject) { + if (!configObject.hasOwnProperty(sectionName)) continue; + let section = configObject[sectionName]; + + // Determine the section type + let sectionType = section.type || 'option'; + // Create a copy of the section without the type + let sectionCopy = Object.assign({}, section); + delete sectionCopy.type; // Remove type from options + + // Start of configuration section + configStr += `config '${sectionType}' '${sectionName}'\n`; + + // Convert nested objects into flat format + let flatOptions = this.flattenObject(sectionCopy); + + // Add options + for (let key in flatOptions) { + if (!flatOptions.hasOwnProperty(key)) continue; + let value = flatOptions[key]; + + // Convert value to string + if (typeof value !== 'string') { + value = String(value); + } + + // Escape single quotes + value = value.replace(/'/g, `'\\''`); + configStr += `\toption '${key}' '${value}'\n`; + } + } + + return configStr; + }, + + /** + * parseUCI(configContent) + * Parses the content of a UCI configuration file and returns a settings object. + */ + parseUCI: function(configContent) { + var self = this; // Save reference to this + var config = {}; + var currentSection = null; + var sectionType = null; + var sectionName = null; + + // Split content into lines + var lines = configContent.split('\n'); + + lines.forEach(function(line) { + // Trim spaces + line = line.trim(); + + // Ignore empty lines and comments + if (line.length === 0 || line.startsWith('#') || line.startsWith('//')) { + return; + } + + // Check if the line starts with a new configuration section + var configRegex = /^config\s+'([^']+)'\s+'([^']+)'$/; + var match = configRegex.exec(line); + if (match) { + sectionType = match[1]; + sectionName = match[2]; + config[sectionName] = { + type: sectionType + }; + currentSection = config[sectionName]; + return; + } + + // Check if the line is an option within a section + var optionRegex = /^option\s+'([^']+)'\s+'([^']+)'$/; + match = optionRegex.exec(line); + if (match && currentSection) { + var optionKey = match[1]; + var optionValue = match[2].replace(/\\'/g, `'`); // Unescape single quotes + + // Add the option to the current section + currentSection[optionKey] = optionValue; + } + }); + + // Restore nested objects + for (let sectionName in config) { + if (!config.hasOwnProperty(sectionName)) continue; + let section = config[sectionName]; + let flatOptions = {}; + + for (let key in section) { + if (!section.hasOwnProperty(key)) continue; + if (key === 'type') continue; // Skip section type + flatOptions[key] = section[key]; + } + + // Restore nested objects using self + let nestedOptions = self.unflattenObject(flatOptions); + config[sectionName] = Object.assign({ + type: section.type + }, nestedOptions); + + // Debug + console.log(`[${PN}]: ` + `Section "${sectionName}" after unflatten:`, config[sectionName]); + } + + console.log(`[${PN}]: ` + `Parsed configuration:`, config); // Debug + return config; + }, + + /** + * get_settings() + * Returns the current settings of the plugin. + */ + get_settings: function() { + var settingsPanel = document.getElementById(`settings-panel-${this.info().name}-${this.uniqueId}`); + if (settingsPanel) { + // Remove 'px' from width and height and combine with 'x' + var width = parseInt(settingsPanel.style.width, 10) || 800; + var height = parseInt(settingsPanel.style.height, 10) || 600; + var window_size = `${width}x${height}`; + return { + window_size: window_size + }; + } + // Default values if the settings panel is not found + return { + window_size: this.window_size || '800x600' + }; + }, + + /** + * set_settings(settings) + * Applies the given settings to the plugin. + */ + set_settings: function(settings) { + if (settings.window_size) { + this.window_size = settings.window_size; + console.log(`[${PN}]: ` + `Window size set to "${this.window_size}".`); + this.apply_window_size(); + } + }, + + /** + * apply_window_size() + * Applies the window size settings to the settings panel. + */ + apply_window_size: function() { + var dimensions = this.window_size.split('x'); + var width = dimensions[0] + 'px'; + var height = dimensions[1] + 'px'; + + var settingsPanel = document.getElementById(`settings-panel-${this.info().name}-${this.uniqueId}`); + if (settingsPanel) { + settingsPanel.style.width = width; + settingsPanel.style.height = height; + } + }, + + /** + * read_settings() + * Reads settings from the configuration file using the Navigation plugin and applies them. + */ + read_settings: function() { + var self = this; + return new Promise(function(resolve, reject) { + console.log(`[${PN}]: ` + `Reading settings for plugin "${self.info().name}"...`); + var navPluginName = self.defaultPlugins['Navigation']; + var navigationPlugin = self.loadedPlugins[navPluginName] || null; + + self.read_file('/etc/config/file-plug-manager', 'text').then(function(fileData) { + self.permissions = fileData.permissions; + self.GroupOwner = fileData.GroupOwner; + + console.log('[Settings] Configuration file content:', fileData.content); + var parsedConfig = self.parseUCI(fileData.content); + console.log('[Settings] Parsed configuration:', parsedConfig); + + // Iterate over all sections of the configuration file + for (let sectionName in parsedConfig) { + if (!parsedConfig.hasOwnProperty(sectionName)) continue; + + // Skip the 'file-plug-manager' section if it does not contain plugin settings + if (sectionName === 'file-plug-manager') continue; + + // Get settings for the current section + let section = parsedConfig[sectionName]; + let pluginName = sectionName; // Assume the section name matches the plugin name + + // Get the plugin by name + let plugin = self.loadedPlugins[pluginName]; + if (plugin && typeof plugin.set_settings === 'function') { + try { + // Remove the section type before passing settings + let { + type, + ...pluginSettings + } = section; + plugin.set_settings(pluginSettings); + console.log(`[${PN}]: ` + `Settings applied to plugin "${pluginName}":`, pluginSettings); + } catch (e) { + console.error(`[${PN}]: ` + `Error applying settings to plugin "${pluginName}":`, e); + self.popm(null, `[${PN}]: ` + _('Settings: Error applying settings to plugin "' + pluginName + '".')); + } + } else { + console.warn(`[${PN}]: ` + `Plugin "${pluginName}" not found or does not implement set_settings().`); + } + } + + // Apply settings for the 'Settings' plugin itself, if they exist + if (parsedConfig['Settings']) { + self.set_settings(parsedConfig['Settings']); + } + + resolve(); + }).catch(function(err) { + if (err.code === 'ENOENT') { + console.warn('[Settings] Configuration file not found. Using default settings.'); + self.popm(null, `[${PN}]: ` + _('Settings: Configuration file not found. Using default settings.')); + self.set_settings({ + window_size: '800x600' + }); + resolve(); + } else { + console.error('[Settings] Error reading settings:', err); + self.popm(null, `[${PN}]: ` + _('Settings: Error reading settings.')); + reject(err); + } + }); + }); + }, + + /** + * setNestedValue(obj, path, value) + * Sets a nested value in an object based on the given path. + * @param {Object} obj - Object to modify. + * @param {Array} path - Path to the value, e.g., ['columnWidths', 'name']. + * @param {string} value - Value to set. + */ + setNestedValue: function(obj, path, value) { + var current = obj; + for (var i = 0; i < path.length - 1; i++) { + var key = path[i]; + if (typeof current[key] !== 'object' || current[key] === null) { + current[key] = {}; // Create a nested object if it doesn't exist + } + current = current[key]; + } + + var finalKey = path[path.length - 1]; + + // Here you can add logic for type conversion if necessary + current[finalKey] = value; + }, + + + // Define CSS styles + // CSS is dynamically generated to include the uniqueId in class names + get_css: function() { + return ` + .settings-panel-${this.uniqueId} { + resize: both; + overflow: auto; + padding: 20px; + border: 1px solid #ccc; + border-radius: 5px; + width: 800px; /* Initial width, can be dynamically set from this.window_size */ + height: 600px; /* Initial height */ + box-sizing: border-box; + background-color: #f9f9f9; + } + .settings-panel-${this.uniqueId} h3 { + margin-top: 20px; + margin-bottom: 10px; + font-size: 1.2em; + border-bottom: 1px solid #ddd; + padding-bottom: 5px; + } + .settings-panel-${this.uniqueId} fieldset { + margin-left: 20px; + border: 1px solid #ddd; + padding: 10px; + border-radius: 5px; + background-color: #fff; + } + .settings-panel-${this.uniqueId} label { + display: inline-block; + width: 200px; + margin-right: 10px; + text-align: right; + vertical-align: top; + } + .settings-panel-${this.uniqueId} .form-field-${this.uniqueId} { + margin-bottom: 15px; + display: flex; + align-items: center; + } + .settings-panel-${this.uniqueId} input[type="text"] { + flex: 1; + padding: 5px; + border: 1px solid #ccc; + border-radius: 3px; + } + .save-button-${this.uniqueId} { + margin-top: 20px; + padding: 10px 20px; + background-color: #4CAF50; + color: white; + border: none; + border-radius: 5px; + cursor: pointer; + font-size: 1em; + } + .save-button-${this.uniqueId}:hover { + background-color: #45a049; + } + `; + }, + + /** + * start(container, loadedPlugins, defaultPlugins, uniqueId) + * Initializes the Settings plugin. + * @param {HTMLElement} container - The DOM element to attach the plugin's UI. + * @param {Object} loadedPlugins - Registry of loaded plugins. + * @param {Object} defaultPlugins - Registry of default plugins. + * @param {string} uniqueId - Unique identifier for this plugin instance. + */ + start: function(container, loadedPlugins, defaultPlugins, uniqueId) { + var self = this; + + // Store the uniqueId for later use + self.uniqueId = uniqueId; + + console.log(`[${PN}]: ` + `Plugin "${self.info().name}" started with unique ID "${self.uniqueId}".`); + + // Inject the dynamically generated CSS into the document + var styleElement = document.createElement('style'); + styleElement.type = 'text/css'; + styleElement.innerHTML = this.get_css(); + document.head.appendChild(styleElement); + console.log(`[${PN}]: ` + `CSS injected for unique ID "${self.uniqueId}".`); + + // Create the settings panel with unique ID suffix + var settingsPanel = E('div', { + 'class': `settings-panel-${self.uniqueId}`, + 'id': `settings-panel-${self.info().name}-${self.uniqueId}` + }); + + // Create the save button with unique ID suffix + var saveButton = E('button', { + 'class': `save-button-${self.uniqueId}`, + 'id': `save-button-${self.info().name}-${self.uniqueId}` + }, _('Save Settings')); + saveButton.onclick = self.saveSettings.bind(self); + + // Add the panel and button to the container + container.appendChild(settingsPanel); + container.appendChild(saveButton); + + self.loadedPlugins = loadedPlugins || {}; + self.defaultPlugins = defaultPlugins || {}; + + var pluginName = self.info().name; + var eventName = `tab-${pluginName}`; + + // Add an event listener to display settings + document.addEventListener(eventName, self.displaySettings.bind(self)); + console.log(`[${PN}]: ` + `Event listener for "${eventName}" added.`); + + // Retrieve the default Dispatcher plugin + var defaultDispatcherName = self.defaultPlugins['Dispatcher']; + if (defaultDispatcherName && self.loadedPlugins[defaultDispatcherName]) { + var defaultDispatcher = self.loadedPlugins[defaultDispatcherName]; + self.popm = defaultDispatcher.pop.bind(defaultDispatcher); + } + + // Retrieve the default Navigation plugin + var navigationPluginName = self.defaultPlugins['Navigation']; + if (!navigationPluginName) { + self.popm(null, `[${PN}]: ` + _('No default Navigation plugin set.')); + console.error('No default Navigation plugin set.'); + return; + } + + var navigationPlugin = self.loadedPlugins[navigationPluginName]; + if (!navigationPlugin || typeof navigationPlugin.write_file !== 'function') { + self.popm(null, `[${PN}]: ` + _('Navigation plugin does not support writing files.')); + console.error('Navigation plugin is unavailable or missing write_file function.'); + return; + } + + // Bind the write_file() and read_file() functions from the Navigation plugin + self.write_file = navigationPlugin.write_file.bind(navigationPlugin); + self.read_file = navigationPlugin.read_file.bind(navigationPlugin); + }, + + /** + * displaySettings() + * Displays the settings form for all loaded plugins. + */ + displaySettings: function() { + var self = this; + console.log(`[${PN}]: ` + `Displaying settings for plugin "${self.info().name}" with unique ID "${self.uniqueId}"...`); + var settingsPanel = document.getElementById(`settings-panel-${self.info().name}-${self.uniqueId}`); + + if (!settingsPanel) { + console.error(`[${PN}]: ` + `settings-panel-${self.info().name}-${self.uniqueId} element not found.`); + self.popm(null, `[${PN}]: ` + _('Settings panel not found.')); + return; + } + + // Clear previous content + settingsPanel.innerHTML = ''; + + var settingsForm = E('form', { + 'id': `settings-form-${self.info().name}-${self.uniqueId}` + }); + var allSettings = {}; + + for (var pluginName in self.loadedPlugins) { + if (self.loadedPlugins.hasOwnProperty(pluginName)) { + try { + var plugin = self.loadedPlugins[pluginName]; + if (plugin && typeof plugin.get_settings === 'function') { + var pluginSettings = plugin.get_settings(); + allSettings[pluginName] = pluginSettings; + console.log(`[${PN}]: ` + `Retrieved settings from plugin "${pluginName}":`, pluginSettings); + + // Add settings to the form + self.addSettingsToForm(settingsForm, pluginName, pluginSettings, plugin.info().type); + } else { + console.warn(`[${PN}]: ` + `Plugin "${pluginName}" does not implement get_settings().`); + } + } catch (err) { + console.error(`[${PN}]: ` + `Error retrieving settings for plugin "${pluginName}":`, err); + self.popm(null, `[${PN}]: ` + _('Error retrieving settings for "' + pluginName + '".')); + } + } + } + + if (Object.keys(allSettings).length === 0) { + settingsForm.innerHTML = '

' + _('No settings available.') + '

'; + } + + settingsPanel.appendChild(settingsForm); + console.log(`[${PN}]: ` + `Settings displayed for plugin "${self.info().name}" with unique ID "${self.uniqueId}".`); + }, + + /** + * addSettingsToForm(settingsForm, pluginName, pluginSettings, pluginType) + * Adds setting fields for a specific plugin to the form. + */ + addSettingsToForm: function(settingsForm, pluginName, pluginSettings, pluginType) { + var header = E('h3', {}, `${pluginName} (${pluginType || 'Unknown'})`); + settingsForm.appendChild(header); + + var fieldsContainer = E('div', { + 'class': `fields-container-${this.uniqueId}` + }); + + // Use a helper function to recursively add fields + this.renderSettingsFields(pluginName, fieldsContainer, pluginSettings, []); + + settingsForm.appendChild(fieldsContainer); + }, + + /** + * renderSettingsFields(pluginName, container, settings, path) + * Recursively renders setting fields in nested objects. + * @param {string} pluginName - Plugin name. + * @param {HTMLElement} container - DOM element to add fields to. + * @param {Object} settings - Settings object (can be nested). + * @param {Array} path - Current path to settings (used for unique field names). + */ + renderSettingsFields: function(pluginName, container, settings, path) { + for (var settingKey in settings) { + if (!settings.hasOwnProperty(settingKey)) continue; + var settingValue = settings[settingKey]; + + // Construct the full path for this parameter + var fullPath = path.concat(settingKey); + + if (typeof settingValue === 'object' && settingValue !== null && !Array.isArray(settingValue)) { + // If the value is an object, create a nested fieldset + var subgroup = E('fieldset', {}, [ + E('legend', {}, settingKey) + ]); + + // Recursively render nested fields + this.renderSettingsFields(pluginName, subgroup, settingValue, fullPath); + + container.appendChild(subgroup); + } else { + // Primitive value (string, number, etc.) + var label = E('label', { + 'for': `${pluginName}-${fullPath.join('-')}-${this.uniqueId}` + }, settingKey); + + // Create an input field + var input = E('input', { + 'type': 'text', + 'id': `${pluginName}-${fullPath.join('-')}-${this.uniqueId}`, + 'name': `${pluginName}-${fullPath.join('-')}-${this.uniqueId}` + }); + input.value = (settingValue !== undefined && settingValue !== null) ? settingValue.toString() : ''; + + var fieldWrapper = E('div', { + 'class': `form-field-${this.uniqueId}` + }, [label, input]); + container.appendChild(fieldWrapper); + } + } + }, + + /** + * saveSettings() + * Saves the current settings of all plugins to the configuration file. + */ + saveSettings: function() { + var self = this; + console.log(`[${PN}]: ` + `Saving settings for plugin "${self.info().name}" with unique ID "${self.uniqueId}"...`); + var settingsForm = document.getElementById(`settings-form-${self.info().name}-${self.uniqueId}`); + + if (!settingsForm) { + self.popm(null, `[${PN}]: ` + _('Settings form not found.')); + return; + } + + var formData = new FormData(settingsForm); + var updatedSettings = {}; + + // Parse form data + formData.forEach(function(value, key) { + // Key format: "pluginName-subkey-subsubkey-...-uniqueId" + var parts = key.split('-'); + var uniqueIdFromKey = parts.pop(); // Remove the uniqueId part + var pluginName = parts.shift(); + var settingPath = parts; // Remaining parts represent the path to settings + + if (!updatedSettings[pluginName]) { + updatedSettings[pluginName] = {}; + } + + // Recreate the nested structure from settingPath + self.setNestedValue(updatedSettings[pluginName], settingPath, value); + }); + + console.log(`[${PN}]: ` + `Updated settings:`, updatedSettings); + + var applySettingsPromises = []; + + for (var pluginName in updatedSettings) { + if (updatedSettings.hasOwnProperty(pluginName)) { + var plugin = self.loadedPlugins[pluginName]; + if (plugin && typeof plugin.set_settings === 'function') { + try { + var result = plugin.set_settings(updatedSettings[pluginName]); + if (result && typeof result.then === 'function') { + applySettingsPromises.push(result); + } + console.log(`[${PN}]: ` + `Applied settings to plugin "${pluginName}":`, updatedSettings[pluginName]); + } catch (err) { + console.error(`[${PN}]: ` + `Error applying settings to plugin "${pluginName}":`, err); + self.popm(null, `[${PN}]: ` + _('Error applying settings to plugin "' + pluginName + '".')); + } + } + } + } + + Promise.all(applySettingsPromises).then(function() { + // After successfully applying settings to all plugins, save to the configuration file + self.saveToConfigFile(updatedSettings).then(function() { + self.popm(null, `[${PN}]: ` + _('Settings saved successfully.')); + console.log(`[${PN}]: ` + `Settings saved.`); + }).catch(function(err) { + console.error(`[${PN}]: ` + `Error saving settings to file:`, err); + self.popm(null, `[${PN}]: ` + _('Error saving settings to file.')); + }); + }).catch(function(err) { + console.error(`[${PN}]: ` + `Error applying settings:`, err); + self.popm(null, `[${PN}]: ` + _('Error applying settings.')); + }); + }, + + /** + * saveToConfigFile(updatedSettings) + * Saves the updated settings to the configuration file. + * @param {Object} updatedSettings - Settings object to save. + * @returns {Promise} - Resolves on successful save. + */ + saveToConfigFile: function(updatedSettings) { + var self = this; + return new Promise(function(resolve, reject) { + console.log('[Settings] Saving updated settings to configuration file.'); + + // Prepare the UCI configuration with separate sections for each plugin + var uciConfig = {}; + + // Add the main 'file-plug-manager' section + uciConfig['file-plug-manager'] = { + type: 'file-plug-manager' + // Add options for 'file-plug-manager' here, if any + }; + + // Add sections for each plugin + for (var pluginName in updatedSettings) { + if (!updatedSettings.hasOwnProperty(pluginName)) continue; + var pluginSettings = updatedSettings[pluginName]; + + // Get the plugin type via info() + var plugin = self.loadedPlugins[pluginName]; + var pluginType = plugin && plugin.info && plugin.info().type ? plugin.info().type : 'option'; + + // Ensure type is set + uciConfig[pluginName] = Object.assign({ + type: pluginType + }, pluginSettings); + } + + var serializedConfig = self.serializeUCI(uciConfig); + + // Write the serialized configuration to the file + self.write_file('/etc/config/file-plug-manager', self.permissions, self.ownerGroup, serializedConfig, 'text').then(function() { + console.log('[Settings] Configuration file written successfully.'); + resolve(); + }).catch(function(err) { + console.error('[Settings] Error writing configuration file:', err); + reject(err); + }); + }); + } +}); \ No newline at end of file diff --git a/applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/hexEditor.js b/applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/hexEditor.js new file mode 100644 index 00000000000..eb8d155ea81 --- /dev/null +++ b/applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/hexEditor.js @@ -0,0 +1,1531 @@ +'use strict'; +'require ui'; +'require dom'; + +/** + * Hex Editor Plugin + * Provides a hex editor with search functionality, virtual scrolling, and editing capabilities. + * Supports loading external binary content for editing and displays the filename. + * Includes configuration for window size and a save button at the bottom, using Text Editor's approach to saving. + */ + +const PN = 'Hex Editor'; +return Class.extend({ + + /** + * Returns metadata about the plugin. + * @returns {Object} Plugin information. + */ + info: function() { + return { + name: 'Hex Editor', // Unique plugin name + type: 'Editor', // Plugin type + style: 'Bin', // Kind of contents expected for editing + description: 'A hex editor plugin with search functionality, virtual scrolling, editing, and save button at bottom.' + }; + }, + + /** + * CSS styles for the Hex Editor plugin. + * All class selectors are prefixed with .{rootClass} to ensure uniqueness. + */ + maincss: ` + .{rootClass} { + position: relative; + display: flex; + flex-direction: column; + resize: both; /* Allows the window to be resizable */ + overflow: hidden; /* Hide scrollbars at the plugin level */ + box-shadow: 2px 2px 5px rgba(0,0,0,0.1); + font-family: 'Courier New', Courier, monospace; /* Changed to monospace font */ + } + + .{rootClass} .filename-display { + font-weight: bold; + color: #333; + font-size: 14px; + padding: 5px 10px; + background-color: #f5f5f5; + border-bottom: 1px solid #000; + } + + .{rootClass} .save-button-container { + padding: 10px; + background-color: #f5f5f5; + border-top: 1px solid #000; + display: flex; + justify-content: flex-end; + } + + .{rootClass} .save-button { + padding: 5px 10px; + background-color: #0078d7; + color: #fff; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + } + + .{rootClass} .save-button:hover { + background-color: #005fa3; + } + + :root { + --span-spacing: 0.25ch; + --clr-background: #f5f5f5; + --clr-selected: #c9daf8; + --clr-selected-editing: #6d9eeb; + --clr-non-printable: #999999; + --clr-border: #000000; + --clr-offset: #666666; + --clr-header: #333333; + --clr-highlight: yellow; + --clr-cursor-active: blue; + --clr-cursor-passive: lightblue; + --animation-duration: 1s; + } + + .{rootClass} .hexedit *, + .{rootClass} .hexedit *::before, + .{rootClass} .hexedit *::after { + box-sizing: border-box; + } + + .{rootClass} .hexedit { + display: flex; + flex-direction: column; + flex: 1; + font-family: 'Courier New', Courier, monospace; + font-size: 14px; + line-height: 1.2em; + background-color: var(--clr-background); + border: 1px solid var(--clr-border); + width: 100%; + flex-grow: 1; + } + + .{rootClass} .hexedit-headers { + display: flex; + background-color: var(--clr-background); + border-bottom: 2px solid var(--clr-border); + font-family: 'Courier New', Courier, monospace; + } + + .{rootClass} .offsets-header, + .{rootClass} .hexview-header, + .{rootClass} .textview-header { + display: flex; + align-items: center; + padding: 5px; + box-sizing: border-box; + font-weight: bold; + color: var(--clr-header); + border-right: 2px solid var(--clr-border); + } + + .{rootClass} .offsets-header { + width: 100px; + text-align: left; + } + + .{rootClass} .hexview-header { + width: calc(16 * 2ch + 20 * var(--span-spacing)); + display: flex; + } + + .{rootClass} .hexview-header span { + width: 2ch; + margin-right: var(--span-spacing); + text-align: center; + } + + .{rootClass} .hexview-header span:last-child { + margin-right: 0; + } + + .{rootClass} .textview-header { + flex: 1; + margin-left: 10px; + text-align: left; + } + + .{rootClass} .hexedit-content { + display: flex; + height: 100%; + flex: 1 1 auto; + overflow: auto; + position: relative; + border-top: 2px solid var(--clr-border); + font-family: 'Courier New', Courier, monospace; + + } + + .{rootClass} .offsets, + .{rootClass} .hexview, + .{rootClass} .textview { + flex-shrink: 0; + display: block; + padding: 5px; + position: relative; + border-right: 2px solid var(--clr-border); + } + + .{rootClass} .offsets { + width: 100px; + display: flex; + flex-direction: column; + text-align: left; + } + + .{rootClass} .offsets span { + display: block; + height: 1.2em; + } + + .{rootClass} .hexview { + width: calc(16 * 2ch + 20 * var(--span-spacing)); + text-align: center; + } + + .{rootClass} .textview { + flex: 1; + margin-left: 10px; + text-align: left; + border-right: none; + } + + .{rootClass} .hex-line, + .{rootClass} .text-line { + display: flex; + height: 1.2em; + } + + .{rootClass} .hex-line span, + .{rootClass} .text-line span { + width: 2ch; + margin-right: var(--span-spacing); + text-align: center; + display: inline-block; + cursor: default; + } + + .{rootClass} .hex-line span:last-child, + .{rootClass} .hexview-header span:last-child, + .{rootClass} .text-line span:last-child { + margin-right: 0; + } + + .{rootClass} .selected { + background-color: var(--clr-selected); + } + + .{rootClass} .selected-editing { + background-color: var(--clr-selected-editing); + } + + .{rootClass} .non-printable { + color: var(--clr-non-printable); + } + + .{rootClass} .offsets::-webkit-scrollbar, + .{rootClass} .hexview::-webkit-scrollbar, + .{rootClass} .textview::-webkit-scrollbar { + display: none; + } + + .{rootClass} .offsets, + .{rootClass} .hexview, + .{rootClass} .textview { + scrollbar-width: none; + } + + .{rootClass} .hexedit .offsets, + .{rootClass} .hexedit .hexview, + .{rootClass} .hexedit .textview { + border-right: 2px solid var(--clr-border); + } + + .{rootClass} .hexedit .textview { + border-right: none; + } + + @media (max-width: 768px) { + .{rootClass} .hexedit { + font-size: 12px; + } + + .{rootClass} .offsets { + width: 120px; + } + + .{rootClass} .hexview { + width: calc(16 * 2ch + 20 * var(--span-spacing)); + } + } + + .{rootClass} .hexedit-search-container { + padding: 10px; + background-color: #f9f9f9; + border-bottom: 1px solid #ccc; + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; + box-sizing: border-box; + } + + .{rootClass} .hexedit-search-group { + display: flex; + align-items: center; + gap: 5px; + width: 100%; + } + + .{rootClass} .hexedit-search-input { + flex: 1; + padding: 8px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; + } + + .{rootClass} .hexedit-search-status { + width: 50px; + text-align: center; + font-size: 14px; + color: #555; + } + + .{rootClass} .hexedit-search-button { + padding: 8px 12px; + cursor: pointer; + background-color: #007bff; + color: white; + border: none; + border-radius: 4px; + font-size: 14px; + transition: background-color 0.3s ease; + } + + .{rootClass} .hexedit-search-button:hover { + background-color: #0056b3; + } + + .{rootClass} .search-highlight { + background-color: var(--clr-highlight); + } + + @keyframes blink-blue { + 0% { background-color: var(--clr-cursor-active); } + 50% { background-color: white; } + 100% { background-color: var(--clr-cursor-active); } + } + + .{rootClass} .active-view-cursor { + animation: blink-blue var(--animation-duration) infinite; + background-color: var(--clr-cursor-active); + } + + .{rootClass} .passive-view-cursor { + background-color: var(--clr-cursor-passive); + } + + .{rootClass} .highlighted { + background-color: var(--clr-highlight); + } + `, + + /** + * Initializes and starts the Hex Editor plugin. + * @param {HTMLElement} container - The container element for the plugin. + * @param {Object} pluginsRegistry - Registry of available plugins. + * @param {Object} default_plugins - Default plugins to be used. + * @param {string} uniqueId - Unique identifier for this plugin instance. + */ + start: function(container, pluginsRegistry, default_plugins, uniqueId) { + var self = this; + + // Ensure the plugin is only initialized once per uniqueId + if (self.initializedIds && self.initializedIds.includes(uniqueId)) { + return; + } + if (!self.initializedIds) { + self.initializedIds = []; + } + self.initializedIds.push(uniqueId); + + // Store references for later use + self.pluginsRegistry = pluginsRegistry; + self.default_plugins = default_plugins; + + // Process and inject CSS with uniqueId + const rootClass = `hex-editor-plugin-${uniqueId}`; + const processedCss = this.maincss.replace(/{rootClass}/g, rootClass); + // Inject the processed CSS into the document + const styleTag = document.createElement('style'); + styleTag.textContent = processedCss; + document.head.appendChild(styleTag); + + // Dynamically fetch the default Dispatcher plugin + var defaultDispatcherName = self.default_plugins['Dispatcher']; + if (defaultDispatcherName && self.pluginsRegistry[defaultDispatcherName]) { + var defaultDispatcher = self.pluginsRegistry[defaultDispatcherName]; + self.popm = defaultDispatcher.pop.bind(defaultDispatcher); + } + + // Dynamically fetch the default Navigation plugin + var navigationPluginName = self.default_plugins['Navigation']; + if (!navigationPluginName) { + self.popm(null, `[${PN}]: ` + _('No default Navigation plugin set.')); + console.error('No default Navigation plugin set.'); + return; + } + + var navigationPlugin = self.pluginsRegistry[navigationPluginName]; + if (!navigationPlugin || typeof navigationPlugin.write_file !== 'function') { + self.popm(null, `[${PN}]: ` + _('Navigation plugin does not support writing files.')); + console.error('Navigation plugin is unavailable or missing write_file function.'); + return; + } + + self.write_file = navigationPlugin.write_file.bind(navigationPlugin); + + // Create the main div for the hex editor with unique root class + self.editorDiv = document.createElement('div'); + self.editorDiv.className = rootClass; + + // Set initial size + self.width = self.settings && self.settings.width ? self.settings.width : '600px'; + self.height = self.settings && self.settings.height ? self.settings.height : '400px'; + self.editorDiv.style.width = self.width; + self.editorDiv.style.height = self.height; + + // Filename display at the top + self.filenameDisplay = document.createElement('div'); + self.filenameDisplay.className = 'filename-display'; + self.filenameDisplay.textContent = 'No file loaded.'; + self.editorDiv.appendChild(self.filenameDisplay); + + // Initialize hex editor inside editorDiv + self.hexEditorInstance = self.initializeHexEditor(self.editorDiv, rootClass); + + // Create the save button container at the bottom + self.saveButtonContainer = document.createElement('div'); + self.saveButtonContainer.className = 'save-button-container'; + + self.saveButton = document.createElement('button'); + self.saveButton.className = 'save-button'; + self.saveButton.textContent = 'Save'; + self.saveButton.onclick = function() { + if (!self.currentFilePath) { + self.popm(null, `[${PN}]: ` + _('No file loaded to save.')); + return; + } + + var data = self.hexEditorInstance.getData(); + var content = data.buffer; + + // Attempt to save the file (similar to Text Editor approach) + self.write_file(self.currentFilePath, self.permissions, self.ownerGroup, content, 'bin') + + .then(function() { + self.popm(null, `[${PN}]: ` + _('File saved successfully.')); + }) + .catch(function(err) { + self.popm(null, `[${PN}]: ` + _('Error saving file.')); + console.error('Error saving file:', err); + }); + }; + + self.saveButtonContainer.appendChild(self.saveButton); + self.editorDiv.appendChild(self.saveButtonContainer); + + // Append the editor div to the provided container + container.appendChild(self.editorDiv); + }, + + byteToChar: function(b) { + return (b >= 32 && b <= 126) ? String.fromCharCode(b) : this._NON_PRINTABLE_CHAR; + }, + + + /** + * Initializes the HexEditor instance. + * @param {HTMLElement} container - The container element for the hex editor. + * @param {string} rootClass - Unique root class for scoping CSS. + * @returns {HexEditor} - The initialized HexEditor instance. + */ + initializeHexEditor: function(container, rootClass) { + var self = this; + + /** + * HexEditor class to handle hex editing functionalities. + */ + class HexEditor { + /** + * Constructs a HexEditor instance. + * + * @param {HTMLElement} hexeditDomObject - The DOM element for the hex editor. + * @param {string} rootClass - Unique root class for scoping CSS. + */ + constructor(hexeditDomObject, rootClass) { + this.rootClass = rootClass; + this.hexedit = this.fillHexeditDom(hexeditDomObject); + this.offsets = this.hexedit.querySelector(`.${rootClass} .offsets`); + this.hexview = this.hexedit.querySelector(`.${rootClass} .hexview`); + this.textview = this.hexedit.querySelector(`.${rootClass} .textview`); + this.hexeditContent = this.hexedit.querySelector(`.${rootClass} .hexedit-content`); + this.hexeditHeaders = this.hexedit.querySelector(`.${rootClass} .hexedit-headers`); + + this.bytesPerRow = 16; + this.startIndex = 0; + this.data = new Uint8Array(0); + + this.selectedIndex = null; + this.editHex = true; + this.currentEdit = ""; + this.readonly = false; + this.ctrlPressed = false; + + this.matches = []; + this.currentMatchIndex = -1; + this.currentSearchType = null; + this.activeView = null; + this.previousSelectedIndex = null; + + this.lastSearchPatterns = { + ascii: '', + hex: '', + regex: '' + }; + + this._NON_PRINTABLE_CHAR = "\u00B7"; + + this._registerEventHandlers(); + + this.resizeObserver = new ResizeObserver(() => { + this.calculateVisibleRows(); + }); + this.resizeObserver.observe(this.hexeditContent); + + this.addSearchUI(); + } + + /** + * Adds the search interface with input fields, status fields, and navigation buttons. + */ + addSearchUI() { + // Create search container + const searchContainer = document.createElement('div'); + searchContainer.classList.add('hexedit-search-container'); + + // Helper function to create search groups + const createSearchGroup = (type, placeholder) => { + const container = document.createElement('div'); + container.classList.add('hexedit-search-group'); + + const input = document.createElement('input'); + input.type = 'text'; + input.placeholder = placeholder; + input.classList.add('hexedit-search-input'); + input.id = `hexedit-search-${type}`; + + const status = document.createElement('span'); + status.classList.add('hexedit-search-status'); + status.id = `hexedit-search-status-${type}`; + status.textContent = '0/0'; // Initial status + + const prevButton = document.createElement('button'); + prevButton.innerHTML = '↑'; // Up arrow + prevButton.classList.add('hexedit-search-button'); + prevButton.title = `Previous ${type.toUpperCase()} Match`; + + const nextButton = document.createElement('button'); + nextButton.innerHTML = '↓'; // Down arrow + nextButton.classList.add('hexedit-search-button'); + nextButton.title = `Next ${type.toUpperCase()} Match`; + + container.appendChild(input); + container.appendChild(status); + container.appendChild(prevButton); + container.appendChild(nextButton); + + // Add event listeners for buttons + prevButton.addEventListener('click', () => this.handleFindPrevious(type)); + nextButton.addEventListener('click', () => this.handleFindNext(type)); + + // Add event listener for Enter key + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') this.handleFindNext(type); + }); + + return container; + }; + + // Create ASCII search group + const asciiGroup = createSearchGroup('ascii', _('Search ASCII')); + + // Create HEX search group + const hexGroup = createSearchGroup('hex', _('Search HEX (e.g., 4F6B)')); + + // Create RegExp search group + const regexGroup = createSearchGroup('regex', _('Search RegExp (e.g., \\d{3})')); + + // Append all search groups to the search container + searchContainer.appendChild(asciiGroup); + searchContainer.appendChild(hexGroup); + searchContainer.appendChild(regexGroup); + + // Insert the search container above the hexedit headers + if (this.hexeditHeaders) { + this.hexedit.insertBefore(searchContainer, this.hexeditHeaders); + } else { + // Fallback: append to hexedit if headers are not found + this.hexeditContent.insertBefore(searchContainer, this.hexeditContent.firstChild); + } + } + + /** + * Handles the "Find Next" button click for a specific search type. + * + * @param {string} searchType - The type of search ('ascii', 'hex', 'regex'). + */ + handleFindNext(searchType) { + const inputElement = document.getElementById(`hexedit-search-${searchType}`); + const currentPattern = inputElement.value.trim(); + + // Check if the search pattern has changed + if (this.lastSearchPatterns[searchType] !== currentPattern) { + // Update the last search pattern + this.lastSearchPatterns[searchType] = currentPattern; + + // Set the current search type and active view + this.currentSearchType = searchType; + this.activeView = (searchType === 'hex') ? 'hex' : 'text'; + + // Perform search + this.performSearch(searchType); + } else { + // If the search pattern has not changed, just go to the next match + if (this.currentSearchType === searchType && this.matches.length > 0) { + // Set activeView based on currentSearchType + this.activeView = (this.currentSearchType === 'hex') ? 'hex' : 'text'; + + // Navigate to the next match relative to the current cursor position + const cursorPosition = this.selectedIndex !== null ? this.selectedIndex : 0; + const nextMatchIndex = this.findNextMatch(cursorPosition); + if (nextMatchIndex !== -1) { + this.navigateToMatch(nextMatchIndex); + } else { + // If there is no next match, go to the first one + this.navigateToMatch(0); + } + } + } + } + + /** + * Handles the "Find Previous" button click for a specific search type. + * + * @param {string} searchType - The type of search ('ascii', 'hex', 'regex'). + */ + handleFindPrevious(searchType) { + const inputElement = document.getElementById(`hexedit-search-${searchType}`); + const currentPattern = inputElement.value.trim(); + + // Check if the search pattern has changed + if (this.lastSearchPatterns[searchType] !== currentPattern) { + // Update the last search pattern + this.lastSearchPatterns[searchType] = currentPattern; + + // Set the current search type and active view + this.currentSearchType = searchType; + this.activeView = (searchType === 'hex') ? 'hex' : 'text'; + + // Perform search + this.performSearch(searchType); + } else { + // If the search pattern has not changed, just go to the previous match + if (this.currentSearchType === searchType && this.matches.length > 0) { + // Set activeView based on currentSearchType + this.activeView = (this.currentSearchType === 'hex') ? 'hex' : 'text'; + + // Navigate to the previous match relative to the current cursor position + const cursorPosition = this.selectedIndex !== null ? this.selectedIndex : this.data.length; + const prevMatchIndex = this.findPreviousMatch(cursorPosition); + if (prevMatchIndex !== -1) { + this.navigateToMatch(prevMatchIndex); + } else { + // If there is no previous match, go to the last one + this.navigateToMatch(this.matches.length - 1); + } + } + } + } + + /** + * Finds the index of the next match after the given cursor position. + * + * @param {number} cursorPosition - The current cursor position. + * @returns {number} - The index in the matches array or -1 if not found. + */ + findNextMatch(cursorPosition) { + for (let i = 0; i < this.matches.length; i++) { + if (this.matches[i].index > cursorPosition) { + return i; + } + } + // If there are no matches after the cursor position, return -1 + return -1; + } + + /** + * Finds the index of the previous match before the given cursor position. + * + * @param {number} cursorPosition - The current cursor position. + * @returns {number} - The index in the matches array or -1 if not found. + */ + findPreviousMatch(cursorPosition) { + for (let i = this.matches.length - 1; i >= 0; i--) { + if (this.matches[i].index < cursorPosition) { + return i; + } + } + // If there are no matches before the cursor position, return -1 + return -1; + } + + /** + * Performs the search based on the specified search type. + * + * @param {string} searchType - The type of search ('ascii', 'hex', 'regex'). + */ + performSearch(searchType) { + let pattern = ''; + switch (searchType) { + case 'ascii': + pattern = document.getElementById(`hexedit-search-${searchType}`).value.trim(); + break; + case 'hex': + pattern = document.getElementById(`hexedit-search-${searchType}`).value.trim(); + break; + case 'regex': + pattern = document.getElementById(`hexedit-search-${searchType}`).value.trim(); + break; + default: + console.warn(`Unknown search type: ${searchType}`); + pattern = ''; + break; + } + + // Reset previous search results + this.clearSearchHighlights(); + this.matches = []; + this.currentMatchIndex = -1; + + if (!pattern) { + // Update status field to 0/0 + this.updateSearchStatus(searchType, 0, 0); + console.log('No search pattern entered.'); + return; + } + + try { + // Determine search type and perform search + if (searchType === 'ascii') { + this.searchASCII(pattern); + } else if (searchType === 'hex') { + this.searchHEX(pattern); + } else if (searchType === 'regex') { + this.searchRegex(pattern); + } + } catch (error) { + console.log(`Error during search: ${error.message}`); + // Update status field to 0/0 on error + this.updateSearchStatus(searchType, 0, 0); + return; + } + + // After searching, highlight all matches and navigate to the first one + if (this.matches.length > 0) { + this.highlightAllMatches(searchType); + this.currentMatchIndex = 0; + this.navigateToMatch(this.currentMatchIndex); + // Update status field with actual match count + this.updateSearchStatus(searchType, this.currentMatchIndex + 1, this.matches.length); + console.log(`Found ${this.matches.length} matches.`); + } else { + // Update status field to 0/0 if no matches found + this.updateSearchStatus(searchType, 0, 0); + console.log('No matches found.'); + } + } + + /** + * Highlights all matched patterns in the hex and text views based on search type. + * + * @param {string} searchType - The type of search ('ascii', 'hex', 'regex'). + */ + highlightAllMatches(searchType) { + // Rendering will handle highlights based on this.matches + this.searchTypeForHighlight = searchType; // Store current search type for rendering + + // Set active view based on search type + if (searchType === 'ascii' || searchType === 'regex') { + this.activeView = 'text'; // Text view is active + } else if (searchType === 'hex') { + this.activeView = 'hex'; // Hex view is active + } + + // Focus the corresponding view + this.focusActiveView(); + + this.renderDom(); // Re-render to apply the highlights + } + + /** + * Navigates to a specific match by its index. + * + * @param {number} matchIndex - The index in the matches array to navigate to. + */ + navigateToMatch(matchIndex) { + if (this.matches.length === 0) { + // Update status field to 0/0 if no matches + this.updateSearchStatus(this.currentSearchType, 0, 0); + console.log('No matches to navigate.'); + return; + } + + // Ensure matchIndex is within bounds + if (matchIndex < 0 || matchIndex >= this.matches.length) { + console.log('navigateToMatch: matchIndex out of bounds.'); + return; + } + + this.currentMatchIndex = matchIndex; + const match = this.matches[matchIndex]; + + // Set activeView based on currentSearchType during navigation + this.activeView = (this.currentSearchType === 'hex') ? 'hex' : 'text'; + + // Set selected index to the match start + this.setSelectedIndex(match.index); + console.log(`Navigated to match ${matchIndex + 1} at offset ${match.index.toString(16)}`); + + // Update status field + this.updateSearchStatus(this.currentSearchType, this.currentMatchIndex + 1, this.matches.length); + } + + /** + * Searches for an ASCII pattern and stores all match positions. + * + * @param {string} pattern - The ASCII pattern to search for. + */ + searchASCII(pattern) { + const dataStr = new TextDecoder('iso-8859-1').decode(this.data); + let startIndex = 0; + let index; + while ((index = dataStr.indexOf(pattern, startIndex)) !== -1) { + this.matches.push({ + index: index, + length: pattern.length + }); + startIndex = index + pattern.length; + } + console.log(`searchASCII: Found ${this.matches.length} matches.`); + } + + /** + * Searches for a HEX pattern and stores all match positions. + * + * @param {string} pattern - The HEX pattern to search for (e.g., "4F6B"). + */ + searchHEX(pattern) { + // Remove spaces and validate hex string + const cleanedPattern = pattern.replace(/\s+/g, ''); + if (!/^[0-9a-fA-F]+$/.test(cleanedPattern)) { + throw new Error('Invalid HEX pattern.'); + } + if (cleanedPattern.length % 2 !== 0) { + throw new Error('HEX pattern length must be even.'); + } + + // Convert hex string to byte array + const bytePattern = new Uint8Array(cleanedPattern.match(/.{1,2}/g).map(byte => parseInt(byte, 16))); + + for (let i = 0; i <= this.data.length - bytePattern.length; i++) { + let found = true; + for (let j = 0; j < bytePattern.length; j++) { + if (this.data[i + j] !== bytePattern[j]) { + found = false; + break; + } + } + if (found) { + this.matches.push({ + index: i, + length: bytePattern.length + }); + } + } + console.log(`searchHEX: Found ${this.matches.length} matches.`); + } + + /** + * Searches using a regular expression and stores all match positions. + * + * @param {RegExp} regexPattern - The regular expression pattern to search for. + */ + searchRegex(regexPattern) { + const regex = new RegExp(regexPattern, 'g'); + const dataStr = new TextDecoder('iso-8859-1').decode(this.data); + let match; + while ((match = regex.exec(dataStr)) !== null) { + const byteIndex = match.index; // With 'iso-8859-1', char index == byte index + const length = match[0].length; + this.matches.push({ + index: byteIndex, + length: length + }); + // Prevent infinite loops with zero-length matches + if (match.index === regex.lastIndex) { + regex.lastIndex++; + } + } + console.log(`searchRegex: Found ${this.matches.length} matches.`); + } + + /** + * Scrolls the editor to make the match at the specified index visible. + * + * @param {number} index - The byte index of the match. + */ + scrollToMatch(index) { + const lineNumber = Math.floor(index / this.bytesPerRow); + const lineHeight = 16; // Height of one row in pixels + + // Calculate new scroll position to ensure the matched line is visible + const newScrollTop = Math.max(0, (lineNumber * lineHeight) - ((this.visibleRows / 2) * lineHeight)); + + console.log(`scrollToMatch called with index: ${index}`); + console.log(`lineNumber: ${lineNumber}`); + console.log(`newScrollTop: ${newScrollTop}`); + + // Update the scrollTop property to trigger handleScroll + this.hexeditContent.scrollTop = newScrollTop; + } + + /** + * Clears previous search highlights. + */ + clearSearchHighlights() { + // Remove previous highlights + this.hexview.querySelectorAll('.search-highlight').forEach(span => { + span.classList.remove('search-highlight'); + }); + this.textview.querySelectorAll('.search-highlight').forEach(span => { + span.classList.remove('search-highlight'); + }); + + // Reset active view + this.activeView = null; + + // Reset all search status fields to 0/0 + ['ascii', 'hex', 'regex'].forEach(type => { + this.updateSearchStatus(type, 0, 0); + }); + } + + /** + * Calculates the number of visible rows based on the container's height. + */ + calculateVisibleRows() { + const lineHeight = 16; // Height of one row in pixels + const containerHeight = this.hexeditContent.clientHeight; + this.visibleRows = Math.floor(containerHeight / lineHeight); + this.visibleByteCount = this.bytesPerRow * this.visibleRows; + // console.log(`calculateVisibleRows: visibleRows=${this.visibleRows}, visibleByteCount=${this.visibleByteCount}`); + this.renderDom(); // Re-render to apply the new rows + } + + /** + * Sets the data to be displayed in the hex editor. + * + * @param {Uint8Array} data - The data to set. + */ + setData(data) { + this.data = data; + this.totalRows = Math.ceil(this.data.length / this.bytesPerRow); + console.log(`setData: data length=${this.data.length}, totalRows=${this.totalRows}`); + this.calculateVisibleRows(); // Ensure visibleRows are calculated before rendering + } + + /** + * Retrieves the current data from the hex editor. + * + * @returns {Uint8Array} - The current data. + */ + getData() { + return this.data; + } + + /** + * Handles the scroll event for virtual scrolling. + * + * @param {Event} event - The scroll event. + */ + handleScroll(event) { + const scrollTop = this.hexeditContent.scrollTop; + const lineHeight = 16; // Approximate height of a byte row in pixels + const firstVisibleLine = Math.floor(scrollTop / lineHeight); + const newStartIndex = firstVisibleLine * this.bytesPerRow; + + // console.log(`handleScroll: scrollTop=${scrollTop}, firstVisibleLine=${firstVisibleLine}, newStartIndex=${newStartIndex}`); + + // Update startIndex and re-render the DOM if necessary + if (newStartIndex !== this.startIndex) { + this.startIndex = newStartIndex; + this.renderDom(); // Re-render visible data + // console.log(`handleScroll: Updated startIndex and rendered DOM.`); + } + } + + /** + * Renders the visible portion of the hex editor based on the current scroll position. + */ + renderDom() { + // Clear existing content + [this.offsets, this.hexview, this.textview].forEach(view => view.innerHTML = ''); + const lineHeight = 16; // Approximate line height in pixels + const totalLines = Math.ceil(this.data.length / this.bytesPerRow); + + // Set the height of the content area to simulate the total height + const contentHeight = totalLines * lineHeight; + [this.offsets, this.hexview, this.textview].forEach(view => view.style.height = `${contentHeight}px`); + // Create fragments to hold the visible content + const offsetsFragment = document.createDocumentFragment(); + const hexviewFragment = document.createDocumentFragment(); + const textviewFragment = document.createDocumentFragment(); + + // Calculate the start and end lines to render + const startLine = Math.floor(this.startIndex / this.bytesPerRow); + const endIndex = Math.min(this.startIndex + this.visibleByteCount, this.data.length); + const endLine = Math.ceil(endIndex / this.bytesPerRow); + + const paddingTop = startLine * lineHeight; + + // Apply padding to offset the content to the correct vertical position + this.offsets.style.paddingTop = paddingTop + 'px'; + this.hexview.style.paddingTop = paddingTop + 'px'; + this.textview.style.paddingTop = paddingTop + 'px'; + + // Render only the visible lines + for (let line = startLine; line < endLine; line++) { + const i = line * this.bytesPerRow; + + // Offsets + const offsetSpan = document.createElement("span"); + offsetSpan.innerText = i.toString(16).padStart(8, '0'); + offsetsFragment.appendChild(offsetSpan); + + // Hexview line + const hexLine = document.createElement('div'); + hexLine.classList.add('hex-line'); + + // Textview line + const textLine = document.createElement('div'); + textLine.classList.add('text-line'); + + for (let j = 0; j < this.bytesPerRow && i + j < this.data.length; j++) { + const index = i + j; + const byte = this.data[index]; + + // Create hex span + const hexSpan = document.createElement('span'); + hexSpan.textContent = byte.toString(16).padStart(2, '0'); + hexSpan.dataset.byteIndex = index; + + // Apply search highlights based on search type + this.matches.forEach(match => { + if (index >= match.index && index < match.index + match.length) { + hexSpan.classList.add('search-highlight'); + } + }); + + hexLine.appendChild(hexSpan); + + // Create text span + const charSpan = document.createElement('span'); + let text = self.byteToChar(byte); + if (text === " ") text = "\u00A0"; + else if (text === "-") text = "\u2011"; + charSpan.textContent = text; + charSpan.dataset.byteIndex = index; + if (text === this._NON_PRINTABLE_CHAR) { + charSpan.classList.add("non-printable"); + } + + // Apply search highlights based on search type + this.matches.forEach(match => { + if (index >= match.index && index < match.index + match.length) { + charSpan.classList.add('search-highlight'); + } + }); + + textLine.appendChild(charSpan); + } + + hexviewFragment.appendChild(hexLine); + textviewFragment.appendChild(textLine); + } + + this.offsets.appendChild(offsetsFragment); + this.hexview.appendChild(hexviewFragment); + this.textview.appendChild(textviewFragment); + + this.updateSelection(); + } + + /** + * Updates the visual selection in the hex and text views. + */ + updateSelection() { + // Restore the background color of the previous selection if any + if (this.previousSelectedIndex !== null) { + const prevHexSpan = this.hexview.querySelector(`span[data-byte-index="${this.previousSelectedIndex}"]`); + const prevTextSpan = this.textview.querySelector(`span[data-byte-index="${this.previousSelectedIndex}"]`); + if (prevHexSpan && prevTextSpan) { + // Remove active cursor classes + prevHexSpan.classList.remove('active-view-cursor'); + prevTextSpan.classList.remove('active-view-cursor'); + + // Restore background based on whether it was part of a match + const wasInMatch = this.matches.some(match => this.previousSelectedIndex >= match.index && this.previousSelectedIndex < match.index + match.length); + if (wasInMatch) { + prevHexSpan.classList.add('highlighted'); + prevTextSpan.classList.add('highlighted'); + } else { + prevHexSpan.classList.remove('highlighted'); + prevTextSpan.classList.remove('highlighted'); + } + } + } + + // Clear previous selection classes from active and passive views + Array.from(this.hexedit.querySelectorAll(".active-view-cursor, .passive-view-cursor, .highlighted")) + .forEach(e => e.classList.remove("active-view-cursor", "passive-view-cursor", "highlighted")); + + if (this.selectedIndex === null) return; + + // Check if selectedIndex is within the rendered range + if (this.selectedIndex >= this.startIndex && this.selectedIndex < this.startIndex + this.visibleByteCount) { + const hexSpan = this.hexview.querySelector(`span[data-byte-index="${this.selectedIndex}"]`); + const textSpan = this.textview.querySelector(`span[data-byte-index="${this.selectedIndex}"]`); + if (hexSpan && textSpan) { + // Determine if the selected byte is part of a match + const isInMatch = this.matches.some(match => this.selectedIndex >= match.index && this.selectedIndex < match.index + match.length); + + // Store current selected index as previous for next update + this.previousSelectedIndex = this.selectedIndex; + + if (this.activeView === 'hex') { + // Active view is Hex + hexSpan.classList.add("active-view-cursor"); // Blinking blue + // Passive view (Text) + textSpan.classList.add("passive-view-cursor"); // Always light blue + } else if (this.activeView === 'text') { + // Active view is Text + textSpan.classList.add("active-view-cursor"); // Blinking blue + // Passive view (Hex) + hexSpan.classList.add("passive-view-cursor"); // Always light blue + } + + // Highlight the selected byte if it was part of a match + if (isInMatch) { + if (this.activeView === 'hex') { + hexSpan.classList.add('highlighted'); + } else if (this.activeView === 'text') { + textSpan.classList.add('highlighted'); + } + } + + // Enable immediate editing in active view + if (this.activeView === 'hex') { + this.editHex = true; + } else if (this.activeView === 'text') { + this.editHex = false; + } + + // Focus the active view to enable immediate editing + this.focusActiveView(); + } + } + } + + /** + * Focuses the active view (hex or text). + */ + focusActiveView() { + if (this.activeView === 'hex') { + this.hexview.focus(); + } else if (this.activeView === 'text') { + this.textview.focus(); + } + } + + /** + * Registers event handlers for the hex editor. + */ + _registerEventHandlers() { + // Make hexview and textview focusable by setting tabindex + this.hexview.tabIndex = 0; + this.textview.tabIndex = 0; + + // Handle focus on hexview + this.hexview.addEventListener("focus", () => { + this.activeView = 'hex'; + this.updateSelection(); + }); + + // Handle focus on textview + this.textview.addEventListener("focus", () => { + this.activeView = 'text'; + this.updateSelection(); + }); + + // Handle click on hexview + this.hexview.addEventListener("click", e => { + if (e.target.dataset.byteIndex === undefined) return; + const index = parseInt(e.target.dataset.byteIndex); + this.currentEdit = ""; + this.editHex = true; + this.setSelectedIndex(index); + this.hexview.focus(); // Ensure hexview gains focus + }); + + // Handle click on textview + this.textview.addEventListener("click", e => { + if (e.target.dataset.byteIndex === undefined) return; + const index = parseInt(e.target.dataset.byteIndex); + this.currentEdit = ""; + this.editHex = false; + this.setSelectedIndex(index); + this.textview.focus(); // Ensure textview gains focus + }); + + // Handle keydown events + this.hexedit.addEventListener("keydown", e => { + // If the target is an input (search UI), do not handle hex editor key events + if (e.target.tagName.toLowerCase() === 'input') return; + + if (e.key === "Control") this.ctrlPressed = true; + if (this.selectedIndex === null || this.ctrlPressed) return; + if (e.key === "Escape") { + this.currentEdit = ""; + this.setSelectedIndex(null); + return; + } + if (this.readonly) { + const offsetChange = this._keyShouldApply(e) ?? 0; + this.setSelectedIndex(this.selectedIndex + offsetChange); + return; + } + // Handle key inputs + const key = e.key; + if (this.editHex && key.length === 1 && key.match(/[0-9a-fA-F]/)) { + this.currentEdit += key; + e.preventDefault(); + if (this.currentEdit.length === 2) { + const value = parseInt(this.currentEdit, 16); + this.setValueAt(this.selectedIndex, value); + this.currentEdit = ""; + this.setSelectedIndex(this.selectedIndex + 1); + } + } else if (!this.editHex && key.length === 1) { + const value = key.charCodeAt(0); + this.setValueAt(this.selectedIndex, value); + this.setSelectedIndex(this.selectedIndex + 1); + e.preventDefault(); + } else { + const offsetChange = this._keyShouldApply(e); + if (offsetChange) { + this.setSelectedIndex(this.selectedIndex + offsetChange); + e.preventDefault(); + } + } + }); + + // Handle keyup events + this.hexedit.addEventListener("keyup", e => { + if (e.key === "Control") this.ctrlPressed = false; + }); + + // Handle scrolling for virtual scrolling + this.hexeditContent.addEventListener('scroll', this.handleScroll.bind(this)); + } + + /** + * Sets the value at a specific index in the data and updates the view if necessary. + * + * @param {number} index - The byte index to set. + * @param {number} value - The value to set. + */ + setValueAt(index, value) { + this.data[index] = value; + // If the index is within the rendered range, update the display + if (index >= this.startIndex && index < this.startIndex + this.visibleByteCount) { + const hexSpan = this.hexview.querySelector(`span[data-byte-index="${index}"]`); + const textSpan = this.textview.querySelector(`span[data-byte-index="${index}"]`); + if (hexSpan) hexSpan.textContent = value.toString(16).padStart(2, '0'); + if (textSpan) { + let text = self.byteToChar(value); + if (text === " ") text = "\u00A0"; + else if (text === "-") text = "\u2011"; + textSpan.textContent = text; + if (text === this._NON_PRINTABLE_CHAR) { + textSpan.classList.add("non-printable"); + } else { + textSpan.classList.remove("non-printable"); + } + } + } + } + + /** + * Sets the currently selected byte index and updates the view. + * + * @param {number|null} index - The byte index to select, or null to clear selection. + */ + setSelectedIndex(index) { + this.selectedIndex = index; + // console.log(`setSelectedIndex called with index: ${index}`); + + if (index !== null) { + // Calculate the line number of the selected index + const lineNumber = Math.floor(index / this.bytesPerRow); + const lineHeight = 16; // Height of one row in pixels + const scrollTop = lineNumber * lineHeight; + + // Determine visible range + const visibleStartLine = Math.floor(this.hexeditContent.scrollTop / lineHeight); + const visibleEndLine = visibleStartLine + this.visibleRows; + + // console.log(`setSelectedIndex: lineNumber=${lineNumber}, visibleStartLine=${visibleStartLine}, visibleEndLine=${visibleEndLine}`); + + // If the selected line is out of the visible range, update scrollTop + if (lineNumber < visibleStartLine || lineNumber >= visibleEndLine) { + const newScrollTop = Math.max(0, (lineNumber * lineHeight) - ((this.visibleRows / 2) * lineHeight)); + this.hexeditContent.scrollTop = newScrollTop; + // console.log(`setSelectedIndex: Updated scrollTop to ${this.hexeditContent.scrollTop}`); + } + } + + this.updateSelection(); + } + + /** + * Updates the search status field for a given search type. + * + * @param {string} searchType - The type of search ('ascii', 'hex', 'regex'). + * @param {number} current - The current match index. + * @param {number} total - The total number of matches. + */ + updateSearchStatus(searchType, current, total) { + // Update only the relevant search type status field + ['ascii', 'hex', 'regex'].forEach(type => { + const statusElement = document.getElementById(`hexedit-search-status-${type}`); + if (type === searchType) { + statusElement.textContent = `${current}/${total}`; + } else { + statusElement.textContent = `0/0`; + } + }); + } + + /** + * Determines if a key event should result in a byte index change. + * + * @param {KeyboardEvent} event - The keyboard event. + * @returns {number|null} - The byte index change or null. + */ + _keyShouldApply(event) { + if (event.key === "Enter") return 1; + if (event.key === "Tab") return 1; + if (event.key === "Backspace") return -1; + if (event.key === "ArrowLeft") return -1; + if (event.key === "ArrowRight") return 1; + if (event.key === "ArrowUp") return -16; + if (event.key === "ArrowDown") return 16; + return null; + } + + /** + * Fills the hex editor DOM structure. + * + * @param {HTMLElement} hexedit - The DOM element for the hex editor. + * @returns {HTMLElement} - The filled hex editor DOM element. + */ + fillHexeditDom(hexedit) { + hexedit.classList.add('hexedit'); + + // Create headers + const offsetsHeader = document.createElement("div"); + offsetsHeader.classList.add("offsets-header"); + offsetsHeader.innerText = _('Offset (h)'); + + const hexviewHeader = document.createElement("div"); + hexviewHeader.classList.add("hexview-header"); + for (let i = 0; i < 16; i++) { + const span = document.createElement("span"); + span.innerText = i.toString(16).toUpperCase().padStart(2, "0"); + hexviewHeader.appendChild(span); + } + + const textviewHeader = document.createElement("div"); + textviewHeader.classList.add("textview-header"); + textviewHeader.innerText = _('Decoded Text'); + + // Header container + const headersContainer = document.createElement("div"); + headersContainer.classList.add("hexedit-headers"); + headersContainer.appendChild(offsetsHeader); + headersContainer.appendChild(hexviewHeader); + headersContainer.appendChild(textviewHeader); + + // Create content areas + const offsets = document.createElement("div"); + offsets.classList.add("offsets"); + + const hexview = document.createElement("div"); + hexview.classList.add("hexview"); + + const textview = document.createElement("div"); + textview.classList.add("textview"); + + // Content container + const contentContainer = document.createElement("div"); + contentContainer.classList.add("hexedit-content"); + contentContainer.appendChild(offsets); + contentContainer.appendChild(hexview); + contentContainer.appendChild(textview); + + // Assemble hex editor + hexedit.appendChild(headersContainer); + hexedit.appendChild(contentContainer); + + // Assign references + hexedit.offsets = offsets; + hexedit.hexview = hexview; + hexedit.textview = textview; + hexedit.headersContainer = headersContainer; + hexedit.contentContainer = contentContainer; + + return hexedit; + } + } + + // Instantiate HexEditor with rootClass + var hexEditorInstance = new HexEditor(container, rootClass); + + return hexEditorInstance; + }, + + /** + * Opens a file in the hex editor. + * @param {string} filePath - The path to the file to edit. + * @param {string | ArrayBuffer} content - The content of the file. + * @param {string} style - The style of the content ('Text' or 'Bin'). + */ + edit: function(filePath, content, style, permissions, ownerGroup) { + var self = this; + + if (style.toLowerCase() !== 'bin') { + self.popm(null, `[${PN}]: ` + _('Unsupported style "' + style + '". Only "Bin" is supported.')); + console.warn('Unsupported style:', style); + return; + } + + self.currentFilePath = filePath; + self.permissions = permissions; + self.ownerGroup = ownerGroup; + + var data; + if (content instanceof ArrayBuffer) { + data = new Uint8Array(content); + } else if (typeof content === 'string') { + data = new Uint8Array(content.length); + for (var i = 0; i < content.length; i++) { + data[i] = content.charCodeAt(i); + } + } else { + self.popm(null, `[${PN}]: ` + _('Unsupported content type.')); + console.error('Unsupported content type:', typeof content); + return; + } + + self.hexEditorInstance.setData(data); + + var parts = filePath.split('/'); + var filename = parts[parts.length - 1]; + self.filenameDisplay.textContent = 'Editing: ' + filename; + + self.popm(null, `[${PN}]: ` + _('Opened file "' + filename + '".')); + }, + + /** + * Saves the current data in the hex editor using the Navigation plugin. + */ + saveFile: function() { + var self = this; + + if (!self.currentFilePath) { + self.popm( + ['$Hex Editor'], 'Hex Editor: No file loaded to save.', 'error' + ); + return; + } + + var data = self.hexEditorInstance.getData(); + var content = data.buffer; // Get ArrayBuffer + + // Attempt to save the file using the Navigation plugin's write_file function + // Assuming write_file returns a Promise + self.write_file(self.currentFilePath, self.permissions, self.ownerGroup, content, 'Bin') + .then(function() { + self.popm( + ['$Hex Editor'], 'Hex Editor: File saved successfully.', 'success' + ); + }) + .catch(function(err) { + self.popm( + ['$Hex Editor'], 'Hex Editor: Error saving file.', 'error' + ); + console.error('Error saving file:', err); + }); + }, + + /** + * Retrieves the current settings of the plugin. + * @returns {Object} - Current settings including window size. + */ + get_settings: function() { + return { + width: this.editorDiv.style.width, + height: this.editorDiv.style.height + }; + }, + + /** + * Applies settings to the plugin. + * @param {Object} settings - Settings object containing window size. + */ + set_settings: function(settings) { + if (settings.width) { + this.editorDiv.style.width = settings.width; + } + if (settings.height) { + this.editorDiv.style.height = settings.height; + } + } +}); diff --git a/applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/term.js b/applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/term.js new file mode 100644 index 00000000000..200b3ceee76 --- /dev/null +++ b/applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/term.js @@ -0,0 +1,354 @@ +'use strict'; +'require ui'; +'require dom'; +'require fs'; + +/** + * Dumb Terminal Plugin + * Emulates a simple terminal for interacting with OpenWRT. + * Supports sending commands and displaying output. + * Utilizes fs.exec() from LuCI's fs.js for command execution. + * + * Enhancements: + * - Adds resizable window with scrollbars. + * - Introduces settings for configuring window size. + */ + +const PN = 'Dumb Term.'; + +return Class.extend({ + /** + * Returns metadata about the plugin. + * @returns {Object} Plugin information. + */ + info: function() { + return { + name: PN, + type: 'Utility', + description: 'Emulates a simple terminal for OpenWRT, allowing sending commands and receiving output.' + }; + }, + + /** + * Generates CSS styles for the Dumb Terminal plugin with a unique suffix. + * @param {string} uniqueId - The unique identifier for this plugin instance. + * @returns {string} - The CSS styles as a string. + */ + generateCss: function(uniqueId) { + return ` + /* CSS for Dumb Terminal Plugin - Instance ${uniqueId} */ + .dumb-terminal-plugin-${uniqueId} { + padding: 10px; + background-color: #1e1e1e; + border: 1px solid #555; + resize: both; /* Allows the window to be resizable */ + overflow: hidden; /* Hide scrollbars at the plugin level */ + box-shadow: 2px 2px 5px rgba(0,0,0,0.3); + font-family: 'Courier New', Courier, 'Lucida Console', 'Liberation Mono', monospace; + color: #ffffff; + position: relative; + display: flex; + flex-direction: column; + width: ${this.width || '400px'}; + height: ${this.height || '300px'}; + } + + .dumb-terminal-plugin-${uniqueId} .terminal-output { + flex-grow: 1; + background-color: #000000; + padding: 10px; + overflow-y: auto; /* Enables vertical scrollbar */ + overflow-x: auto; /* Enables horizontal scrollbar */ + border: 1px solid #333; + margin-bottom: 10px; + white-space: pre-wrap; + font-size: 14px; + font-family: inherit; /* Inherits the monospace font from the parent */ + } + + .dumb-terminal-plugin-${uniqueId} .terminal-input { + display: flex; + } + + .dumb-terminal-plugin-${uniqueId} .terminal-input input { + flex-grow: 1; + padding: 8px; + background-color: #2a2a2a; + border: 1px solid #555; + color: #ffffff; + outline: none; + font-size: 14px; + font-family: inherit; /* Inherits the monospace font from the parent */ + } + + .dumb-terminal-plugin-${uniqueId} .terminal-input button { + padding: 8px 16px; + background-color: #0078d7; + color: #fff; + border: none; + cursor: pointer; + margin-left: 5px; + border-radius: 4px; + font-size: 14px; + font-family: inherit; /* Inherits the monospace font from the parent */ + } + + .dumb-terminal-plugin-${uniqueId} .terminal-input button:hover { + background-color: #005fa3; + } + + /* Dark theme adjustments */ + .dark-theme .dumb-terminal-plugin-${uniqueId} { + background-color: #2a2a2a; + border-color: #777; + } + + .dark-theme .dumb-terminal-plugin-${uniqueId} .terminal-output { + background-color: #1e1e1e; + border-color: #555; + } + + .dark-theme .dumb-terminal-plugin-${uniqueId} .terminal-input input { + background-color: #3a3a3a; + border-color: #555; + color: #fff; + } + + .dark-theme .dumb-terminal-plugin-${uniqueId} .terminal-input button { + background-color: #1e90ff; + } + + .dark-theme .dumb-terminal-plugin-${uniqueId} .terminal-input button:hover { + background-color: #1c7ed6; + } + `; + }, + + /** + * Initializes the plugin within the given container. + * @param {HTMLElement} container - The container element where the plugin will be rendered. + * @param {Object} pluginsRegistry - The registry of all loaded plugins. + * @param {Object} default_plugins - The default plugins for each type. + * @param {string} uniqueId - A unique identifier for this plugin instance. + */ + start: function(container, pluginsRegistry, default_plugins, uniqueId) { + var self = this; + + // Initialize command history + self.commandHistory = []; + self.historyIndex = -1; + + // Ensure the plugin is only initialized once + if (self.initialized) { + return; + } + self.initialized = true; + + // Store references for later use + self.pluginsRegistry = pluginsRegistry; + self.default_plugins = default_plugins; + self.uniqueId = uniqueId; + + // Set default window size + self.width = '400px'; + self.height = '300px'; + + // Create and inject the unique CSS for this plugin instance + var styleTag = document.createElement('style'); + styleTag.type = 'text/css'; + styleTag.id = `dumb-terminal-plugin-style-${uniqueId}`; + styleTag.innerHTML = self.generateCss(uniqueId); + document.head.appendChild(styleTag); + self.styleTag = styleTag; // Store reference for potential future removal + + // Create the main div for the terminal with a unique class + self.terminalDiv = document.createElement('div'); + self.terminalDiv.className = `dumb-terminal-plugin-${uniqueId}`; + self.terminalDiv.style.width = self.width; + self.terminalDiv.style.height = self.height; + + // Create the terminal output area + self.outputDiv = document.createElement('div'); + self.outputDiv.className = 'terminal-output'; + self.outputDiv.textContent = 'Terminal initialized.\n'; + + // Create the input container + self.inputContainer = document.createElement('div'); + self.inputContainer.className = 'terminal-input'; + + // Create the input field for commands + self.inputField = document.createElement('input'); + self.inputField.type = 'text'; + self.inputField.placeholder = 'Enter command...'; + self.inputField.addEventListener('keypress', function(event) { + if (event.key === 'Enter') { + self.executeCommand(); + } + }); + self.inputField.addEventListener('keydown', function(event) { + if (event.key === 'ArrowUp') { + if (self.historyIndex > 0) { + self.historyIndex--; + self.inputField.value = self.commandHistory[self.historyIndex]; + } + event.preventDefault(); + } else if (event.key === 'ArrowDown') { + if (self.historyIndex < self.commandHistory.length - 1) { + self.historyIndex++; + self.inputField.value = self.commandHistory[self.historyIndex]; + } else { + self.historyIndex = self.commandHistory.length; + self.inputField.value = ''; + } + event.preventDefault(); + } + }); + + // Create the execute button + self.executeButton = document.createElement('button'); + self.executeButton.textContent = 'Run'; + self.executeButton.onclick = self.executeCommand.bind(this); + + // Create the clear button + self.clearButton = document.createElement('button'); + self.clearButton.textContent = 'Clear'; + self.clearButton.onclick = function() { + self.outputDiv.textContent = ''; + }; + + // Append input fields and buttons to the input container + self.inputContainer.appendChild(self.inputField); + self.inputContainer.appendChild(self.executeButton); + self.inputContainer.appendChild(self.clearButton); + + // Append output and input containers to the main terminal div + self.terminalDiv.appendChild(self.outputDiv); + self.terminalDiv.appendChild(self.inputContainer); + + // Append the terminal div to the provided container + container.appendChild(self.terminalDiv); + + // Retrieve the Dispatcher plugin for notifications (if available) + var dispatcherName = self.default_plugins['Dispatcher']; + if (dispatcherName && self.pluginsRegistry[dispatcherName]) { + var dispatcher = self.pluginsRegistry[dispatcherName]; + self.popm = dispatcher.pop.bind(dispatcher); + } + + // No need to retrieve Navigation plugin since fs.exec() is used directly + }, + + /** + * Executes the command entered by the user using fs.exec(). + */ + executeCommand: function() { + var self = this; + var commandInput = self.inputField.value.trim(); + if (commandInput === '') return; + + // Display the entered command in the output area + self.outputDiv.textContent += `> ${commandInput}\n`; + self.inputField.value = ''; + self.inputField.focus(); + + // Add to command history + self.commandHistory.push(commandInput); + self.historyIndex = self.commandHistory.length; + + // Split the command into command and arguments + var parts = self.parseCommand(commandInput); + var cmd = parts.cmd; + var args = parts.args; + + // Execute the command using fs.exec() + // fs.exec(command, args, env) returns a Promise + // 'env' can be null if no environment variables are needed + fs.exec(cmd, args, null) + .then(function(result) { + // Assuming result has 'stdout' and 'stderr' + if (result.stdout && result.stdout.trim() !== '') { + self.outputDiv.textContent += `${result.stdout}\n`; + } + if (result.stderr && result.stderr.trim() !== '') { + self.outputDiv.textContent += `Error: ${result.stderr}\n`; + } + if (self.popm) { + self.popm(null, `[${PN}]: Command executed successfully.`); + } + self.outputDiv.scrollTop = self.outputDiv.scrollHeight; + }) + .catch(function(error) { + // Handle errors from the RPC call + self.outputDiv.textContent += `Error: ${error.message}\n`; + if (self.popm) { + self.popm(null, `[${PN}]: Error executing command.`); + } + self.outputDiv.scrollTop = self.outputDiv.scrollHeight; + console.error('Error executing command:', error); + }); + + // Optional: Log the command execution attempt + console.log(`[${PN}]: Executed command - ${commandInput}`); + }, + + /** + * Parses the command string into command and arguments. + * @param {string} commandInput - The raw command string entered by the user. + * @returns {Object} An object containing the command and an array of arguments. + */ + parseCommand: function(commandInput) { + // Simple parsing: split by spaces, handle quotes if necessary + // For more robust parsing, consider using a proper command-line parser + + var regex = /[^\s"]+|"([^"]*)"/gi; + var args = []; + var match; + while ((match = regex.exec(commandInput)) !== null) { + args.push(match[1] ? match[1] : match[0]); + } + + var cmd = args.shift(); // The first element is the command + return { + cmd: cmd, + args: args + }; + }, + + /** + * Retrieves the current settings of the plugin. + * @returns {Object} - Current settings including window size. + */ + get_settings: function() { + return { + width: this.terminalDiv.style.width || '400px', + height: this.terminalDiv.style.height || '300px' + }; + }, + + /** + * Applies settings to the plugin. + * @param {Object} settings - Settings object containing window size. + */ + set_settings: function(settings) { + if (settings.width) { + this.terminalDiv.style.width = settings.width; + } + if (settings.height) { + this.terminalDiv.style.height = settings.height; + } + }, + + /** + * Cleans up the plugin instance by removing injected styles and elements. + */ + destroy: function() { + var self = this; + if (self.styleTag) { + self.styleTag.remove(); + } + if (self.terminalDiv && self.terminalDiv.parentNode) { + self.terminalDiv.parentNode.removeChild(self.terminalDiv); + } + self.initialized = false; + } +}); diff --git a/applications/luci-app-file-plug-manager/po/templates/file-plug-manager.pot b/applications/luci-app-file-plug-manager/po/templates/file-plug-manager.pot new file mode 100644 index 00000000000..4089ac25d92 --- /dev/null +++ b/applications/luci-app-file-plug-manager/po/templates/file-plug-manager.pot @@ -0,0 +1,531 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-12-21 09:04-0500\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=CHARSET\n" +"Content-Transfer-Encoding: 8bit\n" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager.js:145 +msgid "File Plug Manager" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager.js:391 +msgid "Logs are always active" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager.js:481 +#, javascript-format +msgid "Plugin \"%s\" has been activated." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager.js:483 +#, javascript-format +msgid "Plugin \"%s\" not found." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager.js:517 +#, javascript-format +msgid "Duplicate plugin name \"%s\" found. Skipping." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager.js:527 +#, javascript-format +msgid "Plugin \"%s\" is missing required functions. Skipping." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager.js:534 +#, javascript-format +msgid "Plugin \"%s\" has invalid info. Skipping." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager.js:540 +#, javascript-format +msgid "Plugin \"%s\" has unsupported type \"%s\". Skipping." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager.js:548 +#, javascript-format +msgid "Navigation plugin \"%s\" is missing required functions. Skipping." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager.js:556 +#, javascript-format +msgid "Settings plugin \"%s\" is missing read_settings. Skipping." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager.js:571 +#, javascript-format +msgid "Error loading plugin \"%s\"." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager.js:576 +#, javascript-format +msgid "Ignored non-JS file \"%s\" in plugins directory." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager.js:640 +msgid "Settings loaded successfully." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager.js:642 +msgid "Error reading settings." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager.js:646 +msgid "Settings plugin does not implement read_settings." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager.js:649 +msgid "Tab for default Settings plugin not found." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager.js:652 +msgid "Default Settings plugin not found or cannot be started." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager.js:655 +msgid "No default Settings plugin available." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager.js:666 +msgid "Error executing ls to load plugins." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager.js:697 +#, javascript-format +msgid "No plugins available for type \"%s\"." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager.js:725 +msgid "Set as default" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager.js:764 +#, javascript-format +msgid "Set \"%s\" as the default %s plugin." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager.js:800 +msgid "Error parsing dropped data." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager.js:805 +msgid "Unsupported drop data." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager.js:869 +msgid "Target editor plugin does not support editing files." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager.js:874 +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/hexEditor.js:380 +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Settings.js:445 +msgid "No default Navigation plugin set." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager.js:880 +msgid "Default Navigation plugin does not support reading files." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager.js:890 +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:2833 +#, javascript-format +msgid "File \"%s\" opened in editor." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager.js:892 +#, javascript-format +msgid "Error reading file \"%s\"." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager.js:896 +msgid "Navigation plugin does not handle direct file opening." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/hexEditor.js:387 +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Settings.js:452 +msgid "Navigation plugin does not support writing files." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/hexEditor.js:422 +msgid "No file loaded to save." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/hexEditor.js:433 +msgid "File saved successfully." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/hexEditor.js:436 +msgid "Error saving file." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/hexEditor.js:567 +msgid "Search ASCII" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/hexEditor.js:570 +msgid "Search HEX (e.g., 4F6B)" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/hexEditor.js:573 +msgid "Search RegExp (e.g., \\d{3})" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/hexEditor.js:1375 +msgid "Offset (h)" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/hexEditor.js:1387 +msgid "Decoded Text" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/hexEditor.js:1444 +msgid "Unsupported style \"" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/hexEditor.js:1462 +msgid "Unsupported content type." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/hexEditor.js:1473 +msgid "Opened file \"" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:1098 +msgid "Drop files here to upload" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:1109 +msgid "Upload" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:1115 +msgid "Create Folder" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:1121 +msgid "Create File" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:1127 +msgid "Delete Selected" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:1336 +msgid "The specified path is not a directory." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:1339 +#, javascript-format +msgid "Failed to access the specified path: %s" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:1508 +msgid "Failed to set permissions or ownership:" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:1517 +msgid "Network error" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:1565 +#, javascript-format +msgid "Uploading \"%s\"..." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:1595 +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:1597 +#, javascript-format +msgid "File \"%s\" uploaded successfully." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:1604 +#, javascript-format +msgid "Upload failed for file \"%s\"." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:1606 +#, javascript-format +msgid "Error uploading file \"%s\"." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:1620 +msgid "Enter folder name:" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:1626 +#, javascript-format +msgid "Folder \"%s\" created successfully." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:1631 +#, javascript-format +msgid "Failed to create folder \"%s\": %s" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:1641 +msgid "Enter file name:" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:1647 +#, javascript-format +msgid "File \"%s\" created successfully." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:1652 +#, javascript-format +msgid "Failed to create file \"%s\": %s" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:1664 +msgid "Are you sure you want to delete the selected items?" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:1685 +#, javascript-format +msgid "Successfully deleted %d items." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:1689 +#, javascript-format +msgid "Failed to delete \"%s\"." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:1848 +msgid "Loading..." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:1871 +#, javascript-format +msgid "Failed to list directory: %s" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:1872 +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:1923 +msgid "Error loading directory." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:2009 +msgid "" +"Dragging started. Drop onto a directory within this UI to copy/move files " +"(Alt=copy), or drop outside the browser to download." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:2080 +msgid "Edit" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:2090 +msgid "Copy" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:2100 +msgid "Delete" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:2111 +msgid "Download" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:2290 +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:2624 +msgid "Response is not a Blob" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:2302 +msgid "Download failed:" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:2303 +msgid "Download failed: " +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:2314 +#, javascript-format +msgid "Are you sure you want to delete \"%s\"?" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:2316 +#, javascript-format +msgid "File \"%s\" deleted successfully." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:2319 +#, javascript-format +msgid "Failed to delete file \"%s\": %s" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:2364 +#, javascript-format +msgid "Error checking \"%s\": %s" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:2379 +#, javascript-format +msgid "Directory \"%s\" copied successfully as \"%s\"." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:2382 +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:2385 +#, javascript-format +msgid "Failed to copy directory \"%s\": %s" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:2391 +#, javascript-format +msgid "Symlink \"%s\" copied successfully as \"%s\"." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:2394 +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:2397 +#, javascript-format +msgid "Failed to copy symlink \"%s\": %s" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:2403 +#, javascript-format +msgid "File \"%s\" copied successfully as \"%s\"." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:2406 +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:2409 +#, javascript-format +msgid "Failed to copy file \"%s\": %s" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:2413 +#, javascript-format +msgid "Failed to find copy number for \"%s\": %s" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:2436 +#, javascript-format +msgid "Edit \"%s\"" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:2437 +msgid "New Name:" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:2443 +msgid "Owner:Group:" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:2449 +msgid "Permissions:" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:2457 +msgid "Submit" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:2460 +msgid "Cancel" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:2486 +msgid "File name cannot be empty." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:2506 +#, javascript-format +msgid "\"%s\" edited successfully." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:2510 +#, javascript-format +msgid "Failed to edit \"%s\": %s" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:2542 +msgid "Root" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:2767 +msgid "No files were dragged." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:2775 +msgid "Failed to parse dragged files data." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:2817 +msgid "No default editor plugin found." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:2835 +msgid "Unable to activate editor plugin." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:2836 +msgid "Main Dispatcher or activatePlugin method not found." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:2841 +msgid "Default editor does not implement edit function." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Navigation.js:2845 +#, javascript-format +msgid "Failed to read file \"%s\": %s" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Settings.js:268 +msgid "Settings: Error applying settings to plugin \"" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Settings.js:284 +msgid "Settings: Configuration file not found. Using default settings." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Settings.js:291 +msgid "Settings: Error reading settings." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Settings.js:418 +msgid "Save Settings" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Settings.js:473 +msgid "Settings panel not found." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Settings.js:501 +msgid "Error retrieving settings for \"" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Settings.js:507 +msgid "No settings available." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Settings.js:590 +msgid "Settings form not found." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Settings.js:629 +msgid "Error applying settings to plugin \"" +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Settings.js:638 +msgid "Settings saved successfully." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Settings.js:642 +msgid "Error saving settings to file." +msgstr "" + +#: applications/luci-app-file-plug-manager/htdocs/luci-static/resources/view/system/file-plug-manager/plugins/Settings.js:646 +msgid "Error applying settings." +msgstr "" diff --git a/applications/luci-app-file-plug-manager/root/usr/share/luci/menu.d/luci-app-file-plug-manager.json b/applications/luci-app-file-plug-manager/root/usr/share/luci/menu.d/luci-app-file-plug-manager.json new file mode 100644 index 00000000000..153f927093c --- /dev/null +++ b/applications/luci-app-file-plug-manager/root/usr/share/luci/menu.d/luci-app-file-plug-manager.json @@ -0,0 +1,13 @@ +{ + "admin/system/file-plug-manager": { + "title": "File Manager Plugins", + "order": 80, + "action": { + "type": "view", + "path": "system/file-plug-manager" + }, + "depends": { + "acl": [ "luci-app-file-plug-manager" ] + } + } +} diff --git a/applications/luci-app-file-plug-manager/root/usr/share/rpcd/acl.d/luci-app-file-plug-manager.json b/applications/luci-app-file-plug-manager/root/usr/share/rpcd/acl.d/luci-app-file-plug-manager.json new file mode 100644 index 00000000000..2ac5c5c9ca7 --- /dev/null +++ b/applications/luci-app-file-plug-manager/root/usr/share/rpcd/acl.d/luci-app-file-plug-manager.json @@ -0,0 +1,14 @@ +{ + "luci-app-file-plug-manager": { + "description": "Grant access to File Manager", + "write": { + "cgi-io": [ "upload", "download" ], + "ubus": { + "file": [ "*" ] + }, + "file": { + "/*": [ "list", "read", "write", "exec" ] + } + } + } +}