aboutsummaryrefslogtreecommitdiff
path: root/modules/core/module-fs.js
diff options
context:
space:
mode:
Diffstat (limited to 'modules/core/module-fs.js')
-rw-r--r--modules/core/module-fs.js445
1 files changed, 445 insertions, 0 deletions
diff --git a/modules/core/module-fs.js b/modules/core/module-fs.js
new file mode 100644
index 0000000..c4bc786
--- /dev/null
+++ b/modules/core/module-fs.js
@@ -0,0 +1,445 @@
+class FileSystemModule {
+ constructor() { this.commands = {}; this.terminal = null; }
+ getHelpInfo() {
+ return {
+ 'ls': 'ls [path] [-la] — List files and directories',
+ 'cat': 'cat <file> — Display file contents',
+ 'put': 'put <file> <content> — Create or overwrite a file',
+ 'rm': 'rm <file> — Remove a file; use -r/-rf for directories',
+ 'mkdir': 'mkdir <dir> [-p] — Create a directory; -p creates parent paths',
+ 'rmdir': 'rmdir <dir> — Remove a directory',
+ 'mv': 'mv <source> <dest> — Move file or directory',
+ 'cp': 'cp <source> <dest> — Copy file or directory',
+ 'cd': 'cd <path> — Change current directory; cd / to go to root',
+ 'pwd': 'pwd — Print current working directory',
+ 'find': 'find <pattern> — Search files by name pattern',
+ 'grep': 'grep <pattern> [file] — Search file content for pattern',
+ 'head': 'head <file> [lines] — Show first N lines (default: 10)',
+ 'tail': 'tail <file> [lines] — Show last N lines (default: 10)',
+ 'sort': 'sort <file> — Sort lines of a file alphabetically',
+ 'wc': 'wc <file> — Count lines, words, and bytes',
+ 'touch': 'touch <file> — 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 <file> — 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 <filename>', '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 <filename> <content>', '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 <filename>', 'warning');
+ if(args[0] === '-r' || args[0] === '-rf') {
+ const t = args[1]; if (!t) return this.terminal.print('Usage: rm -r <dirname>', 'warning');
+ if (this.terminal.fs.deleteDirRecursive(t)) this.terminal.print('Removed: ' + 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('Removed: ' + args[0], 'success');
+ else this.terminal.print('rm: ' + args[0] + ': No such file', 'error');
+ };
+ this.commands['mkdir'] = (args) => {
+ if(!args[0]) return this.terminal.print('Usage: mkdir <dirname>', 'warning');
+ if (args[0] === '-p') {
+ if(!args[1]) return this.terminal.print('Usage: mkdir -p <path>', '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 <dirname>', '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 <source> <dest>', 'warning');
+ const srcDir = this.terminal.fs.readDir(args[0]);
+ if (srcDir !== null) {
+ if (this.terminal.fs.moveDir(args[0], args[1])) {
+ this.terminal.print('Moved directory: ' + args[0] + ' to ' + args[1], 'success');
+ } else {
+ this.terminal.print('mv: ' + args[0] + ': failed to move directory', 'error');
+ }
+ return;
+ }
+ if (this.terminal.fs.moveFile(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 <source> <dest>', 'warning');
+ const srcDir = this.terminal.fs.readDir(args[0]);
+ if (srcDir !== null) {
+ if (this.terminal.fs.copyDir(args[0], args[1])) {
+ this.terminal.print('Copied directory: ' + args[0] + ' to ' + args[1], 'success');
+ } else {
+ this.terminal.print('cp: ' + args[0] + ': failed to copy directory', 'error');
+ }
+ return;
+ }
+ if (this.terminal.fs.copyFile(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 <pattern>', '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 <pattern> [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 <filename> [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 <filename> [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 <filename>', '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 <filename>', '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 <filename>', '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 => { total += dir.content[k].content ? dir.content[k].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'] = (args) => {
+ if(!args[0]) return this.terminal.print('Usage: download <filename>', 'warning');
+ const content = this.terminal.fs.readFile(args[0]);
+ if(content !== null && content !== undefined) {
+ const blob = new Blob([content], {type: 'application/octet-stream'});
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url; a.download = args[0];
+ a.style.display = 'none';
+ document.body.appendChild(a);
+ a.click();
+ setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 2000);
+ this.terminal.print('Downloaded: ' + args[0], 'success');
+ } else { this.terminal.print('download: ' + args[0] + ': No such file', 'error'); }
+ };
+ 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');
+ };
+
+ // Session commands (save/load/export/import)
+ this.commands['session-save'] = (args) => {
+ if (!args[0]) return this.terminal.print('Usage: session-save <name>', 'warning');
+ const name = args[0].replace(/[^a-zA-Z0-9_-]/g, '_');
+ const snapshot = {
+ type: 'session',
+ savedAt: new Date().toISOString(),
+ fsRoot: this.terminal.fs.root,
+ 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 <name>', '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 <name>', '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 <name>', '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);
+ }
+ });
+ }
+}