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:
1 parent
5888d143fd
commit
9d15a054f2
14 files changed
+407
-33
No files matched your search
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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
@@ -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");
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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};
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user