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
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)
- 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
@@ -718,7 +739,8 @@ cumulus/
│ (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
│ (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),
render, terminal (the main loop, setup, teardown, panic hook), startup (what
`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`),
connect (connect mode), portals (what portals show, going through them,
`: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`,
`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
documents to a temporary folder, type keys and click on a test terminal, and
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 |
| `:` `?` | command line, help (search it with `/`, Enter runs the key or command) |
| `: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) |
| `: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::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;
+9 -2
View File
@@ -109,7 +109,10 @@ pub enum Call {
Export {
format: ExportFormat,
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
@@ -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::Version => Call::Version,
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("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"),
("theme", "light"),
("export", "md"),
("import", "plants.md"),
];
for spec in registry::SPECS {
let argument =
+12 -4
View File
@@ -49,6 +49,7 @@ pub enum CommandId {
Version,
Theme,
Export,
Import,
}
/// A word an argument can be, and what it means.
@@ -326,13 +327,20 @@ pub const SPECS: &[Spec] = &[
&["colorscheme"],
spec(Id::Theme, "theme", "them", &[required("theme", K::Theme)], "switch the theme"),
),
// Export.
spec(
// Export and import.
with_bang(spec(
Id::Export,
"export",
"exp",
&[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() {
assert_eq!(find("e").unwrap().usage(), ":e[dit][!] [file]");
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>");
}
}
+72 -19
View File
@@ -1,5 +1,6 @@
//! Nodes as Markdown text, for the system clipboard (`"+y`, `"+p`): a
//! section per node, the way the user picked in part 8c.
//! Nodes as Markdown text, for the system clipboard (`"+y`, `"+p`) and
//! `:export md`: a section per node, the way the user picked in part 8c
//! (and again for exports in part 9c).
//!
//! ```text
//! ## Raised beds
@@ -14,38 +15,58 @@
//! ### 40 cm high
//! ```
//!
//! The nodes yanked are `##` headings, their children `###`, and everything
//! further down `####`. The tags (when there are any) are a list between
//! On the clipboard, the nodes yanked are `##` headings, their children
//! `###`, 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
//! 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
//! level, and text without headings is a node per line, nested by its
//! indent (list bullets `-` `*` `+` and `1.` are dropped; a line without a
//! bullet under a bulleted one is part of its body).
//! Text from elsewhere reads back too (and `:import` reads it): headings of
//! any level nest by their level, and text without headings is a node per
//! line, nested by its indent. List bullets (`-` `*` `+` `1.`) are dropped,
//! `#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};
/// The heading level of the nodes yanked.
const TOP_LEVEL: usize = 2;
/// How many levels further down headings go: deeper nodes stay at the last.
const DEEPEST: usize = 2;
/// The heading levels on the clipboard: the nodes yanked, and the deepest.
const CLIPBOARD: Levels = Levels { top: 2, deepest: 4 };
/// The heading levels of an export: Markdown's all six.
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 {
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();
for root in &clip.roots {
write_node(&mut text, root, 0);
write_node(&mut text, root, levels.top, levels);
}
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() {
text.push('\n');
}
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() {
text.push_str("---\n");
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 {
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()) {
let indent = indent(line);
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) {
None if last.is_some_and(|(above, bulleted)| bulleted && indent > above) => {
if let Some(body) = bodies.last_mut() {
@@ -144,8 +171,11 @@ fn list(lines: &[&str]) -> Vec<(usize, ClipNode)> {
}
}
found => {
let Ok(title) = Title::parse(found.unwrap_or(rest)) else { continue };
flat.push((indent, ClipNode::new(Node::idea(title, Default::default()))));
let (title, tags) = title_and_tags(found.unwrap_or(rest));
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());
last = Some((indent, found.is_some()));
}
@@ -173,6 +203,29 @@ fn bullet(text: &str) -> Option<&str> {
(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.
fn indent(line: &str) -> usize {
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
//! it read-only,
//! - [`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
//! each document, recent documents, the command history).
@@ -18,6 +20,7 @@ pub mod lock;
pub mod markdown;
pub mod memory;
pub mod names;
pub mod plain_text;
pub mod stamp;
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.");
}
}