From 4fa3c56a74b33d7c385aa3c486ebf4363448394a Mon Sep 17 00:00:00 2001 From: Martin-PB Date: Sat, 17 Oct 2026 14:17:45 +0200 Subject: [PATCH 1/2] Open and write MindManager .mmap maps MindManager keeps its maps in a zip holding Document.xml (X5 and later; the Mac builds of MindManager 10 wrote flat XML), and Cartocortex now reads and writes that. Reading is the strict XML parser from the old .xmind files (no DOCTYPE, predefined entities only): texts, notes (the XHTML note and its plain-text preview), links, task priorities (Prio1-9) and progress (TaskPercentage), fill and text colour ("aarrggbb", alpha first), folding, relationships (as the map's relationship lines), floating topics and callouts. The growth direction of the central topic opens as a logic-left, logic-right or two-sided map. Writing goes the other way (Save as .mmap and a new "Export MindManager" button): one map per sheet, so extra sheets may not show in MindManager itself, and topic links to other topics, pictures, per-word style, boundaries and summaries are not carried. The unzip of the .xmind reader is exported for the .mmap reader. Agent: Vibe (Mistral, glm-5-latest-short) Relu-par-humain: Martin-PB --- README.md | 6 +- src/App.tsx | 2 + src/files.test.ts | 6 ++ src/files.ts | 12 ++- src/mindmanager.test.ts | 111 ++++++++++++++++++++++ src/mindmanager.ts | 206 ++++++++++++++++++++++++++++++++++++++++ src/xmind.ts | 2 +- 7 files changed, 338 insertions(+), 7 deletions(-) create mode 100644 src/mindmanager.test.ts create mode 100644 src/mindmanager.ts diff --git a/README.md b/README.md index 3d6676f..cdcbcf1 100644 --- a/README.md +++ b/README.md @@ -99,11 +99,13 @@ Saving (desktop app): ⌘S saves to the current file, and asks where the first t MIT, see [LICENSE](LICENSE). -Outline formats: Open reads Markdown (`.md`, headings and nested lists; `- [x]` task boxes become 100% progress, the first link becomes the topic's link) and OPML (`.opml`, with notes, links and checked items). "Export OPML" writes one top-level outline per sheet, and Save as can write `.opml`, `.md`, `.xmind` or FreeMind `.mm` as well as `.json`. +Outline formats: Open reads Markdown (`.md`, headings and nested lists; `- [x]` task boxes become 100% progress, the first link becomes the topic's link) and OPML (`.opml`, with notes, links and checked items). "Export OPML" writes one top-level outline per sheet, and Save as can write `.opml`, `.md`, `.xmind` or FreeMind `.mm` or MindManager `.mmap` as well as `.json`. FreeMind / Freeplane: Open reads FreeMind's `.mm` maps (plain or gzipped) — topics, notes, links, links between topics, folding, fill and text colours, bold / italic, priority and progress icons — and left-side topics open as a one-sided or two-sided map. "Export FreeMind" (and Save as `.mm`) writes one root node per sheet, so extra sheets may not show in FreeMind itself; multi-line texts keep their lines, but pictures, relationships, summaries, per-word style, floating positions and structures beyond the sideways layouts are not carried. -Safety rule for opened files: only Cartocortex's own `.json` keeps its place on disk. Opening a `.xmind`, `.opml`, FreeMind or Markdown file is an *import*: the map starts as unsaved, and ⌘S asks for a new file rather than overwriting your original with a lossy copy. +MindManager: Open reads MindManager's `.mmap` maps — a zip holding `Document.xml`, or the flat XML of the MindManager 10 for Mac builds — with topics, notes, links, task priorities and progress, fill and text colours, folding, relationships (as relationship lines), floating topics and callouts; the growth direction of the central topic opens as Logic (right), Logic (left) or Map. "Export MindManager" (and Save as `.mmap`) writes one map per sheet, so extra sheets may not show in MindManager itself; topic links to other topics, pictures, per-word style, boundaries and summaries are not carried. The format was taken from Freeplane's MindManager importer and WiseMapping's, and has not been checked against MindManager itself. + +Safety rule for opened files: only Cartocortex's own `.json` keeps its place on disk. Opening a `.xmind`, `.opml`, FreeMind, MindManager or Markdown file is an *import*: the map starts as unsaved, and ⌘S asks for a new file rather than overwriting your original with a lossy copy. Links between topics: in the panel, "Link to a topic" → "Pick a topic…", then click the target. The source gets a ↪ badge (and "Go ↪" in the panel); clicking it opens any collapsed branches above the target and selects it, also when the target is on another sheet. In `.xmind` files a topic link is `xmind:#`: it survives a round trip, and XMind allows one link per topic, so an outside web link wins if a topic has both. diff --git a/src/App.tsx b/src/App.tsx index 235c4de..c7999c7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import { import { exportXmind } from "./xmind"; import { toOpml } from "./outline-io"; import { toFreemind } from "./freemind"; +import { toMindmanager } from "./mindmanager"; import { downscale } from "./image"; import { TEMPLATES } from "./templates"; import { customColors, customTheme } from "./theme"; @@ -599,6 +600,7 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP { label: "Markdown (.md)", on: () => saveFile(fileName("md"), "md", toMarkdown(s.root)) }, { label: "OPML (.opml)", on: () => saveFile(fileName("opml"), "opml", toOpml(park().roots)) }, { label: "FreeMind (.mm)", on: () => saveFile(fileName("mm"), "mm", toFreemind(park().roots)) }, + { label: "MindManager (.mmap)", on: () => saveFile(fileName("mmap"), "mmap", toMindmanager(park().roots)) }, { label: "Image (.png)", on: () => exportImage("png") }, { label: "Document (.pdf)", on: () => exportImage("pdf") }, ]} /> diff --git a/src/files.test.ts b/src/files.test.ts index 8fecea7..08f3788 100644 --- a/src/files.test.ts +++ b/src/files.test.ts @@ -38,6 +38,12 @@ test("documents are written and read in the format their extension names", async const backMm = await parseDoc("plan.mm", mm); expect(backMm.roots.map((r) => r.text)).toEqual(["Plan", "Second"]); // one root node per sheet expect(backMm.roots[0].children[0].text).toBe("child"); + + const mmap = docBytes("/tmp/plan.mmap", roots, 1); + expect(new TextDecoder().decode(mmap.slice(0, 2))).toBe("PK"); // a zip + const backMmap = await parseDoc("plan.mmap", mmap); + expect(backMmap.roots.map((r) => r.text)).toEqual(["Plan", "Second"]); // one map per sheet + expect(backMmap.roots[0].children[0].text).toBe("child"); }); const store = new Map(); diff --git a/src/files.ts b/src/files.ts index 1f2f594..501ab3c 100644 --- a/src/files.ts +++ b/src/files.ts @@ -1,5 +1,6 @@ import { Book, bookJson, parseBook } from "./book"; import { fromFreemindBytes, toFreemind } from "./freemind"; +import { fromMindmanagerBytes, toMindmanager } from "./mindmanager"; import { Node, toMarkdown } from "./model"; import { fromMarkdown, fromOpml, toOpml } from "./outline-io"; import { exportXmind, importXmindSheets } from "./xmind"; @@ -17,23 +18,25 @@ export const pushRecent = (list: string[], path: string, max = 8) => [path, ...l /** Only Cartocortex's own format keeps its place on disk when opened; every other format is an import (Save asks for a new file). */ export const keepsPath = (name: string) => extOf(name) === "json"; -/** What to write for a document saved at `path`, by extension: XMind, OPML, Markdown, FreeMind, otherwise Cartocortex JSON. */ +/** What to write for a document saved at `path`, by extension: XMind, OPML, Markdown, FreeMind, MindManager, otherwise Cartocortex JSON. */ export function docBytes(path: string, roots: Node[], cur: number): Uint8Array { const ext = extOf(path), text = (s: string) => new TextEncoder().encode(s); if (ext === "xmind") return exportXmind(roots); if (ext === "opml") return text(toOpml(roots)); if (ext === "md" || ext === "markdown") return text(roots.map((r) => toMarkdown(r)).join("\n")); if (ext === "mm") return text(toFreemind(roots)); + if (ext === "mmap") return toMindmanager(roots); return text(bookJson(roots, cur, true)); } -/** The document in a file, by its extension. A Markdown or OPML file gives one sheet, named after the file if it has no single top topic; a FreeMind map gives one sheet per root topic. */ +/** The document in a file, by its extension. A Markdown or OPML file gives one sheet, named after the file if it has no single top topic; a FreeMind or MindManager map gives one sheet per root topic. */ export async function parseDoc(name: string, bytes: Uint8Array): Promise { const ext = extOf(name), text = () => new TextDecoder().decode(bytes); if (ext === "xmind") return { roots: await importXmindSheets(bytes), cur: 0 }; if (ext === "opml") return { roots: [fromOpml(text())], cur: 0 }; if (ext === "md" || ext === "markdown" || ext === "txt") return { roots: [fromMarkdown(text(), baseName(name).replace(/\.[^.]*$/, ""))], cur: 0 }; if (ext === "mm") return { roots: await fromFreemindBytes(bytes), cur: 0 }; + if (ext === "mmap") return { roots: await fromMindmanagerBytes(bytes), cur: 0 }; return parseBook(text()); } @@ -70,11 +73,11 @@ export async function writePath(path: string, bytes: Uint8Array) { export async function pickOpen(): Promise { if (tauri()) { const { open } = await import("@tauri-apps/plugin-dialog"); - const path = await open({ filters: [{ name: "Mind map or outline", extensions: ["json", "xmind", "opml", "md", "markdown", "mm"] }] }); + const path = await open({ filters: [{ name: "Mind map or outline", extensions: ["json", "xmind", "opml", "md", "markdown", "mm", "mmap"] }] }); return typeof path === "string" ? readPath(path) : null; } return new Promise((res) => { - const i = Object.assign(document.createElement("input"), { type: "file", accept: ".json,.xmind,.opml,.md,.markdown,.mm" }); + const i = Object.assign(document.createElement("input"), { type: "file", accept: ".json,.xmind,.opml,.md,.markdown,.mm,.mmap" }); i.onchange = async () => { const f = i.files?.[0]; res(f ? { name: f.name, path: null, bytes: new Uint8Array(await f.arrayBuffer()) } : null); @@ -109,6 +112,7 @@ export async function pickSavePath(defaultName: string): Promise { name: "XMind (.xmind)", extensions: ["xmind"] }, { name: "OPML outline (.opml)", extensions: ["opml"] }, { name: "FreeMind / Freeplane (.mm)", extensions: ["mm"] }, + { name: "MindManager (.mmap)", extensions: ["mmap"] }, { name: "Markdown outline (.md)", extensions: ["md"] }, ], }); diff --git a/src/mindmanager.test.ts b/src/mindmanager.test.ts new file mode 100644 index 0000000..ba68670 --- /dev/null +++ b/src/mindmanager.test.ts @@ -0,0 +1,111 @@ +import { expect, test } from "vitest"; +import { mk, Node } from "./model"; +import { fromMindmanager, fromMindmanagerBytes, toMindmanager } from "./mindmanager"; +import { zip } from "./xmind"; + +const DOC = ` + + + + + + +

a note

on two lines

+ + + + + + + + +
+
+ + + + + + +
`; + +test("reads a MindManager document", () => { + const [root] = fromMindmanager(DOC); + expect(root.text).toBe("Central"); + expect(root.note).toBe("a note\non two lines"); + expect(root.link).toBe("https://example.com"); + expect(root.color).toBe("#0d9488"); + expect(root.textColor).toBe("#1e3a5f"); + expect(root.priority).toBe(1); + expect(root.progress).toBe(40); + expect(root.collapsed).toBe(true); + expect(root.layout).toBe("map"); // MindManager grows both sides by default + expect(root.children.map((c) => c.text)).toEqual(["Right", "Left", "Floating"]); // the map grows right first; floating topics follow + expect(root.children[1].children.map((c) => c.text)).toEqual(["Deep", "Callout"]); // a callout opens as a plain child + expect(root.children[2].float).toEqual({ x: -378, y: 227 }); // millimetres -> pixels + expect(root.rels).toHaveLength(1); + expect(root.rels![0].from).toBe(root.children[1].id); // remapped to our topic ids + expect(root.rels![0].to).toBe(root.children[0].id); +}); + +test("reads the simplified schema some tools write", () => { + const [root] = fromMindmanager(` +plain note`); + expect(root.text).toBe("Root"); + expect(root.note).toBe("plain note"); + expect(root.link).toBe("https://x.org"); + expect(root.children[0].text).toBe("Child"); + expect(root.children[0].priority).toBe(2); +}); + +test("refuses what is not a MindManager map", async () => { + expect(() => fromMindmanager(``)).toThrow(); // no DOCTYPE, ever + expect(() => fromMindmanager(``)).toThrow(/no topic/); + expect(() => fromMindmanager(`not a mind map`)).toThrow(); + await expect(fromMindmanagerBytes(zip({ "empty.txt": "x" }))).rejects.toThrow(/Document.xml/); +}); + +test("writes a zip MindManager re-opens", async () => { + const root: Node = { + ...mk("Central\ntopic"), + note: "note\nlines", + link: "https://example.org", + priority: 3, + progress: 60, + color: "#0d9488", + textColor: "#1e3a5f", + children: [mk("Child"), { ...mk("Folded"), collapsed: true, children: [mk("Grand")] }], + }; + root.rels = [{ id: crypto.randomUUID(), from: root.children[1].children[0].id, to: root.children[0].id }]; + const bytes = toMindmanager([root, mk("Second sheet")]); + expect([bytes[0], bytes[1]]).toEqual([0x50, 0x4b]); // a zip + const back = await fromMindmanagerBytes(bytes); + expect(back.map((r) => r.text)).toEqual(["Central\ntopic", "Second sheet"]); // one map per sheet, newlines kept + const [r] = back; + expect(r.note).toBe("note\nlines"); + expect(r.link).toBe("https://example.org"); + expect(r.priority).toBe(3); + expect(r.progress).toBe(60); + expect(r.color).toBe("#0d9488"); + expect(r.textColor).toBe("#1e3a5f"); + expect(r.children[1].collapsed).toBe(true); + expect(r.rels).toHaveLength(1); + expect(r.rels![0].from).toBe(r.children[1].children[0].id); + expect(r.rels![0].to).toBe(r.children[0].id); +}); + +test("the growth direction picks the layout, and flat XML opens", async () => { + const doc = (dir: string) => ` +`; + expect(fromMindmanager(doc("Left"))[0].layout).toBe("left"); + expect(fromMindmanager(doc("Right"))[0].layout).toBeUndefined(); // the app's default chart grows right + const flat = await fromMindmanagerBytes(new TextEncoder().encode(doc("Right"))); // not a zip: flat XML + expect(flat[0].children[0].text).toBe("A"); +}); + +test("MindManager colours: alpha first, #rrggbb kept, junk dropped", () => { + const one = (v: string) => ``; + const roots = fromMindmanager(`${one("00ffffff")}${one("ff0d9488")}${one("#12ab34")}${one("red")}`); + expect(roots.map((r) => r.color)).toEqual([undefined, "#0d9488", "#12ab34", undefined]); +}); diff --git a/src/mindmanager.ts b/src/mindmanager.ts new file mode 100644 index 0000000..97f5098 --- /dev/null +++ b/src/mindmanager.ts @@ -0,0 +1,206 @@ +import { Node } from "./model"; +import { parseXml, XNode } from "./xmindxml"; +import { unzip, zip } from "./xmind"; + +// Reader and writer for MindManager's .mmap format: a zip holding Document.xml (MindManager X5 and +// later), or the flat XML of the MindManager 10 for Mac builds that did not zip. Reading reuses the +// strict parser from the old .xmind files (no DOCTYPE, predefined entities only). Writing is +// deliberately lossy: one map per sheet, and only what the Document.xml schema knows. + +const kid = (n: XNode | undefined, name: string) => n?.children.find((c) => c.name === name); +const kids = (n: XNode | undefined, name: string) => (n?.children ?? []).filter((c) => c.name === name); + +/** Attribute lookup, case-insensitive: files in the wild mix Url with URL, PlainText with plaintext. */ +function attr(n: XNode | undefined, name: string): string | undefined { + const lower = name.toLowerCase(); + for (const [k, v] of Object.entries(n?.attrs ?? {})) if (k.toLowerCase() === lower) return v; + return undefined; +} + +/** "#rrggbb" from a MindManager colour, which is "aarrggbb" (alpha first; a 00 alpha is no colour); a plain #rrggbb is kept. */ +function mmColor(v: string | undefined): string | undefined { + const s = v?.trim().replace(/^#/, ""); + if (!s || !/^[0-9a-f]{6}([0-9a-f]{2})?$/i.test(s)) return undefined; + if (s.length === 8) return /^00/i.test(s) ? undefined : "#" + s.slice(2).toLowerCase(); + return "#" + s.toLowerCase(); +} + +/** The task priority (1-9) of a MindManager priority id ("urn:mindjet:Prio3"). */ +const prioOf = (v: string | undefined): number | undefined => { + const m = /^urn:mindjet:Prio([1-9])$/i.exec(v ?? ""); + return m ? Number(m[1]) : undefined; +}; + +/** The task progress of a TaskPercentage (0-100) clamped into range; undefined when there is none. */ +const progressOf = (v: string | undefined): number | undefined => { + if (v === undefined || !v.trim()) return undefined; + const n = Number(v); + return Number.isFinite(n) ? Math.min(100, Math.max(0, Math.round(n))) : undefined; +}; + +/** A topic's subtopics: the Topics of its SubTopics, then any direct Topic children (the simplified schema some tools write). */ +const subTopicsOf = (t: XNode): XNode[] => [...kids(kid(t, "SubTopics"), "Topic"), ...kids(t, "Topic")]; + +// MindManager positions are in millimetres; the canvas works in pixels (96 dpi). +const PX_PER_MM = 96 / 25.4; + +/** One MindManager topic -> one Cartocortex topic. `depth` 0 is the central topic of a map. */ +function topicFromXml(t: XNode, ids: Map, depth: number): Node { + const children = subTopicsOf(t).map((c) => topicFromXml(c, ids, depth + 1)); + // The floating topics of the central topic float on the canvas; those of any other topic are its callouts, which open as plain children. + if (depth > 0) children.push(...kids(kid(t, "FloatingTopics"), "Topic").map((c) => topicFromXml(c, ids, depth + 1))); + const textEl = kid(t, "Text"); + const text = attr(textEl, "PlainText") ?? attr(t, "Text") ?? ""; + const note = (attr(kid(kid(t, "NotesGroup"), "NotesXhtmlData"), "PreviewPlainText") ?? kid(t, "Notes")?.text.trim() ?? "").trim(); + const url = attr(kid(t, "Hyperlink"), "Url"); + const link = url && !url.startsWith("#") ? url : undefined; // a "#xpointer(...)" Url points at another topic: dropped + const task = kid(t, "Task"); + const icons = [...kids(kid(kid(t, "IconsGroup"), "Icons"), "Icon"), ...kids(t, "Icon")]; + const priority = prioOf(attr(task, "TaskPriority")) ?? icons.map((i) => prioOf(attr(i, "IconType"))).find((p) => p !== undefined); + const progress = progressOf(attr(task, "TaskPercentage")); + const colorEl = kid(t, "Color"); + const fill = mmColor(attr(colorEl, "FillColor") ?? attr(colorEl, "Value")); + const ink = mmColor(attr(kid(textEl, "Font"), "Color")); + const view = kids(t, "TopicViewGroup").find((g) => attr(g, "ViewIndex") === "0") ?? kids(t, "TopicViewGroup")[0]; + const folded = attr(kid(view, "Collapsed"), "Collapsed")?.toLowerCase() === "true"; + const node: Node = { + id: crypto.randomUUID(), + text, + children, + ...(note ? { note } : {}), + ...(link ? { link } : {}), + ...(fill ? { color: fill } : {}), + ...(ink ? { textColor: ink } : {}), + ...(folded && children.length ? { collapsed: true } : {}), + ...(priority ? { priority } : {}), + ...(progress !== undefined ? { progress } : {}), + }; + const oid = attr(t, "OId") ?? attr(t, "ID"); + if (oid) ids.set(oid, node.id); // for relationships: MindManager's id -> ours + return node; +} + +/** The root topic: an ordinary topic plus the layout its subtopics' growth direction picks, and the map's floating topics. */ +function rootFromXml(x: XNode, ids: Map, map: XNode | undefined): Node { + const node = topicFromXml(x, ids, 0); + const sides = subTopicsOf(x).map((t) => (Number(attr(kid(t, "Offset"), "CX")) < 0 ? -1 : 1)); + const direction = growthOf(x, map); + if (direction === "Left") node.layout = "left"; // everything grows to the left, in order + else if (direction !== "Right") { + // Both sides (MindManager's default): a two-sided map grows its right half first, so right-side topics come first. + node.layout = "map"; + node.children = node.children.filter((_, i) => sides[i] >= 0).concat(node.children.filter((_, i) => sides[i] < 0)); + } + for (const f of kids(kid(x, "FloatingTopics"), "Topic")) { + const n = topicFromXml(f, ids, 1); + const off = kid(f, "Offset"); + n.float = { x: Math.round((Number(attr(off, "CX")) || 0) * PX_PER_MM), y: Math.round((Number(attr(off, "CY")) || 0) * PX_PER_MM) }; + node.children.push(n); + } + return node; +} + +/** Where the central topic's subtopics grow, from its own SubTopicsShape or the StyleGroup default: "Left", "Right" or both. */ +function growthOf(x: XNode, map: XNode | undefined): string { + const defaults = kid(kid(kid(map, "StyleGroup"), "RootTopicDefaultsGroup"), "DefaultSubTopicsShape"); + const dir = attr(kid(x, "SubTopicsShape"), "SubTopicsGrowthDirection") ?? attr(defaults, "SubTopicsGrowthDirection") ?? ""; + return dir.replace(/^urn:mindjet:/i, ""); +} + +/** The map's relationships, their ends remapped to our topic ids; one pointing at a missing topic is dropped. */ +function relsOf(map: XNode, ids: Map): NonNullable { + const out: NonNullable = []; + for (const rel of kids(kid(map, "Relationships"), "Relationship")) { + const end = (index: string) => { + const g = kids(rel, "ConnectionGroup").find((c) => attr(c, "Index") === index); + return ids.get(attr(kid(kid(g, "Connection"), "ObjectReference"), "OIdRef") ?? ""); + }; + const from = ids.get(attr(rel, "FromTopicID") ?? "") ?? end("0"); + const to = ids.get(attr(rel, "ToTopicID") ?? "") ?? end("1"); + if (from && to) out.push({ id: crypto.randomUUID(), from, to }); + } + return out; +} + +/** The root topics of a .mmap map. The growth direction of the central topic opens as a logic (left), logic (right) or two-sided (map) chart. */ +export function fromMindmanager(src: string): Node[] { + const root = parseXml(src); // throws on DOCTYPE, on anything but plain XML + const map = root.name === "Map" ? root : undefined; + const ids = new Map(); + const tops = map + ? [...kids(map, "OneTopic").map((o) => kid(o, "Topic")), ...kids(map, "Topic")].filter((t): t is XNode => !!t) + : root.name === "Topic" + ? [root] + : []; + const roots = tops.map((x) => rootFromXml(x, ids, map)); + if (!roots.length) throw new Error("no topic found in this mind map"); + const rels = map ? relsOf(map, ids) : []; + if (rels.length) roots[0].rels = rels; // relationship lines live on the open sheet's root topic + return roots; +} + +/** A .mmap file: a zip holding Document.xml (magic bytes "PK"), or flat XML. */ +export async function fromMindmanagerBytes(bytes: Uint8Array): Promise { + if (bytes[0] === 0x50 && bytes[1] === 0x4b) { + const files = await unzip(bytes); + const doc = [...files.entries()].find(([name]) => name.toLowerCase() === "document.xml"); + if (!doc) throw new Error("no Document.xml in this MindManager file"); + return fromMindmanager(new TextDecoder().decode(doc[1])); + } + return fromMindmanager(new TextDecoder().decode(bytes)); // the flat XML of MindManager 10 for Mac +} + +const esc = (s: string) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!); +const escAttr = (s: string) => esc(s).replace(/\n/g, " "); // a literal newline in an attribute would read back as a space + +/** MindManager writes colours as "aarrggbb", alpha first. */ +const mmHex = (c: string | undefined) => (c && /^#[0-9a-f]{6}$/i.test(c) ? "ff" + c.slice(1).toLowerCase() : undefined); + +/** One Cartocortex topic -> one MindManager topic. */ +function topicToXml(n: Node, mm: Map): string { + const ink = mmHex(n.textColor); + const inner = [`` : "/>"}`]; + const fill = mmHex(n.color); + if (fill) inner.push(``); + if (n.link) inner.push(``); + if (n.note) { + const body = n.note.split("\n").map((l) => `

${esc(l)}

`).join(""); + inner.push(`${body}`); + } + if (n.priority || n.progress != null) { + const p = n.priority ? ` TaskPriority="urn:mindjet:Prio${Math.min(9, Math.max(1, Math.round(n.priority)))}"` : ""; + const g = n.progress != null ? ` TaskPercentage="${Math.min(100, Math.max(0, Math.round(n.progress)))}"` : ""; + inner.push(``); + } + if (n.collapsed && n.children.length) inner.push(``); + if (n.children.length) inner.push(`${n.children.map((c) => topicToXml(c, mm)).join("")}`); + return `${inner.join("")}`; +} + +/** A .mmap file from Cartocortex sheets: one map per sheet, so extra sheets may not show in MindManager itself. */ +export function toMindmanager(roots: Node[]): Uint8Array { + const mm = new Map(); + const claim = (n: Node) => (mm.set(n.id, crypto.randomUUID()), n.children.forEach(claim)); + roots.forEach(claim); // every id first: a relationship may point at a topic that comes later + const oneTopics = roots.map((r) => `${topicToXml(r, mm)}`).join(""); + const rels = ((roots[0]?.rels ?? []) as NonNullable) + .filter((r) => mm.has(r.from) && mm.has(r.to)) + .map( + (r) => + `` + + ["0", "1"] + .map((i) => ``) + .join("") + + ``, + ) + .join(""); + const document = + `\n` + + `${oneTopics}${rels ? `${rels}` : ""}`; + return zip({ + "Document.xml": document, + // The OPC parts of the archive, kept minimal. + "[Content_Types].xml": `\n\n`, + "_rels/.rels": `\n\n`, + }); +} diff --git a/src/xmind.ts b/src/xmind.ts index 299331e..64a5c42 100644 --- a/src/xmind.ts +++ b/src/xmind.ts @@ -60,7 +60,7 @@ export function zip(files: Record): Uint8Array { const inflate = async (b: Uint8Array) => new Uint8Array(await new Response(new Blob([b as BlobPart]).stream().pipeThrough(new DecompressionStream("deflate-raw"))).arrayBuffer()); -async function unzip(b: Uint8Array): Promise> { +export async function unzip(b: Uint8Array): Promise> { const v = new DataView(b.buffer, b.byteOffset, b.byteLength); let e = b.length - 22; while (e >= 0 && v.getUint32(e, true) !== 0x06054b50) e--; From 807ec4e224d5f05b539544785bfd7ff1b08491fb Mon Sep 17 00:00:00 2001 From: Martin-PB Date: Sun, 18 Oct 2026 05:12:00 +0200 Subject: [PATCH 2/2] Compare gzipped and plain .mm reads without their random ids Reading mints a fresh topic id for every node, so the two reads of the same sample never agreed on their ids and the test could not pass. The comparison now drops the ids and the topic links pointing at them, the same fix the FreeMind branch already carries. Agent: Vibe (Mistral, glm-5-latest-short) Relu-par-humain: Martin-PB --- src/freemind.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/freemind.test.ts b/src/freemind.test.ts index e020bab..f3b569a 100644 --- a/src/freemind.test.ts +++ b/src/freemind.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "vitest"; import { fromFreemind, fromFreemindBytes, toFreemind } from "./freemind"; -import { mk } from "./model"; +import { Node, mk } from "./model"; const sample = ` @@ -48,10 +48,13 @@ test("left-side topics everywhere open as a one-sided chart", () => { expect(roots[0].children.map((c) => c.text)).toEqual(["A", "B"]); }); +// Reading mints fresh ids (and remaps topic links onto them), so compare the maps without them. +const noIds = (r: Node[]) => JSON.stringify(r, (k, v) => (k === "id" || k === "topicLink" ? undefined : v)); + test("a gzipped .mm opens like a plain one", async () => { const plain = new TextEncoder().encode(sample); const gz = new Uint8Array(await new Response(new Blob([plain as BlobPart]).stream().pipeThrough(new CompressionStream("gzip"))).arrayBuffer()); - expect(await fromFreemindBytes(gz)).toEqual(await fromFreemindBytes(plain)); + expect(noIds(await fromFreemindBytes(gz))).toBe(noIds(await fromFreemindBytes(plain))); }); test("strict reading: a DOCTYPE, non-XML and a map without a topic are rejected", () => {