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

+26 -3
View File
@@ -296,6 +296,27 @@ lines.
The root can't branch out (it's the whole document), and neither can a The root can't branch out (it's the whole document), and neither can a
portal (its document is one of its own already). portal (its document is one of its own already).
**Export and import** (settled in part 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, and so on down to `######`; the tags as a list between
two `---` lines, then the body. `:export txt` writes an outline: a title
per line, two spaces a level, its tags after it, and its body under it a
level further in, each line after a `| ` (the user's pick).
- The file goes next to the document and is named after it (`garden.md`),
or where `:export md file` says (`.md` or `.txt` is added). As vim's `:w`
to another file does, it leaves a file that's there alone unless it's
`:export!` (the user's pick).
- Typed in visual mode, `:export` writes just the selection (the user's
pick), named after its first branch (`raised-beds.md`): with `v` exactly
the nodes chosen, with `V` their branches.
- `:import file` reads Markdown headings, a bulleted list or indented lines
(what both exports write, and what the clipboard reads), and the nodes go
under the cursor's node as its last children, laid out as a mind map, as
one undo step (the user's pick). `#tags` at the end of a line are its
tags, and lines after a `| ` are the body of the node above. Looks and
cross-links stay out of both exports.
**Views, panes and popups** (settled in stage 7) **Views, panes and popups** (settled in stage 7)
- The Note view is made of parts: the title, the tags, the body, each link - The Note view is made of parts: the title, the tags, the body, each link
(out, then in) and each child. `j` and `k` step through them, with a bar (out, then in) and each child. `j` and `k` step through them, with a bar
@@ -718,7 +739,8 @@ cumulus/
│ (keys.toml's template), theme_file (themes laid over the built-in one) │ (keys.toml's template), theme_file (themes laid over the built-in one)
├─ storage/ document_file (RON), write_atomically, names (file names from titles), link_paths ├─ storage/ document_file (RON), write_atomically, names (file names from titles), link_paths
│ (portal paths: relative, with /), backup, lock, stamp (noticing changes made │ (portal paths: relative, with /), backup, lock, stamp (noticing changes made
│ outside), memory (memory.ron), markdown (nodes as Markdown, for the clipboard) │ outside), memory (memory.ron), markdown (nodes as Markdown, for the clipboard
│ and :export; text read back as nodes), plain_text (:export txt)
└─ app/ mod.rs (App), input (keys and the mouse, routed), update (carrying actions out), └─ app/ mod.rs (App), input (keys and the mouse, routed), update (carrying actions out),
render, terminal (the main loop, setup, teardown, panic hook), startup (what render, terminal (the main loop, setup, teardown, panic hook), startup (what
`cumulus` opens), documents (the open documents and their files), files `cumulus` opens), documents (the open documents and their files), files
@@ -730,12 +752,13 @@ cumulus/
search (`/`), jumps (each pane's jump list), marks (capital marks, `:marks`), search (`/`), jumps (each pane's jump list), marks (capital marks, `:marks`),
connect (connect mode), portals (what portals show, going through them, connect (connect mode), portals (what portals show, going through them,
`:portal`, backlinks, renames), branch_out (`:branchout`), panes (the split `:portal`, backlinks, renames), branch_out (`:branchout`), panes (the split
tree), popup (help, menu, pickers, card, questions) tree), export (:export, :import), popup (help, menu, pickers, card, questions)
``` ```
The end-to-end tests are in `app/tests.rs` (with `app/tests/files.rs`, The end-to-end tests are in `app/tests.rs` (with `app/tests/files.rs`,
`app/tests/config.rs`, `app/tests/vim.rs`, `app/tests/connect.rs`, `app/tests/config.rs`, `app/tests/vim.rs`, `app/tests/connect.rs`,
`app/tests/portals.rs`, `app/tests/arrange.rs` and `app/tests/branch_out.rs`): `app/tests/portals.rs`, `app/tests/arrange.rs`, `app/tests/branch_out.rs` and
`app/tests/export.rs`):
they copy the sample they copy the sample
documents to a temporary folder, type keys and click on a test terminal, and documents to a temporary folder, type keys and click on a test terminal, and
check the screen, the documents and the files. check the screen, the documents and the files.
+2
View File
@@ -103,6 +103,8 @@ The plan for the everyday keys (all of them are in DESIGN.md):
| `gt` `gT` | the next / previous document | | `gt` `gT` | the next / previous document |
| `:` `?` | command line, help (search it with `/`, Enter runs the key or command) | | `:` `?` | command line, help (search it with `/`, Enter runs the key or command) |
| `:e` `:new` `:bd` | open a document / make one / close it | | `:e` `:new` `:bd` | open a document / make one / close it |
| `:export md` `:export txt` | the document (or, from visual mode, the selection) as Markdown or an outline (`:export!` writes over a file) |
| `:import file` | the nodes in a Markdown or text file, under this node |
| `:marks` `:jumps` `:registers` | what they hold: Enter goes there (or pastes) | | `:marks` `:jumps` `:registers` | what they hold: Enter goes there (or pastes) |
| `:w` `:q` `ZZ` | save / close the pane (the last one quits) / save and close | | `:w` `:q` `ZZ` | save / close the pane (the last one quits) / save and close |
+13 -1
View File
@@ -12,7 +12,7 @@ use super::panes::Arrange;
use super::popup::{MenuFor, PickKind}; use super::popup::{MenuFor, PickKind};
use super::repeat::Look; use super::repeat::Look;
use super::text_edit::StartEdit; use super::text_edit::StartEdit;
use crate::command::parse::SetArg; use crate::command::parse::{ExportFormat, SetArg};
use crate::config::ConfigFile; use crate::config::ConfigFile;
use crate::input::command::{Command, Register}; use crate::input::command::{Command, Register};
use crate::input::key::Key; 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 /// document of its own (at the path typed, or next to this one), with a
/// portal to it in its place. /// portal to it in its place.
BranchOut(Option<String>), 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` /// `gu`: up to the portals that lead to this document; `list`
/// (`:backlinks`) lists them even when there's just one. /// (`:backlinks`) lists them even when there's just one.
GoUp { 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::Tidy(layout) => commands::tidy(ctx, &visual::tops(&nodes, ctx), layout),
Call::Align(side) => commands::align(ctx, on, side), Call::Align(side) => commands::align(ctx, on, side),
Call::BranchOut(path) => app(AppAction::BranchOut(path)), 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; mod configure;
pub mod connect; pub mod connect;
pub mod documents; pub mod documents;
mod export;
mod files; mod files;
mod input; mod input;
pub mod jumps; pub mod jumps;
+3 -2
View File
@@ -7,6 +7,7 @@ mod arrange;
mod branch_out; mod branch_out;
mod config; mod config;
mod connect; mod connect;
mod export;
mod files; mod files;
mod portals; mod portals;
mod vim; mod vim;
@@ -507,8 +508,8 @@ fn indenting_and_outdenting_nodes() {
#[test] #[test]
fn v1_1_commands_say_so_and_portal_titles_stay() { fn v1_1_commands_say_so_and_portal_titles_stay() {
let mut h = Harness::new(); let mut h = Harness::new();
h.type_keys(":export md<CR>"); h.type_keys(":board<CR>");
assert_eq!(h.message(), "this one comes in v1.1"); assert_eq!(h.message(), "the board comes in v1.1");
// Kitchen is a portal: its title is the other document's name. // Kitchen is a portal: its title is the other document's name.
h.type_keys("glgjgjgj"); h.type_keys("glgjgjgj");
assert_eq!(h.cursor(), "Kitchen"); 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::NewPortal(path) => return self.new_portal(&path),
AppAction::GoUp { list } => return self.go_up(list), AppAction::GoUp { list } => return self.go_up(list),
AppAction::BranchOut(path) => return self.branch_out(path), 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 => { AppAction::ToggleWhole => {
if let Mode::Move(grab) = &mut self.shared.mode { if let Mode::Move(grab) = &mut self.shared.mode {
grab.whole = !grab.whole; grab.whole = !grab.whole;
+9 -2
View File
@@ -109,7 +109,10 @@ pub enum Call {
Export { Export {
format: ExportFormat, format: ExportFormat,
path: Option<String>, path: Option<String>,
/// `:export!`: write over a file that's there.
force: bool,
}, },
Import(String),
} }
/// What the command line can name, from the app: names are checked against /// What the command line can name, from the app: names are checked against
@@ -265,7 +268,10 @@ fn build(spec: &Spec, bang: bool, args: &[Option<&str>], sources: &Sources) -> R
Id::Help => Call::Help(first.map(str::to_string)), Id::Help => Call::Help(first.map(str::to_string)),
Id::Version => Call::Version, Id::Version => Call::Version,
Id::Theme => Call::Theme(exactly("theme", text, &sources.themes)?.to_string()), Id::Theme => Call::Theme(exactly("theme", text, &sources.themes)?.to_string()),
Id::Export => Call::Export { format: export_format(text)?, path: second.map(str::to_string) }, Id::Export => {
Call::Export { format: export_format(text)?, path: second.map(str::to_string), force: bang }
}
Id::Import => Call::Import(text.to_string()),
}) })
} }
@@ -471,7 +477,7 @@ mod tests {
assert_eq!(call("color none"), Ok(Call::Color(None))); assert_eq!(call("color none"), Ok(Call::Color(None)));
assert_eq!( assert_eq!(
call("export md out.md"), call("export md out.md"),
Ok(Call::Export { format: ExportFormat::Markdown, path: Some("out.md".into()) }) Ok(Call::Export { format: ExportFormat::Markdown, path: Some("out.md".into()), force: false })
); );
} }
@@ -543,6 +549,7 @@ mod tests {
("set", "mouse"), ("set", "mouse"),
("theme", "light"), ("theme", "light"),
("export", "md"), ("export", "md"),
("import", "plants.md"),
]; ];
for spec in registry::SPECS { for spec in registry::SPECS {
let argument = let argument =
+12 -4
View File
@@ -49,6 +49,7 @@ pub enum CommandId {
Version, Version,
Theme, Theme,
Export, Export,
Import,
} }
/// A word an argument can be, and what it means. /// A word an argument can be, and what it means.
@@ -326,13 +327,20 @@ pub const SPECS: &[Spec] = &[
&["colorscheme"], &["colorscheme"],
spec(Id::Theme, "theme", "them", &[required("theme", K::Theme)], "switch the theme"), spec(Id::Theme, "theme", "them", &[required("theme", K::Theme)], "switch the theme"),
), ),
// Export. // Export and import.
spec( with_bang(spec(
Id::Export, Id::Export,
"export", "export",
"exp", "exp",
&[required("format", K::Choice(FORMATS)), optional("file", K::File)], &[required("format", K::Choice(FORMATS)), optional("file", K::File)],
"export the document", "export the document (or the selection); ! writes over a file",
)),
spec(
Id::Import,
"import",
"imp",
&[required("file", K::File)],
"the nodes in a Markdown or text file, under this node",
), ),
]; ];
@@ -371,7 +379,7 @@ mod tests {
fn usage_shows_how_to_type_it() { fn usage_shows_how_to_type_it() {
assert_eq!(find("e").unwrap().usage(), ":e[dit][!] [file]"); assert_eq!(find("e").unwrap().usage(), ":e[dit][!] [file]");
assert_eq!(find("fold").unwrap().usage(), ":fo[ld] <open|auto|closed>"); assert_eq!(find("fold").unwrap().usage(), ":fo[ld] <open|auto|closed>");
assert_eq!(find("export").unwrap().usage(), ":exp[ort] <md|txt> [file]"); assert_eq!(find("export").unwrap().usage(), ":exp[ort][!] <md|txt> [file]");
assert_eq!(find("in").unwrap().usage(), ":in <idea>"); assert_eq!(find("in").unwrap().usage(), ":in <idea>");
} }
} }
+72 -19
View File
@@ -1,5 +1,6 @@
//! Nodes as Markdown text, for the system clipboard (`"+y`, `"+p`): a //! Nodes as Markdown text, for the system clipboard (`"+y`, `"+p`) and
//! section per node, the way the user picked in part 8c. //! `:export md`: a section per node, the way the user picked in part 8c
//! (and again for exports in part 9c).
//! //!
//! ```text //! ```text
//! ## Raised beds //! ## Raised beds
@@ -14,38 +15,58 @@
//! ### 40 cm high //! ### 40 cm high
//! ``` //! ```
//! //!
//! The nodes yanked are `##` headings, their children `###`, and everything //! On the clipboard, the nodes yanked are `##` headings, their children
//! further down `####`. The tags (when there are any) are a list between //! `###`, and everything further down `####`; an export starts at `#` and
//! goes down to `######`. The tags (when there are any) are a list between
//! two `---` lines, and the body (when there is one) follows. A body line //! two `---` lines, and the body (when there is one) follows. A body line
//! that would read as a heading or a `---` gets a `\` in front. //! that would read as a heading or a `---` gets a `\` in front.
//! //!
//! Text from elsewhere reads back too: headings of any level nest by their //! Text from elsewhere reads back too (and `:import` reads it): headings of
//! level, and text without headings is a node per line, nested by its //! any level nest by their level, and text without headings is a node per
//! indent (list bullets `-` `*` `+` and `1.` are dropped; a line without a //! line, nested by its indent. List bullets (`-` `*` `+` `1.`) are dropped,
//! bullet under a bulleted one is part of its body). //! `#tags` at the end of a line are the node's tags, and a line after `| `
//! (as `:export txt` writes bodies), or a line without a bullet under a
//! bulleted one, is part of the body of the node above.
use crate::model::{Body, Clip, ClipNode, Content, Node, Tag, Tags, Title}; use crate::model::{Body, Clip, ClipNode, Content, Node, Tag, Tags, Title};
/// The heading level of the nodes yanked. /// The heading levels on the clipboard: the nodes yanked, and the deepest.
const TOP_LEVEL: usize = 2; const CLIPBOARD: Levels = Levels { top: 2, deepest: 4 };
/// How many levels further down headings go: deeper nodes stay at the last. /// The heading levels of an export: Markdown's all six.
const DEEPEST: usize = 2; const EXPORT: Levels = Levels { top: 1, deepest: 6 };
/// A clip as Markdown text. /// Which headings nodes get: the top nodes' level, and the level everything
/// further down stays at.
#[derive(Debug, Clone, Copy)]
struct Levels {
top: usize,
deepest: usize,
}
/// A clip as Markdown text, for the clipboard.
pub fn write(clip: &Clip) -> String { pub fn write(clip: &Clip) -> String {
write_with(clip, CLIPBOARD)
}
/// A clip as a Markdown document (`:export md`): its top nodes `#`.
pub fn export(clip: &Clip) -> String {
write_with(clip, EXPORT)
}
fn write_with(clip: &Clip, levels: Levels) -> String {
let mut text = String::new(); let mut text = String::new();
for root in &clip.roots { for root in &clip.roots {
write_node(&mut text, root, 0); write_node(&mut text, root, levels.top, levels);
} }
text text
} }
fn write_node(text: &mut String, clipped: &ClipNode, depth: usize) { fn write_node(text: &mut String, clipped: &ClipNode, level: usize, levels: Levels) {
if !text.is_empty() { if !text.is_empty() {
text.push('\n'); text.push('\n');
} }
let node = &clipped.node; let node = &clipped.node;
text.push_str(&format!("{} {}\n", "#".repeat(TOP_LEVEL + depth.min(DEEPEST)), node.title())); text.push_str(&format!("{} {}\n", "#".repeat(level.min(levels.deepest)), node.title()));
if !node.tags.is_empty() { if !node.tags.is_empty() {
text.push_str("---\n"); text.push_str("---\n");
for tag in node.tags.iter() { for tag in node.tags.iter() {
@@ -63,7 +84,7 @@ fn write_node(text: &mut String, clipped: &ClipNode, depth: usize) {
} }
} }
for child in &clipped.children { for child in &clipped.children {
write_node(text, child, depth + 1); write_node(text, child, level + 1, levels);
} }
} }
@@ -137,6 +158,12 @@ fn list(lines: &[&str]) -> Vec<(usize, ClipNode)> {
for line in lines.iter().filter(|line| !line.trim().is_empty()) { for line in lines.iter().filter(|line| !line.trim().is_empty()) {
let indent = indent(line); let indent = indent(line);
let rest = line.trim(); let rest = line.trim();
if let Some(text) = body_line(rest)
&& let Some(body) = bodies.last_mut()
{
body.push(text.to_string());
continue;
}
match bullet(rest) { match bullet(rest) {
None if last.is_some_and(|(above, bulleted)| bulleted && indent > above) => { None if last.is_some_and(|(above, bulleted)| bulleted && indent > above) => {
if let Some(body) = bodies.last_mut() { if let Some(body) = bodies.last_mut() {
@@ -144,8 +171,11 @@ fn list(lines: &[&str]) -> Vec<(usize, ClipNode)> {
} }
} }
found => { found => {
let Ok(title) = Title::parse(found.unwrap_or(rest)) else { continue }; let (title, tags) = title_and_tags(found.unwrap_or(rest));
flat.push((indent, ClipNode::new(Node::idea(title, Default::default())))); let Ok(title) = Title::parse(title) else { continue };
let mut node = Node::idea(title, Default::default());
node.tags = tags;
flat.push((indent, ClipNode::new(node)));
bodies.push(Vec::new()); bodies.push(Vec::new());
last = Some((indent, found.is_some())); last = Some((indent, found.is_some()));
} }
@@ -173,6 +203,29 @@ fn bullet(text: &str) -> Option<&str> {
(digits > 0).then(|| rest.trim()) (digits > 0).then(|| rest.trim())
} }
/// A line of a body, as `:export txt` writes it: after a `| ` (an empty
/// line is a `|` alone).
fn body_line(text: &str) -> Option<&str> {
if text == "|" { Some("") } else { text.strip_prefix("| ") }
}
/// A line's title, and the `#tags` at its end: `Soil #todo` is "Soil",
/// tagged todo.
fn title_and_tags(text: &str) -> (&str, Tags) {
let mut title = text.trim_end();
let mut tags = Tags::default();
while let Some(at) = title.rfind(' ') {
match title[at + 1..].strip_prefix('#').and_then(|name| Tag::parse(name).ok()) {
Some(tag) => {
tags.insert(tag);
title = title[..at].trim_end();
}
None => break,
}
}
(title, tags)
}
/// How far a line is indented, a tab counting as four spaces. /// How far a line is indented, a tab counting as four spaces.
fn indent(line: &str) -> usize { fn indent(line: &str) -> usize {
line.chars().take_while(|c| c.is_whitespace()).map(|c| if c == '\t' { 4 } else { 1 }).sum() line.chars().take_while(|c| c.is_whitespace()).map(|c| if c == '\t' { 4 } else { 1 }).sum()
+4 -1
View File
@@ -7,7 +7,9 @@
//! - [`lock`]: one cumulus at a time edits a document; another one opens //! - [`lock`]: one cumulus at a time edits a document; another one opens
//! it read-only, //! it read-only,
//! - [`stamp`]: noticing that something else changed a file, //! - [`stamp`]: noticing that something else changed a file,
//! - [`markdown`]: nodes as Markdown, for the system clipboard, //! - [`markdown`]: nodes as Markdown, for the system clipboard and exports,
//! and text read back as nodes (`:import`),
//! - [`plain_text`]: nodes as an indented outline (`:export txt`),
//! - [`memory`]: what cumulus remembers between runs (where you left off in //! - [`memory`]: what cumulus remembers between runs (where you left off in
//! each document, recent documents, the command history). //! each document, recent documents, the command history).
@@ -18,6 +20,7 @@ pub mod lock;
pub mod markdown; pub mod markdown;
pub mod memory; pub mod memory;
pub mod names; pub mod names;
pub mod plain_text;
pub mod stamp; pub mod stamp;
use std::fs::{self, File}; use std::fs::{self, File};
+76
View File
@@ -0,0 +1,76 @@
//! Nodes as plain text, for `:export txt` (the user's pick in part 9c): a
//! title per line, indented two spaces a level, its tags after it, and its
//! body under it, a level further in, each line after a `| ` so it reads as
//! text rather than more nodes.
//!
//! ```text
//! Garden plans
//! Soil #todo
//! Raised beds
//! Cedar, untreated
//! ask about compost
//! | The neighbor has a bin.
//! | Bring a bucket.
//! ```
//!
//! [`markdown::read`](super::markdown::read) reads it back (`:import`).
use crate::model::{Clip, ClipNode, Content};
/// How far each level is indented.
const INDENT: &str = " ";
/// A clip as plain text.
pub fn write(clip: &Clip) -> String {
let mut text = String::new();
for root in &clip.roots {
write_node(&mut text, root, 0);
}
text
}
fn write_node(text: &mut String, clipped: &ClipNode, depth: usize) {
let indent = INDENT.repeat(depth);
let node = &clipped.node;
text.push_str(&format!("{indent}{}", node.title()));
if !node.tags.is_empty() {
text.push_str(&format!(" {}", node.tags));
}
text.push('\n');
if let Content::Idea(idea) = &node.content {
for line in idea.body.as_str().lines() {
let line = if line.is_empty() { "|".to_string() } else { format!("| {line}") };
text.push_str(&format!("{indent}{INDENT}{line}\n"));
}
}
for child in &clipped.children {
write_node(text, child, depth + 1);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::test_support::garden;
use crate::model::{Body, EditOp, NodeId, Tags};
use crate::storage::markdown;
#[test]
fn plain_text_writes_an_outline_and_reads_back() {
let (mut doc, g) = garden();
doc.apply(&EditOp::SetTags { id: g.soil, tags: Tags::parse("#todo #later").unwrap() }).unwrap();
doc.apply(&EditOp::SetBody { id: g.compost, body: Body::new("A bin.\n\nBring a bucket.") }).unwrap();
let all: std::collections::BTreeSet<NodeId> = doc.outline().into_iter().collect();
let text = write(&Clip::take(&doc, &all));
assert_eq!(
text,
"Garden plans\n Soil #later #todo\n Raised beds\n Cedar\n Watering\nask about compost\n | A bin.\n |\n | Bring a bucket.\n"
);
let back = markdown::read(&text).unwrap();
assert_eq!(back.roots.len(), 2);
let soil = &back.roots[0].children[0].node;
assert_eq!((soil.title().as_str(), soil.tags.to_string().as_str()), ("Soil", "#later #todo"));
let Content::Idea(compost) = &back.roots[1].node.content else { panic!("an idea") };
assert_eq!(compost.body.as_str(), "A bin.\n\nBring a bucket.");
}
}