aboutsummaryrefslogtreecommitdiff
path: root/modules/ext
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-05-25 18:02:02 +0200
committerAgando <roduccomm@gmail.com>2026-05-25 18:02:02 +0200
commit4baa484b84c5184286cac2743575251454a5ee2b (patch)
treecb9002f93644f363d0f35831dc97e6492bbb0ed6 /modules/ext
auto: 2026-05-25_18:02 changes
Diffstat (limited to 'modules/ext')
-rw-r--r--modules/ext/alias.js108
-rw-r--r--modules/ext/calc.js77
-rw-r--r--modules/ext/cron.js110
-rw-r--r--modules/ext/diff.js287
-rw-r--r--modules/ext/editor.js70
-rw-r--r--modules/ext/filemgr.js755
-rw-r--r--modules/ext/finder.js47
-rw-r--r--modules/ext/mindmap.js217
-rw-r--r--modules/ext/skill-pack-core.js285
-rw-r--r--modules/ext/theme.js172
-rw-r--r--modules/ext/voice.js102
-rw-r--r--modules/ext/webdav.js532
-rw-r--r--modules/ext/wpm.js149
13 files changed, 2911 insertions, 0 deletions
diff --git a/modules/ext/alias.js b/modules/ext/alias.js
new file mode 100644
index 0000000..4ffab72
--- /dev/null
+++ b/modules/ext/alias.js
@@ -0,0 +1,108 @@
+class AliasModule {
+ constructor() {
+ this.commands = {};
+ this.terminal = null;
+ this.key = 'webx11_aliases';
+ this.defaults = { 'll': 'ls', 'h': 'help', 'cls': 'clear' };
+ this.aliases = this.load();
+ }
+
+ getHelpInfo() {
+ return {
+ 'alias': 'List, set, or show aliases (usage: alias [name=value])',
+ 'unalias': 'Remove a user-defined alias (usage: unalias <name>)',
+ };
+ }
+
+ load() {
+ try {
+ const data = localStorage.getItem(this.key);
+ return data ? { ...this.defaults, ...JSON.parse(data) } : { ...this.defaults };
+ } catch (e) { return { ...this.defaults }; }
+ }
+ save() {
+ const custom = {};
+ Object.keys(this.aliases).forEach(k => {
+ if (!this.defaults[k]) custom[k] = this.aliases[k];
+ });
+ localStorage.setItem(this.key, JSON.stringify(custom));
+ }
+ registerCommands() {
+ const self = this;
+ // Register management commands
+ this.commands['alias'] = (args) => self.handleAlias(args);
+ this.commands['unalias'] = (args) => self.handleUnalias(args);
+ // Register each alias as a delegating command
+ Object.keys(this.aliases).forEach(name => {
+ if (name === 'alias' || name === 'unalias') return;
+ const target = this.aliases[name];
+ this.commands[name] = (args) => {
+ this.terminal.executeCommand(target + (args.length ? ' ' + args.join(' ') : ''));
+ };
+ });
+ }
+ handleAlias(args) {
+ if (!args[0]) {
+ // List all aliases
+ this.terminal.print('=== Aliases ===', 'info');
+ const keys = Object.keys(this.aliases);
+ if (keys.length === 0) {
+ this.terminal.print(' No aliases defined', 'warning');
+ } else {
+ keys.forEach(name => {
+ const mark = this.defaults[name] ? ' (built-in)' : '';
+ this.terminal.print(` ${name}=${this.aliases[name]}${mark}`);
+ });
+ }
+ this.terminal.print('Usage: alias &lt;name&gt;=&lt;value&gt;', 'info');
+ return;
+ }
+ // Parse name=value
+ const input = args.join(' ');
+ const eqIdx = input.indexOf('=');
+ if (eqIdx === -1) {
+ // Show specific alias value
+ const name = args[0];
+ if (this.aliases[name]) {
+ this.terminal.print(`${name}=${this.aliases[name]}`, 'info');
+ } else {
+ this.terminal.print(`Alias '${name}' not found`, 'error');
+ }
+ return;
+ }
+ const name = input.substring(0, eqIdx).trim();
+ const value = input.substring(eqIdx + 1).trim();
+ if (!name) return this.terminal.print('Invalid alias name', 'error');
+ if (!value) return this.terminal.print('Invalid alias value', 'error');
+ if (name === 'alias' || name === 'unalias') {
+ return this.terminal.print('Cannot alias reserved command name', 'error');
+ }
+ this.aliases[name] = value;
+ this.save();
+ // Register the new alias as a command immediately
+ if (!this.defaults[name]) {
+ const target = value;
+ this.commands[name] = (args) => {
+ this.terminal.executeCommand(target + (args.length ? ' ' + args.join(' ') : ''));
+ };
+ }
+ this.terminal.print(`Alias set: ${name}=${value}`, 'success');
+ }
+ handleUnalias(args) {
+ if (!args[0]) return this.terminal.print('Usage: unalias &lt;name&gt;', 'warning');
+ const name = args[0];
+ if (this.defaults[name]) {
+ return this.terminal.print(`Cannot unalias built-in alias '${name}'`, 'warning');
+ }
+ if (!this.aliases[name]) {
+ return this.terminal.print(`Alias '${name}' not found`, 'error');
+ }
+ delete this.aliases[name];
+ delete this.commands[name];
+ this.save();
+ this.terminal.print(`Alias removed: ${name}`, 'success');
+ }
+}
+if (typeof window.webx11 !== 'undefined' && window.webx11) {
+ webx11.moduleManager.registerModule(new AliasModule(), 'https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/ext/alias.js');
+}
diff --git a/modules/ext/calc.js b/modules/ext/calc.js
new file mode 100644
index 0000000..e28079e
--- /dev/null
+++ b/modules/ext/calc.js
@@ -0,0 +1,77 @@
+class CalcModule {
+ constructor() {
+ this.commands = {};
+ this.terminal = null;
+ this.units = {
+ 'km': { to: { 'miles': 0.621371, 'meters': 1000, 'feet': 3280.84 } },
+ 'miles': { to: { 'km': 1.60934, 'meters': 1609.34, 'feet': 5280 } },
+ 'meters': { to: { 'km': 0.001, 'miles': 0.000621371, 'feet': 3.28084 } },
+ 'feet': { to: { 'km': 0.0003048, 'miles': 0.000189394, 'meters': 0.3048 } },
+ 'kg': { to: { 'pounds': 2.20462, 'grams': 1000, 'ounces': 35.274 } },
+ 'pounds': { to: { 'kg': 0.453592, 'grams': 453.592, 'ounces': 16 } },
+ 'grams': { to: { 'kg': 0.001, 'pounds': 0.00220462, 'ounces': 0.035274 } },
+ 'ounces': { to: { 'kg': 0.0283495, 'pounds': 0.0625, 'grams': 28.3495 } },
+ 'celsius': { to: { 'fahrenheit': 'c2f', 'kelvin': 'c2k' } },
+ 'fahrenheit': { to: { 'celsius': 'f2c', 'kelvin': 'f2k' } },
+ 'kelvin': { to: { 'celsius': 'k2c', 'fahrenheit': 'k2f' } },
+ 'inch': { to: { 'cm': 2.54, 'mm': 25.4, 'feet': 0.0833333 } },
+ 'cm': { to: { 'inch': 0.393701, 'mm': 10, 'm': 0.01 } },
+ 'mm': { to: { 'cm': 0.1, 'inch': 0.0393701, 'm': 0.001 } },
+ 'liters': { to: { 'gallons': 0.264172, 'ml': 1000, 'cups': 4.22675 } },
+ 'gallons': { to: { 'liters': 3.78541, 'ml': 3785.41, 'cups': 16 } }
+ };
+ }
+ getHelpInfo() {
+ return { 'calc': 'calc <expression> or calc <value> <from-unit> to <to-unit> — Evaluate expressions or convert between units' };
+ }
+ registerCommands() {
+ this.commands['calc'] = (args) => this.calc(args);
+ }
+ calc(args) {
+ if (!args[0]) return this.terminal.print('Usage: calc <expression> or calc <value> <from-unit> to <to-unit>', 'warning');
+ const input = args.join(' ');
+ const convertMatch = input.match(/^([+-]?\d*\.?\d+)\s*(\w+)\s+(?:to|in|->)\s+(\w+)$/i);
+ if (convertMatch) {
+ return this.convert(parseFloat(convertMatch[1]), convertMatch[2].toLowerCase(), convertMatch[3].toLowerCase());
+ }
+ this.evaluate(input);
+ }
+ evaluate(expr) {
+ const sanitized = expr.replace(/[^0-9+\-*/.()%eE\s]/g, '');
+ if (!sanitized) return this.terminal.print('Invalid expression', 'error');
+ try {
+ const result = new Function('return (' + sanitized + ')')();
+ if (typeof result === 'number' && !isNaN(result)) {
+ this.terminal.print(`${sanitized} = ${result}`, 'success');
+ } else {
+ this.terminal.print('Invalid result', 'error');
+ }
+ } catch (e) {
+ this.terminal.print(`Error: ${e.message}`, 'error');
+ }
+ }
+ convert(value, from, to) {
+ if (!this.units[from]) return this.terminal.print(`Unknown unit: ${from}`, 'error');
+ if (!this.units[from].to[to]) {
+ const avail = Object.keys(this.units[from].to).join(', ');
+ return this.terminal.print(`Cannot convert ${from} to ${to}. Available: ${avail}`, 'warning');
+ }
+ const factor = this.units[from].to[to];
+ if (typeof factor === 'string') {
+ let result;
+ if (factor === 'c2f') result = value * 9/5 + 32;
+ else if (factor === 'c2k') result = value + 273.15;
+ else if (factor === 'f2c') result = (value - 32) * 5/9;
+ else if (factor === 'f2k') result = (value - 32) * 5/9 + 273.15;
+ else if (factor === 'k2c') result = value - 273.15;
+ else if (factor === 'k2f') result = (value - 273.15) * 9/5 + 32;
+ else return this.terminal.print('Conversion not implemented', 'error');
+ this.terminal.print(`${value} ${from} = ${result.toFixed(4)} ${to}`, 'success');
+ } else {
+ this.terminal.print(`${value} ${from} = ${(value * factor).toFixed(4)} ${to}`, 'success');
+ }
+ }
+}
+if (typeof window.webx11 !== 'undefined' && window.webx11) {
+ webx11.moduleManager.registerModule(new CalcModule(), 'https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/calc.js');
+}
diff --git a/modules/ext/cron.js b/modules/ext/cron.js
new file mode 100644
index 0000000..66cd18e
--- /dev/null
+++ b/modules/ext/cron.js
@@ -0,0 +1,110 @@
+class CronModule {
+ constructor() {
+ this.commands = {};
+ this.terminal = null;
+ this.jobs = {};
+ this.counter = 0;
+ this.MIN_INTERVAL = 5;
+ this.STORAGE_KEY = 'webx11_cron_jobs';
+ this._restoreJobs();
+ }
+ requirements() {
+ return { met: true, missing: [] };
+ }
+ getHelpInfo() {
+ return {
+ 'cron-every': 'cron-every <seconds> <command> — Schedule a command to run every N seconds',
+ 'cron-list': 'cron-list — List all active cron jobs',
+ 'cron-stop': 'cron-stop <id> — Stop a specific cron job',
+ 'cron-clear': 'cron-clear — Stop all cron jobs'
+ };
+ }
+ registerCommands() {
+ this.commands['cron-every'] = (args) => this.every(args);
+ this.commands['cron-list'] = () => this.list();
+ this.commands['cron-stop'] = (args) => this.stop(args);
+ this.commands['cron-clear'] = () => this.clear();
+ }
+ _restoreJobs() {
+ try {
+ const saved = localStorage.getItem(this.STORAGE_KEY);
+ if (!saved) return;
+ const data = JSON.parse(saved);
+ if (!Array.isArray(data) || data.length === 0) return;
+ let maxId = 0;
+ data.forEach(j => {
+ if (j.id > maxId) maxId = j.id;
+ if (this.counter < j.id) this.counter = j.id;
+ this.jobs[j.id] = {
+ cmd: j.cmd,
+ interval: j.interval,
+ runs: j.runs || 0,
+ timer: setInterval(() => {
+ if (!this.jobs[j.id]) return;
+ this.jobs[j.id].runs++;
+ this.terminal.print(`[cron #${j.id}] Running: ${j.cmd}`, 'info');
+ this.terminal.executeCommand(j.cmd);
+ }, j.interval)
+ };
+ });
+ this.counter = maxId;
+ if (data.length > 0) this.terminal.print(`↻ Restored ${data.length} cron job(s) from storage`, 'info');
+ } catch (e) { /* silently ignore corrupt data */ }
+ }
+ _saveJobs() {
+ try {
+ const data = Object.entries(this.jobs).map(([id, j]) => ({
+ id: parseInt(id),
+ cmd: j.cmd,
+ interval: j.interval,
+ runs: j.runs
+ }));
+ localStorage.setItem(this.STORAGE_KEY, JSON.stringify(data));
+ } catch (e) { /* quota exceeded */ }
+ }
+ every(args) {
+ if (args.length < 2) return this.terminal.print('Usage: cron-every <seconds> <command>', 'warning');
+ const secs = parseInt(args[0]);
+ if (isNaN(secs) || secs < this.MIN_INTERVAL) return this.terminal.print(`Interval must be >= ${this.MIN_INTERVAL} seconds`, 'warning');
+ const cmd = args.slice(1).join(' ');
+ this.counter++;
+ const id = this.counter;
+ this.jobs[id] = {
+ cmd, interval: secs * 1000, runs: 0,
+ timer: setInterval(() => {
+ this.jobs[id].runs++;
+ this.terminal.print(`[cron #${id}] Running: ${cmd}`, 'info');
+ this.terminal.executeCommand(cmd);
+ }, secs * 1000)
+ };
+ this._saveJobs();
+ this.terminal.print(`Cron job #${id} started: "${cmd}" every ${secs}s`, 'success');
+ }
+ list() {
+ const ids = Object.keys(this.jobs);
+ if (ids.length === 0) return this.terminal.print('No active cron jobs', 'info');
+ this.terminal.print('=== Cron Jobs ===', 'info');
+ ids.forEach(id => {
+ const j = this.jobs[id];
+ this.terminal.print(` #${id}: "${j.cmd}" every ${j.interval / 1000}s (${j.runs} runs)`);
+ });
+ }
+ stop(args) {
+ if (!args[0]) return this.terminal.print('Usage: cron-stop <id>', 'warning');
+ const id = parseInt(args[0]);
+ if (!this.jobs[id]) return this.terminal.print(`Cron job #${id} not found`, 'error');
+ clearInterval(this.jobs[id].timer);
+ delete this.jobs[id];
+ this._saveJobs();
+ this.terminal.print(`Cron job #${id} stopped`, 'success');
+ }
+ clear() {
+ Object.keys(this.jobs).forEach(id => { clearInterval(this.jobs[id].timer); });
+ this.jobs = {};
+ localStorage.removeItem(this.STORAGE_KEY);
+ this.terminal.print('All cron jobs stopped', 'success');
+ }
+}
+if (typeof window.webx11 !== 'undefined' && window.webx11) {
+ webx11.moduleManager.registerModule(new CronModule(), 'https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/cron.js');
+}
diff --git a/modules/ext/diff.js b/modules/ext/diff.js
new file mode 100644
index 0000000..8b79b6b
--- /dev/null
+++ b/modules/ext/diff.js
@@ -0,0 +1,287 @@
+class DiffModule {
+ constructor() {
+ this.commands = {};
+ this.terminal = null;
+ }
+
+ requirements() {
+ return { met: true, missing: [] };
+ }
+
+ getHelpInfo() {
+ return {
+ 'diff': 'Show differences between two VFS files or compare module source (usage: diff <file1> <file2> or diff --module <name>)',
+ };
+ }
+
+ registerCommands() {
+ this.commands['diff'] = (args) => this.diff(args);
+ }
+ async diff(args) {
+ if (!args.length) return this.terminal.print('Usage: diff <file1> <file2> or diff --module <ModuleName>', 'warning');
+
+ if (args[0] === '--module') {
+ if (!args[1]) return this.terminal.print('Usage: diff --module <ModuleName>', 'warning');
+ return this._diffModule(args[1]);
+ }
+
+ return this._diffFiles(args[0], args[1]);
+ }
+ _diffFiles(path1, path2) {
+ let content1, content2;
+ try {
+ content1 = this.terminal.fs.readFile(path1);
+ content2 = this.terminal.fs.readFile(path2);
+ } catch (e) {
+ return this.terminal.print(`Error reading files: ${e.message}`, 'error');
+ }
+ if (content1 === null || content1 === undefined) {
+ return this.terminal.print(`File not found: ${path1}`, 'error');
+ }
+ if (content2 === null || content2 === undefined) {
+ return this.terminal.print(`File not found: ${path2}`, 'error');
+ }
+ const lines1 = String(content1).split('\n');
+ const lines2 = String(content2).split('\n');
+
+ // Strip trailing empty line if file ends with newline
+ if (lines1.length > 0 && lines1[lines1.length - 1] === '') lines1.pop();
+ if (lines2.length > 0 && lines2[lines2.length - 1] === '') lines2.pop();
+
+ if (lines1.length === 0 && lines2.length === 0) {
+ return this.terminal.print('Both files are empty — no differences', 'info');
+ }
+
+ // Compute LCS-based diff
+ const result = this._computeDiff(lines1, lines2);
+
+ // Render output
+ this.terminal.print(`--- ${path1}`, 'info');
+ this.terminal.print(`+++ ${path2}`, 'info');
+
+ if (result.length === 0) {
+ return this.terminal.print('Files are identical', 'success');
+ }
+
+ const maxLineNum = Math.max(lines1.length, lines2.length);
+ const lineNumWidth = String(maxLineNum).length;
+
+ result.forEach(part => {
+ if (part.type === 'same') {
+ // Print up to 3 unchanged lines for context, then collapse
+ if (part.lines.length <= 3) {
+ part.lines.forEach((line, i) => {
+ const ln = String(part.aStart + i + 1).padStart(lineNumWidth);
+ this.terminal.print(` ${ln} ${line}`, 'dim');
+ });
+ } else {
+ // Show first line
+ this.terminal.print(` ${String(part.aStart + 1).padStart(lineNumWidth)} ${part.lines[0]}`, 'dim');
+ this.terminal.print(` ${' '.repeat(lineNumWidth)} ... ${part.lines.length - 2} more unchanged lines`, 'dim');
+ // Show last line
+ const lastIdx = part.lines.length - 1;
+ const ln = String(part.aStart + lastIdx + 1).padStart(lineNumWidth);
+ this.terminal.print(` ${ln} ${part.lines[lastIdx]}`, 'dim');
+ }
+ } else if (part.type === 'removed') {
+ part.lines.forEach((line, i) => {
+ const ln = String(part.aStart + i + 1).padStart(lineNumWidth);
+ this.terminal.print(`-${ln} ${line}`, 'error');
+ });
+ } else if (part.type === 'added') {
+ part.lines.forEach((line, i) => {
+ const ln = String(part.bStart + i + 1).padStart(lineNumWidth);
+ this.terminal.print(`+${ln} ${line}`, 'success');
+ });
+ }
+ });
+ }
+ _computeDiff(a, b) {
+ // Build LCS table
+ const m = a.length;
+ const n = b.length;
+ const dp = Array.from({ length: m + 1 }, () => new Int32Array(n + 1));
+
+ for (let i = 1; i <= m; i++) {
+ for (let j = 1; j <= n; j++) {
+ if (a[i - 1] === b[j - 1]) {
+ dp[i][j] = dp[i - 1][j - 1] + 1;
+ } else {
+ dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
+ }
+ }
+ }
+
+ // Backtrack to build diff parts
+ const parts = [];
+ let i = m, j = n;
+ let removed = [], added = [], same = [];
+ const aStart = 0, bStart = 0;
+
+ const flush = () => {
+ if (same.length > 0) {
+ parts.push({
+ type: 'same',
+ aStart: aStart + (parts.length > 0 ? parts.reduce((sum, p) => {
+ if (p.type === 'same') return sum + p.lines.length;
+ if (p.type === 'removed') return sum + p.lines.length;
+ return sum;
+ }, 0) : 0),
+ lines: same.splice(0)
+ });
+ }
+ if (removed.length > 0 || added.length > 0) {
+ if (removed.length > 0) {
+ const aIdx = parts.reduce((sum, p) => {
+ if (p.type === 'same') return sum + p.lines.length;
+ if (p.type === 'removed') return sum + p.lines.length;
+ return sum;
+ }, 0);
+ parts.push({ type: 'removed', aStart: aIdx, lines: removed.splice(0) });
+ }
+ if (added.length > 0) {
+ const bIdx = parts.reduce((sum, p) => {
+ if (p.type === 'same') return sum;
+ if (p.type === 'added') return sum + p.lines.length;
+ return sum;
+ }, 0);
+ parts.push({ type: 'added', bStart: bIdx, lines: added.splice(0) });
+ }
+ }
+ };
+
+ // Trace back through DP table
+ const trace = [];
+ while (i > 0 || j > 0) {
+ if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
+ trace.push({ type: 'same', lineA: a[i - 1], lineB: b[j - 1] });
+ i--; j--;
+ } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
+ trace.push({ type: 'added', lineB: b[j - 1] });
+ j--;
+ } else {
+ trace.push({ type: 'removed', lineA: a[i - 1] });
+ i--;
+ }
+ }
+
+ // Reverse trace to get forward order
+ trace.reverse();
+
+ // Build parts from trace
+ let aIdx = 0, bIdx = 0;
+ for (const entry of trace) {
+ if (entry.type === 'same') {
+ same.push(entry.lineA);
+ aIdx++;
+ bIdx++;
+ } else if (entry.type === 'removed') {
+ flush();
+ removed.push(entry.lineA);
+ aIdx++;
+ } else if (entry.type === 'added') {
+ flush();
+ added.push(entry.lineB);
+ bIdx++;
+ }
+ }
+ flush();
+
+ return parts;
+ }
+ async _diffModule(modName) {
+ // Find module in registry
+ const entry = this.terminal.moduleManager.moduleRegistry.find(m => m.name === modName);
+ if (!entry) {
+ return this.terminal.print(`Module "${modName}" not found in registry`, 'error');
+ }
+
+ const url = entry.url;
+ if (!url) {
+ return this.terminal.print(`No source URL for module "${modName}"`, 'warning');
+ }
+
+ // Fetch original source
+ let originalSource;
+ try {
+ const resp = await fetch(url);
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
+ originalSource = await resp.text();
+ } catch (e) {
+ return this.terminal.print(`Failed to fetch original source: ${e.message}`, 'error');
+ }
+
+ // Read local edited version
+ const localPath = `_mods/${modName}.js`;
+ let localSource;
+ let hasLocalEdit = false;
+ try {
+ localSource = this.terminal.fs.readFile(localPath);
+ if (localSource === null || localSource === undefined) {
+ hasLocalEdit = false;
+ } else {
+ hasLocalEdit = true;
+ }
+ } catch (e) {
+ hasLocalEdit = false;
+ }
+
+ if (!hasLocalEdit) {
+ // Try to read the module's currently loaded source
+ const loadedModule = this.terminal.moduleManager.modules[modName];
+ if (!loadedModule) {
+ return this.terminal.print(`Module "${modName}" is not loaded and has no local edits`, 'info');
+ }
+ return this.terminal.print(`Module "${modName}" has no local edits — source matches original`, 'success');
+ }
+
+ // Compare original source vs local edit
+ const lines1 = String(originalSource).split('\n');
+ const lines2 = String(localSource).split('\n');
+
+ // Strip trailing empty line if file ends with newline
+ if (lines1.length > 0 && lines1[lines1.length - 1] === '') lines1.pop();
+ if (lines2.length > 0 && lines2[lines2.length - 1] === '') lines2.pop();
+
+ this.terminal.print(`--- ${url}`, 'info');
+ this.terminal.print(`+++ ${localPath}`, 'info');
+
+ if (lines1.length === lines2.length && lines1.every((l, i) => l === lines2[i])) {
+ return this.terminal.print('No differences — local edit matches original', 'success');
+ }
+
+ const result = this._computeDiff(lines1, lines2);
+ const maxLineNum = Math.max(lines1.length, lines2.length);
+ const lineNumWidth = String(maxLineNum).length;
+
+ result.forEach(part => {
+ if (part.type === 'same') {
+ if (part.lines.length <= 3) {
+ part.lines.forEach((line, i) => {
+ const ln = String(part.aStart + i + 1).padStart(lineNumWidth);
+ this.terminal.print(` ${ln} ${line}`, 'dim');
+ });
+ } else {
+ this.terminal.print(` ${String(part.aStart + 1).padStart(lineNumWidth)} ${part.lines[0]}`, 'dim');
+ this.terminal.print(` ${' '.repeat(lineNumWidth)} ... ${part.lines.length - 2} more unchanged lines`, 'dim');
+ const lastIdx = part.lines.length - 1;
+ const ln = String(part.aStart + lastIdx + 1).padStart(lineNumWidth);
+ this.terminal.print(` ${ln} ${part.lines[lastIdx]}`, 'dim');
+ }
+ } else if (part.type === 'removed') {
+ part.lines.forEach((line, i) => {
+ const ln = String(part.aStart + i + 1).padStart(lineNumWidth);
+ this.terminal.print(`-${ln} ${line}`, 'error');
+ });
+ } else if (part.type === 'added') {
+ part.lines.forEach((line, i) => {
+ const ln = String(part.bStart + i + 1).padStart(lineNumWidth);
+ this.terminal.print(`+${ln} ${line}`, 'success');
+ });
+ }
+ });
+ }
+}
+if (typeof window.webx11 !== 'undefined' && window.webx11) {
+ webx11.moduleManager.registerModule(new DiffModule(), 'https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/diff.js');
+}
diff --git a/modules/ext/editor.js b/modules/ext/editor.js
new file mode 100644
index 0000000..571a535
--- /dev/null
+++ b/modules/ext/editor.js
@@ -0,0 +1,70 @@
+class EditorModule {
+ constructor() {
+ this.commands = {};
+ this.terminal = null;
+ this._vfsPath = null;
+ }
+
+ requirements() {
+ return { met: true, missing: [] };
+ }
+
+ getHelpInfo() {
+ return {
+ 'edit': 'Open the code editor for a VFS file (usage: edit <path>)',
+ };
+ }
+
+ registerCommands() {
+ this.commands['edit'] = (args) => this.edit(args);
+ }
+ edit(args) {
+ if (!args[0]) return this.terminal.print('Usage: edit <path>', 'warning');
+ const path = args.join(' ');
+ const modal = document.getElementById('code-editor');
+ const title = document.getElementById('code-editor-title');
+ const textarea = document.getElementById('code-editor-textarea');
+ if (!modal || !textarea) return this.terminal.print('Editor modal not found', 'error');
+ // Read existing file content or use empty for new files
+ const content = this.terminal.fs.readFile(path);
+ const fileContent = (content !== null && content !== false) ? content : '';
+ // Set up modal
+ this._vfsPath = path;
+ title.textContent = '📝 Editing: ' + path;
+ textarea.value = fileContent;
+ modal.style.display = 'flex';
+ textarea.focus();
+ // Override save button to write back to VFS
+ const saveBtn = modal.querySelector('.modal-footer .btn:last-child');
+ const cancelBtn = modal.querySelector('.modal-footer .btn-danger');
+ if (saveBtn) {
+ saveBtn._origOnclick = saveBtn.onclick;
+ saveBtn.onclick = async () => {
+ const code = textarea.value;
+ const ok = this.terminal.fs.writeFile(this._vfsPath, code);
+ if (ok) {
+ this.terminal.print('✅ Saved to ' + this._vfsPath, 'success');
+ } else {
+ this.terminal.print('❌ Failed to save ' + this._vfsPath, 'error');
+ }
+ modal.style.display = 'none';
+ this._vfsPath = null;
+ // Restore original onclick handler
+ if (saveBtn._origOnclick) saveBtn.onclick = saveBtn._origOnclick;
+ delete saveBtn._origOnclick;
+ };
+ }
+ if (cancelBtn) {
+ cancelBtn._origOnclick = cancelBtn.onclick;
+ cancelBtn.onclick = () => {
+ modal.style.display = 'none';
+ this._vfsPath = null;
+ if (cancelBtn._origOnclick) cancelBtn.onclick = cancelBtn._origOnclick;
+ delete cancelBtn._origOnclick;
+ };
+ }
+ }
+}
+if (typeof window.webx11 !== 'undefined' && window.webx11) {
+ webx11.moduleManager.registerModule(new EditorModule(), 'https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/editor.js');
+}
diff --git a/modules/ext/filemgr.js b/modules/ext/filemgr.js
new file mode 100644
index 0000000..5532cee
--- /dev/null
+++ b/modules/ext/filemgr.js
@@ -0,0 +1,755 @@
+class FileManagerModule {
+ constructor() {
+ this.commands = {};
+ this.terminal = null;
+ this.currentPath = [];
+ this._overlay = null;
+ this._selectedFile = null;
+ this._searchFilter = '';
+ this._rawMode = false;
+ }
+
+ requirements() {
+ return { met: true, missing: [] };
+ }
+
+ getHelpInfo() {
+ return {
+ 'files': 'Open the file manager overlay with VFS directory listing and preview',
+ 'preview': 'Preview a specific file (usage: preview <path>)',
+ };
+ }
+
+ registerCommands() {
+ this.commands['files'] = () => this._openOverlay();
+ this.commands['preview'] = (args) => this._previewCommand(args);
+ }
+
+ _previewCommand(args) {
+ if (!args[0]) return this.terminal.print('Usage: preview <path>', 'warning');
+ const path = args.join(' ');
+ this._openOverlay();
+ this._navigateToPathFromParts(path);
+ const content = this.terminal.fs.readFile(path);
+ if (content === null || content === false) {
+ this._renderPreviewError('File not found: ' + path);
+ return;
+ }
+ this._selectedFile = path;
+ this._renderPreview(path, content);
+ this._highlightSelected();
+ }
+
+ _navigateToPathFromParts(path) {
+ const parts = path.split('/').filter(p => p);
+ if (parts.length <= 1) {
+ this.currentPath = [];
+ return;
+ }
+ this.currentPath = parts.slice(0, -1);
+ }
+
+ _detectType(filePath) {
+ const ext = filePath.split('.').pop().toLowerCase();
+ if (!ext || ext === filePath) return 'other';
+ const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
+ const videoExts = ['mp4', 'webm'];
+ const audioExts = ['mp3', 'wav', 'ogg'];
+ const codeExts = ['js', 'py', 'html', 'css', 'yaml', 'yml', 'toml'];
+ if (ext === 'md') return 'md';
+ if (imageExts.includes(ext)) return 'image';
+ if (videoExts.includes(ext)) return 'video';
+ if (audioExts.includes(ext)) return 'audio';
+ if (codeExts.includes(ext)) return 'code';
+ if (ext === 'json') return 'json';
+ if (ext === 'csv') return 'csv';
+ if (ext === 'pdf') return 'pdf';
+ if (ext === 'svg') return 'svg';
+ if (ext === 'txt') return 'text';
+ return 'other';
+ }
+
+ _renderMarkdown(text) {
+ if (!text) return '';
+ try {
+ let html = text
+ .replace(/&/g, '&amp;')
+ .replace(/</g, '&lt;')
+ .replace(/>/g, '&gt;');
+ // code blocks first
+ html = html.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>');
+ // inline code
+ html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
+ // headers
+ html = html.replace(/^### (.+)$/gm, '<h3 style="color:var(--accent-color);margin:8px 0 4px">$1</h3>');
+ html = html.replace(/^## (.+)$/gm, '<h2 style="color:var(--accent-color);margin:10px 0 4px">$1</h2>');
+ html = html.replace(/^# (.+)$/gm, '<h1 style="color:var(--accent-color);margin:12px 0 6px">$1</h1>');
+ // bold
+ html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
+ // italic
+ html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
+ // links
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" style="color:var(--info-color)">$1</a>');
+ // horizontal rules
+ html = html.replace(/^---$/gm, '<hr style="border-color:var(--border-color);margin:8px 0">');
+ // unordered lists
+ html = html.replace(/^[\s]*[-*]\s+(.+)$/gm, '<li style="margin-left:16px;list-style:disc">$1</li>');
+ // paragraphs - double newlines
+ html = html.replace(/\n\n/g, '</p><p style="margin:6px 0">');
+ html = '<p style="margin:6px 0">' + html + '</p>';
+ return html;
+ } catch (e) {
+ return '<pre style="white-space:pre-wrap">' + text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') + '</pre>';
+ }
+ }
+
+ _getPreviewHtml(path, content) {
+ if (content === null || content === false) {
+ return '<div style="color:var(--error-color);padding:20px">File not found: ' + this._escapeHtml(path) + '</div>';
+ }
+
+ const type = this._detectType(path);
+ const ext = path.split('.').pop().toLowerCase();
+ const fileName = path.split('/').pop() || path;
+
+ // Handle binary detection for media types
+ const isLikelyBinary = typeof content === 'string' && content.length > 0 && !content.startsWith('\n') && !content.startsWith('\r') && !content.startsWith(' ') && !content.startsWith('<') && !content.startsWith('{') && !content.startsWith('[') && content.charCodeAt(0) < 32 && content.charCodeAt(0) !== 9 && content.charCodeAt(0) !== 10 && content.charCodeAt(0) !== 13;
+
+ const isTextContent = typeof content === 'string' && (content.startsWith('{') || content.startsWith('[') || content.startsWith('<') || content.startsWith('#') || /^[\w\s\n\r\t.,;:!?()\-=+*@#$%^&"'\/\\|`~]/.test(content.substring(0, 100)));
+
+ if (type === 'md' && isTextContent) {
+ return '<div class="markdown-preview" style="padding:12px;font-family:var(--font-primary);line-height:1.5">' + this._renderMarkdown(content) + '</div>';
+ }
+
+ if (type === 'text') {
+ const truncated = content.substring(0, 2000);
+ const extra = content.length > 2000 ? '<div style="color:var(--text-muted);margin-top:8px">(truncated to 2000 chars)</div>' : '';
+ return '<pre style="white-space:pre-wrap;padding:12px;font-family:var(--font-primary);font-size:0.9em;margin:0;overflow-x:auto">' + this._escapeHtml(truncated) + extra + '</pre>';
+ }
+
+ if (type === 'json') {
+ try {
+ const parsed = JSON.parse(content);
+ const formatted = JSON.stringify(parsed, null, 2);
+ const html = this._syntaxHighlightJson(formatted);
+ return '<pre style="padding:12px;font-family:var(--font-primary);font-size:0.9em;margin:0;overflow-x:auto;line-height:1.4">' + html + '</pre>';
+ } catch (e) {
+ return '<pre style="padding:12px;font-family:var(--font-primary);font-size:0.9em;margin:0;overflow-x:auto">' + this._escapeHtml(content.substring(0, 5000)) + '</pre>';
+ }
+ }
+
+ if (type === 'code') {
+ const lines = content.split('\n');
+ const totalLines = lines.length;
+ const maxLines = 150;
+ const displayLines = lines.slice(0, maxLines);
+ let html = '<table style="border-collapse:collapse;width:100%;font-family:var(--font-primary);font-size:0.85em;margin:0"><tbody>';
+ displayLines.forEach((line, i) => {
+ const lineNum = i + 1;
+ const escaped = this._escapeHtml(line);
+ html += '<tr><td style="color:var(--text-muted);padding:0 8px 0 4px;text-align:right;user-select:none;border-right:1px solid var(--border-color);vertical-align:top;width:40px">' + lineNum + '</td>';
+ html += '<td style="padding:0 8px;white-space:pre;width:100%">' + (escaped || '&nbsp;') + '</td></tr>';
+ });
+ html += '</tbody></table>';
+ if (totalLines > maxLines) {
+ html += '<div style="color:var(--text-muted);padding:8px;border-top:1px solid var(--border-color)">(showing ' + maxLines + ' of ' + totalLines + ' lines)</div>';
+ }
+ return html;
+ }
+
+ if (type === 'image') {
+ if (isLikelyBinary && !isTextContent) {
+ return '<div style="padding:20px;text-align:center;color:var(--text-muted)">📷 Image — <span style="color:var(--info-color);cursor:pointer;text-decoration:underline" onclick="' + this._escapeHtml("var a=document.createElement('a');a.href='data:image/" + ext + ";base64," + btoa(content) + "';a.download='" + fileName + "';a.click()") + '">download to view</span></div>';
+ }
+ const base64 = btoa(unescape(encodeURIComponent(content)));
+ return '<div style="padding:12px;text-align:center"><img src="data:image/' + ext + ';base64,' + base64 + '" style="max-width:80%;max-height:60vh;border-radius:4px;border:1px solid var(--border-color)" alt="' + this._escapeHtml(fileName) + '"></div>';
+ }
+
+ if (type === 'svg') {
+ try {
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(content, 'text/html');
+ const scripts = doc.querySelectorAll('script');
+ scripts.forEach(s => s.remove());
+ // Remove event handlers from svg elements
+ const allEls = doc.querySelectorAll('*');
+ allEls.forEach(el => {
+ for (let i = el.attributes.length - 1; i >= 0; i--) {
+ const attr = el.attributes[i].name;
+ if (attr.startsWith('on')) el.removeAttribute(attr);
+ }
+ });
+ const cleanSvg = doc.body.innerHTML;
+ return '<div style="padding:12px;text-align:center;max-width:100%;overflow:auto">' + cleanSvg + '</div>';
+ } catch (e) {
+ return '<pre style="padding:12px;font-family:var(--font-primary);font-size:0.9em;overflow-x:auto">' + this._escapeHtml(content.substring(0, 5000)) + '</pre>';
+ }
+ }
+
+ if (type === 'video') {
+ if (isLikelyBinary && !isTextContent) {
+ return '<div style="padding:20px;text-align:center;color:var(--text-muted)">📷 Video — <span style="color:var(--info-color);cursor:pointer;text-decoration:underline" onclick="var a=document.createElement(\'a\');a.href=\'data:video/' + ext + ';base64,' + btoa(content) + '\';a.download=\'' + fileName + '\';a.click()">download to view</span></div>';
+ }
+ const base64 = btoa(unescape(encodeURIComponent(content)));
+ return '<div style="padding:12px;text-align:center"><video controls style="max-width:80%;max-height:60vh;border-radius:4px"><source src="data:video/' + ext + ';base64,' + base64 + '" type="video/' + ext + '"></video></div>';
+ }
+
+ if (type === 'audio') {
+ if (isLikelyBinary && !isTextContent) {
+ return '<div style="padding:20px;text-align:center;color:var(--text-muted)">🎵 Audio — <span style="color:var(--info-color);cursor:pointer;text-decoration:underline" onclick="var a=document.createElement(\'a\');a.href=\'data:audio/' + ext + ';base64,' + btoa(content) + '\';a.download=\'' + fileName + '\';a.click()">download to play</span></div>';
+ }
+ const base64 = btoa(unescape(encodeURIComponent(content)));
+ return '<div style="padding:12px;text-align:center"><audio controls style="max-width:80%"><source src="data:audio/' + ext + ';base64,' + base64 + '" type="audio/' + ext + '"></audio></div>';
+ }
+
+ if (type === 'pdf') {
+ try {
+ const base64 = btoa(unescape(encodeURIComponent(content)));
+ return '<div style="padding:12px;text-align:center;height:70vh"><embed src="data:application/pdf;base64,' + base64 + '" type="application/pdf" style="width:100%;height:100%;border:1px solid var(--border-color);border-radius:4px"></div>';
+ } catch (e) {
+ return '<div style="padding:20px;text-align:center;color:var(--text-muted)">📄 PDF — <span style="color:var(--info-color);cursor:pointer;text-decoration:underline" onclick="var a=document.createElement(\'a\');a.href=\'data:application/pdf;base64,' + btoa(content) + '\';a.download=\'' + fileName + '\';a.click()">download to view</span></div>';
+ }
+ }
+
+ if (type === 'csv') {
+ try {
+ const lines = content.split('\n').filter(l => l.trim());
+ const maxRows = 50;
+ const rows = lines.slice(0, maxRows).map(l => l.split(',').map(c => c.trim().replace(/^"|"$/g, '')));
+ let html = '<table class="csv-table" style="border-collapse:collapse;width:100%;font-family:var(--font-primary);font-size:0.85em;margin:0;background:var(--bg-secondary)">';
+ if (rows.length > 0) {
+ html += '<thead><tr>';
+ rows[0].forEach(cell => {
+ html += '<th style="border:1px solid var(--border-color);padding:6px 8px;text-align:left;background:var(--bg-tertiary);color:var(--accent-color);font-weight:bold">' + this._escapeHtml(cell) + '</th>';
+ });
+ html += '</tr></thead><tbody>';
+ rows.slice(1).forEach(row => {
+ html += '<tr>';
+ row.forEach(cell => {
+ html += '<td style="border:1px solid var(--border-color);padding:4px 8px">' + this._escapeHtml(cell) + '</td>';
+ });
+ html += '</tr>';
+ });
+ html += '</tbody></table>';
+ if (lines.length > maxRows) {
+ html += '<div style="color:var(--text-muted);padding:8px">(showing ' + maxRows + ' of ' + lines.length + ' rows)</div>';
+ }
+ }
+ return html;
+ } catch (e) {
+ return '<pre style="padding:12px;font-family:var(--font-primary);font-size:0.9em;overflow-x:auto">' + this._escapeHtml(content.substring(0, 5000)) + '</pre>';
+ }
+ }
+
+ // 'other' — raw text fallback
+ if (typeof content === 'string' && isTextContent) {
+ const lines = content.split('\n');
+ const maxLines = 80;
+ const displayLines = lines.slice(0, maxLines);
+ const truncated = lines.length > maxLines;
+ let html = '<pre style="white-space:pre-wrap;padding:12px;font-family:var(--font-primary);font-size:0.9em;margin:0;overflow-x:auto">';
+ displayLines.forEach((line, i) => {
+ html += this._escapeHtml(line) + '\n';
+ });
+ html += '</pre>';
+ if (truncated) {
+ html += '<div style="color:var(--text-muted);padding:8px;border-top:1px solid var(--border-color)">(showing ' + maxLines + ' of ' + lines.length + ' lines)</div>';
+ }
+ return html;
+ }
+
+ return '<div style="padding:20px;text-align:center;color:var(--text-muted)">📁 Preview not available for this file type</div>';
+ }
+
+ _syntaxHighlightJson(jsonStr) {
+ return jsonStr.replace(/("(?:\\.|[^"\\])*")\s*:/g, '<span style="color:var(--info-color)">$1</span>:')
+ .replace(/:\s*("(?:\\.|[^"\\])*")/g, ': <span style="color:var(--success-color)">$1</span>')
+ .replace(/:\s*(\d+\.?\d*)/g, ': <span style="color:var(--warning-color)">$1</span>')
+ .replace(/:\s*(true|false|null)/g, ': <span style="color:var(--error-color)">$1</span>');
+ }
+
+ _escapeHtml(str) {
+ if (typeof str !== 'string') return '';
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;');
+ }
+
+ _formatSize(size) {
+ if (!size && size !== 0) return '';
+ if (size < 1024) return size + ' B';
+ if (size < 1024 * 1024) return (size / 1024).toFixed(1) + ' KB';
+ return (size / (1024 * 1024)).toFixed(1) + ' MB';
+ }
+
+ _createOverlay() {
+ if (this._overlay) return;
+
+ const overlay = document.createElement('div');
+ overlay.id = 'filemgr-overlay';
+ overlay.style.cssText = 'position:fixed;inset:0;z-index:1800;display:flex;flex-direction:column;background:var(--bg-color);color:var(--text-color);font-family:var(--font-primary);font-size:14px;';
+
+ // Inject responsive styles for the overlay
+ const styleId = 'filemgr-styles';
+ if (!document.getElementById(styleId)) {
+ const style = document.createElement('style');
+ style.id = styleId;
+ style.textContent = `
+ #filemgr-overlay.responsive .filemgr-main {
+ flex-direction: column !important;
+ }
+ #filemgr-overlay.responsive #filemgr-sidebar {
+ width: 100% !important;
+ max-height: 40vh;
+ }
+ #filemgr-overlay.responsive #filemgr-preview {
+ width: 100% !important;
+ }
+ #filemgr-overlay .filemgr-main {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+ }
+ @media (max-width: 600px) {
+ #filemgr-overlay .filemgr-main {
+ flex-direction: column !important;
+ }
+ #filemgr-overlay #filemgr-sidebar {
+ width: 100% !important;
+ max-height: 40vh;
+ border-right: none !important;
+ border-bottom: 1px solid var(--border-color);
+ }
+ #filemgr-overlay #filemgr-preview {
+ width: 100% !important;
+ min-height: 40vh;
+ }
+ #filemgr-action-bar {
+ flex-wrap: wrap;
+ gap: 6px;
+ }
+ #filemgr-action-bar button {
+ min-width: 44px;
+ min-height: 44px;
+ font-size: 0.9em;
+ }
+ }
+ `;
+ document.head.appendChild(style);
+ }
+
+ // Toolbar
+ const toolbar = document.createElement('div');
+ toolbar.style.cssText = 'display:flex;align-items:center;padding:8px 12px;border-bottom:1px solid var(--border-color);background:var(--bg-secondary);flex-shrink:0;gap:8px;flex-wrap:wrap;';
+
+ const closeBtn = document.createElement('button');
+ closeBtn.textContent = '✕';
+ closeBtn.style.cssText = 'background:none;border:1px solid var(--border-color);color:var(--text-color);cursor:pointer;padding:4px 10px;border-radius:4px;font-family:inherit;font-size:1em;min-height:44px;touch-action:manipulation;';
+ closeBtn.onclick = () => this._closeOverlay();
+ toolbar.appendChild(closeBtn);
+
+ const titleSpan = document.createElement('span');
+ titleSpan.textContent = '📁 File Manager';
+ titleSpan.style.cssText = 'font-weight:bold;margin-right:8px;';
+ toolbar.appendChild(titleSpan);
+
+ // Breadcrumbs
+ const breadcrumbs = document.createElement('div');
+ breadcrumbs.style.cssText = 'display:flex;align-items:center;gap:4px;flex-wrap:wrap;font-size:0.9em;';
+ breadcrumbs.id = 'filemgr-breadcrumbs';
+
+ // Root breadcrumb
+ const rootSeg = document.createElement('span');
+ rootSeg.textContent = '/';
+ rootSeg.style.cssText = 'color:var(--accent-color);cursor:pointer;padding:2px 4px;border-radius:3px;';
+ rootSeg.onmouseenter = () => { rootSeg.style.background = 'var(--bg-tertiary)'; };
+ rootSeg.onmouseleave = () => { rootSeg.style.background = 'transparent'; };
+ rootSeg.onclick = () => this._navigateTo([]);
+ breadcrumbs.appendChild(rootSeg);
+
+ toolbar.appendChild(breadcrumbs);
+
+ // Search input
+ const searchInput = document.createElement('input');
+ searchInput.type = 'text';
+ searchInput.placeholder = '🔍 Filter files...';
+ searchInput.style.cssText = 'flex:1;min-width:120px;max-width:300px;padding:6px 10px;background:var(--bg-tertiary);border:1px solid var(--border-color);border-radius:4px;color:var(--text-color);font-family:var(--font-primary);font-size:0.9em;outline:none;';
+ searchInput.oninput = (e) => {
+ this._searchFilter = e.target.value.toLowerCase();
+ this._refreshFileList();
+ };
+ toolbar.appendChild(searchInput);
+
+ overlay.appendChild(toolbar);
+
+ // Main area — wraps sidebar + preview in a responsive flex container
+ const mainArea = document.createElement('div');
+ mainArea.id = 'filemgr-main-container';
+ mainArea.className = 'filemgr-main';
+ mainArea.style.cssText = 'overflow:hidden;';
+
+ // Sidebar
+ const sidebar = document.createElement('div');
+ sidebar.id = 'filemgr-sidebar';
+ sidebar.style.cssText = 'width:30%;min-width:200px;overflow-y:auto;background:var(--bg-secondary);border-right:1px solid var(--border-color);';
+
+ mainArea.appendChild(sidebar);
+
+ // Preview pane
+ const preview = document.createElement('div');
+ preview.id = 'filemgr-preview';
+ preview.style.cssText = 'flex:1;overflow-y:auto;padding:0;';
+
+ mainArea.appendChild(preview);
+
+ overlay.appendChild(mainArea);
+
+ // Action bar
+ const actionBar = document.createElement('div');
+ actionBar.id = 'filemgr-action-bar';
+ actionBar.style.cssText = 'display:flex;align-items:center;padding:8px 12px;border-top:1px solid var(--border-color);background:var(--bg-secondary);flex-shrink:0;gap:8px;flex-wrap:wrap;';
+
+ const statusSpan = document.createElement('span');
+ statusSpan.id = 'filemgr-status';
+ statusSpan.style.cssText = 'flex:1;color:var(--text-muted);font-size:0.85em;';
+ statusSpan.textContent = 'No file selected';
+ actionBar.appendChild(statusSpan);
+
+ const deleteBtn = document.createElement('button');
+ deleteBtn.textContent = '🗑 Delete';
+ deleteBtn.style.cssText = 'padding:6px 14px;background:var(--bg-tertiary);border:1px solid var(--error-color);color:var(--error-color);border-radius:4px;cursor:pointer;font-family:inherit;font-size:0.9em;min-height:44px;touch-action:manipulation;';
+ deleteBtn.onclick = () => this._actionDelete();
+ actionBar.appendChild(deleteBtn);
+
+ const downloadBtn = document.createElement('button');
+ downloadBtn.textContent = '⬇ Download';
+ downloadBtn.style.cssText = 'padding:6px 14px;background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-color);border-radius:4px;cursor:pointer;font-family:inherit;font-size:0.9em;min-height:44px;touch-action:manipulation;';
+ downloadBtn.onclick = () => this._actionDownload();
+ actionBar.appendChild(downloadBtn);
+
+ const editBtn = document.createElement('button');
+ editBtn.textContent = '✏ Edit';
+ editBtn.style.cssText = 'padding:6px 14px;background:var(--bg-tertiary);border:1px solid var(--accent-color);color:var(--accent-color);border-radius:4px;cursor:pointer;font-family:inherit;font-size:0.9em;min-height:44px;touch-action:manipulation;';
+ editBtn.onclick = () => this._actionEdit();
+ actionBar.appendChild(editBtn);
+
+ const previewToggleBtn = document.createElement('button');
+ previewToggleBtn.id = 'filemgr-toggle-raw';
+ previewToggleBtn.textContent = '📄 Raw';
+ previewToggleBtn.style.cssText = 'padding:6px 14px;background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-muted);border-radius:4px;cursor:pointer;font-family:inherit;font-size:0.9em;min-height:44px;touch-action:manipulation;';
+ previewToggleBtn.onclick = () => this._togglePreviewMode();
+ actionBar.appendChild(previewToggleBtn);
+
+ overlay.appendChild(actionBar);
+
+ this._overlay = overlay;
+ return overlay;
+ }
+
+ _openOverlay() {
+ if (this._overlay) {
+ this._overlay.style.display = 'flex';
+ this._refreshFileList();
+ return;
+ }
+ const overlay = this._createOverlay();
+ document.body.appendChild(overlay);
+ this._navigateTo(this.currentPath);
+ }
+
+ _closeOverlay() {
+ if (this._overlay) {
+ this._overlay.style.display = 'none';
+ }
+ }
+
+ _clearOverlay() {
+ if (this._overlay && this._overlay.parentNode) {
+ this._overlay.parentNode.removeChild(this._overlay);
+ }
+ this._overlay = null;
+ this._selectedFile = null;
+ this._searchFilter = '';
+ this._rawMode = false;
+ }
+
+ _navigateTo(pathParts) {
+ this.currentPath = pathParts || [];
+ this._selectedFile = null;
+ this._searchFilter = '';
+ const searchInput = this._overlay?.querySelector('input[type="text"]');
+ if (searchInput) searchInput.value = '';
+ this._updateBreadcrumbs();
+ this._refreshFileList();
+ this._clearPreview();
+ this._updateStatus('No file selected');
+ }
+
+ _updateBreadcrumbs() {
+ const breadcrumbs = document.getElementById('filemgr-breadcrumbs');
+ if (!breadcrumbs) return;
+
+ // Keep root segment, remove rest
+ while (breadcrumbs.children.length > 1) {
+ breadcrumbs.removeChild(breadcrumbs.lastChild);
+ }
+
+ let accumulated = '';
+ this.currentPath.forEach((part, i) => {
+ const sep = document.createElement('span');
+ sep.textContent = ' › ';
+ sep.style.cssText = 'color:var(--text-muted);margin:0 2px;';
+ breadcrumbs.appendChild(sep);
+
+ const seg = document.createElement('span');
+ seg.textContent = part;
+ seg.style.cssText = 'cursor:pointer;padding:2px 4px;border-radius:3px;color:var(--text-color);';
+ if (i === this.currentPath.length - 1) {
+ seg.style.color = 'var(--accent-color)';
+ seg.style.fontWeight = 'bold';
+ }
+ seg.onmouseenter = () => { seg.style.background = 'var(--bg-tertiary)'; };
+ seg.onmouseleave = () => { seg.style.background = 'transparent'; };
+ const pathUpTo = this.currentPath.slice(0, i + 1);
+ seg.onclick = () => this._navigateTo(pathUpTo);
+ breadcrumbs.appendChild(seg);
+ });
+ }
+
+ _refreshFileList() {
+ const sidebar = document.getElementById('filemgr-sidebar');
+ if (!sidebar) return;
+
+ // Read directory
+ const pathStr = '/' + this.currentPath.join('/');
+ const entries = this.terminal.fs.ls(pathStr) || [];
+
+ // Separate dirs and files
+ const dirs = entries.filter(e => e.type === 'dir');
+ const files = entries.filter(e => e.type !== 'dir');
+
+ // Sort alphabetically
+ dirs.sort((a, b) => a.name.localeCompare(b.name));
+ files.sort((a, b) => a.name.localeCompare(b.name));
+
+ // Apply search filter
+ let filteredDirs = dirs;
+ let filteredFiles = files;
+ if (this._searchFilter) {
+ const q = this._searchFilter;
+ filteredDirs = dirs.filter(e => e.name.toLowerCase().includes(q));
+ filteredFiles = files.filter(e => e.name.toLowerCase().includes(q));
+ }
+
+ sidebar.innerHTML = '';
+
+ if (filteredDirs.length === 0 && filteredFiles.length === 0) {
+ const emptyMsg = document.createElement('div');
+ emptyMsg.style.cssText = 'padding:20px;text-align:center;color:var(--text-muted);';
+ emptyMsg.textContent = this._searchFilter ? 'No matching files' : '(empty)';
+ sidebar.appendChild(emptyMsg);
+ this._updateStatus('0 files');
+ return;
+ }
+
+ // Add directory items
+ filteredDirs.forEach(entry => {
+ const item = document.createElement('div');
+ const fullPath = pathStr === '/' ? '/' + entry.name : pathStr + '/' + entry.name;
+ item.dataset.path = fullPath;
+ item.dataset.type = 'dir';
+ item.style.cssText = 'display:flex;align-items:center;padding:6px 10px;cursor:pointer;border-bottom:1px solid var(--border-color);gap:6px;';
+ item.onmouseenter = () => { if (!item.dataset.selected) item.style.background = 'var(--bg-tertiary)'; };
+ item.onmouseleave = () => { if (!item.dataset.selected) item.style.background = 'transparent'; };
+ item.onclick = () => {
+ const newPath = [...this.currentPath, entry.name];
+ this._navigateTo(newPath);
+ };
+
+ const icon = document.createElement('span');
+ icon.textContent = '📁';
+ item.appendChild(icon);
+
+ const nameSpan = document.createElement('span');
+ nameSpan.textContent = entry.name + '/';
+ nameSpan.style.cssText = 'flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
+ item.appendChild(nameSpan);
+
+ sidebar.appendChild(item);
+ });
+
+ // Add file items
+ filteredFiles.forEach(entry => {
+ const item = document.createElement('div');
+ const fullPath = pathStr === '/' ? '/' + entry.name : pathStr + '/' + entry.name;
+ const isSelected = this._selectedFile === fullPath;
+ item.dataset.path = fullPath;
+ item.dataset.type = 'file';
+ item.dataset.selected = isSelected ? 'true' : 'false';
+ const bgColor = isSelected ? 'var(--bg-tertiary)' : 'transparent';
+ item.style.cssText = 'display:flex;align-items:center;padding:6px 10px;cursor:pointer;border-bottom:1px solid var(--border-color);gap:6px;background:' + bgColor + ';';
+ if (isSelected) {
+ item.style.borderLeft = '3px solid var(--accent-color)';
+ }
+ item.onmouseenter = () => { if (!item.dataset.selected) item.style.background = 'var(--bg-tertiary)'; };
+ item.onmouseleave = () => { if (!item.dataset.selected) item.style.background = 'transparent'; };
+ item.onclick = () => {
+ this._selectedFile = fullPath;
+ this._highlightSelected();
+ const content = this.terminal.fs.readFile(fullPath);
+ this._renderPreview(fullPath, content);
+ };
+
+ const icon = document.createElement('span');
+ icon.textContent = '📄';
+ item.appendChild(icon);
+
+ const nameSpan = document.createElement('span');
+ nameSpan.textContent = entry.name;
+ nameSpan.style.cssText = 'flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
+ item.appendChild(nameSpan);
+
+ const sizeSpan = document.createElement('span');
+ sizeSpan.textContent = this._formatSize(entry.size);
+ sizeSpan.style.cssText = 'color:var(--text-muted);font-size:0.8em;white-space:nowrap;margin-left:4px;';
+ item.appendChild(sizeSpan);
+
+ sidebar.appendChild(item);
+ });
+
+ const totalCount = entries.length;
+ this._updateStatus(totalCount + ' item' + (totalCount !== 1 ? 's' : ''));
+ }
+
+ _highlightSelected() {
+ const sidebar = document.getElementById('filemgr-sidebar');
+ if (!sidebar) return;
+ const items = sidebar.querySelectorAll('[data-type="file"]');
+ items.forEach(item => {
+ const selected = item.dataset.path === this._selectedFile;
+ item.dataset.selected = selected ? 'true' : 'false';
+ item.style.background = selected ? 'var(--bg-tertiary)' : 'transparent';
+ item.style.borderLeft = selected ? '3px solid var(--accent-color)' : '3px solid transparent';
+ });
+ }
+
+ _renderPreview(path, content) {
+ const preview = document.getElementById('filemgr-preview');
+ if (!preview) return;
+
+ // Re-read content on each preview call
+ if (content === undefined) {
+ content = this.terminal.fs.readFile(path);
+ }
+
+ // For raw mode, show raw text regardless of type
+ if (this._rawMode && typeof content === 'string') {
+ preview.innerHTML = '<div style="padding:12px"><div style="color:var(--text-muted);font-size:0.85em;margin-bottom:8px">RAW: ' + this._escapeHtml(path) + '</div><pre style="white-space:pre-wrap;font-family:var(--font-primary);font-size:0.9em;margin:0;overflow-x:auto">' + this._escapeHtml(content.substring(0, 10000)) + '</pre></div>';
+ this._updateStatus('Preview: ' + path);
+ return;
+ }
+
+ // Check file size for text files
+ const type = this._detectType(path);
+ const textTypes = ['md', 'text', 'json', 'code', 'csv', 'other'];
+ if (textTypes.includes(type) && typeof content === 'string' && content.length > 51200) {
+ content = content.substring(0, 51200);
+ preview.innerHTML = this._getPreviewHtml(path, content);
+ const truncatedMsg = document.createElement('div');
+ truncatedMsg.style.cssText = 'color:var(--warning-color);padding:8px 12px;border-top:1px solid var(--border-color);font-size:0.85em;';
+ truncatedMsg.textContent = '(truncated — file exceeds 50KB)';
+ preview.appendChild(truncatedMsg);
+ this._updateStatus('Preview: ' + path);
+ return;
+ }
+
+ preview.innerHTML = this._getPreviewHtml(path, content);
+ this._updateStatus('Preview: ' + path);
+ }
+
+ _renderPreviewError(msg) {
+ const preview = document.getElementById('filemgr-preview');
+ if (!preview) return;
+ preview.innerHTML = '<div style="color:var(--error-color);padding:20px;font-size:1em">' + this._escapeHtml(msg) + '</div>';
+ this._updateStatus('Error');
+ }
+
+ _clearPreview() {
+ const preview = document.getElementById('filemgr-preview');
+ if (preview) preview.innerHTML = '';
+ }
+
+ _updateStatus(msg) {
+ const status = document.getElementById('filemgr-status');
+ if (status) status.textContent = msg;
+ }
+
+ _actionDelete() {
+ if (!this._selectedFile) {
+ this._updateStatus('No file selected to delete');
+ return;
+ }
+ if (!confirm('Delete ' + this._selectedFile + '?')) return;
+ const ok = this.terminal.fs.deleteFile(this._selectedFile);
+ if (ok) {
+ this._selectedFile = null;
+ this._refreshFileList();
+ this._clearPreview();
+ this._updateStatus('✅ Deleted');
+ } else {
+ this._updateStatus('❌ Failed to delete');
+ }
+ }
+
+ _actionDownload() {
+ if (!this._selectedFile) {
+ this._updateStatus('No file selected to download');
+ return;
+ }
+ const content = this.terminal.fs.readFile(this._selectedFile);
+ if (content === null || content === false) {
+ this._updateStatus('❌ File not found');
+ return;
+ }
+ const fileName = this._selectedFile.split('/').pop() || 'file';
+ const blob = new Blob([content], { type: 'application/octet-stream' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = fileName;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ this._updateStatus('⬇ Downloaded: ' + fileName);
+ }
+
+ _actionEdit() {
+ if (!this._selectedFile) {
+ this._updateStatus('No file selected to edit');
+ return;
+ }
+ if (typeof webx11 !== 'undefined' && webx11 && webx11.executeCommand) {
+ webx11.executeCommand('edit ' + this._selectedFile);
+ this._updateStatus('✏ Opened in editor: ' + this._selectedFile);
+ } else {
+ this._updateStatus('❌ Editor not available');
+ }
+ }
+
+ _togglePreviewMode() {
+ this._rawMode = !this._rawMode;
+ const btn = document.getElementById('filemgr-toggle-raw');
+ if (btn) {
+ btn.textContent = this._rawMode ? '📄 Rendered' : '📄 Raw';
+ btn.style.borderColor = this._rawMode ? 'var(--accent-color)' : 'var(--border-color)';
+ btn.style.color = this._rawMode ? 'var(--accent-color)' : 'var(--text-muted)';
+ }
+ if (this._selectedFile) {
+ const content = this.terminal.fs.readFile(this._selectedFile);
+ this._renderPreview(this._selectedFile, content);
+ }
+ }
+}
+
+if (typeof window.webx11 !== 'undefined' && window.webx11) {
+ webx11.moduleManager.registerModule(new FileManagerModule(), 'https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/ext/filemgr.js');
+}
diff --git a/modules/ext/finder.js b/modules/ext/finder.js
new file mode 100644
index 0000000..1ca8cf7
--- /dev/null
+++ b/modules/ext/finder.js
@@ -0,0 +1,47 @@
+class FinderModule {
+ constructor() {
+ this.commands = {};
+ this.terminal = null;
+ }
+
+ requirements() {
+ return { met: true, missing: [] };
+ }
+
+ getHelpInfo() {
+ return {
+ 'find': 'Recursively search VFS files/directories by name (usage: find <query>)',
+ };
+ }
+
+ registerCommands() {
+ this.commands['find'] = (args) => this.find(args);
+ }
+ find(args) {
+ if (!args[0]) return this.terminal.print('Usage: find <query> — recursively searches VFS files/dirs matching query', 'warning');
+ const query = args.join(' ').toLowerCase();
+ const results = [];
+ this._recurse(this.terminal.fs.root, [], query, results);
+ if (results.length === 0) return this.terminal.print(`No matches for "${query}"`, 'warning');
+ this.terminal.print(`=== Found ${results.length} match(es) for "${query}" ===`, 'info');
+ results.forEach(r => {
+ const icon = r.type === 'dir' ? '📁' : '📄';
+ this.terminal.print(` ${icon} ${r.path}`);
+ });
+ }
+ _recurse(node, pathParts, query, results) {
+ if (!node || !node.content) return;
+ for (const [name, child] of Object.entries(node.content)) {
+ const fullPath = '/' + [...pathParts, name].join('/');
+ if (name.toLowerCase().includes(query)) {
+ results.push({ name, path: fullPath, type: child.type || (child.content ? 'dir' : 'file') });
+ }
+ if (child.content && typeof child.content === 'object') {
+ this._recurse(child, [...pathParts, name], query, results);
+ }
+ }
+ }
+}
+if (typeof window.webx11 !== 'undefined' && window.webx11) {
+ webx11.moduleManager.registerModule(new FinderModule(), 'https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/finder.js');
+}
diff --git a/modules/ext/mindmap.js b/modules/ext/mindmap.js
new file mode 100644
index 0000000..012bae7
--- /dev/null
+++ b/modules/ext/mindmap.js
@@ -0,0 +1,217 @@
+class MindmapModule {
+ constructor() {
+ this.commands = {};
+ this.terminal = null;
+ this.root = null;
+ this.colors = ['red', 'green', 'yellow', 'blue', 'cyan', 'magenta', 'white'];
+ this._colorIdx = 0;
+ }
+ getHelpInfo() {
+ return { 'mindmap': 'mindmap <new|add|show|export|save|load|list|delete|rename|color|tag> [...] — Create and manage interactive mindmaps' };
+ }
+ _nextColor() {
+ const c = this.colors[this._colorIdx % this.colors.length];
+ this._colorIdx++;
+ return c;
+ }
+ _genId() {
+ return Math.random().toString(36).slice(2, 6);
+ }
+ _findNode(id, node) {
+ if (node.id === id) return node;
+ for (const child of (node.children || [])) {
+ const found = this._findNode(id, child);
+ if (found) return found;
+ }
+ return null;
+ }
+ _treeLines(node, prefix, isLast) {
+ const lines = [];
+ const connector = isLast ? '\u2514\u2500\u2500 ' : '\u251c\u2500\u2500 ';
+ const tagStr = (node.tags && node.tags.length) ? ' [' + node.tags.join(', ') + ']' : '';
+ const colorTag = node.color ? '[' + node.color + ']' : '';
+ lines.push(prefix + connector + colorTag + node.text + ' (' + node.id + ')' + tagStr);
+ if (node.children) {
+ const sorted = node.children;
+ sorted.forEach((child, i) => {
+ const subPrefix = prefix + (isLast ? ' ' : '\u2502 ');
+ this._treeLines(child, subPrefix, i === sorted.length - 1).forEach(l => lines.push(l));
+ });
+ }
+ return lines;
+ }
+ registerCommands() {
+ const self = this;
+ this.commands['mindmap'] = (args) => {
+ if (!args[0]) return this.terminal.print('Usage: mindmap <new|add|show|export|save|load|list|delete|rename|color|tag> [...]', 'warning');
+ const sub = args[0];
+ const rest = args.slice(1);
+ switch (sub) {
+ case 'new': return this._new(rest);
+ case 'add': return this._add(rest);
+ case 'show': return this._show(rest);
+ case 'export': return this._export(rest);
+ case 'save': return this._save(rest);
+ case 'load': return this._load(rest);
+ case 'list': return this._list();
+ case 'delete': return this._delete(rest);
+ case 'rename': return this._rename(rest);
+ case 'color': return this._color(rest);
+ case 'tag': return this._tag(rest);
+ default: this.terminal.print('Unknown subcommand: ' + sub + '. Try: new, add, show, export, save, load, list, delete, rename, color, tag', 'warning');
+ }
+ };
+ }
+ _parseTags(args) {
+ const tagIdx = args.indexOf('-t');
+ let tags = [];
+ if (tagIdx >= 0) {
+ tags = args[tagIdx + 1] ? args[tagIdx + 1].split(',').map(t => t.trim()).filter(Boolean) : [];
+ args.splice(tagIdx, 2);
+ }
+ return { args, tags };
+ }
+ _new(args) {
+ const { args: clean, tags } = this._parseTags([...args]);
+ if (!clean.length) return this.terminal.print('Usage: mindmap new <topic> [-t tag1,tag2]', 'warning');
+ if (this.root) return this.terminal.print('A mindmap already exists. Use mindmap add to extend it.', 'warning');
+ this.root = {
+ id: this._genId(),
+ text: clean.join(' '),
+ color: this._nextColor(),
+ tags: tags,
+ children: []
+ };
+ this.terminal.print('Created root: [' + this.root.color + '] ' + this.root.text + ' (' + this.root.id + ')', 'success');
+ }
+ _add(args) {
+ const { args: clean, tags } = this._parseTags([...args]);
+ if (clean.length < 2) return this.terminal.print('Usage: mindmap add <parent-id> <text> [-t tag1,tag2]', 'warning');
+ if (!this.root) return this.terminal.print('No mindmap. Create one with mindmap new first.', 'warning');
+ const parentId = clean[0];
+ const text = clean.slice(1).join(' ');
+ const parent = this._findNode(parentId, this.root);
+ if (!parent) return this.terminal.print('Node not found: ' + parentId, 'error');
+ const node = {
+ id: this._genId(),
+ text: text,
+ color: this._nextColor(),
+ tags: tags,
+ children: []
+ };
+ parent.children.push(node);
+ this.terminal.print('Added [' + node.color + '] ' + text + ' (' + node.id + ')', 'success');
+ }
+ _show(args) {
+ if (!this.root) return this.terminal.print('No mindmap. Create one with mindmap new first.', 'warning');
+ let node = this.root;
+ if (args.length && args[0]) {
+ const found = this._findNode(args[0], this.root);
+ if (!found) return this.terminal.print('Node not found: ' + args[0], 'error');
+ node = found;
+ }
+ const lines = this._treeLines(node, '', true);
+ lines.forEach(l => this.terminal.print(l));
+ this.terminal.print('(' + node.text + ' — ' + this._countNodes(node) + ' nodes total)', 'info');
+ }
+ _countNodes(node) {
+ let c = 1;
+ for (const child of (node.children || [])) c += this._countNodes(child);
+ return c;
+ }
+ _export(args) {
+ if (!this.root) return this.terminal.print('No mindmap. Create one with mindmap new first.', 'warning');
+ let node = this.root;
+ if (args.length && args[0]) {
+ const found = this._findNode(args[0], this.root);
+ if (!found) return this.terminal.print('Node not found: ' + args[0], 'error');
+ node = found;
+ }
+ const md = this._toMarkdown(node, 0);
+ this.terminal.print(md);
+ this.terminal.print('(Markdown copied to clipboard)', 'info');
+ if (navigator.clipboard) {
+ navigator.clipboard.writeText(md);
+ }
+ }
+ _toMarkdown(node, depth) {
+ const prefix = ' '.repeat(depth) + '- ';
+ const tagStr = (node.tags && node.tags.length) ? ' *[' + node.tags.join(', ') + ']*' : '';
+ let out = prefix + '**' + node.text + '**' + tagStr + '\n';
+ if (node.children) {
+ node.children.forEach(c => { out += this._toMarkdown(c, depth + 1); });
+ }
+ return out;
+ }
+ _save(args) {
+ if (!args[0]) return this.terminal.print('Usage: mindmap save <name>', 'warning');
+ if (!this.root) return this.terminal.print('No mindmap to save.', 'warning');
+ try {
+ localStorage.setItem('webx11_mindmap_' + args[0], JSON.stringify(this.root));
+ this.terminal.print('Mindmap saved as: ' + args[0], 'success');
+ } catch (e) {
+ this.terminal.print('Failed to save: ' + e.message, 'error');
+ }
+ }
+ _load(args) {
+ if (!args[0]) return this.terminal.print('Usage: mindmap load <name>', 'warning');
+ try {
+ const data = localStorage.getItem('webx11_mindmap_' + args[0]);
+ if (!data) return this.terminal.print('No saved mindmap: ' + args[0], 'error');
+ this.root = JSON.parse(data);
+ this.terminal.print('Loaded mindmap: ' + args[0] + ' (' + this._countNodes(this.root) + ' nodes)', 'success');
+ } catch (e) {
+ this.terminal.print('Failed to load: ' + e.message, 'error');
+ }
+ }
+ _list() {
+ const keys = [];
+ for (let i = 0; i < localStorage.length; i++) {
+ const k = localStorage.key(i);
+ if (k && k.startsWith('webx11_mindmap_')) keys.push(k.replace('webx11_mindmap_', ''));
+ }
+ if (!keys.length) return this.terminal.print('No saved mindmaps.', 'info');
+ this.terminal.print('=== Saved Mindmaps ===', 'info');
+ keys.forEach(k => this.terminal.print(' ' + k));
+ }
+ _delete(args) {
+ if (!args[0]) return this.terminal.print('Usage: mindmap delete <name>', 'warning');
+ localStorage.removeItem('webx11_mindmap_' + args[0]);
+ this.terminal.print('Deleted mindmap: ' + args[0], 'success');
+ }
+ _rename(args) {
+ if (args.length < 2) return this.terminal.print('Usage: mindmap rename <id> <text>', 'warning');
+ if (!this.root) return this.terminal.print('No mindmap.', 'warning');
+ const id = args[0];
+ const text = args.slice(1).join(' ');
+ const node = this._findNode(id, this.root);
+ if (!node) return this.terminal.print('Node not found: ' + id, 'error');
+ node.text = text;
+ this.terminal.print('Renamed ' + id + ' to: ' + text, 'success');
+ }
+ _color(args) {
+ if (args.length < 2) return this.terminal.print('Usage: mindmap color <id> <color>', 'warning');
+ if (!this.root) return this.terminal.print('No mindmap.', 'warning');
+ const id = args[0];
+ const color = args[1].toLowerCase();
+ if (!this.colors.includes(color)) return this.terminal.print('Available colors: ' + this.colors.join(', '), 'warning');
+ const node = this._findNode(id, this.root);
+ if (!node) return this.terminal.print('Node not found: ' + id, 'error');
+ node.color = color;
+ this.terminal.print('Set color for ' + id + ' to ' + color, 'success');
+ }
+ _tag(args) {
+ if (args.length < 2) return this.terminal.print('Usage: mindmap tag <id> <tag>', 'warning');
+ if (!this.root) return this.terminal.print('No mindmap.', 'warning');
+ const id = args[0];
+ const tag = args.slice(1).join(' ');
+ const node = this._findNode(id, this.root);
+ if (!node) return this.terminal.print('Node not found: ' + id, 'error');
+ if (!node.tags) node.tags = [];
+ node.tags.push(tag);
+ this.terminal.print('Added tag "' + tag + '" to ' + id, 'success');
+ }
+}
+if (typeof window.webx11 !== 'undefined' && window.webx11) {
+ webx11.moduleManager.registerModule(new MindmapModule(), 'https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/mindmap.js');
+}
diff --git a/modules/ext/skill-pack-core.js b/modules/ext/skill-pack-core.js
new file mode 100644
index 0000000..95da385
--- /dev/null
+++ b/modules/ext/skill-pack-core.js
@@ -0,0 +1,285 @@
+class SkillPackCoreModule {
+ constructor() {
+ this.commands = {};
+ this.terminal = null;
+ this._seeded = false;
+ this._skills = this._buildSkills();
+ }
+
+ requirements() {
+ try {
+ const fs = this.terminal && this.terminal.fs;
+ if (fs && fs.readFile('/skills/_seeded')) {
+ return { met: true, missing: [] };
+ }
+ return { met: false, missing: ['Skills directory not initialized. Will be created on first use.'] };
+ } catch (e) {
+ return { met: true, missing: [] };
+ }
+ }
+
+ getHelpInfo() {
+ return {
+ 'skill-pack-core': 'Seed and manage built-in core skills (navigation, memory, search, modules, backup, mindmap, etc.)',
+ };
+ }
+
+ _buildSkills() {
+ const s = (name, desc, tags, cat, steps) => ({
+ name, description: desc, tags, category: cat,
+ content: `---\nname: ${name}\ndescription: ${desc}\ntags: [${tags.join(', ')}]\ncategory: ${cat}\n---\n## Steps\n${steps}`
+ });
+
+ return [
+ s('navigating-the-terminal', 'How to explore the WebX11 terminal, find commands, check status, and navigate the filesystem', ['terminal', 'navigation', 'help', 'filesystem'], 'system',
+`1. **Get an overview of available commands:**
+ - Call get_help() to get a full command list sorted by category
+ - Call get_status() to see filesystem state, loaded modules, Ollama status
+ - For a specific command, use help <command> or check the help map
+
+2. **Explore the filesystem:**
+ - Use list_files(path?) to browse directories (omit path for current dir)
+ - Use read_file(path) to view file contents
+ - Use get_current_dir to see your working directory
+ - Use run_command("ls") or run_command("tree") for visual output
+
+3. **Understand system state:**
+ - run_command("mem") — memory usage
+ - run_command("status") — network, files, modules, Ollama
+ - run_command("modules") — module manager GUI
+ - get_module_status() — loaded modules, URLs, local edits
+
+4. **When user asks a vague question:**
+ - First call get_status() and get_help() to understand the current state
+ - Then ask clarifying questions based on what you found`),
+
+ s('memory-and-profile', 'How to use the user profile system, session memory, and pattern tracking for persistent context', ['memory', 'profile', 'session', 'persistence'], 'system',
+`1. **User Profile (persists across sessions):**
+ - user_get(key) — read any profile field (e.g. "preferredModel", "fontSize")
+ - user_set(key, value) — write to profile, auto-persists to localStorage
+ - user_list — show all stored profile keys
+ - user_forget(key) — delete a profile key
+ - Dot notation supported: user_set knownFacts.name "Alex"
+
+2. **Session Memory (VFS-based daily logs):**
+ - session_save(summary) — save a summary of what was accomplished
+ - session_load(date) — read a specific day's log (format: YYYY-MM-DD)
+ - session_recent(count) — show last N session summaries
+ - session_stats — aggregate totals across all sessions
+ - Recent sessions auto-load into context on first message
+
+3. **Conversation Patterns:**
+ - Pattern tracking runs automatically — frequent topics surface as hints
+ - my-set, my-get, my-list store additional personal data
+ - AI conversation context persists within a session (use ai-clear to reset)
+
+4. **When to use:**
+ - At start: call session_recent(3) to see what was worked on before
+ - After identifying user preferences: call user_set to store them
+ - After complex tasks: call session_save with a brief summary`),
+
+ s('web-search-and-fetch', 'How to search the web and fetch URL content using the WebX11 proxy API', ['search', 'fetch', 'web', 'proxy', 'api'], 'research',
+`1. **Search the web:**
+ - Call web_search(query) — uses /api/search which queries SearXNG + DDG fallback
+ - Results include: title, url, content snippet, source engine
+ - Always available (same-origin, no CORS issues)
+ - 5-minute server-side cache for repeated queries
+
+2. **Fetch URL content:**
+ - Call web_fetch(url) — uses /api/extract which strips HTML, extracts clean text
+ - Returns: title, content (up to 4000 chars), word_count, extraction method
+ - SSRF protection blocks internal network addresses
+ - 15-minute server-side cache
+
+3. **Search flags (via terminal search command):**
+ - search <query> --summarize — adds AI-generated overview via Ollama
+ - search <query> --fetch — fetches content snippets for top 3 results
+
+4. **When to use:**
+ - User asks about current events, facts, research → call web_search
+ - User says "read this article" or provides a URL → call web_fetch
+ - For in-depth analysis: search first, then fetch top results`),
+
+ s('module-management', 'How to view, edit, test, reload modules and manage startup configuration', ['modules', 'editing', 'code', 'self-improvement'], 'dev',
+`1. **View module source:**
+ - view_module(name) — fetches original source or reads from _mods/ if edited
+ - Supports all loaded modules: SystemModule, FileSystemModule, SearXNGModule, etc.
+
+2. **Edit a module:**
+ - edit_module(name) — copies source to _mods/<name>.js and opens code editor
+ - Editor shows the full source in a textarea
+ - Click "Save & Test" to save and auto-load the edited version
+
+3. **Test and reload:**
+ - test_module(name) — loads edited module from _mods/ for live testing
+ - reload_module(name) — discards edits and reloads from original URL
+ - list_modified_modules — shows all modules with pending edits
+
+4. **Check for updates:**
+ - check_updates — HEAD-requests all loaded module URLs to verify reachability
+
+5. **Manage startup:**
+ - startup-modules — opens the Startup Modules UI to select which modules load
+ - load-local <path> — load a module from VFS or URL without editing
+
+6. **When to use:**
+ - User reports a bug in a module → view_module, edit_module, test_module
+ - User asks "can you add X feature?" → same workflow
+ - Always save edits before testing. If test fails, the original module stays loaded.`),
+
+ s('system-maintenance', 'How to reset, troubleshoot, and maintain the WebX11 terminal', ['system', 'maintenance', 'reset', 'troubleshoot', 'config'], 'system',
+`1. **Factory reset (wipes everything):**
+ - factory-reset --confirm — clears all webx11_ localStorage keys, resets VFS, reloads
+ - Use only when the system is in an unrecoverable state
+
+2. **Soft VFS reset:**
+ - reset-vfs --confirm — clears only the filesystem, preserves settings/profile/modules
+
+3. **Config management:**
+ - config-list — show all config keys and values
+ - config-set <key> <value> — set a config value
+ - config-delete <key> — remove a config key
+ - config-export — dump all config as JSON
+ - config-reset — restore defaults (use before factory-reset if you want to save data)
+
+4. **Health checks (run automatically after complex tasks):**
+ - Check module loading status: loaded vs total
+ - Check for pending module edits
+ - Check memory usage (high if >100MB)
+ - Check skill count (0 skills after 2+ tool calls → suggest create_skill)
+
+5. **Font size and display:**
+ - font-size <8-32> — set terminal font size, persists across sessions
+ - setfont — alias for font-size
+
+6. **When to use:**
+ - System is slow or modules fail to load → check check_updates, memory
+ - User says "reset everything" → factory-reset --confirm
+ - User says "clear my files but keep settings" → reset-vfs --confirm`),
+
+ s('backup-and-restore', 'How to backup, restore, and export WebX11 data', ['backup', 'restore', 'export', 'import', 'persistence'], 'system',
+`1. **Backup VFS:**
+ - vfs-export — downloads the entire virtual filesystem as a dated JSON file
+ - backup — creates a full backup including VFS, modules, config (saves to _backups/)
+
+2. **Export/Import personal data:**
+ - my-export — prints all my-set data as formatted JSON
+ - my-import <json> — bulk-import personal data from JSON string
+
+3. **Export/Import modules:**
+ - export-mods — exports module registry as JSON
+ - import-mods <json> — imports modules from JSON backup
+
+4. **Restore:**
+ - restore <json> — restores terminal state from a backup JSON
+ - Session logs are saved to /memory/ automatically on page unload
+
+5. **When to use:**
+ - Before factory-reset: call my-export, vfs-export, export-mods to save data
+ - When switching browsers or devices: export all, then import
+ - User says "backup my data" or "save my files"`),
+
+ s('mindmap-workflow', 'How to create, view, edit, and manage brainstorming mindmaps', ['mindmap', 'brainstorming', 'planning', 'organization'], 'productivity',
+`1. **Create a new mindmap:**
+ - mindmap new "<topic>" -t tag1,tag2 — creates a root node
+ - Tags are optional (e.g. planning, research, dev)
+ - Root gets auto-assigned a color from 7-color palette
+
+2. **Add child nodes:**
+ - mindmap add <parent-id> "<idea>" -t tag — add a child
+ - Get the parent-id from mindmap show
+ - IDs are 4-char random strings (e.g. a3f8, x7k2)
+
+3. **View:**
+ - mindmap show — full tree with Unicode box-drawing
+ - mindmap show <id> — subtree starting at that node
+
+4. **Edit nodes:**
+ - mindmap rename <id> "<new text>" — change text
+ - mindmap color <id> <color> — set color (red/green/yellow/blue/cyan/magenta/white)
+ - mindmap tag <id> <tag> — add a tag
+
+5. **Persistence:**
+ - mindmap save <name> — save to localStorage
+ - mindmap load <name> — restore
+ - mindmap list — show saved mindmaps
+ - mindmap delete <name> — remove saved
+
+6. **Export:**
+ - mindmap export [id] — Markdown bullet list output, auto-copies to clipboard
+
+7. **When to use:**
+ - Map out project structure, brainstorm features, organize ideas
+ - Break down complex tasks into subtrees
+ - User says "brainstorm", "diagram", "organize thoughts", "plan"`),
+
+ s('skills-system-guide', 'How to use the skills system: load, create, patch, list, and audit skills for persistent procedural memory', ['skills', 'system', 'meta'], 'system',
+`1. **Always load skills at task start:**
+ - At the START of every task, call load_skills(query) to check for stored procedures
+ - Example: load_skills("search the web") returns matching skill content
+ - Example: load_skills("edit module") returns the module editing workflow
+
+2. **Create skills after complex tasks:**
+ - After any task with 3+ tool calls, call create_skill to save the approach
+ - Required fields: name (kebab-case), description, content (markdown with YAML frontmatter)
+ - Optional: tags (comma-separated), category (e.g. research, system, dev)
+ - Skills persist in /skills/ as .md files with /skills/index.json for fast lookup
+
+3. **Patch skills when they're outdated:**
+ - If a stored procedure has wrong steps, call patch_skill(name, newContent)
+ - This replaces the entire skill content and updates the index
+ - Do NOT create a duplicate — always patch the existing skill
+
+4. **Auto-created error skills:**
+ - If the same tool fails 3+ times, a workaround skill is created automatically
+ - Named tool-<name>-workaround (e.g. tool-write_file-workaround)
+
+5. **Audit and maintenance:**
+ - list_skills(category?) — browse available skills, optionally filter
+ - Skills are audited every 20 messages for staleness (empty content, >30 days old)
+
+6. **YAML frontmatter format:**
+ - Each skill file must start with: ---\\nname: <name>\\ndescription: <text>\\ntags: [...]\\ncategory: <cat>\\n---\\n
+ - Steps follow under ## Steps heading
+ - Use numbered lists for sequential steps`),
+ ];
+ }
+ registerCommands() {
+ this.commands['skill-pack-core'] = () => {
+ const count = this._skills.length;
+ this.terminal.print(`Skill Pack: Core — ${count} skills available`, 'info');
+ this.terminal.print('Skills will auto-seed on load', 'info');
+ };
+ }
+ init() {
+ this.seed();
+ }
+ async seed() {
+ console.log('[SkillPackCore] seed() called, _seeded=' + this._seeded);
+ if (this._seeded) return;
+ const fs = this.terminal.fs;
+ if (!fs) { console.warn('[SkillPackCore] this.terminal.fs not available'); return; }
+ fs.mkdirRecursive('/skills');
+ let index = fs.fsReadJSON('/skills/index.json') || {};
+ console.log('[SkillPackCore] existing index keys:', Object.keys(index));
+ let seeded = 0;
+ for (const skill of this._skills) {
+ if (!index[skill.name]) {
+ fs.fsWrite(`/skills/${skill.name}.md`, skill.content);
+ index[skill.name] = {
+ name: skill.name,
+ description: skill.description,
+ tags: skill.tags,
+ category: skill.category,
+ created: new Date().toISOString(),
+ updated: new Date().toISOString()
+ };
+ seeded++;
+ }
+ }
+ if (seeded > 0) {
+ fs.fsWriteJSON('/skills/index.json', index);
+ }
+ this._seeded = true;
+ }
+}
diff --git a/modules/ext/theme.js b/modules/ext/theme.js
new file mode 100644
index 0000000..9a1dab5
--- /dev/null
+++ b/modules/ext/theme.js
@@ -0,0 +1,172 @@
+class ThemeModule {
+ constructor() {
+ this.commands = {};
+ this.terminal = null;
+ this.key = 'webx11_themes';
+ this._previewTimer = null;
+ this.defaults = {
+ 'dark': { '--bg-color': '#000000', '--bg-secondary': '#0a0a0a', '--bg-tertiary': '#111111', '--text-color': '#00ff00', '--text-muted': '#888888', '--accent-color': '#00aa00', '--border-color': '#005500' },
+ 'light': { '--bg-color': '#ffffff', '--bg-secondary': '#f5f5f5', '--bg-tertiary': '#eeeeee', '--text-color': '#222222', '--text-muted': '#888888', '--accent-color': '#0066cc', '--border-color': '#cccccc' },
+ 'amber': { '--bg-color': '#1a0f00', '--bg-secondary': '#2a1a00', '--bg-tertiary': '#332200', '--text-color': '#ffaa00', '--text-muted': '#886600', '--accent-color': '#ff8800', '--border-color': '#663300' },
+ 'cyber': { '--bg-color': '#000a14', '--bg-secondary': '#001a2e', '--bg-tertiary': '#002240', '--text-color': '#00ffff', '--text-muted': '#0088aa', '--accent-color': '#ff00ff', '--border-color': '#004466' },
+ 'ocean': { '--bg-color': '#0a1628', '--bg-secondary': '#0f2240', '--bg-tertiary': '#142e54', '--text-color': '#88ddff', '--text-muted': '#5599bb', '--accent-color': '#00aaff', '--border-color': '#1a4470' },
+ 'forest': { '--bg-color': '#0a1a0e', '--bg-secondary': '#0f2814', '--bg-tertiary': '#14361c', '--text-color': '#88dd88', '--text-muted': '#559955', '--accent-color': '#33cc33', '--border-color': '#1a4a22' },
+ 'monochrome': { '--bg-color': '#0a0a0a', '--bg-secondary': '#181818', '--bg-tertiary': '#222222', '--text-color': '#cccccc', '--text-muted': '#666666', '--accent-color': '#999999', '--border-color': '#333333' },
+ 'sunset': { '--bg-color': '#1a0a14', '--bg-secondary': '#2a1220', '--bg-tertiary': '#3a1a2c', '--text-color': '#ffbb99', '--text-muted': '#cc7755', '--accent-color': '#ff6644', '--border-color': '#552233' },
+ 'midnight': { '--bg-color': '#050510', '--bg-secondary': '#0a0a20', '--bg-tertiary': '#101030', '--text-color': '#aaaaee', '--text-muted': '#555588', '--accent-color': '#6666ff', '--border-color': '#1a1a3a' }
+ };
+ this.themes = this.load();
+ }
+ requirements() {
+ return { met: true, missing: [] };
+ }
+ getHelpInfo() {
+ return { 'theme': 'theme <set|list|new|delete|export|preview|keep> [...] — Manage and preview visual themes' };
+ }
+ load() {
+ try {
+ const data = localStorage.getItem(this.key);
+ return data ? { ...this.defaults, ...JSON.parse(data) } : { ...this.defaults };
+ } catch (e) { return { ...this.defaults }; }
+ }
+ save() {
+ const custom = {};
+ Object.keys(this.themes).forEach(k => {
+ if (!this.defaults[k]) custom[k] = this.themes[k];
+ });
+ localStorage.setItem(this.key, JSON.stringify(custom));
+ }
+ registerCommands() {
+ this.commands['theme'] = (args) => this.handle(args);
+ }
+ handle(args) {
+ if (!args[0]) return this.terminal.print('Usage: theme set <name>, theme list, theme new, theme delete <name>, theme export <name>, theme preview <name>, theme keep', 'warning');
+ const sub = args[0].toLowerCase();
+ if (sub === 'set') this.setTheme(args[1]);
+ else if (sub === 'list') this.listThemes();
+ else if (sub === 'new') this.newTheme(args.slice(1));
+ else if (sub === 'delete') this.deleteTheme(args[1]);
+ else if (sub === 'export') this.exportTheme(args[1]);
+ else if (sub === 'preview') this.previewTheme(args[1]);
+ else if (sub === 'keep') this.keepPreview();
+ else this.terminal.print(`Unknown: ${sub}`, 'error');
+ }
+ _applyVars(name) {
+ if (!this.themes[name]) return false;
+ const vars = this.themes[name];
+ const root = document.documentElement;
+ Object.entries(vars).forEach(([key, val]) => root.style.setProperty(key, val));
+ return true;
+ }
+ setTheme(name) {
+ if (!name) return this.terminal.print('Usage: theme set <name>', 'warning');
+ if (!this.themes[name]) return this.terminal.print(`Theme '${name}' not found`, 'error');
+ // Cancel any active preview
+ if (this._previewTimer) {
+ clearTimeout(this._previewTimer);
+ this._previewTimer = null;
+ }
+ this._applyVars(name);
+ localStorage.setItem('webx11_active_theme', name);
+ this.terminal.print(`Theme set to: ${name}`, 'success');
+ }
+ previewTheme(name) {
+ if (!name) return this.terminal.print('Usage: theme preview <name>', 'warning');
+ if (!this.themes[name]) return this.terminal.print(`Theme '${name}' not found`, 'error');
+ // Cancel any existing preview
+ if (this._previewTimer) {
+ clearTimeout(this._previewTimer);
+ this._previewTimer = null;
+ }
+ const previous = localStorage.getItem('webx11_active_theme') || 'dark';
+ this._applyVars(name);
+ this.terminal.print(`🎨 Previewing: ${name} — will revert in 5s`, 'info');
+ this.terminal.print(' Type "theme keep" to keep this theme permanently', 'info');
+ this._previewTimer = setTimeout(() => {
+ this._previewTimer = null;
+ this._applyVars(previous);
+ this.terminal.print(`⏱ Preview ended — reverted to: ${previous}`, 'info');
+ }, 5000);
+ }
+ keepPreview() {
+ if (!this._previewTimer) return this.terminal.print('No active preview to keep', 'warning');
+ clearTimeout(this._previewTimer);
+ this._previewTimer = null;
+ const root = document.documentElement;
+ const cssVars = ['--bg-color', '--bg-secondary', '--bg-tertiary', '--text-color', '--text-muted', '--accent-color', '--border-color'];
+ const currentVars = {};
+ cssVars.forEach(v => { currentVars[v] = getComputedStyle(root).getPropertyValue(v).trim() || '#000'; });
+ let matchName = null;
+ for (const [name, vars] of Object.entries(this.themes)) {
+ const match = cssVars.every(v => vars[v] === currentVars[v]);
+ if (match) { matchName = name; break; }
+ }
+ if (matchName) {
+ localStorage.setItem('webx11_active_theme', matchName);
+ this.terminal.print(`✅ Preview kept — theme saved as: ${matchName}`, 'success');
+ } else {
+ // Custom vars — save current state as active
+ const active = localStorage.getItem('webx11_active_theme') || 'dark';
+ this.terminal.print(`✅ Preview kept (${active} theme)`, 'success');
+ }
+ }
+ listThemes() {
+ const active = localStorage.getItem('webx11_active_theme') || 'dark';
+ this.terminal.print('=== Themes ===', 'info');
+ Object.keys(this.themes).forEach(name => {
+ const mark = name === active ? ' ✅' : '';
+ this.terminal.print(` ${name}${mark}`);
+ });
+ this.terminal.print('Use: theme set <name> to apply', 'info');
+ }
+ newTheme(args) {
+ if (args.length < 1) return this.terminal.print('Usage: theme new <name> --bg #000 --text #0f0', 'warning');
+ const name = args[0].toLowerCase();
+ if (this.themes[name]) return this.terminal.print(`Theme '${name}' already exists`, 'warning');
+ const vars = {};
+ const root = document.documentElement;
+ const cssVars = ['--bg-color', '--bg-secondary', '--bg-tertiary', '--text-color', '--text-muted', '--accent-color', '--border-color', '--error-color', '--warning-color', '--info-color', '--success-color'];
+ for (let i = 1; i < args.length; i++) {
+ if (args[i].startsWith('--')) {
+ const val = args[i + 1];
+ if (val && !val.startsWith('--')) {
+ vars[args[i]] = val;
+ i++;
+ }
+ }
+ }
+ if (Object.keys(vars).length === 0) {
+ const preview = {};
+ cssVars.forEach(v => { preview[v] = getComputedStyle(root).getPropertyValue(v).trim() || '#000'; });
+ this.themes[name] = preview;
+ } else {
+ this.themes[name] = vars;
+ }
+ this.save();
+ this.terminal.print(`Theme '${name}' created with ${Object.keys(this.themes[name]).length} variables`, 'success');
+ }
+ deleteTheme(name) {
+ if (!name) return this.terminal.print('Usage: theme delete <name>', 'warning');
+ if (this.defaults[name]) return this.terminal.print('Cannot delete built-in theme', 'warning');
+ if (!this.themes[name]) return this.terminal.print(`Theme '${name}' not found`, 'error');
+ delete this.themes[name];
+ this.save();
+ const active = localStorage.getItem('webx11_active_theme');
+ if (active === name) { localStorage.removeItem('webx11_active_theme'); }
+ this.terminal.print(`Theme '${name}' deleted`, 'success');
+ }
+ exportTheme(name) {
+ if (!name) return this.terminal.print('Usage: theme export <name>', 'warning');
+ if (!this.themes[name]) return this.terminal.print(`Theme '${name}' not found`, 'error');
+ const data = JSON.stringify(this.themes[name], null, 2);
+ const blob = new Blob([data], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url; a.download = `theme-${name}.json`; a.click();
+ URL.revokeObjectURL(url);
+ this.terminal.print(`Theme '${name}' exported`, 'success');
+ }
+}
+if (typeof window.webx11 !== 'undefined' && window.webx11) {
+ webx11.moduleManager.registerModule(new ThemeModule(), 'https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/theme.js');
+}
diff --git a/modules/ext/voice.js b/modules/ext/voice.js
new file mode 100644
index 0000000..0fd96cd
--- /dev/null
+++ b/modules/ext/voice.js
@@ -0,0 +1,102 @@
+class VoiceModule {
+ constructor() {
+ this.commands = {};
+ this.terminal = null;
+ this.recognition = null;
+ this.listening = false;
+ this._lastError = '';
+ }
+ requirements() {
+ if (typeof SpeechRecognition !== 'undefined' || typeof webkitSpeechRecognition !== 'undefined') {
+ return { met: true, missing: [] };
+ }
+ return { met: false, missing: ['SpeechRecognition not available in this browser'] };
+ }
+ getHelpInfo() {
+ return { 'voice': 'voice [stop] — Start voice recognition, or type "voice stop" to cancel' };
+ }
+ registerCommands() {
+ this.commands['voice'] = (args) => this.handle(args);
+ }
+ handle(args) {
+ const sub = args[0] && args[0].toLowerCase();
+ if (sub === 'stop') return this.stopListening();
+ if (this.listening) return this.terminal.print('Already listening. Say something or type "voice stop"', 'warning');
+ this.startListening();
+ }
+ _updateButton(listening, error) {
+ const btn = document.getElementById('voice-toggle-btn');
+ if (!btn) return;
+ if (listening) {
+ btn.style.borderColor = 'var(--success-color)';
+ btn.style.color = 'var(--success-color)';
+ btn.style.animation = 'pulse 1.5s ease-in-out infinite';
+ btn.textContent = '🔴 Listening';
+ } else {
+ btn.style.borderColor = '';
+ btn.style.color = '';
+ btn.style.animation = '';
+ if (error) {
+ btn.style.borderColor = 'var(--error-color)';
+ btn.style.color = 'var(--error-color)';
+ } else {
+ btn.style.borderColor = '';
+ btn.style.color = '';
+ }
+ btn.textContent = '🎤 Mic';
+ }
+ }
+ startListening() {
+ const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
+ if (!SpeechRecognition) {
+ this._updateButton(false, true);
+ return this.terminal.print('Speech recognition not supported in this browser', 'error');
+ }
+ this.recognition = new SpeechRecognition();
+ this.recognition.lang = 'en-US';
+ this.recognition.interimResults = false;
+ this.recognition.maxAlternatives = 1;
+ this.recognition.continuous = false;
+ this.listening = true;
+ this._updateButton(true);
+ this.terminal.print('🎤 Listening... (say a command, or tap mic button to cancel)', 'info');
+ this.recognition.onresult = (event) => {
+ const transcript = event.results[0][0].transcript;
+ this.terminal.print(`🎤 Recognized: "${transcript}"`, 'success');
+ const input = this.terminal.inputField;
+ input.value = transcript;
+ input.focus();
+ this.listening = false;
+ this._updateButton(false);
+ };
+ this.recognition.onerror = (event) => {
+ this.terminal.print(`🎤 Error: ${event.error}`, 'error');
+ this.listening = false;
+ this._updateButton(false, true);
+ };
+ this.recognition.onend = () => {
+ if (this.listening) {
+ this.listening = false;
+ this._updateButton(false);
+ this.terminal.print('🎤 Voice input ended', 'info');
+ }
+ };
+ try { this.recognition.start(); }
+ catch (e) {
+ this.terminal.print(`🎤 Failed to start: ${e.message}`, 'error');
+ this.listening = false;
+ this._updateButton(false, true);
+ }
+ }
+ stopListening() {
+ if (this.recognition && this.listening) {
+ this.recognition.stop();
+ this.listening = false;
+ this._updateButton(false);
+ this.terminal.print('🎤 Listening stopped', 'info');
+ }
+ }
+}
+if (typeof window.webx11 !== 'undefined' && window.webx11) {
+ webx11.moduleManager.registerModule(new VoiceModule(), 'https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/voice.js');
+}
diff --git a/modules/ext/webdav.js b/modules/ext/webdav.js
new file mode 100644
index 0000000..63dfb05
--- /dev/null
+++ b/modules/ext/webdav.js
@@ -0,0 +1,532 @@
+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 '';
+ }
+
+ 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-explorer': 'webdav-explorer [path] — Interactive remote file browser',
+ };
+ }
+
+ _loadConfig() {
+ try {
+ const saved = localStorage.getItem(this._STORAGE_KEY);
+ if (saved) {
+ this.config = JSON.parse(saved);
+ // Try to get password from SecretVault
+ if (window.webx11_secretVault && this.config && this.config.username) {
+ const encryptedPass = window.webx11_secretVault.decrypt('webdav_pass_' + this.config.username);
+ if (encryptedPass !== null && encryptedPass !== undefined) {
+ this.config.password = encryptedPass;
+ }
+ }
+ }
+ } catch (e) { /* ignore */ }
+ }
+
+ _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) {
+ window.webx11_secretVault.encrypt('webdav_pass_' + this.config.username, this.config.password);
+ // Also store space token
+ if (this._spaceName) {
+ window.webx11_secretVault.encrypt('webdav_token_' + this._spaceName, this.config.password);
+ }
+ }
+ // Also store in localStorage for backward compat (SecretVault decrypts later)
+ // Store only url and username hint in plaintext lS, encrypted password handled above
+ 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.');
+ }
+ 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) {
+ // Try PROPFIND first (works with WebDAV servers), fallback to nginx 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 */ }
+ // Fallback: use nginx __list__ endpoint
+ return this._listDirViaAutoIndex(path);
+ }
+
+ async _listDirViaAutoIndex(path) {
+ const baseUrl = this.config.url.replace(/\/+$/, '');
+ const urlPath = path ? (path.startsWith('/') ? path : '/' + path) : '/';
+ // Use __list__ for directory listing
+ const listUrl = baseUrl.replace(/\/spaces\/([^/]+)\//, '/spaces/$1/__list__/') + urlPath.replace(/^\//, '');
+ 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
+ const linkRegex = /<a href="([^"]+)">([^<]+)<\/a>\s+(\d{2}-[A-Za-z]{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 === '../') continue;
+ if (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) {
+ const simpleRegex = /<a href="([^"]+)">/g;
+ const names = [];
+ while ((match = simpleRegex.exec(html)) !== null) {
+ const href = match[1];
+ if (href === '../' || href === '/' || href === './') continue;
+ if (href.startsWith('?')) continue;
+ names.push(href);
+ }
+ 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
+ });
+ }
+ }
+ return entries;
+ }
+
+ _parsePropfind(xml) {
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(xml, 'text/xml');
+ const responses = doc.querySelectorAll('D\\:response, response');
+ const entries = [];
+ responses.forEach((resp) => {
+ 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;
+ }
+
+ 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 <url> [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) {
+ token = window.webx11_secretVault.decrypt('webdav_token_' + spaceName);
+ }
+
+ // Fallback to localStorage
+ if (!token) {
+ token = localStorage.getItem('webx11_spaces_' + spaceName);
+ }
+
+ if (token) {
+ self.config = {
+ url: autoUrl,
+ username: spaceName,
+ password: token
+ };
+ 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 /spaces/' + spaceName + '/.token or the dashboard.', 'info');
+ self.terminal.print('Auto-setting URL: ' + autoUrl, 'info');
+ self.terminal.print('Then configure with: webdav-config ' + autoUrl + ' ' + spaceName + ' <your-token>', 'info');
+ // At minimum set the URL
+ self.config = {
+ url: autoUrl,
+ username: spaceName,
+ password: ''
+ };
+ 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 : '')
+ };
+ 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'] = () => {
+ if (!self.config || !self.config.url) {
+ self.terminal.print('WebDAV not configured. Use webdav-config <url> [username] [password]', 'warning');
+ return;
+ }
+ 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 <vfs-path> [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 <remote-path> [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) => {
+ const name = (args[0] || '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');
+ const filename = name + '-' + dateStr + '.json';
+ const remotePath = 'backups/' + filename;
+
+ try {
+ await self._ensureDir('backups');
+ const serialized = JSON.stringify(self.terminal.fs.root, null, 2);
+ const resp = await self._webdavRequest('PUT', remotePath, {
+ body: serialized,
+ headers: { 'Content-Type': 'application/json' }
+ });
+ if (resp.ok || resp.status === 201 || resp.status === 204) {
+ self.terminal.print('Backup saved: ' + remotePath + ' (' + self._formatSize(serialized.length) + ')', 'success');
+ } 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 <backup-filename>', 'warning');
+ self.terminal.print(' Use webdav-ls backups/ to see available backups', 'info');
+ return;
+ }
+ let remotePath = args[0];
+ 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);
+ const content = await resp.text();
+ 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-explorer: interactive file browser via WebDAV ---
+ this.commands['webdav-explorer'] = async (args) => {
+ const startPath = args[0] || '/';
+ try {
+ const entries = await self._listDir(startPath);
+ self.terminal.print('📁 WebDAV: ' + startPath, 'info');
+ if (entries.length === 0) {
+ self.terminal.print('(empty)', 'info');
+ return;
+ }
+ entries.forEach(e => {
+ const icon = e.type === 'dir' ? '📁' : '📄';
+ const size = e.type === 'file' ? ' (' + self._formatSize(e.size) + ')' : '/';
+ self.terminal.print(icon + ' ' + e.name + (e.type === 'dir' ? '/' : '') + size);
+ });
+ self.terminal.print('---');
+ self.terminal.print('↳ Navigate: webdav-explorer <dir> | Pull: webdav-pull <file>', 'text-muted');
+ self.terminal.print(entries.length + ' item(s)', 'text-muted');
+ } catch (e) {
+ self.terminal.print('WebDAV error: ' + e.message, 'error');
+ }
+ };
+ }
+
+ _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';
+ }
+}
diff --git a/modules/ext/wpm.js b/modules/ext/wpm.js
new file mode 100644
index 0000000..9c8d820
--- /dev/null
+++ b/modules/ext/wpm.js
@@ -0,0 +1,149 @@
+class WpmModule {
+ constructor() {
+ this.commands = {};
+ this.terminal = null;
+ this.indexUrl = 'https://webx11.duckdns.org/spaces/chatstudio/newos4/packages.json';
+ this.index = null;
+ this.installed = this.loadInstalled();
+ }
+ getHelpInfo() {
+ return { 'wpm': 'wpm <install|list|search|update|remove|info> [...] — Web Package Manager for installing and managing modules' };
+ }
+ loadInstalled() {
+ try {
+ const data = localStorage.getItem('webx11_wpm_installed');
+ return data ? JSON.parse(data) : {};
+ } catch (e) { return {}; }
+ }
+ saveInstalled() {
+ localStorage.setItem('webx11_wpm_installed', JSON.stringify(this.installed));
+ }
+ registerCommands() {
+ this.commands['wpm'] = (args) => this.handle(args);
+ }
+ handle(args) {
+ if (!args[0]) return this.terminal.print('Usage: wpm install <pkg>, wpm list, wpm search <kw>, wpm update, wpm remove <pkg>, wpm info <pkg>', 'warning');
+ const sub = args[0].toLowerCase();
+ if (sub === 'install') this.install(args[1]);
+ else if (sub === 'list') this.list();
+ else if (sub === 'search') this.search(args.slice(1).join(' '));
+ else if (sub === 'update') this.update();
+ else if (sub === 'remove') this.remove(args[1]);
+ else if (sub === 'info') this.info(args[1]);
+ else this.terminal.print(`Unknown: ${sub}`, 'error');
+ }
+ async loadIndex() {
+ if (this.index) return this.index;
+ try {
+ this.terminal.print('Fetching package index...', 'info');
+ const res = await fetch(this.indexUrl);
+ if (!res.ok) throw new Error('HTTP ' + res.status);
+ this.index = await res.json();
+ return this.index;
+ } catch (e) {
+ this.terminal.print(`Failed to load index: ${e.message}`, 'error');
+ return null;
+ }
+ }
+ async install(name) {
+ if (!name) return this.terminal.print('Usage: wpm install <package>', 'warning');
+ const idx = await this.loadIndex();
+ if (!idx) return;
+ const pkg = idx.packages[name];
+ if (!pkg) return this.terminal.print(`Package "${name}" not found. Try: wpm search <keyword>`, 'error');
+ if (this.installed[name]) return this.terminal.print(`Package "${name}" already installed. Use wpm update to reinstall.`, 'warning');
+ this.terminal.print(`Installing ${pkg.name} v${pkg.version}...`, 'info');
+ this.terminal.print(` ${pkg.desc}`, 'info');
+ const success = await this.terminal.moduleManager.loadModuleFromURL(pkg.url);
+ if (success) {
+ this.installed[name] = { version: pkg.version, installed: new Date().toISOString() };
+ this.saveInstalled();
+ this.terminal.print(`"${name}" installed successfully!`, 'success');
+ }
+ }
+ async list() {
+ const idx = await this.loadIndex();
+ if (!idx) return;
+ const names = Object.keys(this.installed);
+ this.terminal.print('=== Installed Packages ===', 'info');
+ if (names.length === 0) {
+ this.terminal.print(' None. Use: wpm install <pkg>', 'info');
+ } else {
+ names.forEach(name => {
+ const inst = this.installed[name];
+ this.terminal.print(` ${name} v${inst.version} (${inst.installed.split('T')[0]})`);
+ });
+ }
+ this.terminal.print('\n=== Available Packages ===', 'info');
+ Object.keys(idx.packages).sort().forEach(name => {
+ const pkg = idx.packages[name];
+ const installed = this.installed[name] ? ' ✅' : '';
+ this.terminal.print(` ${name} - ${pkg.desc}${installed}`);
+ });
+ }
+ async search(keyword) {
+ if (!keyword) return this.terminal.print('Usage: wpm search <keyword>', 'warning');
+ const idx = await this.loadIndex();
+ if (!idx) return;
+ const kw = keyword.toLowerCase();
+ const results = [];
+ Object.entries(idx.packages).forEach(([name, pkg]) => {
+ if (name.includes(kw) || pkg.desc.toLowerCase().includes(kw) || pkg.category.toLowerCase().includes(kw)) {
+ results.push({ name, ...pkg });
+ }
+ });
+ if (results.length === 0) return this.terminal.print(`No packages matching "${keyword}"`, 'info');
+ this.terminal.print(`=== Search: "${keyword}" (${results.length} results) ===`, 'info');
+ results.forEach(r => {
+ const installed = this.installed[r.name] ? ' [installed]' : '';
+ this.terminal.print(` ${r.name} [${r.category}] - ${r.desc}${installed}`);
+ });
+ this.terminal.print('Use: wpm install <name> to install', 'info');
+ }
+ async update() {
+ this.index = null;
+ await this.loadIndex();
+ if (!this.index) return;
+ this.terminal.print('Index refreshed.', 'success');
+ const outdated = [];
+ Object.keys(this.installed).forEach(name => {
+ const pkg = this.index.packages[name];
+ if (pkg && pkg.version !== this.installed[name].version) {
+ outdated.push(name);
+ }
+ });
+ if (outdated.length > 0) {
+ this.terminal.print(`Outdated packages: ${outdated.join(', ')}. Reinstall with "wpm install <name>"`, 'warning');
+ }
+ }
+ async remove(name) {
+ if (!name) return this.terminal.print('Usage: wpm remove <package>', 'warning');
+ if (!this.installed[name]) return this.terminal.print(`Package "${name}" is not installed via wpm`, 'warning');
+ const idx = await this.loadIndex();
+ const className = idx && idx.packages[name] ? idx.packages[name].name.replace(/\s+/g, '') + 'Module' : null;
+ if (className && this.terminal.moduleManager.unregisterModule(className)) {
+ this.terminal.print(`Module ${className} unregistered`, 'info');
+ }
+ delete this.installed[name];
+ this.saveInstalled();
+ this.terminal.print(`Package "${name}" removed. If module persists, use: removemod <ClassName>`, 'success');
+ }
+ async info(name) {
+ if (!name) return this.terminal.print('Usage: wpm info <package>', 'warning');
+ const idx = await this.loadIndex();
+ if (!idx) return;
+ const pkg = idx.packages[name];
+ if (!pkg) return this.terminal.print(`Package "${name}" not found`, 'error');
+ this.terminal.print(`=== ${pkg.name} v${pkg.version} ===`, 'info');
+ this.terminal.print(`Description: ${pkg.desc}`);
+ this.terminal.print(`Category: ${pkg.category}`);
+ this.terminal.print(`Commands: ${pkg.commands.join(', ')}`);
+ this.terminal.print(`URL: ${pkg.url}`);
+ const installed = this.installed[name];
+ if (installed) this.terminal.print(`Installed: ${installed.installed}`, 'success');
+ else this.terminal.print('Not installed. Use: wpm install ' + name, 'info');
+ }
+}
+if (typeof window.webx11 !== 'undefined' && window.webx11) {
+ webx11.moduleManager.registerModule(new WpmModule(), 'https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/wpm.js');
+}