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
+13
-1
@@ -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
@@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
pub mod connect;
|
||||
pub mod documents;
|
||||
mod export;
|
||||
mod files;
|
||||
mod input;
|
||||
pub mod jumps;
|
||||
|
||||
+3
-2
@@ -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");
|
||||
|
||||
@@ -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::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;
|
||||
|
||||
Reference in new issue
Block a user