diff --git a/Cargo.lock b/Cargo.lock index 212e96c..219df08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -30,6 +30,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.18" @@ -1199,6 +1208,7 @@ dependencies = [ "open", "ratatui", "ratatui-image", + "regex", "reqwest", "rss", "scraper", @@ -1721,6 +1731,35 @@ dependencies = [ "thiserror", ] +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + [[package]] name = "reqwest" version = "0.12.5" diff --git a/Cargo.toml b/Cargo.toml index 2540714..7a038a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,7 @@ lexopt = "0.3.0" ratatui-image = { version = "1.0.5", optional = true , default-features = false } image = { version = "0.25.1", optional = true, features = ["png"], default-features = false } url = "2.5.4" +regex = "1.12" [lib] name = "nyaa" diff --git a/src/app.rs b/src/app.rs index fe92af8..c997f1e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -22,7 +22,7 @@ use crate::{ client::{Client, DownloadClientResult, SingleDownloadResult}, clip::ClipboardManager, config::{Config, ConfigManager}, - results::Results, + results::{ResultRow, Results}, source::{ nyaa_html::NyaaHtmlSource, request_client, Item, Source, SourceInfo, SourceResults, Sources, }, @@ -174,6 +174,7 @@ pub struct Context { pub batch: Vec, pub last_key: String, pub results: Results, + pub show_excluded: bool, pub deltatime: f64, //errors: Vec, notifications: Vec, @@ -218,6 +219,24 @@ impl Context { pub fn quit(&mut self) { self.should_quit = true; } + + /// Items currently visible in the results table (respects the show_excluded toggle). + pub fn visible_items(&self) -> &[Item] { + if self.show_excluded { + &self.results.all_items + } else { + &self.results.response.items + } + } + + /// Pre-rendered rows currently visible (parallel to `visible_items`). + pub fn visible_rows(&self) -> &[ResultRow] { + if self.show_excluded { + &self.results.all_rows + } else { + &self.results.table.rows + } + } } impl Default for Context { @@ -237,6 +256,7 @@ impl Default for Context { batch: vec![], last_key: "".to_owned(), results: Results::default(), + show_excluded: false, deltatime: 0.0, failed_config_load: true, should_quit: false, @@ -347,7 +367,7 @@ impl App { .results .table .selected() - .and_then(|i| ctx.results.response.items.get(i)) + .and_then(|i| ctx.visible_items().get(i)) { tokio::spawn(sync.clone().download( tx_dl.clone(), @@ -408,6 +428,7 @@ impl App { ctx.config.sources.clone(), ctx.theme.clone(), ctx.config.clone().into(), + ctx.config.exclude.clone(), )); last_load_abort = Some(task.abort_handle()); continue; // Redraw @@ -649,7 +670,7 @@ impl App { ['y', c] => { let s = self.widgets.results.table.state.selected().unwrap_or(0); ctx.mode = Mode::Normal; - match ctx.results.response.items.get(s).cloned() { + match ctx.visible_items().get(s).cloned() { Some(item) => { let link = match c { 't' => item.torrent_link, @@ -688,3 +709,113 @@ impl App { } } } + +#[cfg(test)] +mod tests { + use crate::{ + config::ExcludeConfig, + results::{ResultResponse, ResultRow, ResultTable, Results}, + source::Item, + sync::SearchQuery, + }; + + use super::Context; + + fn make_item(title: &str) -> Item { + Item { + title: title.to_owned(), + ..Default::default() + } + } + + fn make_results(titles: &[&str], excluded_titles: &[&str]) -> Results { + let all_items: Vec = titles.iter().map(|t| make_item(t)).collect(); + let visible_items: Vec = all_items + .iter() + .filter(|i| !excluded_titles.contains(&i.title.as_str())) + .cloned() + .collect(); + + let make_row = |_: &Item| ResultRow::default(); + let all_rows: Vec = all_items.iter().map(make_row).collect(); + let visible_rows: Vec = visible_items.iter().map(make_row).collect(); + + Results::new_with_all( + SearchQuery::default(), + ResultResponse { + items: visible_items, + last_page: 1, + total_results: titles.len() - excluded_titles.len(), + }, + ResultTable { + headers: ResultRow::default(), + rows: visible_rows, + binding: vec![], + }, + all_items, + all_rows, + ) + } + + #[test] + fn visible_items_default_hides_excluded() { + let mut ctx = Context::default(); + ctx.results = make_results(&["Bleach - 01", "One Piece - 01"], &["Bleach - 01"]); + + assert_eq!(ctx.show_excluded, false); + let items = ctx.visible_items(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].title, "One Piece - 01"); + } + + #[test] + fn visible_items_show_excluded_reveals_all() { + let mut ctx = Context::default(); + ctx.results = make_results(&["Bleach - 01", "One Piece - 01"], &["Bleach - 01"]); + ctx.show_excluded = true; + + let items = ctx.visible_items(); + assert_eq!(items.len(), 2); + } + + #[test] + fn visible_rows_matches_visible_items_length() { + let mut ctx = Context::default(); + ctx.results = make_results(&["A", "B", "C"], &["B"]); + + assert_eq!(ctx.visible_items().len(), ctx.visible_rows().len()); + ctx.show_excluded = true; + assert_eq!(ctx.visible_items().len(), ctx.visible_rows().len()); + } + + #[test] + fn toggle_changes_visible_count() { + let mut ctx = Context::default(); + ctx.results = make_results(&["A", "B", "C"], &["A", "C"]); + + assert_eq!(ctx.visible_items().len(), 1); // only B + ctx.show_excluded = true; + assert_eq!(ctx.visible_items().len(), 3); // all + ctx.show_excluded = false; + assert_eq!(ctx.visible_items().len(), 1); // back to filtered + } + + #[test] + fn no_exclude_config_shows_all_items() { + let mut ctx = Context::default(); + // When there is no exclude config, all_items == response.items. + ctx.results = make_results(&["X", "Y", "Z"], &[]); + + assert_eq!(ctx.visible_items().len(), 3); + ctx.show_excluded = true; + assert_eq!(ctx.visible_items().len(), 3); + } + + #[test] + fn exclude_config_default_is_not_active() { + // ExcludeConfig with empty lists should never exclude anything. + let cfg = ExcludeConfig::default(); + let filter = cfg.into_filter(); + assert!(!filter.should_exclude("Bleach - 01 [1080p]")); + } +} diff --git a/src/config.rs b/src/config.rs index 4aea1c8..546f081 100644 --- a/src/config.rs +++ b/src/config.rs @@ -15,6 +15,7 @@ use crate::{ widget::notifications::NotificationConfig, }; use directories::ProjectDirs; +use regex::{Regex, RegexBuilder}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; pub static CONFIG_FILE: &str = "config.toml"; @@ -42,6 +43,99 @@ impl AppConfig { } } +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ExcludeConfig { + #[serde(default)] + pub sentences: Vec, + #[serde(default)] + pub regexes: Vec, + #[serde(default = "default_true")] + pub case_insensitive: bool, +} + +fn default_true() -> bool { + true +} + +impl Default for ExcludeConfig { + fn default() -> Self { + Self { + sentences: vec![], + regexes: vec![], + case_insensitive: true, + } + } +} + +impl ExcludeConfig { + pub fn into_filter(self) -> ExcludeFilter { + let mut compiled_regexes = Vec::new(); + + for reg_str in self.regexes { + let re_result = RegexBuilder::new(®_str) + .case_insensitive(self.case_insensitive) + .build(); + + match re_result { + Ok(re) => compiled_regexes.push(re), + Err(e) => eprintln!("Failed to compile regex '{}': {}", reg_str, e), + } + } + + let processed_sentences = if self.case_insensitive { + self.sentences + .into_iter() + .map(|s| s.to_lowercase()) + .collect() + } else { + self.sentences + }; + + ExcludeFilter { + sentences: processed_sentences, + compiled_regexes, + case_insensitive: self.case_insensitive, + } + } +} + +// To have regex compiled once +pub struct ExcludeFilter { + sentences: Vec, + compiled_regexes: Vec, + case_insensitive: bool, +} + +impl ExcludeFilter { + pub fn should_exclude(&self, title: &str) -> bool { + // Sentences optimization: lowercase only once + let title_lower: String; + let title_to_check = if self.case_insensitive { + title_lower = title.to_lowercase(); + &title_lower + } else { + title + }; + + // fast substring match + for sentence in &self.sentences { + if title_to_check.contains(sentence) { + return true; + } + } + + // pre compiled regex match + // (use original title because the regex is already handling case) + for re in &self.compiled_regexes { + if re.is_match(title) { + return true; + } + } + + false + } +} + #[derive(Serialize, Deserialize, Clone)] #[serde(default)] pub struct Config { @@ -71,6 +165,8 @@ pub struct Config { pub client: ClientConfig, #[serde(rename = "source")] pub sources: SourceConfig, + + pub exclude: Option, } impl Default for Config { @@ -94,6 +190,7 @@ impl Default for Config { clipboard: None, client: ClientConfig::default(), sources: SourceConfig::default(), + exclude: None, } } } @@ -225,3 +322,124 @@ pub fn get_configuration_folder(app_name: &str) -> Result ExcludeFilter { + ExcludeConfig { + sentences: sentences.iter().map(|s| s.to_string()).collect(), + regexes: regexes.iter().map(|s| s.to_string()).collect(), + case_insensitive, + } + .into_filter() + } + + // --- Default --- + + #[test] + fn default_case_insensitive_is_true() { + assert!(ExcludeConfig::default().case_insensitive); + } + + // --- Sentence matching --- + + #[test] + fn sentence_case_sensitive() { + let f = filter(&["bleach"], &[], false); + assert!(f.should_exclude("bleach 720p")); + assert!(!f.should_exclude("Bleach 720p")); + } + + #[test] + fn sentence_case_insensitive() { + let f = filter(&["bleach"], &[], true); + assert!(f.should_exclude("Bleach 720p")); + assert!(f.should_exclude("BLEACH 720p")); + assert!(f.should_exclude("bleach 720p")); + } + + #[test] + fn sentence_substring_match() { + let f = filter(&["your forma"], &[], true); + assert!(f.should_exclude("[Group] Your Forma - 01 [1080p]")); + assert!(!f.should_exclude("[Group] Bleach - 01 [1080p]")); + } + + #[test] + fn multiple_sentences_any_match() { + let f = filter(&["bleach", "naruto"], &[], true); + assert!(f.should_exclude("Bleach - 01")); + assert!(f.should_exclude("Naruto Shippuden - 01")); + assert!(!f.should_exclude("One Piece - 01")); + } + + #[test] + fn no_filters_never_excludes() { + let f = filter(&[], &[], true); + assert!(!f.should_exclude("Anything Goes Here")); + } + + // --- Regex matching --- + + #[test] + fn regex_case_insensitive() { + let f = filter(&[], &["^\\[SubGroup\\]"], true); + assert!(f.should_exclude("[SubGroup] Show - 01")); + assert!(f.should_exclude("[subgroup] Show - 01")); + assert!(!f.should_exclude("Show - 01 [SubGroup]")); + } + + #[test] + fn regex_case_sensitive() { + let f = filter(&[], &["^\\[SubGroup\\]"], false); + assert!(f.should_exclude("[SubGroup] Show - 01")); + assert!(!f.should_exclude("[subgroup] Show - 01")); + } + + #[test] + fn regex_invalid_does_not_panic() { + // An invalid regex should be silently skipped, not crash. + let f = filter(&[], &["[invalid regex"], true); + assert!(!f.should_exclude("anything")); + } + + #[test] + fn sentences_and_regexes_combined() { + let f = filter(&["bleach"], &["\\b480p\\b"], true); + assert!(f.should_exclude("Bleach - 01 [1080p]")); // matched by sentence + assert!(f.should_exclude("One Piece - 01 [480p]")); // matched by regex + assert!(!f.should_exclude("One Piece - 01 [1080p]")); // no match + } + + // --- TOML deserialization --- + + #[test] + fn deserialize_minimal_toml() { + let cfg: ExcludeConfig = toml::from_str( + r#" + sentences = ["bleach"] + "#, + ) + .unwrap(); + assert_eq!(cfg.sentences, ["bleach"]); + assert!(cfg.regexes.is_empty()); + assert!(cfg.case_insensitive); // serde default = true + } + + #[test] + fn deserialize_full_toml() { + let cfg: ExcludeConfig = toml::from_str( + r#" + sentences = ["bleach", "your forma"] + regexes = ["\\b480p\\b"] + case_insensitive = false + "#, + ) + .unwrap(); + assert_eq!(cfg.sentences, ["bleach", "your forma"]); + assert_eq!(cfg.regexes, ["\\b480p\\b"]); + assert!(!cfg.case_insensitive); + } +} diff --git a/src/results.rs b/src/results.rs index 4d93fbf..f7a4dec 100644 --- a/src/results.rs +++ b/src/results.rs @@ -12,14 +12,39 @@ pub struct Results { pub search: SearchQuery, pub response: ResultResponse, pub table: ResultTable, + /// Unfiltered items, kept for the "show excluded" toggle. + pub all_items: Vec, + /// Rows parallel to `all_items`. + pub all_rows: Vec, } impl Results { pub fn new(search: SearchQuery, response: ResultResponse, table: ResultTable) -> Self { + // When no exclude filter is active, all_items == response.items. + let all_items = response.items.clone(); + let all_rows = table.rows.clone(); Self { search, response, table, + all_items, + all_rows, + } + } + + pub fn new_with_all( + search: SearchQuery, + response: ResultResponse, + table: ResultTable, + all_items: Vec, + all_rows: Vec, + ) -> Self { + Self { + search, + response, + table, + all_items, + all_rows, } } } diff --git a/src/sync.rs b/src/sync.rs index 6a29401..87f771b 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -11,8 +11,8 @@ use tokio::sync::mpsc; use crate::{ app::LoadType, client::{Client, ClientConfig, DownloadClientResult}, - config::CONFIG_FILE, - results::Results, + config::{ExcludeConfig, CONFIG_FILE}, + results::{ResultTable, Results}, source::{Item, SourceConfig, SourceExtraConfig, SourceResponse, SourceResults, Sources}, theme::{Theme, THEMES_PATH}, widget::sort::SelectedSort, @@ -30,6 +30,7 @@ pub trait EventSync { config: SourceConfig, theme: Theme, extra: SourceExtraConfig, + exclude: Option, ) -> impl std::future::Future + std::marker::Send + 'static; fn download( self, @@ -99,14 +100,45 @@ impl EventSync for AppSync { config: SourceConfig, theme: Theme, extra: SourceExtraConfig, + exclude: Option, ) { let res = src.load(load_type, &client, &search, &config, &extra).await; let fmt = match res { - Ok(SourceResponse::Results(res)) => Ok(SourceResults::Results(Results::new( - search.clone(), - res.clone(), - src.format_table(&res.items, &search, &config, &theme), - ))), + Ok(SourceResponse::Results(mut res)) => { + // Format rows for every item once, then split into all vs. visible. + let all_table = src.format_table(&res.items, &search, &config, &theme); + let all_items = res.items.clone(); + let all_rows = all_table.rows.clone(); + + if let Some(exc) = exclude { + let filter = exc.into_filter(); + // Filter items and their corresponding rows together. + let (items, rows): (Vec<_>, Vec<_>) = all_items + .iter() + .cloned() + .zip(all_rows.iter().cloned()) + .filter(|(item, _)| !filter.should_exclude(&item.title)) + .unzip(); + res.items = items; + res.total_results = res.items.len(); + let visible_table = ResultTable { + headers: all_table.headers, + rows, + binding: all_table.binding, + }; + Ok(SourceResults::Results(Results::new_with_all( + search, + res, + visible_table, + all_items, + all_rows, + ))) + } else { + Ok(SourceResults::Results(Results::new_with_all( + search, res, all_table, all_items, all_rows, + ))) + } + } #[cfg(feature = "captcha")] Ok(SourceResponse::Captcha(c)) => Ok(SourceResults::Captcha(c)), Err(e) => Err(e), diff --git a/src/widget/results.rs b/src/widget/results.rs index f9b053b..5f777b0 100644 --- a/src/widget/results.rs +++ b/src/widget/results.rs @@ -39,35 +39,44 @@ impl ResultsWidget { } fn try_select_add(&self, ctx: &mut Context, start: usize, stop: usize) { - if let Some(item) = ctx.results.response.items.get(start..=stop) { - item.iter().for_each(|i| { - if !ctx.batch.iter().any(|s| s.id == i.id) { - ctx.batch.push(i.to_owned()); - } - }); - } + let items: Vec<_> = ctx + .visible_items() + .get(start..=stop) + .map(|s| s.to_vec()) + .unwrap_or_default(); + items.iter().for_each(|i| { + if !ctx.batch.iter().any(|s| s.id == i.id) { + ctx.batch.push(i.to_owned()); + } + }); } fn try_select_remove(&self, ctx: &mut Context, start: usize, stop: usize) { - if let Some(item) = ctx.results.response.items.get(start..=stop) { - item.iter().for_each(|i| { - if let Some(p) = ctx.batch.iter().position(|s| s.id == i.id) { - ctx.batch.remove(p); - } - }) - } + let items: Vec<_> = ctx + .visible_items() + .get(start..=stop) + .map(|s| s.to_vec()) + .unwrap_or_default(); + items.iter().for_each(|i| { + if let Some(p) = ctx.batch.iter().position(|s| s.id == i.id) { + ctx.batch.remove(p); + } + }); } fn try_select_toggle(&self, ctx: &mut Context, start: usize, stop: usize) { - if let Some(item) = ctx.results.response.items.get(start..=stop) { - item.iter().for_each(|i| { - if let Some(p) = ctx.batch.iter().position(|s| s.id == i.id) { - ctx.batch.remove(p); - } else { - ctx.batch.push(i.to_owned()); - } - }) - } + let items: Vec<_> = ctx + .visible_items() + .get(start..=stop) + .map(|s| s.to_vec()) + .unwrap_or_default(); + items.iter().for_each(|i| { + if let Some(p) = ctx.batch.iter().position(|s| s.id == i.id) { + ctx.batch.remove(p); + } else { + ctx.batch.push(i.to_owned()); + } + }); } fn select_on_move( @@ -128,14 +137,7 @@ impl super::Widget for ResultsWidget { Paragraph::new(message).render(load_area, buf); vec![] } - _ => ctx - .results - .table - .rows - .clone() - .into_iter() - .map(Into::into) - .collect(), + _ => ctx.visible_rows().iter().cloned().map(Into::into).collect(), }; let sb = super::scrollbar(ctx, ScrollbarOrientation::VerticalRight).begin_symbol(Some("")); @@ -154,11 +156,13 @@ impl super::Widget for ResultsWidget { ctx.src.to_string() ); + let excluded_indicator = if ctx.show_excluded { " [show all]" } else { "" }; let title = title!( - "Results {}-{} ({} total): Page {}/{}", + "Results {}-{} ({} total){}: Page {}/{}", first_item + 1, num_items + first_item, ctx.results.response.total_results, + excluded_indicator, ctx.page, ctx.results.response.last_page, ); @@ -200,9 +204,9 @@ impl super::Widget for ResultsWidget { if area.height >= 3 { let offset = self.table.state.offset(); - let start = offset.min(ctx.results.response.items.len()); - let end = (offset + visible_height).min(ctx.results.response.items.len()); - if let Some(visible_items) = ctx.results.response.items.get(start..end) { + let start = offset.min(ctx.visible_items().len()); + let end = (offset + visible_height).min(ctx.visible_items().len()); + if let Some(visible_items) = ctx.visible_items().get(start..end) { let selected_ids: Vec = ctx.batch.clone().into_iter().map(|i| i.id).collect(); let vert_left = ctx.theme.border.to_border_set().vertical_left; @@ -259,6 +263,11 @@ impl super::Widget for ResultsWidget { ctx.mode = Mode::Loading(LoadType::Searching); } } + (Char('.'), &KeyModifiers::NONE) => { + ctx.show_excluded = !ctx.show_excluded; + self.table.select(0); + *self.table.state.offset_mut() = 0; + } (Char('n') | Char('l') | Right, &KeyModifiers::NONE) => { if ctx.page < ctx.results.response.last_page { ctx.page += 1; @@ -273,27 +282,27 @@ impl super::Widget for ResultsWidget { } (Char('j') | KeyCode::Down, &KeyModifiers::NONE) => { let prev = self.table.selected().unwrap_or(0); - let selected = self.table.next(ctx.results.response.items.len(), 1); + let selected = self.table.next(ctx.visible_items().len(), 1); self.select_on_move(ctx, prev, selected, selected); } (Char('k') | KeyCode::Up, &KeyModifiers::NONE) => { let prev = self.table.selected().unwrap_or(0); - let selected = self.table.next(ctx.results.response.items.len(), -1); + let selected = self.table.next(ctx.visible_items().len(), -1); self.select_on_move(ctx, prev, selected, selected); } (Char('J'), &KeyModifiers::SHIFT) => { let prev = self.table.selected().unwrap_or(0); - let selected = self.table.next(ctx.results.response.items.len(), 4); + let selected = self.table.next(ctx.visible_items().len(), 4); self.select_on_move(ctx, prev, prev + 1, selected); } (Char('K'), &KeyModifiers::SHIFT) => { let prev = self.table.selected().unwrap_or(0); - let selected = self.table.next(ctx.results.response.items.len(), -4); + let selected = self.table.next(ctx.visible_items().len(), -4); self.select_on_move(ctx, prev, selected, prev.saturating_sub(1)); } (Char('G'), &KeyModifiers::SHIFT) => { let prev = self.table.selected().unwrap_or(0); - let selected = ctx.results.response.items.len().saturating_sub(1); + let selected = ctx.visible_items().len().saturating_sub(1); self.table.select(selected); if self.visual_mode != VisualMode::None && prev != selected { @@ -333,9 +342,7 @@ impl super::Widget for ResultsWidget { } (Char('o'), &KeyModifiers::NONE) => { let link = ctx - .results - .response - .items + .visible_items() .get(self.table.state.selected().unwrap_or(0)) .map(|item| item.post_link.clone()) .unwrap_or("https://nyaa.si".to_owned()); @@ -385,7 +392,7 @@ impl super::Widget for ResultsWidget { } (Char(' '), &KeyModifiers::NONE) => { if let Some(sel) = self.table.state.selected() { - if let Some(item) = &mut ctx.results.response.items.get_mut(sel) { + if let Some(item) = ctx.visible_items().get(sel).cloned() { if let Some(p) = ctx.batch.iter().position(|s| s.id == item.id) { ctx.batch.remove(p); } else { @@ -426,6 +433,7 @@ impl super::Widget for ResultsWidget { ("N, L", "Last Page"), ("P, H", "First Page"), ("r", "Reload"), + (".", "Toggle show/hide excluded items"), ("o", "Open in browser"), ( "yt, ym, yp, yi, yn", diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 49670bc..dc89b94 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -171,6 +171,7 @@ impl EventSync for TestSync { _config: nyaa::source::SourceConfig, _theme: nyaa::theme::Theme, _extra: SourceExtraConfig, + _exclude: Option, ) { }