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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,15 +101,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`, `.xmind` or FreeMind `.mm` or MindManager `.mmap` 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` or WiseMapping `.wxml` 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.

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.

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.
Safety rule for opened files: only Cartocortex's own `.json` keeps its place on disk. Opening a `.xmind`, `.opml`, FreeMind, MindManager, 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
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { exportXmind } from "./xmind";
import { toOpml } from "./outline-io";
import { toFreemind } from "./freemind";
import { toMindmanager } from "./mindmanager";
import { toWisemapping } from "./wisemapping";
import { downscale } from "./image";
import { TEMPLATES } from "./templates";
import { customColors, customTheme } from "./theme";
Expand Down Expand Up @@ -612,6 +613,7 @@ export default function App({ active, primary, tabId, restored, openPath, onMeta
{ 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: "WiseMapping (.wxml)", on: () => saveFile(fileName("wxml"), "wxml", toWisemapping(park().roots)) },
{ label: "Image (.png)", on: () => exportImage("png") },
{ label: "Document (.pdf)", on: () => exportImage("pdf") },
]} />
Expand Down
7 changes: 7 additions & 0 deletions src/files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ test("documents are written and read in the format their extension names", async
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 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
Expand Up @@ -3,6 +3,7 @@ import { fromFreemindBytes, toFreemind } from "./freemind";
import { fromMindmanagerBytes, toMindmanager } from "./mindmanager";
import { Node, toMarkdown } from "./model";
import { fromMarkdown, fromOpml, toOpml } from "./outline-io";
import { fromWisemapping, toWisemapping } from "./wisemapping";
import { exportXmind, importXmindReport } from "./xmind";

export type Opened = { name: string; path: string | null; bytes: Uint8Array };
Expand All @@ -18,25 +19,27 @@ 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, MindManager, otherwise Cartocortex JSON. */
/** What to write for a document saved at `path`, by extension: XMind, OPML, Markdown, FreeMind, MindManager, 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 === "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; a FreeMind or MindManager 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; a WiseMapping map gives one sheet. `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: [] };
if (ext === "wxml") return { roots: fromWisemapping(text()), cur: 0, lost: [] };
return { ...parseBook(text()), lost: [] };
}

Expand Down Expand Up @@ -91,11 +94,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", "mmap"] }] });
const path = await open({ filters: [{ name: "Mind map or outline", extensions: ["json", "xmind", "opml", "md", "markdown", "mm", "mmap", "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,.mm,.mmap" });
const i = Object.assign(document.createElement("input"), { type: "file", accept: ".json,.xmind,.opml,.md,.markdown,.mm,.mmap,.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 @@ -131,6 +134,7 @@ export async function pickSavePath(defaultName: string): Promise<string | null>
{ name: "OPML outline (.opml)", extensions: ["opml"] },
{ name: "FreeMind / Freeplane (.mm)", extensions: ["mm"] },
{ name: "MindManager (.mmap)", extensions: ["mmap"] },
{ name: "WiseMapping (.wxml)", extensions: ["wxml"] },
{ name: "Markdown outline (.md)", extensions: ["md"] },
],
});
Expand Down
116 changes: 116 additions & 0 deletions src/wisemapping.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { expect, test } from "vitest";
import { mk, Node } from "./model";
import { fromWisemapping, toWisemapping } from "./wisemapping";

const sample = `<map version="tango" name="essai">
<topic central="true" id="1" text="Racine"><note><![CDATA[une note
sur deux lignes]]></note><topic id="2" position="200,-50" order="0" text="Droite"><link url="https://example.com/"/><eicon id="\u2b50"/></topic><topic id="3" position="-200,50" order="1" text="Gauche" shrink="true"><topic id="4" position="-290,25" order="0" text="Enfant &lt;et&gt;"/></topic><topic id="5" position="200,150" order="2" fontStyle=";;#cc0000;bold;italic;" bgColor="#00ccff" shape="line"><text><![CDATA[premi\u00e8re
seconde]]></text></topic></topic>
<topic id="6" position="400,300" order="0" text="Libre"><note text="encoded%20note"/></topic>
<relationship srcTopicId="5" destTopicId="6" lineType="3" endArrow="true" startArrow="false"/>
</map>`;

test("a WiseMapping sample opens: text blocks, notes, styles and relationships come across", () => {
const roots = fromWisemapping(sample);
expect(roots).toHaveLength(1);
const root = roots[0];
expect(root.text).toBe("Racine");
expect(root.note).toBe("une note\nsur deux lignes"); // the note is a CDATA block
expect(root.layout).toBe("map"); // one left-side topic among three
expect(root.children.map((c) => c.text)).toEqual(["Droite", "premi\u00e8re\nseconde", "Gauche", "Libre"]); // right side first
const right = root.children[0];
expect(right.link).toBe("https://example.com/");
expect(right.icon).toBe("\u2b50"); // an emoji marker
const styled = root.children[1];
expect(styled.textColor).toBe("#cc0000"); // fontStyle is "family;size;colour;weight;style;"
expect(styled.bold).toBe(true);
expect(styled.italic).toBe(true);
expect(styled.color).toBe("#00ccff"); // bgColor
expect(styled.shape).toBe("line");
const left = root.children[2];
expect(left.collapsed).toBe(true); // shrink, with a child under it
expect(left.children[0].text).toBe("Enfant <et>"); // entities are decoded
const free = root.children[3];
expect(free.float).toEqual({ x: 400, y: 300 }); // a topic at map level floats on the canvas
expect(free.note).toBe("encoded note"); // the old percent-encoded note attribute
expect(root.rels).toHaveLength(1);
expect(root.rels![0].from).toBe(styled.id);
expect(root.rels![0].to).toBe(free.id);
});

test("children keep their order attribute, and a left-leaning map opens as Logic (left)", () => {
const pela = fromWisemapping(`<map version="pela"><topic central="true" id="1"><topic id="2" order="2" position="-200,60" text="C"/><topic id="3" order="1" position="-200,-60" text="B"/><topic id="4" order="0" position="-200,0" text="A"/></topic></map>`);
expect(pela[0].layout).toBe("left");
expect(pela[0].children.map((c) => c.text)).toEqual(["A", "B", "C"]);
const none = fromWisemapping(`<map version="pela"><topic id="1" text="Premier"/><topic id="2" text="Detache" position="300,100"/></map>`);
expect(none[0].text).toBe("Premier"); // no central attribute: the first topic opens as the map's centre
expect(none[0].children[0].text).toBe("Detache");
expect(none[0].children[0].float).toEqual({ x: 300, y: 100 });
});

test("strict reading: a DOCTYPE, another format and a map without a topic are rejected", () => {
expect(() => fromWisemapping("<!DOCTYPE map><map/>")).toThrow();
expect(() => fromWisemapping("<html><body>pas une carte</body></html>")).toThrow(/not a WiseMapping map/);
expect(() => fromWisemapping('<map version="tango"/>')).toThrow(/no topic/);
expect(() => fromWisemapping("ceci n'est pas du XML")).toThrow();
});

test("multi-line text, styles, folding and relationships survive the round trip", () => {
const root: Node = {
...mk("Carte"),
children: [
{
...mk("a]]>b\nfin"), // "]]>" cannot sit inside a CDATA block, so it is split the way WiseMapping writes it
color: "#00ccff",
textColor: "#ff0000",
bold: true,
italic: true,
icon: "\u2b50",
note: "une\nnote",
link: "https://example.com/",
shape: "rect",
children: [mk('Enfant "&"')],
},
{ ...mk("Pli\u00e9"), collapsed: true, children: [mk("Dedans")] },
],
};
root.rels = [{ id: root.children[0].children[0].id, from: root.children[0].id, to: root.children[1].children[0].id }];
const back = fromWisemapping(toWisemapping([root, mk("Second")]))[0];
expect(back.children[0].text).toBe("a]]>b\nfin");
expect(back.children[0].children[0].text).toBe('Enfant "&"'); // XML escaping holds
expect(back.children[0].color).toBe("#00ccff");
expect(back.children[0].textColor).toBe("#ff0000");
expect(back.children[0].bold).toBe(true);
expect(back.children[0].italic).toBe(true);
expect(back.children[0].icon).toBe("\u2b50");
expect(back.children[0].note).toBe("une\nnote");
expect(back.children[0].link).toBe("https://example.com/");
expect(back.children[0].shape).toBe("rect");
expect(back.children[1].collapsed).toBe(true);
expect(back.rels).toHaveLength(1);
expect(back.rels![0].from).toBe(back.children[0].id);
expect(back.rels![0].to).toBe(back.children[1].children[0].id);
const second = back.children[2];
expect(second.text).toBe("Second");
expect(second.float).toEqual({ x: 200, y: 250 }); // the sheet after the first opens as a free branch
});

test("writing works out positions: a two-sided map splits, a left chart stays left", () => {
const out = toWisemapping([{ ...mk("R"), layout: "map" as const, children: [mk("A"), mk("B"), mk("C")] }]);
expect(out).toContain('position="200,-25"'); // A and B grow right
expect(out).toContain('position="200,25"');
expect(out).toContain('position="-200,0"'); // C grows left
const back = fromWisemapping(out)[0];
expect(back.layout).toBe("map");
expect(back.children.map((c) => c.text)).toEqual(["A", "B", "C"]); // the right side comes first

const left = toWisemapping([{ ...mk("R"), layout: "left" as const, children: [mk("A"), mk("B")] }]);
expect(left).toContain('position="-200,-25"');
expect(left).toContain('position="-200,25"');
expect(fromWisemapping(left)[0].layout).toBe("left");

const deep = toWisemapping([{ ...mk("R"), children: [{ ...mk("A"), children: [mk("A1"), mk("A2")] }] }]);
expect(deep).toContain('position="200,0"'); // a first-level topic sits 200 px from the centre
expect(deep).toContain('position="290,-25"'); // a deeper one 90 px from its parent
expect(deep).toContain('position="290,25"');
});
Loading
Loading