aboutsummaryrefslogtreecommitdiff
path: root/modules/ext/mindmap.js
diff options
context:
space:
mode:
Diffstat (limited to 'modules/ext/mindmap.js')
-rw-r--r--modules/ext/mindmap.js1186
1 files changed, 996 insertions, 190 deletions
diff --git a/modules/ext/mindmap.js b/modules/ext/mindmap.js
index 1daa85b..a5e7286 100644
--- a/modules/ext/mindmap.js
+++ b/modules/ext/mindmap.js
@@ -1,23 +1,94 @@
+// v4.5.44 — Mindmap module: multi-mindmap, find, move, undo, alias, import, merge, diff, stats, mermaid, refresh-html, archive, clone, weight, dirty-flag
class MindmapModule {
constructor() {
+ // Multi-mindmap state
+ this.mindmaps = {}; // {name: {root, dirty, lastSave, aliases}}
+ this.current = null;
+ this.undoLimit = 10;
+ this.undoStack = []; // [{mindmap, op, inverse, ts}]
+ this._idCounter = 0;
+ // Shared
this.commands = {};
this.terminal = null;
- this.root = null;
this.colors = ['red', 'green', 'yellow', 'blue', 'cyan', 'magenta', 'white'];
this._colorIdx = 0;
}
getHelpInfo() {
- return { 'mindmap': 'mindmap <new|add|show|export|save|load|list|delete|rename|color|tag> [...] — Create and manage interactive mindmaps' };
+ return {
+ 'mindmap': 'mindmap <subcommand> [...] — Create, edit, visualize mindmaps. v4.5.44: multi-mindmap, find, move, undo, import, merge, diff, stats, mermaid, refresh-html. Run `mindmap help` for full list.'
+ };
+ }
+ _printHelp() {
+ this.terminal.print('Usage: mindmap <subcommand> [...]', 'info');
+ this.terminal.print('', '');
+ this.terminal.print(' ── Creating ──', 'info');
+ this.terminal.print(' new <topic> [-t tags] Create a new mindmap (in-memory)');
+ this.terminal.print(' switch <name> Switch to a different in-memory mindmap');
+ this.terminal.print(' load <name> [--force] Load a saved mindmap from VFS');
+ this.terminal.print(' save [name] Save current mindmap to VFS');
+ this.terminal.print(' import <file> [--format=...] Import JSON/Mermaid/Markdown from VFS file');
+ this.terminal.print(' merge <name> [--into=<id>] Merge a saved mindmap into current');
+ this.terminal.print(' clone <id> Duplicate a node (and its children)');
+ this.terminal.print(' archive [name] Save to /mindmaps/archive/<name>-<ts>.json');
+ this.terminal.print('', '');
+ this.terminal.print(' ── Editing ──', 'info');
+ this.terminal.print(' add <parent-id> <text> [-t tags] Add a child node');
+ this.terminal.print(' edit <id> <new-text> Rename a node (alias: rename)');
+ this.terminal.print(' move <id> <new-parent-id> Reparent a node (with all children)');
+ this.terminal.print(' remove <id> Remove a node and its children');
+ this.terminal.print(' color <id> <color> Set node color');
+ this.terminal.print(' weight <id> <1-5> Set node weight (affects SVG size)');
+ this.terminal.print(' tag <id> <tag> Add a tag to a node');
+ this.terminal.print(' alias <id> <name> Set a human-readable alias');
+ this.terminal.print(' undo Undo last destructive op (stack of 10)');
+ this.terminal.print('', '');
+ this.terminal.print(' ── Viewing ──', 'info');
+ this.terminal.print(' show [id] Display the mindmap tree');
+ this.terminal.print(' find <text> Search nodes by text (case-insensitive)');
+ this.terminal.print(' stats Node count, depth, tags, colors');
+ this.terminal.print(' tags-list [tag] List nodes with a given tag (or all tags)');
+ this.terminal.print(' diff <name1> <name2> Compare two saved mindmaps');
+ this.terminal.print('', '');
+ this.terminal.print(' ── Export ──', 'info');
+ this.terminal.print(' export <md|json|mermaid> Export in format (default md)');
+ this.terminal.print(' json Print internal JSON');
+ this.terminal.print(' mermaid Print Mermaid mindmap syntax');
+ this.terminal.print(' svg [name] Render inline SVG, optionally save');
+ this.terminal.print(' refresh-html [name] Regenerate standalone architecture HTML');
+ this.terminal.print('', '');
+ this.terminal.print(' ── Files ──', 'info');
+ this.terminal.print(' list List saved mindmaps in VFS');
+ this.terminal.print(' list-loaded List in-memory mindmaps');
+ this.terminal.print(' delete-file <name> Delete a saved mindmap file (alias: delete)');
+ }
+ // ── Helpers ──
+ _genId() {
+ let id, attempts = 0;
+ do {
+ id = Math.random().toString(36).slice(2, 7);
+ attempts++;
+ } while (this._idExists(id) && attempts < 100);
+ if (this._idExists(id)) {
+ id = 'n' + (++this._idCounter).toString(36);
+ }
+ return id;
+ }
+ _idExists(id) {
+ if (!this.current || !this.mindmaps[this.current]) return false;
+ const cur = this.mindmaps[this.current];
+ return this._findNode(id, cur.root) !== null;
}
_nextColor() {
const c = this.colors[this._colorIdx % this.colors.length];
this._colorIdx++;
return c;
}
- _genId() {
- return Math.random().toString(36).slice(2, 6);
+ _ensureMindmap() {
+ if (!this.current || !this.mindmaps[this.current]) return null;
+ return this.mindmaps[this.current];
}
_findNode(id, node) {
+ if (!node) return null;
if (node.id === id) return node;
for (const child of (node.children || [])) {
const found = this._findNode(id, child);
@@ -25,63 +96,71 @@ class MindmapModule {
}
return null;
}
+ _findParent(id, node, parent) {
+ if (!node) return null;
+ if (node.id === id) return parent || null;
+ for (const child of (node.children || [])) {
+ const found = this._findParent(id, child, node);
+ if (found) return found;
+ }
+ return null;
+ }
+ _isDescendant(possible, ancestor) {
+ if (!ancestor.children) return false;
+ for (const c of ancestor.children) {
+ if (c.id === possible.id) return true;
+ if (this._isDescendant(possible, c)) return true;
+ }
+ return false;
+ }
+ _resolveRef(ref) {
+ const cur = this._ensureMindmap();
+ if (!cur) return null;
+ const byId = this._findNode(ref, cur.root);
+ if (byId) return byId;
+ if (cur.aliases && cur.aliases[ref]) {
+ return this._findNode(cur.aliases[ref], cur.root);
+ }
+ return null;
+ }
+ _countNodes(node) {
+ let c = 1;
+ for (const child of (node.children || [])) c += this._countNodes(child);
+ return c;
+ }
+ _maxDepth(node, d) {
+ if (d === undefined) d = 0;
+ if (!node.children || node.children.length === 0) return d;
+ let max = d;
+ for (const c of node.children) {
+ const cd = this._maxDepth(c, d + 1);
+ if (cd > max) max = cd;
+ }
+ return max;
+ }
_treeLines(node, prefix, isLast) {
const lines = [];
- const connector = isLast ? '\u2514\u2500\u2500 ' : '\u251c\u2500\u2500 ';
+ const connector = isLast ? '└── ' : '├── ';
const tagStr = (node.tags && node.tags.length) ? ' [' + node.tags.join(', ') + ']' : '';
const colorTag = node.color ? '[' + node.color + ']' : '';
- lines.push(prefix + connector + colorTag + node.text + ' (' + node.id + ')' + tagStr);
+ const weightTag = node.weight && node.weight !== 3 ? ' (w:' + node.weight + ')' : '';
+ lines.push(prefix + connector + colorTag + node.text + ' (' + node.id + ')' + tagStr + weightTag);
if (node.children) {
- const sorted = node.children;
- sorted.forEach((child, i) => {
- const subPrefix = prefix + (isLast ? ' ' : '\u2502 ');
- this._treeLines(child, subPrefix, i === sorted.length - 1).forEach(l => lines.push(l));
+ node.children.forEach((child, i) => {
+ const subPrefix = prefix + (isLast ? ' ' : '│ ');
+ this._treeLines(child, subPrefix, i === node.children.length - 1).forEach(l => lines.push(l));
});
}
return lines;
}
- registerCommands() {
- const self = this;
- this.commands['mindmap'] = (args) => {
- if (!args[0] || args[0] === '--help' || args[0] === '-h') {
- this.terminal.print('Usage: mindmap <new|add|show|export|save|load|list|delete|rename|color|tag|svg> [...]', 'info');
- this.terminal.print(' new <topic> [-t tags] — Create a new mindmap', 'info');
- this.terminal.print(' add <parent-id> <text> [-t tags] — Add a child node', 'info');
- this.terminal.print(' show — Display the mindmap tree', 'info');
- this.terminal.print(' export — Print as indented text', 'info');
- this.terminal.print(' svg [name] — Render as inline SVG, optionally save to VFS', 'info');
- this.terminal.print(' save [name] — Save to VFS', 'info');
- this.terminal.print(' load <name> — Load from VFS', 'info');
- this.terminal.print(' list — List saved mindmaps', 'info');
- this.terminal.print(' delete <id> — Remove a node', 'info');
- this.terminal.print(' rename <id> <new-text>', 'info');
- this.terminal.print(' color <id> <color>', 'info');
- this.terminal.print(' tag <id> [tag1,tag2...]', 'info');
- return;
- }
- const sub = args[0];
- const rest = args.slice(1);
- switch (sub) {
- case '--help':
- case '-h':
- case 'help':
- this.terminal.print('Usage: mindmap <new|add|show|export|save|load|list|delete|rename|color|tag> [...]', 'info');
- return;
- case 'new': return this._new(rest);
- case 'add': return this._add(rest);
- case 'show': return this._show(rest);
- case 'export': return this._export(rest);
- case 'save': return this._save(rest);
- case 'load': return this._load(rest);
- case 'list': return this._list();
- case 'delete': return this._delete(rest);
- case 'rename': return this._rename(rest);
- case 'color': return this._color(rest);
- case 'tag': return this._tag(rest);
- case 'svg': return this._svg(rest);
- default: this.terminal.print('Unknown subcommand: ' + sub + '. Try: new, add, show, export, save, load, list, delete, rename, color, tag, svg', 'warning');
- }
- };
+ _toMarkdown(node, depth) {
+ const prefix = ' '.repeat(depth) + '- ';
+ const tagStr = (node.tags && node.tags.length) ? ' *[' + node.tags.join(', ') + ']*' : '';
+ let out = prefix + '**' + node.text + '**' + tagStr + '\n';
+ if (node.children) {
+ node.children.forEach(c => { out += this._toMarkdown(c, depth + 1); });
+ }
+ return out;
}
_parseTags(args) {
const tagIdx = args.indexOf('-t');
@@ -92,27 +171,136 @@ class MindmapModule {
}
return { args, tags };
}
- _new(args) {
- const { args: clean, tags } = this._parseTags([...args]);
- if (!clean.length) return this.terminal.print('Usage: mindmap new <topic> [-t tag1,tag2]', 'warning');
- if (this.root) return this.terminal.print('A mindmap already exists. Use mindmap add to extend it.', 'warning');
- this.root = {
+ _escapeHtml(s) {
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
+ }
+ _escapeMermaid(s) {
+ return String(s)
+ .replace(/&/g, '&amp;')
+ .replace(/</g, '&lt;')
+ .replace(/>/g, '&gt;')
+ .replace(/\(/g, '&#40;')
+ .replace(/\)/g, '&#41;')
+ .replace(/#/g, '&#35;')
+ .replace(/\[/g, '&#91;')
+ .replace(/\]/g, '&#93;');
+ }
+ _pushUndo(op, inverse, mindmapName) {
+ if (!mindmapName) mindmapName = this.current;
+ this.undoStack.push({ op: op, inverse: inverse, mindmap: mindmapName, ts: Date.now() });
+ if (this.undoStack.length > this.undoLimit) {
+ this.undoStack.shift();
+ }
+ }
+ _cloneNodeWithNewIds(node) {
+ const newNode = {
id: this._genId(),
- text: clean.join(' '),
- color: this._nextColor(),
- tags: tags,
+ text: node.text,
+ color: node.color,
+ tags: node.tags ? [...node.tags] : [],
children: []
};
- this.terminal.print('Created root: [' + this.root.color + '] ' + this.root.text + ' (' + this.root.id + ')', 'success');
+ if (node.weight) newNode.weight = node.weight;
+ if (node.children) {
+ for (const c of node.children) {
+ newNode.children.push(this._cloneNodeWithNewIds(c));
+ }
+ }
+ return newNode;
+ }
+ // ── Layout (improved for scale) ──
+ _layoutTree(root) {
+ const nw = 160, nh = 44, hGap = 24, vGap = 60, pad = 30;
+ // Pass 1: subtree widths
+ const computeWidth = (node) => {
+ if (!node.children || node.children.length === 0) {
+ node._w = nw;
+ return nw;
+ }
+ let totalW = 0;
+ for (const c of node.children) {
+ totalW += computeWidth(c);
+ }
+ totalW += (node.children.length - 1) * hGap;
+ node._w = Math.max(nw, totalW);
+ return node._w;
+ };
+ computeWidth(root);
+ // Pass 2: position
+ const nodes = [], edges = [];
+ let maxY = 0;
+ const position = (node, x, y) => {
+ const w = nw * (node.weight ? node.weight / 3 : 1);
+ const h = nh * (node.weight ? node.weight / 3 : 1);
+ nodes.push({ x: x, y: y, w: w, h: h, text: node.text, color: node.color, id: node.id, weight: node.weight || 3 });
+ if (y + h > maxY) maxY = y + h;
+ if (node.children && node.children.length > 0) {
+ let childOffset = 0;
+ const startX = x + nw / 2 - node._w / 2;
+ for (let i = 0; i < node.children.length; i++) {
+ const c = node.children[i];
+ const cx = startX + childOffset;
+ edges.push({ x1: x + nw / 2, y1: y + h, x2: cx + nw / 2, y2: y + h + vGap });
+ position(c, cx, y + h + vGap);
+ childOffset += c._w + hGap;
+ }
+ }
+ };
+ position(root, pad, pad);
+ // Bounds
+ let minX = Infinity, maxX = 0;
+ for (const n of nodes) {
+ if (n.x < minX) minX = n.x;
+ if (n.x + n.w > maxX) maxX = n.x + n.w;
+ }
+ if (minX === Infinity) { minX = 0; maxX = nw; }
+ return { nodes: nodes, edges: edges, width: (maxX - minX) + pad * 2, height: maxY + pad, minX: minX };
+ }
+ // ── Subcommands ──
+ _new(args) {
+ const parsed = this._parseTags([...args]);
+ const clean = parsed.args, tags = parsed.tags;
+ if (!clean.length) return this.terminal.print('Usage: mindmap new <topic> [-t tag1,tag2]', 'warning');
+ const name = clean.join(' ');
+ if (this.mindmaps[name]) {
+ return this.terminal.print(`Mindmap '${name}' already exists in memory. Use mindmap switch ${name}.`, 'warning');
+ }
+ this.mindmaps[name] = {
+ root: {
+ id: this._genId(),
+ text: name,
+ color: this._nextColor(),
+ tags: tags,
+ children: []
+ },
+ dirty: false,
+ lastSave: null,
+ aliases: {}
+ };
+ this.current = name;
+ const mm = this.mindmaps[name];
+ this.terminal.print('Created mindmap: [' + mm.root.color + '] ' + name + ' (' + mm.root.id + ')', 'success');
+ }
+ _switch(args) {
+ if (!args[0]) return this.terminal.print('Usage: mindmap switch <name>', 'warning');
+ const name = args[0];
+ if (!this.mindmaps[name]) {
+ return this.terminal.print(`Mindmap '${name}' not in memory. Use mindmap load to load a saved one.`, 'error');
+ }
+ this.current = name;
+ const cur = this.mindmaps[name];
+ this.terminal.print(`Switched to: ${name} (${this._countNodes(cur.root)} nodes${cur.dirty ? ', DIRTY' : ''})`, 'info');
}
_add(args) {
- const { args: clean, tags } = this._parseTags([...args]);
+ const parsed = this._parseTags([...args]);
+ const clean = parsed.args, tags = parsed.tags;
if (clean.length < 2) return this.terminal.print('Usage: mindmap add <parent-id> <text> [-t tag1,tag2]', 'warning');
- if (!this.root) return this.terminal.print('No mindmap. Create one with mindmap new first.', 'warning');
- const parentId = clean[0];
+ const cur = this._ensureMindmap();
+ if (!cur) return this.terminal.print('No mindmap. Create one with mindmap new first.', 'warning');
+ const parentRef = clean[0];
const text = clean.slice(1).join(' ');
- const parent = this._findNode(parentId, this.root);
- if (!parent) return this.terminal.print('Node not found: ' + parentId, 'error');
+ const parent = this._resolveRef(parentRef);
+ if (!parent) return this.terminal.print(`Node not found: ${parentRef}`, 'error');
const node = {
id: this._genId(),
text: text,
@@ -120,201 +308,819 @@ class MindmapModule {
tags: tags,
children: []
};
+ if (!parent.children) parent.children = [];
parent.children.push(node);
+ cur.dirty = true;
+ this._pushUndo('add', { parentId: parent.id, node: JSON.parse(JSON.stringify(node)) });
this.terminal.print('Added [' + node.color + '] ' + text + ' (' + node.id + ')', 'success');
}
+ _edit(args) {
+ return this._rename(args);
+ }
+ _rename(args) {
+ if (args.length < 2) return this.terminal.print('Usage: mindmap rename <id> <new-text>', 'warning');
+ const cur = this._ensureMindmap();
+ if (!cur) return this.terminal.print('No mindmap.', 'warning');
+ const ref = args[0];
+ const text = args.slice(1).join(' ');
+ const node = this._resolveRef(ref);
+ if (!node) return this.terminal.print(`Node not found: ${ref}`, 'error');
+ const oldText = node.text;
+ node.text = text;
+ cur.dirty = true;
+ this._pushUndo('rename', { id: node.id, oldText: oldText, newText: text });
+ this.terminal.print('Renamed ' + ref + ' to: ' + text, 'success');
+ }
+ _move(args) {
+ if (args.length < 2) return this.terminal.print('Usage: mindmap move <id> <new-parent-id>', 'warning');
+ const cur = this._ensureMindmap();
+ if (!cur) return this.terminal.print('No mindmap.', 'warning');
+ const ref = args[0];
+ const newParentRef = args[1];
+ const node = this._resolveRef(ref);
+ if (!node) return this.terminal.print(`Node not found: ${ref}`, 'error');
+ const newParent = this._resolveRef(newParentRef);
+ if (!newParent) return this.terminal.print(`Parent not found: ${newParentRef}`, 'error');
+ if (node.id === newParent.id) return this.terminal.print('Cannot move a node under itself', 'error');
+ if (this._isDescendant(newParent, node)) {
+ return this.terminal.print('Cannot move a node under one of its descendants (would create a cycle)', 'error');
+ }
+ const oldParent = this._findParent(node.id, cur.root);
+ if (!oldParent) return this.terminal.print('Old parent not found (orphan node)', 'error');
+ oldParent.children = oldParent.children.filter(c => c.id !== node.id);
+ if (!newParent.children) newParent.children = [];
+ newParent.children.push(node);
+ cur.dirty = true;
+ this._pushUndo('move', { id: node.id, oldParentId: oldParent.id, newParentId: newParent.id });
+ this.terminal.print(`Moved ${ref} under ${newParentRef}`, 'success');
+ }
+ _removeNode(args) {
+ if (!args[0]) return this.terminal.print('Usage: mindmap remove <id>', 'warning');
+ const cur = this._ensureMindmap();
+ if (!cur) return this.terminal.print('No mindmap.', 'warning');
+ const ref = args[0];
+ const node = this._resolveRef(ref);
+ if (!node) return this.terminal.print(`Node not found: ${ref}`, 'error');
+ if (node.id === cur.root.id) {
+ delete this.mindmaps[this.current];
+ this.current = null;
+ return this.terminal.print('Removed root mindmap. Create a new one with mindmap new.', 'info');
+ }
+ const parent = this._findParent(node.id, cur.root);
+ if (!parent) return this.terminal.print('Parent not found', 'error');
+ const idx = parent.children.findIndex(c => c.id === node.id);
+ const removedNode = parent.children.splice(idx, 1)[0];
+ cur.dirty = true;
+ this._pushUndo('remove', { parentId: parent.id, idx: idx, node: JSON.parse(JSON.stringify(removedNode)) });
+ this.terminal.print(`Removed ${ref} (${this._countNodes(removedNode)} nodes)`, 'success');
+ }
_show(args) {
- if (!this.root) return this.terminal.print('No mindmap. Create one with mindmap new first.', 'warning');
- let node = this.root;
+ const cur = this._ensureMindmap();
+ if (!cur) return this.terminal.print('No mindmap. Create one with mindmap new first.', 'warning');
+ let node = cur.root;
if (args.length && args[0]) {
- const found = this._findNode(args[0], this.root);
- if (!found) return this.terminal.print('Node not found: ' + args[0], 'error');
+ const found = this._resolveRef(args[0]);
+ if (!found) return this.terminal.print(`Node not found: ${args[0]}`, 'error');
node = found;
}
const lines = this._treeLines(node, '', true);
lines.forEach(l => this.terminal.print(l));
- this.terminal.print('(' + node.text + ' — ' + this._countNodes(node) + ' nodes total)', 'info');
+ this.terminal.print(`(${node.text} — ${this._countNodes(node)} nodes total)`, 'info');
}
- _countNodes(node) {
- let c = 1;
- for (const child of (node.children || [])) c += this._countNodes(child);
- return c;
+ _find(args) {
+ if (!args[0]) return this.terminal.print('Usage: mindmap find <text>', 'warning');
+ const cur = this._ensureMindmap();
+ if (!cur) return this.terminal.print('No mindmap.', 'warning');
+ const q = args.join(' ').toLowerCase();
+ const matches = [];
+ const walk = (node, depth) => {
+ if (node.text.toLowerCase().includes(q) || (node.tags && node.tags.some(t => t.toLowerCase().includes(q)))) {
+ matches.push({ id: node.id, text: node.text, depth: depth, tags: node.tags || [] });
+ }
+ for (const c of (node.children || [])) walk(c, depth + 1);
+ };
+ walk(cur.root, 0);
+ if (matches.length === 0) {
+ return this.terminal.print(`No matches for "${q}"`, 'info');
+ }
+ this.terminal.print(`Found ${matches.length} match(es) for "${q}":`, 'info');
+ for (const m of matches) {
+ const indent = ' '.repeat(m.depth);
+ this.terminal.print(` ${indent}${m.id} ${m.text}${m.tags.length ? ' [' + m.tags.join(', ') + ']' : ''}`);
+ }
}
- _export(args) {
- if (!this.root) return this.terminal.print('No mindmap. Create one with mindmap new first.', 'warning');
- let node = this.root;
- if (args.length && args[0]) {
- const found = this._findNode(args[0], this.root);
- if (!found) return this.terminal.print('Node not found: ' + args[0], 'error');
- node = found;
+ _alias(args) {
+ if (args.length < 2) return this.terminal.print('Usage: mindmap alias <id> <name>', 'warning');
+ const cur = this._ensureMindmap();
+ if (!cur) return this.terminal.print('No mindmap.', 'warning');
+ const ref = args[0];
+ const alias = args.slice(1).join(' ');
+ const node = this._resolveRef(ref);
+ if (!node) return this.terminal.print(`Node not found: ${ref}`, 'error');
+ if (cur.aliases[alias] && cur.aliases[alias] !== node.id) {
+ return this.terminal.print(`Alias '${alias}' is already used by node ${cur.aliases[alias]}`, 'warning');
}
- const md = this._toMarkdown(node, 0);
- this.terminal.print(md);
- this.terminal.print('(Markdown copied to clipboard)', 'info');
- if (navigator.clipboard) {
- navigator.clipboard.writeText(md);
+ for (const a of Object.keys(cur.aliases)) {
+ if (cur.aliases[a] === node.id) delete cur.aliases[a];
}
+ cur.aliases[alias] = node.id;
+ cur.dirty = true;
+ this.terminal.print(`Aliased ${ref} as '${alias}'`, 'success');
}
- _toMarkdown(node, depth) {
- const prefix = ' '.repeat(depth) + '- ';
- const tagStr = (node.tags && node.tags.length) ? ' *[' + node.tags.join(', ') + ']*' : '';
- let out = prefix + '**' + node.text + '**' + tagStr + '\n';
+ _export(args) {
+ const cur = this._ensureMindmap();
+ if (!cur) return this.terminal.print('No mindmap.', 'warning');
+ const format = (args[0] || 'md').toLowerCase();
+ if (format === 'md' || format === 'markdown') {
+ const md = this._toMarkdown(cur.root, 0);
+ this.terminal.print(md);
+ this.terminal.print('(Markdown copied to clipboard)', 'info');
+ if (navigator.clipboard) navigator.clipboard.writeText(md).catch(() => {});
+ return;
+ } else if (format === 'json') {
+ return this._json(args.slice(1));
+ } else if (format === 'mermaid') {
+ return this._mermaid(args.slice(1));
+ } else {
+ return this.terminal.print(`Unknown format: ${format}. Use md, json, or mermaid.`, 'warning');
+ }
+ }
+ _json(args) {
+ const cur = this._ensureMindmap();
+ if (!cur) return this.terminal.print('No mindmap.', 'warning');
+ const data = { root: cur.root, aliases: cur.aliases };
+ const json = JSON.stringify(data, null, 2);
+ this.terminal.print(json);
+ if (navigator.clipboard) navigator.clipboard.writeText(json).catch(() => {});
+ this.terminal.print(`(${json.length} chars, copied to clipboard)`, 'info');
+ }
+ _mermaid(args) {
+ const cur = this._ensureMindmap();
+ if (!cur) return this.terminal.print('No mindmap.', 'warning');
+ const mm = 'mindmap\n' + this._mermaidWalk(cur.root, 1);
+ this.terminal.print(mm);
+ if (navigator.clipboard) navigator.clipboard.writeText(mm).catch(() => {});
+ this.terminal.print(`(Mermaid syntax, ${mm.length} chars, copied to clipboard)`, 'info');
+ }
+ _mermaidWalk(node, depth) {
+ const indent = ' '.repeat(depth);
+ let out = indent + this._escapeMermaid(node.text) + '\n';
if (node.children) {
- node.children.forEach(c => { out += this._toMarkdown(c, depth + 1); });
+ for (const c of node.children) {
+ out += this._mermaidWalk(c, depth + 1);
+ }
}
return out;
}
_save(args) {
- if (!args[0]) return this.terminal.print('Usage: mindmap save <name>', 'warning');
- if (!this.root) return this.terminal.print('No mindmap to save.', 'warning');
+ const cur = this._ensureMindmap();
+ if (!cur) return this.terminal.print('No mindmap to save.', 'warning');
+ const name = args[0] || this.current;
+ if (!name) return this.terminal.print('Usage: mindmap save <name>', 'warning');
try {
const fs = this.terminal.fs;
fs.mkdirRecursive('/mindmaps');
- fs.writeFile('/mindmaps/' + args[0] + '.json', JSON.stringify(this.root, null, 2));
- this.terminal.print('Mindmap saved as: ' + args[0], 'success');
+ const data = { root: cur.root, aliases: cur.aliases };
+ fs.writeFile('/mindmaps/' + name + '.json', JSON.stringify(data, null, 2));
+ cur.dirty = false;
+ cur.lastSave = Date.now();
+ this.terminal.print('Mindmap saved as: ' + name + ' (' + this._countNodes(cur.root) + ' nodes)', 'success');
} catch (e) {
this.terminal.print('Failed to save: ' + e.message, 'error');
}
}
_load(args) {
- if (!args[0]) return this.terminal.print('Usage: mindmap load <name>', 'warning');
+ if (!args[0]) return this.terminal.print('Usage: mindmap load <name> [--force]', 'warning');
+ const cur = this._ensureMindmap();
+ const force = args.includes('--force');
+ const name = args.filter(a => a !== '--force')[0];
+ if (cur && cur.dirty && !force) {
+ return this.terminal.print(`⚠️ Current mindmap '${this.current}' has unsaved changes. Use --force to load anyway, or save first.`, 'warning');
+ }
try {
const fs = this.terminal.fs;
- const data = fs.readFile('/mindmaps/' + args[0] + '.json');
- if (!data) return this.terminal.print('No saved mindmap: ' + args[0], 'error');
- this.root = JSON.parse(data);
- this.terminal.print('Loaded mindmap: ' + args[0] + ' (' + this._countNodes(this.root) + ' nodes)', 'success');
+ const data = fs.readFile('/mindmaps/' + name + '.json');
+ if (!data) return this.terminal.print('No saved mindmap: ' + name, 'error');
+ const parsed = JSON.parse(data);
+ if (parsed.root) {
+ this.mindmaps[name] = {
+ root: parsed.root,
+ dirty: false,
+ lastSave: null,
+ aliases: parsed.aliases || {}
+ };
+ } else {
+ this.mindmaps[name] = {
+ root: parsed,
+ dirty: false,
+ lastSave: null,
+ aliases: {}
+ };
+ }
+ this.current = name;
+ this.terminal.print(`Loaded mindmap: ${name} (${this._countNodes(this.mindmaps[name].root)} nodes)`, 'success');
} catch (e) {
this.terminal.print('Failed to load: ' + e.message, 'error');
}
}
- _list() {
- const fs = this.terminal.fs;
- const files = fs.readDir('/mindmaps') || [];
- const keys = files.filter(f => f.name.endsWith('.json')).map(f => f.name.replace('.json', ''));
- if (!keys.length) return this.terminal.print('No saved mindmaps.', 'info');
- this.terminal.print('=== Saved Mindmaps ===', 'info');
- keys.forEach(k => this.terminal.print(' ' + k));
- }
- _delete(args) {
- if (!args[0]) return this.terminal.print('Usage: mindmap delete <name>', 'warning');
- const fs = this.terminal.fs;
- fs.deleteFile('/mindmaps/' + args[0] + '.json');
- this.terminal.print('Deleted mindmap: ' + args[0], 'success');
+ _list(args) {
+ try {
+ const fs = this.terminal.fs;
+ const files = fs.readDir('/mindmaps') || [];
+ const keys = files.filter(f => f.name && f.name.endsWith('.json')).map(f => f.name.replace('.json', ''));
+ if (!keys.length) return this.terminal.print('No saved mindmaps.', 'info');
+ this.terminal.print('=== Saved Mindmaps ===', 'info');
+ keys.forEach(k => this.terminal.print(' ' + k));
+ } catch (e) {
+ this.terminal.print('No saved mindmaps (or /mindmaps does not exist).', 'info');
+ }
}
- _rename(args) {
- if (args.length < 2) return this.terminal.print('Usage: mindmap rename <id> <text>', 'warning');
- if (!this.root) return this.terminal.print('No mindmap.', 'warning');
- const id = args[0];
- const text = args.slice(1).join(' ');
- const node = this._findNode(id, this.root);
- if (!node) return this.terminal.print('Node not found: ' + id, 'error');
- node.text = text;
- this.terminal.print('Renamed ' + id + ' to: ' + text, 'success');
+ _listLoaded(args) {
+ const names = Object.keys(this.mindmaps);
+ if (!names.length) return this.terminal.print('No mindmaps in memory. Use mindmap new to create one.', 'info');
+ this.terminal.print('=== In-Memory Mindmaps ===', 'info');
+ for (const name of names) {
+ const mm = this.mindmaps[name];
+ const isCurrent = name === this.current ? ' *' : '';
+ const dirty = mm.dirty ? ' [DIRTY]' : '';
+ this.terminal.print(` ${name}${isCurrent}${dirty} (${this._countNodes(mm.root)} nodes)`);
+ }
+ }
+ _deleteFile(args) {
+ if (!args[0]) return this.terminal.print('Usage: mindmap delete-file <name>', 'warning');
+ try {
+ const fs = this.terminal.fs;
+ fs.deleteFile('/mindmaps/' + args[0] + '.json');
+ this.terminal.print('Deleted mindmap file: ' + args[0], 'success');
+ } catch (e) {
+ this.terminal.print('Failed to delete: ' + e.message, 'error');
+ }
}
_color(args) {
if (args.length < 2) return this.terminal.print('Usage: mindmap color <id> <color>', 'warning');
- if (!this.root) return this.terminal.print('No mindmap.', 'warning');
- const id = args[0];
+ const cur = this._ensureMindmap();
+ if (!cur) return this.terminal.print('No mindmap.', 'warning');
+ const ref = args[0];
const color = args[1].toLowerCase();
if (!this.colors.includes(color)) return this.terminal.print('Available colors: ' + this.colors.join(', '), 'warning');
- const node = this._findNode(id, this.root);
- if (!node) return this.terminal.print('Node not found: ' + id, 'error');
+ const node = this._resolveRef(ref);
+ if (!node) return this.terminal.print(`Node not found: ${ref}`, 'error');
+ const oldColor = node.color;
node.color = color;
- this.terminal.print('Set color for ' + id + ' to ' + color, 'success');
+ cur.dirty = true;
+ this._pushUndo('color', { id: node.id, oldColor: oldColor, newColor: color });
+ this.terminal.print('Set color for ' + ref + ' to ' + color, 'success');
+ }
+ _weight(args) {
+ if (args.length < 2) return this.terminal.print('Usage: mindmap weight <id> <1-5>', 'warning');
+ const cur = this._ensureMindmap();
+ if (!cur) return this.terminal.print('No mindmap.', 'warning');
+ const ref = args[0];
+ const w = parseInt(args[1]);
+ if (isNaN(w) || w < 1 || w > 5) return this.terminal.print('Weight must be 1-5', 'warning');
+ const node = this._resolveRef(ref);
+ if (!node) return this.terminal.print(`Node not found: ${ref}`, 'error');
+ const oldWeight = node.weight || 3;
+ node.weight = w;
+ cur.dirty = true;
+ this._pushUndo('weight', { id: node.id, oldWeight: oldWeight, newWeight: w });
+ this.terminal.print('Set weight for ' + ref + ' to ' + w, 'success');
}
_tag(args) {
if (args.length < 2) return this.terminal.print('Usage: mindmap tag <id> <tag>', 'warning');
- if (!this.root) return this.terminal.print('No mindmap.', 'warning');
- const id = args[0];
+ const cur = this._ensureMindmap();
+ if (!cur) return this.terminal.print('No mindmap.', 'warning');
+ const ref = args[0];
const tag = args.slice(1).join(' ');
- const node = this._findNode(id, this.root);
- if (!node) return this.terminal.print('Node not found: ' + id, 'error');
+ const node = this._resolveRef(ref);
+ if (!node) return this.terminal.print(`Node not found: ${ref}`, 'error');
if (!node.tags) node.tags = [];
+ if (node.tags.includes(tag)) return this.terminal.print(`Tag '${tag}' already on node`, 'info');
node.tags.push(tag);
- this.terminal.print('Added tag "' + tag + '" to ' + id, 'success');
+ cur.dirty = true;
+ this._pushUndo('tag', { id: node.id, tag: tag, action: 'add' });
+ this.terminal.print('Added tag "' + tag + '" to ' + ref, 'success');
+ }
+ _tagsList(args) {
+ const cur = this._ensureMindmap();
+ if (!cur) return this.terminal.print('No mindmap.', 'warning');
+ const tagCounts = {};
+ const tagNodes = {};
+ const walk = (node) => {
+ for (const t of (node.tags || [])) {
+ tagCounts[t] = (tagCounts[t] || 0) + 1;
+ if (!tagNodes[t]) tagNodes[t] = [];
+ tagNodes[t].push({ id: node.id, text: node.text });
+ }
+ for (const c of (node.children || [])) walk(c);
+ };
+ walk(cur.root);
+ if (args[0]) {
+ const tag = args[0];
+ const nodes = tagNodes[tag];
+ if (!nodes) return this.terminal.print(`No nodes with tag '${tag}'`, 'info');
+ this.terminal.print(`Nodes with tag '${tag}':`, 'info');
+ for (const n of nodes) {
+ this.terminal.print(` ${n.id} ${n.text}`);
+ }
+ } else {
+ const tags = Object.keys(tagCounts).sort();
+ if (!tags.length) return this.terminal.print('No tags in current mindmap', 'info');
+ this.terminal.print('Tags in current mindmap:', 'info');
+ for (const t of tags) {
+ this.terminal.print(` ${t} (${tagCounts[t]} node${tagCounts[t] > 1 ? 's' : ''})`);
+ }
+ }
+ }
+ _import(args) {
+ if (!args[0]) return this.terminal.print('Usage: mindmap import <file> [--format=md|json|mermaid]', 'warning');
+ const fs = this.terminal.fs;
+ const file = args[0];
+ const formatFlag = args.find(a => a.startsWith('--format='));
+ let format = formatFlag ? formatFlag.split('=')[1] : null;
+ const content = fs.readFile(file);
+ if (!content) return this.terminal.print(`Cannot read file: ${file}`, 'error');
+ if (!format) {
+ if (file.endsWith('.json')) format = 'json';
+ else if (file.endsWith('.mmd') || content.trimStart().startsWith('mindmap')) format = 'mermaid';
+ else if (file.endsWith('.md')) format = 'md';
+ else format = 'md';
+ }
+ let root;
+ try {
+ if (format === 'json') {
+ const parsed = JSON.parse(content);
+ root = parsed.root || parsed;
+ } else if (format === 'mermaid') {
+ root = this._parseMermaid(content);
+ } else if (format === 'md' || format === 'markdown') {
+ root = this._parseMarkdown(content);
+ } else {
+ return this.terminal.print(`Unknown format: ${format}`, 'error');
+ }
+ } catch (e) {
+ return this.terminal.print(`Parse failed: ${e.message}`, 'error');
+ }
+ if (!root) return this.terminal.print('No content parsed', 'error');
+ this._regenerateIds(root);
+ const name = root.text || 'imported';
+ if (this.mindmaps[name]) {
+ return this.terminal.print(`Mindmap '${name}' already exists in memory`, 'warning');
+ }
+ this.mindmaps[name] = { root: root, dirty: true, lastSave: null, aliases: {} };
+ this.current = name;
+ this.terminal.print(`Imported ${this._countNodes(root)} nodes as '${name}'`, 'success');
+ }
+ _parseMermaid(content) {
+ const lines = content.split('\n').filter(l => l.trim());
+ if (lines[0] && lines[0].trim() === 'mindmap') lines.shift();
+ const stack = [];
+ let root = null;
+ for (const line of lines) {
+ const m = line.match(/^(\s*)/);
+ const depth = m[1].length;
+ const text = line.trim();
+ if (!text) continue;
+ const node = { id: this._genId(), text: text, color: this._nextColor(), tags: [], children: [] };
+ while (stack.length > 0 && stack[stack.length - 1].depth >= depth) {
+ stack.pop();
+ }
+ if (stack.length === 0) {
+ root = node;
+ } else {
+ if (!stack[stack.length - 1].node.children) stack[stack.length - 1].node.children = [];
+ stack[stack.length - 1].node.children.push(node);
+ }
+ stack.push({ node: node, depth: depth });
+ }
+ return root;
+ }
+ _parseMarkdown(content) {
+ const lines = content.split('\n');
+ const stack = [];
+ let root = null;
+ for (const line of lines) {
+ const m = line.match(/^(\s*)- (.*)/);
+ if (!m) continue;
+ const depth = Math.floor(m[1].length / 2);
+ let text = m[2];
+ const tags = [];
+ const boldMatch = text.match(/^\*\*(.+?)\*\*/);
+ if (boldMatch) text = boldMatch[1];
+ const tagMatch = text.match(/\*\[(.+?)\]\*/);
+ if (tagMatch) {
+ tags.push(...tagMatch[1].split(',').map(t => t.trim()));
+ text = text.replace(/\*\[.+?\]\*/, '').trim();
+ }
+ const node = { id: this._genId(), text: text, color: this._nextColor(), tags: tags, children: [] };
+ while (stack.length > 0 && stack[stack.length - 1].depth >= depth) {
+ stack.pop();
+ }
+ if (stack.length === 0) {
+ root = node;
+ } else {
+ if (!stack[stack.length - 1].node.children) stack[stack.length - 1].node.children = [];
+ stack[stack.length - 1].node.children.push(node);
+ }
+ stack.push({ node: node, depth: depth });
+ }
+ return root;
+ }
+ _regenerateIds(node) {
+ node.id = this._genId();
+ if (node.children) {
+ for (const c of node.children) this._regenerateIds(c);
+ }
+ }
+ _merge(args) {
+ if (!args[0]) return this.terminal.print('Usage: mindmap merge <name> [--into=<parent-id>]', 'warning');
+ const cur = this._ensureMindmap();
+ if (!cur) return this.terminal.print('No mindmap.', 'warning');
+ const name = args[0];
+ const intoFlag = args.find(a => a.startsWith('--into='));
+ const targetRef = intoFlag ? intoFlag.split('=')[1] : null;
+ try {
+ const fs = this.terminal.fs;
+ const data = fs.readFile('/mindmaps/' + name + '.json');
+ if (!data) return this.terminal.print(`No saved mindmap: ${name}`, 'error');
+ const parsed = JSON.parse(data);
+ const sourceRoot = parsed.root || parsed;
+ let target = cur.root;
+ if (targetRef) {
+ target = this._resolveRef(targetRef);
+ if (!target) return this.terminal.print(`Target not found: ${targetRef}`, 'error');
+ }
+ const cloned = this._cloneNodeWithNewIds(sourceRoot);
+ if (!target.children) target.children = [];
+ target.children.push(cloned);
+ cur.dirty = true;
+ this._pushUndo('merge', { parentId: target.id, nodeId: cloned.id });
+ this.terminal.print(`Merged ${this._countNodes(sourceRoot)} nodes from '${name}' into '${target.text}' (new root id: ${cloned.id})`, 'success');
+ } catch (e) {
+ this.terminal.print('Merge failed: ' + e.message, 'error');
+ }
+ }
+ _diff(args) {
+ if (args.length < 2) return this.terminal.print('Usage: mindmap diff <name1> <name2>', 'warning');
+ try {
+ const fs = this.terminal.fs;
+ const a = JSON.parse(fs.readFile('/mindmaps/' + args[0] + '.json') || '{}');
+ const b = JSON.parse(fs.readFile('/mindmaps/' + args[1] + '.json') || '{}');
+ const aRoot = a.root || a;
+ const bRoot = b.root || b;
+ if (!aRoot.text || !bRoot.text) return this.terminal.print('Could not load both mindmaps', 'error');
+ const result = this._diffTrees(aRoot, bRoot);
+ this.terminal.print(`=== Diff: ${args[0]} → ${args[1]} ===`, 'header');
+ if (result.added.length === 0 && result.removed.length === 0 && result.modified.length === 0) {
+ return this.terminal.print(' (no differences)', 'info');
+ }
+ if (result.added.length) {
+ this.terminal.print(` + Added (${result.added.length}):`, 'success');
+ for (const a of result.added) this.terminal.print(` + ${a.path} (${a.text})`);
+ }
+ if (result.removed.length) {
+ this.terminal.print(` - Removed (${result.removed.length}):`, 'error');
+ for (const r of result.removed) this.terminal.print(` - ${r.path} (${r.text})`);
+ }
+ if (result.modified.length) {
+ this.terminal.print(` ~ Modified (${result.modified.length}):`, 'warning');
+ for (const m of result.modified) this.terminal.print(` ~ ${m.path}: tags ${JSON.stringify(m.aTags)} → ${JSON.stringify(m.bTags)}`);
+ }
+ } catch (e) {
+ this.terminal.print('Diff failed: ' + e.message, 'error');
+ }
+ }
+ _diffTrees(a, b, path) {
+ if (path === undefined) path = '';
+ const added = [], removed = [], modified = [];
+ const aMap = new Map();
+ const bMap = new Map();
+ if (a.children) for (const c of a.children) aMap.set(c.text, c);
+ if (b.children) for (const c of b.children) bMap.set(c.text, c);
+ for (const text of aMap.keys()) {
+ if (!bMap.has(text)) {
+ removed.push({ path: path + '/' + text, text: text });
+ } else {
+ const aNode = aMap.get(text);
+ const bNode = bMap.get(text);
+ const aTags = JSON.stringify(aNode.tags || []);
+ const bTags = JSON.stringify(bNode.tags || []);
+ if (aTags !== bTags) {
+ modified.push({ path: path + '/' + text, aText: text, bText: text, aTags: aNode.tags, bTags: bNode.tags });
+ }
+ const sub = this._diffTrees(aNode, bNode, path + '/' + text);
+ added.push(...sub.added);
+ removed.push(...sub.removed);
+ modified.push(...sub.modified);
+ }
+ }
+ for (const text of bMap.keys()) {
+ if (!aMap.has(text)) {
+ added.push({ path: path + '/' + text, text: text });
+ }
+ }
+ return { added: added, removed: removed, modified: modified };
+ }
+ _stats(args) {
+ const cur = this._ensureMindmap();
+ if (!cur) return this.terminal.print('No mindmap.', 'warning');
+ const total = this._countNodes(cur.root);
+ const maxDepth = this._maxDepth(cur.root);
+ const levelCounts = {};
+ const colorCounts = {};
+ const tagCounts = {};
+ const walk = (node, d) => {
+ levelCounts[d] = (levelCounts[d] || 0) + 1;
+ if (node.color) colorCounts[node.color] = (colorCounts[node.color] || 0) + 1;
+ for (const t of (node.tags || [])) tagCounts[t] = (tagCounts[t] || 0) + 1;
+ for (const c of (node.children || [])) walk(c, d + 1);
+ };
+ walk(cur.root, 0);
+ this.terminal.print(`📊 Mindmap Stats: ${this.current}`, 'header');
+ this.terminal.print(` Total nodes: ${total}`);
+ this.terminal.print(` Max depth: ${maxDepth + 1} levels`);
+ this.terminal.print(` Aliases: ${Object.keys(cur.aliases).length}`);
+ this.terminal.print(` Dirty: ${cur.dirty ? 'yes (unsaved changes)' : 'no'}`);
+ if (cur.lastSave) this.terminal.print(` Last save: ${new Date(cur.lastSave).toISOString()}`);
+ this.terminal.print(` ── Nodes per level ──`, 'info');
+ for (const d of Object.keys(levelCounts).sort()) {
+ this.terminal.print(` Level ${d}: ${levelCounts[d]}`);
+ }
+ if (Object.keys(colorCounts).length) {
+ this.terminal.print(` ── Colors ──`, 'info');
+ for (const c of Object.keys(colorCounts)) {
+ this.terminal.print(` ${c}: ${colorCounts[c]}`);
+ }
+ }
+ if (Object.keys(tagCounts).length) {
+ this.terminal.print(` ── Tags (top 10) ──`, 'info');
+ const sorted = Object.entries(tagCounts).sort((a, b) => b[1] - a[1]).slice(0, 10);
+ for (const [t, n] of sorted) {
+ this.terminal.print(` ${t}: ${n}`);
+ }
+ }
+ }
+ _undo(args) {
+ if (this.undoStack.length === 0) {
+ return this.terminal.print('Nothing to undo', 'info');
+ }
+ const entry = this.undoStack.pop();
+ const cur = this.mindmaps[entry.mindmap];
+ if (!cur) {
+ return this.terminal.print(`Cannot undo: mindmap '${entry.mindmap}' no longer exists`, 'error');
+ }
+ this.current = entry.mindmap;
+ switch (entry.op) {
+ case 'add': {
+ const parent = this._findNode(entry.inverse.parentId, cur.root);
+ if (parent) {
+ parent.children = parent.children.filter(c => c.id !== entry.inverse.node.id);
+ }
+ this.terminal.print(`Undid add: removed ${entry.inverse.node.text}`, 'success');
+ break;
+ }
+ case 'remove': {
+ const parent = this._findNode(entry.inverse.parentId, cur.root);
+ if (parent) {
+ parent.children.splice(entry.inverse.idx, 0, entry.inverse.node);
+ }
+ this.terminal.print(`Undid remove: restored ${entry.inverse.node.text}`, 'success');
+ break;
+ }
+ case 'rename': {
+ const node = this._findNode(entry.inverse.id, cur.root);
+ if (node) node.text = entry.inverse.oldText;
+ this.terminal.print(`Undid rename: ${entry.inverse.newText} → ${entry.inverse.oldText}`, 'success');
+ break;
+ }
+ case 'move': {
+ const oldParent = this._findNode(entry.inverse.oldParentId, cur.root);
+ const newParent = this._findNode(entry.inverse.newParentId, cur.root);
+ if (oldParent && newParent) {
+ newParent.children = newParent.children.filter(c => c.id !== entry.inverse.id);
+ const node = this._findNode(entry.inverse.id, cur.root);
+ if (node) oldParent.children.push(node);
+ }
+ this.terminal.print(`Undid move`, 'success');
+ break;
+ }
+ case 'color': {
+ const node = this._findNode(entry.inverse.id, cur.root);
+ if (node) node.color = entry.inverse.oldColor;
+ this.terminal.print(`Undid color change`, 'success');
+ break;
+ }
+ case 'tag': {
+ const node = this._findNode(entry.inverse.id, cur.root);
+ if (node && entry.inverse.action === 'add') {
+ node.tags = node.tags.filter(t => t !== entry.inverse.tag);
+ }
+ this.terminal.print(`Undid tag add`, 'success');
+ break;
+ }
+ case 'weight': {
+ const node = this._findNode(entry.inverse.id, cur.root);
+ if (node) node.weight = entry.inverse.oldWeight;
+ this.terminal.print(`Undid weight change`, 'success');
+ break;
+ }
+ case 'merge': {
+ const parent = this._findNode(entry.inverse.parentId, cur.root);
+ if (parent) {
+ parent.children = parent.children.filter(c => c.id !== entry.inverse.nodeId);
+ }
+ this.terminal.print(`Undid merge`, 'success');
+ break;
+ }
+ }
+ }
+ _clone(args) {
+ if (!args[0]) return this.terminal.print('Usage: mindmap clone <id>', 'warning');
+ const cur = this._ensureMindmap();
+ if (!cur) return this.terminal.print('No mindmap.', 'warning');
+ const node = this._resolveRef(args[0]);
+ if (!node) return this.terminal.print(`Node not found: ${args[0]}`, 'error');
+ const parent = this._findParent(node.id, cur.root);
+ if (!parent) return this.terminal.print('Cannot clone root (use new instead)', 'error');
+ const cloned = this._cloneNodeWithNewIds(node);
+ cloned.text = node.text + ' (copy)';
+ parent.children.push(cloned);
+ cur.dirty = true;
+ this._pushUndo('add', { parentId: parent.id, node: JSON.parse(JSON.stringify(cloned)) });
+ this.terminal.print(`Cloned ${args[0]} as ${cloned.id} (${this._countNodes(cloned)} nodes)`, 'success');
+ }
+ _archive(args) {
+ const cur = this._ensureMindmap();
+ if (!cur) return this.terminal.print('No mindmap.', 'warning');
+ const name = args[0] || this.current;
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
+ const filename = `archive/${name}-${ts}.json`;
+ try {
+ const fs = this.terminal.fs;
+ fs.mkdirRecursive('/mindmaps/archive');
+ const data = { root: cur.root, aliases: cur.aliases };
+ fs.writeFile('/mindmaps/' + filename, JSON.stringify(data, null, 2));
+ this.terminal.print(`Archived to /mindmaps/${filename}`, 'success');
+ this._cleanArchives(name);
+ } catch (e) {
+ this.terminal.print('Archive failed: ' + e.message, 'error');
+ }
+ }
+ _cleanArchives(name) {
+ try {
+ const fs = this.terminal.fs;
+ const dir = fs.readDir('/mindmaps/archive') || [];
+ const matching = dir.filter(f => f.name && f.name.startsWith(name + '-') && f.name.endsWith('.json'))
+ .map(f => f.name)
+ .sort();
+ while (matching.length > 20) {
+ const oldest = matching.shift();
+ fs.deleteFile('/mindmaps/archive/' + oldest);
+ }
+ } catch (e) {}
+ }
+ _refreshHtml(args) {
+ this.terminal.print('🔄 Regenerating architecture HTML from modules-index.json...', 'info');
+ const cacheVer = (typeof localStorage !== 'undefined' && localStorage.getItem('webx11_cache_ver')) || '';
+ fetch('/spaces/newos4/modules-index.json?v=' + cacheVer)
+ .then(r => r.json())
+ .then(idx => {
+ const byLayer = {};
+ for (const m of Object.values(idx.modules)) {
+ const l = m.layer || m.type || 'other';
+ if (!byLayer[l]) byLayer[l] = [];
+ byLayer[l].push(m);
+ }
+ const ver = (typeof window !== 'undefined' && window.NEWOS4_VERSION) || 'v4.5.44';
+ let html = '<!DOCTYPE html>\n<html lang="en">\n<head>\n';
+ html += '<meta charset="UTF-8">\n<meta name="viewport" content="width=device-width, initial-scale=1.0">\n';
+ html += '<title>newOS4 ' + ver + ' — Architecture</title>\n';
+ html += '<script src="mermaid.min.js?v=11"><\/script>\n';
+ html += '<style>body{background:#0a0a14;color:#e0e0e0;font-family:Segoe UI,sans-serif;padding:20px;max-width:1400px;margin:0 auto}h1{color:#00ff88;text-align:center;border-bottom:2px solid #00ff88;padding-bottom:12px}.layer{background:#12121e;border-left:3px solid #7c5cfc;padding:12px;margin:12px 0;border-radius:6px}.layer h2{color:#a78bfa;margin-bottom:8px;text-transform:uppercase}.mod{background:#1a1a2e;padding:8px;margin:4px 0;border-radius:4px;border-left:2px solid #2a2a4a}.mod .name{color:#22d3ee;font-weight:600}.mod .cmds{color:#888;font-size:0.9em;margin-top:4px}.mod .desc{color:#aaa;font-size:0.85em;margin-top:2px;font-style:italic}.footer{text-align:center;margin-top:30px;color:#666;font-size:0.85em;border-top:1px solid #2a2a4a;padding-top:12px}</style>\n';
+ html += '</head>\n<body>\n';
+ html += '<h1>🗺️ newOS4 ' + ver + ' — Architecture</h1>\n';
+ html += '<p style="text-align:center;color:#888">Auto-generated from modules-index.json · ' + new Date().toISOString() + '</p>\n';
+ for (const layer of Object.keys(byLayer).sort()) {
+ const mods = byLayer[layer];
+ html += '<div class="layer"><h2>' + this._escapeHtml(layer) + ' (' + mods.length + ' modules)</h2>\n';
+ for (const m of mods) {
+ const cmds = m.commands ? m.commands.slice(0, 6).join(', ') + (m.commands.length > 6 ? '...' : '') : '';
+ html += '<div class="mod"><div class="name">' + this._escapeHtml(m.name) + '</div>';
+ if (cmds) html += '<div class="cmds">' + this._escapeHtml(cmds) + '</div>';
+ if (m.description) html += '<div class="desc">' + this._escapeHtml(m.description.substring(0, 80)) + '</div>';
+ html += '</div>\n';
+ }
+ html += '</div>\n';
+ }
+ html += '<div class="footer">Generated by <code>mindmap refresh-html</code> · ' + new Date().toISOString() + '</div>\n';
+ html += '</body>\n</html>\n';
+ try {
+ const fs = this.terminal.fs;
+ fs.mkdirRecursive('/mindmaps');
+ const outFile = '/mindmaps/_generated-architecture.html';
+ fs.writeFile(outFile, html);
+ this.terminal.print(`✅ Wrote ${(html.length / 1024).toFixed(1)}KB to ${outFile}`, 'success');
+ const preview = html.split('\n').slice(0, 25).join('\n');
+ this.terminal.print('--- Preview (first 25 lines) ---', 'info');
+ this.terminal.print(preview);
+ this.terminal.print('--- End preview ---', 'info');
+ this.terminal.print(`💡 To deploy: cat ${outFile} > /var/www/webx11.duckdns.org/spaces/newos4/mindmap.html`, 'info');
+ } catch (e) {
+ this.terminal.print('Could not save: ' + e.message, 'error');
+ }
+ })
+ .catch(e => {
+ this.terminal.print('Failed to fetch modules-index.json: ' + e.message, 'error');
+ });
}
_svg(args) {
- if (!this.root) return this.terminal.print('No mindmap. Create one with mindmap new first.', 'warning');
+ const cur = this._ensureMindmap();
+ if (!cur) return this.terminal.print('No mindmap. Create one with mindmap new first.', 'warning');
const saveName = args[0] || null;
-
- // Layout settings
- const nw = 160, nh = 44, hGap = 24, vGap = 60, pad = 30;
-
- // Color mapping from name to hex
+ const layout = this._layoutTree(cur.root);
const colorMap = {
red: '#fb7185', green: '#34d399', yellow: '#fbbf24', blue: '#818cf8',
cyan: '#22d3ee', magenta: '#e879f9', white: '#e0e0e0'
};
-
- const layout = { nodes: [], edges: [], width: 0, height: 0, _y: pad, minX: Infinity, maxX: 0, minY: Infinity, maxY: 0 };
-
- const layoutTree = (node, x) => {
- const myY = layout._y;
- const myX = x;
- const color = colorMap[node.color] || '#818cf8';
- layout.nodes.push({ x: myX, y: myY, w: nw, h: nh, text: node.text, color, id: node.id });
- if (myX < layout.minX) layout.minX = myX;
- const rightEdge = x + nw;
- if (rightEdge > layout.maxX) layout.maxX = rightEdge;
- const bottomEdge = myY + nh;
- if (bottomEdge > layout.maxY) layout.maxY = bottomEdge;
-
- if (node.children && node.children.length > 0) {
- const totalW = node.children.length * nw + (node.children.length - 1) * hGap;
- let cx = x + nw / 2 - totalW / 2;
- layout._y += nh + vGap;
- for (const child of node.children) {
- const childX = cx;
- layout.edges.push({ x1: myX + nw / 2, y1: myY, x2: childX + nw / 2, y2: layout._y - vGap + nh });
- layoutTree(child, childX);
- cx += nw + hGap;
- }
- layout._y -= nh + vGap;
- }
- };
-
- layout._y = pad;
- layoutTree(this.root, pad);
-
- // Normalize coordinates — shift so minX becomes pad
- const xShift = (layout.minX === Infinity ? 0 : -layout.minX) + pad;
- layout.height = layout.maxY + pad;
- const svgWidth = (layout.maxX - layout.minX) + pad * 2;
-
- // Build SVG — apply xShift to all coordinates
- let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${layout.height}" style="max-width:100%;background:#0a0a14;font-family:monospace;border-radius:8px;">`;
-
- // Edges first (behind nodes)
+ const xShift = layout.minX === Infinity ? 0 : -layout.minX;
+ const svgWidth = layout.width;
+ const svgHeight = layout.height;
+ let svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + svgWidth + ' ' + svgHeight + '" style="max-width:100%;background:#0a0a14;font-family:monospace;border-radius:8px;">';
+ // Edges first
for (const e of layout.edges) {
- const sx = e.x1 + xShift, sy = e.y1;
- const ex = e.x2 + xShift, ey = e.y2;
+ const sx = e.x1 + xShift + 30, sy = e.y1;
+ const ex = e.x2 + xShift + 30, ey = e.y2;
const mx = (sx + ex) / 2;
- svg += `<path d="M${sx},${sy} C${mx},${sy + 20} ${mx},${ey - 20} ${ex},${ey}" fill="none" stroke="#2a2a4a" stroke-width="1.5"/>`;
+ svg += '<path d="M' + sx + ',' + sy + ' C' + mx + ',' + (sy + 20) + ' ' + mx + ',' + (ey - 20) + ' ' + ex + ',' + ey + '" fill="none" stroke="#2a2a4a" stroke-width="1.5"/>';
}
-
- // Nodes on top
+ // Nodes
for (const n of layout.nodes) {
- const nx = n.x + xShift;
- svg += `<rect x="${nx}" y="${n.y}" width="${n.w}" height="${n.h}" rx="6" fill="${n.color}18" stroke="${n.color}" stroke-width="1.5"/>`;
+ const nx = n.x + xShift + 30;
+ const color = colorMap[n.color] || '#818cf8';
+ const fontSize = n.w < 120 ? '9' : '10';
const text = n.text.length > 18 ? n.text.substring(0, 16) + '..' : n.text;
- svg += `<text x="${nx + n.w / 2}" y="${n.y + n.h / 2 + 4}" text-anchor="middle" fill="#e0e0e0" font-size="10" font-family="monospace">${text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}</text>`;
+ svg += '<rect x="' + nx + '" y="' + n.y + '" width="' + n.w + '" height="' + n.h + '" rx="6" fill="' + color + '18" stroke="' + color + '" stroke-width="1.5"/>';
+ svg += '<text x="' + (nx + n.w / 2) + '" y="' + (n.y + n.h / 2 + 4) + '" text-anchor="middle" fill="#e0e0e0" font-size="' + fontSize + '" font-family="monospace">' + this._escapeHtml(text) + '</text>';
}
svg += '</svg>';
-
this.terminal.printHTML(svg);
-
if (saveName) {
try {
const fs = this.terminal.fs;
fs.mkdirRecursive('/mindmaps');
fs.writeFile('/mindmaps/' + saveName + '.svg', svg);
- this.terminal.print('💾 Saved to /mindmaps/' + saveName + '.svg', 'success');
+ this.terminal.print(`💾 Saved to /mindmaps/${saveName}.svg (${layout.nodes.length} nodes, ${svgWidth}x${svgHeight})`, 'success');
} catch (e) {
this.terminal.print('Failed to save SVG: ' + e.message, 'error');
}
}
}
+ // ── Main dispatcher ──
+ registerCommands() {
+ this.commands['mindmap'] = (args) => {
+ if (!args[0] || args[0] === '--help' || args[0] === '-h' || args[0] === 'help') {
+ this._printHelp();
+ return;
+ }
+ const sub = args[0];
+ const rest = args.slice(1);
+ switch (sub) {
+ case 'new': return this._new(rest);
+ case 'add': return this._add(rest);
+ case 'edit': return this._edit(rest);
+ case 'rename': return this._rename(rest);
+ case 'show': return this._show(rest);
+ case 'find': return this._find(rest);
+ case 'export': return this._export(rest);
+ case 'json': return this._json(rest);
+ case 'mermaid': return this._mermaid(rest);
+ case 'save': return this._save(rest);
+ case 'load': return this._load(rest);
+ case 'list': return this._list(rest);
+ case 'list-loaded': return this._listLoaded(rest);
+ case 'switch': return this._switch(rest);
+ case 'delete': return this._deleteFile(rest);
+ case 'delete-file': return this._deleteFile(rest);
+ case 'remove': return this._removeNode(rest);
+ case 'move': return this._move(rest);
+ case 'color': return this._color(rest);
+ case 'weight': return this._weight(rest);
+ case 'tag': return this._tag(rest);
+ case 'tags-list': return this._tagsList(rest);
+ case 'alias': return this._alias(rest);
+ case 'import': return this._import(rest);
+ case 'merge': return this._merge(rest);
+ case 'diff': return this._diff(rest);
+ case 'stats': return this._stats(rest);
+ case 'undo': return this._undo(rest);
+ case 'clone': return this._clone(rest);
+ case 'archive': return this._archive(rest);
+ case 'refresh-html': return this._refreshHtml(rest);
+ case 'svg': return this._svg(rest);
+ default: this.terminal.print('Unknown subcommand: ' + sub + '. Try: mindmap help', 'warning');
+ }
+ };
+ }
}
if (typeof window.webx11 !== 'undefined' && window.webx11) {
webx11.moduleManager.registerModule(new MindmapModule(), 'https://webx11.duckdns.org/spaces/newos4/modules/ext/mindmap.js');