diff --git a/src/lib.rs b/src/lib.rs index 82e5d3fe..976659df 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ pub mod postprocessors; mod references; mod walker; +use std::collections::HashSet; use std::ffi::OsString; use std::fs::{self, File}; use std::io::prelude::*; @@ -245,6 +246,7 @@ pub struct Exporter<'a> { preserve_mtime: bool, postprocessors: Vec<&'a Postprocessor<'a>>, embed_postprocessors: Vec<&'a Postprocessor<'a>>, + linked_attachments_only: bool, } impl<'a> fmt::Debug for Exporter<'a> { @@ -291,6 +293,7 @@ impl<'a> Exporter<'a> { vault_contents: None, postprocessors: vec![], embed_postprocessors: vec![], + linked_attachments_only: false, } } @@ -339,6 +342,12 @@ impl<'a> Exporter<'a> { self } + /// Set whether non-markdown files should only be included if linked or embedded in a note. + pub fn linked_attachments_only(&mut self, linked_only: bool) -> &mut Self { + self.linked_attachments_only = linked_only; + self + } + /// Append a function to the chain of [postprocessors][Postprocessor] to run on exported /// Obsidian Markdown notes. pub fn add_postprocessor(&mut self, processor: &'a Postprocessor<'_>) -> &mut Self { @@ -409,7 +418,11 @@ impl<'a> Exporter<'a> { .expect("file should always be nested under root") .to_path_buf(); let destination = &self.destination.join(relative_path); - self.export_note(&file, destination) + if !self.linked_attachments_only || is_markdown_file(&file) { + self.export_note(&file, destination) + } else { + Ok(()) + } })?; Ok(()) } @@ -431,7 +444,8 @@ impl<'a> Exporter<'a> { fn parse_and_export_obsidian_note(&self, src: &Path, dest: &Path) -> Result<()> { let mut context = Context::new(src.to_path_buf(), dest.to_path_buf()); - let (frontmatter, mut markdown_events) = self.parse_obsidian_note(src, &context)?; + let (frontmatter, mut markdown_events, found_attachments) = + self.parse_obsidian_note(src, &context)?; context.frontmatter = frontmatter; for func in &self.postprocessors { match func(&mut context, &mut markdown_events) { @@ -441,6 +455,17 @@ impl<'a> Exporter<'a> { } } + if self.linked_attachments_only { + for attachment in found_attachments { + let relative_path = attachment + .strip_prefix(self.start_at.clone()) + .expect("file should always be nested under root") + .to_path_buf(); + let destination = &self.destination.join(relative_path); + self.export_note(&attachment, destination)?; + } + } + let mut outfile = create_file(&context.destination)?; let write_frontmatter = match self.frontmatter_strategy { FrontmatterStrategy::Always => true, @@ -472,7 +497,7 @@ impl<'a> Exporter<'a> { &self, path: &Path, context: &Context, - ) -> Result<(Frontmatter, MarkdownEvents<'b>)> { + ) -> Result<(Frontmatter, MarkdownEvents<'b>, HashSet)> { if context.note_depth() > NOTE_RECURSION_LIMIT { return Err(ExportError::RecursionLimitExceeded { file_tree: context.file_tree(), @@ -481,6 +506,12 @@ impl<'a> Exporter<'a> { let content = fs::read_to_string(path).context(ReadSnafu { path })?; let mut frontmatter = String::new(); + // If `linked_attachments_only` is enabled, this is used to keep track of which attachments + // have been linked to in this note or any embedded notes. Note that a file is only + // considered an attachment if it is not a markdown file. These can then be exported after + // the note is fully parsed and any postprocessing has been applied. + let mut found_attachments: HashSet = HashSet::new(); + let parser_options = Options::ENABLE_TABLES | Options::ENABLE_FOOTNOTES | Options::ENABLE_STRIKETHROUGH @@ -575,6 +606,7 @@ impl<'a> Exporter<'a> { ref_parser.ref_text.clone().as_ref() ), context, + &mut found_attachments, ); events.append(&mut elements); buffer.clear(); @@ -583,7 +615,8 @@ impl<'a> Exporter<'a> { Some(RefType::Embed) => { let mut elements = self.embed_file( ref_parser.ref_text.clone().as_ref(), - context + context, + &mut found_attachments, )?; events.append(&mut elements); buffer.clear(); @@ -605,6 +638,7 @@ impl<'a> Exporter<'a> { Ok(( frontmatter_from_str(&frontmatter).context(FrontMatterDecodeSnafu { path })?, events.into_iter().map(event_to_owned).collect(), + found_attachments, )) } @@ -617,6 +651,7 @@ impl<'a> Exporter<'a> { &self, link_text: &'a str, context: &'a Context, + found_attachments: &mut HashSet, ) -> Result> { let note_ref = ObsidianNoteReference::from_str(link_text); @@ -626,7 +661,7 @@ impl<'a> Exporter<'a> { // If we have None file it is either to a section or id within the same file and thus // the current embed logic will fail, recurssing until it reaches it's limit. // For now we just bail early. - None => return Ok(self.make_link_to_file(note_ref, context)), + None => return Ok(self.make_link_to_file(note_ref, context, found_attachments)), }; if path.is_none() { @@ -648,14 +683,16 @@ impl<'a> Exporter<'a> { if !self.process_embeds_recursively && context.file_tree().contains(path) { return Ok([ vec![Event::Text(CowStr::Borrowed("→ "))], - self.make_link_to_file(note_ref, &child_context), + self.make_link_to_file(note_ref, &child_context, found_attachments), ] .concat()); } let events = match path.extension().unwrap_or(&no_ext).to_str() { Some("md") => { - let (frontmatter, mut events) = self.parse_obsidian_note(path, &child_context)?; + let (frontmatter, mut events, child_found_attachments) = + self.parse_obsidian_note(path, &child_context)?; + found_attachments.extend(child_found_attachments); child_context.frontmatter = frontmatter; if let Some(section) = note_ref.section { events = reduce_to_section(events, section); @@ -674,7 +711,7 @@ impl<'a> Exporter<'a> { events } Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "svg") => { - self.make_link_to_file(note_ref, &child_context) + self.make_link_to_file(note_ref, &child_context, found_attachments) .into_iter() .map(|event| match event { // make_link_to_file returns a link to a file. With this we turn the link @@ -697,7 +734,7 @@ impl<'a> Exporter<'a> { }) .collect() } - _ => self.make_link_to_file(note_ref, &child_context), + _ => self.make_link_to_file(note_ref, &child_context, found_attachments), }; Ok(events) } @@ -706,6 +743,7 @@ impl<'a> Exporter<'a> { &self, reference: ObsidianNoteReference<'_>, context: &Context, + found_attachments: &mut HashSet, ) -> MarkdownEvents<'c> { let target_file = reference.file.map_or_else( || Some(context.current_file()), @@ -728,6 +766,9 @@ impl<'a> Exporter<'a> { ]; } let target_file = target_file.unwrap(); + if self.linked_attachments_only && !is_markdown_file(target_file) { + found_attachments.insert(target_file.clone()); + } // We use root_file() rather than current_file() here to make sure links are always // relative to the outer-most note, which is the note which this content is inserted into // in case of embedded notes. diff --git a/src/main.rs b/src/main.rs index 1f38c604..a874ce6b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -70,6 +70,13 @@ struct Opts { default = "false" )] hard_linebreaks: bool, + + #[options( + no_short, + help = "Non-markdown files are only exported if they are linked or embedded in a note.", + default = "false" + )] + linked_attachments_only: bool, } fn frontmatter_strategy_from_str(input: &str) -> Result { @@ -105,6 +112,7 @@ fn main() { exporter.frontmatter_strategy(args.frontmatter_strategy); exporter.process_embeds_recursively(!args.no_recursive_embeds); exporter.preserve_mtime(args.preserve_mtime); + exporter.linked_attachments_only(args.linked_attachments_only); exporter.walk_options(walk_options); if args.hard_linebreaks { diff --git a/tests/postprocessors_test.rs b/tests/postprocessors_test.rs index ed66e219..336db6c2 100644 --- a/tests/postprocessors_test.rs +++ b/tests/postprocessors_test.rs @@ -1,5 +1,5 @@ use std::collections::HashSet; -use std::fs::{read_to_string, remove_file}; +use std::fs::{read_to_string, remove_file, read}; use std::path::PathBuf; use std::sync::Mutex; @@ -266,6 +266,7 @@ fn test_filter_by_tags() { vec!["export".into()], ); exporter.add_postprocessor(&filter_by_tags); + exporter.linked_attachments_only(true); exporter.run().unwrap(); let walker = WalkDir::new("tests/testdata/expected/filter-by-tags/") @@ -279,13 +280,13 @@ fn test_filter_by_tags() { continue; }; let filename = entry.file_name().to_string_lossy().into_owned(); - let expected = read_to_string(entry.path()).unwrap_or_else(|_| { + let expected = read(entry.path()).unwrap_or_else(|_| { panic!( "failed to read {} from testdata/expected/filter-by-tags", entry.path().display() ) }); - let actual = read_to_string(tmp_dir.path().join(PathBuf::from(&filename))) + let actual = read(tmp_dir.path().join(PathBuf::from(&filename))) .unwrap_or_else(|_| panic!("failed to read {} from temporary exportdir", filename)); assert_eq!( diff --git a/tests/testdata/expected/filter-by-tags/export-me.md b/tests/testdata/expected/filter-by-tags/export-me.md index 2d4d4f0a..8429d7f5 100644 --- a/tests/testdata/expected/filter-by-tags/export-me.md +++ b/tests/testdata/expected/filter-by-tags/export-me.md @@ -5,3 +5,5 @@ tags: --- A public note + +![white.png](white.png) diff --git a/tests/testdata/expected/filter-by-tags/white.png b/tests/testdata/expected/filter-by-tags/white.png new file mode 100644 index 00000000..0e0a663d Binary files /dev/null and b/tests/testdata/expected/filter-by-tags/white.png differ diff --git a/tests/testdata/input/filter-by-tags/bulb.svg b/tests/testdata/input/filter-by-tags/bulb.svg new file mode 100644 index 00000000..ecbfb2ec --- /dev/null +++ b/tests/testdata/input/filter-by-tags/bulb.svg @@ -0,0 +1 @@ + diff --git a/tests/testdata/input/filter-by-tags/export-me.md b/tests/testdata/input/filter-by-tags/export-me.md index 5b36253c..71fa890b 100644 --- a/tests/testdata/input/filter-by-tags/export-me.md +++ b/tests/testdata/input/filter-by-tags/export-me.md @@ -3,3 +3,5 @@ tags: [export, me] --- A public note + +![[white.png]] diff --git a/tests/testdata/input/filter-by-tags/export-no-export.md b/tests/testdata/input/filter-by-tags/export-no-export.md index 7d604f50..742412bb 100644 --- a/tests/testdata/input/filter-by-tags/export-no-export.md +++ b/tests/testdata/input/filter-by-tags/export-no-export.md @@ -3,3 +3,7 @@ tags: [export, no-export, private] --- A private note + +![[white.png]] + +![[bulb.svg]] diff --git a/tests/testdata/input/filter-by-tags/no-frontmatter.md b/tests/testdata/input/filter-by-tags/no-frontmatter.md index 427b0ea4..0f018310 100644 --- a/tests/testdata/input/filter-by-tags/no-frontmatter.md +++ b/tests/testdata/input/filter-by-tags/no-frontmatter.md @@ -1 +1,3 @@ -A note without frontmatter should be exported. +A note without frontmatter should not be exported. + +![[note.pdf]] diff --git a/tests/testdata/input/filter-by-tags/no-no-export.md b/tests/testdata/input/filter-by-tags/no-no-export.md index cc215040..c3d762bf 100644 --- a/tests/testdata/input/filter-by-tags/no-no-export.md +++ b/tests/testdata/input/filter-by-tags/no-no-export.md @@ -3,3 +3,5 @@ tags: [no, no-export] --- A private note + +![[bulb.svg]] diff --git a/tests/testdata/input/filter-by-tags/note.pdf b/tests/testdata/input/filter-by-tags/note.pdf new file mode 100644 index 00000000..574011d0 Binary files /dev/null and b/tests/testdata/input/filter-by-tags/note.pdf differ diff --git a/tests/testdata/input/filter-by-tags/private.md b/tests/testdata/input/filter-by-tags/private.md index 88b76e9d..2f49cfe4 100644 --- a/tests/testdata/input/filter-by-tags/private.md +++ b/tests/testdata/input/filter-by-tags/private.md @@ -3,3 +3,5 @@ tags: [private] --- A private note. + +![[bulb.svg]] diff --git a/tests/testdata/input/filter-by-tags/white.png b/tests/testdata/input/filter-by-tags/white.png new file mode 100644 index 00000000..0e0a663d Binary files /dev/null and b/tests/testdata/input/filter-by-tags/white.png differ