diff --git a/CHANGELOG.md b/CHANGELOG.md index f0a6943..b85672a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased ### Added - Nested tag support - "parent/child" tags used with the "Tag" hierarchy create nested folders. +- Separate make and index commands, allow filtering annotations in both (Issue [#90](https://github.com/out-of-cheese-error/gooseberry/issues/90)) ## [0.8.1] - 2021-03-14 ### Changed diff --git a/README.md b/README.md index 34d897f..7435db8 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,10 @@ Gooseberry takes annotations from a given Hypothesis group which you can create/ You can set all the below options at once by running `gooseberry config kb all` or changing the corresponding keys in the config file (found at `gooseberry config location`) +Generate knowledge base files using `gooseberry make` - this command has options to filter annotations, and to clear the directory before generating (`-c` or `--clear`). +By default, it also generates an index file (configured by the `index` and `link` configuration options below) using the filtered annotations - this can be disabled with `-n` or `--no-index`. +Use `gooseberry index` to generate just the index file, this command also has annotation filtering options. + #### Knowledge base directory `gooseberry config kb directory` diff --git a/src/gooseberry/cli.rs b/src/gooseberry/cli.rs index 5f14e89..0369f1f 100644 --- a/src/gooseberry/cli.rs +++ b/src/gooseberry/cli.rs @@ -74,11 +74,24 @@ pub enum GooseberrySubcommand { #[structopt(use_delimiter = true)] ids: Vec, }, - /// Create your knowledge-base text files + /// Create knowledge-base text files using optionally filtered annotations Make { - /// Don't ask for confirmation before clearing knowledge base directory + #[structopt(flatten)] + filters: Filters, + /// Clear knowledge base directory before recreating #[structopt(short, long)] + clear: bool, + /// Don't ask for confirmation before clearing + #[structopt(short, long, requires = "clear")] force: bool, + /// Don't make index file + #[structopt(short, long)] + no_index: bool, + }, + /// Create an index file using hierarchy and optionally filtered annotations + Index { + #[structopt(flatten)] + filters: Filters, }, /// Generate shell completions Complete { diff --git a/src/gooseberry/knowledge_base.rs b/src/gooseberry/knowledge_base.rs index 6a186c5..36b2663 100644 --- a/src/gooseberry/knowledge_base.rs +++ b/src/gooseberry/knowledge_base.rs @@ -158,6 +158,92 @@ pub struct PageTemplate { pub raw_annotations: Vec, } +fn group_annotations_by_order( + order: OrderBy, + annotations: Vec, +) -> HashMap> { + let mut order_to_annotations = HashMap::new(); + match order { + OrderBy::Tag => { + let path_separator = &std::path::MAIN_SEPARATOR.to_string(); + for annotation in annotations { + if annotation.annotation.tags.is_empty() { + order_to_annotations + .entry(EMPTY_TAG.to_owned()) + .or_insert_with(Vec::new) + .push(annotation); + } else { + for tag in &annotation.annotation.tags { + let tag = tag.replace("/", path_separator); + order_to_annotations + .entry(tag) + .or_insert_with(Vec::new) + .push(annotation.clone()); + } + } + } + } + OrderBy::URI => { + for annotation in annotations { + order_to_annotations + .entry(uri_to_filename(&annotation.annotation.uri)) + .or_insert_with(Vec::new) + .push(annotation); + } + } + OrderBy::Title => { + for annotation in annotations { + order_to_annotations + .entry(sanitize(&annotation.title)) + .or_insert_with(Vec::new) + .push(annotation); + } + } + OrderBy::BaseURI => { + for annotation in annotations { + order_to_annotations + .entry(uri_to_filename(&annotation.base_uri)) + .or_insert_with(Vec::new) + .push(annotation); + } + } + OrderBy::ID => { + for annotation in annotations { + order_to_annotations + .entry(annotation.annotation.id.to_string()) + .or_insert_with(Vec::new) + .push(annotation); + } + } + OrderBy::Empty => panic!("Shouldn't happen"), + _ => panic!("{} shouldn't occur in hierarchy", order), + } + order_to_annotations +} + +fn sort_annotations(sort: &[OrderBy], annotations: &mut Vec) { + annotations.sort_by(|a, b| { + sort.iter().fold(Ordering::Equal, |acc, &field| { + acc.then_with(|| match field { + OrderBy::Tag => a + .annotation + .tags + .join(",") + .cmp(&b.annotation.tags.join(",")), + OrderBy::URI => clean_uri(&a.annotation.uri).cmp(&clean_uri(&b.annotation.uri)), + OrderBy::BaseURI => clean_uri(&a.base_uri).cmp(&clean_uri(&b.base_uri)), + OrderBy::Title => a.title.cmp(&b.title), + OrderBy::ID => a.annotation.id.cmp(&b.annotation.id), + OrderBy::Created => format!("{}", a.annotation.created.format("%+")) + .cmp(&format!("{}", b.annotation.created.format("%+"))), + OrderBy::Updated => format!("{}", a.annotation.updated.format("%+")) + .cmp(&format!("{}", b.annotation.updated.format("%+"))), + OrderBy::Empty => panic!("Shouldn't happen"), + }) + }) + }); +} + /// ## Markdown generation /// functions related to generating the `mdBook` wiki impl Gooseberry { @@ -181,10 +267,18 @@ impl Gooseberry { } /// Make mdBook wiki - pub async fn make(&mut self, force: bool) -> color_eyre::Result<()> { + pub async fn make( + &mut self, + filters: Filters, + clear: bool, + force: bool, + make: bool, + index: bool, + ) -> color_eyre::Result<()> { self.configure_kb()?; let kb_dir = self.config.kb_dir.as_ref().unwrap(); - if kb_dir.exists() + if clear + && kb_dir.exists() && (force || Confirm::with_theme(&ColorfulTheme::default()) .with_prompt("Clear knowledge base directory?") @@ -194,113 +288,26 @@ impl Gooseberry { fs::remove_dir_all(&kb_dir)?; fs::create_dir_all(&kb_dir)?; } - self.make_book(&kb_dir).await?; + self.make_book(filters, &kb_dir, make, index).await?; Ok(()) } - fn group_annotations_by_order( - &self, - order: OrderBy, - annotations: Vec, - ) -> HashMap> { - let mut order_to_annotations = HashMap::new(); - match order { - OrderBy::Tag => { - let path_separator = &std::path::MAIN_SEPARATOR.to_string(); - for annotation in annotations { - if annotation.annotation.tags.is_empty() { - order_to_annotations - .entry(EMPTY_TAG.to_owned()) - .or_insert_with(Vec::new) - .push(annotation); - } else { - for tag in &annotation.annotation.tags { - let tag = tag.replace("/", path_separator); - order_to_annotations - .entry(tag) - .or_insert_with(Vec::new) - .push(annotation.clone()); - } - } - } - } - OrderBy::URI => { - for annotation in annotations { - order_to_annotations - .entry(uri_to_filename(&annotation.annotation.uri)) - .or_insert_with(Vec::new) - .push(annotation); - } - } - OrderBy::Title => { - for annotation in annotations { - order_to_annotations - .entry(sanitize(&annotation.title)) - .or_insert_with(Vec::new) - .push(annotation); - } - } - OrderBy::BaseURI => { - for annotation in annotations { - order_to_annotations - .entry(uri_to_filename(&annotation.base_uri)) - .or_insert_with(Vec::new) - .push(annotation); - } - } - OrderBy::ID => { - for annotation in annotations { - order_to_annotations - .entry(annotation.annotation.id.to_string()) - .or_insert_with(Vec::new) - .push(annotation); - } - } - OrderBy::Empty => panic!("Shouldn't happen"), - _ => panic!("{} shouldn't occur in hierarchy", order), - } - order_to_annotations - } - - fn sort_annotations(&self, annotations: &mut Vec) { - annotations.sort_by(|a, b| { - self.config - .sort - .as_ref() - .unwrap_or(&vec![OrderBy::Created]) - .iter() - .fold(Ordering::Equal, |acc, &field| { - acc.then_with(|| match field { - OrderBy::Tag => a - .annotation - .tags - .join(",") - .cmp(&b.annotation.tags.join(",")), - OrderBy::URI => { - clean_uri(&a.annotation.uri).cmp(&clean_uri(&b.annotation.uri)) - } - OrderBy::BaseURI => clean_uri(&a.base_uri).cmp(&clean_uri(&b.base_uri)), - OrderBy::Title => a.title.cmp(&b.title), - OrderBy::ID => a.annotation.id.cmp(&b.annotation.id), - OrderBy::Created => format!("{}", a.annotation.created.format("%+")) - .cmp(&format!("{}", b.annotation.created.format("%+"))), - OrderBy::Updated => format!("{}", a.annotation.updated.format("%+")) - .cmp(&format!("{}", b.annotation.updated.format("%+"))), - OrderBy::Empty => panic!("Shouldn't happen"), - }) - }) - }); - } /// Write markdown files for wiki - async fn make_book(&self, src_dir: &Path) -> color_eyre::Result<()> { - let pb = utils::get_spinner("Building knowledge base..."); + async fn make_book( + &self, + filters: Filters, + src_dir: &Path, + make: bool, + index: bool, + ) -> color_eyre::Result<()> { + let pb = utils::get_spinner("Fetching annotations..."); let extension = self.config.file_extension.as_ref().unwrap(); let index_file = src_dir.join(format!( "{}.{}", self.config.index_name.as_ref().unwrap(), extension )); - if index_file.exists() { + if index && index_file.exists() { // Initialize fs::remove_file(&index_file)?; } @@ -310,7 +317,7 @@ impl Gooseberry { // Get all annotations let mut annotations: Vec<_> = self - .filter_annotations(Filters::default(), None) + .filter_annotations(filters, None) .await? .into_iter() .filter(|a| { @@ -324,7 +331,12 @@ impl Gooseberry { }) .map(AnnotationTemplate::from_annotation) .collect(); - self.sort_annotations(&mut annotations); + pb.finish_with_message(&format!("Fetched {} annotations", annotations.len())); + let pb = utils::get_spinner("Building knowledge base..."); + sort_annotations( + self.config.sort.as_ref().unwrap_or(&vec![OrderBy::Created]), + &mut annotations, + ); let order = self.config.hierarchy.as_ref().unwrap(); if order.is_empty() { @@ -360,28 +372,32 @@ impl Gooseberry { .collect(); let path = PathBuf::from(format!("{}.{}", folder_name, extension)); let link_data = get_link_data(&path, &src_dir)?; - index_links.push(hbs.render("index_link", &link_data)?); - let page_data = PageTemplate { - link_data, - annotations: inner_annotations - .iter() - .map(|a| hbs.render("annotation", &a)) - .collect::, _>>()?, - raw_annotations: inner_annotations, - }; - // TODO: check if nested tags work on Windows - // TODO: add tests for nested tags - if let Some(prefix) = path.parent() { - fs::create_dir_all(prefix)?; + if index { + index_links.push(hbs.render("index_link", &link_data)?); + } + if make { + let page_data = PageTemplate { + link_data, + annotations: inner_annotations + .iter() + .map(|a| hbs.render("annotation", &a)) + .collect::, _>>()?, + raw_annotations: inner_annotations, + }; + // TODO: check if nested tags work on Windows + // TODO: add tests for nested tags + if let Some(prefix) = path.parent() { + fs::create_dir_all(prefix)?; + } + fs::File::create(&path)? + .write_all(hbs.render("page", &page_data)?.as_bytes())?; } - fs::File::create(&path)? - .write_all(hbs.render("page", &page_data)?.as_bytes())?; } else { - if !folder.exists() { + if make && !folder.exists() { fs::create_dir(&folder)?; } for (new_folder, annotations) in - self.group_annotations_by_order(order[depth], inner_annotations) + group_annotations_by_order(order[depth], inner_annotations) { (recurse_folder.f)( recurse_folder, @@ -395,7 +411,6 @@ impl Gooseberry { Ok(()) }, }; - // Make directory structure (recurse_folder.f)( &recurse_folder, @@ -404,15 +419,22 @@ impl Gooseberry { 0, &mut index_links, )?; - // Make Index file - fs::File::create(index_file)? - .write_all(index_links.into_iter().collect::().as_bytes())?; + if index { + // Make Index file + fs::File::create(&index_file)? + .write_all(index_links.into_iter().collect::().as_bytes())?; + } } pb.finish_with_message("Done!"); - println!( - "Knowledge base built at: {:?}", - self.config.kb_dir.as_ref().unwrap() - ); + if make { + println!( + "Knowledge base built at: {:?}", + self.config.kb_dir.as_ref().unwrap() + ); + } + if index { + println!("Index file location: {:?}", index_file); + } Ok(()) } } diff --git a/src/gooseberry/mod.rs b/src/gooseberry/mod.rs index 6562837..a555419 100644 --- a/src/gooseberry/mod.rs +++ b/src/gooseberry/mod.rs @@ -98,7 +98,15 @@ impl Gooseberry { search, fuzzy, } => self.sync_group(group_id, filters, search, fuzzy).await, - GooseberrySubcommand::Make { force } => self.make(force).await, + GooseberrySubcommand::Make { + filters, + clear, + force, + no_index, + } => self.make(filters, clear, force, true, !no_index).await, + GooseberrySubcommand::Index { filters } => { + self.make(filters, false, false, false, true).await + } GooseberrySubcommand::Clear { force } => self.clear(force), GooseberrySubcommand::Uri { filters, ids } => { let annotations: Vec = self.filter_annotations(filters, None).await?; diff --git a/tests/cli.rs b/tests/cli.rs index ebbebe2..eb0f525 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -352,6 +352,8 @@ async fn make() -> color_eyre::Result<()> { cmd.env("GOOSEBERRY_CONFIG", &test_data.config_file) .arg("make") .arg("-f") + .arg("-c") + .arg("-n") .assert() .success(); @@ -370,8 +372,21 @@ async fn make() -> color_eyre::Result<()> { }) }) .collect::, _>>()?; - // index file - assert!(file_names.contains("SUMMARY.md")); + // index file shouldn't exist yet + assert!(!file_names.contains("SUMMARY.md")); + + let mut cmd = Command::cargo_bin("gooseberry")?; + cmd.env("GOOSEBERRY_CONFIG", &test_data.config_file) + .arg("index") + .assert() + .success(); + // now index file should exist + assert!(test_data + .temp_dir + .path() + .join("kb") + .join("SUMMARY.md") + .exists()); // check all tag files assert!(["test_tag", "test_tag1", "test_tag2", "test tag5"]