// 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.colors = ['red', 'green', 'yellow', 'blue', 'cyan', 'magenta', 'white']; this._colorIdx = 0; } getHelpInfo() { return { 'mindmap': 'mindmap [...] — 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 [...]', 'info'); this.terminal.print('', ''); this.terminal.print(' ── Creating ──', 'info'); this.terminal.print(' new [-t tags] Create a new mindmap (in-memory)'); this.terminal.print(' switch Switch to a different in-memory mindmap'); this.terminal.print(' load [--force] Load a saved mindmap from VFS'); this.terminal.print(' save [name] Save current mindmap to VFS'); this.terminal.print(' import [--format=...] Import JSON/Mermaid/Markdown from VFS file'); this.terminal.print(' merge [--into=] Merge a saved mindmap into current'); this.terminal.print(' clone Duplicate a node (and its children)'); this.terminal.print(' archive [name] Save to /mindmaps/archive/-.json'); this.terminal.print('', ''); this.terminal.print(' ── Editing ──', 'info'); this.terminal.print(' add [-t tags] Add a child node'); this.terminal.print(' edit Rename a node (alias: rename)'); this.terminal.print(' move Reparent a node (with all children)'); this.terminal.print(' remove Remove a node and its children'); this.terminal.print(' color Set node color'); this.terminal.print(' weight <1-5> Set node weight (affects SVG size)'); this.terminal.print(' tag Add a tag to a node'); this.terminal.print(' alias 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 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 Compare two saved mindmaps'); this.terminal.print('', ''); this.terminal.print(' ── Export ──', 'info'); this.terminal.print(' export 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 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; } _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); if (found) return found; } 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 ? '└── ' : '├── '; const tagStr = (node.tags && node.tags.length) ? ' [' + node.tags.join(', ') + ']' : ''; const colorTag = node.color ? '[' + node.color + ']' : ''; const weightTag = node.weight && node.weight !== 3 ? ' (w:' + node.weight + ')' : ''; lines.push(prefix + connector + colorTag + node.text + ' (' + node.id + ')' + tagStr + weightTag); if (node.children) { 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; } _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'); let tags = []; if (tagIdx >= 0) { tags = args[tagIdx + 1] ? args[tagIdx + 1].split(',').map(t => t.trim()).filter(Boolean) : []; args.splice(tagIdx, 2); } return { args, tags }; } _escapeHtml(s) { return String(s).replace(/&/g, '&').replace(//g, '>'); } _escapeMermaid(s) { return String(s) .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: node.text, color: node.color, tags: node.tags ? [...node.tags] : [], children: [] }; 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 [-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 ', '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 parsed = this._parseTags([...args]); const clean = parsed.args, tags = parsed.tags; if (clean.length < 2) return this.terminal.print('Usage: mindmap add [-t tag1,tag2]', 'warning'); 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._resolveRef(parentRef); if (!parent) return this.terminal.print(`Node not found: ${parentRef}`, 'error'); const node = { id: this._genId(), text: text, color: this._nextColor(), 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 ', '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 ', '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 ', '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) { 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._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'); } _find(args) { if (!args[0]) return this.terminal.print('Usage: mindmap find ', '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(', ') + ']' : ''}`); } } _alias(args) { if (args.length < 2) return this.terminal.print('Usage: mindmap alias ', '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'); } 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'); } _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) { for (const c of node.children) { out += this._mermaidWalk(c, depth + 1); } } return out; } _save(args) { 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 ', 'warning'); try { const fs = this.terminal.fs; fs.mkdirRecursive('/mindmaps'); 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 [--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/' + 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(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'); } } _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 ', '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 ', 'warning'); 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._resolveRef(ref); if (!node) return this.terminal.print(`Node not found: ${ref}`, 'error'); const oldColor = node.color; node.color = color; 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 <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 ', 'warning'); 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._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); 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 [--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 [--into=]', '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 ', '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 ', '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 = '\n\n\n'; html += '\n\n'; html += 'newOS4 ' + ver + ' — Architecture\n'; html += '