From 8dc7e59a79bd645d80bc69808fa5de49477b54fd Mon Sep 17 00:00:00 2001 From: Nick Groenen Date: Sun, 12 Sep 2021 14:53:27 +0200 Subject: [PATCH] New: support postprocessors running on embedded notes This introduces support for postprocessors that are run on the result of a note that is being embedded into another note. This differs from the existing postprocessors (which remain unchanged) that run once all embeds have been processed and merged with the final note. These "embed postprocessors" may be set through the new `Exporter::add_embed_postprocessor` method. --- src/lib.rs | 56 ++++++++++- tests/postprocessors_test.rs | 98 +++++++++++++++++++ .../testdata/expected/postprocessors/Note.md | 3 + .../Note_embed_postprocess_only.md | 10 ++ .../Note_embed_stop_and_skip.md | 10 ++ tests/testdata/input/postprocessors/Note.md | 3 + tests/testdata/input/postprocessors/_embed.md | 5 + 7 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 tests/testdata/expected/postprocessors/Note_embed_postprocess_only.md create mode 100644 tests/testdata/expected/postprocessors/Note_embed_stop_and_skip.md create mode 100644 tests/testdata/input/postprocessors/_embed.md 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.