class FileSystemModule { constructor() { this.commands = {}; this.terminal = null; } getHelpInfo() { return { 'ls': 'ls [path] [-la] — List files and directories', 'cat': 'cat — Display file contents', 'put': 'put — Create or overwrite a file', 'rm': 'rm — Remove a file (moved to trash); use -r/-rf for directories', 'trash': 'trash — Move a file to trash', 'trash-dir': 'trash-dir — Move a directory to trash', 'ls-trash': 'ls-trash — List items in trash', 'empty-trash': 'empty-trash — Permanently delete all trash items', 'mkdir': 'mkdir [-p] — Create a directory; -p creates parent paths', 'rmdir': 'rmdir — Remove a directory', 'mv': 'mv — Move file or directory', 'cp': 'cp — Copy file or directory', 'cd': 'cd — Change current directory; cd / to go to root', 'pwd': 'pwd — Print current working directory', 'find': 'find — Search files by name pattern', 'grep': 'grep [file] — Search file content for pattern', 'head': 'head [lines] — Show first N lines (default: 10)', 'tail': 'tail [lines] — Show last N lines (default: 10)', 'sort': 'sort — Sort lines of a file alphabetically', 'wc': 'wc — Count lines, words, and bytes', 'touch': 'touch — Create empty file or update timestamp', 'tree': 'tree — Display directory structure as a tree', 'du': 'du [path] — Estimate file/directory size', 'upload': 'upload — Upload a file from your device', 'download': 'download — Download a file to your device', 'vfs-export': 'vfs-export — Export the entire virtual filesystem as JSON', 'vfs-import': 'vfs-import — Import a previously exported VFS JSON file', }; } registerCommands() { const self = this; this.commands['ls'] = (args) => { const target = args && args.length > 0 && !args[0].startsWith('-') ? args[0] : undefined; const flags = (args || []).filter(a => a.startsWith('-')); const showLong = flags.some(f => f.includes('l')); const showHidden = flags.some(f => f.includes('a')); const files = this.terminal.fs.ls(target); if (!files || files.length === 0) { this.terminal.print('(empty)', 'info'); return; } const filtered = showHidden ? files : files.filter(f => !f.name.startsWith('.')); if (filtered.length === 0) { this.terminal.print('(no visible files, use ls -a)', 'info'); return; } const maxName = Math.max(...filtered.map(f => f.name.length), 8); if (showLong) { filtered.forEach(f => { const type = f.type === 'dir' ? 'd' : '-'; const perms = f.type === 'dir' ? 'rwxr-xr-x' : 'rw-r--r--'; const size = String(f.size || 0).padStart(6); this.terminal.print(type + perms + ' darkbot ' + size + ' ' + f.name); }); const total = filtered.reduce((s, f) => s + (f.size || 0), 0); this.terminal.print('total ' + filtered.length + ' (' + total + ' bytes)', 'text-muted'); } else { this.terminal.printClickable(filtered); } }; this.commands['cat'] = (args) => { if(!args[0]) return this.terminal.print('Usage: cat ', 'warning'); const content = this.terminal.fs.readFile(args[0]); if(content !== null && content !== undefined) { if (content.length === 0) this.terminal.print('(empty file)', 'info'); else this.terminal.print(content); } else this.terminal.print('cat: ' + args[0] + ': No such file', 'error'); }; this.commands['echo'] = (args) => { if (!args || args.length === 0) { this.terminal.print(''); return; } const ri = args.indexOf('>'); if (ri >= 0) { const c = args.slice(0, ri).join(' '); const fn = args.slice(ri + 1).join(' '); if (fn) { this.terminal.fs.writeFile(fn, c); this.terminal.print('Wrote ' + c.length + ' bytes to ' + fn, 'success'); } return; } this.terminal.print(args.join(' ')); }; this.commands['put'] = (args) => { if(args.length < 2) return this.terminal.print('Usage: put ', 'warning'); const name = args[0]; const content = args.slice(1).join(' '); this.terminal.fs.writeFile(name, content); this.terminal.print('Created/updated: ' + name + ' (' + content.length + ' bytes)', 'success'); }; this.commands['rm'] = (args) => { if(!args[0]) return this.terminal.print('Usage: rm ', 'warning'); if(args[0] === '-r' || args[0] === '-rf') { const t = args[1]; if (!t) return this.terminal.print('Usage: rm -r ', 'warning'); // Use trash for recursive dir removal too if (this.terminal.fs.trashDir(t)) this.terminal.print('Moved to trash: ' + t, 'success'); else this.terminal.print('rm: ' + t + ': No such file or directory', 'error'); return; } if(this.terminal.fs.deleteFile(args[0])) this.terminal.print('Moved to trash: ' + args[0], 'success'); else this.terminal.print('rm: ' + args[0] + ': No such file', 'error'); }; this.commands['trash'] = (args) => { const fs = this.terminal.fs; if(!args[0]) return this.terminal.print('Usage: trash ', 'warning'); const node = fs.readFile(args[0]) !== null ? { type: 'file' } : fs.readDir(args[0]); if (!node) return this.terminal.print('trash: ' + args[0] + ': No such file', 'error'); if (node.type === 'dir') { if (fs.trashDir(args[0])) this.terminal.print('Moved to trash: ' + args[0], 'success'); else this.terminal.print('trash: ' + args[0] + ': Failed', 'error'); } else { const content = fs.readFile(args[0]); if (content !== null) { fs.trashFile(args[0], { type: 'file', content, modified: new Date() }); this.terminal.print('Moved to trash: ' + args[0], 'success'); } else { this.terminal.print('trash: ' + args[0] + ': No such file', 'error'); } } }; this.commands['trash-dir'] = (args) => { if(!args[0]) return this.terminal.print('Usage: trash-dir ', 'warning'); if (this.terminal.fs.trashDir(args[0])) this.terminal.print('Moved to trash: ' + args[0], 'success'); else this.terminal.print('trash-dir: ' + args[0] + ': No such directory', 'error'); }; this.commands['ls-trash'] = () => { const fs = this.terminal.fs; const items = fs.listTrash ? fs.listTrash() : []; if (!items.length) return this.terminal.print('Trash is empty', 'info'); this.terminal.print('Trash items:', 'info'); items.forEach(it => { const date = new Date(it.ts).toLocaleString(); this.terminal.print(` ${it.name} [${it.type}] ${date}`); }); }; this.commands['empty-trash'] = () => { const fs = this.terminal.fs; const removed = fs.emptyTrash ? fs.emptyTrash(0) : 0; this.terminal.print(`Emptied trash: ${removed} item(s) permanently deleted`, removed > 0 ? 'warning' : 'info'); }; this.commands['mkdir'] = (args) => { if(!args[0]) return this.terminal.print('Usage: mkdir ', 'warning'); if (args[0] === '-p') { if(!args[1]) return this.terminal.print('Usage: mkdir -p ', 'warning'); if(this.terminal.fs.mkdirRecursive(args[1])) this.terminal.print('Created directories: ' + args[1], 'success'); else this.terminal.print('mkdir: cannot create ' + args[1], 'error'); return; } if(this.terminal.fs.mkdir(args[0])) this.terminal.print('Created directory: ' + args[0], 'success'); else this.terminal.print('mkdir: cannot create ' + args[0] + ': File exists', 'error'); }; this.commands['rmdir'] = (args) => { if(!args[0]) return this.terminal.print('Usage: rmdir ', 'warning'); if (this.terminal.fs.deleteDirRecursive(args[0])) this.terminal.print('Removed directory: ' + args[0], 'success'); else this.terminal.print('rmdir: ' + args[0] + ': No such directory', 'error'); }; this.commands['mv'] = (args) => { if(!args[0] || !args[1]) return this.terminal.print('Usage: mv ', 'warning'); if (this.terminal.fs.mv(args[0], args[1])) this.terminal.print('Moved: ' + args[0] + ' to ' + args[1], 'success'); else this.terminal.print('mv: ' + args[0] + ': No such file or directory', 'error'); }; this.commands['cp'] = (args) => { if(!args[0] || !args[1]) return this.terminal.print('Usage: cp ', 'warning'); if (this.terminal.fs.cp(args[0], args[1])) this.terminal.print('Copied: ' + args[0] + ' to ' + args[1], 'success'); else this.terminal.print('cp: ' + args[0] + ': No such file or directory', 'error'); }; this.commands['cd'] = (args) => { if (!args[0] || args[0] === '/') { this.terminal.fs.currentPath = []; this.terminal.fs.saveToStorage(); this.terminal.updatePrompt(); return; } const segs = this.terminal.fs._toAbsoluteSegments(args[0]); if (segs === null) { this.terminal.print('cd: ' + args[0] + ': No such file or directory', 'error'); return; } const dir = this.terminal.fs._resolveDir(args[0]); if (dir) { this.terminal.fs.currentPath = segs; this.terminal.fs.saveToStorage(); this.terminal.updatePrompt(); } else { this.terminal.print('cd: ' + args[0] + ': Not a directory', 'error'); } }; this.commands['pwd'] = () => { this.terminal.print('/' + this.terminal.fs.currentPath.join('/')); }; this.commands['find'] = (args) => { if (!args[0]) return this.terminal.print('Usage: find ', 'warning'); const results = this.terminal.fs.findFiles ? this.terminal.fs.findFiles(args[0].toLowerCase()) : []; if (results.length === 0) return this.terminal.print('No matches for ' + args[0], 'info'); results.forEach(r => this.terminal.print('./' + r)); this.terminal.print('Found ' + results.length + ' match(es)', 'info'); }; this.commands['grep'] = (args) => { if(!args[0]) return this.terminal.print('Usage: grep [filename]', 'warning'); const pattern = args[0]; const target = args[1]; if (target) { const c = this.terminal.fs.readFile(target); if(c === null || c === undefined) return this.terminal.print('grep: ' + target + ': No such file', 'error'); const lines = c.split('\n'); const re = new RegExp(pattern, 'i'); let count = 0; lines.forEach((line, i) => { if (re.test(line)) { this.terminal.print((i+1) + ': ' + line); count++; } }); if (count === 0) this.terminal.print('(no matches)', 'info'); else this.terminal.print(count + ' match(es)', 'text-muted'); } else { const dir = this.terminal.fs.getCurrentDir(); if (!dir) return this.terminal.print('grep: cannot search', 'error'); let total = 0; const re = new RegExp(pattern, 'i'); Object.keys(dir.content).forEach(name => { const node = dir.content[name]; if (node.type === 'file') { (node.content || '').split('\n').forEach((line, i) => { if (re.test(line)) { this.terminal.print(name + ':' + (i+1) + ': ' + line); total++; } }); } }); if (total === 0) this.terminal.print('(no matches)', 'info'); else this.terminal.print(total + ' total match(es)', 'text-muted'); } }; this.commands['head'] = (args) => { if(!args[0]) return this.terminal.print('Usage: head [lines]', 'warning'); const c = this.terminal.fs.readFile(args[0]); if(c === null || c === undefined) return this.terminal.print('head: ' + args[0] + ': No such file', 'error'); const n = args[1] ? parseInt(args[1]) : 10; this.terminal.print(c.split('\n').slice(0, n).join('\n')); }; this.commands['tail'] = (args) => { if(!args[0]) return this.terminal.print('Usage: tail [lines]', 'warning'); const c = this.terminal.fs.readFile(args[0]); if(c === null || c === undefined) return this.terminal.print('tail: ' + args[0] + ': No such file', 'error'); const n = args[1] ? parseInt(args[1]) : 10; this.terminal.print(c.split('\n').slice(-n).join('\n')); }; this.commands['sort'] = (args) => { if(!args[0]) return this.terminal.print('Usage: sort ', 'warning'); const c = this.terminal.fs.readFile(args[0]); if(c === null || c === undefined) return this.terminal.print('sort: ' + args[0] + ': No such file', 'error'); c.split('\n').sort().forEach(l => this.terminal.print(l)); }; this.commands['wc'] = (args) => { if(!args[0]) return this.terminal.print('Usage: wc ', 'warning'); const content = this.terminal.fs.readFile(args[0]); if(content === null || content === undefined) return this.terminal.print('wc: ' + args[0] + ': No such file', 'error'); const lines = content.split('\n').length; const words = content.split(/\s+/).filter(w => w).length; this.terminal.print(lines + ' ' + words + ' ' + content.length + ' ' + args[0]); }; this.commands['touch'] = (args) => { if(!args[0]) return this.terminal.print('Usage: touch ', 'warning'); const e = this.terminal.fs.readFile(args[0]); if (e === null || e === undefined) { const segs = this.terminal.fs._toAbsoluteSegments(args[0]); if (segs) { const dirPath = segs.slice(0, -1).join('/') || '.'; const name = segs[segs.length - 1]; this.terminal.fs.mkdirRecursive(dirPath); } this.terminal.fs.writeFile(args[0], ''); this.terminal.print('Created: ' + args[0], 'success'); } else { this.terminal.print('Touched: ' + args[0], 'success'); } }; this.commands['tree'] = () => { const dir = this.terminal.fs.getCurrentDir(); if (!dir) return this.terminal.print('(no directory)', 'warning'); const lines = []; this._printTree(dir, '', lines); lines.forEach(l => this.terminal.print(l)); }; this.commands['du'] = (args) => { const t = args && args[0] ? args[0] : '.'; if (t === '.') { const dir = this.terminal.fs.getCurrentDir(); if (!dir) return; let total = 0; Object.keys(dir.content).forEach(k => { const node = dir.content[k]; total += node && typeof node.content === 'string' ? node.content.length : 0; }); this.terminal.print(total + '\t' + t); } else { const c = this.terminal.fs.readFile(t); if (c === null || c === undefined) return; this.terminal.print(c.length + '\t' + t); } }; this.commands['upload'] = () => { document.getElementById('file-upload-input').click(); }; this.commands['download'] = async (args) => { if(!args[0]) return this.terminal.print('Usage: download ', 'warning'); const fname = args[0]; const content = this.terminal.fs.readFile(fname); if(content === null || content === undefined) { return this.terminal.print('download: ' + fname + ': No such file', 'error'); } // Push to WebDAV first — this is the only path to a real file download on Telegram WebView // because browser-based file downloads are blocked in WebViews let downloadUrl = null; if (this.terminal.moduleManager && this.terminal.moduleManager.modules.WebDAVModule) { const wdav = this.terminal.moduleManager.modules.WebDAVModule; if (wdav.config && wdav.config.url) { try { // Make sure /downloads/ dir exists try { await wdav._ensureDir('/downloads'); } catch(e) {} const resp = await wdav._webdavRequest('PUT', '/downloads/' + fname, { body: content, headers: { 'Content-Type': 'application/octet-stream' } }); if (resp.ok || resp.status === 201 || resp.status === 204) { // Build the public URL the user can tap const baseUrl = wdav.config.url.replace(/\/+$/, ''); const hostMatch = baseUrl.match(/^(https?:\/\/[^\/]+)/); const host = hostMatch ? hostMatch[1] : 'https://webx11.duckdns.org'; downloadUrl = host + '/spaces/' + wdav._spaceName + '/downloads/' + encodeURIComponent(fname); this.terminal.print('Uploaded to: ' + downloadUrl, 'success'); } else { this.terminal.print('WebDAV push failed: HTTP ' + resp.status, 'warning'); } } catch (e) { this.terminal.print('WebDAV push error: ' + e.message, 'warning'); } } else { this.terminal.print('WebDAV not configured. Run: webdav-config', 'warning'); } } // Build a visible anchor link — tap = real user gesture = native browser download bar const size = content.length; const sizeStr = size > 1024 ? (size / 1024).toFixed(1) + ' KB' : size + ' B'; const line = document.createElement('div'); line.className = 'output-line'; line.style.cssText = 'padding:8px 0;display:flex;align-items:center;gap:10px;flex-wrap:wrap;'; if (downloadUrl) { line.innerHTML = '⬇ ' + fname + ' (' + sizeStr + ') ' + '⬇ Tap to download'; } else { line.innerHTML = '⬇ ' + fname + ' (' + sizeStr + ') — run webdav-config first to enable downloads'; } this.terminal.outputContainer.appendChild(line); this.terminal.scrollToBottom(); }; this.commands['vfs-export'] = () => { const data = this.terminal.fs.root; const json = JSON.stringify(data, null, 2); const blob = new Blob([json], {type: 'application/json'}); const sizeMB = blob.size / (1024 * 1024); if (sizeMB > 5) { this.terminal.print('Warning: VFS size is ' + sizeMB.toFixed(1) + ' MB — large download may take time', 'warning'); } const now = new Date(); const dateStr = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0') + '-' + String(now.getDate()).padStart(2, '0'); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'webx11-filesystem-' + dateStr + '.json'; a.style.display = 'none'; document.body.appendChild(a); a.click(); setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 2000); this.terminal.print('Exported VFS to webx11-filesystem-' + dateStr + '.json', 'success'); }; this.commands['vfs-size'] = () => { try { const json = JSON.stringify(this.terminal.fs.root); const totalSize = (json.length / 1024 / 1024).toFixed(2); const root = this.terminal.fs.root; const sizeMap = []; function walk(node, path) { if (!node || !node.content) return; for (const [name, child] of Object.entries(node.content)) { const fullPath = path + '/' + name; if (child.type === 'file') { const size = (child.content ? child.content.length : 0); sizeMap.push({ path: fullPath, size, sizeKB: (size / 1024).toFixed(1) }); } else if (child.type === 'dir') { walk(child, fullPath); } } } walk(root, ''); sizeMap.sort((a, b) => b.size - a.size); const totalFiles = sizeMap.length; const top5 = sizeMap.slice(0, 5); this.terminal.print('📊 VFS Size Report', 'text-muted'); this.terminal.print(' Total: ' + totalSize + ' MB across ' + totalFiles + ' files'); this.terminal.print(' Largest:'); top5.forEach((f, i) => { this.terminal.print(' ' + (i + 1) + '. ' + f.path + ' — ' + f.sizeKB + ' KB'); }); // Show system dir sizes const sysDirs = ['/_snapshots', '/_sessions', '/.trash']; for (const dir of sysDirs) { const dirNode = this.terminal.fs.readDir(dir); if (dirNode) { const cnt = dirNode.length; // estimate dir size const dirSize = JSON.stringify(dirNode).length; this.terminal.print(' ' + dir + ': ' + cnt + ' files, ~' + (dirSize / 1024).toFixed(1) + ' KB', dirSize > 102400 ? 'warning' : 'text-muted'); } } } catch (e) { this.terminal.print('vfs-size error: ' + e.message, 'error'); } }; // Session commands (save/load/export/import) this.commands['session-save'] = (args) => { if (!args[0]) return this.terminal.print('Usage: session-save ', 'warning'); const name = args[0].replace(/[^a-zA-Z0-9_-]/g, '_'); // Strip system dirs to avoid recursive bloat in snapshots const fsRoot = JSON.parse(JSON.stringify(this.terminal.fs.root)); if (fsRoot && fsRoot.content) { delete fsRoot.content['_sessions']; delete fsRoot.content['_snapshots']; delete fsRoot.content['.trash']; } const snapshot = { type: 'session', savedAt: new Date().toISOString(), fsRoot: fsRoot, currentPath: this.terminal.fs.currentPath, version: 1 }; const filePath = '_sessions/' + name + '.json'; this.terminal.fs.mkdirRecursive('_sessions'); this.terminal.fs.writeFile(filePath, JSON.stringify(snapshot, null, 2)); this.terminal.print('Session saved: ' + name, 'success'); }; this.commands['session-load'] = (args) => { if (!args[0]) return this.terminal.print('Usage: session-load ', 'warning'); const filePath = '_sessions/' + args[0] + '.json'; const content = this.terminal.fs.readFile(filePath); if (content === null) return this.terminal.print('Session not found: ' + args[0], 'error'); try { const session = JSON.parse(content); if (!session || session.type !== 'session' || !session.fsRoot) { return this.terminal.print('Invalid session file: ' + args[0], 'error'); } this.terminal.fs.root = session.fsRoot; this.terminal.fs.currentPath = session.currentPath || []; this.terminal.fs.saveToStorage(); this.terminal.updatePrompt(); this.terminal.print('Session loaded: ' + args[0] + ' (' + new Date(session.savedAt).toLocaleString() + ')', 'success'); this.terminal.executeCommand('ls'); } catch (e) { this.terminal.print('Failed to load session: ' + e.message, 'error'); } }; this.commands['session-list'] = () => { this.terminal.fs.mkdirRecursive('_sessions'); const files = this.terminal.fs.ls('_sessions'); if (!files || files.length === 0) return this.terminal.print('No saved sessions', 'info'); this.terminal.print('Saved sessions:', 'text-muted'); files.forEach(f => { if (f.type === 'file' && f.name.endsWith('.json')) { try { const content = JSON.parse(this.terminal.fs.readFile('_sessions/' + f.name)); const label = f.name.replace('.json', ''); const date = content.savedAt ? new Date(content.savedAt).toLocaleString() : 'unknown'; const fileCount = content.fsRoot ? Object.keys(content.fsRoot.content || {}).length : 0; this.terminal.print(' ' + label + ' (' + fileCount + ' items, ' + date + ')'); } catch (e) { this.terminal.print(' ' + f.name.replace('.json', '') + ' (corrupt)'); } } }); }; this.commands['session-delete'] = (args) => { if (!args[0]) return this.terminal.print('Usage: session-delete ', 'warning'); const filePath = '_sessions/' + args[0] + '.json'; if (this.terminal.fs.deleteFile(filePath)) { this.terminal.print('Session deleted: ' + args[0], 'success'); } else { this.terminal.print('Session not found: ' + args[0], 'error'); } }; this.commands['session-export'] = (args) => { if (!args[0]) return this.terminal.print('Usage: session-export ', 'warning'); const filePath = '_sessions/' + args[0] + '.json'; const content = this.terminal.fs.readFile(filePath); if (content === null) return this.terminal.print('Session not found: ' + args[0], 'error'); const blob = new Blob([content], {type: 'application/json'}); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'webx11-session-' + args[0] + '.json'; a.style.display = 'none'; document.body.appendChild(a); a.click(); setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 2000); this.terminal.print('Exported session: ' + args[0], 'success'); }; this.commands['session-import'] = () => { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json'; input.onchange = (e) => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (evt) => { try { const session = JSON.parse(evt.target.result); if (!session || session.type !== 'session' || !session.fsRoot) { this.terminal.print('Invalid session file (not a valid session export)', 'error'); return; } this.terminal.fs.root = session.fsRoot; this.terminal.fs.currentPath = session.currentPath || []; this.terminal.fs.saveToStorage(); this.terminal.updatePrompt(); this.terminal.print('Session imported: ' + file.name + ' (' + (session.savedAt ? new Date(session.savedAt).toLocaleString() : 'unknown date') + ')', 'success'); this.terminal.executeCommand('ls'); } catch (err) { this.terminal.print('Failed to import session: ' + err.message, 'error'); } }; reader.readAsText(file); }; input.click(); }; this.commands['vfs-import'] = () => { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json'; input.onchange = (e) => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (evt) => { try { const data = JSON.parse(evt.target.result); if (!data || !data.type) { this.terminal.print('Invalid VFS file', 'error'); return; } if (this.terminal.fs.restoreFromExport(data)) { this.terminal.updatePrompt(); this.terminal.print('VFS restored from: ' + file.name, 'success'); this.terminal.executeCommand('ls'); } else { this.terminal.print('Failed to restore VFS', 'error'); } } catch (err) { this.terminal.print('Failed to import VFS: ' + err.message, 'error'); } }; reader.readAsText(file); }; input.click(); }; } _printTree(dir, prefix, lines) { const names = Object.keys(dir.content || {}).sort(); names.forEach((name, i) => { const isLast = i === names.length - 1; const node = dir.content[name]; const connector = isLast ? '\u2514\u2500\u2500 ' : '\u251c\u2500\u2500 '; const label = node.type === 'dir' ? name + '/' : name; lines.push(prefix + connector + label); if (node.type === 'dir') { this._printTree(node, prefix + (isLast ? ' ' : '\u2502 '), lines); } }); } }