Files
cumulus/src/app/export.rs
T
skkeyeandClaude Opus 5.5 9d15a054f2 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]>
2026-10-02 11:46:04 -04:00

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