From 17270dbccea04f130b77a355b408ae70ae34cde6 Mon Sep 17 00:00:00 2001 From: Martin-PB Date: Sat, 17 Oct 2026 15:27:29 +0200 Subject: [PATCH 1/2] Open and write WiseMapping .wxml maps WiseMapping keeps its maps as plain XML (the current "tango" dialect and the older "pela" files), and Cartocortex now reads and writes that. Reading is the strict XML parser from the old .xmind files (no DOCTYPE, predefined entities only): texts (a single-line attribute or a multi-line block), notes (the CDATA note and the percent-encoded attribute of the old files), links, fill and text colours, bold and 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) and a two-sided one as a map. Writing goes the other way (Save as .wxml and a new "Export WiseMapping" button): the first sheet is the map and the sheets after it are WiseMapping's free 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. Agent: Vibe (Mistral, glm-5-latest-short) Relu-par-humain: Martin-PB --- README.md | 6 +- src/App.tsx | 2 + src/files.test.ts | 7 ++ src/files.ts | 12 ++- src/wisemapping.test.ts | 116 +++++++++++++++++++++++++++ src/wisemapping.ts | 168 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 305 insertions(+), 6 deletions(-) create mode 100644 src/wisemapping.test.ts create mode 100644 src/wisemapping.ts diff --git a/README.md b/README.md index cdcbcf1..c105a69 100644 --- a/README.md +++ b/README.md @@ -99,13 +99,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` 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. -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. +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. + +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 c7999c7..daf331d 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"; @@ -601,6 +602,7 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP { 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 08f3788..d8987f5 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 501ab3c..bf46595 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, importXmindSheets } 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. */ +/** 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. */ 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 }; @@ -37,6 +39,7 @@ export async function parseDoc(name: string, bytes: Uint8Array): Promise { 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 === "wxml") return { roots: fromWisemapping(text()), cur: 0 }; return parseBook(text()); } @@ -73,11 +76,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", "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); @@ -113,6 +116,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..5617e65 --- /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(({ from, to }) => !!from && !!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`; +} From 54c0b421d43fe57cdb875ef8db887d2e49b2548c Mon Sep 17 00:00:00 2001 From: Martin-PB Date: Sun, 18 Oct 2026 05:12:22 +0200 Subject: [PATCH 2/2] WiseMapping: narrow the relationship filter to its kept ends The reader drops relationships whose ends are missing, but the type checker still saw both ends as possibly undefined and refused the build; the filter now carries a type guard saying what it keeps. The FreeMind gzip test also compares the two reads without their fresh ids, the same fix the FreeMind branch already carries. Agent: Vibe (Mistral, glm-5-latest-short) Relu-par-humain: Martin-PB --- src/freemind.test.ts | 7 +++++-- src/wisemapping.ts | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/freemind.test.ts b/src/freemind.test.ts index e020bab..f3b569a 100644 --- a/src/freemind.test.ts +++ b/src/freemind.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "vitest"; import { fromFreemind, fromFreemindBytes, toFreemind } from "./freemind"; -import { mk } from "./model"; +import { Node, mk } from "./model"; const sample = ` @@ -48,10 +48,13 @@ test("left-side topics everywhere open as a one-sided chart", () => { expect(roots[0].children.map((c) => c.text)).toEqual(["A", "B"]); }); +// Reading mints fresh ids (and remaps topic links onto them), so compare the maps without them. +const noIds = (r: Node[]) => JSON.stringify(r, (k, v) => (k === "id" || k === "topicLink" ? undefined : v)); + test("a gzipped .mm opens like a plain one", async () => { const plain = new TextEncoder().encode(sample); const gz = new Uint8Array(await new Response(new Blob([plain as BlobPart]).stream().pipeThrough(new CompressionStream("gzip"))).arrayBuffer()); - expect(await fromFreemindBytes(gz)).toEqual(await fromFreemindBytes(plain)); + expect(noIds(await fromFreemindBytes(gz))).toBe(noIds(await fromFreemindBytes(plain))); }); test("strict reading: a DOCTYPE, non-XML and a map without a topic are rejected", () => { diff --git a/src/wisemapping.ts b/src/wisemapping.ts index 5617e65..7acc90a 100644 --- a/src/wisemapping.ts +++ b/src/wisemapping.ts @@ -105,7 +105,7 @@ export function fromWisemapping(src: string): Node[] { // 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(({ from, to }) => !!from && !!to) + .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];