Export to Markdown or text, and import text as nodes (stage 9c)

:export md writes the document as Markdown headings in the clipboard's
format (the user's pick): # for the root and each loose node, ## for
their children, down to ######, the tags between --- lines, then the
body. :export txt writes an indented outline, tags after each title and
the body under it after "| " (the user's pick), from the new
storage::plain_text. The file goes next to the document, named after it,
or where it's told; as vim's :w to another file does, an existing file
needs :export! (the user's pick). Typed in visual mode, :export writes
just the selection, named after its first branch (the user's pick).

:import file reads Markdown headings, a list or indented lines with the
clipboard's reader, now also taking #tags at the end of a line and "| "
body lines, so a text export reads back whole. The nodes go under the
cursor's node, laid out as a mind map, as one undo step (the user's
pick).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
skkeyeandClaude Opus 5.5 committed 2026-10-02 11:46:04 -04:00
1 parent 5888d143fd
commit 9d15a054f2
14 files changed
+407 -33

No files matched your search

+13 -1
View File
@@ -12,7 +12,7 @@ use super::panes::Arrange;
use super::popup::{MenuFor, PickKind};
use super::repeat::Look;
use super::text_edit::StartEdit;
use crate::command::parse::SetArg;
use crate::command::parse::{ExportFormat, SetArg};
use crate::config::ConfigFile;
use crate::input::command::{Command, Register};
use crate::input::key::Key;
@@ -142,6 +142,18 @@ pub enum AppAction {
/// document of its own (at the path typed, or next to this one), with a
/// portal to it in its place.
BranchOut(Option<String>),
/// `:export`: the document (or these selected nodes) as Markdown or
/// text, to the file named (or one next to the document); `force`
/// (`:export!`) writes over a file that's there.
Export {
format: ExportFormat,
path: Option<String>,
force: bool,
nodes: Vec<NodeId>,
},
/// `:import`: the nodes in a Markdown or text file, under the cursor's
/// node.
Import(String),
/// `gu`: up to the portals that lead to this document; `list`
/// (`:backlinks`) lists them even when there's just one.
GoUp {
+4 -1
View File
@@ -84,7 +84,10 @@ pub fn actions_for(call: Call, ctx: &ViewContext<'_>, on: &[NodeId]) -> Vec<Acti
Call::Tidy(layout) => commands::tidy(ctx, &visual::tops(&nodes, ctx), layout),
Call::Align(side) => commands::align(ctx, on, side),
Call::BranchOut(path) => app(AppAction::BranchOut(path)),
Call::Export { .. } => later("this one comes in v1.1"),
Call::Export { format, path, force } => {
app(AppAction::Export { format, path, force, nodes: on.to_vec() })
}
Call::Import(path) => app(AppAction::Import(path)),
}
}
+120
View File
@@ -0,0 +1,120 @@
//! Export and import (part 9c). `:export md` and `:export txt` write the
//! document (or, typed in visual mode, the selection) as Markdown headings
//! or an indented outline (the user's picks), next to the document unless a
//! file is named. As vim's `:w` does with another file, a file that's there
//! already stays unless it's `:export!` (the user's pick). `:import` reads
//! Markdown, a list or indented lines from a file, and the nodes go under
//! the cursor's node, as `p` pastes them.
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use super::App;
use super::action::{self, CursorAfter, DocAction, Message, PasteAt};
use super::commands;
use crate::command::parse::ExportFormat;
use crate::model::{Clip, NodeId};
use crate::storage::names::{self, file_name};
use crate::storage::{markdown, plain_text, write_atomically};
impl App {
/// `:export`: `on` is the selection it was typed on (none: the whole
/// document), `typed` the file named.
pub(super) fn export(
&mut self,
format: ExportFormat,
typed: Option<String>,
force: bool,
on: &[NodeId],
) -> Option<Message> {
let (word, extension) = match format {
ExportFormat::Markdown => ("md", "md"),
ExportFormat::Text => ("txt", "txt"),
};
let open = self.shared.docs.get(self.buffer());
let doc = open.buffer.doc();
let ids: BTreeSet<NodeId> =
if on.is_empty() { doc.outline().into_iter().collect() } else { on.iter().copied().collect() };
let clip = Clip::take(doc, &ids);
let path = match &typed {
Some(typed) => {
let path = self.typed_path(typed);
if path.extension().is_none() { path.with_extension(extension) } else { path }
}
// Named after the document, or after the first branch selected.
None => {
let stem = match clip.roots.first() {
Some(top) if !on.is_empty() => file_name(top.node.title().as_str()),
_ => open.file_name(),
};
let stem = stem.strip_suffix(&format!(".{}", names::EXTENSION)).unwrap_or(&stem).to_string();
self.folder().join(format!("{stem}.{extension}"))
}
};
let name = file_label(&path);
if path.extension().is_some_and(|found| found == names::EXTENSION) {
return Some(Message::problem("an export is Markdown or text: name a .md or .txt file"));
}
let there = path.exists();
if there && !force {
let again =
typed.map_or_else(|| format!(":export! {word}"), |typed| format!(":export! {word} {typed}"));
return Some(Message::problem(format!("{name} is there already: {again} writes over it")));
}
let text = match format {
ExportFormat::Markdown => markdown::export(&clip),
ExportFormat::Text => plain_text::write(&clip),
};
if let Err(error) = write_atomically(&path, &text) {
return Some(Message::problem(format!("couldn't write {name}: {error}")));
}
let over = if there { " (over the one there)" } else { "" };
Some(Message::done(format!("wrote {} to {name}{over}", action::nodes(clip.count()))))
}
/// `:import`: the nodes in a Markdown or text file, under the cursor's
/// node as its last children, laid out as a mind map: one undo step.
pub(super) fn import(&mut self, typed: &str) -> Option<Message> {
if let Some(refused) = self.refuse_read_only() {
return Some(refused);
}
let path = self.typed_path(typed);
let name = file_label(&path);
if path.extension().is_some_and(|found| found == names::EXTENSION) {
return Some(Message::info(format!(
"{name} is a cumulus document: :e opens it, :portal leads to it"
)));
}
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Some(Message::problem(format!("there's no {name} there")));
}
Err(error) => return Some(Message::problem(format!("couldn't read {name}: {error}"))),
};
let Some(mut clip) = markdown::read(&text) else {
return Some(Message::info(format!("nothing to import in {name}: it has no text")));
};
let ctx = self.shared.ctx(self.buffer());
clip.lay_out(|node| ctx.box_size(node));
let count = clip.count();
let op = commands::paste(&ctx, clip, PasteAt::Child, 1)?;
let edit = DocAction::Edit { op: Box::new(op), cursor: CursorAfter::Added, group: None };
if let Some(problem) = self.doc_action(edit) {
return Some(problem);
}
Some(Message::done(format!("imported {} from {name} · u undoes it", action::nodes(count))))
}
/// A file named on the command line: `~` is the home folder, and a
/// relative path starts at the document's folder.
fn typed_path(&self, typed: &str) -> PathBuf {
let path = self.paths.expand(typed.trim());
if path.is_relative() { self.folder().join(path) } else { path }
}
}
/// A file's name, for messages.
fn file_label(path: &Path) -> String {
path.file_name().map_or_else(|| path.display().to_string(), |name| name.to_string_lossy().into_owned())
}
+1
View File
@@ -27,6 +27,7 @@ pub mod commands;
mod configure;
pub mod connect;
pub mod documents;
mod export;
mod files;
mod input;
pub mod jumps;
+3 -2
View File
@@ -7,6 +7,7 @@ mod arrange;
mod branch_out;
mod config;
mod connect;
mod export;
mod files;
mod portals;
mod vim;
@@ -507,8 +508,8 @@ fn indenting_and_outdenting_nodes() {
#[test]
fn v1_1_commands_say_so_and_portal_titles_stay() {
let mut h = Harness::new();
h.type_keys(":export md<CR>");
assert_eq!(h.message(), "this one comes in v1.1");
h.type_keys(":board<CR>");
assert_eq!(h.message(), "the board comes in v1.1");
// Kitchen is a portal: its title is the other document's name.
h.type_keys("glgjgjgj");
assert_eq!(h.cursor(), "Kitchen");
+61
View File
@@ -0,0 +1,61 @@
//! Export and import (part 9c), end to end: the document, or a selection,
//! as Markdown or text, and text read back as nodes.
use super::*;
#[test]
fn export_writes_the_document_as_markdown_or_text() {
let mut h = Harness::new();
h.type_keys(":export md<CR>");
assert_eq!(h.message(), "wrote 14 nodes to garden.md");
let md = h.file("garden.md");
assert!(
md.starts_with("# Garden plans\n\n## Soil\n---\n- todo\n---\n\n## Raised beds\n\n### Cedar"),
"{md}"
);
assert!(md.contains("\n#### Timer on the tap\n") && md.contains("\n# Seeds to order\n"), "{md}");
// As vim's :w does with another file: there already, so :export! it is.
h.type_keys(":export md<CR>");
assert_eq!(h.message(), "garden.md is there already: :export! md writes over it");
h.type_keys(":export! md<CR>");
assert_eq!(h.message(), "wrote 14 nodes to garden.md (over the one there)");
// Plain text: an outline, with the bodies after a |.
h.type_keys(":export txt<CR>");
let txt = h.file("garden.txt");
assert!(txt.starts_with("Garden plans\n Soil #todo\n Raised beds\n Cedar, untreated\n"), "{txt}");
assert!(txt.ends_with("ask about compost\n | The neighbor has a bin.\n | Bring a bucket.\n"), "{txt}");
}
#[test]
fn a_selection_exports_on_its_own() {
let mut h = Harness::new();
h.type_keys("glgjV:export md<CR>");
assert_eq!(h.message(), "wrote 3 nodes to raised-beds.md");
assert_eq!(h.file("raised-beds.md"), "# Raised beds\n\n## Cedar, untreated\n\n## 40 cm high\n");
h.type_keys("ggglvjj:export txt notes<CR>");
assert_eq!(h.file("notes.txt"), "Soil #todo\nRaised beds\nWatering\n", "just those three");
}
#[test]
fn import_reads_text_under_the_cursor() {
let mut h = Harness::new();
std::fs::write(h.path("plants.md"), "- Plants\n - Roses #red\n - Ferns\n shady corner\n").unwrap();
h.type_keys(":import plants.md<CR>");
assert_eq!(h.message(), "imported 3 nodes from plants.md · u undoes it");
assert_eq!(h.parent("Plants"), "Garden plans");
assert_eq!(h.children("Plants"), ["Roses", "Ferns"]);
assert_eq!((h.tags("Roses").as_str(), h.body("Ferns").as_str()), ("#red", "shady corner"));
h.type_keys("u");
assert!(!h.screen().contains("Plants"));
// What :export txt writes comes back the same.
h.type_keys(":export txt<CR>:new Copy<CR>:import garden.txt<CR>");
assert_eq!(h.message(), "imported 14 nodes from garden.txt · u undoes it");
assert_eq!(h.children("Copy"), ["Garden plans", "Seeds to order", "ask about compost"]);
assert_eq!(h.children("Raised beds"), ["Cedar, untreated", "40 cm high"]);
assert_eq!(h.body("ask about compost"), "The neighbor has a bin.\nBring a bucket.");
// A document isn't text to import, and a missing file says so.
h.type_keys(":import garden.cum<CR>");
assert_eq!(h.message(), "garden.cum is a cumulus document: :e opens it, :portal leads to it");
h.type_keys(":import nothing.md<CR>");
assert_eq!(h.message(), "there's no nothing.md there");
}
+4
View File
@@ -126,6 +126,10 @@ impl App {
AppAction::NewPortal(path) => return self.new_portal(&path),
AppAction::GoUp { list } => return self.go_up(list),
AppAction::BranchOut(path) => return self.branch_out(path),
AppAction::Export { format, path, force, nodes } => {
return self.export(format, path, force, &nodes);
}
AppAction::Import(path) => return self.import(&path),
AppAction::ToggleWhole => {
if let Mode::Move(grab) = &mut self.shared.mode {
grab.whole = !grab.whole;