Skip to content
Open
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
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,17 @@ Saving (desktop app): ⌘S saves to the current file, and asks where the first t

MIT, see [LICENSE](LICENSE).

Outline formats: Open reads Markdown (`.md`, headings and nested lists; `- [x]` task boxes become 100% progress, the first link becomes the topic's link) and OPML (`.opml`, with notes, links and checked items). "Export OPML" writes one top-level outline per sheet, and Save as can write `.opml`, `.md` 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`, MindManager `.mmap`, Mindomo `.mom` 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.

Safety rule for opened files: only Cartocortex's own `.json` keeps its place on disk. Opening a `.xmind`, `.opml`, FreeMind, MindManager, Mindomo, 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:#<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
8 changes: 8 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import {
} from "./model";
import { exportXmind } from "./xmind";
import { toOpml } from "./outline-io";
import { toFreemind } from "./freemind";
import { toMindmanager } from "./mindmanager";
import { toMindomo } from "./mindomo";
import { toWisemapping } from "./wisemapping";
import { downscale } from "./image";
import { TEMPLATES } from "./templates";
import { customColors, customTheme } from "./theme";
Expand Down Expand Up @@ -597,6 +601,10 @@ 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: "WiseMapping (.wxml)", on: () => saveFile(fileName("wxml"), "wxml", toWisemapping(park().roots)) },
{ label: "Image (.png)", on: () => exportImage("png") },
{ label: "Document (.pdf)", on: () => exportImage("pdf") },
]} />
Expand Down
25 changes: 25 additions & 0 deletions src/files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,31 @@ 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("<?xml")).toBe(true); // FreeMind's XML, not a zip
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 wxml = docBytes("/tmp/plan.wxml", roots, 1);
expect(new TextDecoder().decode(wxml).startsWith("<?xml")).toBe(true); // plain XML, not a zip
const backWxml = await parseDoc("plan.wxml", wxml);
expect(backWxml.roots.map((r) => 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 store = new Map<string, string>();
Expand Down
24 changes: 20 additions & 4 deletions src/files.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { Book, bookJson, parseBook } from "./book";
import { fromFreemindBytes, toFreemind } from "./freemind";
import { fromMindmanagerBytes, toMindmanager } from "./mindmanager";
import { fromMindomoBytes, toMindomo } from "./mindomo";
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 };
Expand All @@ -16,21 +20,29 @@ 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, 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 === "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 or Mindomo map gives one sheet per root topic; a WiseMapping map gives one sheet. */
export async function parseDoc(name: string, bytes: Uint8Array): Promise<Book> {
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 === "wxml") return { roots: fromWisemapping(text()), cur: 0 };
return parseBook(text());
}

Expand Down Expand Up @@ -67,11 +79,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"] }] });
const path = await open({ filters: [{ name: "Mind map or outline", extensions: ["json", "xmind", "opml", "md", "markdown", "mm", "mmap", "mom", "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,.wxml" });
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 @@ -105,6 +117,10 @@ export async function pickSavePath(defaultName: string): Promise<string | null>
{ 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: "WiseMapping (.wxml)", extensions: ["wxml"] },
{ name: "Markdown outline (.md)", extensions: ["md"] },
],
});
Expand Down
98 changes: 98 additions & 0 deletions src/freemind.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { expect, test } from "vitest";
import { fromFreemind, fromFreemindBytes, toFreemind } from "./freemind";
import { Node, mk } from "./model";

const sample = `<map version="1.0.1">
<node ID="ID_root" TEXT="Projet" FOLDED="true">
<node ID="ID_a" TEXT="Étude" POSITION="right" COLOR="#ff0000">
<richcontent TYPE="NODE"><html><head/><body><p>Ligne un</p><p>Ligne &lt;deux&gt;</p></body></html></richcontent>
<icon BUILTIN="full-3"/>
<arrowlink DESTINATION="ID_b"/>
<node ID="ID_a1" TEXT="Bibliographie">
<hook NAME="map/Note"><text>Voir la revue de 2025</text></hook>
</node>
</node>
<node ID="ID_b" TEXT="Création" POSITION="left" LINK="https://example.com/">
<icon BUILTIN="75%"/>
<node ID="ID_b1" TEXT="Maquette" BACKGROUND_COLOR="00ccff">
<font BOLD="true" ITALIC="true"/>
</node>
</node>
</node>
</map>`;

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 <deux>", "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(`<map version="1.0.1"><node TEXT="Racine"><node TEXT="A" POSITION="left"/><node TEXT="B" POSITION="left"/></node></map>`);
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("<!DOCTYPE map><map/>")).toThrow();
expect(() => fromFreemind("ceci n'est pas du XML")).toThrow();
expect(() => fromFreemind("<map version='1.0.1'/>")).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(
`<map version="1.0.1"><node TEXT="R">` +
`<node TEXT="A"><hook NAME="map/Note"><text>ancienne note</text></hook></node>` +
`<node TEXT="B"><richcontent TYPE="NOTE"><html><head/><body><p>note riche</p></body></html></richcontent></node>` +
`</node></map>`,
);
expect(roots[0].children[0].note).toBe("ancienne note");
expect(roots[0].children[1].note).toBe("note riche");
});
Loading
Loading