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.
This commit is contained in:
Nick Groenen 2021-09-12 14:53:27 +02:00
parent 6afcd75f07
commit 8dc7e59a79
No known key found for this signature in database
GPG Key ID: 4F0AD019928AE098
7 changed files with 182 additions and 3 deletions

View File

@ -45,6 +45,26 @@ pub type MarkdownEvents<'a> = Vec<Event<'a>>;
/// 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") => {

View File

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

View File

@ -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.

View File

@ -0,0 +1,10 @@
---
foo: bar
is_root_note: true
---
# Title
This note is embedded. It mentions the word bar.
Sentence containing foo.

View File

@ -0,0 +1,10 @@
---
foo: bar
is_root_note: true
---
# Title
Sentence containing foo.

View File

@ -1,7 +1,10 @@
---
foo: bar
is_root_note: true
---
# Title
![[_embed]]
Sentence containing foo.

View File

@ -0,0 +1,5 @@
---
is_root_note: false
---
This note is embedded. It mentions the word foo.