diff --git a/README.md b/README.md index 901c0f5..3c6a8ec 100644 --- a/README.md +++ b/README.md @@ -101,15 +101,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`, `.xmind` or FreeMind `.mm` or MindManager `.mmap` 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` or WiseMapping `.wxml` 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. 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. + 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. +Safety rule for opened files: only Cartocortex's own `.json` keeps its place on disk. Opening a `.xmind`, `.opml`, FreeMind, MindManager, 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 ff56c69..503b274 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { exportXmind } from "./xmind"; import { toOpml } from "./outline-io"; import { toFreemind } from "./freemind"; import { toMindmanager } from "./mindmanager"; +import { toWisemapping } from "./wisemapping"; import { downscale } from "./image"; import { TEMPLATES } from "./templates"; import { customColors, customTheme } from "./theme"; @@ -612,6 +613,7 @@ export default function App({ active, primary, tabId, restored, openPath, onMeta { 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: "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 43e8079..34ae825 100644 --- a/src/files.test.ts +++ b/src/files.test.ts @@ -44,6 +44,13 @@ test("documents are written and read in the format their extension names", async 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 store = new Map(); diff --git a/src/files.ts b/src/files.ts index 329b33c..8e8d259 100644 --- a/src/files.ts +++ b/src/files.ts @@ -3,6 +3,7 @@ import { fromFreemindBytes, toFreemind } from "./freemind"; import { fromMindmanagerBytes, toMindmanager } from "./mindmanager"; import { Node, toMarkdown } from "./model"; import { fromMarkdown, fromOpml, toOpml } from "./outline-io"; +import { fromWisemapping, toWisemapping } from "./wisemapping"; import { exportXmind, importXmindReport } from "./xmind"; export type Opened = { name: string; path: string | null; bytes: Uint8Array }; @@ -18,7 +19,7 @@ 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, MindManager, otherwise Cartocortex JSON. */ +/** What to write for a document saved at `path`, by extension: XMind, OPML, Markdown, FreeMind, MindManager, 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); @@ -26,10 +27,11 @@ export function docBytes(path: string, roots: Node[], cur: number): Uint8Array { 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 === "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; a FreeMind or MindManager 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; a WiseMapping map gives one sheet. `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 }; @@ -37,6 +39,7 @@ export async function parseDocReport(name: string, bytes: Uint8Array): 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", "mmap"] }] }); + const path = await open({ filters: [{ name: "Mind map or outline", extensions: ["json", "xmind", "opml", "md", "markdown", "mm", "mmap", "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,.mm,.mmap" }); + const i = Object.assign(document.createElement("input"), { type: "file", accept: ".json,.xmind,.opml,.md,.markdown,.mm,.mmap,.wxml" }); i.onchange = async () => { const f = i.files?.[0]; res(f ? { name: f.name, path: null, bytes: new Uint8Array(await f.arrayBuffer()) } : null); @@ -131,6 +134,7 @@ export async function pickSavePath(defaultName: string): Promise { name: "OPML outline (.opml)", extensions: ["opml"] }, { name: "FreeMind / Freeplane (.mm)", extensions: ["mm"] }, { name: "MindManager (.mmap)", extensions: ["mmap"] }, + { name: "WiseMapping (.wxml)", extensions: ["wxml"] }, { name: "Markdown outline (.md)", extensions: ["md"] }, ], }); 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`; +}