diff --git a/README.md b/README.md index 7c13ecf..9238e94 100644 --- a/README.md +++ b/README.md @@ -99,9 +99,17 @@ 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` or `.xmind` 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`, MindManager `.mmap`, Mindomo `.mom` or WiseMapping `.wxml` as well as `.json`. -Safety rule for opened files: only Cartocortex's own `.json` keeps its place on disk. Opening a `.xmind`, `.opml` 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. +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. + +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. + +WiseMapping: Open reads WiseMapping's `.wxml` maps — the current "tango" format and the older "pela" files — with topics (text on a line or multi-line blocks), notes, links, fill and text colours, bold / italic, shapes (box and line), folding, the map's relationships (as relationship lines) and its free branches (as floating topics, positions kept); the side a topic sits on comes from its position, so a left-leaning map opens as Logic (left) or Map. "Export WiseMapping" (and Save as `.wxml`) writes the first sheet as the map and the sheets after it as WiseMapping's floating branches, with positions worked out from the tree shape; markers keep their emoji, but priorities, progress, pictures, per-word style, boundaries, summaries and topic links are not carried. The format was taken from WiseMapping's own serializer and importer, and has not been checked against WiseMapping itself. + +Mindomo: Open reads Mindomo's `.mom` maps — the zips of JSON parts Mindomo Desktop 11 writes — with topics (titles keep their line breaks), folding, video and web links, and the map's relations (as relationship lines); topics the file marks as deleted, and everything under them, are quietly skipped, a root besides the map's own opens as its own sheet, and the map's name labels the sheet. "Export Mindomo" (and Save as `.mom`) writes one map per sheet, so extra sheets may not show in Mindomo itself. Notes, colours, markers, priorities, pictures, per-word style, boundaries and summaries are not carried; the older Mindomo XML and `.pmom` files are not read. The format was taken apart from files saved by Mindomo Desktop 11, and has not been checked against Mindomo itself. + +Safety rule for opened files: only Cartocortex's own `.json` keeps its place on disk. Opening a `.xmind`, `.opml`, FreeMind, MindManager, Mindomo, WiseMapping 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 84f8c05..87fe8b7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,10 @@ import { } from "./model"; import { exportXmind } from "./xmind"; import { toOpml } from "./outline-io"; +import { toFreemind } from "./freemind"; +import { toMindmanager } from "./mindmanager"; +import { toMindomo } from "./mindomo"; +import { toWisemapping } from "./wisemapping"; import { downscale } from "./image"; import { TEMPLATES } from "./templates"; import { customColors, customTheme } from "./theme"; @@ -597,6 +601,10 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP { label: "XMind (.xmind)", on: () => saveFile(fileName("xmind"), "xmind", exportXmind(park().roots)) }, { 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: "Mindomo (.mom)", on: () => saveFile(fileName("mom"), "mom", toMindomo(park().roots)) }, + { label: "WiseMapping (.wxml)", on: () => saveFile(fileName("wxml"), "wxml", toWisemapping(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 14ab949..17d28d0 100644 --- a/src/files.test.ts +++ b/src/files.test.ts @@ -32,6 +32,31 @@ test("documents are written and read in the format their extension names", async expect(back.roots.map((r) => r.text)).toEqual(["Plan", "Second"]); expect(back.roots[0].children[0].text).toBe("child"); expect(back.roots[0].title).toBe("Sheet A"); + + const mm = docBytes("/tmp/plan.mm", roots, 1); + expect(new TextDecoder().decode(mm).startsWith(" 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 wxml = docBytes("/tmp/plan.wxml", roots, 1); + expect(new TextDecoder().decode(wxml).startsWith(" r.text)).toEqual(["Plan"]); // one map per file; the sheets after it open as free branches + expect(backWxml.roots[0].children[0].text).toBe("child"); + expect(backWxml.roots[0].children.some((c) => c.float && c.text === "Second")).toBe(true); + + const mom = docBytes("/tmp/plan.mom", roots, 1); + expect(new TextDecoder().decode(mom.slice(0, 2))).toBe("PK"); // a zip + const backMom = await parseDoc("plan.mom", mom); + expect(backMom.roots.map((r) => r.text)).toEqual(["Plan", "Second"]); // one map per file; the sheets after it re-open as sheets + expect(backMom.roots[0].children[0].text).toBe("child"); }); const store = new Map(); diff --git a/src/files.ts b/src/files.ts index f242e82..8192a1b 100644 --- a/src/files.ts +++ b/src/files.ts @@ -1,6 +1,10 @@ import { Book, bookJson, parseBook } from "./book"; +import { fromFreemindBytes, toFreemind } from "./freemind"; +import { fromMindmanagerBytes, toMindmanager } from "./mindmanager"; +import { fromMindomoBytes, toMindomo } from "./mindomo"; import { Node, toMarkdown } from "./model"; import { fromMarkdown, fromOpml, toOpml } from "./outline-io"; +import { fromWisemapping, toWisemapping } from "./wisemapping"; import { exportXmind, importXmindSheets } from "./xmind"; export type Opened = { name: string; path: string | null; bytes: Uint8Array }; @@ -16,21 +20,29 @@ 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, otherwise Cartocortex JSON. */ +/** What to write for a document saved at `path`, by extension: XMind, OPML, Markdown, FreeMind, MindManager, Mindomo, WiseMapping, 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); + if (ext === "mom") return toMindomo(roots); + if (ext === "wxml") return text(toWisemapping(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. */ +/** 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, MindManager or Mindomo map gives one sheet per root topic; a WiseMapping map gives one sheet. */ 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 }; + if (ext === "mom") return { roots: await fromMindomoBytes(bytes), cur: 0 }; + if (ext === "wxml") return { roots: fromWisemapping(text()), cur: 0 }; return parseBook(text()); } @@ -67,11 +79,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"] }] }); + const path = await open({ filters: [{ name: "Mind map or outline", extensions: ["json", "xmind", "opml", "md", "markdown", "mm", "mmap", "mom", "wxml"] }] }); 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" }); + const i = Object.assign(document.createElement("input"), { type: "file", accept: ".json,.xmind,.opml,.md,.markdown,.mm,.mmap,.mom,.wxml" }); i.onchange = async () => { const f = i.files?.[0]; res(f ? { name: f.name, path: null, bytes: new Uint8Array(await f.arrayBuffer()) } : null); @@ -105,6 +117,10 @@ export async function pickSavePath(defaultName: string): Promise { name: "Cartocortex map (.json)", extensions: ["json"] }, { name: "XMind (.xmind)", extensions: ["xmind"] }, { name: "OPML outline (.opml)", extensions: ["opml"] }, + { name: "FreeMind / Freeplane (.mm)", extensions: ["mm"] }, + { name: "MindManager (.mmap)", extensions: ["mmap"] }, + { name: "Mindomo (.mom)", extensions: ["mom"] }, + { name: "WiseMapping (.wxml)", extensions: ["wxml"] }, { name: "Markdown outline (.md)", extensions: ["md"] }, ], }); diff --git a/src/freemind.test.ts b/src/freemind.test.ts new file mode 100644 index 0000000..f3b569a --- /dev/null +++ b/src/freemind.test.ts @@ -0,0 +1,98 @@ +import { expect, test } from "vitest"; +import { fromFreemind, fromFreemindBytes, toFreemind } from "./freemind"; +import { Node, mk } from "./model"; + +const sample = ` + + +

Ligne un

Ligne <deux>

+ + + + Voir la revue de 2025 + +
+ + + + + + +
+
`; + +test("a FreeMind sample opens: rich text keeps its line order, links and icons come across", () => { + const roots = fromFreemind(sample); + expect(roots).toHaveLength(1); + const root = roots[0]; + expect(root.text).toBe("Projet"); + expect(root.collapsed).toBe(true); // FOLDED, with children under it + expect(root.layout).toBe("map"); // one right-side topic, one left-side + expect(root.children.map((c) => c.text)).toEqual(["Ligne un\nLigne ", "Création"]); // right first + const study = root.children[0]; + expect(study.textColor).toBe("#ff0000"); + expect(study.priority).toBe(3); + expect(study.topicLink).toBe(root.children[1].id); // arrowlink remapped to the new ids + expect(study.children[0].note).toBe("Voir la revue de 2025"); // FreeMind 0.9's map/Note hook + const creation = root.children[1]; + expect(creation.link).toBe("https://example.com/"); + expect(creation.progress).toBe(75); + expect(creation.children[0].color).toBe("#00ccff"); // a hex without the # + expect(creation.children[0].bold).toBe(true); + expect(creation.children[0].italic).toBe(true); +}); + +test("left-side topics everywhere open as a one-sided chart", () => { + const roots = fromFreemind(``); + expect(roots[0].layout).toBe("left"); + 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(noIds(await fromFreemindBytes(gz))).toBe(noIds(await fromFreemindBytes(plain))); +}); + +test("strict reading: a DOCTYPE, non-XML and a map without a topic are rejected", () => { + expect(() => fromFreemind("")).toThrow(); + expect(() => fromFreemind("ceci n'est pas du XML")).toThrow(); + expect(() => fromFreemind("")).toThrow(); +}); + +test("colours, multi-line text and progress survive the round trip", () => { + const root = { + ...mk("Sauvegarde"), + layout: "left" as const, + children: [ + { ...mk("Première\nDeuxième"), color: "#00ccff", textColor: "#ff0000", bold: true, italic: true, priority: 2, progress: 30, children: [mk("Enfant & \"suite\"")] }, + mk("Simple"), + ], + }; + const back = fromFreemind(toFreemind([root]))[0]; + expect(back.text).toBe("Sauvegarde"); + expect(back.layout).toBe("left"); + expect(back.children.map((c) => c.text)).toEqual(["Première\nDeuxième", "Simple"]); + const first = back.children[0]; + expect(first.color).toBe("#00ccff"); + expect(first.textColor).toBe("#ff0000"); + expect(first.bold).toBe(true); + expect(first.italic).toBe(true); + expect(first.priority).toBe(2); + expect(first.progress).toBe(25); // written as the nearest FreeMind progress icon + expect(first.children[0].text).toBe('Enfant & "suite"'); // XML escaping holds +}); + +test("notes open from both FreeMind 0.9's hook and newer richcontent", () => { + const roots = fromFreemind( + `` + + `ancienne note` + + `

note riche

` + + `
`, + ); + expect(roots[0].children[0].note).toBe("ancienne note"); + expect(roots[0].children[1].note).toBe("note riche"); +}); diff --git a/src/freemind.ts b/src/freemind.ts new file mode 100644 index 0000000..1950bbc --- /dev/null +++ b/src/freemind.ts @@ -0,0 +1,205 @@ +import { Node } from "./model"; +import { decode, parseXml, resolveTopicLinks, XNode } from "./xmindxml"; + +// Reader and writer for the FreeMind / Freeplane .mm format: plain XML, or the gzip FreeMind also writes +// (magic bytes 1f 8b). Reading reuses the strict parser from the old .xmind reader (no DOCTYPE, predefined +// entities only). Writing is deliberately lossy: one root node per sheet, only the properties FreeMind +// itself knows, and nothing but plain lines of text where FreeMind allows rich HTML. + +const kid = (n: XNode, name: string) => n.children.find((c) => c.name === name); + +/** Attribute lookup, case-insensitive: files in the wild mix TEXT with text, COLOR with color. */ +function attr(n: XNode, 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 FreeMind's colour attributes, which sometimes drop the #; anything else is dropped. */ +const hexColor = (v: string | undefined): string | undefined => { + const s = v?.trim(); + return s ? (/^#[0-9a-f]{6}$/i.test(s) ? s : /^[0-9a-f]{6}$/i.test(s) ? "#" + s : undefined) : undefined; +}; + +/** FreeMind's progress icons and the percentage each one stands for. */ +const PROGRESS: Record = { "0%": 0, "25%": 25, "50%": 50, "75%": 75, "100%": 100 }; + +/** The FreeMind progress icon closest to `pct`. */ +const progressIcon = (pct: number) => Object.keys(PROGRESS).reduce((best, k) => (Math.abs(PROGRESS[k] - pct) < Math.abs(PROGRESS[best] - pct) ? k : best), "0%"); + +/** Raw `` blocks in source order: the parsed tree keeps the text but not where the `
`s were. */ +const richSlices = (src: string): string[] => { + const out: string[] = []; + let i = 0; + for (;;) { + const s = src.indexOf("", s); + if (e < 0) return out; + out.push(src.slice(s, e)); + i = e + 14; + } +}; + +/** Every node called `name` in document order. */ +const nodesNamed = (n: XNode, name: string, out: XNode[] = []): XNode[] => { + if (n.name === name) out.push(n); + n.children.forEach((c) => nodesNamed(c, name, out)); + return out; +}; + +/** richcontent node -> raw source slice, zipped in document order; empty map when the counts do not match up. */ +const rawSlices = (src: string, root: XNode): Map => { + const m = new Map(); + const slices = richSlices(src); + const nodes = nodesNamed(root, "richcontent"); + if (slices.length === nodes.length) nodes.forEach((n, i) => m.set(n, slices[i])); + return m; +}; + +/** Lines of text in a raw richcontent block: a new line per `

` and per `
`, tags dropped, entities decoded. */ +const htmlOf = (raw: string): string => { + let out = ""; + let i = 0; + for (;;) { + const lt = raw.indexOf("<", i); + if (lt < 0) { + out += decode(raw.slice(i)); + break; + } + if (lt > i) out += decode(raw.slice(i, lt)); + const gt = raw.indexOf(">", lt); + if (gt < 0) break; + const name = (/^([a-z0-9]+)/i.exec(raw.slice(lt + 1, gt))?.[1] ?? "").toLowerCase(); + if (name === "br" || name === "p" || name === "li") out += "\n"; + i = gt + 1; + } + return out.split("\n").map((l) => l.replace(/\s+/g, " ").trim()).filter(Boolean).join("\n"); +}; + +/** richcontent text from the parsed node alone (no raw slice): the text survives, the line breaks may not. */ +const plainOf = (n: XNode): string => { + const own = n.text.trim(); + const kids = n.children.map(plainOf).filter(Boolean); + return kids.length ? (own ? [own, ...kids].join("\n") : kids.join("\n")) : own; +}; + +/** The text of a richcontent block: from the raw slice when we have one (keeping text and `
` order), else from the tree. */ +const htmlText = (n: XNode, raw: Map): string => { + const slice = raw.get(n); + return slice === undefined ? plainOf(n) : htmlOf(slice); +}; + +/** One FreeMind node -> one Cartocortex topic. */ +function nodeFromXml(x: XNode, ids: Map, raw: Map): Node { + const children = x.children.filter((c) => c.name === "node").map((c) => nodeFromXml(c, ids, raw)); + const nodeRich = x.children.find((c) => c.name === "richcontent" && attr(c, "TYPE")?.toUpperCase() === "NODE"); + const noteRich = x.children.find((c) => c.name === "richcontent" && attr(c, "TYPE")?.toUpperCase() === "NOTE"); + const noteHook = x.children.find((c) => c.name === "hook" && attr(c, "NAME") === "map/Note"); // FreeMind 0.9 + const icons = x.children.filter((c) => c.name === "icon").map((c) => attr(c, "BUILTIN") ?? ""); + const prio = icons.map((b) => /^full-(\d+)$/.exec(b)?.[1]).find(Boolean); + const prog = icons.map((b) => PROGRESS[b]).find((p) => p !== undefined); + const text = nodeRich ? htmlText(nodeRich, raw) : attr(x, "TEXT") ?? ""; + const note = noteRich ? htmlText(noteRich, raw) : noteHook ? (kid(noteHook, "text")?.text ?? "").trim() : ""; + const fill = hexColor(attr(x, "BACKGROUND_COLOR")); + const ink = hexColor(attr(x, "COLOR")); + const font = kid(x, "font"); + const dest = x.children.find((c) => c.name === "arrowlink"); + const to = dest ? attr(dest, "DESTINATION") : undefined; + const link = attr(x, "LINK"); + const node: Node = { + id: crypto.randomUUID(), + text, + children, + ...(note ? { note } : {}), + ...(link ? { link } : {}), + ...(to ? { topicLink: to } : {}), + ...(fill ? { color: fill } : {}), + ...(ink ? { textColor: ink } : {}), + ...(font && attr(font, "BOLD")?.toLowerCase() === "true" ? { bold: true } : {}), + ...(font && attr(font, "ITALIC")?.toLowerCase() === "true" ? { italic: true } : {}), + ...(children.length && attr(x, "FOLDED")?.toLowerCase() === "true" ? { collapsed: true } : {}), + ...(prio ? { priority: Math.min(9, Math.max(1, Number(prio))) } : {}), + ...(prog !== undefined ? { progress: prog } : {}), + }; + const id = attr(x, "ID"); + if (id) ids.set(id, node.id); // for arrowlink: FreeMind's id -> ours + return node; +} + +/** The root topics of a .mm map. Left-side first-level topics open as a one-sided (logic left) or two-sided (map) chart. */ +export function fromFreemind(src: string): Node[] { + const root = parseXml(src); // throws on DOCTYPE, on anything but plain XML + const raw = rawSlices(src, root); + const ids = new Map(); + const roots = root.children.filter((c) => c.name === "node").map((c) => { + const sides = c.children.filter((k) => k.name === "node").map((k) => (attr(k, "POSITION") ?? "").toLowerCase() === "left"); + const node = nodeFromXml(c, ids, raw); + const lefts = sides.filter(Boolean).length; + if (!lefts) return node; + node.layout = lefts === sides.length ? "left" : "map"; + // The two-sided map grows its first half to the right, so right-side topics come first. + node.children = node.children.filter((_, i) => !sides[i]).concat(node.children.filter((_, i) => sides[i])); + return node; + }); + if (!roots.length) throw new Error("no topic found in this mind map"); + resolveTopicLinks(roots, ids); // arrowlink destinations -> our topic ids; links to missing topics are dropped + return roots; +} + +/** A .mm file: plain XML, or gzip (magic bytes 1f 8b). */ +export async function fromFreemindBytes(bytes: Uint8Array): Promise { + if (bytes[0] !== 0x1f || bytes[1] !== 0x8b) return fromFreemind(new TextDecoder().decode(bytes)); + const text = await new Response(new Blob([bytes as BlobPart]).stream().pipeThrough(new DecompressionStream("gzip"))).text(); + return fromFreemind(text); +} + +const esc = (s: string) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!); + +const hex = (c: string | undefined) => (c && /^#[0-9a-f]{6}$/i.test(c) ? c : undefined); + +/** FreeMind keeps line breaks in a richcontent block, one `

` per line. */ +const richBlock = (type: string, lines: string[]): string => + `${lines.map((l) => `

${esc(l)}

`).join("")}
`; + +/** One Cartocortex topic -> one FreeMind node. `side` is the POSITION of a first-level topic; `childSides` of its children. */ +function nodeToXml(n: Node, fm: Map, side?: "left" | "right", childSides?: Array<"left" | "right" | undefined>): string { + const lines = n.text.split("\n"); + const attrs = + ` ID="${fm.get(n.id)}"` + + ` TEXT="${esc(lines[0])}"` + + (side ? ` POSITION="${side}"` : "") + + (hex(n.textColor) ? ` COLOR="${n.textColor}"` : "") + + (hex(n.color) ? ` BACKGROUND_COLOR="${n.color}"` : "") + + (n.link ? ` LINK="${esc(n.link)}"` : "") + + (n.collapsed && n.children.length ? ' FOLDED="true"' : ""); + const inner: string[] = []; + if (n.bold || n.italic) inner.push(``); + if (n.priority) inner.push(``); + if (n.progress != null) inner.push(``); + if (lines.length > 1) inner.push(richBlock("NODE", lines)); // TEXT carries the first line for plain-text readers + if (n.note) inner.push(richBlock("NOTE", n.note.split("\n"))); + const to = n.topicLink ? fm.get(n.topicLink) : undefined; + if (to) inner.push(``); + const kids = n.children.map((c, i) => nodeToXml(c, fm, childSides?.[i])).join(""); + return `${inner.join("")}${kids}`; +} + +/** Where a first-level topic sits, from the root's layout: everything left, or the first half right and the rest left. */ +const childSides = (root: Node): Array<"left" | "right" | undefined> => { + if (root.layout === "left") return root.children.map(() => "left" as const); + if (root.layout === "map") { + const cut = Math.ceil(root.children.length / 2); + return root.children.map((_, i) => (i < cut ? ("right" as const) : ("left" as const))); + } + return root.children.map(() => undefined); +}; + +/** A .mm map from Cartocortex sheets: one root node per sheet, so extra sheets may not show in FreeMind itself. */ +export function toFreemind(roots: Node[]): string { + const fm = new Map(); + const claim = (n: Node) => (fm.set(n.id, "ID_" + crypto.randomUUID()), n.children.forEach(claim)); + roots.forEach(claim); // every id first: an arrowlink may point at a topic that comes later + const nodes = roots.map((r) => nodeToXml(r, fm, undefined, childSides(r))).join("\n"); + return `\n\n${nodes}\n\n`; +} 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/mindomo.test.ts b/src/mindomo.test.ts new file mode 100644 index 0000000..43f9c3f --- /dev/null +++ b/src/mindomo.test.ts @@ -0,0 +1,91 @@ +import { expect, test } from "vitest"; +import { mk, Node } from "./model"; +import { fromMindomoBytes, toMindomo } from "./mindomo"; +import { unzip, zip } from "./xmind"; + +/** A small .mom, in the shape of the files Mindomo Desktop 11 writes. */ +const mom = (nodes: unknown[], relations?: unknown[]) => + zip({ + "themes.json": "{}", + "document.json": JSON.stringify({ version: 40, mapInfo: { id: "d1", name: "Test map", rootId: "root" } }), + "nodes.json": JSON.stringify(nodes), + ...(relations ? { "relations.json": JSON.stringify(relations) } : {}), + "changes.json": "[]", + "Document.xml": '', + }); + +test("reads a Mindomo map", async () => { + const bytes = mom( + [ + { version: 42, id: "root", title: "Central", index: 0, style: { valign: 6, halign: 4, orientation: 100 }, creatorId: 0, type: 0 }, + // listed out of order: `index`, not the list, orders the siblings + { version: 42, id: "b", parentId: "root", nextId: "a", title: "Watch", multimediaUrl: "https://youtu.be/x", mediaType: 1, index: 1, style: { expanded: true, orientation: 0 }, type: 0 }, + { version: 42, id: "a", parentId: "root", previousId: "b", title: "Plan\non two lines", index: 0, style: { expanded: false, orientation: 0 }, type: 0 }, + { version: 42, id: "deep", parentId: "a", title: "Deep", index: 0, style: { expanded: true }, type: 0 }, + { version: 42, id: "gone", parentId: "root", deleted: true, title: "Deleted", index: 2, type: 0 }, + { version: 42, id: "gc", parentId: "gone", title: "Under the deleted", index: 0, type: 0 }, + { version: 42, id: "sheet2", title: "Second sheet", index: 0, style: { expanded: true }, type: 0 }, // parentless: its own sheet + ], + [{ version: 40, id: "r1", fromNodeId: "deep", toNodeId: "b", ctrlX1: "0", ctrlY1: "0", ctrlX2: "0", ctrlY2: "0", width: "1", type: "1" }], + ); + const roots = await fromMindomoBytes(bytes); + expect(roots.map((r) => r.text)).toEqual(["Central", "Second sheet"]); // the rootId tree first, then any other parentless topic + const [root] = roots; + expect(root.title).toBe("Test map"); // the map's name labels the sheet + expect(root.children.map((c) => c.text)).toEqual(["Plan\non two lines", "Watch"]); // by index, newlines kept + expect(root.children[0].collapsed).toBe(true); // style.expanded === false + expect(root.children[0].children[0].text).toBe("Deep"); + expect(root.children[1].link).toBe("https://youtu.be/x"); // a media address opens as a link + expect(root.rels).toHaveLength(1); + expect(root.rels![0].from).toBe(root.children[0].children[0].id); // remapped to our topic ids + expect(root.rels![0].to).toBe(root.children[1].id); +}); + +test("refuses what is not a Mindomo map", async () => { + await expect(fromMindomoBytes(new TextEncoder().encode(''))).rejects.toThrow(/older Mindomo XML/); + await expect(fromMindomoBytes(zip({ "document.json": "{}" }))).rejects.toThrow(/nodes.json/); // not this format's parts + const orphan = mom([{ id: "x", parentId: "ghost", title: "Orphan", index: 0 }]); + await expect(fromMindomoBytes(orphan)).rejects.toThrow(/parent/); + const empty = mom([]); + await expect(fromMindomoBytes(empty)).rejects.toThrow(/no topic/); +}); + +test("writes a .mom Mindomo can open again", async () => { + const root: Node = { + ...mk("Central\ntopic"), + link: "https://example.org", + 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 = toMindomo([root, mk("Second sheet")]); + expect([bytes[0], bytes[1]]).toEqual([0x50, 0x4b]); // a zip + const parts = await unzip(bytes); + expect([...parts.keys()].sort()).toEqual(["Document.xml", "changes.json", "document.json", "nodes.json", "relations.json", "themes.json"]); + const doc = JSON.parse(new TextDecoder().decode(parts.get("document.json")!)); + const nodes = JSON.parse(new TextDecoder().decode(parts.get("nodes.json")!)) as Record[]; + expect(nodes[0].id).toBe(doc.mapInfo.rootId); // the first sheet is the map + expect(doc.mapInfo.name).toBe("Central\ntopic"); + expect(nodes[0].parentId).toBeUndefined(); // the root has no parent + expect("expanded" in (nodes[0].style as object)).toBe(false); + expect(nodes[1].nextId).toBe(nodes[2].id); // siblings are chained + expect(nodes[2].previousId).toBe(nodes[1].id); + expect((nodes[2].style as { expanded?: boolean }).expanded).toBe(false); // folded stays folded + expect(nodes[4].id).toHaveLength(32); // ids like Mindomo's: 32 hex characters + const back = await fromMindomoBytes(bytes); + expect(back.map((r) => r.text)).toEqual(["Central\ntopic", "Second sheet"]); // one map per sheet, newlines kept + const [r] = back; + expect(r.link).toBe("https://example.org"); + 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("no relationships, no relations.json", async () => { + const bytes = toMindomo([mk("Only")]); + const parts = await unzip(bytes); + expect(parts.has("relations.json")).toBe(false); // the part is left out when the map has no relationship lines + const back = await fromMindomoBytes(bytes); + expect(back[0].rels).toBeUndefined(); + expect(back[0].text).toBe("Only"); +}); diff --git a/src/mindomo.ts b/src/mindomo.ts new file mode 100644 index 0000000..ac5e128 --- /dev/null +++ b/src/mindomo.ts @@ -0,0 +1,196 @@ +import { Node } from "./model"; +import { unzip, zip } from "./xmind"; + +// Reader and writer for Mindomo Desktop's .mom format: a zip of JSON parts. document.json holds the +// map's name and the id of its root topic, nodes.json every topic in one flat list (each child pointing +// at its parent), relations.json the map's relationship lines, and themes.json, changes.json and +// Document.xml parts Cartocortex does not need. The packed .pmom variant and the older XML +// "Mindomo XML Map" files are not covered. The format was taken apart from files saved by Mindomo +// Desktop 11, and has not been checked against Mindomo itself. + +/** One entry of Mindomo's nodes.json: a topic in the flat list. Children point at their parent, siblings carry an `index`, and a file saved after a delete keeps the topic marked `deleted: true`. */ +type MomNode = { + id: string; + parentId?: string; + title?: string; + deleted?: boolean; + index?: number; + style?: { expanded?: boolean }; + multimediaUrl?: string; // a video or web address Mindomo shows on the topic +}; + +/** Mindomo's document.json; only the parts the reader needs. */ +type MomDoc = { mapInfo?: { name?: string; rootId?: string } }; + +/** The topic's media link, when it is an ordinary web address. */ +const linkOf = (n: MomNode) => (/^https?:\/\//i.test(n.multimediaUrl ?? "") ? n.multimediaUrl : undefined); + +/** The tree hanging off one Mindomo topic: children by their `index`, ties by list order. */ +function treeOf(n: MomNode, kids: Map, ids: Map): Node { + const children = [...(kids.get(n.id) ?? [])] + .sort((a, b) => (a.index ?? 1e9) - (b.index ?? 1e9)) + .map((c) => treeOf(c, kids, ids)); + const link = linkOf(n); + const node: Node = { + id: crypto.randomUUID(), + text: n.title ?? "", + children, + ...(link ? { link } : {}), + ...(n.style?.expanded === false && children.length ? { collapsed: true } : {}), + }; + ids.set(n.id, node.id); // lets relationships find their new ends + return node; +} + +/** The root topics of a .mom map: the tree of document.json's rootId (or of the topic without a parent), then any other parentless topic as its own sheet. */ +export async function fromMindomoBytes(bytes: Uint8Array): Promise { + if (bytes[0] !== 0x50 || bytes[1] !== 0x4b) throw new Error("not a Mindomo .mom file (an older Mindomo XML map is not read)"); + const files = await unzip(bytes); + const nodesFile = [...files.entries()].find(([name]) => name.toLowerCase() === "nodes.json"); + if (!nodesFile) throw new Error("no nodes.json in this Mindomo file"); + const doc = files.has("document.json") ? (JSON.parse(new TextDecoder().decode(files.get("document.json")!)) as MomDoc) : {}; + const all = JSON.parse(new TextDecoder().decode(nodesFile[1])) as MomNode[]; + const byId = new Map(); + for (const n of all) if (typeof n.id === "string" && !byId.has(n.id)) byId.set(n.id, n); + // A file saved after a delete keeps the topic, marked `deleted`; anything under it is gone with it. + const dead = new Set(); + const gone = (n: MomNode, seen: Set): boolean => { + if (dead.has(n.id)) return true; + if (n.deleted) return (dead.add(n.id), true); + if (n.parentId === undefined) return false; + const p = byId.get(n.parentId); + if (!p) throw new Error(`a topic points at parent "${n.parentId}", which is not in this file`); + if (seen.has(n.id)) return true; // parents that loop never reach the root: gone with the loop + seen.add(n.id); + return gone(p, seen) ? (dead.add(n.id), true) : false; + }; + const nodes = all.filter((n) => typeof n.id === "string" && byId.get(n.id) === n && !gone(n, new Set())); + const kids = new Map(); + for (const n of nodes) { + if (n.parentId === undefined) continue; + const list = kids.get(n.parentId); + if (list) list.push(n); + else kids.set(n.parentId, [n]); + } + const ids = new Map(); + const rootId = doc.mapInfo?.rootId; + const parentless = nodes.filter((n) => n.parentId === undefined); + const roots: Node[] = []; + if (rootId !== undefined && byId.has(rootId) && !dead.has(rootId)) roots.push(treeOf(byId.get(rootId)!, kids, ids)); + roots.push(...parentless.filter((n) => n.id !== rootId).map((n) => treeOf(n, kids, ids))); + if (!roots.length) throw new Error("no topic found in this mind map"); + if (ids.size !== nodes.length) throw new Error(`${nodes.length - ids.size} topic(s) in this file are not part of the map's tree`); + const name = doc.mapInfo?.name; + if (name) roots[0].title = name; // the map's name labels the sheet + const relsFile = [...files.entries()].find(([entry]) => entry.toLowerCase() === "relations.json"); + if (relsFile) { + const rels = (JSON.parse(new TextDecoder().decode(relsFile[1])) as { fromNodeId?: string; toNodeId?: string }[]).flatMap((r) => { + const from = ids.get(r.fromNodeId ?? ""), to = ids.get(r.toNodeId ?? ""); + return from && to ? [{ id: crypto.randomUUID(), from, to }] : []; // an end that is gone is dropped + }); + if (rels.length) roots[0].rels = rels; // relationship lines live on the open sheet's root topic + } + return roots; +} + +/** A fresh Mindomo id: 32 hex characters, the shape of the ids in Mindomo's own files. */ +const momId = () => [...crypto.getRandomValues(new Uint8Array(16))].map((x) => x.toString(16).padStart(2, "0")).join(""); + +/** Mindomo writes a topic's creation date in the local "YYYY-MM-DD hh:mm:ss" and its modification date in ISO. */ +const clock = () => { + const d = new Date(); + const p = (n: number) => String(n).padStart(2, "0"); + return { + iso: d.toISOString(), + local: `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`, + }; +}; + +/** One Cartocortex topic -> one Mindomo node, chained to its siblings the way Mindomo keeps them in order. */ +function nodeOf( + n: Node, + mm: Map, + parent: string | undefined, + index: number, + next: string | undefined, + prev: string | undefined, + when: { iso: string; local: string }, + central: boolean, + out: Record[], +) { + const id = mm.get(n.id)!; + out.push({ + version: 42, + id, + ...(parent === undefined ? {} : { parentId: parent }), + ...(next ? { nextId: next } : {}), + ...(prev ? { previousId: prev } : {}), + originalId: id, + title: n.text, + titleStyle: "", + creationDate: when.local, + modificationDate: when.iso, + assignedTo: [], + assignedToVirtual: [], + index, + style: central ? { halign: 4, valign: 6, orientation: 100 } : { halign: 4, valign: 6, expanded: !n.collapsed, orientation: 0 }, + comments: { votes: {} }, + creatorId: 0, + isTemplate: 0, + type: 0, + ...(n.link ? { multimediaUrl: n.link, mediaType: 1 } : {}), + }); + n.children.forEach((c, i, all) => + nodeOf(c, mm, id, i, all[i + 1] ? mm.get(all[i + 1].id) : undefined, i ? mm.get(all[i - 1].id) : undefined, when, false, out), + ); +} + +/** A .mom file from Cartocortex sheets: one Mindomo map per sheet, so extra sheets may not show in Mindomo itself. Writing is deliberately lossy: titles, folding and links are what is carried. */ +export function toMindomo(roots: Node[]): Uint8Array { + const mm = new Map(); + const claim = (n: Node) => (mm.set(n.id, momId()), n.children.forEach(claim)); + roots.forEach(claim); // every id first: a relationship may point at a topic that comes later + const when = clock(); + const nodes: Record[] = []; + roots.forEach((r, i) => nodeOf(r, mm, undefined, 0, undefined, undefined, when, i === 0, nodes)); + const rels = ((roots[0]?.rels ?? []) as NonNullable) + .filter((r) => mm.has(r.from) && mm.has(r.to)) + .map((r) => ({ + version: 40, + id: momId(), + fromNodeId: mm.get(r.from), + toNodeId: mm.get(r.to), + ctrlX1: "0", + ctrlY1: "0", + ctrlX2: "0", + ctrlY2: "0", + width: "1", + type: "1", + })); + const document = { + version: 40, + mapInfo: { + id: momId(), + name: roots[0]?.title || roots[0]?.text || "Map", + description: "", + type: 0, + creationDate: when.iso, + modificationDate: when.iso, + rootId: roots[0] ? mm.get(roots[0].id) : momId(), + language: "en", + }, + desktopVersion: "11.1.6", + commandsFromOrder: 0, + commandsFromOrderUndo: 0, + mapUsers: {}, + mapUsersVirtual: {}, + }; + return zip({ + "themes.json": "{}", + "document.json": JSON.stringify(document), + "nodes.json": JSON.stringify(nodes), + ...(rels.length ? { "relations.json": JSON.stringify(rels) } : {}), + "changes.json": "[]", + "Document.xml": '\n', + }); +} diff --git a/src/wisemapping.test.ts b/src/wisemapping.test.ts new file mode 100644 index 0000000..93bd953 --- /dev/null +++ b/src/wisemapping.test.ts @@ -0,0 +1,116 @@ +import { expect, test } from "vitest"; +import { mk, Node } from "./model"; +import { fromWisemapping, toWisemapping } from "./wisemapping"; + +const sample = ` + + + +`; + +test("a WiseMapping sample opens: text blocks, notes, styles and relationships come across", () => { + const roots = fromWisemapping(sample); + expect(roots).toHaveLength(1); + const root = roots[0]; + expect(root.text).toBe("Racine"); + expect(root.note).toBe("une note\nsur deux lignes"); // the note is a CDATA block + expect(root.layout).toBe("map"); // one left-side topic among three + expect(root.children.map((c) => c.text)).toEqual(["Droite", "premi\u00e8re\nseconde", "Gauche", "Libre"]); // right side first + const right = root.children[0]; + expect(right.link).toBe("https://example.com/"); + expect(right.icon).toBe("\u2b50"); // an emoji marker + const styled = root.children[1]; + expect(styled.textColor).toBe("#cc0000"); // fontStyle is "family;size;colour;weight;style;" + expect(styled.bold).toBe(true); + expect(styled.italic).toBe(true); + expect(styled.color).toBe("#00ccff"); // bgColor + expect(styled.shape).toBe("line"); + const left = root.children[2]; + expect(left.collapsed).toBe(true); // shrink, with a child under it + expect(left.children[0].text).toBe("Enfant "); // entities are decoded + const free = root.children[3]; + expect(free.float).toEqual({ x: 400, y: 300 }); // a topic at map level floats on the canvas + expect(free.note).toBe("encoded note"); // the old percent-encoded note attribute + expect(root.rels).toHaveLength(1); + expect(root.rels![0].from).toBe(styled.id); + expect(root.rels![0].to).toBe(free.id); +}); + +test("children keep their order attribute, and a left-leaning map opens as Logic (left)", () => { + const pela = fromWisemapping(``); + expect(pela[0].layout).toBe("left"); + expect(pela[0].children.map((c) => c.text)).toEqual(["A", "B", "C"]); + const none = fromWisemapping(``); + expect(none[0].text).toBe("Premier"); // no central attribute: the first topic opens as the map's centre + expect(none[0].children[0].text).toBe("Detache"); + expect(none[0].children[0].float).toEqual({ x: 300, y: 100 }); +}); + +test("strict reading: a DOCTYPE, another format and a map without a topic are rejected", () => { + expect(() => fromWisemapping("")).toThrow(); + expect(() => fromWisemapping("pas une carte")).toThrow(/not a WiseMapping map/); + expect(() => fromWisemapping('')).toThrow(/no topic/); + expect(() => fromWisemapping("ceci n'est pas du XML")).toThrow(); +}); + +test("multi-line text, styles, folding and relationships survive the round trip", () => { + const root: Node = { + ...mk("Carte"), + children: [ + { + ...mk("a]]>b\nfin"), // "]]>" cannot sit inside a CDATA block, so it is split the way WiseMapping writes it + color: "#00ccff", + textColor: "#ff0000", + bold: true, + italic: true, + icon: "\u2b50", + note: "une\nnote", + link: "https://example.com/", + shape: "rect", + children: [mk('Enfant "&"')], + }, + { ...mk("Pli\u00e9"), collapsed: true, children: [mk("Dedans")] }, + ], + }; + root.rels = [{ id: root.children[0].children[0].id, from: root.children[0].id, to: root.children[1].children[0].id }]; + const back = fromWisemapping(toWisemapping([root, mk("Second")]))[0]; + expect(back.children[0].text).toBe("a]]>b\nfin"); + expect(back.children[0].children[0].text).toBe('Enfant "&"'); // XML escaping holds + expect(back.children[0].color).toBe("#00ccff"); + expect(back.children[0].textColor).toBe("#ff0000"); + expect(back.children[0].bold).toBe(true); + expect(back.children[0].italic).toBe(true); + expect(back.children[0].icon).toBe("\u2b50"); + expect(back.children[0].note).toBe("une\nnote"); + expect(back.children[0].link).toBe("https://example.com/"); + expect(back.children[0].shape).toBe("rect"); + expect(back.children[1].collapsed).toBe(true); + expect(back.rels).toHaveLength(1); + expect(back.rels![0].from).toBe(back.children[0].id); + expect(back.rels![0].to).toBe(back.children[1].children[0].id); + const second = back.children[2]; + expect(second.text).toBe("Second"); + expect(second.float).toEqual({ x: 200, y: 250 }); // the sheet after the first opens as a free branch +}); + +test("writing works out positions: a two-sided map splits, a left chart stays left", () => { + const out = toWisemapping([{ ...mk("R"), layout: "map" as const, children: [mk("A"), mk("B"), mk("C")] }]); + expect(out).toContain('position="200,-25"'); // A and B grow right + expect(out).toContain('position="200,25"'); + expect(out).toContain('position="-200,0"'); // C grows left + const back = fromWisemapping(out)[0]; + expect(back.layout).toBe("map"); + expect(back.children.map((c) => c.text)).toEqual(["A", "B", "C"]); // the right side comes first + + const left = toWisemapping([{ ...mk("R"), layout: "left" as const, children: [mk("A"), mk("B")] }]); + expect(left).toContain('position="-200,-25"'); + expect(left).toContain('position="-200,25"'); + expect(fromWisemapping(left)[0].layout).toBe("left"); + + const deep = toWisemapping([{ ...mk("R"), children: [{ ...mk("A"), children: [mk("A1"), mk("A2")] }] }]); + expect(deep).toContain('position="200,0"'); // a first-level topic sits 200 px from the centre + expect(deep).toContain('position="290,-25"'); // a deeper one 90 px from its parent + expect(deep).toContain('position="290,25"'); +}); diff --git a/src/wisemapping.ts b/src/wisemapping.ts new file mode 100644 index 0000000..7acc90a --- /dev/null +++ b/src/wisemapping.ts @@ -0,0 +1,168 @@ +import { Node } from "./model"; +import { parseXml, XNode } from "./xmindxml"; + +// Reader and writer for WiseMapping's .wxml format: plain XML, the current "tango" dialect and the +// older "pela" files. Reading reuses the strict parser from the old .xmind files (no DOCTYPE, +// predefined entities only). Writing is deliberately lossy: one map (the first sheet), with the +// sheets after it as WiseMapping's free branches, and only what the tango 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 SrcTopicId with srcTopicId. */ +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 WiseMapping colour, which sometimes drops the #; anything else is dropped. */ +const hexColor = (v: string | undefined): string | undefined => { + const s = v?.trim(); + return s ? (/^#[0-9a-f]{6}$/i.test(s) ? s : /^[0-9a-f]{6}$/i.test(s) ? "#" + s.toLowerCase() : undefined) : undefined; +}; + +/** The percent-escapes of the old pela notes, read the way WiseMapping reads them: %xx and %uxxxx. */ +const unescapeWise = (s: string) => + s.replace(/%u([0-9a-f]{4})|%([0-9a-f]{2})/gi, (m, u: string, h: string) => String.fromCharCode(parseInt(u || h, 16))); + +/** A "x,y" position; undefined when the attribute is missing or corrupt. */ +const posOf = (t: XNode): { x: number; y: number } | undefined => { + const p = (attr(t, "position") ?? "").split(","); + const x = Number(p[0]), y = Number(p[1]); + return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : undefined; +}; + +/** A topic's child topics, in their `order`; a missing order keeps the file's own order, as in WiseMapping. */ +const ordered = (t: XNode): XNode[] => kids(t, "topic").sort((a, b) => (Number(attr(a, "order")) || 0) - (Number(attr(b, "order")) || 0)); + +/** A WiseMapping shape -> ours; the "elipse" spelling and the default are the app's own round box. */ +const shapeOf = (v: string | undefined): Node["shape"] | undefined => { + const s = (v ?? "").replace("rectagle", "rectangle"); // the typo WiseMapping itself reads around + return s === "line" ? "line" : s === "rectangle" || s === "rounded rectangle" ? "rect" : undefined; +}; + +/** One WiseMapping topic -> one Cartocortex topic. */ +function topicFromXml(t: XNode, ids: Map): Node { + const children = ordered(t).map((c) => topicFromXml(c, ids)); + const textEl = kid(t, "text"); + const text = textEl ? textEl.text : attr(t, "text") ?? ""; // a multi-line text is a block, not the attribute + const noteEl = kid(t, "note"); + const noteAttr = attr(noteEl, "text"); + const note = noteEl ? (noteAttr ? unescapeWise(noteAttr) : noteEl.text) : ""; // the old attribute is percent-encoded + const link = attr(kid(t, "link"), "url"); + const icon = kids(t, "eicon").map((e) => attr(e, "id")).find(Boolean); // the old has no emoji we could read + const font = (attr(t, "fontStyle") ?? "").split(";"); + const fill = hexColor(attr(t, "bgColor")); + const ink = hexColor(font[2]); + const shape = shapeOf(attr(t, "shape")); + const node: Node = { + id: crypto.randomUUID(), + text, + children, + ...(note.trim() ? { note: note.trim() } : {}), + ...(link ? { link } : {}), + ...(icon ? { icon } : {}), + ...(fill ? { color: fill } : {}), + ...(ink ? { textColor: ink } : {}), + ...(font[3] === "bold" ? { bold: true } : {}), + ...(font[4] === "italic" ? { italic: true } : {}), + ...(shape ? { shape } : {}), + ...(attr(t, "shrink") === "true" && children.length ? { collapsed: true } : {}), + }; + const id = attr(t, "id"); + if (id) ids.set(id, node.id); // for relationships: WiseMapping's id -> ours + return node; +} + +/** The root topic of a .wxml map, with the map's free branches floating beside it. */ +export function fromWisemapping(src: string): Node[] { + const map = parseXml(src); // throws on DOCTYPE, on anything but plain XML + if (map.name !== "map") throw new Error("not a WiseMapping map"); + const ids = new Map(); + const topics = kids(map, "topic"); + const central = topics.find((t) => attr(t, "central") !== undefined) ?? topics[0]; + if (!central) throw new Error("no topic found in this mind map"); + const node = topicFromXml(central, ids); + if (node.collapsed) delete node.collapsed; // a shrink on the central topic: WiseMapping's own reader calls that a bug + // The side a first-level topic sits on comes from its position: all left opens as Logic (left), both sides as a Map. + const sides = ordered(central).map((c) => ((posOf(c)?.x ?? 0) < 0 ? -1 : 1)); + const lefts = sides.filter((s) => s < 0).length; + if (lefts === sides.length && lefts) node.layout = "left"; + else if (lefts) { + // 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)); + } + // The map's free branches: topics at map level, which open as floating topics. + for (const t of topics) { + if (t === central) continue; + const n = topicFromXml(t, ids); + const pos = posOf(t); + n.float = { x: Math.round(pos?.x ?? 0), y: Math.round(pos?.y ?? 0) }; + node.children.push(n); + } + // The map's relationships, their ends remapped to our ids. + const rels = kids(map, "relationship") + .map((r) => ({ from: ids.get(attr(r, "srcTopicId") ?? ""), to: ids.get(attr(r, "destTopicId") ?? "") })) + .filter((r): r is { from: string; to: string } => !!r.from && !!r.to) + .map(({ from, to }) => ({ id: crypto.randomUUID(), from, to })); + if (rels.length) node.rels = rels; // relationship lines live on the open sheet's root topic + return [node]; +} + +const esc = (s: string) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!); + +/** A CDATA block; "]]>" cannot sit inside one, so it is split the way WiseMapping writes it. */ +const cdata = (s: string) => "").join("]]]]>") + "]]>"; + +const hex = (c: string | undefined) => (c && /^#[0-9a-f]{6}$/i.test(c) ? c.toLowerCase() : undefined); + +// WiseMapping's own spacing: a first-level topic sits 200 px from the centre, a deeper one 90 px from its parent. +const SPREAD = [200, 90], ROW = 50; + +/** One Cartocortex topic -> one WiseMapping topic. `side` -1 grows left; `pos` is the topic's canvas position. */ +function topicToXml(n: Node, wise: Map, pos: { x: number; y: number }, order: number | undefined, side: 1 | -1, spread: number, central = false): string { + const ink = hex(n.textColor), fill = hex(n.color); + const attrs = + (central ? ' central="true"' : ` position="${Math.ceil(pos.x)},${Math.ceil(pos.y)}"`) + + ` id="${wise.get(n.id)}"` + + (order !== undefined ? ` order="${order}"` : "") + + (!n.text.includes("\n") && n.text ? ` text="${esc(n.text)}"` : "") + + (n.shape === "line" ? ' shape="line"' : n.shape === "rect" ? ' shape="rectangle"' : "") + + (n.collapsed && n.children.length ? ' shrink="true"' : "") + + (ink || n.bold || n.italic ? ` fontStyle=";;${ink ?? ""};${n.bold ? "bold" : ""};${n.italic ? "italic" : ""};"` : "") + + (fill ? ` bgColor="${fill}"` : ""); + const inner: string[] = []; + if (n.text.includes("\n") && n.text) inner.push(`${cdata(n.text)}`); + if (n.icon) inner.push(``); + if (n.link) inner.push(``); + if (n.note) inner.push(`${cdata(n.note)}`); + const attached = n.children.filter((c) => !c.float); // a sheet's floating topics are written at map level + const cut = central && n.layout === "map" ? Math.ceil(attached.length / 2) : central && n.layout === "left" ? 0 : attached.length; + const child = (c: Node, k: number) => { + const s: 1 | -1 = central ? (k < cut ? 1 : -1) : side; + const group = central ? (s > 0 ? k : k - cut) : k; + const len = central ? (s > 0 ? cut : attached.length - cut) : attached.length; + return topicToXml(c, wise, { x: pos.x + s * spread, y: pos.y + (group - (len - 1) / 2) * ROW }, k, s, SPREAD[1]); + }; + return `${inner.join("")}${attached.map(child).join("")}
`; +} + +/** A .wxml map from Cartocortex sheets: the first sheet is the map, the sheets after it are WiseMapping's free branches. */ +export function toWisemapping(roots: Node[]): string { + const wise = new Map(); + let next = 0; + const claim = (n: Node) => (wise.set(n.id, String(++next)), n.children.forEach(claim)); + roots.forEach(claim); // every id first: a relationship may point at a topic that comes later + const parts: string[] = []; + const free = (n: Node, x: number, y: number, k: number) => parts.push(topicToXml(n, wise, { x, y }, k, 1, SPREAD[1])); + if (roots[0]) parts.push(topicToXml(roots[0], wise, { x: 0, y: 0 }, undefined, 1, SPREAD[0], true)); + let k = 0; + for (const r of roots) for (const c of r.children.filter((c) => c.float)) free(c, c.float!.x, c.float!.y, k++); + roots.slice(1).forEach((r, i) => free(r, 200, 250 * (i + 1), k++)); // the sheets after the first + // The relationships of the open sheet, their ends remapped to WiseMapping's ids. + const rels = (roots[0]?.rels ?? []).filter((r) => wise.has(r.from) && wise.has(r.to)) + .map((r) => ``); + return `\n\n${[...parts, ...rels].join("\n")}\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--; diff --git a/src/xmindxml.ts b/src/xmindxml.ts index 50794dd..2d51a25 100644 --- a/src/xmindxml.ts +++ b/src/xmindxml.ts @@ -8,7 +8,7 @@ export type XNode = { name: string; attrs: Record; children: XNo const local = (name: string) => name.replace(/^.*:/, ""); // drop namespace prefixes: xlink:href -> href -const decode = (s: string) => +export const decode = (s: string) => s.replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos);/gi, (m, e: string) => { if (e[0] === "#") { const code = e[1].toLowerCase() === "x" ? parseInt(e.slice(2), 16) : Number(e.slice(1));