//! 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()) }