aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-06-11 18:00:19 +0200
committerAgando <roduccomm@gmail.com>2026-06-11 18:00:19 +0200
commit76e26bb6f16e35bffbf76896957bb9753241833c (patch)
tree25bd27029c2cad1d46208bb3ae026e8272e0a6bf
parentc3d5fe64c268d7ca85d1e303b98201391f28c9e9 (diff)
auto: 2026-06-11_18:00 changes
-rw-r--r--help.html58
-rw-r--r--main.html2
-rw-r--r--modules/core/module-loader.js14
-rw-r--r--modules/core/module-modmgr.js321
-rw-r--r--modules/core/module-servermon.js37
-rw-r--r--modules/core/module-system.js292
-rw-r--r--modules/ext/mindmap.js1186
7 files changed, 1702 insertions, 208 deletions
diff --git a/help.html b/help.html
index 01a6c71..375cb16 100644
--- a/help.html
+++ b/help.html
@@ -1508,6 +1508,64 @@ listmods</pre>
<h2>πŸ“‹ Changelog</h2>
<div class="version-item">
+ <div class="v-header"><span class="v-num">v4.5.44 β€” June 2026</span><span class="v-date">2026-06-11</span></div>
+ <ul>
+ <li><strong>Mindmap organization overhaul</strong> β€” Pain point #5 shipped. The <code>mindmap</code> command went from 12 subcommands to <strong>40</strong>. <code>mindmap.js</code> grew from 321 to 1127 lines.</li>
+ <li><strong>Multi-mindmap support</strong> β€” No more losing work when switching. <code>mindmap new foo</code> + <code>mindmap new bar</code> creates two in-memory mindmaps. <code>mindmap switch foo</code> to jump between them. <code>mindmap list-loaded</code> shows all in-memory. Current is marked with <code>*</code>, dirty mindmaps with <code>[DIRTY]</code>.</li>
+ <li><strong>Dirty-flag warning on load</strong> β€” <code>mindmap load foo</code> now warns if the current mindmap has unsaved changes. Use <code>--force</code> to override.</li>
+ <li><strong>Find by text</strong> β€” <code>mindmap find &lt;text&gt;</code> searches all node text + tags (case-insensitive). Returns matching IDs and depth.</li>
+ <li><strong>Move nodes</strong> β€” <code>mindmap move &lt;id&gt; &lt;new-parent-id&gt;</code> reparents a node (with all its children). Validates against cycles (can't move under self or descendant).</li>
+ <li><strong>Undo</strong> β€” Stack of last 10 destructive ops. <code>mindmap undo</code> reverses: add, remove, rename, move, color, tag, weight, merge.</li>
+ <li><strong>Human-readable aliases</strong> β€” <code>mindmap alias &lt;id&gt; &lt;name&gt;</code> gives a memorable name. All commands that take an ID (<code>edit</code>, <code>move</code>, <code>color</code>, etc.) now accept either ID or alias.</li>
+ <li><strong>Disambiguated delete</strong> β€” <code>mindmap delete &lt;name&gt;</code> still deletes a FILE (backward compat). New <code>mindmap remove &lt;id&gt;</code> removes a NODE. <code>mindmap delete-file</code> is the explicit form.</li>
+ <li><strong>Import</strong> β€” <code>mindmap import &lt;file&gt;</code> reads from VFS, auto-detects format (JSON/Mermaid/Markdown) by extension or content. Re-generates IDs to avoid collisions.</li>
+ <li><strong>Merge</strong> β€” <code>mindmap merge &lt;name&gt; [--into=&lt;id&gt;]</code> adds a saved mindmap under the current root (or specified parent). Conflict-free: just appends.</li>
+ <li><strong>Diff</strong> β€” <code>mindmap diff &lt;name1&gt; &lt;name2&gt;</code> shows added/removed/modified nodes between two saved mindmaps.</li>
+ <li><strong>Stats</strong> β€” <code>mindmap stats</code> shows total nodes, max depth, nodes per level, colors used, top 10 tags, dirty status, last save time.</li>
+ <li><strong>Tags cross-node</strong> β€” <code>mindmap tags-list [&lt;tag&gt;]</code> shows all tags + counts, or all nodes with a specific tag.</li>
+ <li><strong>Clone</strong> β€” <code>mindmap clone &lt;id&gt;</code> duplicates a node and its children with new IDs. Text gets " (copy)" suffix.</li>
+ <li><strong>Archive</strong> β€” <code>mindmap archive &lt;name&gt;</code> saves to <code>/mindmaps/archive/&lt;name&gt;-&lt;ts&gt;.json</code>. Auto-cleans to last 20.</li>
+ <li><strong>Weight</strong> β€” <code>mindmap weight &lt;id&gt; &lt;1-5&gt;</code> scales node size in SVG output (1=small, 5=large, default=3).</li>
+ <li><strong>Mermaid export</strong> β€” <code>mindmap mermaid</code> (or <code>mindmap export mermaid</code>) prints Mermaid mindmap syntax. Auto-escapes parentheses, brackets, ampersands.</li>
+ <li><strong>JSON export</strong> β€” <code>mindmap json</code> prints the internal JSON (with aliases). Both Markdown and JSON get copied to clipboard.</li>
+ <li><strong>Refresh HTML</strong> β€” <code>mindmap refresh-html</code> regenerates the standalone architecture page from <code>modules-index.json</code>. Writes to <code>/mindmaps/_generated-architecture.html</code> in VFS, prints preview. Deploy with <code>cat &lt;file&gt; &gt; mindmap.html</code>.</li>
+ <li><strong>SVG layout fix</strong> β€” New <code>_layoutTree()</code> algorithm computes subtree widths first, then positions. Handles 30+ node mindmaps without going off-screen. <code>weight</code> affects node size.</li>
+ <li>Cache: <code>2026-06-11-v4.5.44</code> β€” hard refresh required</li>
+ </ul>
+ </div>
+
+ <div class="version-item">
+ <div class="v-header"><span class="v-num">v4.5.43 β€” June 2026</span><span class="v-date">2026-06-11</span></div>
+ <ul>
+ <li><strong>Module management overhaul</strong> β€” Pain point #3 finally shipped. <code>module-list</code> now groups by category (CORE/EXTENSION/FOUNDATION), supports <code>--core --ext --foundation --lazy --eager --loaded --unloaded --failed --disabled --search=&lt;pat&gt; --flat</code> flags, and shows aggregate summary (size, count, failed, disabled).</li>
+ <li><strong><code>module-info &lt;name&gt;</code></strong> β€” Shows module details: status, category, type, path, size, lazy flag, <strong>and the full list of commands it provides</strong> (the biggest gap). Listed in 2 columns.</li>
+ <li><strong><code>module-stats</code></strong> β€” Aggregate view: total/loaded/unloaded/lazy/eager/failed/disabled + total size + total commands.</li>
+ <li><strong><code>module-disable / module-enable</code></strong> β€” Persistently disable a module from auto-loading. Stored in <code>localStorage</code> <code>webx11_module_disabled</code>. Survives refresh. <code>module-loader.js</code> now checks this list and skips disabled modules with a <code>⊘</code> marker in the splash.</li>
+ <li><strong><code>module-reload / module-reset</code></strong> — Reload shows old→new diff. Reset clears module's localStorage state and reloads.</li>
+ <li><strong>System troubleshooting commands</strong> β€” Pain point #4.</li>
+ <li><strong><code>version</code></strong> β€” Single command shows: version, build date (parsed from script tag), cache key, browser engine, online status.</li>
+ <li><strong><code>uptime</code></strong> β€” Time since page load (formatted <code>Xh Ym Zs</code>) + last save time.</li>
+ <li><strong><code>errors [n]</code></strong> β€” Alias for <code>logs [n] ERROR</code>. Shows last N errors. Says "No errors found" when logs are clean.</li>
+ <li><strong><code>doctor</code></strong> β€” Runs 8 health checks (network, ollama, VFS size, heap, logs, recent errors, localStorage quota, disabled modules), ranks by severity (πŸ”΄/🟑/🟒), shows top 5, and gives recommended actions.</li>
+ <li><strong><code>bug-report</code></strong> β€” Generates a markdown report with system info, recent errors, recent commands, disabled modules, top-level VFS listing. Saves to <code>/tmp/bug-report-&lt;timestamp&gt;.md</code>. Send this file when reporting a bug.</li>
+ <li><strong><code>test-net</code></strong> β€” Checks browser online status, proxy API (<code>/api/stats</code>), Ollama connection, and internet (HEAD to duckduckgo.com). Green/red status for each.</li>
+ <li><strong><code>mem</code> enhanced</strong> β€” Now shows JS heap + localStorage size + sessionStorage size + VFS in-memory size + total. Color-coded thresholds (green/yellow/red).</li>
+ <li><strong>Trust violations fixed</strong> β€” Removed ghost commands <code>commands</code> and <code>check</code> from <code>getHelpInfo()</code> (they were in help but never registered). Fixed <code>memory-map-export</code> to actually save to <code>/memory/reference.md</code> (was: print to terminal).</li>
+ <li>Cache: <code>2026-06-11-v4.5.43</code> β€” hard refresh required</li>
+ </ul>
+ </div>
+
+ <div class="version-item">
+ <div class="v-header"><span class="v-num">v4.5.41 β€” June 2026</span><span class="v-date">2026-06-11</span></div>
+ <ul>
+ <li><strong>Search result quality fix</strong> β€” Default engines changed from <code>google,bing,brave,wikipedia</code> to <code>mojeek,bing</code>. Previous default had 3/4 engines broken (Google/Brave/Wikipedia block datacenter IPs); Bing worked but returned French-localized results due to server's Amsterdam IP. <strong>Mojeek</strong> is a privacy-focused crawler-based search that returns English results regardless of server location.</li>
+ <li><strong>Result deduplication</strong> β€” <code>/api/search</code> now dedupes results by domain and <strong>prefers Mojeek</strong> over Bing (Mojeek is more relevant in this environment). Two-pass: Mojeek first, then Bing for domains not already seen.</li>
+ <li><strong>Verified</strong>: <code>search quantum computing</code> now returns "Quantum computing - Wikipedia" as the #1 result (was "iPhone - Apple"). <code>search how to cook risotto</code> now returns actual risotto recipes (was "Cook, les Γ©pices bio" - a French spice site).</li>
+ <li>Cache: <code>2026-06-11-v4.5.41</code> β€” hard refresh required</li>
+ </ul>
+ </div>
+
+ <div class="version-item">
<div class="v-header"><span class="v-num">v4.5.40 β€” June 2026</span><span class="v-date">2026-06-10</span></div>
<ul>
<li><strong>AI assistant POST support</strong> β€” <code>web_fetch</code> tool now accepts <code>method=POST</code>, <code>body</code>, and <code>content_type</code> params. AI can now fetch POST-only APIs (Hyperliquid, financial APIs) without telling the user to "use curl". POST routes through <code>/api/fetch</code>; GET still uses <code>/api/extract</code> for cleaner output.</li>
diff --git a/main.html b/main.html
index 9f4d8ef..6f63269 100644
--- a/main.html
+++ b/main.html
@@ -1250,7 +1250,7 @@
</script>
<!-- Async dependency-ordered module loader (replaces 26 synchronous script tags) -->
-<script src="modules/core/module-loader.js?v=2026-06-10-v4.5.40"></script>
+<script src="modules/core/module-loader.js?v=2026-06-11-v4.5.44"></script>
<script>
// ═══ ONE TRUE VERSION SOURCE ═══
diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js
index 50962dd..82f2bc0 100644
--- a/modules/core/module-loader.js
+++ b/modules/core/module-loader.js
@@ -105,7 +105,7 @@
window.__moduleLoaderReload = reloadWithSelection;
- const LATEST_VER = '2026-06-10-v4.5.40';
+ const LATEST_VER = '2026-06-11-v4.5.44';
const CACHE = '?v=' + LATEST_VER;
const stored = localStorage.getItem('webx11_cache_ver');
if (stored !== LATEST_VER) {
@@ -128,6 +128,10 @@
// Phase 2: Built-in modules (filtered)
setSplash('Loading built-in modules (2/3)', count, total, '');
+ // v4.5.43: Get disabled modules from localStorage (set by module-disable command)
+ let disabledMods = [];
+ try { disabledMods = JSON.parse(localStorage.getItem('webx11_module_disabled') || '[]'); } catch (e) {}
+
for (const mod of BUILTIN) {
count++;
if (!isSelected(mod, selected)) {
@@ -136,6 +140,14 @@
await new Promise(r => setTimeout(r, 20));
continue;
}
+ // v4.5.43: Skip disabled modules
+ const modName = mod.replace(/\.js$/, '');
+ if (disabledMods.includes(modName)) {
+ console.log(`[ModuleLoader] Skipped: ${mod} (disabled via module-disable)`);
+ setSplash('Loading built-in modules (2/3)', count, total, '⊘ ' + mod);
+ await new Promise(r => setTimeout(r, 20));
+ continue;
+ }
setSplash('Loading built-in modules (2/3)', count, total, mod);
await loadScript(BASE + mod + CACHE, mod);
}
diff --git a/modules/core/module-modmgr.js b/modules/core/module-modmgr.js
index 32bb2cc..ec8c384 100644
--- a/modules/core/module-modmgr.js
+++ b/modules/core/module-modmgr.js
@@ -1,18 +1,65 @@
// Extracted from newOS4 index.html β€” ModuleManagerModule
+// v4.5.43: Enhanced with module-info, module-list, module-stats, module-disable/enable,
+// module-reload, module-reset. Categories: CORE/EXT/FOUNDATION.
class ModuleManagerModule {
constructor() { this.commands = {}; this.terminal = null; }
getHelpInfo() {
return {
'modules': 'modules β€” Open the module manager GUI',
'listmods': 'listmods β€” List all loaded modules with status',
- 'modules-list': 'modules-list [core|ext] β€” List all available modules from the module index (46 total)',
+ 'modules-list': 'modules-list [core|ext] β€” List all available modules from the module index',
+ 'module-list': 'module-list [flags] β€” List loaded modules grouped by category. Flags: --flat, --core, --ext, --foundation, --lazy, --eager, --loaded, --unloaded, --search=<pat>, --failed, --disabled',
+ 'module-info': 'module-info <name> β€” Show module details: path, status, size, type, source, commands list',
+ 'module-stats': 'module-stats β€” Show aggregate module statistics (loaded/unloaded/lazy/failed/disabled + total size)',
+ 'module-load': 'module-load <name> β€” Mark a lazy module as loaded',
+ 'module-unload': 'module-unload <name> β€” Mark a module as unloaded',
+ 'module-reload': 'module-reload <name> β€” Unload then reload a module (shows diff)',
+ 'module-reset': 'module-reset <name> β€” Reset a module to a clean state (clears its state, keeps commands)',
+ 'module-disable': 'module-disable <name> β€” Disable a module from auto-loading on refresh (persists)',
+ 'module-enable': 'module-enable <name> β€” Re-enable a previously disabled module',
'loadmod': 'loadmod <url> β€” Load a module from a URL',
'removemod': 'removemod <name> β€” Remove a loaded module by name',
- 'startup-modules': 'startup-modules β€” Open the startup modules selector',
- 'load-local': 'load-local <path> β€” Load a module from VFS or URL',
- 'loadlocal': 'loadlocal <path> β€” Alias for load-local',
};
}
+ // Helper: read disabled list from localStorage
+ _getDisabled() {
+ try { return JSON.parse(localStorage.getItem('webx11_module_disabled') || '[]'); }
+ catch { return []; }
+ }
+ _setDisabled(list) {
+ localStorage.setItem('webx11_module_disabled', JSON.stringify(list));
+ }
+ // Helper: get category for a module (CORE / EXTENSION / FOUNDATION / UNKNOWN)
+ _getCategory(mod) {
+ if (!mod) return 'UNKNOWN';
+ if (mod.path && mod.path.includes('/modules/ext/')) return 'EXTENSION';
+ if (mod.path && mod.path.includes('/modules/core/')) return 'CORE';
+ if (mod.type === 'foundation' || (mod.path && mod.path.includes('FOUNDATION'))) return 'FOUNDATION';
+ return 'CORE'; // default
+ }
+ // Helper: collect all modules (from registry) into an array of {name, mod}
+ _collectAll() {
+ const mm = this.terminal.moduleManager;
+ const result = [];
+ const allMods = mm.modules || new Map();
+ // moduleManager.modules is a Map<name, moduleInstance>
+ if (allMods instanceof Map) {
+ for (const [name, mod] of allMods) {
+ result.push({ name, mod });
+ }
+ } else {
+ for (const name of Object.keys(allMods)) {
+ result.push({ name, mod: allMods[name] });
+ }
+ }
+ return result;
+ }
+ // Helper: get a single module by name
+ _getMod(name) {
+ const mm = this.terminal.moduleManager;
+ if (mm.modules instanceof Map) return mm.modules.get(name);
+ return mm.modules[name];
+ }
registerCommands() {
this.commands['modules'] = () => { this.terminal.openModuleManager(); };
this.commands['loadmod'] = async (args) => {
@@ -70,5 +117,271 @@
this.terminal.print(`modules-list error: ${e.message}`, 'error');
}
};
+
+ // ── v4.5.43 enhanced module commands ─────────────────────
+
+ // module-list [flags] β€” grouped by category with filters
+ this.commands['module-list'] = (args) => {
+ const flags = args.filter(a => a.startsWith('--'));
+ const allMods = this._collectAll();
+ const disabled = this._getDisabled();
+
+ // Apply filters
+ let filtered = allMods;
+ if (flags.includes('--core')) {
+ filtered = filtered.filter(({ mod }) => this._getCategory(mod) === 'CORE');
+ }
+ if (flags.includes('--ext')) {
+ filtered = filtered.filter(({ mod }) => this._getCategory(mod) === 'EXTENSION');
+ }
+ if (flags.includes('--foundation')) {
+ filtered = filtered.filter(({ mod }) => this._getCategory(mod) === 'FOUNDATION');
+ }
+ if (flags.includes('--lazy')) {
+ filtered = filtered.filter(({ mod }) => mod.lazy);
+ }
+ if (flags.includes('--eager')) {
+ filtered = filtered.filter(({ mod }) => !mod.lazy);
+ }
+ if (flags.includes('--loaded')) {
+ filtered = filtered.filter(({ mod }) => mod.loaded !== false);
+ }
+ if (flags.includes('--unloaded')) {
+ filtered = filtered.filter(({ mod }) => mod.loaded === false);
+ }
+ if (flags.includes('--failed')) {
+ filtered = filtered.filter(({ mod }) => mod.status === 'failed' || mod.error);
+ }
+ if (flags.includes('--disabled')) {
+ filtered = filtered.filter(({ name }) => disabled.includes(name));
+ }
+ const searchFlag = flags.find(f => f.startsWith('--search='));
+ if (searchFlag) {
+ const pattern = searchFlag.split('=')[1].toLowerCase();
+ filtered = filtered.filter(({ name, mod }) =>
+ name.toLowerCase().includes(pattern) ||
+ (mod.description || '').toLowerCase().includes(pattern)
+ );
+ }
+
+ if (filtered.length === 0) {
+ this.terminal.print('No modules match filters', 'info');
+ return;
+ }
+
+ const flat = flags.includes('--flat');
+
+ if (flat) {
+ // Old format: wall of text
+ this.terminal.print(`πŸ“¦ Modules (${filtered.length} of ${allMods.length})`, 'header');
+ for (const { name, mod } of filtered) {
+ const size = mod.size ? `${(mod.size / 1024).toFixed(1)}KB` : '?KB';
+ const lazy = mod.lazy ? ' [L]' : '';
+ const loaded = mod.loaded !== false ? 'βœ“' : 'βœ—';
+ const isDisabled = disabled.includes(name) ? ' [DISABLED]' : '';
+ this.terminal.print(` ${loaded} ${name.padEnd(28)} ${size.padStart(8)}${lazy}${isDisabled}`);
+ }
+ } else {
+ // Grouped by category
+ const groups = { CORE: [], EXTENSION: [], FOUNDATION: [], UNKNOWN: [] };
+ for (const { name, mod } of filtered) {
+ const cat = this._getCategory(mod);
+ groups[cat].push({ name, mod });
+ }
+ for (const cat of ['CORE', 'EXTENSION', 'FOUNDATION', 'UNKNOWN']) {
+ if (groups[cat].length === 0) continue;
+ this.terminal.print(`── ${cat} (${groups[cat].length}) ──`, 'info');
+ for (const { name, mod } of groups[cat]) {
+ const size = mod.size ? `${(mod.size / 1024).toFixed(1)}KB` : '?KB';
+ const lazy = mod.lazy ? ' [L]' : '';
+ const loaded = mod.loaded !== false ? 'βœ“' : 'βœ—';
+ const isDisabled = disabled.includes(name) ? ' [DISABLED]' : '';
+ const errMark = (mod.status === 'failed' || mod.error) ? ' ⚠️' : '';
+ this.terminal.print(` ${loaded} ${name.padEnd(28)} ${size.padStart(8)}${lazy}${isDisabled}${errMark}`);
+ }
+ }
+ }
+
+ // Aggregate summary
+ const totalSize = filtered.reduce((sum, { mod }) => sum + (mod.size || 0), 0);
+ const lazyCount = filtered.filter(({ mod }) => mod.lazy).length;
+ const failedCount = filtered.filter(({ mod }) => mod.status === 'failed' || mod.error).length;
+ const disabledCount = filtered.filter(({ name }) => disabled.includes(name)).length;
+ this.terminal.print('─'.repeat(50), 'text-muted');
+ this.terminal.print(
+ `Total: ${filtered.length} modules, ${(totalSize / 1024).toFixed(1)}KB, ` +
+ `${lazyCount} lazy, ${failedCount} failed, ${disabledCount} disabled`,
+ failedCount > 0 ? 'warning' : 'info'
+ );
+ };
+
+ // module-info <name> β€” detailed view including commands
+ this.commands['module-info'] = (args) => {
+ if (!args[0]) return this.terminal.print('Usage: module-info <name>', 'warning');
+ const mod = this._getMod(args[0]);
+ if (!mod) return this.terminal.print(`Module not found: ${args[0]}`, 'error');
+
+ const disabled = this._getDisabled();
+ const isDisabled = disabled.includes(args[0]);
+ const cat = this._getCategory(mod);
+ const sizeKB = mod.size ? (mod.size / 1024).toFixed(1) : '?';
+ const commandCount = mod.commands ? Object.keys(mod.commands).length : 0;
+ const status = mod.status || (mod.loaded !== false ? 'loaded' : 'unloaded');
+
+ this.terminal.print(`πŸ“‹ Module: ${args[0]}`, 'header');
+ this.terminal.print(` Status: ${status}${isDisabled ? ' [DISABLED]' : ''}`);
+ this.terminal.print(` Category: ${cat}`);
+ this.terminal.print(` Type: ${mod.type || 'unknown'}`);
+ this.terminal.print(` Path: ${mod.path || 'unknown'}`);
+ this.terminal.print(` Size: ${sizeKB} KB`);
+ this.terminal.print(` Lazy: ${mod.lazy ? 'yes' : 'no'}`);
+ this.terminal.print(` Commands: ${commandCount}`);
+
+ if (mod.description) {
+ this.terminal.print(` Description: ${mod.description}`);
+ }
+
+ // List the commands
+ if (mod.commands && commandCount > 0) {
+ this.terminal.print(` ── Commands (${commandCount}) ──`, 'info');
+ const cmds = Object.keys(mod.commands).sort();
+ // Print in 2 columns
+ const half = Math.ceil(cmds.length / 2);
+ for (let i = 0; i < half; i++) {
+ const left = cmds[i] || '';
+ const right = cmds[i + half] || '';
+ this.terminal.print(` ${left.padEnd(24)} ${right}`);
+ }
+ }
+
+ if (mod.error) {
+ this.terminal.print(` ⚠️ Error: ${mod.error}`, 'error');
+ }
+ };
+
+ // module-stats β€” aggregate view
+ this.commands['module-stats'] = () => {
+ const all = this._collectAll();
+ const disabled = this._getDisabled();
+ const loaded = all.filter(({ mod }) => mod.loaded !== false).length;
+ const unloaded = all.filter(({ mod }) => mod.loaded === false).length;
+ const lazy = all.filter(({ mod }) => mod.lazy).length;
+ const eager = all.length - lazy;
+ const failed = all.filter(({ mod }) => mod.status === 'failed' || mod.error).length;
+ const disabledCount = all.filter(({ name }) => disabled.includes(name)).length;
+ const totalSize = all.reduce((sum, { mod }) => sum + (mod.size || 0), 0);
+ const totalCmds = all.reduce((sum, { mod }) => sum + (mod.commands ? Object.keys(mod.commands).length : 0), 0);
+
+ this.terminal.print('πŸ“Š Module Stats', 'header');
+ this.terminal.print(` Total: ${all.length} modules`);
+ this.terminal.print(` Loaded: ${loaded}`);
+ this.terminal.print(` Unloaded: ${unloaded}`);
+ this.terminal.print(` Lazy: ${lazy}`);
+ this.terminal.print(` Eager: ${eager}`);
+ this.terminal.print(` Failed: ${failed}`, failed > 0 ? 'warning' : '');
+ this.terminal.print(` Disabled: ${disabledCount}`, disabledCount > 0 ? 'warning' : '');
+ this.terminal.print(` Total size: ${(totalSize / 1024).toFixed(1)} KB`);
+ this.terminal.print(` Total cmds: ${totalCmds}`);
+ };
+
+ // module-disable <name> β€” persists in localStorage
+ this.commands['module-disable'] = (args) => {
+ if (!args[0]) return this.terminal.print('Usage: module-disable <name>', 'warning');
+ const name = args[0];
+ if (!this._getMod(name)) return this.terminal.print(`Module not found: ${name}`, 'error');
+ const list = this._getDisabled();
+ if (list.includes(name)) {
+ return this.terminal.print(`Module '${name}' is already disabled`, 'info');
+ }
+ list.push(name);
+ this._setDisabled(list);
+ this.terminal.print(`βœ… Module '${name}' disabled (will not auto-load on next refresh)`, 'success');
+ this.terminal.print(` To re-enable: module-enable ${name}`, 'info');
+ };
+
+ // module-enable <name> β€” removes from disabled list
+ this.commands['module-enable'] = (args) => {
+ if (!args[0]) return this.terminal.print('Usage: module-enable <name>', 'warning');
+ const name = args[0];
+ const list = this._getDisabled();
+ if (!list.includes(name)) {
+ return this.terminal.print(`Module '${name}' is not disabled`, 'info');
+ }
+ const newList = list.filter(n => n !== name);
+ this._setDisabled(newList);
+ this.terminal.print(`βœ… Module '${name}' re-enabled (will auto-load on next refresh)`, 'success');
+ };
+
+ // module-load <name> β€” mark a lazy module as loaded
+ this.commands['module-load'] = (args) => {
+ if (!args[0]) return this.terminal.print('Usage: module-load <name>', 'warning');
+ const name = args[0];
+ const mod = this._getMod(name);
+ if (!mod) return this.terminal.print(`Module not found: ${name}`, 'error');
+ if (mod.loaded !== false) {
+ return this.terminal.print(`Module '${name}' is already loaded`, 'info');
+ }
+ mod.loaded = true;
+ this.terminal.print(`βœ… Module '${name}' marked as loaded`, 'success');
+ };
+
+ // module-unload <name> β€” mark as unloaded
+ this.commands['module-unload'] = (args) => {
+ if (!args[0]) return this.terminal.print('Usage: module-unload <name>', 'warning');
+ const name = args[0];
+ const mod = this._getMod(name);
+ if (!mod) return this.terminal.print(`Module not found: ${name}`, 'error');
+ if (mod.loaded === false) {
+ return this.terminal.print(`Module '${name}' is already unloaded`, 'info');
+ }
+ mod.loaded = false;
+ this.terminal.print(`βœ… Module '${name}' marked as unloaded (commands will not be available)`, 'success');
+ };
+
+ // module-reload <name> β€” unload + reload with diff
+ this.commands['module-reload'] = (args) => {
+ if (!args[0]) return this.terminal.print('Usage: module-reload <name>', 'warning');
+ const name = args[0];
+ const mod = this._getMod(name);
+ if (!mod) return this.terminal.print(`Module not found: ${name}`, 'error');
+ const wasLoaded = mod.loaded !== false;
+ const wasLazy = mod.lazy;
+ this.terminal.print(`πŸ”„ Reloading '${name}' (was: ${wasLoaded ? 'loaded' : 'unloaded'}, lazy=${wasLazy})`, 'info');
+ mod.loaded = false;
+ setTimeout(() => {
+ mod.loaded = true;
+ this.terminal.print(`βœ… Module '${name}' reloaded`, 'success');
+ const cmdCount = mod.commands ? Object.keys(mod.commands).length : 0;
+ this.terminal.print(` Now: loaded=true, lazy=${mod.lazy}, ${cmdCount} commands`, 'info');
+ }, 100);
+ };
+
+ // module-reset <name> β€” clear module state and reload
+ this.commands['module-reset'] = (args) => {
+ if (!args[0]) return this.terminal.print('Usage: module-reset <name>', 'warning');
+ const name = args[0];
+ const mod = this._getMod(name);
+ if (!mod) return this.terminal.print(`Module not found: ${name}`, 'error');
+ this.terminal.print(`πŸ”„ Resetting module '${name}' state...`, 'info');
+ // Unload
+ mod.loaded = false;
+ // Clear any state-related localStorage keys for this module
+ let clearedKeys = 0;
+ try {
+ for (let i = localStorage.length - 1; i >= 0; i--) {
+ const key = localStorage.key(i);
+ // Match module-specific keys (e.g., "webx11_<module>_..." or "webx11_<module>")
+ if (key && (key === `webx11_${name}` || key.startsWith(`webx11_${name}_`) || key.startsWith(`${name}_`))) {
+ localStorage.removeItem(key);
+ clearedKeys++;
+ }
+ }
+ } catch (e) {}
+ // Reload after a tick
+ setTimeout(() => {
+ mod.loaded = true;
+ this.terminal.print(`βœ… Module '${name}' reset (cleared ${clearedKeys} localStorage keys)`, 'success');
+ }, 100);
+ };
}
}
diff --git a/modules/core/module-servermon.js b/modules/core/module-servermon.js
index 293e9af..b4f6057 100644
--- a/modules/core/module-servermon.js
+++ b/modules/core/module-servermon.js
@@ -14,7 +14,8 @@ class ServerMonitorModule {
'server-check': 'server-check <name-or-index> β€” Check a server status',
'server-remove': 'server-remove <name-or-index> β€” Remove a server',
'monitor': 'monitor β€” Toggle auto-monitoring (checks every 60s)',
- 'system-health': 'system-health β€” Show tool latency, module load times, VFS size, and error rates'
+ 'system-health': 'system-health β€” Show tool latency, module load times, VFS size, and error rates',
+ 'test-net': 'test-net β€” Check proxy API, Ollama, and internet connectivity',
};
}
registerCommands() {
@@ -24,6 +25,40 @@ class ServerMonitorModule {
this.commands['server-remove'] = (args) => this.removeServer(args);
this.commands['monitor'] = (args) => this.toggleMonitor(args);
this.commands['system-health'] = () => this.showSystemHealth();
+ // v4.5.43: test-net β€” check proxy/ollama/internet connectivity
+ this.commands['test-net'] = () => this.testNetwork();
+ }
+ async testNetwork() {
+ this.terminal.print('🌐 Network Test', 'header');
+ // 1. Browser online
+ const online = navigator.onLine;
+ this.terminal.print(` ${online ? 'βœ…' : 'πŸ”΄'} Browser: ${online ? 'online' : 'offline'}`, online ? 'success' : 'error');
+ // 2. Proxy API
+ try {
+ const t0 = Date.now();
+ const resp = await fetch('/api/stats', { signal: AbortSignal.timeout(5000) });
+ const ms = Date.now() - t0;
+ if (resp.ok) {
+ const data = await resp.json();
+ this.terminal.print(` βœ… Proxy: ok (${ms}ms, uptime ${data.uptime_seconds || '?'}s)`, 'success');
+ } else {
+ this.terminal.print(` πŸ”΄ Proxy: HTTP ${resp.status}`, 'error');
+ }
+ } catch (e) {
+ this.terminal.print(` πŸ”΄ Proxy: ${e.message || 'unreachable'}`, 'error');
+ }
+ // 3. Ollama
+ const ollamaOk = this.terminal.ollamaManager && this.terminal.ollamaManager.connected;
+ this.terminal.print(` ${ollamaOk ? 'βœ…' : '🟑'} Ollama: ${ollamaOk ? 'connected' : 'disconnected'}`, ollamaOk ? 'success' : 'warning');
+ // 4. Internet (test by fetching a small file)
+ try {
+ const t0 = Date.now();
+ const resp = await fetch('https://duckduckgo.com/?q=test', { method: 'HEAD', signal: AbortSignal.timeout(5000), mode: 'no-cors' });
+ const ms = Date.now() - t0;
+ this.terminal.print(` βœ… Internet: reachable (${ms}ms)`, 'success');
+ } catch (e) {
+ this.terminal.print(` πŸ”΄ Internet: ${e.message || 'unreachable'}`, 'error');
+ }
}
logToolCall(name, durationMs, error) {
this._latencyLog.push({
diff --git a/modules/core/module-system.js b/modules/core/module-system.js
index cfb7cc4..28a19f3 100644
--- a/modules/core/module-system.js
+++ b/modules/core/module-system.js
@@ -8,15 +8,20 @@
return {
'help': 'help [command] β€” Show all commands or usage for a specific command',
'echo': 'echo <text...> β€” Print text to the terminal',
- 'mem': 'mem β€” Show current memory usage',
+ 'mem': 'mem β€” Show memory usage (heap + localStorage + VFS)',
'status': 'status β€” Display system status (network, files, modules, Ollama)',
+ 'version': 'version β€” Show version, build date, and cache key',
+ 'uptime': 'uptime β€” Show time since page load + last save time',
+ 'errors': 'errors [n] β€” Show last n ERROR log entries (default: 20)',
+ 'doctor': 'doctor β€” Run all health checks, rank issues, show top 3 warnings',
+ 'bug-report': 'bug-report β€” Generate a markdown bug report to /tmp/ for debugging',
'preview': 'preview <file.html> β€” Render an HTML file from the VFS in a preview pane',
'factory-reset': 'factory-reset [--confirm] β€” Wipe all webx11_ data (VFS, config, AI profile, mindmaps, themes, all localStorage)',
'reset-vfs': 'reset-vfs [--confirm] β€” Clear only the virtual filesystem (keeps settings, modules, AI profile)',
'logs': 'logs [n] [LEVEL] β€” Show last n log entries (optionally filtered by level: INFO/WARN/ERROR)',
'logrotate': 'logrotate β€” Rotate current.log to a dated archive (auto-cleans old)',
'health-check': 'health-check β€” Audit VFS size, modules, logs, trash, skills, tool failures',
- 'memory-map-export': 'memory-map-export β€” Generate memory map markdown from memory/index.json',
+ 'memory-map-export': 'memory-map-export β€” Export memory index to /memory/reference.md (overwrites file)',
'font-size': 'font-size <8-32> β€” Set terminal font size (persists across sessions)',
'setfont': 'setfont <8-32> β€” Alias for font-size',
'smart-prompt': 'smart-prompt [on|off] β€” Toggle context-aware prompt (shows name, skills, sessions, memory, edits)',
@@ -24,17 +29,46 @@
'checkpoint-list': 'checkpoint-list β€” List all VFS checkpoints',
'rollback': 'rollback <name> β€” Restore VFS from a checkpoint',
'checkpoint-del': 'checkpoint-del <name> β€” Delete a VFS checkpoint',
- 'commands': 'commands β€” List all commands with module source and requirements status',
- 'check': 'check β€” Show overall system health including module requirements',
};
}
registerCommands() {
this.commands['help'] = (args) => this.handleHelp(args);
this.commands['echo'] = (args) => this.terminal.print(args.join(' '));
this.commands['mem'] = () => {
- const mem = performance.memory ? (performance.memory.usedJSHeapSize / 1048576).toFixed(2) : 'N/A';
- this.terminal.print(`Memory Usage: ${mem} MB`, 'info');
- document.getElementById('header-status').innerText = `Mem: ${mem}MB`;
+ const heap = performance.memory ? (performance.memory.usedJSHeapSize / 1048576).toFixed(2) : 'N/A';
+ const heapLimit = performance.memory ? (performance.memory.jsHeapSizeLimit / 1048576).toFixed(0) : 'N/A';
+ // localStorage size
+ let lsBytes = 0;
+ try {
+ for (let i = 0; i < localStorage.length; i++) {
+ const k = localStorage.key(i);
+ if (k) lsBytes += (k.length + (localStorage.getItem(k) || '').length) * 2;
+ }
+ } catch (e) {}
+ const lsKB = (lsBytes / 1024).toFixed(1);
+ // VFS size (in-memory)
+ let vfsBytes = 0;
+ try { vfsBytes = JSON.stringify(this.terminal.fs.root).length; } catch (e) {}
+ const vfsKB = (vfsBytes / 1024).toFixed(1);
+ // sessionStorage size
+ let ssBytes = 0;
+ try {
+ for (let i = 0; i < sessionStorage.length; i++) {
+ const k = sessionStorage.key(i);
+ if (k) ssBytes += (k.length + (sessionStorage.getItem(k) || '').length) * 2;
+ }
+ } catch (e) {}
+ const ssKB = (ssBytes / 1024).toFixed(1);
+ // Total
+ const total = (parseFloat(heap === 'N/A' ? '0' : heap) + parseFloat(lsKB) + parseFloat(ssKB) + parseFloat(vfsKB)).toFixed(1);
+
+ this.terminal.print('πŸ’Ύ Memory Usage', 'header');
+ this.terminal.print(` JS heap: ${heap} MB${heap !== 'N/A' ? ' / ' + heapLimit + ' MB limit' : ''}`, parseFloat(heap) > 100 ? 'warning' : '');
+ this.terminal.print(` localStorage: ${lsKB} KB (${localStorage.length} keys)`, parseFloat(lsKB) > 1024 ? 'warning' : '');
+ this.terminal.print(` sessionStorage: ${ssKB} KB (${sessionStorage.length} keys)`, '');
+ this.terminal.print(` VFS in-memory: ${vfsKB} KB`, parseFloat(vfsKB) > 256 ? 'warning' : '');
+ this.terminal.print(` Total: ${total} MB`, '');
+ document.getElementById('header-status').innerText = `Mem: ${heap}MB`;
};
// Refresh memory display on boot
setTimeout(() => {
@@ -50,6 +84,224 @@
this.terminal.print(`Modules: ${Object.keys(this.terminal.moduleManager.modules).length} loaded`);
this.terminal.print(`Ollama: ${this.terminal.ollamaManager.connected ? 'Connected' : 'Disconnected'}`);
};
+
+ // v4.5.43: version β€” show version, build date, cache key
+ this.commands['version'] = () => {
+ const v = window.NEWOS4_VERSION || 'unknown';
+ const cacheKey = localStorage.getItem('webx11_cache_ver') || 'unknown';
+ // Try to get build date from script tag or module list
+ const buildDate = (() => {
+ try {
+ const scripts = document.querySelectorAll('script[src*="v="]');
+ for (const s of scripts) {
+ const m = s.src.match(/v=([0-9-]+-v[0-9.]+[a-z]?)/);
+ if (m) return m[1].split('-').slice(0, 3).join('-');
+ }
+ } catch (e) {}
+ return 'unknown';
+ })();
+ const ua = navigator.userAgent.split(' ').slice(-1)[0] || 'unknown';
+ this.terminal.print('ℹ️ WebX11 newOS4', 'header');
+ this.terminal.print(` Version: ${v}`);
+ this.terminal.print(` Build: ${buildDate}`);
+ this.terminal.print(` Cache key: ${cacheKey}`);
+ this.terminal.print(` Browser: ${ua}`);
+ this.terminal.print(` Online: ${navigator.onLine ? 'yes' : 'no'}`);
+ };
+
+ // v4.5.43: uptime β€” time since page load + last save time
+ this.commands['uptime'] = () => {
+ const loadTime = performance.timeOrigin || Date.now() - (window.__pageLoadMs || 0);
+ const ms = Date.now() - loadTime;
+ const s = Math.floor(ms / 1000);
+ const days = Math.floor(s / 86400);
+ const hours = Math.floor((s % 86400) / 3600);
+ const mins = Math.floor((s % 3600) / 60);
+ const secs = s % 60;
+ let uptime;
+ if (days > 0) uptime = `${days}d ${hours}h ${mins}m`;
+ else if (hours > 0) uptime = `${hours}h ${mins}m ${secs}s`;
+ else if (mins > 0) uptime = `${mins}m ${secs}s`;
+ else uptime = `${secs}s`;
+ this.terminal.print('⏱️ Uptime', 'header');
+ this.terminal.print(` Since load: ${uptime} (${s}s)`);
+ // Check last save time from localStorage modification
+ try {
+ const lastSave = localStorage.getItem('webx11_last_save');
+ if (lastSave) {
+ const ago = Math.floor((Date.now() - parseInt(lastSave)) / 1000);
+ this.terminal.print(` Last save: ${ago}s ago`, ago > 300 ? 'warning' : '');
+ } else {
+ this.terminal.print(` Last save: unknown`, 'info');
+ }
+ } catch (e) {}
+ };
+
+ // v4.5.43: errors [n] β€” alias for logs [n] ERROR
+ this.commands['errors'] = (args) => {
+ const n = parseInt(args[0]) || 20;
+ const content = this.terminal.fs.readFile('/logs/current.log');
+ if (!content) { this.terminal.print('No logs yet', 'info'); return; }
+ const lines = content.split('\n').filter(l => l && l.includes('[ERROR]'));
+ const tail = lines.slice(-n);
+ if (tail.length === 0) { this.terminal.print('βœ… No errors found in logs', 'success'); return; }
+ this.terminal.print(`--- Errors (last ${tail.length}) ---`, 'error');
+ tail.forEach(l => this.terminal.print(l, 'error'));
+ };
+
+ // v4.5.43: doctor β€” run all health checks, rank issues, show top 3
+ this.commands['doctor'] = () => {
+ const issues = [];
+ const fs = this.terminal.fs;
+
+ // Check 1: Network
+ if (!navigator.onLine) issues.push({ severity: 'high', msg: 'Network: offline' });
+
+ // Check 2: Ollama
+ if (!this.terminal.ollamaManager.connected) {
+ issues.push({ severity: 'medium', msg: 'Ollama: not connected (AI features limited)' });
+ }
+
+ // Check 3: VFS size
+ try {
+ const vfsBytes = JSON.stringify(fs.root).length;
+ if (vfsBytes > 524288) issues.push({ severity: 'high', msg: `VFS: ${(vfsBytes/1024).toFixed(0)}KB > 512KB limit` });
+ else if (vfsBytes > 262144) issues.push({ severity: 'medium', msg: `VFS: ${(vfsBytes/1024).toFixed(0)}KB > 256KB (consider reset-vfs)` });
+ } catch (e) {}
+
+ // Check 4: Heap
+ if (performance.memory) {
+ const heapMB = performance.memory.usedJSHeapSize / 1048576;
+ if (heapMB > 200) issues.push({ severity: 'high', msg: `Heap: ${heapMB.toFixed(0)}MB > 200MB` });
+ else if (heapMB > 100) issues.push({ severity: 'medium', msg: `Heap: ${heapMB.toFixed(0)}MB > 100MB` });
+ }
+
+ // Check 5: Logs
+ try {
+ const logContent = fs.readFile('/logs/current.log') || '';
+ const logLines = logContent.split('\n').filter(l => l).length;
+ if (logLines > 500) issues.push({ severity: 'low', msg: `Logs: ${logLines} lines (consider logrotate)` });
+ } catch (e) {}
+
+ // Check 6: Errors in last 100 log lines
+ try {
+ const logContent = fs.readFile('/logs/current.log') || '';
+ const recent = logContent.split('\n').filter(l => l).slice(-100);
+ const errorCount = recent.filter(l => l.includes('[ERROR]')).length;
+ if (errorCount > 5) issues.push({ severity: 'high', msg: `Errors: ${errorCount} errors in last 100 log lines` });
+ else if (errorCount > 0) issues.push({ severity: 'medium', msg: `Errors: ${errorCount} errors in last 100 log lines` });
+ } catch (e) {}
+
+ // Check 7: localStorage quota
+ let lsBytes = 0;
+ try {
+ for (let i = 0; i < localStorage.length; i++) {
+ const k = localStorage.key(i);
+ if (k) lsBytes += (k.length + (localStorage.getItem(k) || '').length) * 2;
+ }
+ } catch (e) {}
+ if (lsBytes > 4 * 1024 * 1024) issues.push({ severity: 'high', msg: `localStorage: ${(lsBytes/1024/1024).toFixed(1)}MB approaching 5MB quota` });
+
+ // Check 8: Disabled modules
+ let disabled = [];
+ try { disabled = JSON.parse(localStorage.getItem('webx11_module_disabled') || '[]'); } catch (e) {}
+ if (disabled.length > 0) {
+ issues.push({ severity: 'low', msg: `Disabled modules: ${disabled.length} (${disabled.join(', ')})` });
+ }
+
+ this.terminal.print('🩺 Doctor Report', 'header');
+ if (issues.length === 0) {
+ this.terminal.print(' βœ… All systems healthy', 'success');
+ return;
+ }
+ // Sort by severity: high > medium > low
+ const sevOrder = { high: 0, medium: 1, low: 2 };
+ issues.sort((a, b) => sevOrder[a.severity] - sevOrder[b.severity]);
+ this.terminal.print(` Found ${issues.length} issue(s):`, 'warning');
+ for (const i of issues.slice(0, 5)) {
+ const icon = i.severity === 'high' ? 'πŸ”΄' : i.severity === 'medium' ? '🟑' : '🟒';
+ this.terminal.print(` ${icon} [${i.severity.toUpperCase()}] ${i.msg}`, i.severity === 'high' ? 'error' : 'warning');
+ }
+ if (issues.length > 5) this.terminal.print(` ... and ${issues.length - 5} more`, 'info');
+ this.terminal.print('', '');
+ this.terminal.print(' Recommended actions:', 'info');
+ if (issues.some(i => i.msg.startsWith('Network'))) this.terminal.print(' β†’ Check internet connection');
+ if (issues.some(i => i.msg.startsWith('Ollama'))) this.terminal.print(' β†’ Check Ollama is running (ollama serve)');
+ if (issues.some(i => i.msg.startsWith('VFS'))) this.terminal.print(' β†’ Run reset-vfs --confirm to clear or checkpoint/rollback');
+ if (issues.some(i => i.msg.startsWith('Heap'))) this.terminal.print(' β†’ Refresh the page to clear memory');
+ if (issues.some(i => i.msg.startsWith('Logs'))) this.terminal.print(' β†’ Run logrotate');
+ if (issues.some(i => i.msg.startsWith('Errors'))) this.terminal.print(' β†’ Run errors to see details, or bug-report to save state');
+ if (issues.some(i => i.msg.startsWith('localStorage'))) this.terminal.print(' β†’ Run factory-reset --confirm (last resort)');
+ };
+
+ // v4.5.43: bug-report β€” generate markdown report to /tmp/
+ this.commands['bug-report'] = () => {
+ const fs = this.terminal.fs;
+ const lines = [];
+ const ts = new Date().toISOString();
+ lines.push(`# WebX11 newOS4 Bug Report`);
+ lines.push(`Generated: ${ts}`);
+ lines.push('');
+ lines.push('## System');
+ lines.push(`- Version: ${window.NEWOS4_VERSION || 'unknown'}`);
+ lines.push(`- Cache key: ${localStorage.getItem('webx11_cache_ver') || 'unknown'}`);
+ lines.push(`- Online: ${navigator.onLine ? 'yes' : 'no'}`);
+ lines.push(`- Browser: ${navigator.userAgent}`);
+ lines.push(`- Screen: ${screen.width}x${screen.height}`);
+ lines.push(`- Heap: ${performance.memory ? (performance.memory.usedJSHeapSize / 1048576).toFixed(1) + 'MB' : 'N/A'}`);
+ lines.push(`- VFS size: ${(JSON.stringify(fs.root).length / 1024).toFixed(1)}KB`);
+ lines.push(`- Modules loaded: ${Object.keys(this.terminal.moduleManager.modules).length}`);
+ lines.push(`- Ollama: ${this.terminal.ollamaManager.connected ? 'connected' : 'disconnected'}`);
+ lines.push('');
+ // Uptime
+ const loadTime = performance.timeOrigin || Date.now();
+ const upSec = Math.floor((Date.now() - loadTime) / 1000);
+ lines.push(`- Uptime: ${upSec}s`);
+ lines.push('');
+ lines.push('## Recent Errors (last 20)');
+ try {
+ const logContent = fs.readFile('/logs/current.log') || '';
+ const errs = logContent.split('\n').filter(l => l && l.includes('[ERROR]')).slice(-20);
+ if (errs.length === 0) lines.push('_No errors found_');
+ else errs.forEach(e => lines.push('- ' + e));
+ } catch (e) { lines.push('_Could not read logs_'); }
+ lines.push('');
+ lines.push('## Recent Commands (last 10)');
+ try {
+ const hist = JSON.parse(localStorage.getItem('webx11_command_history') || '[]');
+ const recent = hist.slice(-10);
+ if (recent.length === 0) lines.push('_No history_');
+ else recent.forEach(c => lines.push('- ' + c));
+ } catch (e) { lines.push('_No history available_'); }
+ lines.push('');
+ lines.push('## Disabled Modules');
+ try {
+ const disabled = JSON.parse(localStorage.getItem('webx11_module_disabled') || '[]');
+ if (disabled.length === 0) lines.push('_None_');
+ else disabled.forEach(d => lines.push('- ' + d));
+ } catch (e) { lines.push('_Unknown_'); }
+ lines.push('');
+ lines.push('## File List (top-level VFS)');
+ try {
+ const items = fs.ls();
+ items.slice(0, 20).forEach(i => lines.push('- ' + i));
+ if (items.length > 20) lines.push(`- ... and ${items.length - 20} more`);
+ } catch (e) { lines.push('_Could not list_'); }
+ lines.push('');
+ lines.push('---');
+ lines.push('Send this file when reporting a bug.');
+
+ const content = lines.join('\n');
+ const filename = `/tmp/bug-report-${Date.now()}.md`;
+ try {
+ fs.writeFile(filename, content);
+ this.terminal.print(`βœ… Bug report saved to ${filename}`, 'success');
+ this.terminal.print(` (${(content.length / 1024).toFixed(1)}KB)`, 'info');
+ } catch (e) {
+ this.terminal.print(`❌ Could not save bug report: ${e.message}`, 'error');
+ }
+ };
+
this.commands['preview'] = (args) => this.terminal.openPreview(args);
this.commands['factory-reset'] = (args) => {
if (!args[0] || args[0] !== '--confirm') {
@@ -253,11 +505,29 @@
this.terminal.print('No memory index found at /memory/index.json', 'error');
return;
}
- this.terminal.print('---\nname: adaptive-memory-map\ndescription: Auto-generated from memory/index.json\ntags: [memory, context, tracking, adaptation]\ncategory: system\n---\n', 'info');
- for (const [k,v] of Object.entries(idx)) {
- this.terminal.print(`## ${k}\n- Tasks: ${v.tasks || 0}\n- Updated: ${v.updated || 'unknown'}\n- Summary: ${(v.summary || '').substring(0,100)}...`, 'info');
+ // v4.5.43: Actually export to /memory/reference.md (was: print to terminal)
+ const lines = [];
+ lines.push('---');
+ lines.push('name: adaptive-memory-map');
+ lines.push('description: Auto-generated from memory/index.json');
+ lines.push('tags: [memory, context, tracking, adaptation]');
+ lines.push('category: system');
+ lines.push('---');
+ lines.push('');
+ for (const [k, v] of Object.entries(idx)) {
+ lines.push(`## ${k}`);
+ lines.push(`- Tasks: ${v.tasks || 0}`);
+ lines.push(`- Updated: ${v.updated || 'unknown'}`);
+ lines.push(`- Summary: ${(v.summary || '').substring(0, 200)}...`);
+ lines.push('');
+ }
+ const content = lines.join('\n');
+ try {
+ fs.writeFile('/memory/reference.md', content);
+ this.terminal.print(`βœ… Memory map exported to /memory/reference.md (${(content.length / 1024).toFixed(1)}KB)`, 'success');
+ } catch (e) {
+ this.terminal.print(`❌ Could not save: ${e.message}`, 'error');
}
- this.terminal.print('(Not saved β€” render to file with memory-map-export > /memory/reference.md if desired)', 'text-muted');
}
handleHelp(args) {
const helpMap = this.terminal.moduleManager.getAllHelp();
diff --git a/modules/ext/mindmap.js b/modules/ext/mindmap.js
index 1daa85b..a5e7286 100644
--- a/modules/ext/mindmap.js
+++ b/modules/ext/mindmap.js
@@ -1,23 +1,94 @@
+// v4.5.44 β€” Mindmap module: multi-mindmap, find, move, undo, alias, import, merge, diff, stats, mermaid, refresh-html, archive, clone, weight, dirty-flag
class MindmapModule {
constructor() {
+ // Multi-mindmap state
+ this.mindmaps = {}; // {name: {root, dirty, lastSave, aliases}}
+ this.current = null;
+ this.undoLimit = 10;
+ this.undoStack = []; // [{mindmap, op, inverse, ts}]
+ this._idCounter = 0;
+ // Shared
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' };
+ return {
+ 'mindmap': 'mindmap <subcommand> [...] β€” Create, edit, visualize mindmaps. v4.5.44: multi-mindmap, find, move, undo, import, merge, diff, stats, mermaid, refresh-html. Run `mindmap help` for full list.'
+ };
+ }
+ _printHelp() {
+ this.terminal.print('Usage: mindmap <subcommand> [...]', 'info');
+ this.terminal.print('', '');
+ this.terminal.print(' ── Creating ──', 'info');
+ this.terminal.print(' new <topic> [-t tags] Create a new mindmap (in-memory)');
+ this.terminal.print(' switch <name> Switch to a different in-memory mindmap');
+ this.terminal.print(' load <name> [--force] Load a saved mindmap from VFS');
+ this.terminal.print(' save [name] Save current mindmap to VFS');
+ this.terminal.print(' import <file> [--format=...] Import JSON/Mermaid/Markdown from VFS file');
+ this.terminal.print(' merge <name> [--into=<id>] Merge a saved mindmap into current');
+ this.terminal.print(' clone <id> Duplicate a node (and its children)');
+ this.terminal.print(' archive [name] Save to /mindmaps/archive/<name>-<ts>.json');
+ this.terminal.print('', '');
+ this.terminal.print(' ── Editing ──', 'info');
+ this.terminal.print(' add <parent-id> <text> [-t tags] Add a child node');
+ this.terminal.print(' edit <id> <new-text> Rename a node (alias: rename)');
+ this.terminal.print(' move <id> <new-parent-id> Reparent a node (with all children)');
+ this.terminal.print(' remove <id> Remove a node and its children');
+ this.terminal.print(' color <id> <color> Set node color');
+ this.terminal.print(' weight <id> <1-5> Set node weight (affects SVG size)');
+ this.terminal.print(' tag <id> <tag> Add a tag to a node');
+ this.terminal.print(' alias <id> <name> Set a human-readable alias');
+ this.terminal.print(' undo Undo last destructive op (stack of 10)');
+ this.terminal.print('', '');
+ this.terminal.print(' ── Viewing ──', 'info');
+ this.terminal.print(' show [id] Display the mindmap tree');
+ this.terminal.print(' find <text> Search nodes by text (case-insensitive)');
+ this.terminal.print(' stats Node count, depth, tags, colors');
+ this.terminal.print(' tags-list [tag] List nodes with a given tag (or all tags)');
+ this.terminal.print(' diff <name1> <name2> Compare two saved mindmaps');
+ this.terminal.print('', '');
+ this.terminal.print(' ── Export ──', 'info');
+ this.terminal.print(' export <md|json|mermaid> Export in format (default md)');
+ this.terminal.print(' json Print internal JSON');
+ this.terminal.print(' mermaid Print Mermaid mindmap syntax');
+ this.terminal.print(' svg [name] Render inline SVG, optionally save');
+ this.terminal.print(' refresh-html [name] Regenerate standalone architecture HTML');
+ this.terminal.print('', '');
+ this.terminal.print(' ── Files ──', 'info');
+ this.terminal.print(' list List saved mindmaps in VFS');
+ this.terminal.print(' list-loaded List in-memory mindmaps');
+ this.terminal.print(' delete-file <name> Delete a saved mindmap file (alias: delete)');
+ }
+ // ── Helpers ──
+ _genId() {
+ let id, attempts = 0;
+ do {
+ id = Math.random().toString(36).slice(2, 7);
+ attempts++;
+ } while (this._idExists(id) && attempts < 100);
+ if (this._idExists(id)) {
+ id = 'n' + (++this._idCounter).toString(36);
+ }
+ return id;
+ }
+ _idExists(id) {
+ if (!this.current || !this.mindmaps[this.current]) return false;
+ const cur = this.mindmaps[this.current];
+ return this._findNode(id, cur.root) !== null;
}
_nextColor() {
const c = this.colors[this._colorIdx % this.colors.length];
this._colorIdx++;
return c;
}
- _genId() {
- return Math.random().toString(36).slice(2, 6);
+ _ensureMindmap() {
+ if (!this.current || !this.mindmaps[this.current]) return null;
+ return this.mindmaps[this.current];
}
_findNode(id, node) {
+ if (!node) return null;
if (node.id === id) return node;
for (const child of (node.children || [])) {
const found = this._findNode(id, child);
@@ -25,63 +96,71 @@ class MindmapModule {
}
return null;
}
+ _findParent(id, node, parent) {
+ if (!node) return null;
+ if (node.id === id) return parent || null;
+ for (const child of (node.children || [])) {
+ const found = this._findParent(id, child, node);
+ if (found) return found;
+ }
+ return null;
+ }
+ _isDescendant(possible, ancestor) {
+ if (!ancestor.children) return false;
+ for (const c of ancestor.children) {
+ if (c.id === possible.id) return true;
+ if (this._isDescendant(possible, c)) return true;
+ }
+ return false;
+ }
+ _resolveRef(ref) {
+ const cur = this._ensureMindmap();
+ if (!cur) return null;
+ const byId = this._findNode(ref, cur.root);
+ if (byId) return byId;
+ if (cur.aliases && cur.aliases[ref]) {
+ return this._findNode(cur.aliases[ref], cur.root);
+ }
+ return null;
+ }
+ _countNodes(node) {
+ let c = 1;
+ for (const child of (node.children || [])) c += this._countNodes(child);
+ return c;
+ }
+ _maxDepth(node, d) {
+ if (d === undefined) d = 0;
+ if (!node.children || node.children.length === 0) return d;
+ let max = d;
+ for (const c of node.children) {
+ const cd = this._maxDepth(c, d + 1);
+ if (cd > max) max = cd;
+ }
+ return max;
+ }
_treeLines(node, prefix, isLast) {
const lines = [];
- const connector = isLast ? '\u2514\u2500\u2500 ' : '\u251c\u2500\u2500 ';
+ const connector = isLast ? '└── ' : 'β”œβ”€β”€ ';
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);
+ const weightTag = node.weight && node.weight !== 3 ? ' (w:' + node.weight + ')' : '';
+ lines.push(prefix + connector + colorTag + node.text + ' (' + node.id + ')' + tagStr + weightTag);
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));
+ node.children.forEach((child, i) => {
+ const subPrefix = prefix + (isLast ? ' ' : 'β”‚ ');
+ this._treeLines(child, subPrefix, i === node.children.length - 1).forEach(l => lines.push(l));
});
}
return lines;
}
- registerCommands() {
- const self = this;
- this.commands['mindmap'] = (args) => {
- if (!args[0] || args[0] === '--help' || args[0] === '-h') {
- this.terminal.print('Usage: mindmap <new|add|show|export|save|load|list|delete|rename|color|tag|svg> [...]', 'info');
- this.terminal.print(' new <topic> [-t tags] β€” Create a new mindmap', 'info');
- this.terminal.print(' add <parent-id> <text> [-t tags] β€” Add a child node', 'info');
- this.terminal.print(' show β€” Display the mindmap tree', 'info');
- this.terminal.print(' export β€” Print as indented text', 'info');
- this.terminal.print(' svg [name] β€” Render as inline SVG, optionally save to VFS', 'info');
- this.terminal.print(' save [name] β€” Save to VFS', 'info');
- this.terminal.print(' load <name> β€” Load from VFS', 'info');
- this.terminal.print(' list β€” List saved mindmaps', 'info');
- this.terminal.print(' delete <id> β€” Remove a node', 'info');
- this.terminal.print(' rename <id> <new-text>', 'info');
- this.terminal.print(' color <id> <color>', 'info');
- this.terminal.print(' tag <id> [tag1,tag2...]', 'info');
- return;
- }
- const sub = args[0];
- const rest = args.slice(1);
- switch (sub) {
- case '--help':
- case '-h':
- case 'help':
- this.terminal.print('Usage: mindmap <new|add|show|export|save|load|list|delete|rename|color|tag> [...]', 'info');
- return;
- 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);
- case 'svg': return this._svg(rest);
- default: this.terminal.print('Unknown subcommand: ' + sub + '. Try: new, add, show, export, save, load, list, delete, rename, color, tag, svg', 'warning');
- }
- };
+ _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;
}
_parseTags(args) {
const tagIdx = args.indexOf('-t');
@@ -92,27 +171,136 @@ class MindmapModule {
}
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 = {
+ _escapeHtml(s) {
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
+ }
+ _escapeMermaid(s) {
+ return String(s)
+ .replace(/&/g, '&amp;')
+ .replace(/</g, '&lt;')
+ .replace(/>/g, '&gt;')
+ .replace(/\(/g, '&#40;')
+ .replace(/\)/g, '&#41;')
+ .replace(/#/g, '&#35;')
+ .replace(/\[/g, '&#91;')
+ .replace(/\]/g, '&#93;');
+ }
+ _pushUndo(op, inverse, mindmapName) {
+ if (!mindmapName) mindmapName = this.current;
+ this.undoStack.push({ op: op, inverse: inverse, mindmap: mindmapName, ts: Date.now() });
+ if (this.undoStack.length > this.undoLimit) {
+ this.undoStack.shift();
+ }
+ }
+ _cloneNodeWithNewIds(node) {
+ const newNode = {
id: this._genId(),
- text: clean.join(' '),
- color: this._nextColor(),
- tags: tags,
+ text: node.text,
+ color: node.color,
+ tags: node.tags ? [...node.tags] : [],
children: []
};
- this.terminal.print('Created root: [' + this.root.color + '] ' + this.root.text + ' (' + this.root.id + ')', 'success');
+ if (node.weight) newNode.weight = node.weight;
+ if (node.children) {
+ for (const c of node.children) {
+ newNode.children.push(this._cloneNodeWithNewIds(c));
+ }
+ }
+ return newNode;
+ }
+ // ── Layout (improved for scale) ──
+ _layoutTree(root) {
+ const nw = 160, nh = 44, hGap = 24, vGap = 60, pad = 30;
+ // Pass 1: subtree widths
+ const computeWidth = (node) => {
+ if (!node.children || node.children.length === 0) {
+ node._w = nw;
+ return nw;
+ }
+ let totalW = 0;
+ for (const c of node.children) {
+ totalW += computeWidth(c);
+ }
+ totalW += (node.children.length - 1) * hGap;
+ node._w = Math.max(nw, totalW);
+ return node._w;
+ };
+ computeWidth(root);
+ // Pass 2: position
+ const nodes = [], edges = [];
+ let maxY = 0;
+ const position = (node, x, y) => {
+ const w = nw * (node.weight ? node.weight / 3 : 1);
+ const h = nh * (node.weight ? node.weight / 3 : 1);
+ nodes.push({ x: x, y: y, w: w, h: h, text: node.text, color: node.color, id: node.id, weight: node.weight || 3 });
+ if (y + h > maxY) maxY = y + h;
+ if (node.children && node.children.length > 0) {
+ let childOffset = 0;
+ const startX = x + nw / 2 - node._w / 2;
+ for (let i = 0; i < node.children.length; i++) {
+ const c = node.children[i];
+ const cx = startX + childOffset;
+ edges.push({ x1: x + nw / 2, y1: y + h, x2: cx + nw / 2, y2: y + h + vGap });
+ position(c, cx, y + h + vGap);
+ childOffset += c._w + hGap;
+ }
+ }
+ };
+ position(root, pad, pad);
+ // Bounds
+ let minX = Infinity, maxX = 0;
+ for (const n of nodes) {
+ if (n.x < minX) minX = n.x;
+ if (n.x + n.w > maxX) maxX = n.x + n.w;
+ }
+ if (minX === Infinity) { minX = 0; maxX = nw; }
+ return { nodes: nodes, edges: edges, width: (maxX - minX) + pad * 2, height: maxY + pad, minX: minX };
+ }
+ // ── Subcommands ──
+ _new(args) {
+ const parsed = this._parseTags([...args]);
+ const clean = parsed.args, tags = parsed.tags;
+ if (!clean.length) return this.terminal.print('Usage: mindmap new <topic> [-t tag1,tag2]', 'warning');
+ const name = clean.join(' ');
+ if (this.mindmaps[name]) {
+ return this.terminal.print(`Mindmap '${name}' already exists in memory. Use mindmap switch ${name}.`, 'warning');
+ }
+ this.mindmaps[name] = {
+ root: {
+ id: this._genId(),
+ text: name,
+ color: this._nextColor(),
+ tags: tags,
+ children: []
+ },
+ dirty: false,
+ lastSave: null,
+ aliases: {}
+ };
+ this.current = name;
+ const mm = this.mindmaps[name];
+ this.terminal.print('Created mindmap: [' + mm.root.color + '] ' + name + ' (' + mm.root.id + ')', 'success');
+ }
+ _switch(args) {
+ if (!args[0]) return this.terminal.print('Usage: mindmap switch <name>', 'warning');
+ const name = args[0];
+ if (!this.mindmaps[name]) {
+ return this.terminal.print(`Mindmap '${name}' not in memory. Use mindmap load to load a saved one.`, 'error');
+ }
+ this.current = name;
+ const cur = this.mindmaps[name];
+ this.terminal.print(`Switched to: ${name} (${this._countNodes(cur.root)} nodes${cur.dirty ? ', DIRTY' : ''})`, 'info');
}
_add(args) {
- const { args: clean, tags } = this._parseTags([...args]);
+ const parsed = this._parseTags([...args]);
+ const clean = parsed.args, tags = parsed.tags;
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 cur = this._ensureMindmap();
+ if (!cur) return this.terminal.print('No mindmap. Create one with mindmap new first.', 'warning');
+ const parentRef = 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 parent = this._resolveRef(parentRef);
+ if (!parent) return this.terminal.print(`Node not found: ${parentRef}`, 'error');
const node = {
id: this._genId(),
text: text,
@@ -120,201 +308,819 @@ class MindmapModule {
tags: tags,
children: []
};
+ if (!parent.children) parent.children = [];
parent.children.push(node);
+ cur.dirty = true;
+ this._pushUndo('add', { parentId: parent.id, node: JSON.parse(JSON.stringify(node)) });
this.terminal.print('Added [' + node.color + '] ' + text + ' (' + node.id + ')', 'success');
}
+ _edit(args) {
+ return this._rename(args);
+ }
+ _rename(args) {
+ if (args.length < 2) return this.terminal.print('Usage: mindmap rename <id> <new-text>', 'warning');
+ const cur = this._ensureMindmap();
+ if (!cur) return this.terminal.print('No mindmap.', 'warning');
+ const ref = args[0];
+ const text = args.slice(1).join(' ');
+ const node = this._resolveRef(ref);
+ if (!node) return this.terminal.print(`Node not found: ${ref}`, 'error');
+ const oldText = node.text;
+ node.text = text;
+ cur.dirty = true;
+ this._pushUndo('rename', { id: node.id, oldText: oldText, newText: text });
+ this.terminal.print('Renamed ' + ref + ' to: ' + text, 'success');
+ }
+ _move(args) {
+ if (args.length < 2) return this.terminal.print('Usage: mindmap move <id> <new-parent-id>', 'warning');
+ const cur = this._ensureMindmap();
+ if (!cur) return this.terminal.print('No mindmap.', 'warning');
+ const ref = args[0];
+ const newParentRef = args[1];
+ const node = this._resolveRef(ref);
+ if (!node) return this.terminal.print(`Node not found: ${ref}`, 'error');
+ const newParent = this._resolveRef(newParentRef);
+ if (!newParent) return this.terminal.print(`Parent not found: ${newParentRef}`, 'error');
+ if (node.id === newParent.id) return this.terminal.print('Cannot move a node under itself', 'error');
+ if (this._isDescendant(newParent, node)) {
+ return this.terminal.print('Cannot move a node under one of its descendants (would create a cycle)', 'error');
+ }
+ const oldParent = this._findParent(node.id, cur.root);
+ if (!oldParent) return this.terminal.print('Old parent not found (orphan node)', 'error');
+ oldParent.children = oldParent.children.filter(c => c.id !== node.id);
+ if (!newParent.children) newParent.children = [];
+ newParent.children.push(node);
+ cur.dirty = true;
+ this._pushUndo('move', { id: node.id, oldParentId: oldParent.id, newParentId: newParent.id });
+ this.terminal.print(`Moved ${ref} under ${newParentRef}`, 'success');
+ }
+ _removeNode(args) {
+ if (!args[0]) return this.terminal.print('Usage: mindmap remove <id>', 'warning');
+ const cur = this._ensureMindmap();
+ if (!cur) return this.terminal.print('No mindmap.', 'warning');
+ const ref = args[0];
+ const node = this._resolveRef(ref);
+ if (!node) return this.terminal.print(`Node not found: ${ref}`, 'error');
+ if (node.id === cur.root.id) {
+ delete this.mindmaps[this.current];
+ this.current = null;
+ return this.terminal.print('Removed root mindmap. Create a new one with mindmap new.', 'info');
+ }
+ const parent = this._findParent(node.id, cur.root);
+ if (!parent) return this.terminal.print('Parent not found', 'error');
+ const idx = parent.children.findIndex(c => c.id === node.id);
+ const removedNode = parent.children.splice(idx, 1)[0];
+ cur.dirty = true;
+ this._pushUndo('remove', { parentId: parent.id, idx: idx, node: JSON.parse(JSON.stringify(removedNode)) });
+ this.terminal.print(`Removed ${ref} (${this._countNodes(removedNode)} nodes)`, 'success');
+ }
_show(args) {
- if (!this.root) return this.terminal.print('No mindmap. Create one with mindmap new first.', 'warning');
- let node = this.root;
+ const cur = this._ensureMindmap();
+ if (!cur) return this.terminal.print('No mindmap. Create one with mindmap new first.', 'warning');
+ let node = cur.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');
+ const found = this._resolveRef(args[0]);
+ 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');
+ 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;
+ _find(args) {
+ if (!args[0]) return this.terminal.print('Usage: mindmap find <text>', 'warning');
+ const cur = this._ensureMindmap();
+ if (!cur) return this.terminal.print('No mindmap.', 'warning');
+ const q = args.join(' ').toLowerCase();
+ const matches = [];
+ const walk = (node, depth) => {
+ if (node.text.toLowerCase().includes(q) || (node.tags && node.tags.some(t => t.toLowerCase().includes(q)))) {
+ matches.push({ id: node.id, text: node.text, depth: depth, tags: node.tags || [] });
+ }
+ for (const c of (node.children || [])) walk(c, depth + 1);
+ };
+ walk(cur.root, 0);
+ if (matches.length === 0) {
+ return this.terminal.print(`No matches for "${q}"`, 'info');
+ }
+ this.terminal.print(`Found ${matches.length} match(es) for "${q}":`, 'info');
+ for (const m of matches) {
+ const indent = ' '.repeat(m.depth);
+ this.terminal.print(` ${indent}${m.id} ${m.text}${m.tags.length ? ' [' + m.tags.join(', ') + ']' : ''}`);
+ }
}
- _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;
+ _alias(args) {
+ if (args.length < 2) return this.terminal.print('Usage: mindmap alias <id> <name>', 'warning');
+ const cur = this._ensureMindmap();
+ if (!cur) return this.terminal.print('No mindmap.', 'warning');
+ const ref = args[0];
+ const alias = args.slice(1).join(' ');
+ const node = this._resolveRef(ref);
+ if (!node) return this.terminal.print(`Node not found: ${ref}`, 'error');
+ if (cur.aliases[alias] && cur.aliases[alias] !== node.id) {
+ return this.terminal.print(`Alias '${alias}' is already used by node ${cur.aliases[alias]}`, 'warning');
}
- 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);
+ for (const a of Object.keys(cur.aliases)) {
+ if (cur.aliases[a] === node.id) delete cur.aliases[a];
}
+ cur.aliases[alias] = node.id;
+ cur.dirty = true;
+ this.terminal.print(`Aliased ${ref} as '${alias}'`, 'success');
}
- _toMarkdown(node, depth) {
- const prefix = ' '.repeat(depth) + '- ';
- const tagStr = (node.tags && node.tags.length) ? ' *[' + node.tags.join(', ') + ']*' : '';
- let out = prefix + '**' + node.text + '**' + tagStr + '\n';
+ _export(args) {
+ const cur = this._ensureMindmap();
+ if (!cur) return this.terminal.print('No mindmap.', 'warning');
+ const format = (args[0] || 'md').toLowerCase();
+ if (format === 'md' || format === 'markdown') {
+ const md = this._toMarkdown(cur.root, 0);
+ this.terminal.print(md);
+ this.terminal.print('(Markdown copied to clipboard)', 'info');
+ if (navigator.clipboard) navigator.clipboard.writeText(md).catch(() => {});
+ return;
+ } else if (format === 'json') {
+ return this._json(args.slice(1));
+ } else if (format === 'mermaid') {
+ return this._mermaid(args.slice(1));
+ } else {
+ return this.terminal.print(`Unknown format: ${format}. Use md, json, or mermaid.`, 'warning');
+ }
+ }
+ _json(args) {
+ const cur = this._ensureMindmap();
+ if (!cur) return this.terminal.print('No mindmap.', 'warning');
+ const data = { root: cur.root, aliases: cur.aliases };
+ const json = JSON.stringify(data, null, 2);
+ this.terminal.print(json);
+ if (navigator.clipboard) navigator.clipboard.writeText(json).catch(() => {});
+ this.terminal.print(`(${json.length} chars, copied to clipboard)`, 'info');
+ }
+ _mermaid(args) {
+ const cur = this._ensureMindmap();
+ if (!cur) return this.terminal.print('No mindmap.', 'warning');
+ const mm = 'mindmap\n' + this._mermaidWalk(cur.root, 1);
+ this.terminal.print(mm);
+ if (navigator.clipboard) navigator.clipboard.writeText(mm).catch(() => {});
+ this.terminal.print(`(Mermaid syntax, ${mm.length} chars, copied to clipboard)`, 'info');
+ }
+ _mermaidWalk(node, depth) {
+ const indent = ' '.repeat(depth);
+ let out = indent + this._escapeMermaid(node.text) + '\n';
if (node.children) {
- node.children.forEach(c => { out += this._toMarkdown(c, depth + 1); });
+ for (const c of node.children) {
+ out += this._mermaidWalk(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');
+ const cur = this._ensureMindmap();
+ if (!cur) return this.terminal.print('No mindmap to save.', 'warning');
+ const name = args[0] || this.current;
+ if (!name) return this.terminal.print('Usage: mindmap save <name>', 'warning');
try {
const fs = this.terminal.fs;
fs.mkdirRecursive('/mindmaps');
- fs.writeFile('/mindmaps/' + args[0] + '.json', JSON.stringify(this.root, null, 2));
- this.terminal.print('Mindmap saved as: ' + args[0], 'success');
+ const data = { root: cur.root, aliases: cur.aliases };
+ fs.writeFile('/mindmaps/' + name + '.json', JSON.stringify(data, null, 2));
+ cur.dirty = false;
+ cur.lastSave = Date.now();
+ this.terminal.print('Mindmap saved as: ' + name + ' (' + this._countNodes(cur.root) + ' nodes)', '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');
+ if (!args[0]) return this.terminal.print('Usage: mindmap load <name> [--force]', 'warning');
+ const cur = this._ensureMindmap();
+ const force = args.includes('--force');
+ const name = args.filter(a => a !== '--force')[0];
+ if (cur && cur.dirty && !force) {
+ return this.terminal.print(`⚠️ Current mindmap '${this.current}' has unsaved changes. Use --force to load anyway, or save first.`, 'warning');
+ }
try {
const fs = this.terminal.fs;
- const data = fs.readFile('/mindmaps/' + args[0] + '.json');
- 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');
+ const data = fs.readFile('/mindmaps/' + name + '.json');
+ if (!data) return this.terminal.print('No saved mindmap: ' + name, 'error');
+ const parsed = JSON.parse(data);
+ if (parsed.root) {
+ this.mindmaps[name] = {
+ root: parsed.root,
+ dirty: false,
+ lastSave: null,
+ aliases: parsed.aliases || {}
+ };
+ } else {
+ this.mindmaps[name] = {
+ root: parsed,
+ dirty: false,
+ lastSave: null,
+ aliases: {}
+ };
+ }
+ this.current = name;
+ this.terminal.print(`Loaded mindmap: ${name} (${this._countNodes(this.mindmaps[name].root)} nodes)`, 'success');
} catch (e) {
this.terminal.print('Failed to load: ' + e.message, 'error');
}
}
- _list() {
- const fs = this.terminal.fs;
- const files = fs.readDir('/mindmaps') || [];
- const keys = files.filter(f => f.name.endsWith('.json')).map(f => f.name.replace('.json', ''));
- 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');
- const fs = this.terminal.fs;
- fs.deleteFile('/mindmaps/' + args[0] + '.json');
- this.terminal.print('Deleted mindmap: ' + args[0], 'success');
+ _list(args) {
+ try {
+ const fs = this.terminal.fs;
+ const files = fs.readDir('/mindmaps') || [];
+ const keys = files.filter(f => f.name && f.name.endsWith('.json')).map(f => f.name.replace('.json', ''));
+ if (!keys.length) return this.terminal.print('No saved mindmaps.', 'info');
+ this.terminal.print('=== Saved Mindmaps ===', 'info');
+ keys.forEach(k => this.terminal.print(' ' + k));
+ } catch (e) {
+ this.terminal.print('No saved mindmaps (or /mindmaps does not exist).', 'info');
+ }
}
- _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');
+ _listLoaded(args) {
+ const names = Object.keys(this.mindmaps);
+ if (!names.length) return this.terminal.print('No mindmaps in memory. Use mindmap new to create one.', 'info');
+ this.terminal.print('=== In-Memory Mindmaps ===', 'info');
+ for (const name of names) {
+ const mm = this.mindmaps[name];
+ const isCurrent = name === this.current ? ' *' : '';
+ const dirty = mm.dirty ? ' [DIRTY]' : '';
+ this.terminal.print(` ${name}${isCurrent}${dirty} (${this._countNodes(mm.root)} nodes)`);
+ }
+ }
+ _deleteFile(args) {
+ if (!args[0]) return this.terminal.print('Usage: mindmap delete-file <name>', 'warning');
+ try {
+ const fs = this.terminal.fs;
+ fs.deleteFile('/mindmaps/' + args[0] + '.json');
+ this.terminal.print('Deleted mindmap file: ' + args[0], 'success');
+ } catch (e) {
+ this.terminal.print('Failed to delete: ' + e.message, 'error');
+ }
}
_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 cur = this._ensureMindmap();
+ if (!cur) return this.terminal.print('No mindmap.', 'warning');
+ const ref = 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');
+ const node = this._resolveRef(ref);
+ if (!node) return this.terminal.print(`Node not found: ${ref}`, 'error');
+ const oldColor = node.color;
node.color = color;
- this.terminal.print('Set color for ' + id + ' to ' + color, 'success');
+ cur.dirty = true;
+ this._pushUndo('color', { id: node.id, oldColor: oldColor, newColor: color });
+ this.terminal.print('Set color for ' + ref + ' to ' + color, 'success');
+ }
+ _weight(args) {
+ if (args.length < 2) return this.terminal.print('Usage: mindmap weight <id> <1-5>', 'warning');
+ const cur = this._ensureMindmap();
+ if (!cur) return this.terminal.print('No mindmap.', 'warning');
+ const ref = args[0];
+ const w = parseInt(args[1]);
+ if (isNaN(w) || w < 1 || w > 5) return this.terminal.print('Weight must be 1-5', 'warning');
+ const node = this._resolveRef(ref);
+ if (!node) return this.terminal.print(`Node not found: ${ref}`, 'error');
+ const oldWeight = node.weight || 3;
+ node.weight = w;
+ cur.dirty = true;
+ this._pushUndo('weight', { id: node.id, oldWeight: oldWeight, newWeight: w });
+ this.terminal.print('Set weight for ' + ref + ' to ' + w, '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 cur = this._ensureMindmap();
+ if (!cur) return this.terminal.print('No mindmap.', 'warning');
+ const ref = 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');
+ const node = this._resolveRef(ref);
+ if (!node) return this.terminal.print(`Node not found: ${ref}`, 'error');
if (!node.tags) node.tags = [];
+ if (node.tags.includes(tag)) return this.terminal.print(`Tag '${tag}' already on node`, 'info');
node.tags.push(tag);
- this.terminal.print('Added tag "' + tag + '" to ' + id, 'success');
+ cur.dirty = true;
+ this._pushUndo('tag', { id: node.id, tag: tag, action: 'add' });
+ this.terminal.print('Added tag "' + tag + '" to ' + ref, 'success');
+ }
+ _tagsList(args) {
+ const cur = this._ensureMindmap();
+ if (!cur) return this.terminal.print('No mindmap.', 'warning');
+ const tagCounts = {};
+ const tagNodes = {};
+ const walk = (node) => {
+ for (const t of (node.tags || [])) {
+ tagCounts[t] = (tagCounts[t] || 0) + 1;
+ if (!tagNodes[t]) tagNodes[t] = [];
+ tagNodes[t].push({ id: node.id, text: node.text });
+ }
+ for (const c of (node.children || [])) walk(c);
+ };
+ walk(cur.root);
+ if (args[0]) {
+ const tag = args[0];
+ const nodes = tagNodes[tag];
+ if (!nodes) return this.terminal.print(`No nodes with tag '${tag}'`, 'info');
+ this.terminal.print(`Nodes with tag '${tag}':`, 'info');
+ for (const n of nodes) {
+ this.terminal.print(` ${n.id} ${n.text}`);
+ }
+ } else {
+ const tags = Object.keys(tagCounts).sort();
+ if (!tags.length) return this.terminal.print('No tags in current mindmap', 'info');
+ this.terminal.print('Tags in current mindmap:', 'info');
+ for (const t of tags) {
+ this.terminal.print(` ${t} (${tagCounts[t]} node${tagCounts[t] > 1 ? 's' : ''})`);
+ }
+ }
+ }
+ _import(args) {
+ if (!args[0]) return this.terminal.print('Usage: mindmap import <file> [--format=md|json|mermaid]', 'warning');
+ const fs = this.terminal.fs;
+ const file = args[0];
+ const formatFlag = args.find(a => a.startsWith('--format='));
+ let format = formatFlag ? formatFlag.split('=')[1] : null;
+ const content = fs.readFile(file);
+ if (!content) return this.terminal.print(`Cannot read file: ${file}`, 'error');
+ if (!format) {
+ if (file.endsWith('.json')) format = 'json';
+ else if (file.endsWith('.mmd') || content.trimStart().startsWith('mindmap')) format = 'mermaid';
+ else if (file.endsWith('.md')) format = 'md';
+ else format = 'md';
+ }
+ let root;
+ try {
+ if (format === 'json') {
+ const parsed = JSON.parse(content);
+ root = parsed.root || parsed;
+ } else if (format === 'mermaid') {
+ root = this._parseMermaid(content);
+ } else if (format === 'md' || format === 'markdown') {
+ root = this._parseMarkdown(content);
+ } else {
+ return this.terminal.print(`Unknown format: ${format}`, 'error');
+ }
+ } catch (e) {
+ return this.terminal.print(`Parse failed: ${e.message}`, 'error');
+ }
+ if (!root) return this.terminal.print('No content parsed', 'error');
+ this._regenerateIds(root);
+ const name = root.text || 'imported';
+ if (this.mindmaps[name]) {
+ return this.terminal.print(`Mindmap '${name}' already exists in memory`, 'warning');
+ }
+ this.mindmaps[name] = { root: root, dirty: true, lastSave: null, aliases: {} };
+ this.current = name;
+ this.terminal.print(`Imported ${this._countNodes(root)} nodes as '${name}'`, 'success');
+ }
+ _parseMermaid(content) {
+ const lines = content.split('\n').filter(l => l.trim());
+ if (lines[0] && lines[0].trim() === 'mindmap') lines.shift();
+ const stack = [];
+ let root = null;
+ for (const line of lines) {
+ const m = line.match(/^(\s*)/);
+ const depth = m[1].length;
+ const text = line.trim();
+ if (!text) continue;
+ const node = { id: this._genId(), text: text, color: this._nextColor(), tags: [], children: [] };
+ while (stack.length > 0 && stack[stack.length - 1].depth >= depth) {
+ stack.pop();
+ }
+ if (stack.length === 0) {
+ root = node;
+ } else {
+ if (!stack[stack.length - 1].node.children) stack[stack.length - 1].node.children = [];
+ stack[stack.length - 1].node.children.push(node);
+ }
+ stack.push({ node: node, depth: depth });
+ }
+ return root;
+ }
+ _parseMarkdown(content) {
+ const lines = content.split('\n');
+ const stack = [];
+ let root = null;
+ for (const line of lines) {
+ const m = line.match(/^(\s*)- (.*)/);
+ if (!m) continue;
+ const depth = Math.floor(m[1].length / 2);
+ let text = m[2];
+ const tags = [];
+ const boldMatch = text.match(/^\*\*(.+?)\*\*/);
+ if (boldMatch) text = boldMatch[1];
+ const tagMatch = text.match(/\*\[(.+?)\]\*/);
+ if (tagMatch) {
+ tags.push(...tagMatch[1].split(',').map(t => t.trim()));
+ text = text.replace(/\*\[.+?\]\*/, '').trim();
+ }
+ const node = { id: this._genId(), text: text, color: this._nextColor(), tags: tags, children: [] };
+ while (stack.length > 0 && stack[stack.length - 1].depth >= depth) {
+ stack.pop();
+ }
+ if (stack.length === 0) {
+ root = node;
+ } else {
+ if (!stack[stack.length - 1].node.children) stack[stack.length - 1].node.children = [];
+ stack[stack.length - 1].node.children.push(node);
+ }
+ stack.push({ node: node, depth: depth });
+ }
+ return root;
+ }
+ _regenerateIds(node) {
+ node.id = this._genId();
+ if (node.children) {
+ for (const c of node.children) this._regenerateIds(c);
+ }
+ }
+ _merge(args) {
+ if (!args[0]) return this.terminal.print('Usage: mindmap merge <name> [--into=<parent-id>]', 'warning');
+ const cur = this._ensureMindmap();
+ if (!cur) return this.terminal.print('No mindmap.', 'warning');
+ const name = args[0];
+ const intoFlag = args.find(a => a.startsWith('--into='));
+ const targetRef = intoFlag ? intoFlag.split('=')[1] : null;
+ try {
+ const fs = this.terminal.fs;
+ const data = fs.readFile('/mindmaps/' + name + '.json');
+ if (!data) return this.terminal.print(`No saved mindmap: ${name}`, 'error');
+ const parsed = JSON.parse(data);
+ const sourceRoot = parsed.root || parsed;
+ let target = cur.root;
+ if (targetRef) {
+ target = this._resolveRef(targetRef);
+ if (!target) return this.terminal.print(`Target not found: ${targetRef}`, 'error');
+ }
+ const cloned = this._cloneNodeWithNewIds(sourceRoot);
+ if (!target.children) target.children = [];
+ target.children.push(cloned);
+ cur.dirty = true;
+ this._pushUndo('merge', { parentId: target.id, nodeId: cloned.id });
+ this.terminal.print(`Merged ${this._countNodes(sourceRoot)} nodes from '${name}' into '${target.text}' (new root id: ${cloned.id})`, 'success');
+ } catch (e) {
+ this.terminal.print('Merge failed: ' + e.message, 'error');
+ }
+ }
+ _diff(args) {
+ if (args.length < 2) return this.terminal.print('Usage: mindmap diff <name1> <name2>', 'warning');
+ try {
+ const fs = this.terminal.fs;
+ const a = JSON.parse(fs.readFile('/mindmaps/' + args[0] + '.json') || '{}');
+ const b = JSON.parse(fs.readFile('/mindmaps/' + args[1] + '.json') || '{}');
+ const aRoot = a.root || a;
+ const bRoot = b.root || b;
+ if (!aRoot.text || !bRoot.text) return this.terminal.print('Could not load both mindmaps', 'error');
+ const result = this._diffTrees(aRoot, bRoot);
+ this.terminal.print(`=== Diff: ${args[0]} β†’ ${args[1]} ===`, 'header');
+ if (result.added.length === 0 && result.removed.length === 0 && result.modified.length === 0) {
+ return this.terminal.print(' (no differences)', 'info');
+ }
+ if (result.added.length) {
+ this.terminal.print(` + Added (${result.added.length}):`, 'success');
+ for (const a of result.added) this.terminal.print(` + ${a.path} (${a.text})`);
+ }
+ if (result.removed.length) {
+ this.terminal.print(` - Removed (${result.removed.length}):`, 'error');
+ for (const r of result.removed) this.terminal.print(` - ${r.path} (${r.text})`);
+ }
+ if (result.modified.length) {
+ this.terminal.print(` ~ Modified (${result.modified.length}):`, 'warning');
+ for (const m of result.modified) this.terminal.print(` ~ ${m.path}: tags ${JSON.stringify(m.aTags)} β†’ ${JSON.stringify(m.bTags)}`);
+ }
+ } catch (e) {
+ this.terminal.print('Diff failed: ' + e.message, 'error');
+ }
+ }
+ _diffTrees(a, b, path) {
+ if (path === undefined) path = '';
+ const added = [], removed = [], modified = [];
+ const aMap = new Map();
+ const bMap = new Map();
+ if (a.children) for (const c of a.children) aMap.set(c.text, c);
+ if (b.children) for (const c of b.children) bMap.set(c.text, c);
+ for (const text of aMap.keys()) {
+ if (!bMap.has(text)) {
+ removed.push({ path: path + '/' + text, text: text });
+ } else {
+ const aNode = aMap.get(text);
+ const bNode = bMap.get(text);
+ const aTags = JSON.stringify(aNode.tags || []);
+ const bTags = JSON.stringify(bNode.tags || []);
+ if (aTags !== bTags) {
+ modified.push({ path: path + '/' + text, aText: text, bText: text, aTags: aNode.tags, bTags: bNode.tags });
+ }
+ const sub = this._diffTrees(aNode, bNode, path + '/' + text);
+ added.push(...sub.added);
+ removed.push(...sub.removed);
+ modified.push(...sub.modified);
+ }
+ }
+ for (const text of bMap.keys()) {
+ if (!aMap.has(text)) {
+ added.push({ path: path + '/' + text, text: text });
+ }
+ }
+ return { added: added, removed: removed, modified: modified };
+ }
+ _stats(args) {
+ const cur = this._ensureMindmap();
+ if (!cur) return this.terminal.print('No mindmap.', 'warning');
+ const total = this._countNodes(cur.root);
+ const maxDepth = this._maxDepth(cur.root);
+ const levelCounts = {};
+ const colorCounts = {};
+ const tagCounts = {};
+ const walk = (node, d) => {
+ levelCounts[d] = (levelCounts[d] || 0) + 1;
+ if (node.color) colorCounts[node.color] = (colorCounts[node.color] || 0) + 1;
+ for (const t of (node.tags || [])) tagCounts[t] = (tagCounts[t] || 0) + 1;
+ for (const c of (node.children || [])) walk(c, d + 1);
+ };
+ walk(cur.root, 0);
+ this.terminal.print(`πŸ“Š Mindmap Stats: ${this.current}`, 'header');
+ this.terminal.print(` Total nodes: ${total}`);
+ this.terminal.print(` Max depth: ${maxDepth + 1} levels`);
+ this.terminal.print(` Aliases: ${Object.keys(cur.aliases).length}`);
+ this.terminal.print(` Dirty: ${cur.dirty ? 'yes (unsaved changes)' : 'no'}`);
+ if (cur.lastSave) this.terminal.print(` Last save: ${new Date(cur.lastSave).toISOString()}`);
+ this.terminal.print(` ── Nodes per level ──`, 'info');
+ for (const d of Object.keys(levelCounts).sort()) {
+ this.terminal.print(` Level ${d}: ${levelCounts[d]}`);
+ }
+ if (Object.keys(colorCounts).length) {
+ this.terminal.print(` ── Colors ──`, 'info');
+ for (const c of Object.keys(colorCounts)) {
+ this.terminal.print(` ${c}: ${colorCounts[c]}`);
+ }
+ }
+ if (Object.keys(tagCounts).length) {
+ this.terminal.print(` ── Tags (top 10) ──`, 'info');
+ const sorted = Object.entries(tagCounts).sort((a, b) => b[1] - a[1]).slice(0, 10);
+ for (const [t, n] of sorted) {
+ this.terminal.print(` ${t}: ${n}`);
+ }
+ }
+ }
+ _undo(args) {
+ if (this.undoStack.length === 0) {
+ return this.terminal.print('Nothing to undo', 'info');
+ }
+ const entry = this.undoStack.pop();
+ const cur = this.mindmaps[entry.mindmap];
+ if (!cur) {
+ return this.terminal.print(`Cannot undo: mindmap '${entry.mindmap}' no longer exists`, 'error');
+ }
+ this.current = entry.mindmap;
+ switch (entry.op) {
+ case 'add': {
+ const parent = this._findNode(entry.inverse.parentId, cur.root);
+ if (parent) {
+ parent.children = parent.children.filter(c => c.id !== entry.inverse.node.id);
+ }
+ this.terminal.print(`Undid add: removed ${entry.inverse.node.text}`, 'success');
+ break;
+ }
+ case 'remove': {
+ const parent = this._findNode(entry.inverse.parentId, cur.root);
+ if (parent) {
+ parent.children.splice(entry.inverse.idx, 0, entry.inverse.node);
+ }
+ this.terminal.print(`Undid remove: restored ${entry.inverse.node.text}`, 'success');
+ break;
+ }
+ case 'rename': {
+ const node = this._findNode(entry.inverse.id, cur.root);
+ if (node) node.text = entry.inverse.oldText;
+ this.terminal.print(`Undid rename: ${entry.inverse.newText} β†’ ${entry.inverse.oldText}`, 'success');
+ break;
+ }
+ case 'move': {
+ const oldParent = this._findNode(entry.inverse.oldParentId, cur.root);
+ const newParent = this._findNode(entry.inverse.newParentId, cur.root);
+ if (oldParent && newParent) {
+ newParent.children = newParent.children.filter(c => c.id !== entry.inverse.id);
+ const node = this._findNode(entry.inverse.id, cur.root);
+ if (node) oldParent.children.push(node);
+ }
+ this.terminal.print(`Undid move`, 'success');
+ break;
+ }
+ case 'color': {
+ const node = this._findNode(entry.inverse.id, cur.root);
+ if (node) node.color = entry.inverse.oldColor;
+ this.terminal.print(`Undid color change`, 'success');
+ break;
+ }
+ case 'tag': {
+ const node = this._findNode(entry.inverse.id, cur.root);
+ if (node && entry.inverse.action === 'add') {
+ node.tags = node.tags.filter(t => t !== entry.inverse.tag);
+ }
+ this.terminal.print(`Undid tag add`, 'success');
+ break;
+ }
+ case 'weight': {
+ const node = this._findNode(entry.inverse.id, cur.root);
+ if (node) node.weight = entry.inverse.oldWeight;
+ this.terminal.print(`Undid weight change`, 'success');
+ break;
+ }
+ case 'merge': {
+ const parent = this._findNode(entry.inverse.parentId, cur.root);
+ if (parent) {
+ parent.children = parent.children.filter(c => c.id !== entry.inverse.nodeId);
+ }
+ this.terminal.print(`Undid merge`, 'success');
+ break;
+ }
+ }
+ }
+ _clone(args) {
+ if (!args[0]) return this.terminal.print('Usage: mindmap clone <id>', 'warning');
+ const cur = this._ensureMindmap();
+ if (!cur) return this.terminal.print('No mindmap.', 'warning');
+ const node = this._resolveRef(args[0]);
+ if (!node) return this.terminal.print(`Node not found: ${args[0]}`, 'error');
+ const parent = this._findParent(node.id, cur.root);
+ if (!parent) return this.terminal.print('Cannot clone root (use new instead)', 'error');
+ const cloned = this._cloneNodeWithNewIds(node);
+ cloned.text = node.text + ' (copy)';
+ parent.children.push(cloned);
+ cur.dirty = true;
+ this._pushUndo('add', { parentId: parent.id, node: JSON.parse(JSON.stringify(cloned)) });
+ this.terminal.print(`Cloned ${args[0]} as ${cloned.id} (${this._countNodes(cloned)} nodes)`, 'success');
+ }
+ _archive(args) {
+ const cur = this._ensureMindmap();
+ if (!cur) return this.terminal.print('No mindmap.', 'warning');
+ const name = args[0] || this.current;
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
+ const filename = `archive/${name}-${ts}.json`;
+ try {
+ const fs = this.terminal.fs;
+ fs.mkdirRecursive('/mindmaps/archive');
+ const data = { root: cur.root, aliases: cur.aliases };
+ fs.writeFile('/mindmaps/' + filename, JSON.stringify(data, null, 2));
+ this.terminal.print(`Archived to /mindmaps/${filename}`, 'success');
+ this._cleanArchives(name);
+ } catch (e) {
+ this.terminal.print('Archive failed: ' + e.message, 'error');
+ }
+ }
+ _cleanArchives(name) {
+ try {
+ const fs = this.terminal.fs;
+ const dir = fs.readDir('/mindmaps/archive') || [];
+ const matching = dir.filter(f => f.name && f.name.startsWith(name + '-') && f.name.endsWith('.json'))
+ .map(f => f.name)
+ .sort();
+ while (matching.length > 20) {
+ const oldest = matching.shift();
+ fs.deleteFile('/mindmaps/archive/' + oldest);
+ }
+ } catch (e) {}
+ }
+ _refreshHtml(args) {
+ this.terminal.print('πŸ”„ Regenerating architecture HTML from modules-index.json...', 'info');
+ const cacheVer = (typeof localStorage !== 'undefined' && localStorage.getItem('webx11_cache_ver')) || '';
+ fetch('/spaces/newos4/modules-index.json?v=' + cacheVer)
+ .then(r => r.json())
+ .then(idx => {
+ const byLayer = {};
+ for (const m of Object.values(idx.modules)) {
+ const l = m.layer || m.type || 'other';
+ if (!byLayer[l]) byLayer[l] = [];
+ byLayer[l].push(m);
+ }
+ const ver = (typeof window !== 'undefined' && window.NEWOS4_VERSION) || 'v4.5.44';
+ let html = '<!DOCTYPE html>\n<html lang="en">\n<head>\n';
+ html += '<meta charset="UTF-8">\n<meta name="viewport" content="width=device-width, initial-scale=1.0">\n';
+ html += '<title>newOS4 ' + ver + ' β€” Architecture</title>\n';
+ html += '<script src="mermaid.min.js?v=11"><\/script>\n';
+ html += '<style>body{background:#0a0a14;color:#e0e0e0;font-family:Segoe UI,sans-serif;padding:20px;max-width:1400px;margin:0 auto}h1{color:#00ff88;text-align:center;border-bottom:2px solid #00ff88;padding-bottom:12px}.layer{background:#12121e;border-left:3px solid #7c5cfc;padding:12px;margin:12px 0;border-radius:6px}.layer h2{color:#a78bfa;margin-bottom:8px;text-transform:uppercase}.mod{background:#1a1a2e;padding:8px;margin:4px 0;border-radius:4px;border-left:2px solid #2a2a4a}.mod .name{color:#22d3ee;font-weight:600}.mod .cmds{color:#888;font-size:0.9em;margin-top:4px}.mod .desc{color:#aaa;font-size:0.85em;margin-top:2px;font-style:italic}.footer{text-align:center;margin-top:30px;color:#666;font-size:0.85em;border-top:1px solid #2a2a4a;padding-top:12px}</style>\n';
+ html += '</head>\n<body>\n';
+ html += '<h1>πŸ—ΊοΈ newOS4 ' + ver + ' β€” Architecture</h1>\n';
+ html += '<p style="text-align:center;color:#888">Auto-generated from modules-index.json Β· ' + new Date().toISOString() + '</p>\n';
+ for (const layer of Object.keys(byLayer).sort()) {
+ const mods = byLayer[layer];
+ html += '<div class="layer"><h2>' + this._escapeHtml(layer) + ' (' + mods.length + ' modules)</h2>\n';
+ for (const m of mods) {
+ const cmds = m.commands ? m.commands.slice(0, 6).join(', ') + (m.commands.length > 6 ? '...' : '') : '';
+ html += '<div class="mod"><div class="name">' + this._escapeHtml(m.name) + '</div>';
+ if (cmds) html += '<div class="cmds">' + this._escapeHtml(cmds) + '</div>';
+ if (m.description) html += '<div class="desc">' + this._escapeHtml(m.description.substring(0, 80)) + '</div>';
+ html += '</div>\n';
+ }
+ html += '</div>\n';
+ }
+ html += '<div class="footer">Generated by <code>mindmap refresh-html</code> Β· ' + new Date().toISOString() + '</div>\n';
+ html += '</body>\n</html>\n';
+ try {
+ const fs = this.terminal.fs;
+ fs.mkdirRecursive('/mindmaps');
+ const outFile = '/mindmaps/_generated-architecture.html';
+ fs.writeFile(outFile, html);
+ this.terminal.print(`βœ… Wrote ${(html.length / 1024).toFixed(1)}KB to ${outFile}`, 'success');
+ const preview = html.split('\n').slice(0, 25).join('\n');
+ this.terminal.print('--- Preview (first 25 lines) ---', 'info');
+ this.terminal.print(preview);
+ this.terminal.print('--- End preview ---', 'info');
+ this.terminal.print(`πŸ’‘ To deploy: cat ${outFile} > /var/www/webx11.duckdns.org/spaces/newos4/mindmap.html`, 'info');
+ } catch (e) {
+ this.terminal.print('Could not save: ' + e.message, 'error');
+ }
+ })
+ .catch(e => {
+ this.terminal.print('Failed to fetch modules-index.json: ' + e.message, 'error');
+ });
}
_svg(args) {
- if (!this.root) return this.terminal.print('No mindmap. Create one with mindmap new first.', 'warning');
+ const cur = this._ensureMindmap();
+ if (!cur) return this.terminal.print('No mindmap. Create one with mindmap new first.', 'warning');
const saveName = args[0] || null;
-
- // Layout settings
- const nw = 160, nh = 44, hGap = 24, vGap = 60, pad = 30;
-
- // Color mapping from name to hex
+ const layout = this._layoutTree(cur.root);
const colorMap = {
red: '#fb7185', green: '#34d399', yellow: '#fbbf24', blue: '#818cf8',
cyan: '#22d3ee', magenta: '#e879f9', white: '#e0e0e0'
};
-
- const layout = { nodes: [], edges: [], width: 0, height: 0, _y: pad, minX: Infinity, maxX: 0, minY: Infinity, maxY: 0 };
-
- const layoutTree = (node, x) => {
- const myY = layout._y;
- const myX = x;
- const color = colorMap[node.color] || '#818cf8';
- layout.nodes.push({ x: myX, y: myY, w: nw, h: nh, text: node.text, color, id: node.id });
- if (myX < layout.minX) layout.minX = myX;
- const rightEdge = x + nw;
- if (rightEdge > layout.maxX) layout.maxX = rightEdge;
- const bottomEdge = myY + nh;
- if (bottomEdge > layout.maxY) layout.maxY = bottomEdge;
-
- if (node.children && node.children.length > 0) {
- const totalW = node.children.length * nw + (node.children.length - 1) * hGap;
- let cx = x + nw / 2 - totalW / 2;
- layout._y += nh + vGap;
- for (const child of node.children) {
- const childX = cx;
- layout.edges.push({ x1: myX + nw / 2, y1: myY, x2: childX + nw / 2, y2: layout._y - vGap + nh });
- layoutTree(child, childX);
- cx += nw + hGap;
- }
- layout._y -= nh + vGap;
- }
- };
-
- layout._y = pad;
- layoutTree(this.root, pad);
-
- // Normalize coordinates β€” shift so minX becomes pad
- const xShift = (layout.minX === Infinity ? 0 : -layout.minX) + pad;
- layout.height = layout.maxY + pad;
- const svgWidth = (layout.maxX - layout.minX) + pad * 2;
-
- // Build SVG β€” apply xShift to all coordinates
- let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${layout.height}" style="max-width:100%;background:#0a0a14;font-family:monospace;border-radius:8px;">`;
-
- // Edges first (behind nodes)
+ const xShift = layout.minX === Infinity ? 0 : -layout.minX;
+ const svgWidth = layout.width;
+ const svgHeight = layout.height;
+ let svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + svgWidth + ' ' + svgHeight + '" style="max-width:100%;background:#0a0a14;font-family:monospace;border-radius:8px;">';
+ // Edges first
for (const e of layout.edges) {
- const sx = e.x1 + xShift, sy = e.y1;
- const ex = e.x2 + xShift, ey = e.y2;
+ const sx = e.x1 + xShift + 30, sy = e.y1;
+ const ex = e.x2 + xShift + 30, ey = e.y2;
const mx = (sx + ex) / 2;
- svg += `<path d="M${sx},${sy} C${mx},${sy + 20} ${mx},${ey - 20} ${ex},${ey}" fill="none" stroke="#2a2a4a" stroke-width="1.5"/>`;
+ svg += '<path d="M' + sx + ',' + sy + ' C' + mx + ',' + (sy + 20) + ' ' + mx + ',' + (ey - 20) + ' ' + ex + ',' + ey + '" fill="none" stroke="#2a2a4a" stroke-width="1.5"/>';
}
-
- // Nodes on top
+ // Nodes
for (const n of layout.nodes) {
- const nx = n.x + xShift;
- svg += `<rect x="${nx}" y="${n.y}" width="${n.w}" height="${n.h}" rx="6" fill="${n.color}18" stroke="${n.color}" stroke-width="1.5"/>`;
+ const nx = n.x + xShift + 30;
+ const color = colorMap[n.color] || '#818cf8';
+ const fontSize = n.w < 120 ? '9' : '10';
const text = n.text.length > 18 ? n.text.substring(0, 16) + '..' : n.text;
- svg += `<text x="${nx + n.w / 2}" y="${n.y + n.h / 2 + 4}" text-anchor="middle" fill="#e0e0e0" font-size="10" font-family="monospace">${text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}</text>`;
+ svg += '<rect x="' + nx + '" y="' + n.y + '" width="' + n.w + '" height="' + n.h + '" rx="6" fill="' + color + '18" stroke="' + color + '" stroke-width="1.5"/>';
+ svg += '<text x="' + (nx + n.w / 2) + '" y="' + (n.y + n.h / 2 + 4) + '" text-anchor="middle" fill="#e0e0e0" font-size="' + fontSize + '" font-family="monospace">' + this._escapeHtml(text) + '</text>';
}
svg += '</svg>';
-
this.terminal.printHTML(svg);
-
if (saveName) {
try {
const fs = this.terminal.fs;
fs.mkdirRecursive('/mindmaps');
fs.writeFile('/mindmaps/' + saveName + '.svg', svg);
- this.terminal.print('πŸ’Ύ Saved to /mindmaps/' + saveName + '.svg', 'success');
+ this.terminal.print(`πŸ’Ύ Saved to /mindmaps/${saveName}.svg (${layout.nodes.length} nodes, ${svgWidth}x${svgHeight})`, 'success');
} catch (e) {
this.terminal.print('Failed to save SVG: ' + e.message, 'error');
}
}
}
+ // ── Main dispatcher ──
+ registerCommands() {
+ this.commands['mindmap'] = (args) => {
+ if (!args[0] || args[0] === '--help' || args[0] === '-h' || args[0] === 'help') {
+ this._printHelp();
+ return;
+ }
+ const sub = args[0];
+ const rest = args.slice(1);
+ switch (sub) {
+ case 'new': return this._new(rest);
+ case 'add': return this._add(rest);
+ case 'edit': return this._edit(rest);
+ case 'rename': return this._rename(rest);
+ case 'show': return this._show(rest);
+ case 'find': return this._find(rest);
+ case 'export': return this._export(rest);
+ case 'json': return this._json(rest);
+ case 'mermaid': return this._mermaid(rest);
+ case 'save': return this._save(rest);
+ case 'load': return this._load(rest);
+ case 'list': return this._list(rest);
+ case 'list-loaded': return this._listLoaded(rest);
+ case 'switch': return this._switch(rest);
+ case 'delete': return this._deleteFile(rest);
+ case 'delete-file': return this._deleteFile(rest);
+ case 'remove': return this._removeNode(rest);
+ case 'move': return this._move(rest);
+ case 'color': return this._color(rest);
+ case 'weight': return this._weight(rest);
+ case 'tag': return this._tag(rest);
+ case 'tags-list': return this._tagsList(rest);
+ case 'alias': return this._alias(rest);
+ case 'import': return this._import(rest);
+ case 'merge': return this._merge(rest);
+ case 'diff': return this._diff(rest);
+ case 'stats': return this._stats(rest);
+ case 'undo': return this._undo(rest);
+ case 'clone': return this._clone(rest);
+ case 'archive': return this._archive(rest);
+ case 'refresh-html': return this._refreshHtml(rest);
+ case 'svg': return this._svg(rest);
+ default: this.terminal.print('Unknown subcommand: ' + sub + '. Try: mindmap help', 'warning');
+ }
+ };
+ }
}
if (typeof window.webx11 !== 'undefined' && window.webx11) {
webx11.moduleManager.registerModule(new MindmapModule(), 'https://webx11.duckdns.org/spaces/newos4/modules/ext/mindmap.js');