diff --git a/README.md b/README.md index 8fcea1b..901c0f5 100644 --- a/README.md +++ b/README.md @@ -101,13 +101,15 @@ 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. -Zip-based files (`.xmind`) are refused if they would unpack to more than 64 MB, whatever size they claim, so a booby-trapped map cannot exhaust memory. +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 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. +Zip-based files (`.xmind`, `.mmap`) are refused if they would unpack to more than 64 MB, whatever size they claim, so a booby-trapped map cannot exhaust memory. + +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 455cbae..ff56c69 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"; @@ -610,6 +611,7 @@ export default function App({ active, primary, tabId, restored, openPath, onMeta { 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 0af0d20..43e8079 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 9e69242..329b33c 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, importXmindReport } 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. `lost` lists what an XMind import could not carry over. */ +/** 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. `lost` lists what an XMind import could not carry over. */ export async function parseDocReport(name: string, bytes: Uint8Array): Promise { const ext = extOf(name), text = () => new TextDecoder().decode(bytes); if (ext === "xmind") return { ...(await importXmindReport(bytes)), cur: 0 }; if (ext === "opml") return { roots: [fromOpml(text())], cur: 0, lost: [] }; if (ext === "md" || ext === "markdown" || ext === "txt") return { roots: [fromMarkdown(text(), baseName(name).replace(/\.[^.]*$/, ""))], cur: 0, lost: [] }; if (ext === "mm") return { roots: await fromFreemindBytes(bytes), cur: 0, lost: [] }; + if (ext === "mmap") return { roots: await fromMindmanagerBytes(bytes), cur: 0, lost: [] }; return { ...parseBook(text()), lost: [] }; } @@ -88,11 +91,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); @@ -127,6 +130,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 749b399..9e04fa4 100644 --- a/src/xmind.ts +++ b/src/xmind.ts @@ -82,7 +82,7 @@ const tooBig = (): never => { throw new Error(`this file unpacks to more than ${MAX_UNZIPPED / 1024 / 1024} MB, which Cartocortex refuses to open`); }; -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--;