Feat: implement tag based and frontmatter based filtering

TODO: tests for tags
This commit is contained in:
Martin Heuschober 2023-10-14 00:53:19 +01:00
parent f47e86b859
commit 71aeef7c64
7 changed files with 127 additions and 74 deletions

View File

@ -138,11 +138,14 @@ To completely remove any frontmatter from exported notes, use `--frontmatter=nev
## Ignoring files ## Ignoring files
The following files are not exported by default: The following files are not exported by default:
- hidden files (can be adjusted with `--hidden`)
- files mattching a pattern listed in `.export-ignore` (can be adjusted with `--ignore-file`) * hidden files (can be adjusted with `--hidden`)
- any files that are ignored by git (can be adjusted with `--no-git`) * files mattching a pattern listed in `.export-ignore` (can be adjusted with `--ignore-file`)
- any files having `private: true` in their frontmatter (the keyword `private` can be changed with `--ignore-frontmatter-keyword`) * any files that are ignored by git (can be adjusted with `--no-git`)
* any files having `private: true` in their frontmatter (the keyword `private` can be changed with `--ignore-frontmatter-keyword`)
* using `--skip-tags foo --skip-tags bar` will skip any files that have the tags `foo` or `bar` in their frontmatter
* using `--only-tags foo --only-tags bar` will skip any files that **don't** have the tags `foo` or `bar` in their frontmatter
(See `--help` for more information). (See `--help` for more information).

View File

@ -12,9 +12,14 @@ To completely remove any frontmatter from exported notes, use `--frontmatter=nev
## Ignoring files ## Ignoring files
By default, hidden files, patterns listed in `.export-ignore` as well as any files ignored by git (if your vault is part of a git repository) will be excluded from exports. The following files are not exported by default:
* hidden files (can be adjusted with `--hidden`)
* files mattching a pattern listed in `.export-ignore` (can be adjusted with `--ignore-file`)
* any files that are ignored by git (can be adjusted with `--no-git`)
* any files having `private: true` in their frontmatter (the keyword `private` can be changed with `--ignore-frontmatter-keyword`)
* using `--skip-tags foo --skip-tags bar` will skip any files that have the tags `foo` or `bar` in their frontmatter
* using `--only-tags foo --only-tags bar` will skip any files that **don't** have the tags `foo` or `bar` in their frontmatter
These options may be adjusted with `--hidden`, `--ignore-file` and `--no-git` if desired.
(See `--help` for more information). (See `--help` for more information).
Notes linking to ignored notes will be unlinked (they'll only include the link text). Notes linking to ignored notes will be unlinked (they'll only include the link text).

View File

