: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]>
121 lines
5.4 KiB
Rust
121 lines
5.4 KiB
Rust
//! 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())
|
|
}
|