Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,13 +101,15 @@ Saving (desktop app): ⌘S saves to the current file, and asks where the first t

MIT, see [LICENSE](LICENSE).

Outline formats: Open reads Markdown (`.md`, headings and nested lists; `- [x]` task boxes become 100% progress, the first link becomes the topic's link) and OPML (`.opml`, with notes, links and checked items). "Export OPML" writes one top-level outline per sheet, and Save as can write `.opml`, `.md`, `.xmind` or FreeMind `.mm` as well as `.json`.
Outline formats: Open reads Markdown (`.md`, headings and nested lists; `- [x]` task boxes become 100% progress, the first link becomes the topic's link) and OPML (`.opml`, with notes, links and checked items). "Export OPML" writes one top-level outline per sheet, and Save as can write `.opml`, `.md`, `.xmind` or FreeMind `.mm` or MindManager `.mmap` as well as `.json`.

FreeMind / Freeplane: Open reads FreeMind's `.mm` maps (plain or gzipped) — topics, notes, links, links between topics, folding, fill and text colours, bold / italic, priority and progress icons — and left-side topics open as a one-sided or two-sided map. "Export FreeMind" (and Save as `.mm`) writes one root node per sheet, so extra sheets may not show in FreeMind itself; multi-line texts keep their lines, but pictures, relationships, summaries, per-word style, floating positions and structures beyond the sideways layouts are not carried.

Zip-based files (`.xmind`) are refused if they would unpack to more than 64 MB, whatever size they claim, so a booby-trapped map cannot exhaust memory.
MindManager: Open reads MindManager's `.mmap` maps — a zip holding `Document.xml`, or the flat XML of the MindManager 10 for Mac builds — with topics, notes, links, task priorities and progress, fill and text colours, folding, relationships (as relationship lines), floating topics and callouts; the growth direction of the central topic opens as Logic (right), Logic (left) or Map. "Export MindManager" (and Save as `.mmap`) writes one map per sheet, so extra sheets may not show in MindManager itself; topic links to other topics, pictures, per-word style, boundaries and summaries are not carried. The format was taken from Freeplane's MindManager importer and WiseMapping's, and has not been checked against MindManager itself.

Safety rule for opened files: only Cartocortex's own `.json` keeps its place on disk. Opening a `.xmind`, `.opml`, FreeMind or Markdown file is an *import*: the map starts as unsaved, and ⌘S asks for a new file rather than overwriting your original with a lossy copy.
Zip-based files (`.xmind`, `.mmap`) are refused if they would unpack to more than 64 MB, whatever size they claim, so a booby-trapped map cannot exhaust memory.

Safety rule for opened files: only Cartocortex's own `.json` keeps its place on disk. Opening a `.xmind`, `.opml`, FreeMind, MindManager or Markdown file is an *import*: the map starts as unsaved, and ⌘S asks for a new file rather than overwriting your original with a lossy copy.

Links between topics: in the panel, "Link to a topic" → "Pick a topic…", then click the target. The source gets a ↪ badge (and "Go ↪" in the panel); clicking it opens any collapsed branches above the target and selects it, also when the target is on another sheet. In `.xmind` files a topic link is `xmind:#<topic id>`: it survives a round trip, and XMind allows one link per topic, so an outside web link wins if a topic has both.

Expand Down
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
import { exportXmind } from "./xmind";
import { toOpml } from "./outline-io";
import { toFreemind } from "./freemind";
import { toMindmanager } from "./mindmanager";
import { downscale } from "./image";
import { TEMPLATES } from "./templates";
import { customColors, customTheme } from "./theme";
Expand Down Expand Up @@ -610,6 +611,7 @@ export default function App({ active, primary, tabId, restored, openPath, onMeta
{ label: "Markdown (.md)", on: () => saveFile(fileName("md"), "md", toMarkdown(s.root)) },
{ label: "OPML (.opml)", on: () => saveFile(fileName("opml"), "opml", toOpml(park().roots)) },
{ label: "FreeMind (.mm)", on: () => saveFile(fileName("mm"), "mm", toFreemind(park().roots)) },
{ label: "MindManager (.mmap)", on: () => saveFile(fileName("mmap"), "mmap", toMindmanager(park().roots)) },
{ label: "Image (.png)", on: () => exportImage("png") },
{ label: "Document (.pdf)", on: () => exportImage("pdf") },
]} />
Expand Down
6 changes: 6 additions & 0 deletions src/files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ test("documents are written and read in the format their extension names", async
const backMm = await parseDoc("plan.mm", mm);
expect(backMm.roots.map((r) => r.text)).toEqual(["Plan", "Second"]); // one root node per sheet
expect(backMm.roots[0].children[0].text).toBe("child");

const mmap = docBytes("/tmp/plan.mmap", roots, 1);
expect(new TextDecoder().decode(mmap.slice(0, 2))).toBe("PK"); // a zip
const backMmap = await parseDoc("plan.mmap", mmap);
expect(backMmap.roots.map((r) => r.text)).toEqual(["Plan", "Second"]); // one map per sheet
expect(backMmap.roots[0].children[0].text).toBe("child");
});

const store = new Map<string, string>();
Expand Down
12 changes: 8 additions & 4 deletions src/files.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Book, bookJson, parseBook } from "./book";
import { fromFreemindBytes, toFreemind } from "./freemind";
import { fromMindmanagerBytes, toMindmanager } from "./mindmanager";
import { Node, toMarkdown } from "./model";
import { fromMarkdown, fromOpml, toOpml } from "./outline-io";
import { exportXmind, importXmindReport } from "./xmind";
Expand All @@ -17,23 +18,25 @@ export const pushRecent = (list: string[], path: string, max = 8) => [path, ...l
/** Only Cartocortex's own format keeps its place on disk when opened; every other format is an import (Save asks for a new file). */
export const keepsPath = (name: string) => extOf(name) === "json";

/** What to write for a document saved at `path`, by extension: XMind, OPML, Markdown, FreeMind, otherwise Cartocortex JSON. */
/** What to write for a document saved at `path`, by extension: XMind, OPML, Markdown, FreeMind, MindManager, otherwise Cartocortex JSON. */
export function docBytes(path: string, roots: Node[], cur: number): Uint8Array {
const ext = extOf(path), text = (s: string) => new TextEncoder().encode(s);
if (ext === "xmind") return exportXmind(roots);
if (ext === "opml") return text(toOpml(roots));
if (ext === "md" || ext === "markdown") return text(roots.map((r) => toMarkdown(r)).join("\n"));
if (ext === "mm") return text(toFreemind(roots));
if (ext === "mmap") return toMindmanager(roots);
return text(bookJson(roots, cur, true));
}

/** The document in a file, by its extension. A Markdown or OPML file gives one sheet, named after the file if it has no single top topic. A FreeMind map gives one sheet per root topic. `lost` lists what an XMind import could not carry over. */
/** The document in a file, by its extension. A Markdown or OPML file gives one sheet, named after the file if it has no single top topic; a FreeMind or MindManager map gives one sheet per root topic. `lost` lists what an XMind import could not carry over. */
export async function parseDocReport(name: string, bytes: Uint8Array): Promise<Book & { lost: string[] }> {
const ext = extOf(name), text = () => new TextDecoder().decode(bytes);
if (ext === "xmind") return { ...(await importXmindReport(bytes)), cur: 0 };
if (ext === "opml") return { roots: [fromOpml(text())], cur: 0, lost: [] };
if (ext === "md" || ext === "markdown" || ext === "txt") return { roots: [fromMarkdown(text(), baseName(name).replace(/\.[^.]*$/, ""))], cur: 0, lost: [] };
if (ext === "mm") return { roots: await fromFreemindBytes(bytes), cur: 0, lost: [] };
if (ext === "mmap") return { roots: await fromMindmanagerBytes(bytes), cur: 0, lost: [] };
return { ...parseBook(text()), lost: [] };
}

Expand Down Expand Up @@ -88,11 +91,11 @@ export async function writePath(path: string, bytes: Uint8Array) {
export async function pickOpen(): Promise<Opened | null> {
if (tauri()) {
const { open } = await import("@tauri-apps/plugin-dialog");
const path = await open({ filters: [{ name: "Mind map or outline", extensions: ["json", "xmind", "opml", "md", "markdown", "mm"] }] });
const path = await open({ filters: [{ name: "Mind map or outline", extensions: ["json", "xmind", "opml", "md", "markdown", "mm", "mmap"] }] });
return typeof path === "string" ? readPath(path) : null;
}
return new Promise((res) => {
const i = Object.assign(document.createElement("input"), { type: "file", accept: ".json,.xmind,.opml,.md,.markdown,.mm" });
const i = Object.assign(document.createElement("input"), { type: "file", accept: ".json,.xmind,.opml,.md,.markdown,.mm,.mmap" });
i.onchange = async () => {
const f = i.files?.[0];
res(f ? { name: f.name, path: null, bytes: new Uint8Array(await f.arrayBuffer()) } : null);
Expand Down Expand Up @@ -127,6 +130,7 @@ export async function pickSavePath(defaultName: string): Promise<string | null>
{ name: "XMind (.xmind)", extensions: ["xmind"] },
{ name: "OPML outline (.opml)", extensions: ["opml"] },
{ name: "FreeMind / Freeplane (.mm)", extensions: ["mm"] },
{ name: "MindManager (.mmap)", extensions: ["mmap"] },
{ name: "Markdown outline (.md)", extensions: ["md"] },
],
});
Expand Down
111 changes: 111 additions & 0 deletions src/mindmanager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { expect, test } from "vitest";
import { mk, Node } from "./model";
import { fromMindmanager, fromMindmanagerBytes, toMindmanager } from "./mindmanager";
import { zip } from "./xmind";

const DOC = `<?xml version="1.0" encoding="UTF-8"?>
<ap:Map xmlns:ap="http://schemas.mindjet.com/MindManager/Application/2003" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<ap:OneTopic>
<ap:Topic OId="root1">
<ap:Text PlainText="Central"><ap:Font Color="ff1e3a5f"/></ap:Text>
<ap:Color FillColor="ff0d9488"/>
<ap:Hyperlink Url="https://example.com"/>
<ap:NotesGroup><ap:NotesXhtmlData PreviewPlainText="a note
on two lines"><html xmlns="http://www.w3.org/1999/xhtml"><body><p>a note</p><p>on two lines</p></body></html></ap:NotesXhtmlData></ap:NotesGroup>
<ap:Task TaskPriority="urn:mindjet:Prio1" TaskPercentage="40"/>
<ap:TopicViewGroup ViewIndex="0"><ap:Collapsed Collapsed="true"/></ap:TopicViewGroup>
<ap:SubTopics>
<ap:Topic OId="left1"><ap:Text PlainText="Left"/><ap:Offset CX="-40" CY="10"/><ap:FloatingTopics><ap:Topic><ap:Text PlainText="Callout"/></ap:Topic></ap:FloatingTopics><ap:SubTopics><ap:Topic OId="deep"><ap:Text PlainText="Deep"/></ap:Topic></ap:SubTopics></ap:Topic>
<ap:Topic OId="right1"><ap:Text PlainText="Right"/><ap:Offset CX="40" CY="-10"/></ap:Topic>
</ap:SubTopics>
<ap:SubTopicsShape SubTopicsGrowthDirection="urn:mindjet:LeftAndRight"/>
<ap:FloatingTopics><ap:Topic OId="float1"><ap:Text PlainText="Floating"/><ap:Offset CX="-100" CY="60"/></ap:Topic></ap:FloatingTopics>
</ap:Topic>
</ap:OneTopic>
<ap:Relationships>
<ap:Relationship>
<ap:ConnectionGroup Index="0"><ap:Connection><ap:ObjectReference OIdRef="left1"/></ap:Connection></ap:ConnectionGroup>
<ap:ConnectionGroup Index="1"><ap:Connection><ap:ObjectReference OIdRef="right1"/></ap:Connection></ap:ConnectionGroup>
</ap:Relationship>
</ap:Relationships>
</ap:Map>`;

test("reads a MindManager document", () => {
const [root] = fromMindmanager(DOC);
expect(root.text).toBe("Central");
expect(root.note).toBe("a note\non two lines");
expect(root.link).toBe("https://example.com");
expect(root.color).toBe("#0d9488");
expect(root.textColor).toBe("#1e3a5f");
expect(root.priority).toBe(1);
expect(root.progress).toBe(40);
expect(root.collapsed).toBe(true);
expect(root.layout).toBe("map"); // MindManager grows both sides by default
expect(root.children.map((c) => c.text)).toEqual(["Right", "Left", "Floating"]); // the map grows right first; floating topics follow
expect(root.children[1].children.map((c) => c.text)).toEqual(["Deep", "Callout"]); // a callout opens as a plain child
expect(root.children[2].float).toEqual({ x: -378, y: 227 }); // millimetres -> pixels
expect(root.rels).toHaveLength(1);
expect(root.rels![0].from).toBe(root.children[1].id); // remapped to our topic ids
expect(root.rels![0].to).toBe(root.children[0].id);
});

test("reads the simplified schema some tools write", () => {
const [root] = fromMindmanager(`<?xml version="1.0"?>
<Map><Topic ID="t1" Text="Root"><Notes>plain note</Notes><Hyperlink URL="https://x.org"/><Topic ID="t2" Text="Child"><Icon IconType="urn:mindjet:Prio2"/></Topic></Topic></Map>`);
expect(root.text).toBe("Root");
expect(root.note).toBe("plain note");
expect(root.link).toBe("https://x.org");
expect(root.children[0].text).toBe("Child");
expect(root.children[0].priority).toBe(2);
});

test("refuses what is not a MindManager map", async () => {
expect(() => fromMindmanager(`<?xml version="1.0"?><!DOCTYPE Map><Map/>`)).toThrow(); // no DOCTYPE, ever
expect(() => fromMindmanager(`<?xml version="1.0"?><Map><OneTopic/></Map>`)).toThrow(/no topic/);
expect(() => fromMindmanager(`not a mind map`)).toThrow();
await expect(fromMindmanagerBytes(zip({ "empty.txt": "x" }))).rejects.toThrow(/Document.xml/);
});

test("writes a zip MindManager re-opens", async () => {
const root: Node = {
...mk("Central\ntopic"),
note: "note\nlines",
link: "https://example.org",
priority: 3,
progress: 60,
color: "#0d9488",
textColor: "#1e3a5f",
children: [mk("Child"), { ...mk("Folded"), collapsed: true, children: [mk("Grand")] }],
};
root.rels = [{ id: crypto.randomUUID(), from: root.children[1].children[0].id, to: root.children[0].id }];
const bytes = toMindmanager([root, mk("Second sheet")]);
expect([bytes[0], bytes[1]]).toEqual([0x50, 0x4b]); // a zip
const back = await fromMindmanagerBytes(bytes);
expect(back.map((r) => r.text)).toEqual(["Central\ntopic", "Second sheet"]); // one map per sheet, newlines kept
const [r] = back;
expect(r.note).toBe("note\nlines");
expect(r.link).toBe("https://example.org");
expect(r.priority).toBe(3);
expect(r.progress).toBe(60);
expect(r.color).toBe("#0d9488");
expect(r.textColor).toBe("#1e3a5f");
expect(r.children[1].collapsed).toBe(true);
expect(r.rels).toHaveLength(1);
expect(r.rels![0].from).toBe(r.children[1].children[0].id);
expect(r.rels![0].to).toBe(r.children[0].id);
});

test("the growth direction picks the layout, and flat XML opens", async () => {
const doc = (dir: string) => `<?xml version="1.0"?>
<ap:Map xmlns:ap="http://schemas.mindjet.com/MindManager/Application/2003"><ap:OneTopic><ap:Topic><ap:Text PlainText="R"/><ap:SubTopicsShape SubTopicsGrowthDirection="urn:mindjet:${dir}"/><ap:SubTopics><ap:Topic><ap:Text PlainText="A"/></ap:Topic></ap:SubTopics></ap:Topic></ap:OneTopic></ap:Map>`;
expect(fromMindmanager(doc("Left"))[0].layout).toBe("left");
expect(fromMindmanager(doc("Right"))[0].layout).toBeUndefined(); // the app's default chart grows right
const flat = await fromMindmanagerBytes(new TextEncoder().encode(doc("Right"))); // not a zip: flat XML
expect(flat[0].children[0].text).toBe("A");
});

test("MindManager colours: alpha first, #rrggbb kept, junk dropped", () => {
const one = (v: string) => `<OneTopic><Topic><Text PlainText="t"/><Color FillColor="${v}"/></Topic></OneTopic>`;
const roots = fromMindmanager(`<Map>${one("00ffffff")}${one("ff0d9488")}${one("#12ab34")}${one("red")}</Map>`);
expect(roots.map((r) => r.color)).toEqual([undefined, "#0d9488", "#12ab34", undefined]);
});
Loading
Loading