@ -305,6 +305,7 @@ impl<'a> Exporter<'a> {
self.frontmatter_strategy = strategy; self.frontmatter_strategy = strategy;
self self
} }
/// Set the frontmatter keyword that excludes files from being exported. /// Set the frontmatter keyword that excludes files from being exported.
pub fn ignore_frontmatter_keyword(&mut self, keyword: &'a str) -> &mut Exporter<'a> { pub fn ignore_frontmatter_keyword(&mut self, keyword: &'a str) -> &mut Exporter<'a> {
self.ignore_frontmatter_keyword = keyword; self.ignore_frontmatter_keyword = keyword;
@ -410,39 +411,34 @@ impl<'a> Exporter<'a> {
let mut context = Context::new(src.to_path_buf(), dest.to_path_buf()); let mut context = Context::new(src.to_path_buf(), dest.to_path_buf());
let (frontmatter, mut markdown_events) = self.parse_obsidian_note(src, &context)?; let (frontmatter, mut markdown_events) = self.parse_obsidian_note(src, &context)?;
match frontmatter.get(self.ignore_frontmatter_keyword) { context.frontmatter = frontmatter;
Some(serde_yaml::Value::Bool(true)) => Ok(()), for func in &self.postprocessors {
_ => { match func(&mut context, &mut markdown_events) {
context.frontmatter = frontmatter; PostprocessorResult::StopHere => break,
for func in &self.postprocessors { PostprocessorResult::StopAndSkipNote => return Ok(()),
match func(&mut context, &mut markdown_events) { PostprocessorResult::Continue => (),
PostprocessorResult::StopHere => break,
PostprocessorResult::StopAndSkipNote => return Ok(()),
PostprocessorResult::Continue => (),
}
}
let dest = context.destination;
let mut outfile = create_file(&dest)?;
let write_frontmatter = match self.frontmatter_strategy {
FrontmatterStrategy::Always => true,
FrontmatterStrategy::Never => false,
FrontmatterStrategy::Auto => !context.frontmatter.is_empty(),
};
if write_frontmatter {
let mut frontmatter_str = frontmatter_to_str(context.frontmatter)
.context(FrontMatterEncodeSnafu { path: src })?;
frontmatter_str.push('\n');
outfile
.write_all(frontmatter_str.as_bytes())
.context(WriteSnafu { path: &dest })?;
}
outfile
.write_all(render_mdevents_to_mdtext(markdown_events).as_bytes())
.context(WriteSnafu { path: &dest })?;
Ok(())
} }
} }
let dest = context.destination;
let mut outfile = create_file(&dest)?;
let write_frontmatter = match self.frontmatter_strategy {
FrontmatterStrategy::Always => true,
FrontmatterStrategy::Never => false,
FrontmatterStrategy::Auto => !context.frontmatter.is_empty(),
};
if write_frontmatter {
let mut frontmatter_str = frontmatter_to_str(context.frontmatter)
.context(FrontMatterEncodeSnafu { path: src })?;
frontmatter_str.push('\n');
outfile
.write_all(frontmatter_str.as_bytes())
.context(WriteSnafu { path: &dest })?;
}
outfile
.write_all(render_mdevents_to_mdtext(markdown_events).as_bytes())
.context(WriteSnafu { path: &dest })?;
Ok(())
} }
fn parse_obsidian_note<'b>( fn parse_obsidian_note<'b>(

View File

@ -1,7 +1,7 @@
use eyre::{eyre, Result}; use eyre::{eyre, Result};
use gumdrop::Options; use gumdrop::Options;
use obsidian_export::postprocessors::softbreaks_to_hardbreaks; use obsidian_export::{postprocessors::*, ExportError};
use obsidian_export::{ExportError, Exporter, FrontmatterStrategy, WalkOptions}; use obsidian_export::{Exporter, FrontmatterStrategy, WalkOptions};
use std::{env, path::PathBuf}; use std::{env, path::PathBuf};
const VERSION: &str = env!("CARGO_PKG_VERSION"); const VERSION: &str = env!("CARGO_PKG_VERSION");
@ -39,12 +39,18 @@ struct Opts {
)] )]
ignore_file: String, ignore_file: String,
#[options(no_short, help = "Exclude files with this tag from the export")]
skip_tags: Vec<String>,
#[options(no_short, help = "Only include files with this tag in the export")]
only_tags: Vec<String>,
#[options( #[options(
no_short, no_short,
help = "Exclude files with this keyword in the frontmatter from export", help = "Exclude files with this flag in the frontmatter from the export",
default = "private" default = "private"
)] )]
ignore_frontmatter_keyword: String, ignore_frontmatter_flag: String,
#[options(no_short, help = "Export hidden files", default = "false")] #[options(no_short, help = "Export hidden files", default = "false")]
hidden: bool, hidden: bool,
@ -63,6 +69,14 @@ struct Opts {
hard_linebreaks: bool, hard_linebreaks: bool,
} }
// fn comma_separated(input: &str) -> Result<Vec<String>> {
// Ok(if input.is_empty() {
// Vec::new()
// } else {
// input.split(",").map(|s| s.trim().to_string()).collect()
// })
// }
fn frontmatter_strategy_from_str(input: &str) -> Result<FrontmatterStrategy> { fn frontmatter_strategy_from_str(input: &str) -> Result<FrontmatterStrategy> {
match input { match input {
"auto" => Ok(FrontmatterStrategy::Auto), "auto" => Ok(FrontmatterStrategy::Auto),
@ -101,6 +115,12 @@ fn main() {
exporter.add_postprocessor(&softbreaks_to_hardbreaks); exporter.add_postprocessor(&softbreaks_to_hardbreaks);
} }
let frontmatter_flag_postprocessor = filter_by_frontmatter_flag(args.ignore_frontmatter_flag);
exporter.add_postprocessor(&frontmatter_flag_postprocessor);
let tags_postprocessor = filter_by_tags(args.skip_tags, args.only_tags);
exporter.add_postprocessor(&tags_postprocessor);
if let Some(path) = args.start_at { if let Some(path) = args.start_at {
exporter.start_at(path); exporter.start_at(path);
} }

View File

@ -2,6 +2,7 @@
use super::{Context, MarkdownEvents, PostprocessorResult}; use super::{Context, MarkdownEvents, PostprocessorResult};
use pulldown_cmark::Event; use pulldown_cmark::Event;
use serde_yaml::Value;
/// This postprocessor converts all soft line breaks to hard line breaks. Enabling this mimics /// This postprocessor converts all soft line breaks to hard line breaks. Enabling this mimics
/// Obsidian's _'Strict line breaks'_ setting. /// Obsidian's _'Strict line breaks'_ setting.
@ -16,3 +17,43 @@ pub fn softbreaks_to_hardbreaks(
} }
PostprocessorResult::Continue PostprocessorResult::Continue
} }
pub fn filter_by_tags(
skip_tags: Vec<String>,
only_tags: Vec<String>,
) -> impl Fn(&mut Context, &mut MarkdownEvents) -> PostprocessorResult {
move |context: &mut Context, _events: &mut MarkdownEvents| -> PostprocessorResult {
if !skip_tags.is_empty() || !only_tags.is_empty() {
match context.frontmatter.get("tags") {
Some(Value::Sequence(tags)) => {
let skip = skip_tags
.iter()
.any(|tag| tags.contains(&Value::String(tag.to_string())));
let include = only_tags.is_empty()
|| only_tags
.iter()
.any(|tag| tags.contains(&Value::String(tag.to_string())));
if skip || !include {
PostprocessorResult::StopAndSkipNote
} else {
PostprocessorResult::Continue
}
}
_ => PostprocessorResult::Continue,
}
} else {
PostprocessorResult::Continue
}
}
}
pub fn filter_by_frontmatter_flag(
flag: String,
) -> impl Fn(&mut Context, &mut MarkdownEvents) -> PostprocessorResult {
move |context: &mut Context, _events: &mut MarkdownEvents| -> PostprocessorResult {
match context.frontmatter.get(flag.as_str()) {
Some(Value::Bool(true)) => PostprocessorResult::StopAndSkipNote,
_ => PostprocessorResult::Continue,
}
}
}

