From d07eeb7af13cc33b6857201ceaa5a67e3ee7c76e Mon Sep 17 00:00:00 2001 From: Martin-PB Date: Sat, 17 Oct 2026 13:54:34 +0200 Subject: [PATCH 1/9] Open and write FreeMind/Freeplane .mm maps FreeMind's .mm is the de facto open interchange format for mind maps, so Cartocortex reads and writes it. Reading is the strict XML parser from the old .xmind files (no DOCTYPE, predefined entities only), on plain and gzipped files: texts (richcontent keeps its line order), notes (richcontent and the old map/Note hook), links, links between topics (arrowlink), folding, fill and text colour, bold / italic, priority (full-N) and progress (0%-100%) icons. Left-side top-level topics open as a logic-left or two-sided map. Writing goes the other way (Save as .mm and a new "Export FreeMind" button): one root node per sheet, so extra sheets may not show in FreeMind itself, and structures beyond the sideways layouts, pictures, relationships, summaries, per-word style and floating positions are not carried. The xmindxml entity decoder is exported for the reader. Agent: Vibe (Mistral, glm-5-latest-short) Relu-par-humain: Martin-PB --- README.md | 48 +++++----- src/App.tsx | 72 +++++++-------- src/files.test.ts | 6 ++ src/files.ts | 12 ++- src/freemind.test.ts | 95 ++++++++++++++++++++ src/freemind.ts | 205 +++++++++++++++++++++++++++++++++++++++++++ src/xmindxml.ts | 2 +- 7 files changed, 377 insertions(+), 63 deletions(-) create mode 100644 src/freemind.test.ts create mode 100644 src/freemind.ts diff --git a/README.md b/README.md index 7c13ecf..e36d3d4 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A local, native mind-mapping app (XMind-style). Tauri 2 + React + TypeScript. Files stay on your machine. -Tabs and menus: the toolbar is three menus (File with templates, recent files and export; Insert; View with structure, theme and panels) plus Undo, Redo, Delete, Find and Save. Each map opens in its own tab (a blank tab is reused; Open… puts a map in a new tab when the current one holds a file or unsaved work); the second row of tabs is the sheets inside one map. Only the first tab keeps the autosave. +Tabs and menus: the toolbar is three menus (File with templates, recent files and export; Insert; View with structure, theme and panels) plus Undo, Redo, Delete, Find and Save. Each map opens in its own tab (a blank tab is reused; Open… puts a map in a new tab when the current one holds a file or unsaved work); the second row of tabs is the sheets inside one map. Only the first tab keeps the autosave. ## Download @@ -29,18 +29,18 @@ npm test # unit tests for the tree model and layout | Space / F2 / double-click | Edit text (Enter to commit, Esc to cancel) | | Arrows | Move selection | | Delete / Backspace | Delete node | -| ⌘/Ctrl+Z, +Shift | Undo, redo | -| ⌘/Ctrl+S | Save as JSON | +| ⌘/Ctrl+Z, +Shift | Undo, redo | +| ⌘/Ctrl+S | Save as JSON | | Drag a node onto another | Re-parent | -| Wheel / drag background | Pan; ⌘/Ctrl+wheel or pinch zooms | +| Wheel / drag background | Pan; ⌘/Ctrl+wheel or pinch zooms | -The ‹ chip on a node collapses its branch. The map autosaves to local storage; Save/Open use `.json`, and Export MD writes a Markdown outline. +The ‹ chip on a node collapses its branch. The map autosaves to local storage; Save/Open use `.json`, and Export MD writes a Markdown outline. ## Layout -- `src/model.ts` – immutable tree ops, tidy-tree layout, Markdown export -- `src/App.tsx` – canvas, keyboard handling, undo/redo, save/open -- `src-tauri/` – Rust shell with the dialog and fs plugins +- `src/model.ts` – immutable tree ops, tidy-tree layout, Markdown export +- `src/App.tsx` – canvas, keyboard handling, undo/redo, save/open +- `src-tauri/` – Rust shell with the dialog and fs plugins ## Not yet @@ -48,19 +48,19 @@ Direction variants of XMind structures (up-pointing org chart, left-hand tree), ## Added -Fill colour, shape, emoji markers, notes and links (inspector panel); relationship lines and boundaries; Logic and Map (two-sided) structures; five themes with per-branch colours; copy/cut/paste/duplicate (⌘C/X/V/D); find (⌘F, Enter cycles); fit-to-view; `.xmind` open and export (all sheets; text, notes, links, folding and structure only). +Fill colour, shape, emoji markers, notes and links (inspector panel); relationship lines and boundaries; Logic and Map (two-sided) structures; five themes with per-branch colours; copy/cut/paste/duplicate (⌘C/X/V/D); find (⌘F, Enter cycles); fit-to-view; `.xmind` open and export (all sheets; text, notes, links, folding and structure only). Outline view (toolbar "Outline": Enter = sibling, Tab / Shift+Tab = indent / outdent, shares undo with the map) and PNG / PDF export (the PDF is one page holding an image of the map, so its text isn't selectable). Summaries: select a topic, click "Summary", then click the last topic of the range (it must share a parent). A bracket and an editable label (double-click) appear beside the span; click the label and press Delete to remove it. They are included in PNG/PDF export but not in `.xmind` export. -Presentation: "Present" goes full-screen and reveals one top-level branch per step (→ / Space / Enter next, ← back, Esc exit); earlier branches dim. +Presentation: "Present" goes full-screen and reveals one top-level branch per step (→ / Space / Enter next, ← back, Esc exit); earlier branches dim. -Multi-select: Shift / ⌘ / Ctrl-click toggles topics; Shift-drag on empty canvas draws a selection box (⌘/Ctrl-drag adds to the current selection); ⌘A selects all. Delete, drag-to-reparent, and the Fill / Shape / Marker controls act on the whole selection, and Summary wraps selected siblings in one click. Copy / paste / duplicate still work on the primary topic only. +Multi-select: Shift / ⌘ / Ctrl-click toggles topics; Shift-drag on empty canvas draws a selection box (⌘/Ctrl-drag adds to the current selection); ⌘A selects all. Delete, drag-to-reparent, and the Fill / Shape / Marker controls act on the whole selection, and Summary wraps selected siblings in one click. Copy / paste / duplicate still work on the primary topic only. Floating topics: "Float" (or double-click empty canvas) adds a free-standing topic with no branch to the root. Drag it anywhere; release over another topic to attach it there. In `.xmind` export they become ordinary top-level topics (positions are not kept). Deleting topics also removes relationships, boundaries and summaries that pointed at them. -Sheets: tabs under the toolbar, one root topic and one undo history per sheet. "+" adds a sheet, double-click a tab renames it, × then "Delete?" removes it. Save writes every sheet to one `.json` (older single-map files still open); `.xmind` import and export carry all sheets. PNG, PDF, Markdown and presentation use the open sheet. +Sheets: tabs under the toolbar, one root topic and one undo history per sheet. "+" adds a sheet, double-click a tab renames it, × then "Delete?" removes it. Save writes every sheet to one `.json` (older single-map files still open); `.xmind` import and export carry all sheets. PNG, PDF, Markdown and presentation use the open sheet. Security: the native window runs under a Content-Security-Policy (`src-tauri/tauri.conf.json`): scripts only from the app itself, no network connections except Tauri's own bridge, images only from the app, `data:` and `blob:`. Inline styles are allowed because topic colours are applied as style attributes. Files you open are parsed as data and never executed. @@ -68,27 +68,27 @@ Security: the native window runs under a Content-Security-Policy (`src-tauri/tau `app-icon.svg` is generated by `scripts/make-icon.mjs` (a side-view brain with a neuron network on a pale teal tile, in the Glossolalie palette: teal #0D9488, navy #1E3A5F and pale tints; the palette and the neuron layout seed are constants at the top). To rebuild: `node scripts/make-icon.mjs`, render the SVG to `app-icon.png` at 1024px (any SVG renderer, e.g. `@resvg/resvg-js`), then `npx tauri icon app-icon.png` to refresh `src-tauri/icons/`. -Custom theme: pick "Custom…" in the theme menu to edit the central/accent colour and six branch colours in the panel; changes preview live and commit as one undo step. Colours are stored in the document (`themeColors`) and only `#rrggbb` values are accepted. +Custom theme: pick "Custom…" in the theme menu to edit the central/accent colour and six branch colours in the panel; changes preview live and commit as one undo step. Colours are stored in the document (`themeColors`) and only `#rrggbb` values are accepted. Look: the UI follows the Glossolalie palette (teal `#0D9488` accent, navy `#1E3A5F` ink, pale teal tints for surfaces, thin 40%-tint rules) with no dark fills and no dark mode; the central topic is a tinted pill with a thicker teal border. The default theme is named "Glossolalie"; the other themes only change branch colours. PNG / PDF export uses the same ink and tints. -Task markers and links: the panel sets a priority (1-5, a numbered disc: darker is more urgent) and a progress (0-100%, a pie that becomes a check at 100%) on the selected topics; both map to XMind's `priority-N` and `task-*` markers on `.xmind` import and export (other XMind markers are dropped). A topic's link shows a 🔗 badge: click it, or use "Open link", to open it in the system browser. Only http, https and mailto links open; a bare host such as `example.com` gets https://, and `javascript:` or `file:` links are refused. +Task markers and links: the panel sets a priority (1-5, a numbered disc: darker is more urgent) and a progress (0-100%, a pie that becomes a check at 100%) on the selected topics; both map to XMind's `priority-N` and `task-*` markers on `.xmind` import and export (other XMind markers are dropped). A topic's link shows a 🔗 badge: click it, or use "Open link", to open it in the system browser. Only http, https and mailto links open; a bare host such as `example.com` gets https://, and `javascript:` or `file:` links are refused. -Structures: the toolbar picker offers Logic (right), Logic (left), Map (both sides), Org chart (top-down), Tree (indented list), Fishbone, Timeline, Brace and Matrix. Arrow keys follow the structure (in an org chart, ↓ goes to the first child and ← / → move between topics in a row; in a tree, ↑ / ↓ walk the rows). Summaries are drawn in the Logic and Map structures only. The structure is saved per sheet and maps to XMind's org-chart, tree-chart, logic and map structures; XMind structures not listed open as their nearest match, or as Logic. +Structures: the toolbar picker offers Logic (right), Logic (left), Map (both sides), Org chart (top-down), Tree (indented list), Fishbone, Timeline, Brace and Matrix. Arrow keys follow the structure (in an org chart, ↓ goes to the first child and ← / → move between topics in a row; in a tree, ↑ / ↓ walk the rows). Summaries are drawn in the Logic and Map structures only. The structure is saved per sheet and maps to XMind's org-chart, tree-chart, logic and map structures; XMind structures not listed open as their nearest match, or as Logic. -Text style: the panel's Text section (and ⌘B / ⌘I) sets bold, italic, strikethrough and a text colour on the selected topics. With the cursor in a topic (or in the notes) the same controls style just the selected words; otherwise they style the whole topic. Notes are now a small rich editor too (plain text underneath, so search still works). Per-word style is saved with the map and shows in PNG / PDF export, but `.xmind` export writes plain text for titles and notes (topic-level style is still exported). The style is kept in PNG / PDF export and in `.xmind` files (as XMind's `fo:font-weight`, `fo:font-style`, `fo:text-decoration` and `fo:color` properties; I have not checked it against a file made by XMind itself). Colours read from files must be plain `#rrggbb`. +Text style: the panel's Text section (and ⌘B / ⌘I) sets bold, italic, strikethrough and a text colour on the selected topics. With the cursor in a topic (or in the notes) the same controls style just the selected words; otherwise they style the whole topic. Notes are now a small rich editor too (plain text underneath, so search still works). Per-word style is saved with the map and shows in PNG / PDF export, but `.xmind` export writes plain text for titles and notes (topic-level style is still exported). The style is kept in PNG / PDF export and in `.xmind` files (as XMind's `fo:font-weight`, `fo:font-style`, `fo:text-decoration` and `fo:color` properties; I have not checked it against a file made by XMind itself). Colours read from files must be plain `#rrggbb`. -Fishbone: the central topic is the head on the right; top-level topics alternate above and below the spine on slanted bones, with their sub-topics as ribs along each bone and deeper levels hanging to the left of their parent. Arrow keys walk the bones in reading order (← towards the tail, → towards the head). The head is always on the right (XMind's right-headed fishbone). +Fishbone: the central topic is the head on the right; top-level topics alternate above and below the spine on slanted bones, with their sub-topics as ribs along each bone and deeper levels hanging to the left of their parent. Arrow keys walk the bones in reading order (← towards the tail, → towards the head). The head is always on the right (XMind's right-headed fishbone). Old XMind files: `.xmind` files saved by XMind 8 and earlier (XML inside, `content.xml`) now open too, with every sheet's title, structure, topics, notes, links, folding and priority / progress markers. Floating topics, relationships, styles and other markers in those files are dropped. Files from newer XMind (JSON inside) are read as before, and take precedence when a file has both. The reader is strict by design: XML with a DOCTYPE is refused. -Timeline: the central topic starts a horizontal line and the top-level topics sit on it, in order. Each topic's sub-topics stack in a column under the first topic, above the second, and so on (nearest first, indented by depth). ← / → move along the line; ↑ / ↓ move into and along a stack. It maps to XMind's horizontal timeline, and any XMind timeline variant opens as this one. +Timeline: the central topic starts a horizontal line and the top-level topics sit on it, in order. Each topic's sub-topics stack in a column under the first topic, above the second, and so on (nearest first, indented by depth). ← / → move along the line; ↑ / ↓ move into and along a stack. It maps to XMind's horizontal timeline, and any XMind timeline variant opens as this one. XMind compatibility notes: structure classes, `fo:*` style keys, marker ids and the style / marker JSON layout were checked against identifiers inside the installed XMind app, but not against a document saved by XMind (none was available on this machine). More structures: **Logic (left)** mirrors the logic chart around the root. **Brace** keeps the logic layout but draws a curly brace around each parent's children (a plain line when there is only a row or two). **Matrix** (XMind's spreadsheet structure) puts the top-level topics side by side as columns, each with its sub-topics listed beneath. They map to XMind's `logic.left`, `brace.right` and `spreadsheet.column`; XMind's `brace.left` opens as the right-hand brace, and its row-wise matrix opens as columns. -Saving (desktop app): ⌘S saves to the current file, and asks where the first time (or after New); ⇧⌘S saves under a new name. The format follows the extension: `.json` (Cartocortex, keeps everything) or `.xmind`. The window title shows the file name, with a • while there are unsaved changes, and New, Open and closing the window ask before discarding them. "Recent…" lists the last 8 files (kept across restarts). In a browser, Save downloads a `.json` instead. The map is also autosaved in the app's own storage, which is what you see when you start without opening a file. +Saving (desktop app): ⌘S saves to the current file, and asks where the first time (or after New); ⇧⌘S saves under a new name. The format follows the extension: `.json` (Cartocortex, keeps everything) or `.xmind`. The window title shows the file name, with a • while there are unsaved changes, and New, Open and closing the window ask before discarding them. "Recent…" lists the last 8 files (kept across restarts). In a browser, Save downloads a `.json` instead. The map is also autosaved in the app's own storage, which is what you see when you start without opening a file. ## Development and releases @@ -99,12 +99,14 @@ 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`. -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. -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. +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. Pictures: the panel's "Picture" section puts an image (PNG, JPEG, GIF or WebP) on the selected topic, shown as a small thumbnail in the topic; click the thumbnail for the full image (Esc or a click closes it). The picture is downscaled to at most 800 px and stored inside the map file, so keep an eye on size (the autosave in the app's own storage has a few-megabyte limit). Pictures appear in PNG / PDF export but not in `.xmind`, OPML or Markdown. Only embedded raster images are ever drawn: SVG and web addresses in a map file are ignored. -Templates: the "Template…" menu next to New starts a map from one of eight starters (brainstorm, project plan as a timeline, SWOT as a matrix, cause and effect as a fishbone, meeting notes as a tree, org chart, weekly plan, pros and cons as a brace map). Like New, it asks before discarding unsaved changes. +Templates: the "Template…" menu next to New starts a map from one of eight starters (brainstorm, project plan as a timeline, SWOT as a matrix, cause and effect as a fishbone, meeting notes as a tree, org chart, weekly plan, pros and cons as a brace map). Like New, it asks before discarding unsaved changes. diff --git a/src/App.tsx b/src/App.tsx index 84f8c05..9c64895 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"; @@ -33,7 +34,7 @@ const THEMES: Record { const name = docPath ? baseName(docPath) : docName ?? "Untitled"; - const title = `${dirty ? "• " : ""}${name} — Cartocortex`; + const title = `${dirty ? "• " : ""}${name} — Cartocortex`; onMeta(name, dirty); if (!active) return; document.title = title; @@ -542,7 +543,7 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP window.addEventListener("pointerup", up); }; - // Shift-drag on empty canvas draws a selection box; ⌘/Ctrl-drag adds to the current selection. + // Shift-drag on empty canvas draws a selection box; ⌘/Ctrl-drag adds to the current selection. const marquee = (e: React.PointerEvent) => { const r = stage.current!.getBoundingClientRect(); const sx = e.clientX - r.left, sy = e.clientY - r.top; @@ -586,10 +587,10 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP
open() }, - { label: "Save", kbd: "⌘S", on: () => saveDoc() }, - ...(isTauri ? [{ label: "Save as…", kbd: "⇧⌘S", on: () => saveDoc(true) }] : []), + { label: "New map", kbd: "⌘N", on: newDoc }, + { label: "Open…", kbd: "⌘O", on: () => open() }, + { label: "Save", kbd: "⌘S", on: () => saveDoc() }, + ...(isTauri ? [{ label: "Save as…", kbd: "⇧⌘S", on: () => saveDoc(true) }] : []), { heading: "New from template" }, ...TEMPLATES.map((x) => ({ label: x.name, on: () => startFrom(x.build()) })), ...(isTauri && recent.length ? [{ heading: "Recent" }, ...recent.map((p) => ({ label: baseName(p), title: p, on: () => open(p) }))] : []), @@ -597,18 +598,19 @@ 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: "Image (.png)", on: () => exportImage("png") }, { label: "Document (.pdf)", on: () => exportImage("pdf") }, ]} /> setLinking(!linking) }, + { label: linking ? "Relationship: pick the target…" : "Relationship", title: "Choose this, then click the target topic", on: () => setLinking(!linking) }, { label: "Boundary", checked: !!s.root.bounds?.includes(s.sel), on: toggleBound }, { - label: summarizing ? "Summary: pick the range end…" : "Summary", + label: summarizing ? "Summary: pick the range end…" : "Summary", title: "Select several sibling topics first, or choose this and then click the last topic of the range", on: () => { const span = siblingSpan(s.root, ids); @@ -623,7 +625,7 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP label: l, checked: (s.root.layout ?? "right") === v, on: () => setDocProp({ layout: v }), })), { heading: "Theme" }, - ...[...Object.entries(THEMES).map(([id, t]) => [id, t.name]), ["custom", "Custom…"]].map(([id, name]) => ({ + ...[...Object.entries(THEMES).map(([id, t]) => [id, t.name]), ["custom", "Custom…"]].map(([id, name]) => ({ label: name, checked: (s.root.theme ?? "classic") === id, on: () => { setDraft(null); @@ -638,14 +640,14 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP { label: "Present", on: startPresenting }, ]} /> - - - + + + (setQ(e.target.value), setHit(-1))} onKeyDown={(e) => { @@ -653,7 +655,7 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP if (e.key === "Escape") (setQ(""), e.currentTarget.blur()); }} /> - +
{sheets.roots.map((r0, i) => { @@ -691,7 +693,7 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP title={delAsk === i ? "Click again to delete this sheet" : "Delete sheet"} onClick={(e) => (e.stopPropagation(), delAsk === i ? deleteSheet(i) : setDelAsk(i))} > - {delAsk === i ? "Delete?" : "×"} + {delAsk === i ? "Delete?" : "×"} )}
@@ -796,7 +798,7 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP ) : null} {n.progress != null && ( - {n.progress >= 100 ? "✓" : ""} + {n.progress >= 100 ? "✓" : ""} )} {s.edit === b.id ? ( @@ -810,23 +812,23 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP /> ) : ( - {validRuns(n.text, n.runs)?.map((r, k) => {r.t}) ?? (n.text || "…")} + {validRuns(n.text, n.runs)?.map((r, k) => {r.t}) ?? (n.text || "…")} )} - {n.note && 📝} + {n.note && 📝} {n.link && ( e.stopPropagation()} onClick={(e) => (e.stopPropagation(), openLink(n.link!))}> - 🔗 + 🔗 )} {n.topicLink && where(n.topicLink) && ( e.stopPropagation()} onClick={(e) => (e.stopPropagation(), goTo(n.topicLink!))} > - ↪ + ↪ )} {n.children.length > 0 && ( @@ -836,7 +838,7 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP onPointerDown={(e) => e.stopPropagation()} onClick={() => commit((r) => toggle(r, b.id))} > - {n.collapsed ? n.children.length : "‹"} + {n.collapsed ? n.children.length : "‹"} )}
@@ -863,7 +865,7 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP }} /> ) : ( - {x.text || "…"} + {x.text || "…"} )} ))} @@ -917,8 +919,8 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP
e.preventDefault() /* keep the editor's selection */}> - - + +
e.preventDefault()}> @@ -944,22 +946,22 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP {!multi && ( <> - e.target.value !== (selNode.link ?? "") && setNode({ link: e.target.value || undefined })} /> - {selNode.link && } + e.target.value !== (selNode.link ?? "") && setNode({ link: e.target.value || undefined })} /> + {selNode.link && }
- {selNode.topicLink && where(selNode.topicLink) && } + {selNode.topicLink && where(selNode.topicLink) && } {selNode.topicLink && }
{selNode.topicLink && ( -
{where(selNode.topicLink) ? `→ ${where(selNode.topicLink)!.node.text || "…"}` : "The linked topic no longer exists."}
+
{where(selNode.topicLink) ? `→ ${where(selNode.topicLink)!.node.text || "…"}` : "The linked topic no longer exists."}
)}
- + {selNode.image && }
@@ -983,9 +985,9 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP )} {present !== null && (
- + {present + 1} / {steps} - +
)} diff --git a/src/files.test.ts b/src/files.test.ts index 14ab949..8fecea7 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 f242e82..1f2f594 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, importXmindSheets } 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. */ +/** 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. */ export async function parseDoc(name: string, bytes: Uint8Array): Promise { 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 }; return parseBook(text()); } @@ -67,11 +70,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); @@ -105,6 +108,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..e020bab --- /dev/null +++ b/src/freemind.test.ts @@ -0,0 +1,95 @@ +import { expect, test } from "vitest"; +import { fromFreemind, fromFreemindBytes, toFreemind } from "./freemind"; +import { 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"]); +}); + +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(await fromFreemindBytes(gz)).toEqual(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 50794dd..2d51a25 100644 --- a/src/xmindxml.ts +++ b/src/xmindxml.ts @@ -8,7 +8,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)); From 75c75ab96ecdfb5dcffaf696be6b0bebbfcb75d3 Mon Sep 17 00:00:00 2001 From: Martin-PB Date: Sat, 17 Oct 2026 14:16:38 +0200 Subject: [PATCH 2/9] Restore the UTF-8 characters the first commit mangled The first commit of this branch wrote App.tsx and README.md with the parts it did not mean to change double-encoded: every non-ASCII character (the command-key shortcut signs, arrows, ellipses) had become mojibake. Both files are rebuilt from main with the same .mm edits, so the branch now only changes what the FreeMind support needs. Agent: Vibe (Mistral, glm-5-latest-short) Relu-par-humain: Martin-PB --- README.md | 44 ++++++++++++++++----------------- src/App.tsx | 70 ++++++++++++++++++++++++++--------------------------- 2 files changed, 57 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index e36d3d4..3d6676f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A local, native mind-mapping app (XMind-style). Tauri 2 + React + TypeScript. Files stay on your machine. -Tabs and menus: the toolbar is three menus (File with templates, recent files and export; Insert; View with structure, theme and panels) plus Undo, Redo, Delete, Find and Save. Each map opens in its own tab (a blank tab is reused; Open… puts a map in a new tab when the current one holds a file or unsaved work); the second row of tabs is the sheets inside one map. Only the first tab keeps the autosave. +Tabs and menus: the toolbar is three menus (File with templates, recent files and export; Insert; View with structure, theme and panels) plus Undo, Redo, Delete, Find and Save. Each map opens in its own tab (a blank tab is reused; Open… puts a map in a new tab when the current one holds a file or unsaved work); the second row of tabs is the sheets inside one map. Only the first tab keeps the autosave. ## Download @@ -29,18 +29,18 @@ npm test # unit tests for the tree model and layout | Space / F2 / double-click | Edit text (Enter to commit, Esc to cancel) | | Arrows | Move selection | | Delete / Backspace | Delete node | -| ⌘/Ctrl+Z, +Shift | Undo, redo | -| ⌘/Ctrl+S | Save as JSON | +| ⌘/Ctrl+Z, +Shift | Undo, redo | +| ⌘/Ctrl+S | Save as JSON | | Drag a node onto another | Re-parent | -| Wheel / drag background | Pan; ⌘/Ctrl+wheel or pinch zooms | +| Wheel / drag background | Pan; ⌘/Ctrl+wheel or pinch zooms | -The ‹ chip on a node collapses its branch. The map autosaves to local storage; Save/Open use `.json`, and Export MD writes a Markdown outline. +The ‹ chip on a node collapses its branch. The map autosaves to local storage; Save/Open use `.json`, and Export MD writes a Markdown outline. ## Layout -- `src/model.ts` – immutable tree ops, tidy-tree layout, Markdown export -- `src/App.tsx` – canvas, keyboard handling, undo/redo, save/open -- `src-tauri/` – Rust shell with the dialog and fs plugins +- `src/model.ts` – immutable tree ops, tidy-tree layout, Markdown export +- `src/App.tsx` – canvas, keyboard handling, undo/redo, save/open +- `src-tauri/` – Rust shell with the dialog and fs plugins ## Not yet @@ -48,19 +48,19 @@ Direction variants of XMind structures (up-pointing org chart, left-hand tree), ## Added -Fill colour, shape, emoji markers, notes and links (inspector panel); relationship lines and boundaries; Logic and Map (two-sided) structures; five themes with per-branch colours; copy/cut/paste/duplicate (⌘C/X/V/D); find (⌘F, Enter cycles); fit-to-view; `.xmind` open and export (all sheets; text, notes, links, folding and structure only). +Fill colour, shape, emoji markers, notes and links (inspector panel); relationship lines and boundaries; Logic and Map (two-sided) structures; five themes with per-branch colours; copy/cut/paste/duplicate (⌘C/X/V/D); find (⌘F, Enter cycles); fit-to-view; `.xmind` open and export (all sheets; text, notes, links, folding and structure only). Outline view (toolbar "Outline": Enter = sibling, Tab / Shift+Tab = indent / outdent, shares undo with the map) and PNG / PDF export (the PDF is one page holding an image of the map, so its text isn't selectable). Summaries: select a topic, click "Summary", then click the last topic of the range (it must share a parent). A bracket and an editable label (double-click) appear beside the span; click the label and press Delete to remove it. They are included in PNG/PDF export but not in `.xmind` export. -Presentation: "Present" goes full-screen and reveals one top-level branch per step (→ / Space / Enter next, ← back, Esc exit); earlier branches dim. +Presentation: "Present" goes full-screen and reveals one top-level branch per step (→ / Space / Enter next, ← back, Esc exit); earlier branches dim. -Multi-select: Shift / ⌘ / Ctrl-click toggles topics; Shift-drag on empty canvas draws a selection box (⌘/Ctrl-drag adds to the current selection); ⌘A selects all. Delete, drag-to-reparent, and the Fill / Shape / Marker controls act on the whole selection, and Summary wraps selected siblings in one click. Copy / paste / duplicate still work on the primary topic only. +Multi-select: Shift / ⌘ / Ctrl-click toggles topics; Shift-drag on empty canvas draws a selection box (⌘/Ctrl-drag adds to the current selection); ⌘A selects all. Delete, drag-to-reparent, and the Fill / Shape / Marker controls act on the whole selection, and Summary wraps selected siblings in one click. Copy / paste / duplicate still work on the primary topic only. Floating topics: "Float" (or double-click empty canvas) adds a free-standing topic with no branch to the root. Drag it anywhere; release over another topic to attach it there. In `.xmind` export they become ordinary top-level topics (positions are not kept). Deleting topics also removes relationships, boundaries and summaries that pointed at them. -Sheets: tabs under the toolbar, one root topic and one undo history per sheet. "+" adds a sheet, double-click a tab renames it, × then "Delete?" removes it. Save writes every sheet to one `.json` (older single-map files still open); `.xmind` import and export carry all sheets. PNG, PDF, Markdown and presentation use the open sheet. +Sheets: tabs under the toolbar, one root topic and one undo history per sheet. "+" adds a sheet, double-click a tab renames it, × then "Delete?" removes it. Save writes every sheet to one `.json` (older single-map files still open); `.xmind` import and export carry all sheets. PNG, PDF, Markdown and presentation use the open sheet. Security: the native window runs under a Content-Security-Policy (`src-tauri/tauri.conf.json`): scripts only from the app itself, no network connections except Tauri's own bridge, images only from the app, `data:` and `blob:`. Inline styles are allowed because topic colours are applied as style attributes. Files you open are parsed as data and never executed. @@ -68,27 +68,27 @@ Security: the native window runs under a Content-Security-Policy (`src-tauri/tau `app-icon.svg` is generated by `scripts/make-icon.mjs` (a side-view brain with a neuron network on a pale teal tile, in the Glossolalie palette: teal #0D9488, navy #1E3A5F and pale tints; the palette and the neuron layout seed are constants at the top). To rebuild: `node scripts/make-icon.mjs`, render the SVG to `app-icon.png` at 1024px (any SVG renderer, e.g. `@resvg/resvg-js`), then `npx tauri icon app-icon.png` to refresh `src-tauri/icons/`. -Custom theme: pick "Custom…" in the theme menu to edit the central/accent colour and six branch colours in the panel; changes preview live and commit as one undo step. Colours are stored in the document (`themeColors`) and only `#rrggbb` values are accepted. +Custom theme: pick "Custom…" in the theme menu to edit the central/accent colour and six branch colours in the panel; changes preview live and commit as one undo step. Colours are stored in the document (`themeColors`) and only `#rrggbb` values are accepted. Look: the UI follows the Glossolalie palette (teal `#0D9488` accent, navy `#1E3A5F` ink, pale teal tints for surfaces, thin 40%-tint rules) with no dark fills and no dark mode; the central topic is a tinted pill with a thicker teal border. The default theme is named "Glossolalie"; the other themes only change branch colours. PNG / PDF export uses the same ink and tints. -Task markers and links: the panel sets a priority (1-5, a numbered disc: darker is more urgent) and a progress (0-100%, a pie that becomes a check at 100%) on the selected topics; both map to XMind's `priority-N` and `task-*` markers on `.xmind` import and export (other XMind markers are dropped). A topic's link shows a 🔗 badge: click it, or use "Open link", to open it in the system browser. Only http, https and mailto links open; a bare host such as `example.com` gets https://, and `javascript:` or `file:` links are refused. +Task markers and links: the panel sets a priority (1-5, a numbered disc: darker is more urgent) and a progress (0-100%, a pie that becomes a check at 100%) on the selected topics; both map to XMind's `priority-N` and `task-*` markers on `.xmind` import and export (other XMind markers are dropped). A topic's link shows a 🔗 badge: click it, or use "Open link", to open it in the system browser. Only http, https and mailto links open; a bare host such as `example.com` gets https://, and `javascript:` or `file:` links are refused. -Structures: the toolbar picker offers Logic (right), Logic (left), Map (both sides), Org chart (top-down), Tree (indented list), Fishbone, Timeline, Brace and Matrix. Arrow keys follow the structure (in an org chart, ↓ goes to the first child and ← / → move between topics in a row; in a tree, ↑ / ↓ walk the rows). Summaries are drawn in the Logic and Map structures only. The structure is saved per sheet and maps to XMind's org-chart, tree-chart, logic and map structures; XMind structures not listed open as their nearest match, or as Logic. +Structures: the toolbar picker offers Logic (right), Logic (left), Map (both sides), Org chart (top-down), Tree (indented list), Fishbone, Timeline, Brace and Matrix. Arrow keys follow the structure (in an org chart, ↓ goes to the first child and ← / → move between topics in a row; in a tree, ↑ / ↓ walk the rows). Summaries are drawn in the Logic and Map structures only. The structure is saved per sheet and maps to XMind's org-chart, tree-chart, logic and map structures; XMind structures not listed open as their nearest match, or as Logic. -Text style: the panel's Text section (and ⌘B / ⌘I) sets bold, italic, strikethrough and a text colour on the selected topics. With the cursor in a topic (or in the notes) the same controls style just the selected words; otherwise they style the whole topic. Notes are now a small rich editor too (plain text underneath, so search still works). Per-word style is saved with the map and shows in PNG / PDF export, but `.xmind` export writes plain text for titles and notes (topic-level style is still exported). The style is kept in PNG / PDF export and in `.xmind` files (as XMind's `fo:font-weight`, `fo:font-style`, `fo:text-decoration` and `fo:color` properties; I have not checked it against a file made by XMind itself). Colours read from files must be plain `#rrggbb`. +Text style: the panel's Text section (and ⌘B / ⌘I) sets bold, italic, strikethrough and a text colour on the selected topics. With the cursor in a topic (or in the notes) the same controls style just the selected words; otherwise they style the whole topic. Notes are now a small rich editor too (plain text underneath, so search still works). Per-word style is saved with the map and shows in PNG / PDF export, but `.xmind` export writes plain text for titles and notes (topic-level style is still exported). The style is kept in PNG / PDF export and in `.xmind` files (as XMind's `fo:font-weight`, `fo:font-style`, `fo:text-decoration` and `fo:color` properties; I have not checked it against a file made by XMind itself). Colours read from files must be plain `#rrggbb`. -Fishbone: the central topic is the head on the right; top-level topics alternate above and below the spine on slanted bones, with their sub-topics as ribs along each bone and deeper levels hanging to the left of their parent. Arrow keys walk the bones in reading order (← towards the tail, → towards the head). The head is always on the right (XMind's right-headed fishbone). +Fishbone: the central topic is the head on the right; top-level topics alternate above and below the spine on slanted bones, with their sub-topics as ribs along each bone and deeper levels hanging to the left of their parent. Arrow keys walk the bones in reading order (← towards the tail, → towards the head). The head is always on the right (XMind's right-headed fishbone). Old XMind files: `.xmind` files saved by XMind 8 and earlier (XML inside, `content.xml`) now open too, with every sheet's title, structure, topics, notes, links, folding and priority / progress markers. Floating topics, relationships, styles and other markers in those files are dropped. Files from newer XMind (JSON inside) are read as before, and take precedence when a file has both. The reader is strict by design: XML with a DOCTYPE is refused. -Timeline: the central topic starts a horizontal line and the top-level topics sit on it, in order. Each topic's sub-topics stack in a column under the first topic, above the second, and so on (nearest first, indented by depth). ← / → move along the line; ↑ / ↓ move into and along a stack. It maps to XMind's horizontal timeline, and any XMind timeline variant opens as this one. +Timeline: the central topic starts a horizontal line and the top-level topics sit on it, in order. Each topic's sub-topics stack in a column under the first topic, above the second, and so on (nearest first, indented by depth). ← / → move along the line; ↑ / ↓ move into and along a stack. It maps to XMind's horizontal timeline, and any XMind timeline variant opens as this one. XMind compatibility notes: structure classes, `fo:*` style keys, marker ids and the style / marker JSON layout were checked against identifiers inside the installed XMind app, but not against a document saved by XMind (none was available on this machine). More structures: **Logic (left)** mirrors the logic chart around the root. **Brace** keeps the logic layout but draws a curly brace around each parent's children (a plain line when there is only a row or two). **Matrix** (XMind's spreadsheet structure) puts the top-level topics side by side as columns, each with its sub-topics listed beneath. They map to XMind's `logic.left`, `brace.right` and `spreadsheet.column`; XMind's `brace.left` opens as the right-hand brace, and its row-wise matrix opens as columns. -Saving (desktop app): ⌘S saves to the current file, and asks where the first time (or after New); ⇧⌘S saves under a new name. The format follows the extension: `.json` (Cartocortex, keeps everything) or `.xmind`. The window title shows the file name, with a • while there are unsaved changes, and New, Open and closing the window ask before discarding them. "Recent…" lists the last 8 files (kept across restarts). In a browser, Save downloads a `.json` instead. The map is also autosaved in the app's own storage, which is what you see when you start without opening a file. +Saving (desktop app): ⌘S saves to the current file, and asks where the first time (or after New); ⇧⌘S saves under a new name. The format follows the extension: `.json` (Cartocortex, keeps everything) or `.xmind`. The window title shows the file name, with a • while there are unsaved changes, and New, Open and closing the window ask before discarding them. "Recent…" lists the last 8 files (kept across restarts). In a browser, Save downloads a `.json` instead. The map is also autosaved in the app's own storage, which is what you see when you start without opening a file. ## Development and releases @@ -103,10 +103,10 @@ Outline formats: Open reads Markdown (`.md`, headings and nested lists; `- [x]` 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. -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. +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. +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. Pictures: the panel's "Picture" section puts an image (PNG, JPEG, GIF or WebP) on the selected topic, shown as a small thumbnail in the topic; click the thumbnail for the full image (Esc or a click closes it). The picture is downscaled to at most 800 px and stored inside the map file, so keep an eye on size (the autosave in the app's own storage has a few-megabyte limit). Pictures appear in PNG / PDF export but not in `.xmind`, OPML or Markdown. Only embedded raster images are ever drawn: SVG and web addresses in a map file are ignored. -Templates: the "Template…" menu next to New starts a map from one of eight starters (brainstorm, project plan as a timeline, SWOT as a matrix, cause and effect as a fishbone, meeting notes as a tree, org chart, weekly plan, pros and cons as a brace map). Like New, it asks before discarding unsaved changes. +Templates: the "Template…" menu next to New starts a map from one of eight starters (brainstorm, project plan as a timeline, SWOT as a matrix, cause and effect as a fishbone, meeting notes as a tree, org chart, weekly plan, pros and cons as a brace map). Like New, it asks before discarding unsaved changes. diff --git a/src/App.tsx b/src/App.tsx index 9c64895..235c4de 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -34,7 +34,7 @@ const THEMES: Record { const name = docPath ? baseName(docPath) : docName ?? "Untitled"; - const title = `${dirty ? "• " : ""}${name} — Cartocortex`; + const title = `${dirty ? "• " : ""}${name} — Cartocortex`; onMeta(name, dirty); if (!active) return; document.title = title; @@ -543,7 +543,7 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP window.addEventListener("pointerup", up); }; - // Shift-drag on empty canvas draws a selection box; ⌘/Ctrl-drag adds to the current selection. + // Shift-drag on empty canvas draws a selection box; ⌘/Ctrl-drag adds to the current selection. const marquee = (e: React.PointerEvent) => { const r = stage.current!.getBoundingClientRect(); const sx = e.clientX - r.left, sy = e.clientY - r.top; @@ -587,10 +587,10 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP
open() }, - { label: "Save", kbd: "⌘S", on: () => saveDoc() }, - ...(isTauri ? [{ label: "Save as…", kbd: "⇧⌘S", on: () => saveDoc(true) }] : []), + { label: "New map", kbd: "⌘N", on: newDoc }, + { label: "Open…", kbd: "⌘O", on: () => open() }, + { label: "Save", kbd: "⌘S", on: () => saveDoc() }, + ...(isTauri ? [{ label: "Save as…", kbd: "⇧⌘S", on: () => saveDoc(true) }] : []), { heading: "New from template" }, ...TEMPLATES.map((x) => ({ label: x.name, on: () => startFrom(x.build()) })), ...(isTauri && recent.length ? [{ heading: "Recent" }, ...recent.map((p) => ({ label: baseName(p), title: p, on: () => open(p) }))] : []), @@ -604,13 +604,13 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP ]} /> setLinking(!linking) }, + { label: linking ? "Relationship: pick the target…" : "Relationship", title: "Choose this, then click the target topic", on: () => setLinking(!linking) }, { label: "Boundary", checked: !!s.root.bounds?.includes(s.sel), on: toggleBound }, { - label: summarizing ? "Summary: pick the range end…" : "Summary", + label: summarizing ? "Summary: pick the range end…" : "Summary", title: "Select several sibling topics first, or choose this and then click the last topic of the range", on: () => { const span = siblingSpan(s.root, ids); @@ -625,7 +625,7 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP label: l, checked: (s.root.layout ?? "right") === v, on: () => setDocProp({ layout: v }), })), { heading: "Theme" }, - ...[...Object.entries(THEMES).map(([id, t]) => [id, t.name]), ["custom", "Custom…"]].map(([id, name]) => ({ + ...[...Object.entries(THEMES).map(([id, t]) => [id, t.name]), ["custom", "Custom…"]].map(([id, name]) => ({ label: name, checked: (s.root.theme ?? "classic") === id, on: () => { setDraft(null); @@ -640,14 +640,14 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP { label: "Present", on: startPresenting }, ]} /> - - - + + + (setQ(e.target.value), setHit(-1))} onKeyDown={(e) => { @@ -655,7 +655,7 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP if (e.key === "Escape") (setQ(""), e.currentTarget.blur()); }} /> - +
{sheets.roots.map((r0, i) => { @@ -693,7 +693,7 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP title={delAsk === i ? "Click again to delete this sheet" : "Delete sheet"} onClick={(e) => (e.stopPropagation(), delAsk === i ? deleteSheet(i) : setDelAsk(i))} > - {delAsk === i ? "Delete?" : "×"} + {delAsk === i ? "Delete?" : "×"} )}
@@ -798,7 +798,7 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP ) : null} {n.progress != null && ( - {n.progress >= 100 ? "✓" : ""} + {n.progress >= 100 ? "✓" : ""} )} {s.edit === b.id ? ( @@ -812,23 +812,23 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP /> ) : ( - {validRuns(n.text, n.runs)?.map((r, k) => {r.t}) ?? (n.text || "…")} + {validRuns(n.text, n.runs)?.map((r, k) => {r.t}) ?? (n.text || "…")} )} - {n.note && 📝} + {n.note && 📝} {n.link && ( e.stopPropagation()} onClick={(e) => (e.stopPropagation(), openLink(n.link!))}> - 🔗 + 🔗 )} {n.topicLink && where(n.topicLink) && ( e.stopPropagation()} onClick={(e) => (e.stopPropagation(), goTo(n.topicLink!))} > - ↪ + ↪ )} {n.children.length > 0 && ( @@ -838,7 +838,7 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP onPointerDown={(e) => e.stopPropagation()} onClick={() => commit((r) => toggle(r, b.id))} > - {n.collapsed ? n.children.length : "‹"} + {n.collapsed ? n.children.length : "‹"} )}
@@ -865,7 +865,7 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP }} /> ) : ( - {x.text || "…"} + {x.text || "…"} )}
))} @@ -919,8 +919,8 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP
e.preventDefault() /* keep the editor's selection */}> - - + +
e.preventDefault()}> @@ -946,22 +946,22 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP {!multi && ( <> - e.target.value !== (selNode.link ?? "") && setNode({ link: e.target.value || undefined })} /> - {selNode.link && } + e.target.value !== (selNode.link ?? "") && setNode({ link: e.target.value || undefined })} /> + {selNode.link && }
- {selNode.topicLink && where(selNode.topicLink) && } + {selNode.topicLink && where(selNode.topicLink) && } {selNode.topicLink && }
{selNode.topicLink && ( -
{where(selNode.topicLink) ? `→ ${where(selNode.topicLink)!.node.text || "…"}` : "The linked topic no longer exists."}
+
{where(selNode.topicLink) ? `→ ${where(selNode.topicLink)!.node.text || "…"}` : "The linked topic no longer exists."}
)}
- + {selNode.image && }
@@ -985,9 +985,9 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP )} {present !== null && (
- + {present + 1} / {steps} - +
)} From 4fa3c56a74b33d7c385aa3c486ebf4363448394a Mon Sep 17 00:00:00 2001 From: Martin-PB Date: Sat, 17 Oct 2026 14:17:45 +0200 Subject: [PATCH 3/9] Open and write MindManager .mmap maps MindManager keeps its maps in a zip holding Document.xml (X5 and later; the Mac builds of MindManager 10 wrote flat XML), and Cartocortex now reads and writes that. Reading is the strict XML parser from the old .xmind files (no DOCTYPE, predefined entities only): texts, notes (the XHTML note and its plain-text preview), links, task priorities (Prio1-9) and progress (TaskPercentage), fill and text colour ("aarrggbb", alpha first), folding, relationships (as the map's relationship lines), floating topics and callouts. The growth direction of the central topic opens as a logic-left, logic-right or two-sided map. Writing goes the other way (Save as .mmap and a new "Export MindManager" button): one map per sheet, so extra sheets may not show in MindManager itself, and topic links to other topics, pictures, per-word style, boundaries and summaries are not carried. The unzip of the .xmind reader is exported for the .mmap reader. Agent: Vibe (Mistral, glm-5-latest-short) Relu-par-humain: Martin-PB --- README.md | 6 +- src/App.tsx | 2 + src/files.test.ts | 6 ++ src/files.ts | 12 ++- src/mindmanager.test.ts | 111 ++++++++++++++++++++++ src/mindmanager.ts | 206 ++++++++++++++++++++++++++++++++++++++++ src/xmind.ts | 2 +- 7 files changed, 338 insertions(+), 7 deletions(-) create mode 100644 src/mindmanager.test.ts create mode 100644 src/mindmanager.ts diff --git a/README.md b/README.md index 3d6676f..cdcbcf1 100644 --- a/README.md +++ b/README.md @@ -99,11 +99,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`, `.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. -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. +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, 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:#`: 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/src/App.tsx b/src/App.tsx index 235c4de..c7999c7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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"; @@ -599,6 +600,7 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP { 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") }, ]} /> diff --git a/src/files.test.ts b/src/files.test.ts index 8fecea7..08f3788 100644 --- a/src/files.test.ts +++ b/src/files.test.ts @@ -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(); diff --git a/src/files.ts b/src/files.ts index 1f2f594..501ab3c 100644 --- a/src/files.ts +++ b/src/files.ts @@ -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, importXmindSheets } from "./xmind"; @@ -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. */ +/** 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. */ export async function parseDoc(name: string, bytes: Uint8Array): Promise { 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 }; return parseBook(text()); } @@ -70,11 +73,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", "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); @@ -109,6 +112,7 @@ export async function pickSavePath(defaultName: string): Promise { 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"] }, ], }); diff --git a/src/mindmanager.test.ts b/src/mindmanager.test.ts new file mode 100644 index 0000000..ba68670 --- /dev/null +++ b/src/mindmanager.test.ts @@ -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 = ` + + + + + + +

a note

on two lines

+ + + + + + + + +
+
+ + + + + + +
`; + +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(` +plain note`); + 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(``)).toThrow(); // no DOCTYPE, ever + expect(() => fromMindmanager(``)).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) => ` +`; + 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) => ``; + const roots = fromMindmanager(`${one("00ffffff")}${one("ff0d9488")}${one("#12ab34")}${one("red")}`); + expect(roots.map((r) => r.color)).toEqual([undefined, "#0d9488", "#12ab34", undefined]); +}); diff --git a/src/mindmanager.ts b/src/mindmanager.ts new file mode 100644 index 0000000..97f5098 --- /dev/null +++ b/src/mindmanager.ts @@ -0,0 +1,206 @@ +import { Node } from "./model"; +import { parseXml, XNode } from "./xmindxml"; +import { unzip, zip } from "./xmind"; + +// Reader and writer for MindManager's .mmap format: a zip holding Document.xml (MindManager X5 and +// later), or the flat XML of the MindManager 10 for Mac builds that did not zip. Reading reuses the +// strict parser from the old .xmind files (no DOCTYPE, predefined entities only). Writing is +// deliberately lossy: one map per sheet, and only what the Document.xml schema knows. + +const kid = (n: XNode | undefined, name: string) => n?.children.find((c) => c.name === name); +const kids = (n: XNode | undefined, name: string) => (n?.children ?? []).filter((c) => c.name === name); + +/** Attribute lookup, case-insensitive: files in the wild mix Url with URL, PlainText with plaintext. */ +function attr(n: XNode | undefined, 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 a MindManager colour, which is "aarrggbb" (alpha first; a 00 alpha is no colour); a plain #rrggbb is kept. */ +function mmColor(v: string | undefined): string | undefined { + const s = v?.trim().replace(/^#/, ""); + if (!s || !/^[0-9a-f]{6}([0-9a-f]{2})?$/i.test(s)) return undefined; + if (s.length === 8) return /^00/i.test(s) ? undefined : "#" + s.slice(2).toLowerCase(); + return "#" + s.toLowerCase(); +} + +/** The task priority (1-9) of a MindManager priority id ("urn:mindjet:Prio3"). */ +const prioOf = (v: string | undefined): number | undefined => { + const m = /^urn:mindjet:Prio([1-9])$/i.exec(v ?? ""); + return m ? Number(m[1]) : undefined; +}; + +/** The task progress of a TaskPercentage (0-100) clamped into range; undefined when there is none. */ +const progressOf = (v: string | undefined): number | undefined => { + if (v === undefined || !v.trim()) return undefined; + const n = Number(v); + return Number.isFinite(n) ? Math.min(100, Math.max(0, Math.round(n))) : undefined; +}; + +/** A topic's subtopics: the Topics of its SubTopics, then any direct Topic children (the simplified schema some tools write). */ +const subTopicsOf = (t: XNode): XNode[] => [...kids(kid(t, "SubTopics"), "Topic"), ...kids(t, "Topic")]; + +// MindManager positions are in millimetres; the canvas works in pixels (96 dpi). +const PX_PER_MM = 96 / 25.4; + +/** One MindManager topic -> one Cartocortex topic. `depth` 0 is the central topic of a map. */ +function topicFromXml(t: XNode, ids: Map, depth: number): Node { + const children = subTopicsOf(t).map((c) => topicFromXml(c, ids, depth + 1)); + // The floating topics of the central topic float on the canvas; those of any other topic are its callouts, which open as plain children. + if (depth > 0) children.push(...kids(kid(t, "FloatingTopics"), "Topic").map((c) => topicFromXml(c, ids, depth + 1))); + const textEl = kid(t, "Text"); + const text = attr(textEl, "PlainText") ?? attr(t, "Text") ?? ""; + const note = (attr(kid(kid(t, "NotesGroup"), "NotesXhtmlData"), "PreviewPlainText") ?? kid(t, "Notes")?.text.trim() ?? "").trim(); + const url = attr(kid(t, "Hyperlink"), "Url"); + const link = url && !url.startsWith("#") ? url : undefined; // a "#xpointer(...)" Url points at another topic: dropped + const task = kid(t, "Task"); + const icons = [...kids(kid(kid(t, "IconsGroup"), "Icons"), "Icon"), ...kids(t, "Icon")]; + const priority = prioOf(attr(task, "TaskPriority")) ?? icons.map((i) => prioOf(attr(i, "IconType"))).find((p) => p !== undefined); + const progress = progressOf(attr(task, "TaskPercentage")); + const colorEl = kid(t, "Color"); + const fill = mmColor(attr(colorEl, "FillColor") ?? attr(colorEl, "Value")); + const ink = mmColor(attr(kid(textEl, "Font"), "Color")); + const view = kids(t, "TopicViewGroup").find((g) => attr(g, "ViewIndex") === "0") ?? kids(t, "TopicViewGroup")[0]; + const folded = attr(kid(view, "Collapsed"), "Collapsed")?.toLowerCase() === "true"; + const node: Node = { + id: crypto.randomUUID(), + text, + children, + ...(note ? { note } : {}), + ...(link ? { link } : {}), + ...(fill ? { color: fill } : {}), + ...(ink ? { textColor: ink } : {}), + ...(folded && children.length ? { collapsed: true } : {}), + ...(priority ? { priority } : {}), + ...(progress !== undefined ? { progress } : {}), + }; + const oid = attr(t, "OId") ?? attr(t, "ID"); + if (oid) ids.set(oid, node.id); // for relationships: MindManager's id -> ours + return node; +} + +/** The root topic: an ordinary topic plus the layout its subtopics' growth direction picks, and the map's floating topics. */ +function rootFromXml(x: XNode, ids: Map, map: XNode | undefined): Node { + const node = topicFromXml(x, ids, 0); + const sides = subTopicsOf(x).map((t) => (Number(attr(kid(t, "Offset"), "CX")) < 0 ? -1 : 1)); + const direction = growthOf(x, map); + if (direction === "Left") node.layout = "left"; // everything grows to the left, in order + else if (direction !== "Right") { + // Both sides (MindManager's default): a two-sided map grows its right half first, so right-side topics come first. + node.layout = "map"; + node.children = node.children.filter((_, i) => sides[i] >= 0).concat(node.children.filter((_, i) => sides[i] < 0)); + } + for (const f of kids(kid(x, "FloatingTopics"), "Topic")) { + const n = topicFromXml(f, ids, 1); + const off = kid(f, "Offset"); + n.float = { x: Math.round((Number(attr(off, "CX")) || 0) * PX_PER_MM), y: Math.round((Number(attr(off, "CY")) || 0) * PX_PER_MM) }; + node.children.push(n); + } + return node; +} + +/** Where the central topic's subtopics grow, from its own SubTopicsShape or the StyleGroup default: "Left", "Right" or both. */ +function growthOf(x: XNode, map: XNode | undefined): string { + const defaults = kid(kid(kid(map, "StyleGroup"), "RootTopicDefaultsGroup"), "DefaultSubTopicsShape"); + const dir = attr(kid(x, "SubTopicsShape"), "SubTopicsGrowthDirection") ?? attr(defaults, "SubTopicsGrowthDirection") ?? ""; + return dir.replace(/^urn:mindjet:/i, ""); +} + +/** The map's relationships, their ends remapped to our topic ids; one pointing at a missing topic is dropped. */ +function relsOf(map: XNode, ids: Map): NonNullable { + const out: NonNullable = []; + for (const rel of kids(kid(map, "Relationships"), "Relationship")) { + const end = (index: string) => { + const g = kids(rel, "ConnectionGroup").find((c) => attr(c, "Index") === index); + return ids.get(attr(kid(kid(g, "Connection"), "ObjectReference"), "OIdRef") ?? ""); + }; + const from = ids.get(attr(rel, "FromTopicID") ?? "") ?? end("0"); + const to = ids.get(attr(rel, "ToTopicID") ?? "") ?? end("1"); + if (from && to) out.push({ id: crypto.randomUUID(), from, to }); + } + return out; +} + +/** The root topics of a .mmap map. The growth direction of the central topic opens as a logic (left), logic (right) or two-sided (map) chart. */ +export function fromMindmanager(src: string): Node[] { + const root = parseXml(src); // throws on DOCTYPE, on anything but plain XML + const map = root.name === "Map" ? root : undefined; + const ids = new Map(); + const tops = map + ? [...kids(map, "OneTopic").map((o) => kid(o, "Topic")), ...kids(map, "Topic")].filter((t): t is XNode => !!t) + : root.name === "Topic" + ? [root] + : []; + const roots = tops.map((x) => rootFromXml(x, ids, map)); + if (!roots.length) throw new Error("no topic found in this mind map"); + const rels = map ? relsOf(map, ids) : []; + if (rels.length) roots[0].rels = rels; // relationship lines live on the open sheet's root topic + return roots; +} + +/** A .mmap file: a zip holding Document.xml (magic bytes "PK"), or flat XML. */ +export async function fromMindmanagerBytes(bytes: Uint8Array): Promise { + if (bytes[0] === 0x50 && bytes[1] === 0x4b) { + const files = await unzip(bytes); + const doc = [...files.entries()].find(([name]) => name.toLowerCase() === "document.xml"); + if (!doc) throw new Error("no Document.xml in this MindManager file"); + return fromMindmanager(new TextDecoder().decode(doc[1])); + } + return fromMindmanager(new TextDecoder().decode(bytes)); // the flat XML of MindManager 10 for Mac +} + +const esc = (s: string) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!); +const escAttr = (s: string) => esc(s).replace(/\n/g, " "); // a literal newline in an attribute would read back as a space + +/** MindManager writes colours as "aarrggbb", alpha first. */ +const mmHex = (c: string | undefined) => (c && /^#[0-9a-f]{6}$/i.test(c) ? "ff" + c.slice(1).toLowerCase() : undefined); + +/** One Cartocortex topic -> one MindManager topic. */ +function topicToXml(n: Node, mm: Map): string { + const ink = mmHex(n.textColor); + const inner = [`` : "/>"}`]; + const fill = mmHex(n.color); + if (fill) inner.push(``); + if (n.link) inner.push(``); + if (n.note) { + const body = n.note.split("\n").map((l) => `

${esc(l)}

`).join(""); + inner.push(`${body}`); + } + if (n.priority || n.progress != null) { + const p = n.priority ? ` TaskPriority="urn:mindjet:Prio${Math.min(9, Math.max(1, Math.round(n.priority)))}"` : ""; + const g = n.progress != null ? ` TaskPercentage="${Math.min(100, Math.max(0, Math.round(n.progress)))}"` : ""; + inner.push(``); + } + if (n.collapsed && n.children.length) inner.push(``); + if (n.children.length) inner.push(`${n.children.map((c) => topicToXml(c, mm)).join("")}`); + return `${inner.join("")}`; +} + +/** A .mmap file from Cartocortex sheets: one map per sheet, so extra sheets may not show in MindManager itself. */ +export function toMindmanager(roots: Node[]): Uint8Array { + const mm = new Map(); + const claim = (n: Node) => (mm.set(n.id, crypto.randomUUID()), n.children.forEach(claim)); + roots.forEach(claim); // every id first: a relationship may point at a topic that comes later + const oneTopics = roots.map((r) => `${topicToXml(r, mm)}`).join(""); + const rels = ((roots[0]?.rels ?? []) as NonNullable) + .filter((r) => mm.has(r.from) && mm.has(r.to)) + .map( + (r) => + `` + + ["0", "1"] + .map((i) => ``) + .join("") + + ``, + ) + .join(""); + const document = + `\n` + + `${oneTopics}${rels ? `${rels}` : ""}`; + return zip({ + "Document.xml": document, + // The OPC parts of the archive, kept minimal. + "[Content_Types].xml": `\n\n`, + "_rels/.rels": `\n\n`, + }); +} diff --git a/src/xmind.ts b/src/xmind.ts index 299331e..64a5c42 100644 --- a/src/xmind.ts +++ b/src/xmind.ts @@ -60,7 +60,7 @@ export function zip(files: Record): Uint8Array { const inflate = async (b: Uint8Array) => new Uint8Array(await new Response(new Blob([b as BlobPart]).stream().pipeThrough(new DecompressionStream("deflate-raw"))).arrayBuffer()); -async function unzip(b: Uint8Array): Promise> { +export async function unzip(b: Uint8Array): Promise> { const v = new DataView(b.buffer, b.byteOffset, b.byteLength); let e = b.length - 22; while (e >= 0 && v.getUint32(e, true) !== 0x06054b50) e--; From 17270dbccea04f130b77a355b408ae70ae34cde6 Mon Sep 17 00:00:00 2001 From: Martin-PB Date: Sat, 17 Oct 2026 15:27:29 +0200 Subject: [PATCH 4/9] Open and write WiseMapping .wxml maps WiseMapping keeps its maps as plain XML (the current "tango" dialect and the older "pela" files), and Cartocortex now reads and writes that. Reading is the strict XML parser from the old .xmind files (no DOCTYPE, predefined entities only): texts (a single-line attribute or a multi-line block), notes (the CDATA note and the percent-encoded attribute of the old files), links, fill and text colours, bold and 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) and a two-sided one as a map. Writing goes the other way (Save as .wxml and a new "Export WiseMapping" button): the first sheet is the map and the sheets after it are WiseMapping's free 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. Agent: Vibe (Mistral, glm-5-latest-short) Relu-par-humain: Martin-PB --- README.md | 6 +- src/App.tsx | 2 + src/files.test.ts | 7 ++ src/files.ts | 12 ++- src/wisemapping.test.ts | 116 +++++++++++++++++++++++++++ src/wisemapping.ts | 168 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 305 insertions(+), 6 deletions(-) create mode 100644 src/wisemapping.test.ts create mode 100644 src/wisemapping.ts diff --git a/README.md b/README.md index cdcbcf1..c105a69 100644 --- a/README.md +++ b/README.md @@ -99,13 +99,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` 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. -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. +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. + +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:#`: 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/src/App.tsx b/src/App.tsx index c7999c7..daf331d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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"; @@ -601,6 +602,7 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP { 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") }, ]} /> diff --git a/src/files.test.ts b/src/files.test.ts index 08f3788..d8987f5 100644 --- a/src/files.test.ts +++ b/src/files.test.ts @@ -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(" 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(); diff --git a/src/files.ts b/src/files.ts index 501ab3c..bf46595 100644 --- a/src/files.ts +++ b/src/files.ts @@ -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, importXmindSheets } from "./xmind"; export type Opened = { name: string; path: string | null; bytes: Uint8Array }; @@ -18,7 +19,7 @@ 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); @@ -26,10 +27,11 @@ export function docBytes(path: string, roots: Node[], cur: number): Uint8Array { 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. */ +/** 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. */ export async function parseDoc(name: string, bytes: Uint8Array): Promise { const ext = extOf(name), text = () => new TextDecoder().decode(bytes); if (ext === "xmind") return { roots: await importXmindSheets(bytes), cur: 0 }; @@ -37,6 +39,7 @@ export async function parseDoc(name: string, bytes: Uint8Array): Promise { 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 === "wxml") return { roots: fromWisemapping(text()), cur: 0 }; return parseBook(text()); } @@ -73,11 +76,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", "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); @@ -113,6 +116,7 @@ export async function pickSavePath(defaultName: string): Promise { 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"] }, ], }); diff --git a/src/wisemapping.test.ts b/src/wisemapping.test.ts new file mode 100644 index 0000000..93bd953 --- /dev/null +++ b/src/wisemapping.test.ts @@ -0,0 +1,116 @@ +import { expect, test } from "vitest"; +import { mk, Node } from "./model"; +import { fromWisemapping, toWisemapping } from "./wisemapping"; + +const sample = ` + + + +`; + +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 "); // 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(``); + expect(pela[0].layout).toBe("left"); + expect(pela[0].children.map((c) => c.text)).toEqual(["A", "B", "C"]); + const none = fromWisemapping(``); + 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("")).toThrow(); + expect(() => fromWisemapping("pas une carte")).toThrow(/not a WiseMapping map/); + expect(() => fromWisemapping('')).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"'); +}); diff --git a/src/wisemapping.ts b/src/wisemapping.ts new file mode 100644 index 0000000..5617e65 --- /dev/null +++ b/src/wisemapping.ts @@ -0,0 +1,168 @@ +import { Node } from "./model"; +import { parseXml, XNode } from "./xmindxml"; + +// Reader and writer for WiseMapping's .wxml format: plain XML, the current "tango" dialect and the +// older "pela" files. Reading reuses the strict parser from the old .xmind files (no DOCTYPE, +// predefined entities only). Writing is deliberately lossy: one map (the first sheet), with the +// sheets after it as WiseMapping's free branches, and only what the tango schema knows. + +const kid = (n: XNode | undefined, name: string) => n?.children.find((c) => c.name === name); +const kids = (n: XNode | undefined, name: string) => (n?.children ?? []).filter((c) => c.name === name); + +/** Attribute lookup, case-insensitive: files in the wild mix SrcTopicId with srcTopicId. */ +function attr(n: XNode | undefined, 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 a WiseMapping colour, which sometimes drops 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.toLowerCase() : undefined) : undefined; +}; + +/** The percent-escapes of the old pela notes, read the way WiseMapping reads them: %xx and %uxxxx. */ +const unescapeWise = (s: string) => + s.replace(/%u([0-9a-f]{4})|%([0-9a-f]{2})/gi, (m, u: string, h: string) => String.fromCharCode(parseInt(u || h, 16))); + +/** A "x,y" position; undefined when the attribute is missing or corrupt. */ +const posOf = (t: XNode): { x: number; y: number } | undefined => { + const p = (attr(t, "position") ?? "").split(","); + const x = Number(p[0]), y = Number(p[1]); + return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : undefined; +}; + +/** A topic's child topics, in their `order`; a missing order keeps the file's own order, as in WiseMapping. */ +const ordered = (t: XNode): XNode[] => kids(t, "topic").sort((a, b) => (Number(attr(a, "order")) || 0) - (Number(attr(b, "order")) || 0)); + +/** A WiseMapping shape -> ours; the "elipse" spelling and the default are the app's own round box. */ +const shapeOf = (v: string | undefined): Node["shape"] | undefined => { + const s = (v ?? "").replace("rectagle", "rectangle"); // the typo WiseMapping itself reads around + return s === "line" ? "line" : s === "rectangle" || s === "rounded rectangle" ? "rect" : undefined; +}; + +/** One WiseMapping topic -> one Cartocortex topic. */ +function topicFromXml(t: XNode, ids: Map): Node { + const children = ordered(t).map((c) => topicFromXml(c, ids)); + const textEl = kid(t, "text"); + const text = textEl ? textEl.text : attr(t, "text") ?? ""; // a multi-line text is a block, not the attribute + const noteEl = kid(t, "note"); + const noteAttr = attr(noteEl, "text"); + const note = noteEl ? (noteAttr ? unescapeWise(noteAttr) : noteEl.text) : ""; // the old attribute is percent-encoded + const link = attr(kid(t, "link"), "url"); + const icon = kids(t, "eicon").map((e) => attr(e, "id")).find(Boolean); // the old has no emoji we could read + const font = (attr(t, "fontStyle") ?? "").split(";"); + const fill = hexColor(attr(t, "bgColor")); + const ink = hexColor(font[2]); + const shape = shapeOf(attr(t, "shape")); + const node: Node = { + id: crypto.randomUUID(), + text, + children, + ...(note.trim() ? { note: note.trim() } : {}), + ...(link ? { link } : {}), + ...(icon ? { icon } : {}), + ...(fill ? { color: fill } : {}), + ...(ink ? { textColor: ink } : {}), + ...(font[3] === "bold" ? { bold: true } : {}), + ...(font[4] === "italic" ? { italic: true } : {}), + ...(shape ? { shape } : {}), + ...(attr(t, "shrink") === "true" && children.length ? { collapsed: true } : {}), + }; + const id = attr(t, "id"); + if (id) ids.set(id, node.id); // for relationships: WiseMapping's id -> ours + return node; +} + +/** The root topic of a .wxml map, with the map's free branches floating beside it. */ +export function fromWisemapping(src: string): Node[] { + const map = parseXml(src); // throws on DOCTYPE, on anything but plain XML + if (map.name !== "map") throw new Error("not a WiseMapping map"); + const ids = new Map(); + const topics = kids(map, "topic"); + const central = topics.find((t) => attr(t, "central") !== undefined) ?? topics[0]; + if (!central) throw new Error("no topic found in this mind map"); + const node = topicFromXml(central, ids); + if (node.collapsed) delete node.collapsed; // a shrink on the central topic: WiseMapping's own reader calls that a bug + // The side a first-level topic sits on comes from its position: all left opens as Logic (left), both sides as a Map. + const sides = ordered(central).map((c) => ((posOf(c)?.x ?? 0) < 0 ? -1 : 1)); + const lefts = sides.filter((s) => s < 0).length; + if (lefts === sides.length && lefts) node.layout = "left"; + else if (lefts) { + // A two-sided map grows its right half first, so right-side topics come first. + node.layout = "map"; + node.children = node.children.filter((_, i) => sides[i] >= 0).concat(node.children.filter((_, i) => sides[i] < 0)); + } + // The map's free branches: topics at map level, which open as floating topics. + for (const t of topics) { + if (t === central) continue; + const n = topicFromXml(t, ids); + const pos = posOf(t); + n.float = { x: Math.round(pos?.x ?? 0), y: Math.round(pos?.y ?? 0) }; + node.children.push(n); + } + // The map's relationships, their ends remapped to our ids. + const rels = kids(map, "relationship") + .map((r) => ({ from: ids.get(attr(r, "srcTopicId") ?? ""), to: ids.get(attr(r, "destTopicId") ?? "") })) + .filter(({ from, to }) => !!from && !!to) + .map(({ from, to }) => ({ id: crypto.randomUUID(), from, to })); + if (rels.length) node.rels = rels; // relationship lines live on the open sheet's root topic + return [node]; +} + +const esc = (s: string) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!); + +/** A CDATA block; "]]>" cannot sit inside one, so it is split the way WiseMapping writes it. */ +const cdata = (s: string) => "").join("]]]]>") + "]]>"; + +const hex = (c: string | undefined) => (c && /^#[0-9a-f]{6}$/i.test(c) ? c.toLowerCase() : undefined); + +// WiseMapping's own spacing: a first-level topic sits 200 px from the centre, a deeper one 90 px from its parent. +const SPREAD = [200, 90], ROW = 50; + +/** One Cartocortex topic -> one WiseMapping topic. `side` -1 grows left; `pos` is the topic's canvas position. */ +function topicToXml(n: Node, wise: Map, pos: { x: number; y: number }, order: number | undefined, side: 1 | -1, spread: number, central = false): string { + const ink = hex(n.textColor), fill = hex(n.color); + const attrs = + (central ? ' central="true"' : ` position="${Math.ceil(pos.x)},${Math.ceil(pos.y)}"`) + + ` id="${wise.get(n.id)}"` + + (order !== undefined ? ` order="${order}"` : "") + + (!n.text.includes("\n") && n.text ? ` text="${esc(n.text)}"` : "") + + (n.shape === "line" ? ' shape="line"' : n.shape === "rect" ? ' shape="rectangle"' : "") + + (n.collapsed && n.children.length ? ' shrink="true"' : "") + + (ink || n.bold || n.italic ? ` fontStyle=";;${ink ?? ""};${n.bold ? "bold" : ""};${n.italic ? "italic" : ""};"` : "") + + (fill ? ` bgColor="${fill}"` : ""); + const inner: string[] = []; + if (n.text.includes("\n") && n.text) inner.push(`${cdata(n.text)}`); + if (n.icon) inner.push(``); + if (n.link) inner.push(``); + if (n.note) inner.push(`${cdata(n.note)}`); + const attached = n.children.filter((c) => !c.float); // a sheet's floating topics are written at map level + const cut = central && n.layout === "map" ? Math.ceil(attached.length / 2) : central && n.layout === "left" ? 0 : attached.length; + const child = (c: Node, k: number) => { + const s: 1 | -1 = central ? (k < cut ? 1 : -1) : side; + const group = central ? (s > 0 ? k : k - cut) : k; + const len = central ? (s > 0 ? cut : attached.length - cut) : attached.length; + return topicToXml(c, wise, { x: pos.x + s * spread, y: pos.y + (group - (len - 1) / 2) * ROW }, k, s, SPREAD[1]); + }; + return `${inner.join("")}${attached.map(child).join("")}`; +} + +/** A .wxml map from Cartocortex sheets: the first sheet is the map, the sheets after it are WiseMapping's free branches. */ +export function toWisemapping(roots: Node[]): string { + const wise = new Map(); + let next = 0; + const claim = (n: Node) => (wise.set(n.id, String(++next)), n.children.forEach(claim)); + roots.forEach(claim); // every id first: a relationship may point at a topic that comes later + const parts: string[] = []; + const free = (n: Node, x: number, y: number, k: number) => parts.push(topicToXml(n, wise, { x, y }, k, 1, SPREAD[1])); + if (roots[0]) parts.push(topicToXml(roots[0], wise, { x: 0, y: 0 }, undefined, 1, SPREAD[0], true)); + let k = 0; + for (const r of roots) for (const c of r.children.filter((c) => c.float)) free(c, c.float!.x, c.float!.y, k++); + roots.slice(1).forEach((r, i) => free(r, 200, 250 * (i + 1), k++)); // the sheets after the first + // The relationships of the open sheet, their ends remapped to WiseMapping's ids. + const rels = (roots[0]?.rels ?? []).filter((r) => wise.has(r.from) && wise.has(r.to)) + .map((r) => ``); + return `\n\n${[...parts, ...rels].join("\n")}\n\n`; +} From db3f6c4d841d380a4e1d189fd2bd6e746ce3eaae Mon Sep 17 00:00:00 2001 From: Martin-PB Date: Sat, 17 Oct 2026 15:56:18 +0200 Subject: [PATCH 5/9] Open and write Mindomo .mom maps Mindomo Desktop keeps its maps as zips of JSON parts, and Cartocortex now reads and writes that. Reading walks nodes.json: titles keep their line breaks, folded topics stay folded, a topic's video or web address opens as its link, the map's relations open as relationship lines, and topics the file marks as deleted (and everything under them) are quietly skipped; the map named in document.json opens as the first sheet, and any other parentless topic in the file opens as a sheet of its own. A file that is not a zip is refused, with a note that the older Mindomo XML is not read. Writing goes the other way (Save as .mom and a new "Export Mindomo" button): one map per sheet, with fresh ids, the chain of brothers kept in order and the relationship lines written back. Notes, colours, markers, priorities, pictures, per-word style, boundaries and summaries are not carried, 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. Agent: Vibe (Mistral, glm-5-latest-short) Relu-par-humain: Martin-PB --- README.md | 6 +- src/App.tsx | 2 + src/files.test.ts | 6 ++ src/files.ts | 12 ++- src/mindomo.test.ts | 91 ++++++++++++++++++++ src/mindomo.ts | 196 ++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 307 insertions(+), 6 deletions(-) create mode 100644 src/mindomo.test.ts create mode 100644 src/mindomo.ts diff --git a/README.md b/README.md index c105a69..9238e94 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ 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`, MindManager `.mmap` or WiseMapping `.wxml` 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`. 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. @@ -107,7 +107,9 @@ MindManager: Open reads MindManager's `.mmap` maps — a zip holding `Document.x 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. -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. +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:#`: 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/src/App.tsx b/src/App.tsx index daf331d..87fe8b7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ 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"; @@ -602,6 +603,7 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP { 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") }, diff --git a/src/files.test.ts b/src/files.test.ts index d8987f5..17d28d0 100644 --- a/src/files.test.ts +++ b/src/files.test.ts @@ -51,6 +51,12 @@ test("documents are written and read in the format their extension names", async 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(); diff --git a/src/files.ts b/src/files.ts index bf46595..8192a1b 100644 --- a/src/files.ts +++ b/src/files.ts @@ -1,6 +1,7 @@ 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"; @@ -19,7 +20,7 @@ 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, WiseMapping, 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); @@ -27,11 +28,12 @@ export function docBytes(path: string, roots: Node[], cur: number): Uint8Array { 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; a FreeMind or MindManager map gives one sheet per root topic; a WiseMapping map gives one sheet. */ +/** 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 { const ext = extOf(name), text = () => new TextDecoder().decode(bytes); if (ext === "xmind") return { roots: await importXmindSheets(bytes), cur: 0 }; @@ -39,6 +41,7 @@ export async function parseDoc(name: string, bytes: Uint8Array): Promise { 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()); } @@ -76,11 +79,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", "mm", "mmap", "wxml"] }] }); + 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,.mm,.mmap,.wxml" }); + 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); @@ -116,6 +119,7 @@ export async function pickSavePath(defaultName: string): Promise { 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"] }, ], diff --git a/src/mindomo.test.ts b/src/mindomo.test.ts new file mode 100644 index 0000000..43f9c3f --- /dev/null +++ b/src/mindomo.test.ts @@ -0,0 +1,91 @@ +import { expect, test } from "vitest"; +import { mk, Node } from "./model"; +import { fromMindomoBytes, toMindomo } from "./mindomo"; +import { unzip, zip } from "./xmind"; + +/** A small .mom, in the shape of the files Mindomo Desktop 11 writes. */ +const mom = (nodes: unknown[], relations?: unknown[]) => + zip({ + "themes.json": "{}", + "document.json": JSON.stringify({ version: 40, mapInfo: { id: "d1", name: "Test map", rootId: "root" } }), + "nodes.json": JSON.stringify(nodes), + ...(relations ? { "relations.json": JSON.stringify(relations) } : {}), + "changes.json": "[]", + "Document.xml": '', + }); + +test("reads a Mindomo map", async () => { + const bytes = mom( + [ + { version: 42, id: "root", title: "Central", index: 0, style: { valign: 6, halign: 4, orientation: 100 }, creatorId: 0, type: 0 }, + // listed out of order: `index`, not the list, orders the siblings + { version: 42, id: "b", parentId: "root", nextId: "a", title: "Watch", multimediaUrl: "https://youtu.be/x", mediaType: 1, index: 1, style: { expanded: true, orientation: 0 }, type: 0 }, + { version: 42, id: "a", parentId: "root", previousId: "b", title: "Plan\non two lines", index: 0, style: { expanded: false, orientation: 0 }, type: 0 }, + { version: 42, id: "deep", parentId: "a", title: "Deep", index: 0, style: { expanded: true }, type: 0 }, + { version: 42, id: "gone", parentId: "root", deleted: true, title: "Deleted", index: 2, type: 0 }, + { version: 42, id: "gc", parentId: "gone", title: "Under the deleted", index: 0, type: 0 }, + { version: 42, id: "sheet2", title: "Second sheet", index: 0, style: { expanded: true }, type: 0 }, // parentless: its own sheet + ], + [{ version: 40, id: "r1", fromNodeId: "deep", toNodeId: "b", ctrlX1: "0", ctrlY1: "0", ctrlX2: "0", ctrlY2: "0", width: "1", type: "1" }], + ); + const roots = await fromMindomoBytes(bytes); + expect(roots.map((r) => r.text)).toEqual(["Central", "Second sheet"]); // the rootId tree first, then any other parentless topic + const [root] = roots; + expect(root.title).toBe("Test map"); // the map's name labels the sheet + expect(root.children.map((c) => c.text)).toEqual(["Plan\non two lines", "Watch"]); // by index, newlines kept + expect(root.children[0].collapsed).toBe(true); // style.expanded === false + expect(root.children[0].children[0].text).toBe("Deep"); + expect(root.children[1].link).toBe("https://youtu.be/x"); // a media address opens as a link + expect(root.rels).toHaveLength(1); + expect(root.rels![0].from).toBe(root.children[0].children[0].id); // remapped to our topic ids + expect(root.rels![0].to).toBe(root.children[1].id); +}); + +test("refuses what is not a Mindomo map", async () => { + await expect(fromMindomoBytes(new TextEncoder().encode(''))).rejects.toThrow(/older Mindomo XML/); + await expect(fromMindomoBytes(zip({ "document.json": "{}" }))).rejects.toThrow(/nodes.json/); // not this format's parts + const orphan = mom([{ id: "x", parentId: "ghost", title: "Orphan", index: 0 }]); + await expect(fromMindomoBytes(orphan)).rejects.toThrow(/parent/); + const empty = mom([]); + await expect(fromMindomoBytes(empty)).rejects.toThrow(/no topic/); +}); + +test("writes a .mom Mindomo can open again", async () => { + const root: Node = { + ...mk("Central\ntopic"), + link: "https://example.org", + 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 = toMindomo([root, mk("Second sheet")]); + expect([bytes[0], bytes[1]]).toEqual([0x50, 0x4b]); // a zip + const parts = await unzip(bytes); + expect([...parts.keys()].sort()).toEqual(["Document.xml", "changes.json", "document.json", "nodes.json", "relations.json", "themes.json"]); + const doc = JSON.parse(new TextDecoder().decode(parts.get("document.json")!)); + const nodes = JSON.parse(new TextDecoder().decode(parts.get("nodes.json")!)) as Record[]; + expect(nodes[0].id).toBe(doc.mapInfo.rootId); // the first sheet is the map + expect(doc.mapInfo.name).toBe("Central\ntopic"); + expect(nodes[0].parentId).toBeUndefined(); // the root has no parent + expect("expanded" in (nodes[0].style as object)).toBe(false); + expect(nodes[1].nextId).toBe(nodes[2].id); // siblings are chained + expect(nodes[2].previousId).toBe(nodes[1].id); + expect((nodes[2].style as { expanded?: boolean }).expanded).toBe(false); // folded stays folded + expect(nodes[4].id).toHaveLength(32); // ids like Mindomo's: 32 hex characters + const back = await fromMindomoBytes(bytes); + expect(back.map((r) => r.text)).toEqual(["Central\ntopic", "Second sheet"]); // one map per sheet, newlines kept + const [r] = back; + expect(r.link).toBe("https://example.org"); + 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("no relationships, no relations.json", async () => { + const bytes = toMindomo([mk("Only")]); + const parts = await unzip(bytes); + expect(parts.has("relations.json")).toBe(false); // the part is left out when the map has no relationship lines + const back = await fromMindomoBytes(bytes); + expect(back[0].rels).toBeUndefined(); + expect(back[0].text).toBe("Only"); +}); diff --git a/src/mindomo.ts b/src/mindomo.ts new file mode 100644 index 0000000..ac5e128 --- /dev/null +++ b/src/mindomo.ts @@ -0,0 +1,196 @@ +import { Node } from "./model"; +import { unzip, zip } from "./xmind"; + +// Reader and writer for Mindomo Desktop's .mom format: a zip of JSON parts. document.json holds the +// map's name and the id of its root topic, nodes.json every topic in one flat list (each child pointing +// at its parent), relations.json the map's relationship lines, and themes.json, changes.json and +// Document.xml parts Cartocortex does not need. The packed .pmom variant and the older XML +// "Mindomo XML Map" files are not covered. The format was taken apart from files saved by Mindomo +// Desktop 11, and has not been checked against Mindomo itself. + +/** One entry of Mindomo's nodes.json: a topic in the flat list. Children point at their parent, siblings carry an `index`, and a file saved after a delete keeps the topic marked `deleted: true`. */ +type MomNode = { + id: string; + parentId?: string; + title?: string; + deleted?: boolean; + index?: number; + style?: { expanded?: boolean }; + multimediaUrl?: string; // a video or web address Mindomo shows on the topic +}; + +/** Mindomo's document.json; only the parts the reader needs. */ +type MomDoc = { mapInfo?: { name?: string; rootId?: string } }; + +/** The topic's media link, when it is an ordinary web address. */ +const linkOf = (n: MomNode) => (/^https?:\/\//i.test(n.multimediaUrl ?? "") ? n.multimediaUrl : undefined); + +/** The tree hanging off one Mindomo topic: children by their `index`, ties by list order. */ +function treeOf(n: MomNode, kids: Map, ids: Map): Node { + const children = [...(kids.get(n.id) ?? [])] + .sort((a, b) => (a.index ?? 1e9) - (b.index ?? 1e9)) + .map((c) => treeOf(c, kids, ids)); + const link = linkOf(n); + const node: Node = { + id: crypto.randomUUID(), + text: n.title ?? "", + children, + ...(link ? { link } : {}), + ...(n.style?.expanded === false && children.length ? { collapsed: true } : {}), + }; + ids.set(n.id, node.id); // lets relationships find their new ends + return node; +} + +/** The root topics of a .mom map: the tree of document.json's rootId (or of the topic without a parent), then any other parentless topic as its own sheet. */ +export async function fromMindomoBytes(bytes: Uint8Array): Promise { + if (bytes[0] !== 0x50 || bytes[1] !== 0x4b) throw new Error("not a Mindomo .mom file (an older Mindomo XML map is not read)"); + const files = await unzip(bytes); + const nodesFile = [...files.entries()].find(([name]) => name.toLowerCase() === "nodes.json"); + if (!nodesFile) throw new Error("no nodes.json in this Mindomo file"); + const doc = files.has("document.json") ? (JSON.parse(new TextDecoder().decode(files.get("document.json")!)) as MomDoc) : {}; + const all = JSON.parse(new TextDecoder().decode(nodesFile[1])) as MomNode[]; + const byId = new Map(); + for (const n of all) if (typeof n.id === "string" && !byId.has(n.id)) byId.set(n.id, n); + // A file saved after a delete keeps the topic, marked `deleted`; anything under it is gone with it. + const dead = new Set(); + const gone = (n: MomNode, seen: Set): boolean => { + if (dead.has(n.id)) return true; + if (n.deleted) return (dead.add(n.id), true); + if (n.parentId === undefined) return false; + const p = byId.get(n.parentId); + if (!p) throw new Error(`a topic points at parent "${n.parentId}", which is not in this file`); + if (seen.has(n.id)) return true; // parents that loop never reach the root: gone with the loop + seen.add(n.id); + return gone(p, seen) ? (dead.add(n.id), true) : false; + }; + const nodes = all.filter((n) => typeof n.id === "string" && byId.get(n.id) === n && !gone(n, new Set())); + const kids = new Map(); + for (const n of nodes) { + if (n.parentId === undefined) continue; + const list = kids.get(n.parentId); + if (list) list.push(n); + else kids.set(n.parentId, [n]); + } + const ids = new Map(); + const rootId = doc.mapInfo?.rootId; + const parentless = nodes.filter((n) => n.parentId === undefined); + const roots: Node[] = []; + if (rootId !== undefined && byId.has(rootId) && !dead.has(rootId)) roots.push(treeOf(byId.get(rootId)!, kids, ids)); + roots.push(...parentless.filter((n) => n.id !== rootId).map((n) => treeOf(n, kids, ids))); + if (!roots.length) throw new Error("no topic found in this mind map"); + if (ids.size !== nodes.length) throw new Error(`${nodes.length - ids.size} topic(s) in this file are not part of the map's tree`); + const name = doc.mapInfo?.name; + if (name) roots[0].title = name; // the map's name labels the sheet + const relsFile = [...files.entries()].find(([entry]) => entry.toLowerCase() === "relations.json"); + if (relsFile) { + const rels = (JSON.parse(new TextDecoder().decode(relsFile[1])) as { fromNodeId?: string; toNodeId?: string }[]).flatMap((r) => { + const from = ids.get(r.fromNodeId ?? ""), to = ids.get(r.toNodeId ?? ""); + return from && to ? [{ id: crypto.randomUUID(), from, to }] : []; // an end that is gone is dropped + }); + if (rels.length) roots[0].rels = rels; // relationship lines live on the open sheet's root topic + } + return roots; +} + +/** A fresh Mindomo id: 32 hex characters, the shape of the ids in Mindomo's own files. */ +const momId = () => [...crypto.getRandomValues(new Uint8Array(16))].map((x) => x.toString(16).padStart(2, "0")).join(""); + +/** Mindomo writes a topic's creation date in the local "YYYY-MM-DD hh:mm:ss" and its modification date in ISO. */ +const clock = () => { + const d = new Date(); + const p = (n: number) => String(n).padStart(2, "0"); + return { + iso: d.toISOString(), + local: `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`, + }; +}; + +/** One Cartocortex topic -> one Mindomo node, chained to its siblings the way Mindomo keeps them in order. */ +function nodeOf( + n: Node, + mm: Map, + parent: string | undefined, + index: number, + next: string | undefined, + prev: string | undefined, + when: { iso: string; local: string }, + central: boolean, + out: Record[], +) { + const id = mm.get(n.id)!; + out.push({ + version: 42, + id, + ...(parent === undefined ? {} : { parentId: parent }), + ...(next ? { nextId: next } : {}), + ...(prev ? { previousId: prev } : {}), + originalId: id, + title: n.text, + titleStyle: "", + creationDate: when.local, + modificationDate: when.iso, + assignedTo: [], + assignedToVirtual: [], + index, + style: central ? { halign: 4, valign: 6, orientation: 100 } : { halign: 4, valign: 6, expanded: !n.collapsed, orientation: 0 }, + comments: { votes: {} }, + creatorId: 0, + isTemplate: 0, + type: 0, + ...(n.link ? { multimediaUrl: n.link, mediaType: 1 } : {}), + }); + n.children.forEach((c, i, all) => + nodeOf(c, mm, id, i, all[i + 1] ? mm.get(all[i + 1].id) : undefined, i ? mm.get(all[i - 1].id) : undefined, when, false, out), + ); +} + +/** A .mom file from Cartocortex sheets: one Mindomo map per sheet, so extra sheets may not show in Mindomo itself. Writing is deliberately lossy: titles, folding and links are what is carried. */ +export function toMindomo(roots: Node[]): Uint8Array { + const mm = new Map(); + const claim = (n: Node) => (mm.set(n.id, momId()), n.children.forEach(claim)); + roots.forEach(claim); // every id first: a relationship may point at a topic that comes later + const when = clock(); + const nodes: Record[] = []; + roots.forEach((r, i) => nodeOf(r, mm, undefined, 0, undefined, undefined, when, i === 0, nodes)); + const rels = ((roots[0]?.rels ?? []) as NonNullable) + .filter((r) => mm.has(r.from) && mm.has(r.to)) + .map((r) => ({ + version: 40, + id: momId(), + fromNodeId: mm.get(r.from), + toNodeId: mm.get(r.to), + ctrlX1: "0", + ctrlY1: "0", + ctrlX2: "0", + ctrlY2: "0", + width: "1", + type: "1", + })); + const document = { + version: 40, + mapInfo: { + id: momId(), + name: roots[0]?.title || roots[0]?.text || "Map", + description: "", + type: 0, + creationDate: when.iso, + modificationDate: when.iso, + rootId: roots[0] ? mm.get(roots[0].id) : momId(), + language: "en", + }, + desktopVersion: "11.1.6", + commandsFromOrder: 0, + commandsFromOrderUndo: 0, + mapUsers: {}, + mapUsersVirtual: {}, + }; + return zip({ + "themes.json": "{}", + "document.json": JSON.stringify(document), + "nodes.json": JSON.stringify(nodes), + ...(rels.length ? { "relations.json": JSON.stringify(rels) } : {}), + "changes.json": "[]", + "Document.xml": '\n', + }); +} From 65c8ee6d51f08dfc213685e8f0f099e3d063a74a Mon Sep 17 00:00:00 2001 From: Martin-PB Date: Sat, 17 Oct 2026 17:03:30 +0200 Subject: [PATCH 6/9] Open and write iThoughts .itmz maps iThoughts keeps a map as a zip holding one mapdata.xml, and Cartocortex now reads and writes that. Reading walks the nested elements (wrapped in a element the way iThoughts writes them, or straight inside the way MultiMarkdown writes them): titles keep their line breaks, notes travel along, and a topic at the top level besides the map's own opens as its own sheet. A file that is not a zip, or a zip without mapdata.xml, is refused with a clear error. Writing goes the other way (Save as .itmz and a new "Export iThoughts" button): one map per sheet, each topic with a fresh uuid and its note, and a leaf closing itself the way iThoughts writes one. Colours, icons, images, hyperlinks, task info, folding, floating topics and sheet names are not carried, and the old iThoughtsHD .itm files are not read. The format was taken from what iThoughts writes (as documented by toketaWare and the iThoughts community) and from MultiMarkdown's own exporter, and has not been checked against iThoughts itself. Agent: Vibe (Mistral, glm-5-latest-short) Relu-par-humain: Martin-PB --- README.md | 6 +++-- src/App.tsx | 2 ++ src/files.test.ts | 6 +++++ src/files.ts | 12 ++++++---- src/ithoughts.test.ts | 54 +++++++++++++++++++++++++++++++++++++++++ src/ithoughts.ts | 56 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 130 insertions(+), 6 deletions(-) create mode 100644 src/ithoughts.test.ts create mode 100644 src/ithoughts.ts diff --git a/README.md b/README.md index 9238e94..8e226e9 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ 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`, MindManager `.mmap`, Mindomo `.mom` or WiseMapping `.wxml` 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`, iThoughts `.itmz`, MindManager `.mmap`, Mindomo `.mom` 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. @@ -109,7 +109,9 @@ WiseMapping: Open reads WiseMapping's `.wxml` maps — the current "tango" forma 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. +iThoughts: Open reads iThoughts's `.itmz` maps — a zip holding one mapdata.xml (the old iThoughtsHD `.itm` files are not read) — with topics in file order, titles that span lines and notes; a topic at the top level besides the map's own opens as its own sheet. "Export iThoughts" (and Save as `.itmz`) writes one map per sheet, so extra sheets may not show in iThoughts itself. Colours, icons, images, hyperlinks, task info, folding, floating topics and sheet names are not carried. The format follows what iThoughts itself writes and what MultiMarkdown's exporter writes, and has not been checked against iThoughts itself. + +Safety rule for opened files: only Cartocortex's own `.json` keeps its place on disk. Opening a `.xmind`, `.opml`, FreeMind, iThoughts, 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:#`: 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/src/App.tsx b/src/App.tsx index 87fe8b7..9fdc3b5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import { import { exportXmind } from "./xmind"; import { toOpml } from "./outline-io"; import { toFreemind } from "./freemind"; +import { toIthoughts } from "./ithoughts"; import { toMindmanager } from "./mindmanager"; import { toMindomo } from "./mindomo"; import { toWisemapping } from "./wisemapping"; @@ -604,6 +605,7 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP { 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: "iThoughts (.itmz)", on: () => saveFile(fileName("itmz"), "itmz", toIthoughts(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") }, diff --git a/src/files.test.ts b/src/files.test.ts index 17d28d0..1e92aed 100644 --- a/src/files.test.ts +++ b/src/files.test.ts @@ -57,6 +57,12 @@ test("documents are written and read in the format their extension names", async 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 itmz = docBytes("/tmp/plan.itmz", roots, 1); + expect(new TextDecoder().decode(itmz.slice(0, 2))).toBe("PK"); // a zip + const backItmz = await parseDoc("plan.itmz", itmz); + expect(backItmz.roots.map((r) => r.text)).toEqual(["Plan", "Second"]); // one map per file; the sheets after it re-open as sheets + expect(backItmz.roots[0].children[0].text).toBe("child"); }); const store = new Map(); diff --git a/src/files.ts b/src/files.ts index 8192a1b..ca22ec3 100644 --- a/src/files.ts +++ b/src/files.ts @@ -1,5 +1,6 @@ import { Book, bookJson, parseBook } from "./book"; import { fromFreemindBytes, toFreemind } from "./freemind"; +import { fromIthoughtsBytes, toIthoughts } from "./ithoughts"; import { fromMindmanagerBytes, toMindmanager } from "./mindmanager"; import { fromMindomoBytes, toMindomo } from "./mindomo"; import { Node, toMarkdown } from "./model"; @@ -20,7 +21,7 @@ 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, Mindomo, WiseMapping, otherwise Cartocortex JSON. */ +/** What to write for a document saved at `path`, by extension: XMind, OPML, Markdown, FreeMind, MindManager, Mindomo, iThoughts, 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); @@ -29,11 +30,12 @@ export function docBytes(path: string, roots: Node[], cur: number): Uint8Array { if (ext === "mm") return text(toFreemind(roots)); if (ext === "mmap") return toMindmanager(roots); if (ext === "mom") return toMindomo(roots); + if (ext === "itmz") return toIthoughts(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, MindManager or Mindomo map gives one sheet per root topic; a WiseMapping map gives one sheet. */ +/** 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, Mindomo or iThoughts map gives one sheet per root topic; a WiseMapping map gives one sheet. */ export async function parseDoc(name: string, bytes: Uint8Array): Promise { const ext = extOf(name), text = () => new TextDecoder().decode(bytes); if (ext === "xmind") return { roots: await importXmindSheets(bytes), cur: 0 }; @@ -42,6 +44,7 @@ export async function parseDoc(name: string, bytes: Uint8Array): Promise { 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 === "itmz") return { roots: await fromIthoughtsBytes(bytes), cur: 0 }; if (ext === "wxml") return { roots: fromWisemapping(text()), cur: 0 }; return parseBook(text()); } @@ -79,11 +82,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", "mm", "mmap", "mom", "wxml"] }] }); + const path = await open({ filters: [{ name: "Mind map or outline", extensions: ["json", "xmind", "opml", "md", "markdown", "mm", "mmap", "mom", "itmz", "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,.mom,.wxml" }); + const i = Object.assign(document.createElement("input"), { type: "file", accept: ".json,.xmind,.opml,.md,.markdown,.mm,.mmap,.mom,.itmz,.wxml" }); i.onchange = async () => { const f = i.files?.[0]; res(f ? { name: f.name, path: null, bytes: new Uint8Array(await f.arrayBuffer()) } : null); @@ -120,6 +123,7 @@ export async function pickSavePath(defaultName: string): Promise { name: "FreeMind / Freeplane (.mm)", extensions: ["mm"] }, { name: "MindManager (.mmap)", extensions: ["mmap"] }, { name: "Mindomo (.mom)", extensions: ["mom"] }, + { name: "iThoughts (.itmz)", extensions: ["itmz"] }, { name: "WiseMapping (.wxml)", extensions: ["wxml"] }, { name: "Markdown outline (.md)", extensions: ["md"] }, ], diff --git a/src/ithoughts.test.ts b/src/ithoughts.test.ts new file mode 100644 index 0000000..8e9ecd7 --- /dev/null +++ b/src/ithoughts.test.ts @@ -0,0 +1,54 @@ +import { expect, test } from "vitest"; +import { mk, Node } from "./model"; +import { fromIthoughtsBytes, toIthoughts } from "./ithoughts"; +import { unzip, zip } from "./xmind"; + +/** A small .itmz, in the shape of the files iThoughts itself writes. */ +const itmz = (xml: string) => zip({ "mapdata.xml": xml }); + +test("reads an iThoughts map", async () => { + const bytes = itmz( + '\n\n\n' + + '\n' + + '\n\n\n' + + '\n' + + '\n\n\n\n', + ); + const roots = await fromIthoughtsBytes(bytes); + expect(roots.map((r) => r.text)).toEqual(["Central", "Second sheet"]); // the map's topic first, then any other at the top level + expect(roots[0].note).toBe("A note"); + expect(roots[0].children.map((c) => c.text)).toEqual(["Plan\non two lines", "Watch"]); // file order, numeric escapes decoded + expect(roots[0].children[0].children[0].text).toBe("Deep"); // nesting is the hierarchy +}); + +test("reads the wrapper-free shape as well", async () => { + const bytes = itmz(''); + const roots = await fromIthoughtsBytes(bytes); + expect(roots.map((r) => r.text)).toEqual(["Only"]); // MultiMarkdown writes no wrapper + expect(roots[0].children[0].text).toBe("Child"); +}); + +test("refuses what is not an iThoughts map", async () => { + await expect(fromIthoughtsBytes(new TextEncoder().encode(''))).rejects.toThrow(/not an iThoughts .itmz/); + await expect(fromIthoughtsBytes(zip({ "style.xml": "