diff --git a/DESIGN.md b/DESIGN.md index 3be74c8..34fe24e 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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. diff --git a/README.md b/README.md index 27d8794..a5392ba 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/src/app/action.rs b/src/app/action.rs index 80f0ced..867d169 100644 --- a/src/app/action.rs +++ b/src/app/action.rs @@ -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), + /// `: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, + force: bool, + nodes: Vec, + }, + /// `: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 { diff --git a/src/app/calls.rs b/src/app/calls.rs index f8505ff..04c8d9e 100644 --- a/src/app/calls.rs +++ b/src/app/calls.rs @@ -84,7 +84,10 @@ pub fn actions_for(call: Call, ctx: &ViewContext<'_>, on: &[NodeId]) -> Vec 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)), } } diff --git a/src/app/export.rs b/src/app/export.rs new file mode 100644 index 0000000..2dd9467 --- /dev/null +++ b/src/app/export.rs @@ -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, + force: bool, + on: &[NodeId], + ) -> Option { + 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 = + 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 { + 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()) +} diff --git a/src/app/mod.rs b/src/app/mod.rs index d4071e5..01f1527 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -27,6 +27,7 @@ pub mod commands; mod configure; pub mod connect; pub mod documents; +mod export; mod files; mod input; pub mod jumps; diff --git a/src/app/tests.rs b/src/app/tests.rs index 30ea9b4..7b17215 100644 --- a/src/app/tests.rs +++ b/src/app/tests.rs @@ -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"); - assert_eq!(h.message(), "this one comes in v1.1"); + h.type_keys(":board"); + 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"); diff --git a/src/app/tests/export.rs b/src/app/tests/export.rs new file mode 100644 index 0000000..71f9b43 --- /dev/null +++ b/src/app/tests/export.rs @@ -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"); + 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"); + assert_eq!(h.message(), "garden.md is there already: :export! md writes over it"); + h.type_keys(":export! md"); + 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"); + 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"); + 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"); + 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"); + 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:new Copy:import garden.txt"); + 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"); + assert_eq!(h.message(), "garden.cum is a cumulus document: :e opens it, :portal leads to it"); + h.type_keys(":import nothing.md"); + assert_eq!(h.message(), "there's no nothing.md there"); +} diff --git a/src/app/update.rs b/src/app/update.rs index 41deb1e..7b80735 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -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; diff --git a/src/command/parse.rs b/src/command/parse.rs index 3137e1e..7f266a1 100644 --- a/src/command/parse.rs +++ b/src/command/parse.rs @@ -109,7 +109,10 @@ pub enum Call { Export { format: ExportFormat, path: Option, + /// `: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 = diff --git a/src/command/registry.rs b/src/command/registry.rs index 1eb3b3b..11ffb53 100644 --- a/src/command/registry.rs +++ b/src/command/registry.rs @@ -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] "); - assert_eq!(find("export").unwrap().usage(), ":exp[ort] [file]"); + assert_eq!(find("export").unwrap().usage(), ":exp[ort][!] [file]"); assert_eq!(find("in").unwrap().usage(), ":in "); } } diff --git a/src/storage/markdown.rs b/src/storage/markdown.rs index 8040ee9..9bd9e28 100644 --- a/src/storage/markdown.rs +++ b/src/storage/markdown.rs @@ -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() diff --git a/src/storage/mod.rs b/src/storage/mod.rs index f0299b1..cdab65c 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -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}; diff --git a/src/storage/plain_text.rs b/src/storage/plain_text.rs new file mode 100644 index 0000000..f7326bf --- /dev/null +++ b/src/storage/plain_text.rs @@ -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 = 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."); + } +}