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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:#<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
1 change: 1 addition & 0 deletions node_modules
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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") },
]} />
Expand Down
6 changes: 6 additions & 0 deletions src/files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<?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 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,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";
Expand All @@ -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<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: [] };
return { ...parseBook(text()), lost: [] };
}

Expand Down Expand Up @@ -85,11 +88,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"] }] });
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);
Expand Down Expand Up @@ -123,6 +126,7 @@ 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: "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