diff options
| author | Agando <roduccomm@gmail.com> | 2026-06-11 18:00:19 +0200 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-06-11 18:00:19 +0200 |
| commit | 76e26bb6f16e35bffbf76896957bb9753241833c (patch) | |
| tree | 25bd27029c2cad1d46208bb3ae026e8272e0a6bf | |
| parent | c3d5fe64c268d7ca85d1e303b98201391f28c9e9 (diff) | |
auto: 2026-06-11_18:00 changes
| -rw-r--r-- | help.html | 58 | ||||
| -rw-r--r-- | main.html | 2 | ||||
| -rw-r--r-- | modules/core/module-loader.js | 14 | ||||
| -rw-r--r-- | modules/core/module-modmgr.js | 321 | ||||
| -rw-r--r-- | modules/core/module-servermon.js | 37 | ||||
| -rw-r--r-- | modules/core/module-system.js | 292 | ||||
| -rw-r--r-- | modules/ext/mindmap.js | 1186 |
7 files changed, 1702 insertions, 208 deletions
@@ -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 <text></code> searches all node text + tags (case-insensitive). Returns matching IDs and depth.</li> + <li><strong>Move nodes</strong> β <code>mindmap move <id> <new-parent-id></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 <id> <name></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 <name></code> still deletes a FILE (backward compat). New <code>mindmap remove <id></code> removes a NODE. <code>mindmap delete-file</code> is the explicit form.</li> + <li><strong>Import</strong> β <code>mindmap import <file></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 <name> [--into=<id>]</code> adds a saved mindmap under the current root (or specified parent). Conflict-free: just appends.</li> + <li><strong>Diff</strong> β <code>mindmap diff <name1> <name2></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 [<tag>]</code> shows all tags + counts, or all nodes with a specific tag.</li> + <li><strong>Clone</strong> β <code>mindmap clone <id></code> duplicates a node and its children with new IDs. Text gets " (copy)" suffix.</li> + <li><strong>Archive</strong> β <code>mindmap archive <name></code> saves to <code>/mindmaps/archive/<name>-<ts>.json</code>. Auto-cleans to last 20.</li> + <li><strong>Weight</strong> β <code>mindmap weight <id> <1-5></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 <file> > 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=<pat> --flat</code> flags, and shows aggregate summary (size, count, failed, disabled).</li> + <li><strong><code>module-info <name></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-<timestamp>.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> @@ -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, '&').replace(/</g, '<').replace(/>/g, '>'); + } + _escapeMermaid(s) { + return String(s) + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/\(/g, '(') + .replace(/\)/g, ')') + .replace(/#/g, '#') + .replace(/\[/g, '[') + .replace(/\]/g, ']'); + } + _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,'&').replace(/</g,'<').replace(/>/g,'>')}</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'); |