class WebDAVModule { constructor() { this.commands = {}; this.terminal = null; this.config = null; // { url, username, password } this._browsingPath = '/'; this._STORAGE_KEY = 'webx11_webdav_config'; // Auto-discover space name from page URL this._spaceName = this._detectSpaceName(); this._autoConfigured = false; this._loadConfig(); } _detectSpaceName() { try { const path = window.location.pathname; // /spaces/chatstudio/newos4/ → chatstudio const parts = path.split('/').filter(p => p); const spacesIdx = parts.indexOf('spaces'); if (spacesIdx >= 0 && spacesIdx + 1 < parts.length) { return parts[spacesIdx + 1]; } } catch (e) {} return ''; } _detectSessionUser() { try { const cookies = document.cookie.split(';').map(c => c.trim()); for (const c of cookies) { if (c.startsWith('webx11_user=')) { return decodeURIComponent(c.substring('webx11_user='.length)); } } } catch (e) {} return null; } _promptForToken(spaceName) { // Use textarea+execCommand for mobile WebView compatibility const input = window.prompt('Enter your WebDAV token for space "' + spaceName + '":', ''); if (input && input.trim()) { return input.trim(); } return null; } requirements() { const cfg = localStorage.getItem('webx11_webdav_config'); if (!cfg) return { met: false, missing: ['WebDAV endpoint not configured. Use webdav-config to set it up.'] }; try { const parsed = JSON.parse(cfg); if (!parsed.url) return { met: false, missing: ['WebDAV URL not set'] }; return { met: true, missing: [] }; } catch (e) { return { met: false, missing: ['WebDAV config corrupted'] }; } } getHelpInfo() { return { 'webdav-config': 'webdav-config [url user pass] — Configure WebDAV. No args = auto-detect from current page URL', 'webdav-status': 'Show current WebDAV configuration status', 'webdav-ls': 'List files/directories on the WebDAV remote', 'webdav-push': 'Upload a VFS file to WebDAV', 'webdav-pull': 'Download a WebDAV file to VFS', 'webdav-backup': 'Backup entire VFS to WebDAV as a dated JSON snapshot', 'webdav-restore': 'Restore VFS from a WebDAV backup file', 'webdav-backup-list': 'List available backup files on WebDAV', 'webdav-browser': 'webdav-browser [path] — Modern WebDAV file-tree browser with bulk operations', 'webdav-rm': 'webdav-rm — Delete a remote file or directory', 'webdav-mkdir': 'webdav-mkdir — Create a remote directory', 'webdav-rename': 'webdav-rename — Rename or move a remote file/dir', 'webdav-move': 'webdav-move — Alias for webdav-rename', }; } _loadConfig() { try { const saved = localStorage.getItem(this._STORAGE_KEY); if (saved) { this.config = JSON.parse(saved); // Try to get password from SecretVault (async — will be resolved on first use) if (window.webx11_secretVault && this.config && this.config.username) { // Store a placeholder; actual decrypt happens on first auth call this._cachedPassUser = this.config.username; } } } catch (e) { /* ignore */ } } async _resolvePassword() { if (!window.webx11_secretVault || !this._cachedPassUser) return; try { const pass = await window.webx11_secretVault.decrypt('webdav_pass_' + this._cachedPassUser); if (pass !== null && typeof pass === 'string') { this.config.password = pass; } else { // Fallback to old token key const tokenPass = await window.webx11_secretVault.decrypt('webdav_token_' + this._cachedPassUser); if (tokenPass !== null && typeof tokenPass === 'string') { this.config.password = tokenPass; } } } catch (e) { /* ignore */ } this._cachedPassUser = null; } async _saveConfig() { try { if (this.config) { const toStore = { url: this.config.url, username: this.config.username }; // Try SecretVault for encrypted password storage if (window.webx11_secretVault && this.config.password) { await window.webx11_secretVault.encrypt('webdav_pass_' + this.config.username, this.config.password); // Also store space token if (this._spaceName) { await window.webx11_secretVault.encrypt('webdav_token_' + this._spaceName, this.config.password); } } // Also store in localStorage for backward compat (url and username only) localStorage.setItem(this._STORAGE_KEY, JSON.stringify(toStore)); } else { localStorage.removeItem(this._STORAGE_KEY); } } catch (e) { console.error('WebDAV config save error:', e); } } _getAuthHeader() { if (!this.config || !this.config.password) return {}; const token = btoa(this.config.username + ':' + this.config.password); return { 'Authorization': 'Basic ' + token }; } async _webdavRequest(method, path, options = {}) { if (!this.config || !this.config.url) { throw new Error('WebDAV not configured. Use webdav-config first.'); } // Resolve password from SecretVault if needed (lazy load) if (this._cachedPassUser) { await this._resolvePassword(); } const baseUrl = this.config.url.replace(/\/+$/, ''); const urlPath = path ? (path.startsWith('/') ? path : '/' + path) : '/'; const url = baseUrl + urlPath; const headers = { ...this._getAuthHeader(), ...(options.headers || {}) }; const fetchOpts = { method: method, headers: headers, body: options.body || undefined }; const response = await fetch(url, fetchOpts); return response; } async _listDir(path) { // For WebX11 spaces, skip PROPFIND (nginx returns 302) and use __list__ directly if (this._spaceName) { return this._listDirViaAutoIndex(path); } // Non-nginx: try PROPFIND first, fallback to autoindex try { const response = await this._webdavRequest('PROPFIND', path, { headers: { 'Depth': '1' } }); if (response.ok) { const text = await response.text(); return this._parsePropfind(text); } } catch (e) { /* PROPFIND failed, try autoindex */ } return this._listDirViaAutoIndex(path); } async _listDirViaAutoIndex(path) { if (!this._spaceName) { throw new Error('WebDAV: cannot use __list__ without space name'); } // Derive host from configured URL to support different environments let host = 'https://webx11.duckdns.org'; if (this.config && this.config.url) { try { const u = new URL(this.config.url); host = u.origin; } catch (e) {} } const cleanPath = path ? (path.startsWith('/') ? path : '/' + path) : '/'; // Avoid double slash when path starts with / const listUrl = host + '/spaces/' + this._spaceName + '/__list__' + (cleanPath === '/' ? '' : cleanPath.replace(/\/{2,}/g, '/')); try { const resp = await fetch(listUrl, { method: 'GET' }); if (!resp.ok) throw new Error('Autoindex failed: HTTP ' + resp.status); const html = await resp.text(); return this._parseAutoindex(html); } catch (e) { throw new Error('Cannot list directory: ' + e.message); } } _parseAutoindex(html) { const entries = []; // Parse nginx autoindex HTML format — flexible regex that doesn't depend on date locale const linkRegex = /([^<]+)<\/a>\s+([\w]{2}-[\w]{3}-\d{4}\s+\d{2}:\d{2})\s+([\d\w.-]+)/g; let match; while ((match = linkRegex.exec(html)) !== null) { const href = match[1]; const name = match[2].replace(/\/$/, ''); const sizeStr = match[4]; if (name === '..' || name === '../' || name === '__list__') continue; const isDir = href.endsWith('/'); let size = 0; if (!isDir && sizeStr !== '-') { size = parseInt(sizeStr) || 0; } entries.push({ name: name, type: isDir ? 'dir' : 'file', size: size, href: href }); } // Fallback: if regex failed, try simpler parse if (entries.length === 0) { // Try broadest: extract all hrefs const broadRegex = /]*>/g; const names = []; while ((match = broadRegex.exec(html)) !== null) { const href = match[1]; if (href === '../' || href === '/' || href === './') continue; if (href.startsWith('?')) continue; names.push(href); } if (names.length > 0) { for (const href of names) { const isDir = href.endsWith('/'); entries.push({ name: decodeURIComponent(href.replace(/\/$/, '').split('/').pop() || href), type: isDir ? 'dir' : 'file', size: 0, href: href }); } } else { // Last resort: match any const lastRegex = / { const href = (resp.querySelector('D\\:href, href') || {}).textContent || ''; const isDir = !!((resp.querySelector('D\\:collection, collection') || {}).textContent); const getContentLength = (resp.querySelector('D\\:getcontentlength, getcontentlength, lp1\\:getcontentlength') || {}).textContent; const size = getContentLength ? parseInt(getContentLength) : 0; const displayName = href.split('/').filter(Boolean).pop() || '/'; if (displayName) { entries.push({ name: decodeURIComponent(displayName), type: isDir ? 'dir' : 'file', size: size, href: href }); } }); // Remove the root entry (same as first child's dir) return entries.filter((e, i) => i > 0 || e.type !== 'dir' || entries.length <= 1); } async _ensureDir(remotePath) { // Try MKCOL — ignore 405 (already exists) and 409 (parent missing) const response = await this._webdavRequest('MKCOL', remotePath); if (response.ok || response.status === 405 || response.status === 301 || response.status === 302) return true; if (response.status === 409) { // Parent missing — try creating parent recursively const parts = remotePath.replace(/\/$/, '').split('/').filter(Boolean); if (parts.length > 1) { const parent = parts.slice(0, -1).join('/'); await this._ensureDir(parent); // Retry MKCOL const retry = await this._webdavRequest('MKCOL', remotePath); if (retry.ok || retry.status === 405) return true; } // Try creating path component by component as a fallback let current = ''; for (const part of parts) { current += '/' + part; try { const attempt = await this._webdavRequest('MKCOL', current); if (!attempt.ok && attempt.status !== 405) { // Ignore — it likely exists already or we can't create it } } catch (e) { /* ignore */ } } return true; } return false; } async _deleteFile(remotePath) { const resp = await this._webdavRequest('DELETE', remotePath); return resp.ok || resp.status === 204 || resp.status === 404; } async _cleanupRetention() { // Smart retention: keep newest 10 + 1 per day for last 7 days try { const entries = await this._listDir('backups'); const files = entries.filter(e => e.type === 'file' && e.name.endsWith('.json')); if (files.length <= 17) return; // 10 newest + 7 daily = 17 max needed // Sort by date descending (newest first) files.sort((a, b) => { const da = this._extractDateFromBackup(a.name); const db = this._extractDateFromBackup(b.name); return db - da; }); const keep = new Set(); // Always keep newest 10 for (let i = 0; i < Math.min(10, files.length); i++) keep.add(files[i].name); // Keep 1 per day for last 7 days (outside the newest 10) const dayMap = new Map(); // YYYY-MM-DD -> first backup of that day for (let i = 10; i < files.length; i++) { const f = files[i]; const day = this._extractDayString(f.name); if (!dayMap.has(day)) dayMap.set(day, f); } // Get last 7 unique days const sortedDays = Array.from(dayMap.keys()).sort().slice(-7); for (const day of sortedDays) keep.add(dayMap.get(day).name); // Delete the rest let deleted = 0; for (const f of files) { if (!keep.has(f.name)) { const ok = await this._deleteFile('backups/' + f.name); if (ok) deleted++; } } if (deleted > 0) { this.terminal.print(`Retention cleanup: removed ${deleted} old backup(s)`, 'info'); } } catch (e) { this.terminal.print('Retention cleanup skipped: ' + e.message, 'info'); } } _extractDateFromBackup(name) { // Match YYYY-MM-DD_HHMMSS in filename const m = name.match(/(\d{4})-(\d{2})-(\d{2})_(\d{2})(\d{2})(\d{2})/); if (!m) return 0; return new Date(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}`).getTime(); } _extractDayString(name) { const m = name.match(/(\d{4}-\d{2}-\d{2})_/); return m ? m[1] : ''; } registerCommands() { const self = this; // --- webdav-config: set WebDAV endpoint and credentials --- this.commands['webdav-config'] = async (args) => { // No args: auto-detect space name and try to configure from token if (!args || args.length === 0) { const spaceName = self._spaceName; if (!spaceName) { self.terminal.print('Could not detect space name from URL.', 'warning'); self.terminal.print('Usage: webdav-config [username] [password]', 'info'); return; } // Auto-configure URL from space name const autoUrl = 'https://webx11.duckdns.org/spaces/' + spaceName + '/'; let token = null; // Check SecretVault first if (window.webx11_secretVault) { const vaultToken = window.webx11_secretVault.decrypt('webdav_token_' + spaceName); if (vaultToken && typeof vaultToken.then === 'function') { token = await vaultToken; } else if (vaultToken !== null && vaultToken !== undefined) { token = vaultToken; } } // Fallback to localStorage if (!token) { token = localStorage.getItem('webx11_spaces_' + spaceName); } // If still no token, use session-aware prompting if (!token) { const sessionUser = self._detectSessionUser(); if (sessionUser && sessionUser === spaceName) { self.terminal.print('👋 Welcome back, ' + sessionUser + '!', 'success'); self.terminal.print('No saved WebDAV config found. Enter your token:', 'info'); } else if (sessionUser) { self.terminal.print('👋 You are logged in as "' + sessionUser + '" — this space is "' + spaceName + '".', 'info'); self.terminal.print('Enter the WebDAV token for space "' + spaceName + '":', 'info'); } else { self.terminal.print('No saved WebDAV config found for space "' + spaceName + '".', 'info'); self.terminal.print('Enter the WebDAV token (get it from the WebX11 dashboard):', 'info'); } self.terminal.print('', 'text-muted'); self.terminal.print(' webdav-config ' + autoUrl + ' ' + spaceName + ' ', 'success'); self.terminal.print('', 'text-muted'); // Pre-configure URL + username so user only needs token self.config = { url: autoUrl, username: spaceName, password: '' }; await self._saveConfig(); return; } if (token) { self.config = { url: autoUrl, username: spaceName, password: token }; await self._saveConfig(); self._autoConfigured = true; self.terminal.print('Auto-configured WebDAV for space: ' + spaceName, 'success'); try { const resp = await self._webdavRequest('OPTIONS', '/'); self.terminal.print('Connected: ' + resp.status + ' ' + resp.statusText, 'success'); self.terminal.print('WebDAV configured: ' + self.config.url, 'success'); } catch (e) { self.terminal.print('Connection test failed: ' + e.message, 'warning'); self.terminal.print('Config saved but connection may need CORS or network access.', 'warning'); } } else { self.terminal.print('No WebDAV token found for space: ' + spaceName, 'warning'); self.terminal.print('Get your token from the WebX11 dashboard.', 'info'); self.terminal.print('Auto-setting URL: ' + autoUrl, 'info'); self.terminal.print('Then configure with: webdav-config ' + autoUrl + ' ' + spaceName + ' ', 'info'); // At minimum set the URL self.config = { url: autoUrl, username: spaceName, password: '' }; await self._saveConfig(); self._autoConfigured = true; } return; } // Legacy 1-3 arg behavior self.config = { url: args[0], username: args[1] || (self.config ? self.config.username : ''), password: args[2] || (self.config ? self.config.password : '') }; await self._saveConfig(); self.terminal.print('Testing WebDAV connection...', 'info'); try { const resp = await self._webdavRequest('OPTIONS', '/'); self.terminal.print('Connected: ' + resp.status + ' ' + resp.statusText, 'success'); self.terminal.print('WebDAV configured: ' + self.config.url, 'success'); } catch (e) { self.terminal.print('Connection test failed: ' + e.message, 'warning'); self.terminal.print('Config saved but connection may need CORS or network access.', 'warning'); } }; // --- webdav-status: show current WebDAV config --- this.commands['webdav-status'] = async () => { if (!self.config || !self.config.url) { self.terminal.print('WebDAV not configured. Use webdav-config [username] [password]', 'warning'); return; } // Resolve password from SecretVault if needed if (self._cachedPassUser) { await self._resolvePassword(); } self.terminal.print('WebDAV Status:', 'text-muted'); self.terminal.print(' URL: ' + self.config.url); self.terminal.print(' User: ' + (self.config.username || '(none)')); self.terminal.print(' Password: ' + (self.config.password ? '**** (' + self.config.password.length + ' chars)' : '(none)')); self.terminal.print(' Space detected: ' + (self._spaceName || '(none)')); if (self._autoConfigured) { self.terminal.print(' Auto-configured: yes', 'success'); } }; // --- webdav-ls: list remote directory --- this.commands['webdav-ls'] = async (args) => { const remotePath = args[0] || '/'; try { const entries = await self._listDir(remotePath); if (entries.length === 0) { self.terminal.print('(empty)', 'info'); return; } entries.forEach(e => { const icon = e.type === 'dir' ? '📁' : '📄'; const sizeStr = e.type === 'file' ? ' (' + self._formatSize(e.size) + ')' : '/'; self.terminal.print(icon + ' ' + e.name + (e.type === 'dir' ? '/' : '') + (e.type === 'file' ? sizeStr : '')); }); self.terminal.print(entries.length + ' item(s)', 'text-muted'); } catch (e) { self.terminal.print('Failed to list: ' + e.message, 'error'); } }; // --- webdav-push: upload a VFS file to WebDAV --- this.commands['webdav-push'] = async (args) => { if (!args[0]) { self.terminal.print('Usage: webdav-push [remote-path]', 'warning'); return; } const vfsPath = args[0]; const remotePath = args[1] || vfsPath; const content = self.terminal.fs.readFile(vfsPath); if (content === null) { self.terminal.print('File not found in VFS: ' + vfsPath, 'error'); return; } try { // Ensure remote dir exists const parts = remotePath.replace(/\/$/, '').split('/').filter(Boolean); if (parts.length > 1) { const parent = parts.slice(0, -1).join('/'); await self._ensureDir(parent); } const resp = await self._webdavRequest('PUT', remotePath, { body: content, headers: { 'Content-Type': 'text/plain' } }); if (resp.ok || resp.status === 201 || resp.status === 204) { self.terminal.print('Pushed: ' + vfsPath + ' → ' + remotePath, 'success'); } else { self.terminal.print('Push failed: HTTP ' + resp.status, 'error'); } } catch (e) { self.terminal.print('Push error: ' + e.message, 'error'); } }; // --- webdav-pull: download a WebDAV file to VFS --- this.commands['webdav-pull'] = async (args) => { if (!args[0]) { self.terminal.print('Usage: webdav-pull [vfs-path]', 'warning'); return; } const remotePath = args[0]; const vfsPath = args[1] || remotePath.split('/').pop(); try { const resp = await self._webdavRequest('GET', remotePath); if (!resp.ok) throw new Error('HTTP ' + resp.status); const content = await resp.text(); self.terminal.fs.writeFile(vfsPath, content); self.terminal.print('Pulled: ' + remotePath + ' → ' + vfsPath + ' (' + content.length + ' bytes)', 'success'); } catch (e) { self.terminal.print('Pull error: ' + e.message, 'error'); } }; // --- webdav-backup: backup entire VFS to WebDAV --- this.commands['webdav-backup'] = async (args) => { // Parse flags let encryptPass = null; let nameIdx = 0; for (let i = 0; i < args.length; i++) { if (args[i] === '--encrypt' || args[i] === '-e') { encryptPass = args[i + 1]; i++; } else if (!args[i].startsWith('-')) { nameIdx = i; } } const name = (args[nameIdx] || 'backup').replace(/[^a-zA-Z0-9_-]/g, '_'); const now = new Date(); const dateStr = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0') + '-' + String(now.getDate()).padStart(2, '0') + '_' + String(now.getHours()).padStart(2, '0') + String(now.getMinutes()).padStart(2, '0') + String(now.getSeconds()).padStart(2, '0'); let filename = name + '-' + dateStr + '.json'; let remotePath = 'backups/' + filename; try { await self._ensureDir('backups'); // Build structured export: root + currentPath + aliases + moduleRegistry let exportData; try { exportData = { type: 'webx11-backup', version: 2, savedAt: new Date().toISOString(), root: self.terminal.fs.root, currentPath: self.terminal.fs.currentPath, aliases: self.terminal.moduleManager.aliases || {}, moduleRegistry: self.terminal.moduleManager.moduleRegistry || [] }; } catch (e) { self.terminal.print('Failed to read VFS: ' + e.message, 'error'); return; } const serialized = JSON.stringify(exportData, null, 2); // Size check: abort if >5MB if (serialized.length > 5 * 1024 * 1024) { self.terminal.print('Backup too large: ' + self._formatSize(serialized.length) + ' (>5MB limit). Try reducing VFS size first.', 'error'); return; } // Gzip payload let payload = serialized; let isGzipped = false; if (typeof CompressionStream !== 'undefined') { try { const blob = new Blob([serialized]); const stream = blob.stream().pipeThrough(new CompressionStream('gzip')); const resp = new Response(stream); const buf = await resp.arrayBuffer(); payload = new Uint8Array(buf); isGzipped = true; } catch (e) { self.terminal.print('Gzip unavailable, falling back to plain JSON', 'info'); } } else { self.terminal.print('CompressionStream not supported, saving as plain JSON', 'info'); } // Encrypt if requested if (encryptPass) { if (!isGzipped) { self.terminal.print('Encryption requires gzip compression. CompressionStream unavailable — aborting.', 'error'); return; } const wrapper = await self._encryptPayload(payload, encryptPass); payload = JSON.stringify(wrapper); filename = name + '-' + dateStr + '.json.enc'; remotePath = 'backups/' + filename; } const resp = await self._webdavRequest('PUT', remotePath, { body: payload, headers: { 'Content-Type': encryptPass ? 'application/json' : (isGzipped ? 'application/gzip' : 'application/json') } }); if (resp.ok || resp.status === 201 || resp.status === 204) { const sizeStr = isGzipped ? self._formatSize(serialized.length) + ' → ' + self._formatSize(typeof payload === 'string' ? payload.length : payload.length) : self._formatSize(serialized.length); const encMark = encryptPass ? ' [🔒 encrypted]' : ''; self.terminal.print('Backup saved: ' + remotePath + ' (' + sizeStr + ')' + encMark, 'success'); // Run retention cleanup in background self._cleanupRetention().catch(() => {}); } else { self.terminal.print('Backup failed: HTTP ' + resp.status, 'error'); } } catch (e) { self.terminal.print('Backup error: ' + e.message, 'error'); } }; // --- webdav-restore: restore VFS from a WebDAV backup --- this.commands['webdav-restore'] = async (args) => { if (!args[0]) { self.terminal.print('Usage: webdav-restore [--passphrase ]', 'warning'); self.terminal.print(' Use webdav-ls backups/ to see available backups', 'info'); return; } let remotePath = args[0]; let passphrase = null; for (let i = 1; i < args.length; i++) { if (args[i] === '--passphrase' || args[i] === '-p') passphrase = args[i + 1]; } if (!remotePath.startsWith('backups/') && !remotePath.startsWith('/backups/')) { remotePath = 'backups/' + remotePath; } try { const resp = await self._webdavRequest('GET', remotePath); if (!resp.ok) throw new Error('HTTP ' + resp.status); // Read raw bytes first (resp.text() corrupts gzip binary) const arrayBuf = await resp.arrayBuffer(); const bytes = new Uint8Array(arrayBuf); let text; // Detect gzip by magic bytes 0x1f 0x8b if (bytes.length > 2 && bytes[0] === 0x1f && bytes[1] === 0x8b) { self.terminal.print('Detected gzip-compressed backup, decompressing...', 'info'); try { const blob = new Blob([bytes]); const stream = blob.stream().pipeThrough(new DecompressionStream('gzip')); const decompressed = await new Response(stream).text(); text = decompressed; } catch (e) { throw new Error('Gzip decompression failed: ' + e.message); } } else { text = new TextDecoder().decode(bytes); } // Detect encrypted wrapper let isEncrypted = false; let wrapper = null; try { wrapper = JSON.parse(text); if (wrapper && wrapper._encrypted) isEncrypted = true; } catch (e) { /* not JSON wrapper */ } let content = text; if (isEncrypted) { if (!passphrase) { self.terminal.print('🔒 This backup is encrypted. Run: webdav-restore ' + args[0] + ' --passphrase ', 'warning'); return; } self.terminal.print('Decrypting backup...', 'info'); const decryptedU8 = await self._decryptPayload(wrapper, passphrase); content = new TextDecoder().decode(decryptedU8); } const data = JSON.parse(content); if (!data || !data.type) { self.terminal.print('Invalid backup file (not a valid VFS export)', 'error'); return; } if (self.terminal.fs.restoreFromExport(data)) { self.terminal.updatePrompt(); self.terminal.print('VFS restored from: ' + remotePath, 'success'); self.terminal.executeCommand('ls'); } else { self.terminal.print('Failed to restore VFS from backup', 'error'); } } catch (e) { self.terminal.print('Restore error: ' + e.message, 'error'); } }; // --- webdav-backup-list: list available backups --- this.commands['webdav-backup-list'] = async () => { try { const entries = await self._listDir('backups'); if (entries.length === 0) { self.terminal.print('No backups found', 'info'); return; } self.terminal.print('Available backups:', 'text-muted'); entries.forEach(e => { if (e.type === 'file') { self.terminal.print(' ' + e.name + ' (' + self._formatSize(e.size) + ')'); } }); } catch (e) { self.terminal.print('Failed to list backups: ' + e.message, 'info'); self.terminal.print(' (backups directory may not exist yet — run webdav-backup first)', 'info'); } }; // --- webdav-browser: modern visual file browser --- this.commands['webdav-browser'] = async (args) => { const startPath = args[0] || self._browsingPath || '/'; await self.openWebdavBrowser(startPath); }; // --- webdav-rm: delete remote file/dir --- this.commands['webdav-rm'] = async (args) => { if (!args[0]) { self.terminal.print('Usage: webdav-rm ', 'warning'); return; } try { const ok = await self._deleteFile(args[0]); if (ok) { self.terminal.print('Deleted: ' + args[0], 'success'); } else { self.terminal.print('Delete failed: resource may not exist', 'error'); } } catch (e) { self.terminal.print('Delete error: ' + e.message, 'error'); } }; // --- webdav-mkdir: create remote directory --- this.commands['webdav-mkdir'] = async (args) => { if (!args[0]) { self.terminal.print('Usage: webdav-mkdir ', 'warning'); return; } try { const ok = await self._ensureDir(args[0]); if (ok) { self.terminal.print('Created directory: ' + args[0], 'success'); } else { self.terminal.print('Failed to create directory', 'error'); } } catch (e) { self.terminal.print('Mkdir error: ' + e.message, 'error'); } }; // --- webdav-rename: rename/move remote file or dir (MOVE method) --- this.commands['webdav-rename'] = async (args) => { if (!args[0] || !args[1]) { self.terminal.print('Usage: webdav-rename ', 'warning'); self.terminal.print('Renames or moves a file/directory using WebDAV MOVE', 'info'); return; } try { const resp = await self._webdavRequest('MOVE', args[0], { headers: { 'Destination': args[1] } }); if (resp.ok || resp.status === 201 || resp.status === 204) { self.terminal.print('Renamed: ' + args[0] + ' → ' + args[1], 'success'); } else { self.terminal.print('Rename failed: HTTP ' + resp.status, 'error'); } } catch (e) { self.terminal.print('Rename error: ' + e.message, 'error'); } }; // --- webdav-move: alias for webdav-rename --- this.commands['webdav-move'] = this.commands['webdav-rename']; } /* ── Modern WebDAV File-Tree Browser with Bulk Ops ── */ async openWebdavBrowser(path) { if (!this.config || !this.config.url) { if (this.terminal) this.terminal.print('WebDAV not configured. Use webdav-config first.', 'warning'); return; } this._browsingPath = path; const self = this; this._selected = {}; // Remove existing modal if any const existing = document.getElementById('webdav-browser-modal'); if (existing) existing.remove(); // Fetch listing let entries = []; let errorMsg = ''; try { entries = await this._listDir(path); } catch (e) { errorMsg = e.message; } // Sort: dirs first, then files, alphabetically entries.sort(function(a, b) { if (a.type !== b.type) return a.type === 'dir' ? -1 : 1; return a.name.localeCompare(b.name); }); // Build modal const overlay = document.createElement('div'); overlay.id = 'webdav-browser-modal'; overlay.className = 'modal-overlay'; overlay.style.cssText = 'display:flex; justify-content:center; align-items:center; z-index:999;'; overlay.onclick = function(e) { if (e.target === this) self.closeWebdavBrowser(); }; const content = document.createElement('div'); content.className = 'modal-content'; content.style.cssText = 'max-width:750px; max-height:85vh; width:94%;'; // Header const header = document.createElement('div'); header.className = 'modal-header'; header.innerHTML = '📁 WebDAV File Tree' + '' + '' + '✕'; content.appendChild(header); // Body const body = document.createElement('div'); body.className = 'modal-body'; body.style.cssText = 'padding:0; overflow-y:auto; flex-grow:1;'; if (errorMsg) { const errDiv = document.createElement('div'); errDiv.style.cssText = 'color:var(--error-color); padding:16px; text-align:center;'; errDiv.textContent = '❌ ' + errorMsg; body.appendChild(errDiv); } else { // Select-all row const selectAllRow = document.createElement('div'); selectAllRow.style.cssText = 'display:flex;align-items:center;padding:6px 10px;border-bottom:1px solid #222;font-size:0.8em;color:var(--text-muted);gap:8px;'; const selAllCheck = document.createElement('input'); selAllCheck.type = 'checkbox'; selAllCheck.style.cssText = 'accent-color:var(--success-color);width:16px;height:16px;cursor:pointer;'; selAllCheck.onchange = function() { const checked = this.checked; entries.forEach(function(e) { self._selected[e.name] = checked; }); document.querySelectorAll('.wdav-checkbox').forEach(function(c) { c.checked = checked; }); self._updateSelectionCount(); }; selectAllRow.appendChild(selAllCheck); selectAllRow.appendChild(document.createTextNode('Select All (' + entries.length + ' items)')); body.appendChild(selectAllRow); // File rows const listContainer = document.createElement('div'); let rowIndex = 0; entries.forEach(function(entry) { var row = document.createElement('div'); row.className = 'wdav-row'; row.style.cssText = 'display:flex;align-items:center;padding:7px 10px;border-bottom:1px solid #1a1a1a;cursor:default;min-height:38px;gap:8px;'; row.onmouseover = function() { this.style.background = 'var(--bg-tertiary)'; }; row.onmouseout = function() { this.style.background = ''; }; row.dataset.index = rowIndex++; // Checkbox var cb = document.createElement('input'); cb.type = 'checkbox'; cb.className = 'wdav-checkbox'; cb.style.cssText = 'accent-color:var(--success-color);width:15px;height:15px;cursor:pointer;flex-shrink:0;'; cb.onchange = function(e) { self._selected[entry.name] = this.checked; self._updateSelectionCount(); e.stopPropagation(); }; row.appendChild(cb); // Icon var iconSpan = document.createElement('span'); iconSpan.textContent = entry.type === 'dir' ? '📁' : '📄'; iconSpan.style.cssText = 'flex-shrink:0;font-size:1.1em;'; // Filename (editable on dblclick) var nameSpan = document.createElement('span'); nameSpan.textContent = entry.name + (entry.type === 'dir' ? '/' : ''); nameSpan.style.cssText = 'flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:0.9em;cursor:pointer;'; nameSpan.title = entry.name; nameSpan.ondblclick = function(e) { e.stopPropagation(); var input = document.createElement('input'); input.type = 'text'; input.value = entry.name; input.style.cssText = 'width:100%;background:#1a1a1a;border:1px solid var(--success-color);color:var(--text-color);padding:2px 6px;border-radius:3px;font-size:0.9em;'; input.onblur = function() { var newName = this.value.trim(); if (newName && newName !== entry.name) { var parentPath = path.replace(/\/+$/, '') || ''; var oldFull = parentPath + '/' + entry.name; var newFull = parentPath + '/' + newName; self._webdavRequest('MOVE', oldFull, { headers: { 'Destination': newFull } }) .then(function(r) { if (r.ok || r.status === 201 || r.status === 204) { self.terminal.print('Renamed: ' + entry.name + ' → ' + newName, 'success'); self.openWebdavBrowser(path); } else { self.terminal.print('Rename failed: HTTP ' + r.status, 'error'); nameSpan.textContent = entry.name; } }); } else { nameSpan.textContent = entry.name; } }; input.onkeydown = function(ev) { if (ev.key === 'Enter') this.blur(); if (ev.key === 'Escape') { nameSpan.textContent = entry.name; } }; nameSpan.textContent = ''; nameSpan.appendChild(input); input.focus(); input.select(); }; // Click = navigate dir / preview file if (entry.type === 'dir') { row.onclick = function(e) { if (e.target.type === 'checkbox') return; self.openWebdavBrowser(entry.href); }; } else { row.onclick = function(e) { if (e.target.type === 'checkbox') return; self.previewWebdavFile(entry); }; } // Size var sizeSpan = document.createElement('span'); sizeSpan.style.cssText = 'font-size:0.75em;color:var(--text-muted);flex-shrink:0;min-width:60px;text-align:right;'; if (entry.type === 'file') { sizeSpan.textContent = self._formatSize(entry.size); } // Context menu on right-click row.oncontextmenu = function(e) { e.preventDefault(); self._showContextMenu(e, entry, path); }; row.appendChild(iconSpan); row.appendChild(nameSpan); row.appendChild(sizeSpan); listContainer.appendChild(row); }); body.appendChild(listContainer); // Keyboard navigation body.tabIndex = -1; body.style.outline = 'none'; var focusIdx = 0; body.onkeydown = function(e) { var rows = listContainer.querySelectorAll('.wdav-row'); if (e.key === 'ArrowDown' || e.key === 'j') { e.preventDefault(); focusIdx = Math.min(focusIdx + 1, rows.length - 1); rows[focusIdx].scrollIntoView({ block: 'nearest' }); rows.forEach(function(r, i) { r.style.borderLeft = i === focusIdx ? '2px solid var(--success-color)' : ''; }); } else if (e.key === 'ArrowUp' || e.key === 'k') { e.preventDefault(); focusIdx = Math.max(focusIdx - 1, 0); rows[focusIdx].scrollIntoView({ block: 'nearest' }); rows.forEach(function(r, i) { r.style.borderLeft = i === focusIdx ? '2px solid var(--success-color)' : ''; }); } else if (e.key === ' ') { e.preventDefault(); var cb = rows[focusIdx]?.querySelector('.wdav-checkbox'); if (cb) { cb.checked = !cb.checked; cb.onchange({ stopPropagation: function(){} }); } } else if (e.key === 'Enter') { e.preventDefault(); rows[focusIdx]?.click(); } }; setTimeout(function() { body.focus(); }, 100); } content.appendChild(body); // Bulk action bar const bulkBar = document.createElement('div'); bulkBar.id = 'wdav-bulk-bar'; bulkBar.style.cssText = 'display:none;padding:8px 10px;border-top:1px solid var(--success-color);background:var(--bg-tertiary);gap:6px;align-items:center;flex-wrap:wrap;'; bulkBar.innerHTML = '0 selected' + '' + '' + ''; self._updateSelectionCount = function() { var count = Object.values(self._selected).filter(Boolean).length; var el = document.getElementById('sel-count'); if (el) el.textContent = count > 0 ? count + ' selected' : ''; var bar = document.getElementById('wdav-bulk-bar'); var cnt = document.getElementById('bulk-count'); if (bar) bar.style.display = count > 0 ? 'flex' : 'none'; if (cnt) cnt.textContent = count + ' selected'; }; // Wire bulk actions setTimeout(function() { document.getElementById('bulk-delete')?.addEventListener('click', function() { var names = Object.entries(self._selected).filter(function(e) { return e[1]; }).map(function(e) { return e[0]; }); if (!names.length || !confirm('Delete ' + names.length + ' item(s) from ' + path + '?')) return; names.forEach(function(name) { var fullPath = (path.replace(/\/+$/, '') || '') + '/' + name; self._deleteFile(fullPath).then(function(ok) { if (ok) self.terminal.print('Deleted: ' + name, 'success'); else self.terminal.print('Delete failed: ' + name, 'error'); }); }); setTimeout(function() { self.openWebdavBrowser(path); }, 500); }); document.getElementById('bulk-move')?.addEventListener('click', function() { var names = Object.entries(self._selected).filter(function(e) { return e[1]; }).map(function(e) { return e[0]; }); if (!names.length) return; var target = prompt('Move ' + names.length + ' item(s) to directory:', '/'); if (!target) return; target = target.replace(/\/+$/, ''); names.forEach(function(name) { var oldFull = (path.replace(/\/+$/, '') || '') + '/' + name; var newFull = target + '/' + name; self._webdavRequest('MOVE', oldFull, { headers: { 'Destination': newFull } }) .then(function(r) { if (r.ok || r.status === 201 || r.status === 204) self.terminal.print('Moved: ' + name, 'success'); else self.terminal.print('Move failed: ' + name + ' (HTTP ' + r.status + ')', 'error'); }); }); setTimeout(function() { self.openWebdavBrowser(path); }, 500); }); document.getElementById('bulk-download')?.addEventListener('click', function() { var names = Object.entries(self._selected).filter(function(e) { return e[1]; }).map(function(e) { return e[0]; }); names.forEach(function(name) { var fullPath = (path.replace(/\/+$/, '') || '') + '/' + name; // Find entry from entries var entry = entries.find(function(e) { return e.name === name; }); if (entry && entry.type === 'file') self.downloadFromWebdav(entry); }); }); }, 50); content.appendChild(bulkBar); // Footer with breadcrumb + buttons const footer = document.createElement('div'); footer.className = 'modal-footer'; footer.style.cssText = 'display:flex; justify-content:space-between; align-items:center; gap:6px; flex-wrap:wrap; padding:8px 10px;'; // Breadcrumb var breadDiv = document.createElement('div'); breadDiv.style.cssText = 'font-size:0.8em; color:var(--info-color); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1; min-width:0;'; var cleanPath = path.replace(/\/+$/, '') || '/'; var parts = cleanPath.split('/').filter(Boolean); var breadHtml = '📁 /'; var cumulative = ''; for (var i = 0; i < parts.length; i++) { cumulative += '/' + parts[i]; var seg = parts[i]; breadHtml += ' ▸ '; breadHtml += '' + seg + ''; } breadDiv.innerHTML = breadHtml; overlay.__nav = function(p) { self.openWebdavBrowser(p); }; // Button group var btnGroup = document.createElement('div'); btnGroup.style.cssText = 'display:flex; gap:4px; flex-shrink:0;'; var mkdirBtn = document.createElement('button'); mkdirBtn.className = 'btn'; mkdirBtn.textContent = '📁+'; mkdirBtn.title = 'New Directory'; mkdirBtn.onclick = function() { var name = prompt('Directory name:'); if (name) { self._ensureDir((path.replace(/\/+$/, '') || '') + '/' + name).then(function(ok) { if (ok) { self.terminal.print('Created: ' + name, 'success'); self.openWebdavBrowser(path); } else self.terminal.print('Failed to create directory', 'error'); }); } }; var uploadBtn = document.createElement('button'); uploadBtn.className = 'btn'; uploadBtn.textContent = '⬆ Upload'; uploadBtn.onclick = function() { self.uploadToWebdav(path); }; var refreshBtn = document.createElement('button'); refreshBtn.className = 'btn'; refreshBtn.textContent = '🔄'; refreshBtn.title = 'Refresh'; refreshBtn.onclick = function() { self.openWebdavBrowser(path); }; var closeBtn = document.createElement('button'); closeBtn.className = 'btn'; closeBtn.textContent = 'Close'; closeBtn.onclick = function() { self.closeWebdavBrowser(); }; btnGroup.appendChild(mkdirBtn); btnGroup.appendChild(uploadBtn); btnGroup.appendChild(refreshBtn); btnGroup.appendChild(closeBtn); footer.appendChild(breadDiv); footer.appendChild(btnGroup); content.appendChild(footer); overlay.appendChild(content); document.body.appendChild(overlay); this.terminal.print('📂 Opened WebDAV file browser: ' + path, 'info'); } _showContextMenu(e, entry, currentPath) { var existing = document.getElementById('wdav-context-menu'); if (existing) existing.remove(); var menu = document.createElement('div'); menu.id = 'wdav-context-menu'; menu.style.cssText = 'position:fixed;left:' + e.clientX + 'px;top:' + e.clientY + 'px;background:#111;border:1px solid #333;border-radius:6px;padding:4px 0;z-index:1001;min-width:160px;box-shadow:0 4px 12px rgba(0,0,0,0.5);'; menu.onclick = function() { this.remove(); }; var items = []; if (entry.type === 'file') { items.push({ label: '📄 Preview', fn: function() { this.previewWebdavFile(entry); }.bind(this) }); items.push({ label: '⬇ Download', fn: function() { this.downloadFromWebdav(entry); }.bind(this) }); items.push({ label: '📋 Copy Path', fn: function() { var full = (currentPath.replace(/\/+$/, '') || '') + '/' + entry.name; navigator.clipboard.writeText(full).then(function() { this.terminal.print('Path copied: ' + full, 'success'); }.bind(this)); }.bind(this) }); } else { items.push({ label: '📂 Open', fn: function() { this.openWebdavBrowser(entry.href); }.bind(this) }); items.push({ label: '📋 Copy Path', fn: function() { var full = (currentPath.replace(/\/+$/, '') || '') + '/' + entry.name + '/'; navigator.clipboard.writeText(full).then(function() { this.terminal.print('Path copied: ' + full, 'success'); }.bind(this)); }.bind(this) }); } items.push({ label: '✏️ Rename', fn: function() { var newName = prompt('Rename "' + entry.name + '" to:'); if (newName && newName !== entry.name) { var oldFull = (currentPath.replace(/\/+$/, '') || '') + '/' + entry.name; var newFull = (currentPath.replace(/\/+$/, '') || '') + '/' + newName; this._webdavRequest('MOVE', oldFull, { headers: { 'Destination': newFull } }).then(function(r) { if (r.ok || r.status === 201 || r.status === 204) { this.terminal.print('Renamed: ' + entry.name + ' → ' + newName, 'success'); this.openWebdavBrowser(currentPath); } else { this.terminal.print('Rename failed: HTTP ' + r.status, 'error'); } }.bind(this)); } }.bind(this) }); items.push({ label: '🗑 Delete', fn: function() { if (!confirm('Delete ' + entry.name + '?')) return; var full = (currentPath.replace(/\/+$/, '') || '') + '/' + entry.name; this._deleteFile(full).then(function(ok) { if (ok) { this.terminal.print('Deleted: ' + entry.name, 'success'); this.openWebdavBrowser(currentPath); } else { this.terminal.print('Delete failed', 'error'); } }.bind(this)); }.bind(this) }); items.forEach(function(item) { var div = document.createElement('div'); div.textContent = item.label; div.style.cssText = 'padding:6px 14px;cursor:pointer;font-size:0.85em;'; div.onmouseover = function() { this.style.background = '#222'; }; div.onmouseout = function() { this.style.background = ''; }; div.onclick = function(e) { e.stopPropagation(); menu.remove(); item.fn(); }; menu.appendChild(div); }); document.body.appendChild(menu); // Close on click outside var closeMenu = function(ev) { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('click', closeMenu); } }; setTimeout(function() { document.addEventListener('click', closeMenu); }, 10); } closeWebdavBrowser() { var modal = document.getElementById('webdav-browser-modal'); if (modal) modal.remove(); if (this.terminal && this.terminal.inputField) this.terminal.inputField.focus(); this.terminal.print('WebDAV browser closed', 'text-muted'); } async previewWebdavFile(entry) { try { var basePath = (this._browsingPath || '/').replace(/\/+$/, ''); var fullPath = basePath + '/' + entry.name; var resp = await this._webdavRequest('GET', fullPath); if (!resp.ok) { this.terminal.print('Failed to fetch: HTTP ' + resp.status, 'error'); return; } var isHtml = entry.name.match(/\.html?$/i); var isImage = entry.name.match(/\.(png|jpg|jpeg|gif|svg|webp)$/i); var size = parseInt(resp.headers.get('content-length') || '0'); if (size > 512000 && !isHtml && !isImage) { // Large non-html file — offer download instead this.terminal.print(entry.name + ' is ' + this._formatSize(size) + ' — double-click to download instead of preview.', 'warning'); var textPreview = await resp.text(); this.terminal.print('--- ' + entry.name + ' (first 2000 chars) ---'); this.terminal.print(textPreview.substring(0, 2000)); if (textPreview.length > 2000) this.terminal.print('...(truncated ' + this._formatSize(size) + ' total)'); return; } if (isImage) { var blob = await resp.blob(); var dataUrl = await new Promise(function(resolve) { var reader = new FileReader(); reader.onloadend = function() { resolve(reader.result); }; reader.readAsDataURL(blob); }); // Use preview modal var previewTitle = document.getElementById('preview-title'); var previewIframe = document.getElementById('preview-iframe'); if (previewTitle && previewIframe) { previewTitle.textContent = '🖼 ' + entry.name; previewIframe.srcdoc = ''; document.getElementById('preview-modal').style.display = 'flex'; return; } } if (isHtml) { var content = await resp.text(); var previewTitle = document.getElementById('preview-title'); var previewIframe = document.getElementById('preview-iframe'); if (previewTitle && previewIframe) { previewTitle.textContent = '📄 ' + entry.name; previewIframe.srcdoc = content; document.getElementById('preview-modal').style.display = 'flex'; return; } } // Text file — print to terminal var content = await resp.text(); this.terminal.print('--- ' + entry.name + ' (' + this._formatSize(content.length) + ') ---', 'info'); this.terminal.print(content); this.terminal.print('--- End: ' + entry.name + ' ---', 'text-muted'); } catch (e) { this.terminal.print('Preview error: ' + e.message, 'error'); } } uploadToWebdav(currentPath) { var self = this; var input = document.createElement('input'); input.type = 'file'; input.onchange = async function(e) { var file = e.target.files[0]; if (!file) return; var reader = new FileReader(); reader.onload = async function(evt) { var basePath = currentPath.replace(/\/+$/, ''); var remotePath = basePath + '/' + file.name; var parentDir = remotePath.split('/').slice(0, -1).join('/'); try { await self._ensureDir(parentDir); var resp = await self._webdavRequest('PUT', remotePath, { body: evt.target.result, headers: { 'Content-Type': 'application/octet-stream' } }); if (resp.ok || resp.status === 201 || resp.status === 204) { self.terminal.print('⬆ Uploaded: ' + file.name + ' (' + self._formatSize(file.size) + ')', 'success'); self.openWebdavBrowser(currentPath); } else { self.terminal.print('Upload failed: HTTP ' + resp.status, 'error'); } } catch (e) { self.terminal.print('Upload error: ' + e.message, 'error'); } }; reader.readAsText(file); }; input.click(); } async downloadFromWebdav(entry) { try { // Construct path from current browsing path + entry name (avoid autoindex href fragility) var basePath = (this._browsingPath || '/').replace(/\/+$/, ''); var fullPath = basePath + '/' + entry.name; var resp = await this._webdavRequest('GET', fullPath); if (!resp.ok) { this.terminal.print('Download failed: HTTP ' + resp.status, 'error'); return; } // Use blob() instead of text() to preserve binary data var blob = await resp.blob(); var url = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; a.download = entry.name; a.style.cssText = 'display:none;'; document.body.appendChild(a); a.click(); setTimeout(function() { document.body.removeChild(a); URL.revokeObjectURL(url); }, 200); this.terminal.print('⬇ Downloaded: ' + entry.name + ' (' + this._formatSize(blob.size) + ')', 'success'); } catch (e) { this.terminal.print('Download error: ' + e.message, 'error'); } } /* ── Encryption helpers (AES-GCM for backups) ── */ _u8ToBase64(u8) { let s = ''; for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]); return btoa(s); } _base64ToU8(b64) { const s = atob(b64); const u8 = new Uint8Array(s.length); for (let i = 0; i < s.length; i++) u8[i] = s.charCodeAt(i); return u8; } async _deriveKey(passphrase, salt) { const enc = new TextEncoder(); const keyMaterial = await crypto.subtle.importKey('raw', enc.encode(passphrase), 'PBKDF2', false, ['deriveKey']); return crypto.subtle.deriveKey( { name: 'PBKDF2', salt: salt, iterations: 100000, hash: 'SHA-256' }, keyMaterial, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'] ); } async _encryptPayload(gzipU8, passphrase) { const salt = crypto.getRandomValues(new Uint8Array(16)); const iv = crypto.getRandomValues(new Uint8Array(12)); const key = await this._deriveKey(passphrase, salt); const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, gzipU8); return { _encrypted: true, salt: this._u8ToBase64(salt), iv: this._u8ToBase64(iv), iterations: 100000, cipher: this._u8ToBase64(new Uint8Array(ciphertext)) }; } async _decryptPayload(wrapper, passphrase) { const salt = this._base64ToU8(wrapper.salt); const iv = this._base64ToU8(wrapper.iv); const key = await this._deriveKey(passphrase, salt); const ciphertext = this._base64ToU8(wrapper.cipher); const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext); return new Uint8Array(decrypted); } _formatSize(bytes) { if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; } }