View File

@ -37,7 +37,7 @@ fn test_main_variants_with_default_options() {
entry.path().display() entry.path().display()
) )
}); });
let actual = read_to_string(tmp_dir.path().clone().join(PathBuf::from(&filename))) let actual = read_to_string(tmp_dir.path().join(PathBuf::from(&filename)))
.unwrap_or_else(|_| panic!("failed to read {} from temporary exportdir", filename)); .unwrap_or_else(|_| panic!("failed to read {} from temporary exportdir", filename));
assert_eq!( assert_eq!(
@ -62,7 +62,6 @@ fn test_frontmatter_never() {
let actual = read_to_string( let actual = read_to_string(
tmp_dir tmp_dir
.path() .path()
.clone()
.join(PathBuf::from("note-with-frontmatter.md")), .join(PathBuf::from("note-with-frontmatter.md")),
) )
.unwrap(); .unwrap();
@ -85,7 +84,6 @@ fn test_frontmatter_always() {
let actual = read_to_string( let actual = read_to_string(
tmp_dir tmp_dir
.path() .path()
.clone()
.join(PathBuf::from("note-without-frontmatter.md")), .join(PathBuf::from("note-without-frontmatter.md")),
) )
.unwrap(); .unwrap();
@ -96,7 +94,6 @@ fn test_frontmatter_always() {
let actual = read_to_string( let actual = read_to_string(
tmp_dir tmp_dir
.path() .path()
.clone()
.join(PathBuf::from("note-with-frontmatter.md")), .join(PathBuf::from("note-with-frontmatter.md")),
) )
.unwrap(); .unwrap();
@ -114,10 +111,7 @@ fn test_exclude() {
.run() .run()
.expect("exporter returned error"); .expect("exporter returned error");
let excluded_note = tmp_dir let excluded_note = tmp_dir.path().join(PathBuf::from("excluded-note.md"));
.path()
.clone()
.join(PathBuf::from("excluded-note.md"));
assert!( assert!(
!excluded_note.exists(), !excluded_note.exists(),
"exluded-note.md was found in tmpdir, but should be absent due to .export-ignore rules" "exluded-note.md was found in tmpdir, but should be absent due to .export-ignore rules"
@ -136,14 +130,14 @@ fn test_single_file_to_dir() {
assert_eq!( assert_eq!(
read_to_string("tests/testdata/expected/single-file/note.md").unwrap(), read_to_string("tests/testdata/expected/single-file/note.md").unwrap(),
read_to_string(tmp_dir.path().clone().join(PathBuf::from("note.md"))).unwrap(), read_to_string(tmp_dir.path().join(PathBuf::from("note.md"))).unwrap(),
); );
} }
#[test] #[test]
fn test_single_file_to_file() { fn test_single_file_to_file() {
let tmp_dir = TempDir::new().expect("failed to make tempdir"); let tmp_dir = TempDir::new().expect("failed to make tempdir");
let dest = tmp_dir.path().clone().join(PathBuf::from("export.md")); let dest = tmp_dir.path().join(PathBuf::from("export.md"));
Exporter::new( Exporter::new(
PathBuf::from("tests/testdata/input/single-file/note.md"), PathBuf::from("tests/testdata/input/single-file/note.md"),
@ -178,7 +172,7 @@ fn test_start_at_subdir() {
assert_eq!( assert_eq!(
expected, expected,
read_to_string(tmp_dir.path().clone().join(PathBuf::from("Note B.md"))).unwrap(), read_to_string(tmp_dir.path().join(PathBuf::from("Note B.md"))).unwrap(),
); );
} }
@ -204,14 +198,14 @@ fn test_start_at_file_within_subdir_destination_is_dir() {
assert_eq!( assert_eq!(
expected, expected,
read_to_string(tmp_dir.path().clone().join(PathBuf::from("Note B.md"))).unwrap(), read_to_string(tmp_dir.path().join(PathBuf::from("Note B.md"))).unwrap(),
); );
} }
#[test] #[test]
fn test_start_at_file_within_subdir_destination_is_file() { fn test_start_at_file_within_subdir_destination_is_file() {
let tmp_dir = TempDir::new().expect("failed to make tempdir"); let tmp_dir = TempDir::new().expect("failed to make tempdir");
let dest = tmp_dir.path().clone().join(PathBuf::from("note.md")); let dest = tmp_dir.path().join(PathBuf::from("note.md"));
let mut exporter = Exporter::new( let mut exporter = Exporter::new(
PathBuf::from("tests/testdata/input/start-at/"), PathBuf::from("tests/testdata/input/start-at/"),
dest.clone(), dest.clone(),
@ -360,7 +354,7 @@ fn test_no_recursive_embeds() {
assert_eq!( assert_eq!(
read_to_string("tests/testdata/expected/infinite-recursion/Note A.md").unwrap(), 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(), read_to_string(tmp_dir.path().join(PathBuf::from("Note A.md"))).unwrap(),
); );
} }
@ -392,7 +386,7 @@ fn test_non_ascii_filenames() {
entry.path().display() entry.path().display()
) )
}); });
let actual = read_to_string(tmp_dir.path().clone().join(PathBuf::from(&filename))) let actual = read_to_string(tmp_dir.path().join(PathBuf::from(&filename)))
.unwrap_or_else(|_| panic!("failed to read {} from temporary exportdir", filename)); .unwrap_or_else(|_| panic!("failed to read {} from temporary exportdir", filename));
assert_eq!( assert_eq!(
@ -422,7 +416,7 @@ fn test_same_filename_different_directories() {
.unwrap() .unwrap()
}; };
let actual = read_to_string(tmp_dir.path().clone().join(PathBuf::from("Note.md"))).unwrap(); let actual = read_to_string(tmp_dir.path().join(PathBuf::from("Note.md"))).unwrap();
assert_eq!(expected, actual); assert_eq!(expected, actual);
} }
@ -452,7 +446,7 @@ fn test_ignore_frontmatter_default_keyword() {
entry.path().display() entry.path().display()
) )
}); });
let actual = read_to_string(tmp_dir.path().clone().join(PathBuf::from(&filename))) let actual = read_to_string(tmp_dir.path().join(PathBuf::from(&filename)))
.unwrap_or_else(|_| panic!("failed to read {} from temporary exportdir", filename)); .unwrap_or_else(|_| panic!("failed to read {} from temporary exportdir", filename));
assert_eq!( assert_eq!(
@ -490,7 +484,7 @@ fn test_ignore_frontmatter_specific_keyword() {
entry.path().display() entry.path().display()
) )
}); });
let actual = read_to_string(tmp_dir.path().clone().join(PathBuf::from(&filename))) let actual = read_to_string(tmp_dir.path().join(PathBuf::from(&filename)))
.unwrap_or_else(|_| panic!("failed to read {} from temporary exportdir", filename)); .unwrap_or_else(|_| panic!("failed to read {} from temporary exportdir", filename));
assert_eq!( assert_eq!(

View File

@ -44,7 +44,7 @@ fn test_postprocessors() {
exporter.run().unwrap(); exporter.run().unwrap();
let expected = read_to_string("tests/testdata/expected/postprocessors/Note.md").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(); let actual = read_to_string(tmp_dir.path().join(PathBuf::from("Note.md"))).unwrap();
assert_eq!(expected, actual); assert_eq!(expected, actual);
} }
@ -66,7 +66,7 @@ fn test_postprocessor_stophere() {
#[test] #[test]
fn test_postprocessor_stop_and_skip() { fn test_postprocessor_stop_and_skip() {
let tmp_dir = TempDir::new().expect("failed to make tempdir"); let tmp_dir = TempDir::new().expect("failed to make tempdir");
let note_path = tmp_dir.path().clone().join(PathBuf::from("Note.md")); let note_path = tmp_dir.path().join(PathBuf::from("Note.md"));
let mut exporter = Exporter::new( let mut exporter = Exporter::new(
PathBuf::from("tests/testdata/input/postprocessors"), PathBuf::from("tests/testdata/input/postprocessors"),
@ -86,7 +86,7 @@ fn test_postprocessor_stop_and_skip() {
#[test] #[test]
fn test_postprocessor_change_destination() { fn test_postprocessor_change_destination() {
let tmp_dir = TempDir::new().expect("failed to make tempdir"); let tmp_dir = TempDir::new().expect("failed to make tempdir");
let original_note_path = tmp_dir.path().clone().join(PathBuf::from("Note.md")); let original_note_path = tmp_dir.path().join(PathBuf::from("Note.md"));
let mut exporter = Exporter::new( let mut exporter = Exporter::new(
PathBuf::from("tests/testdata/input/postprocessors"), PathBuf::from("tests/testdata/input/postprocessors"),
tmp_dir.path().to_path_buf(), tmp_dir.path().to_path_buf(),
@ -102,7 +102,7 @@ fn test_postprocessor_change_destination() {
}); });
exporter.run().unwrap(); exporter.run().unwrap();
let new_note_path = tmp_dir.path().clone().join(PathBuf::from("MovedNote.md")); let new_note_path = tmp_dir.path().join(PathBuf::from("MovedNote.md"));
assert!(!original_note_path.exists()); assert!(!original_note_path.exists());
assert!(new_note_path.exists()); assert!(new_note_path.exists());
} }
@ -131,7 +131,7 @@ fn test_postprocessor_stateful_callback() {
exporter.run().unwrap(); exporter.run().unwrap();
let expected = tmp_dir.path().clone(); let expected = tmp_dir.path();
let parents = parents.lock().unwrap(); let parents = parents.lock().unwrap();
println!("{:?}", parents); println!("{:?}", parents);
@ -158,7 +158,7 @@ fn test_embed_postprocessors() {
let expected = let expected =
read_to_string("tests/testdata/expected/postprocessors/Note_embed_postprocess_only.md") read_to_string("tests/testdata/expected/postprocessors/Note_embed_postprocess_only.md")
.unwrap(); .unwrap();
let actual = read_to_string(tmp_dir.path().clone().join(PathBuf::from("Note.md"))).unwrap(); let actual = read_to_string(tmp_dir.path().join(PathBuf::from("Note.md"))).unwrap();
assert_eq!(expected, actual); assert_eq!(expected, actual);
} }
@ -178,7 +178,7 @@ fn test_embed_postprocessors_stop_and_skip() {
let expected = let expected =
read_to_string("tests/testdata/expected/postprocessors/Note_embed_stop_and_skip.md") read_to_string("tests/testdata/expected/postprocessors/Note_embed_stop_and_skip.md")
.unwrap(); .unwrap();
let actual = read_to_string(tmp_dir.path().clone().join(PathBuf::from("Note.md"))).unwrap(); let actual = read_to_string(tmp_dir.path().join(PathBuf::from("Note.md"))).unwrap();
assert_eq!(expected, actual); assert_eq!(expected, actual);
} }
@ -244,12 +244,6 @@ fn test_softbreaks_to_hardbreaks() {
let expected = let expected =
read_to_string("tests/testdata/expected/postprocessors/hard_linebreaks.md").unwrap(); read_to_string("tests/testdata/expected/postprocessors/hard_linebreaks.md").unwrap();
let actual = read_to_string( let actual = read_to_string(tmp_dir.path().join(PathBuf::from("hard_linebreaks.md"))).unwrap();
tmp_dir
.path()
.clone()
.join(PathBuf::from("hard_linebreaks.md")),
)
.unwrap();
assert_eq!(expected, actual); assert_eq!(expected, actual);
} }