diff --git a/README.md b/README.md index 7c13ecf..2b40fc0 100644 --- a/README.md +++ b/README.md @@ -99,9 +99,21 @@ 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`, iThoughts `.itmz`, MindManager `.mmap`, Mindomo `.mom`, SimpleMind `.smmx` 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. + +iThoughts: Open reads iThoughts's `.itmz` maps — a zip holding one mapdata.xml (the old iThoughtsHD `.itm` files are not read) — with topics in file order, titles that span lines and notes; a topic at the top level besides the map's own opens as its own sheet. "Export iThoughts" (and Save as `.itmz`) writes one map per sheet, so extra sheets may not show in iThoughts itself. Colours, icons, images, hyperlinks, task info, folding, floating topics and sheet names are not carried. The format follows what iThoughts itself writes and what MultiMarkdown's exporter writes, and has not been checked against iThoughts itself. + +SimpleMind: Open reads SimpleMind's `.smmx` maps — a zip holding document/mindmap.xml, whose topics form a flat list where each one points at its parent — with topics (line breaks kept), notes, web links, task checkboxes and the map's relations (as relationship lines); a file holding several mindmaps opens one sheet per mindmap. "Export SimpleMind" (and Save as `.smmx`) writes one mindmap per sheet, so extra sheets may not show in SimpleMind itself. Callout labels, boundaries, colours, positions, layout hints and folding are not carried. The reading follows a map saved by SimpleMind itself, and the writing has not been checked against SimpleMind itself. + +Safety rule for opened files: only Cartocortex's own `.json` keeps its place on disk. Opening a `.xmind`, `.opml`, FreeMind, iThoughts, MindManager, Mindomo, SimpleMind, 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..eb12821 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,12 @@ import { } from "./model"; import { exportXmind } from "./xmind"; import { toOpml } from "./outline-io"; +import { toFreemind } from "./freemind"; +import { toIthoughts } from "./ithoughts"; +import { toMindmanager } from "./mindmanager"; +import { toMindomo } from "./mindomo"; +import { toSimplemind } from "./simplemind"; +import { toWisemapping } from "./wisemapping"; import { downscale } from "./image"; import { TEMPLATES } from "./templates"; import { customColors, customTheme } from "./theme"; @@ -597,6 +603,12 @@ 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: "iThoughts (.itmz)", on: () => saveFile(fileName("itmz"), "itmz", toIthoughts(park().roots)) }, + { label: "SimpleMind (.smmx)", on: () => saveFile(fileName("smmx"), "smmx", toSimplemind(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..c263e78 100644 --- a/src/files.test.ts +++ b/src/files.test.ts @@ -32,6 +32,43 @@ 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 itmz = docBytes("/tmp/plan.itmz", roots, 1); + expect(new TextDecoder().decode(itmz.slice(0, 2))).toBe("PK"); // a zip + const backItmz = await parseDoc("plan.itmz", itmz); + expect(backItmz.roots.map((r) => r.text)).toEqual(["Plan", "Second"]); // one map per file; the sheets after it re-open as sheets + expect(backItmz.roots[0].children[0].text).toBe("child"); + + const smmx = docBytes("/tmp/plan.smmx", roots, 1); + expect(new TextDecoder().decode(smmx.slice(0, 2))).toBe("PK"); // a zip + const backSmmx = await parseDoc("plan.smmx", smmx); + expect(backSmmx.roots.map((r) => r.text)).toEqual(["Plan", "Second"]); // one mindmap per sheet, so the sheets come back + expect(backSmmx.roots[0].children[0].text).toBe("child"); }); const store = new Map(); diff --git a/src/files.ts b/src/files.ts index f242e82..002e65e 100644 --- a/src/files.ts +++ b/src/files.ts @@ -1,6 +1,12 @@ import { Book, bookJson, parseBook } from "./book"; +import { fromFreemindBytes, toFreemind } from "./freemind"; +import { fromIthoughtsBytes, toIthoughts } from "./ithoughts"; +import { fromMindmanagerBytes, toMindmanager } from "./mindmanager"; +import { fromMindomoBytes, toMindomo } from "./mindomo"; import { Node, toMarkdown } from "./model"; import { fromMarkdown, fromOpml, toOpml } from "./outline-io"; +import { fromSimplemindBytes, toSimplemind } from "./simplemind"; +import { fromWisemapping, toWisemapping } from "./wisemapping"; import { exportXmind, importXmindSheets } from "./xmind"; export type Opened = { name: string; path: string | null; bytes: Uint8Array }; @@ -16,21 +22,33 @@ 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, iThoughts, SimpleMind, 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 === "itmz") return toIthoughts(roots); + if (ext === "smmx") return toSimplemind(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, Mindomo, iThoughts or SimpleMind 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 === "itmz") return { roots: await fromIthoughtsBytes(bytes), cur: 0 }; + if (ext === "smmx") return { roots: await fromSimplemindBytes(bytes), cur: 0 }; + if (ext === "wxml") return { roots: fromWisemapping(text()), cur: 0 }; return parseBook(text()); } @@ -67,11 +85,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", "itmz", "smmx", "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,.itmz,.smmx,.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 +123,12 @@ 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: "iThoughts (.itmz)", extensions: ["itmz"] }, + { name: "SimpleMind (.smmx)", extensions: ["smmx"] }, + { 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/ithoughts.test.ts b/src/ithoughts.test.ts new file mode 100644 index 0000000..8e9ecd7 --- /dev/null +++ b/src/ithoughts.test.ts @@ -0,0 +1,54 @@ +import { expect, test } from "vitest"; +import { mk, Node } from "./model"; +import { fromIthoughtsBytes, toIthoughts } from "./ithoughts"; +import { unzip, zip } from "./xmind"; + +/** A small .itmz, in the shape of the files iThoughts itself writes. */ +const itmz = (xml: string) => zip({ "mapdata.xml": xml }); + +test("reads an iThoughts map", async () => { + const bytes = itmz( + '\n\n\n' + + '\n' + + '\n\n\n' + + '\n' + + '\n\n\n\n', + ); + const roots = await fromIthoughtsBytes(bytes); + expect(roots.map((r) => r.text)).toEqual(["Central", "Second sheet"]); // the map's topic first, then any other at the top level + expect(roots[0].note).toBe("A note"); + expect(roots[0].children.map((c) => c.text)).toEqual(["Plan\non two lines", "Watch"]); // file order, numeric escapes decoded + expect(roots[0].children[0].children[0].text).toBe("Deep"); // nesting is the hierarchy +}); + +test("reads the wrapper-free shape as well", async () => { + const bytes = itmz(''); + const roots = await fromIthoughtsBytes(bytes); + expect(roots.map((r) => r.text)).toEqual(["Only"]); // MultiMarkdown writes no wrapper + expect(roots[0].children[0].text).toBe("Child"); +}); + +test("refuses what is not an iThoughts map", async () => { + await expect(fromIthoughtsBytes(new TextEncoder().encode(''))).rejects.toThrow(/not an iThoughts .itmz/); + await expect(fromIthoughtsBytes(zip({ "style.xml": "