Feat: implement tag based and frontmatter based filtering
TODO: tests for tags
This commit is contained in:
parent
f47e86b859
commit
71aeef7c64
13
README.md
13
README.md
|
@ -138,11 +138,14 @@ To completely remove any frontmatter from exported notes, use `--frontmatter=nev
|
|||
|
||||
## Ignoring files
|
||||
|
||||
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`)
|
||||
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
|
||||
|
||||
(See `--help` for more information).
|
||||
|
||||
|
|
|
@ -12,9 +12,14 @@ To completely remove any frontmatter from exported notes, use `--frontmatter=nev
|
|||
|
||||
## 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).
|
||||
|
||||
Notes linking to ignored notes will be unlinked (they'll only include the link text).
|
||||
|
|
58
src/lib.rs
58
src/lib.rs
|
@ -305,6 +305,7 @@ impl<'a> Exporter<'a> {
|
|||
self.frontmatter_strategy = strategy;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the frontmatter keyword that excludes files from being exported.
|
||||
pub fn ignore_frontmatter_keyword(&mut self, keyword: &'a str) -> &mut Exporter<'a> {
|
||||
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 (frontmatter, mut markdown_events) = self.parse_obsidian_note(src, &context)?;
|
||||
match frontmatter.get(self.ignore_frontmatter_keyword) {
|
||||
Some(serde_yaml::Value::Bool(true)) => Ok(()),
|
||||
_ => {
|
||||
context.frontmatter = frontmatter;
|
||||
for func in &self.postprocessors {
|
||||
match func(&mut context, &mut markdown_events) {
|
||||
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(())
|
||||
context.frontmatter = frontmatter;
|
||||
for func in &self.postprocessors {
|
||||
match func(&mut context, &mut markdown_events) {
|
||||
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(())
|
||||
}
|
||||
|
||||
fn parse_obsidian_note<'b>(
|
||||
|
|
28
src/main.rs
28
src/main.rs
|
@ -1,7 +1,7 @@
|
|||
use eyre::{eyre, Result};
|
||||
use gumdrop::Options;
|
||||
use obsidian_export::postprocessors::softbreaks_to_hardbreaks;
|
||||
use obsidian_export::{ExportError, Exporter, FrontmatterStrategy, WalkOptions};
|
||||
use obsidian_export::{postprocessors::*, ExportError};
|
||||
use obsidian_export::{Exporter, FrontmatterStrategy, WalkOptions};
|
||||
use std::{env, path::PathBuf};
|
||||
|
||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
@ -39,12 +39,18 @@ struct Opts {
|
|||
)]
|
||||
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(
|
||||
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"
|
||||
)]
|
||||
ignore_frontmatter_keyword: String,
|
||||
ignore_frontmatter_flag: String,
|
||||
|
||||
#[options(no_short, help = "Export hidden files", default = "false")]
|
||||
hidden: bool,
|
||||
|
@ -63,6 +69,14 @@ struct Opts {
|
|||
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> {
|
||||
match input {
|
||||
"auto" => Ok(FrontmatterStrategy::Auto),
|
||||
|
@ -101,6 +115,12 @@ fn main() {
|
|||
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 {
|
||||
exporter.start_at(path);
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
use super::{Context, MarkdownEvents, PostprocessorResult};
|
||||
use pulldown_cmark::Event;
|
||||
use serde_yaml::Value;
|
||||
|
||||
/// This postprocessor converts all soft line breaks to hard line breaks. Enabling this mimics
|
||||
/// Obsidian's _'Strict line breaks'_ setting.
|
||||
|
@ -16,3 +17,43 @@ pub fn softbreaks_to_hardbreaks(
|
|||
}
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -37,7 +37,7 @@ fn test_main_variants_with_default_options() {
|
|||
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));
|
||||
|
||||
assert_eq!(
|
||||
|
@ -62,7 +62,6 @@ fn test_frontmatter_never() {
|
|||
let actual = read_to_string(
|
||||
tmp_dir
|
||||
.path()
|
||||
.clone()
|
||||
.join(PathBuf::from("note-with-frontmatter.md")),
|
||||
)
|
||||
.unwrap();
|
||||
|
@ -85,7 +84,6 @@ fn test_frontmatter_always() {
|
|||
let actual = read_to_string(
|
||||
tmp_dir
|
||||
.path()
|
||||
.clone()
|
||||
.join(PathBuf::from("note-without-frontmatter.md")),
|
||||
)
|
||||
.unwrap();
|
||||
|
@ -96,7 +94,6 @@ fn test_frontmatter_always() {
|
|||
let actual = read_to_string(
|
||||
tmp_dir
|
||||
.path()
|
||||
.clone()
|
||||
.join(PathBuf::from("note-with-frontmatter.md")),
|
||||
)
|
||||
.unwrap();
|
||||
|
@ -114,10 +111,7 @@ fn test_exclude() {
|
|||
.run()
|
||||
.expect("exporter returned error");
|
||||
|
||||
let excluded_note = tmp_dir
|
||||
.path()
|
||||
.clone()
|
||||
.join(PathBuf::from("excluded-note.md"));
|
||||
let excluded_note = tmp_dir.path().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"
|
||||
|
@ -136,14 +130,14 @@ fn test_single_file_to_dir() {
|
|||
|
||||
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(),
|
||||
read_to_string(tmp_dir.path().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"));
|
||||
let dest = tmp_dir.path().join(PathBuf::from("export.md"));
|
||||
|
||||
Exporter::new(
|
||||
PathBuf::from("tests/testdata/input/single-file/note.md"),
|
||||
|
@ -178,7 +172,7 @@ fn test_start_at_subdir() {
|
|||
|
||||
assert_eq!(
|
||||
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!(
|
||||
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]
|
||||
fn test_start_at_file_within_subdir_destination_is_file() {
|
||||
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(
|
||||
PathBuf::from("tests/testdata/input/start-at/"),
|
||||
dest.clone(),
|
||||
|
@ -360,7 +354,7 @@ fn test_no_recursive_embeds() {
|
|||
|
||||
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(),
|
||||
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()
|
||||
)
|
||||
});
|
||||
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));
|
||||
|
||||
assert_eq!(
|
||||
|
@ -422,7 +416,7 @@ fn test_same_filename_different_directories() {
|
|||
.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);
|
||||
}
|
||||
|
||||
|
@ -452,7 +446,7 @@ fn test_ignore_frontmatter_default_keyword() {
|
|||
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));
|
||||
|
||||
assert_eq!(
|
||||
|
@ -490,7 +484,7 @@ fn test_ignore_frontmatter_specific_keyword() {
|
|||
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));
|
||||
|
||||
assert_eq!(
|
||||
|
|
|
@ -44,7 +44,7 @@ fn test_postprocessors() {
|
|||
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();
|
||||
let actual = read_to_string(tmp_dir.path().join(PathBuf::from("Note.md"))).unwrap();
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
|
@ -66,7 +66,7 @@ fn test_postprocessor_stophere() {
|
|||
#[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 note_path = tmp_dir.path().join(PathBuf::from("Note.md"));
|
||||
|
||||
let mut exporter = Exporter::new(
|
||||
PathBuf::from("tests/testdata/input/postprocessors"),
|
||||
|
@ -86,7 +86,7 @@ fn test_postprocessor_stop_and_skip() {
|
|||
#[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 original_note_path = tmp_dir.path().join(PathBuf::from("Note.md"));
|
||||
let mut exporter = Exporter::new(
|
||||
PathBuf::from("tests/testdata/input/postprocessors"),
|
||||
tmp_dir.path().to_path_buf(),
|
||||
|
@ -102,7 +102,7 @@ fn test_postprocessor_change_destination() {
|
|||
});
|
||||
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!(new_note_path.exists());
|
||||
}
|
||||
|
@ -131,7 +131,7 @@ fn test_postprocessor_stateful_callback() {
|
|||
|
||||
exporter.run().unwrap();
|
||||
|
||||
let expected = tmp_dir.path().clone();
|
||||
let expected = tmp_dir.path();
|
||||
|
||||
let parents = parents.lock().unwrap();
|
||||
println!("{:?}", parents);
|
||||
|
@ -158,7 +158,7 @@ fn test_embed_postprocessors() {
|
|||
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();
|
||||
let actual = read_to_string(tmp_dir.path().join(PathBuf::from("Note.md"))).unwrap();
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
|
@ -178,7 +178,7 @@ fn test_embed_postprocessors_stop_and_skip() {
|
|||
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();
|
||||
let actual = read_to_string(tmp_dir.path().join(PathBuf::from("Note.md"))).unwrap();
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
|
@ -244,12 +244,6 @@ fn test_softbreaks_to_hardbreaks() {
|
|||
|
||||
let expected =
|
||||
read_to_string("tests/testdata/expected/postprocessors/hard_linebreaks.md").unwrap();
|
||||
let actual = read_to_string(
|
||||
tmp_dir
|
||||
.path()
|
||||
.clone()
|
||||
.join(PathBuf::from("hard_linebreaks.md")),
|
||||
)
|
||||
.unwrap();
|
||||
let actual = read_to_string(tmp_dir.path().join(PathBuf::from("hard_linebreaks.md"))).unwrap();
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue