diff --git a/README.md b/README.md index 666bb2e..71b04a8 100644 --- a/README.md +++ b/README.md @@ -101,11 +101,13 @@ 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` 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. -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. +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. 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/node_modules b/node_modules new file mode 120000 index 0000000..883f1a5 --- /dev/null +++ b/node_modules @@ -0,0 +1 @@ +/Users/thierry/Claude Code/mindmap/node_modules \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index e259285..2d94e51 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { } from "./model"; import { exportXmind } from "./xmind"; import { toOpml } from "./outline-io"; +import { toFreemind } from "./freemind"; import { downscale } from "./image"; import { TEMPLATES } from "./templates"; import { customColors, customTheme } from "./theme"; @@ -608,6 +609,7 @@ export default function App({ active, primary, tabId, restored, openPath, onMeta { 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: "Image (.png)", on: () => exportImage("png") }, { label: "Document (.pdf)", on: () => exportImage("pdf") }, ]} /> diff --git a/src/files.test.ts b/src/files.test.ts index 619c644..0af0d20 100644 --- a/src/files.test.ts +++ b/src/files.test.ts @@ -32,6 +32,12 @@ 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 store = new Map(); diff --git a/src/files.ts b/src/files.ts index eba96f0..9e69242 100644 --- a/src/files.ts +++ b/src/files.ts @@ -1,4 +1,5 @@ import { Book, bookJson, parseBook } from "./book"; +import { fromFreemindBytes, toFreemind } from "./freemind"; import { Node, toMarkdown } from "./model"; import { fromMarkdown, fromOpml, toOpml } from "./outline-io"; import { exportXmind, importXmindReport } from "./xmind"; @@ -16,21 +17,23 @@ 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, 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)); 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. `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 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: [] }; return { ...parseBook(text()), lost: [] }; } @@ -85,11 +88,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"] }] }); 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" }); i.onchange = async () => { const f = i.files?.[0]; res(f ? { name: f.name, path: null, bytes: new Uint8Array(await f.arrayBuffer()) } : null); @@ -123,6 +126,7 @@ 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: "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/xmindxml.ts b/src/xmindxml.ts index 734e794..033d307 100644 --- a/src/xmindxml.ts +++ b/src/xmindxml.ts @@ -9,7 +9,7 @@ export type XNode = { name: string; attrs: Record; children: XNo const local = (name: string) => name.replace(/^.*:/, ""); // drop namespace prefixes: xlink:href -> href -const decode = (s: string) => +export const decode = (s: string) => s.replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos);/gi, (m, e: string) => { if (e[0] === "#") { const code = e[1].toLowerCase() === "x" ? parseInt(e.slice(2), 16) : Number(e.slice(1));