diff --git a/src/lib.rs b/src/lib.rs index 88e1154..0222d07 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -45,6 +45,26 @@ pub type MarkdownEvents<'a> = Vec>; /// 3. Prevent later postprocessors from running ([PostprocessorResult::StopHere]) or cause a note /// to be skipped entirely ([PostprocessorResult::StopAndSkipNote]). /// +/// # Postprocessors and embeds +/// +/// Postprocessors normally run at the end of the export phase, once notes have been fully parsed. +/// This means that any embedded notes have been resolved and merged into the final note already. +/// +/// In some cases it may be desirable to change the contents of these embedded notes *before* they +/// are inserted into the final document. This is possible through the use of +/// [Exporter::add_embed_postprocessor]. +/// These "embed postprocessors" run much the same way as regular postprocessors, but they're run on +/// the note that is about to be embedded in another note. In addition: +/// +/// - Changes to context carry over to later embed postprocessors, but are then discarded. This +/// means that changes to frontmatter do not propagate to the root note for example. +/// - [PostprocessorResult::StopAndSkipNote] prevents the embedded note from being included (it's +/// replaced with a blank document) but doesn't affect the root note. +/// +/// It's possible to pass the same functions to [Exporter::add_postprocessor] and +/// [Exporter::add_embed_postprocessor]. The [Context::note_depth] method may be used to determine +/// whether a note is a root note or an embedded note in this situation. +/// /// # Examples /// /// ## Update frontmatter @@ -217,6 +237,7 @@ pub struct Exporter<'a> { walk_options: WalkOptions<'a>, process_embeds_recursively: bool, postprocessors: Vec<&'a Postprocessor>, + embed_postprocessors: Vec<&'a Postprocessor>, } impl<'a> fmt::Debug for Exporter<'a> { @@ -235,6 +256,13 @@ impl<'a> fmt::Debug for Exporter<'a> { "postprocessors", &format!("<{} postprocessors active>", self.postprocessors.len()), ) + .field( + "embed_postprocessors", + &format!( + "<{} postprocessors active>", + self.embed_postprocessors.len() + ), + ) .finish() } } @@ -252,6 +280,7 @@ impl<'a> Exporter<'a> { process_embeds_recursively: true, vault_contents: None, postprocessors: vec![], + embed_postprocessors: vec![], } } @@ -295,6 +324,12 @@ impl<'a> Exporter<'a> { self } + /// Append a function to the chain of [postprocessors][Postprocessor] for embeds. + pub fn add_embed_postprocessor(&mut self, processor: &'a Postprocessor) -> &mut Exporter<'a> { + self.embed_postprocessors.push(processor); + self + } + /// Export notes using the settings configured on this exporter. pub fn run(&mut self) -> Result<()> { if !self.root.exists() { @@ -377,7 +412,7 @@ impl<'a> Exporter<'a> { match res.2 { PostprocessorResult::StopHere => break, PostprocessorResult::StopAndSkipNote => return Ok(()), - _ => (), + PostprocessorResult::Continue => (), } } @@ -558,7 +593,7 @@ impl<'a> Exporter<'a> { } let path = path.unwrap(); - let child_context = Context::from_parent(context, path); + let mut child_context = Context::from_parent(context, path); let no_ext = OsString::new(); if !self.process_embeds_recursively && context.file_tree().contains(path) { @@ -571,10 +606,25 @@ impl<'a> Exporter<'a> { 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) = self.parse_obsidian_note(path, &child_context)?; + child_context.frontmatter = frontmatter; if let Some(section) = note_ref.section { events = reduce_to_section(events, section); } + for func in &self.embed_postprocessors { + // Postprocessors running on embeds shouldn't be able to change frontmatter (or + // any other metadata), so we give them a clone of the context. + let res = func(child_context, events); + child_context = res.0; + events = res.1; + match res.2 { + PostprocessorResult::StopHere => break, + PostprocessorResult::StopAndSkipNote => { + events = vec![]; + } + PostprocessorResult::Continue => (), + } + } events } Some("png") | Some("jpg") | Some("jpeg") | Some("gif") | Some("webp") => { diff --git a/tests/postprocessors_test.rs b/tests/postprocessors_test.rs index 385467f..10f1c8d 100644 --- a/tests/postprocessors_test.rs +++ b/tests/postprocessors_test.rs @@ -62,7 +62,10 @@ fn test_postprocessor_stophere() { ); exporter.add_postprocessor(&|ctx, mdevents| (ctx, mdevents, PostprocessorResult::StopHere)); + exporter + .add_embed_postprocessor(&|ctx, mdevents| (ctx, mdevents, PostprocessorResult::StopHere)); exporter.add_postprocessor(&|_, _| panic!("should not be called due to above processor")); + exporter.add_embed_postprocessor(&|_, _| panic!("should not be called due to above processor")); exporter.run().unwrap(); } @@ -110,3 +113,98 @@ fn test_postprocessor_change_destination() { assert!(!original_note_path.exists()); assert!(new_note_path.exists()); } + +// The purpose of this test to verify the `append_frontmatter` postprocessor is called to extend +// the frontmatter, and the `foo_to_bar` postprocessor is called to replace instances of "foo" with +// "bar" (only in the note body). +#[test] +fn test_embed_postprocessors() { + let tmp_dir = TempDir::new().expect("failed to make tempdir"); + let mut exporter = Exporter::new( + PathBuf::from("tests/testdata/input/postprocessors"), + tmp_dir.path().to_path_buf(), + ); + exporter.add_embed_postprocessor(&foo_to_bar); + // Should have no effect with embeds: + exporter.add_embed_postprocessor(&append_frontmatter); + + exporter.run().unwrap(); + + let expected = + read_to_string("tests/testdata/expected/postprocessors/Note_embed_postprocess_only.md") + .unwrap(); + let actual = read_to_string(tmp_dir.path().clone().join(PathBuf::from("Note.md"))).unwrap(); + assert_eq!(expected, actual); +} + +// When StopAndSkipNote is used with an embed_preprocessor, it should skip the embedded note but +// continue with the rest of the note. +#[test] +fn test_embed_postprocessors_stop_and_skip() { + let tmp_dir = TempDir::new().expect("failed to make tempdir"); + let mut exporter = Exporter::new( + PathBuf::from("tests/testdata/input/postprocessors"), + tmp_dir.path().to_path_buf(), + ); + exporter.add_embed_postprocessor(&|ctx, mdevents| { + (ctx, mdevents, PostprocessorResult::StopAndSkipNote) + }); + + exporter.run().unwrap(); + + let expected = + read_to_string("tests/testdata/expected/postprocessors/Note_embed_stop_and_skip.md") + .unwrap(); + let actual = read_to_string(tmp_dir.path().clone().join(PathBuf::from("Note.md"))).unwrap(); + assert_eq!(expected, actual); +} + +// This test verifies that the context which is passed to an embed postprocessor is actually +// correct. Primarily, this means the frontmatter should reflect that of the note being embedded as +// opposed to the frontmatter of the root note. +#[test] +fn test_embed_postprocessors_context() { + let tmp_dir = TempDir::new().expect("failed to make tempdir"); + let mut exporter = Exporter::new( + PathBuf::from("tests/testdata/input/postprocessors"), + tmp_dir.path().to_path_buf(), + ); + + exporter.add_postprocessor(&|ctx, mdevents| { + if ctx.current_file() != &PathBuf::from("Note.md") { + return (ctx, mdevents, PostprocessorResult::Continue); + } + let is_root_note = ctx + .frontmatter + .get(&Value::String("is_root_note".to_string())) + .unwrap(); + if is_root_note != &Value::Bool(true) { + // NOTE: Test failure may not give output consistently because the test binary affects + // how output is captured and printed in the thread running this postprocessor. Just + // run the test a couple times until the error shows up. + panic!( + "postprocessor: expected is_root_note in {} to be true, got false", + &ctx.current_file().display() + ) + } + (ctx, mdevents, PostprocessorResult::Continue) + }); + exporter.add_embed_postprocessor(&|ctx, mdevents| { + let is_root_note = ctx + .frontmatter + .get(&Value::String("is_root_note".to_string())) + .unwrap(); + if is_root_note == &Value::Bool(true) { + // NOTE: Test failure may not give output consistently because the test binary affects + // how output is captured and printed in the thread running this postprocessor. Just + // run the test a couple times until the error shows up. + panic!( + "embed_postprocessor: expected is_root_note in {} to be false, got true", + &ctx.current_file().display() + ) + } + (ctx, mdevents, PostprocessorResult::Continue) + }); + + exporter.run().unwrap(); +} diff --git a/tests/testdata/expected/postprocessors/Note.md b/tests/testdata/expected/postprocessors/Note.md index c1d56d0..ea9bd98 100644 --- a/tests/testdata/expected/postprocessors/Note.md +++ b/tests/testdata/expected/postprocessors/Note.md @@ -1,8 +1,11 @@ --- foo: bar +is_root_note: true bar: baz --- # Title +This note is embedded. It mentions the word bar. + Sentence containing bar. diff --git a/tests/testdata/expected/postprocessors/Note_embed_postprocess_only.md b/tests/testdata/expected/postprocessors/Note_embed_postprocess_only.md new file mode 100644 index 0000000..7406bf9 --- /dev/null +++ b/tests/testdata/expected/postprocessors/Note_embed_postprocess_only.md @@ -0,0 +1,10 @@ +--- +foo: bar +is_root_note: true +--- + +# Title + +This note is embedded. It mentions the word bar. + +Sentence containing foo. diff --git a/tests/testdata/expected/postprocessors/Note_embed_stop_and_skip.md b/tests/testdata/expected/postprocessors/Note_embed_stop_and_skip.md new file mode 100644 index 0000000..71f3215 --- /dev/null +++ b/tests/testdata/expected/postprocessors/Note_embed_stop_and_skip.md @@ -0,0 +1,10 @@ +--- +foo: bar +is_root_note: true +--- + +# Title + + + +Sentence containing foo. diff --git a/tests/testdata/input/postprocessors/Note.md b/tests/testdata/input/postprocessors/Note.md index 7157099..4f5f07f 100644 --- a/tests/testdata/input/postprocessors/Note.md +++ b/tests/testdata/input/postprocessors/Note.md @@ -1,7 +1,10 @@ --- foo: bar +is_root_note: true --- # Title +![[_embed]] + Sentence containing foo. diff --git a/tests/testdata/input/postprocessors/_embed.md b/tests/testdata/input/postprocessors/_embed.md new file mode 100644 index 0000000..d87f0df --- /dev/null +++ b/tests/testdata/input/postprocessors/_embed.md @@ -0,0 +1,5 @@ +--- +is_root_note: false +--- + +This note is embedded. It mentions the word foo.