58eb79e53d
Add support for postprocessing of Markdown prior to writing converted notes to disk. Postprocessors may be used when making use of Obsidian export as a Rust library to do the following: 1. Modify a note's `Context`, for example to change the destination filename or update its Frontmatter. 2. Change a note's contents by altering `MarkdownEvents`. 3. Prevent later postprocessors from running or cause a note to be skipped entirely. Future releases of Obsidian export may come with built-in postprocessors for users of the command-line tool to use, if general use-cases can be identified. For example, a future release might include functionality to make notes more suitable for the Hugo static site generator. This functionality would be implemented as a postprocessor that could be enabled through command-line flags.
462 lines
14 KiB
Rust
462 lines
14 KiB
Rust
use obsidian_export::{
|
|
Context, ExportError, Exporter, FrontmatterStrategy, MarkdownEvents, PostprocessorResult,
|
|
};
|
|
use pretty_assertions::assert_eq;
|
|
use pulldown_cmark::{CowStr, Event};
|
|
use serde_yaml::Value;
|
|
use std::fs::{create_dir, read_to_string, remove_file, set_permissions, File, Permissions};
|
|
use std::io::prelude::*;
|
|
use std::path::PathBuf;
|
|
use tempfile::TempDir;
|
|
use walkdir::WalkDir;
|
|
|
|
#[cfg(not(target_os = "windows"))]
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
#[test]
|
|
fn test_main_variants_with_default_options() {
|
|
let tmp_dir = TempDir::new().expect("failed to make tempdir");
|
|
|
|
Exporter::new(
|
|
PathBuf::from("tests/testdata/input/main-samples/"),
|
|
tmp_dir.path().to_path_buf(),
|
|
)
|
|
.run()
|
|
.expect("exporter returned error");
|
|
|
|
let walker = WalkDir::new("tests/testdata/expected/main-samples/")
|
|
// Without sorting here, different test runs may trigger the first assertion failure in
|
|
// unpredictable order.
|
|
.sort_by(|a, b| a.file_name().cmp(b.file_name()))
|
|
.into_iter();
|
|
for entry in walker {
|
|
let entry = entry.unwrap();
|
|
if entry.metadata().unwrap().is_dir() {
|
|
continue;
|
|
};
|
|
let filename = entry.file_name().to_string_lossy().into_owned();
|
|
let expected = read_to_string(entry.path()).expect(&format!(
|
|
"failed to read {} from testdata/expected/main-samples/",
|
|
entry.path().display()
|
|
));
|
|
let actual = read_to_string(tmp_dir.path().clone().join(PathBuf::from(&filename))).expect(
|
|
&format!("failed to read {} from temporary exportdir", filename),
|
|
);
|
|
|
|
assert_eq!(
|
|
expected, actual,
|
|
"{} does not have expected content",
|
|
filename
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_frontmatter_never() {
|
|
let tmp_dir = TempDir::new().expect("failed to make tempdir");
|
|
let mut exporter = Exporter::new(
|
|
PathBuf::from("tests/testdata/input/main-samples/"),
|
|
tmp_dir.path().to_path_buf(),
|
|
);
|
|
exporter.frontmatter_strategy(FrontmatterStrategy::Never);
|
|
exporter.run().expect("exporter returned error");
|
|
|
|
let expected = "Note with frontmatter.\n";
|
|
let actual = read_to_string(
|
|
tmp_dir
|
|
.path()
|
|
.clone()
|
|
.join(PathBuf::from("note-with-frontmatter.md")),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(expected, actual);
|
|
}
|
|
|
|
#[test]
|
|
fn test_frontmatter_always() {
|
|
let tmp_dir = TempDir::new().expect("failed to make tempdir");
|
|
let mut exporter = Exporter::new(
|
|
PathBuf::from("tests/testdata/input/main-samples/"),
|
|
tmp_dir.path().to_path_buf(),
|
|
);
|
|
exporter.frontmatter_strategy(FrontmatterStrategy::Always);
|
|
exporter.run().expect("exporter returned error");
|
|
|
|
// Note without frontmatter should have empty frontmatter added.
|
|
let expected = "---\n---\n\nNote without frontmatter.\n";
|
|
let actual = read_to_string(
|
|
tmp_dir
|
|
.path()
|
|
.clone()
|
|
.join(PathBuf::from("note-without-frontmatter.md")),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(expected, actual);
|
|
|
|
// Note with frontmatter should remain untouched.
|
|
let expected = "---\nFoo: bar\n---\n\nNote with frontmatter.\n";
|
|
let actual = read_to_string(
|
|
tmp_dir
|
|
.path()
|
|
.clone()
|
|
.join(PathBuf::from("note-with-frontmatter.md")),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(expected, actual);
|
|
}
|
|
|
|
#[test]
|
|
fn test_exclude() {
|
|
let tmp_dir = TempDir::new().expect("failed to make tempdir");
|
|
|
|
Exporter::new(
|
|
PathBuf::from("tests/testdata/input/main-samples/"),
|
|
tmp_dir.path().to_path_buf(),
|
|
)
|
|
.run()
|
|
.expect("exporter returned error");
|
|
|
|
let excluded_note = tmp_dir
|
|
.path()
|
|
.clone()
|
|
.join(PathBuf::from("excluded-note.md"));
|
|
assert!(
|
|
!excluded_note.exists(),
|
|
"exluded-note.md was found in tmpdir, but should be absent due to .export-ignore rules"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_single_file_to_dir() {
|
|
let tmp_dir = TempDir::new().expect("failed to make tempdir");
|
|
Exporter::new(
|
|
PathBuf::from("tests/testdata/input/single-file/note.md"),
|
|
tmp_dir.path().to_path_buf(),
|
|
)
|
|
.run()
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
read_to_string("tests/testdata/expected/single-file/note.md").unwrap(),
|
|
read_to_string(tmp_dir.path().clone().join(PathBuf::from("note.md"))).unwrap(),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_single_file_to_file() {
|
|
let tmp_dir = TempDir::new().expect("failed to make tempdir");
|
|
let dest = tmp_dir.path().clone().join(PathBuf::from("export.md"));
|
|
|
|
Exporter::new(
|
|
PathBuf::from("tests/testdata/input/single-file/note.md"),
|
|
dest.clone(),
|
|
)
|
|
.run()
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
read_to_string("tests/testdata/expected/single-file/note.md").unwrap(),
|
|
read_to_string(&dest).unwrap(),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_not_existing_source() {
|
|
let tmp_dir = TempDir::new().expect("failed to make tempdir");
|
|
|
|
let err = Exporter::new(
|
|
PathBuf::from("tests/testdata/no-such-file.md"),
|
|
tmp_dir.path().to_path_buf(),
|
|
)
|
|
.run()
|
|
.unwrap_err();
|
|
|
|
match err {
|
|
ExportError::PathDoesNotExist { path: _ } => {}
|
|
_ => panic!("Wrong error variant: {:?}", err),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_not_existing_destination_with_source_dir() {
|
|
let tmp_dir = TempDir::new().expect("failed to make tempdir");
|
|
|
|
let err = Exporter::new(
|
|
PathBuf::from("tests/testdata/input/main-samples/"),
|
|
tmp_dir.path().to_path_buf().join("does-not-exist"),
|
|
)
|
|
.run()
|
|
.unwrap_err();
|
|
|
|
match err {
|
|
ExportError::PathDoesNotExist { path: _ } => {}
|
|
_ => panic!("Wrong error variant: {:?}", err),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
// This test ensures that when source is a file, but destination points to a regular file
|
|
// inside of a non-existent directory, an error is raised instead of that directory path being
|
|
// created (like `mkdir -p`)
|
|
fn test_not_existing_destination_with_source_file() {
|
|
let tmp_dir = TempDir::new().expect("failed to make tempdir");
|
|
|
|
let err = Exporter::new(
|
|
PathBuf::from("tests/testdata/input/main-samples/obsidian-wikilinks.md"),
|
|
tmp_dir.path().to_path_buf().join("subdir/does-not-exist"),
|
|
)
|
|
.run()
|
|
.unwrap_err();
|
|
|
|
match err {
|
|
ExportError::PathDoesNotExist { path: _ } => {}
|
|
_ => panic!("Wrong error variant: {:?}", err),
|
|
}
|
|
}
|
|
|
|
#[cfg(not(target_os = "windows"))]
|
|
#[test]
|
|
fn test_source_no_permissions() {
|
|
let tmp_dir = TempDir::new().expect("failed to make tempdir");
|
|
let src = tmp_dir.path().to_path_buf().join("source.md");
|
|
let dest = tmp_dir.path().to_path_buf().join("dest.md");
|
|
|
|
let mut file = File::create(&src).unwrap();
|
|
file.write_all("Foo".as_bytes()).unwrap();
|
|
set_permissions(&src, Permissions::from_mode(0o000)).unwrap();
|
|
|
|
match Exporter::new(src, dest).run().unwrap_err() {
|
|
ExportError::FileExportError { path: _, source } => match *source {
|
|
ExportError::ReadError { path: _, source: _ } => {}
|
|
_ => panic!("Wrong error variant for source, got: {:?}", source),
|
|
},
|
|
err => panic!("Wrong error variant: {:?}", err),
|
|
}
|
|
}
|
|
|
|
#[cfg(not(target_os = "windows"))]
|
|
#[test]
|
|
fn test_dest_no_permissions() {
|
|
let tmp_dir = TempDir::new().expect("failed to make tempdir");
|
|
let src = tmp_dir.path().to_path_buf().join("source.md");
|
|
let dest = tmp_dir.path().to_path_buf().join("dest");
|
|
|
|
let mut file = File::create(&src).unwrap();
|
|
file.write_all("Foo".as_bytes()).unwrap();
|
|
|
|
create_dir(&dest).unwrap();
|
|
set_permissions(&dest, Permissions::from_mode(0o555)).unwrap();
|
|
|
|
match Exporter::new(src, dest).run().unwrap_err() {
|
|
ExportError::FileExportError { path: _, source } => match *source {
|
|
ExportError::WriteError { path: _, source: _ } => {}
|
|
_ => panic!("Wrong error variant for source, got: {:?}", source),
|
|
},
|
|
err => panic!("Wrong error variant: {:?}", err),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_infinite_recursion() {
|
|
let tmp_dir = TempDir::new().expect("failed to make tempdir");
|
|
|
|
let err = Exporter::new(
|
|
PathBuf::from("tests/testdata/input/infinite-recursion/"),
|
|
tmp_dir.path().to_path_buf(),
|
|
)
|
|
.run()
|
|
.unwrap_err();
|
|
|
|
match err {
|
|
ExportError::FileExportError { path: _, source } => match *source {
|
|
ExportError::RecursionLimitExceeded { .. } => {}
|
|
_ => panic!("Wrong error variant for source, got: {:?}", source),
|
|
},
|
|
err => panic!("Wrong error variant: {:?}", err),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_no_recursive_embeds() {
|
|
let tmp_dir = TempDir::new().expect("failed to make tempdir");
|
|
|
|
let mut exporter = Exporter::new(
|
|
PathBuf::from("tests/testdata/input/infinite-recursion/"),
|
|
tmp_dir.path().to_path_buf(),
|
|
);
|
|
exporter.process_embeds_recursively(false);
|
|
exporter.run().expect("exporter returned error");
|
|
|
|
assert_eq!(
|
|
read_to_string("tests/testdata/expected/infinite-recursion/Note A.md").unwrap(),
|
|
read_to_string(tmp_dir.path().clone().join(PathBuf::from("Note A.md"))).unwrap(),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_non_ascii_filenames() {
|
|
let tmp_dir = TempDir::new().expect("failed to make tempdir");
|
|
|
|
Exporter::new(
|
|
PathBuf::from("tests/testdata/input/non-ascii/"),
|
|
tmp_dir.path().to_path_buf(),
|
|
)
|
|
.run()
|
|
.expect("exporter returned error");
|
|
|
|
let walker = WalkDir::new("tests/testdata/expected/non-ascii/")
|
|
// Without sorting here, different test runs may trigger the first assertion failure in
|
|
// unpredictable order.
|
|
.sort_by(|a, b| a.file_name().cmp(b.file_name()))
|
|
.into_iter();
|
|
for entry in walker {
|
|
let entry = entry.unwrap();
|
|
if entry.metadata().unwrap().is_dir() {
|
|
continue;
|
|
};
|
|
let filename = entry.file_name().to_string_lossy().into_owned();
|
|
let expected = read_to_string(entry.path()).expect(&format!(
|
|
"failed to read {} from testdata/expected/non-ascii/",
|
|
entry.path().display()
|
|
));
|
|
let actual = read_to_string(tmp_dir.path().clone().join(PathBuf::from(&filename))).expect(
|
|
&format!("failed to read {} from temporary exportdir", filename),
|
|
);
|
|
|
|
assert_eq!(
|
|
expected, actual,
|
|
"{} does not have expected content",
|
|
filename
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_same_filename_different_directories() {
|
|
let tmp_dir = TempDir::new().expect("failed to make tempdir");
|
|
Exporter::new(
|
|
PathBuf::from("tests/testdata/input/same-filename-different-directories"),
|
|
tmp_dir.path().to_path_buf(),
|
|
)
|
|
.run()
|
|
.unwrap();
|
|
|
|
let expected = if cfg!(windows) {
|
|
read_to_string("tests/testdata/expected/same-filename-different-directories/Note.md")
|
|
.unwrap()
|
|
.replace("/", "\\")
|
|
} else {
|
|
read_to_string("tests/testdata/expected/same-filename-different-directories/Note.md")
|
|
.unwrap()
|
|
};
|
|
|
|
let actual = read_to_string(tmp_dir.path().clone().join(PathBuf::from("Note.md"))).unwrap();
|
|
assert_eq!(expected, actual);
|
|
}
|
|
|
|
/// This postprocessor replaces any instance of "foo" with "bar" in the note body.
|
|
fn foo_to_bar(
|
|
ctx: Context,
|
|
events: MarkdownEvents,
|
|
) -> (Context, MarkdownEvents, PostprocessorResult) {
|
|
let events = events
|
|
.into_iter()
|
|
.map(|event| match event {
|
|
Event::Text(text) => Event::Text(CowStr::from(text.replace("foo", "bar"))),
|
|
event => event,
|
|
})
|
|
.collect();
|
|
(ctx, events, PostprocessorResult::Continue)
|
|
}
|
|
|
|
/// This postprocessor appends "bar: baz" to frontmatter.
|
|
fn append_frontmatter(
|
|
mut ctx: Context,
|
|
events: MarkdownEvents,
|
|
) -> (Context, MarkdownEvents, PostprocessorResult) {
|
|
ctx.frontmatter.insert(
|
|
Value::String("bar".to_string()),
|
|
Value::String("baz".to_string()),
|
|
);
|
|
(ctx, events, PostprocessorResult::Continue)
|
|
}
|
|
|
|
// 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_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_postprocessor(&foo_to_bar);
|
|
exporter.add_postprocessor(&append_frontmatter);
|
|
|
|
exporter.run().unwrap();
|
|
|
|
let expected = read_to_string("tests/testdata/expected/postprocessors/Note.md").unwrap();
|
|
let actual = read_to_string(tmp_dir.path().clone().join(PathBuf::from("Note.md"))).unwrap();
|
|
assert_eq!(expected, actual);
|
|
}
|
|
|
|
#[test]
|
|
fn test_postprocessor_stophere() {
|
|
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| (ctx, mdevents, PostprocessorResult::StopHere));
|
|
exporter.add_postprocessor(&|_, _| panic!("should not be called due to above processor"));
|
|
exporter.run().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn test_postprocessor_stop_and_skip() {
|
|
let tmp_dir = TempDir::new().expect("failed to make tempdir");
|
|
let note_path = tmp_dir.path().clone().join(PathBuf::from("Note.md"));
|
|
|
|
let mut exporter = Exporter::new(
|
|
PathBuf::from("tests/testdata/input/postprocessors"),
|
|
tmp_dir.path().to_path_buf(),
|
|
);
|
|
exporter.run().unwrap();
|
|
|
|
assert!(note_path.exists());
|
|
remove_file(¬e_path).unwrap();
|
|
|
|
exporter
|
|
.add_postprocessor(&|ctx, mdevents| (ctx, mdevents, PostprocessorResult::StopAndSkipNote));
|
|
exporter.run().unwrap();
|
|
|
|
assert!(!note_path.exists());
|
|
}
|
|
|
|
#[test]
|
|
fn test_postprocessor_change_destination() {
|
|
let tmp_dir = TempDir::new().expect("failed to make tempdir");
|
|
let original_note_path = tmp_dir.path().clone().join(PathBuf::from("Note.md"));
|
|
let mut exporter = Exporter::new(
|
|
PathBuf::from("tests/testdata/input/postprocessors"),
|
|
tmp_dir.path().to_path_buf(),
|
|
);
|
|
exporter.run().unwrap();
|
|
|
|
assert!(original_note_path.exists());
|
|
remove_file(&original_note_path).unwrap();
|
|
|
|
exporter.add_postprocessor(&|mut ctx, mdevents| {
|
|
ctx.destination.set_file_name("MovedNote.md");
|
|
(ctx, mdevents, PostprocessorResult::Continue)
|
|
});
|
|
exporter.run().unwrap();
|
|
|
|
let new_note_path = tmp_dir.path().clone().join(PathBuf::from("MovedNote.md"));
|
|
assert!(!original_note_path.exists());
|
|
assert!(new_note_path.exists());
|
|
}
|