diff --git a/src/lib.rs b/src/lib.rs index 39697a4..4fe217b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -243,6 +243,7 @@ pub struct Exporter<'a> { walk_options: WalkOptions<'a>, process_embeds_recursively: bool, preserve_mtime: bool, + add_titles: bool, postprocessors: Vec<&'a Postprocessor<'a>>, embed_postprocessors: Vec<&'a Postprocessor<'a>>, } @@ -260,6 +261,7 @@ impl fmt::Debug for Exporter<'_> { &self.process_embeds_recursively, ) .field("preserve_mtime", &self.preserve_mtime) + .field("add_titles", &self.add_titles) .field( "postprocessors", &format!("<{} postprocessors active>", self.postprocessors.len()), @@ -288,6 +290,7 @@ impl<'a> Exporter<'a> { walk_options: WalkOptions::default(), process_embeds_recursively: true, preserve_mtime: false, + add_titles: false, vault_contents: None, postprocessors: vec![], embed_postprocessors: vec![], @@ -339,6 +342,23 @@ impl<'a> Exporter<'a> { self } + /// Enable or disable addition of title headings to the top of each parsed note + /// + /// When this option is enabled, each parsed note will be considered to start with a heading: + /// + /// # Title-of-note + /// + /// even when no such line is present in the source file. The title is inferred based on the + /// filename of the note. + /// + /// This option makes heavily nested note embeds make more sense in the exported document, + /// since it shows which note the embedded content comes from. It loosely matches the behaviour + /// of the mainline Obsidian UI when viewing notes in preview mode. + pub fn add_titles(&mut self, add_titles: bool) -> &mut Exporter<'a> { + self.add_titles = add_titles; + 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 { @@ -504,6 +524,21 @@ impl<'a> Exporter<'a> { // Most of the time, a reference triggers 5 events: [ or ![, [, , ], ] let mut buffer = Vec::with_capacity(5); + if self.add_titles { + // Ensure that each (possibly embedded) note starts with a reasonable top-level heading + let note_name = infer_note_title_from_path(path); + let h1_tag = Tag::Heading { + level: HeadingLevel::H1, + id: None, + classes: vec![], + attrs: vec![], + }; + + events.push(Event::Start(h1_tag.clone())); + events.push(Event::Text(note_name)); + events.push(Event::End(h1_tag.to_end())); + } + let mut parser = Parser::new_ext(&content, parser_options); 'outer: while let Some(event) = parser.next() { // When encountering a metadata block (frontmatter), collect all events until getting @@ -827,6 +862,15 @@ fn lookup_filename_in_vault<'a>( }) } +fn infer_note_title_from_path(path: &Path) -> CowStr<'_> { + const PLACEHOLDER_TITLE: &str = "invalid-note-title"; + + match path.file_stem() { + None => CowStr::from(PLACEHOLDER_TITLE), + Some(s) => CowStr::from(s.to_string_lossy().into_owned()), + } +} + fn render_mdevents_to_mdtext(markdown: &MarkdownEvents<'_>) -> String { let mut buffer = String::new(); cmark_with_options( diff --git a/src/main.rs b/src/main.rs index bdab2d2..c84f45e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -70,6 +70,13 @@ struct Opts { default = "false" )] hard_linebreaks: bool, + + #[options( + no_short, + help = "Add a heading to the beginning of each note based on its filename", + default = "false" + )] + add_titles: 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.add_titles(args.add_titles); exporter.walk_options(walk_options); if args.hard_linebreaks { diff --git a/tests/export_test.rs b/tests/export_test.rs index 8b81721..8e523df 100644 --- a/tests/export_test.rs +++ b/tests/export_test.rs @@ -398,6 +398,25 @@ fn test_no_preserve_mtime() { assert_ne!(src_meta.modified().unwrap(), dest_meta.modified().unwrap()); } +#[test] +fn test_add_titles() { + let tmp_dir = TempDir::new().expect("failed to make tempdir"); + + let mut exporter = Exporter::new( + // Github bug #26 causes embeds not to work with single-file exports. As a workaround, we + // export a whole directory in this test. + PathBuf::from("tests/testdata/input/add-titles/"), + tmp_dir.path().to_path_buf(), + ); + exporter.add_titles(true); + exporter.run().expect("exporter returned error"); + + assert_eq!( + read_to_string("tests/testdata/expected/add-titles/Main note.md").unwrap(), + read_to_string(tmp_dir.path().join(PathBuf::from("Main note.md"))).unwrap(), + ); +} + #[test] fn test_non_ascii_filenames() { let tmp_dir = TempDir::new().expect("failed to make tempdir"); diff --git a/tests/testdata/expected/add-titles/Main note.md b/tests/testdata/expected/add-titles/Main note.md new file mode 100644 index 0000000..98bd5dd --- /dev/null +++ b/tests/testdata/expected/add-titles/Main note.md @@ -0,0 +1,7 @@ +# Main note + +# Sub note + +# Sub sub note + +No more notes diff --git a/tests/testdata/input/add-titles/Main note.md b/tests/testdata/input/add-titles/Main note.md new file mode 100644 index 0000000..f7da68c --- /dev/null +++ b/tests/testdata/input/add-titles/Main note.md @@ -0,0 +1 @@ +![[Sub note]] diff --git a/tests/testdata/input/add-titles/Sub note.md b/tests/testdata/input/add-titles/Sub note.md new file mode 100644 index 0000000..a9a1937 --- /dev/null +++ b/tests/testdata/input/add-titles/Sub note.md @@ -0,0 +1 @@ +![[Sub sub note]] diff --git a/tests/testdata/input/add-titles/Sub sub note.md b/tests/testdata/input/add-titles/Sub sub note.md new file mode 100644 index 0000000..3a17a64 --- /dev/null +++ b/tests/testdata/input/add-titles/Sub sub note.md @@ -0,0 +1 @@ +No more notes