// Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html mod builder; mod parser; mod service; mod sqlwriter; pub(crate) mod writer; use std::borrow::Cow; pub use builder::JoinSearches; pub use builder::Negated; pub use builder::SearchBuilder; pub use parser::parse as parse_search; pub use parser::FieldSearchMode; pub use parser::Node; pub use parser::PropertyKind; pub use parser::RatingKind; pub use parser::SearchNode; pub use parser::StateKind; pub use parser::TemplateKind; use rusqlite::params_from_iter; use rusqlite::types::FromSql; use sqlwriter::RequiredTable; use sqlwriter::SqlWriter; pub use writer::replace_search_node; use crate::browser_table::Column; use crate::card::CardType; use crate::prelude::*; use crate::scheduler::timing::SchedTimingToday; #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum ReturnItemType { Cards, Notes, } #[derive(Debug, PartialEq, Eq, Clone)] pub enum SortMode { NoOrder, Builtin { column: Column, reverse: bool }, Custom(String), } pub trait AsReturnItemType { fn as_return_item_type() -> ReturnItemType; } impl AsReturnItemType for CardId { fn as_return_item_type() -> ReturnItemType { ReturnItemType::Cards } } impl AsReturnItemType for NoteId { fn as_return_item_type() -> ReturnItemType { ReturnItemType::Notes } } impl ReturnItemType { fn required_table(&self) -> RequiredTable { match self { ReturnItemType::Cards => RequiredTable::Cards, ReturnItemType::Notes => RequiredTable::Notes, } } } impl SortMode { fn required_table(&self) -> RequiredTable { match self { SortMode::NoOrder => RequiredTable::CardsOrNotes, SortMode::Builtin { column, .. } => column.required_table(), SortMode::Custom(ref text) => { if text.contains("n.") { if text.contains("c.") { RequiredTable::CardsAndNotes } else { RequiredTable::Notes } } else { RequiredTable::Cards } } } } } impl Column { fn required_table(self) -> RequiredTable { match self { Column::Cards | Column::NoteCreation | Column::NoteMod | Column::Notetype | Column::SortField | Column::Tags => RequiredTable::Notes, _ => RequiredTable::CardsOrNotes, } } } pub trait TryIntoSearch { fn try_into_search(self) -> Result; } impl TryIntoSearch for &str { fn try_into_search(self) -> Result { parser::parse(self).map(Node::Group) } } impl TryIntoSearch for &String { fn try_into_search(self) -> Result { parser::parse(self).map(Node::Group) } } impl TryIntoSearch for T where T: Into, { fn try_into_search(self) -> Result { Ok(self.into()) } } pub struct CardTableGuard<'a> { pub col: &'a mut Collection, pub cards: usize, } impl Drop for CardTableGuard<'_> { fn drop(&mut self) { if let Err(err) = self.col.storage.clear_searched_cards_table() { println!("{err:?}"); } } } pub struct NoteTableGuard<'a> { pub col: &'a mut Collection, pub notes: usize, } impl Drop for NoteTableGuard<'_> { fn drop(&mut self) { if let Err(err) = self.col.storage.clear_searched_notes_table() { println!("{err:?}"); } } } impl Collection { pub fn search_cards(&mut self, search: N, mode: SortMode) -> Result> where N: TryIntoSearch, { self.search(search, mode) } pub fn search_notes(&mut self, search: N, mode: SortMode) -> Result> where N: TryIntoSearch, { self.search(search, mode) } pub fn search_notes_unordered(&mut self, search: N) -> Result> where N: TryIntoSearch, { self.search(search, SortMode::NoOrder) } } impl Collection { fn search(&mut self, search: N, mode: SortMode) -> Result> where N: TryIntoSearch, T: FromSql + AsReturnItemType, { let item_type = T::as_return_item_type(); let top_node = search.try_into_search()?; let writer = SqlWriter::new(self, item_type); let (mut sql, args) = writer.build_query(&top_node, mode.required_table())?; self.add_order(&mut sql, item_type, mode)?; let mut stmt = self.storage.db.prepare(&sql)?; let ids: Vec<_> = stmt .query_map(params_from_iter(args.iter()), |row| row.get(0))? .collect::>()?; Ok(ids) } fn add_order( &mut self, sql: &mut String, item_type: ReturnItemType, mode: SortMode, ) -> Result<()> { match mode { SortMode::NoOrder => (), SortMode::Builtin { column, reverse } => { prepare_sort(self, column, item_type)?; sql.push_str(" order by "); write_order(sql, item_type, column, reverse, self.timing_today()?)?; } SortMode::Custom(order_clause) => { sql.push_str(" order by "); sql.push_str(&order_clause); } } Ok(()) } /// Place the matched card ids into a temporary 'search_cids' table /// instead of returning them. Returns a guard with a collection reference /// and the number of added cards. When the guard is dropped, the temporary /// table is cleaned up. pub(crate) fn search_cards_into_table( &mut self, search: impl TryIntoSearch, mode: SortMode, ) -> Result> { let top_node = search.try_into_search()?; let writer = SqlWriter::new(self, ReturnItemType::Cards); let want_order = mode != SortMode::NoOrder; let (mut sql, args) = writer.build_query(&top_node, mode.required_table())?; self.add_order(&mut sql, ReturnItemType::Cards, mode)?; if want_order { self.storage .setup_searched_cards_table_to_preserve_order()?; } else { self.storage.setup_searched_cards_table()?; } let sql = format!("insert into search_cids {sql}"); let cards = self .storage .db .prepare(&sql)? .execute(params_from_iter(args))?; Ok(CardTableGuard { cards, col: self }) } pub(crate) fn all_cards_for_search(&mut self, search: impl TryIntoSearch) -> Result> { let guard = self.search_cards_into_table(search, SortMode::NoOrder)?; guard.col.storage.all_searched_cards() } pub(crate) fn all_cards_for_search_in_order( &mut self, search: impl TryIntoSearch, mode: SortMode, ) -> Result> { let guard = self.search_cards_into_table(search, mode)?; guard.col.storage.all_searched_cards_in_search_order() } pub(crate) fn all_cards_for_ids( &self, cards: &[CardId], preserve_order: bool, ) -> Result> { self.storage.with_searched_cards_table(preserve_order, || { self.storage.set_search_table_to_card_ids(cards)?; if preserve_order { self.storage.all_searched_cards_in_search_order() } else { self.storage.all_searched_cards() } }) } pub(crate) fn for_each_card_in_search( &mut self, search: impl TryIntoSearch, mut func: impl FnMut(&Collection, Card) -> Result<()>, ) -> Result<()> { let guard = self.search_cards_into_table(search, SortMode::NoOrder)?; guard .col .storage .for_each_card_in_search(|card| func(guard.col, card)) } /// Place the matched card ids into a temporary 'search_nids' table /// instead of returning them. Returns a guard with a collection reference /// and the number of added notes. When the guard is dropped, the temporary /// table is cleaned up. pub(crate) fn search_notes_into_table( &mut self, search: impl TryIntoSearch, ) -> Result> { let top_node = search.try_into_search()?; let writer = SqlWriter::new(self, ReturnItemType::Notes); let mode = SortMode::NoOrder; let (sql, args) = writer.build_query(&top_node, mode.required_table())?; self.storage.setup_searched_notes_table()?; let sql = format!("insert into search_nids {sql}"); let notes = self .storage .db .prepare(&sql)? .execute(params_from_iter(args))?; Ok(NoteTableGuard { notes, col: self }) } /// Place the ids of cards with notes in 'search_nids' into 'search_cids'. /// Returns number of added cards. pub(crate) fn search_cards_of_notes_into_table(&mut self) -> Result> { self.storage.setup_searched_cards_table()?; let cards = self.storage.search_cards_of_notes_into_table()?; Ok(CardTableGuard { cards, col: self }) } } /// Add the order clause to the sql. fn write_order( sql: &mut String, item_type: ReturnItemType, column: Column, reverse: bool, timing: SchedTimingToday, ) -> Result<()> { let order = match item_type { ReturnItemType::Cards => card_order_from_sort_column(column, timing), ReturnItemType::Notes => note_order_from_sort_column(column), }; require!(!order.is_empty(), "Can't sort {item_type:?} by {column:?}."); if reverse { sql.push_str( &order .to_ascii_lowercase() .replace(" desc", "") .replace(" asc", " desc"), ) } else { sql.push_str(&order); } Ok(()) } fn card_order_from_sort_column(column: Column, timing: SchedTimingToday) -> Cow<'static, str> { match column { Column::CardMod => "c.mod asc".into(), Column::Cards => concat!( "coalesce((select pos from sort_order where ntid = n.mid and ord = c.ord),", // need to fall back on ord 0 for cloze cards "(select pos from sort_order where ntid = n.mid and ord = 0)) asc, ord asc" ) .into(), Column::Deck => "(select pos from sort_order where did = c.did) asc".into(), Column::Due => format!("(case when c.due > 1000000000 or c.type = {} then due else (due - {}) * 86400 + {} end) asc", CardType::New as i8, timing.days_elapsed, TimestampSecs::now().0).into(), Column::Ease => format!("c.type = {} asc, c.factor asc", CardType::New as i8).into(), Column::Interval => "c.ivl asc".into(), Column::Lapses => "c.lapses asc".into(), Column::NoteCreation => "n.id asc, c.ord asc".into(), Column::NoteMod => "n.mod asc, c.ord asc".into(), Column::Notetype => "(select pos from sort_order where ntid = n.mid) asc".into(), Column::OriginalPosition => "(select pos from sort_order where nid = c.nid) asc".into(), Column::Reps => "c.reps asc".into(), Column::SortField => "n.sfld collate nocase asc, c.ord asc".into(), Column::Tags => "n.tags asc".into(), Column::Answer | Column::Custom | Column::Question => "".into(), Column::Stability => "extract_fsrs_variable(c.data, 's') asc".into(), Column::Difficulty => "extract_fsrs_variable(c.data, 'd') asc".into(), Column::Retrievability => format!( "extract_fsrs_retrievability(c.data, case when c.odue !=0 then c.odue else c.due end, c.ivl, {}, {}, {}) asc", timing.days_elapsed, timing.next_day_at.0, timing.now.0, ) .into(), } } fn note_order_from_sort_column(column: Column) -> Cow<'static, str> { match column { Column::CardMod | Column::Cards | Column::Deck | Column::Due | Column::Ease | Column::Interval | Column::Lapses | Column::OriginalPosition | Column::Reps => "(select pos from sort_order where nid = n.id) asc".into(), Column::NoteCreation => "n.id asc".into(), Column::NoteMod => "n.mod asc".into(), Column::Notetype => "(select pos from sort_order where ntid = n.mid) asc".into(), Column::SortField => "n.sfld collate nocase asc".into(), Column::Tags => "n.tags asc".into(), Column::Answer | Column::Custom | Column::Question | Column::Stability | Column::Difficulty | Column::Retrievability => "".into(), } } fn prepare_sort(col: &mut Collection, column: Column, item_type: ReturnItemType) -> Result<()> { let temp_string; let sql = match item_type { ReturnItemType::Cards => match column { Column::Cards => include_str!("template_order.sql"), Column::Deck => include_str!("deck_order.sql"), Column::Notetype => include_str!("notetype_order.sql"), Column::OriginalPosition => include_str!("note_original_position_order.sql"), _ => return Ok(()), }, ReturnItemType::Notes => match column { Column::Cards => include_str!("note_cards_order.sql"), Column::CardMod => include_str!("card_mod_order.sql"), Column::Deck => include_str!("note_decks_order.sql"), Column::Due => { temp_string = format!("{} ORDER BY MIN({});", include_str!("note_due_order.sql"), format_args!("CASE WHEN due > 1000000000 OR type = {ctype} THEN due ELSE (due - {today}) * 86400 + {current_timestamp} END", ctype = CardType::New as i8, today = col.timing_today()?.days_elapsed, current_timestamp = TimestampSecs::now().0)); &temp_string } Column::Ease => include_str!("note_ease_order.sql"), Column::Interval => include_str!("note_interval_order.sql"), Column::Lapses => include_str!("note_lapses_order.sql"), Column::OriginalPosition => include_str!("note_original_position_order.sql"), Column::Reps => include_str!("note_reps_order.sql"), Column::Notetype => include_str!("notetype_order.sql"), _ => return Ok(()), }, }; col.storage.db.execute_batch(sql)?; Ok(()) } #[cfg(test)] mod test { use anki_proto::search::browser_columns::Sorting; use strum::IntoEnumIterator; use super::*; impl SchedTimingToday { pub(crate) fn zero() -> Self { SchedTimingToday { now: TimestampSecs(0), days_elapsed: 0, next_day_at: TimestampSecs(0), } } } #[test] fn column_default_sort_order_should_match_order_by_clause() { let timing = SchedTimingToday::zero(); for column in Column::iter() { assert_eq!( card_order_from_sort_column(column, timing).is_empty(), matches!(column.default_cards_order(), Sorting::None) ); assert_eq!( note_order_from_sort_column(column).is_empty(), matches!(column.default_notes_order(), Sorting::None) ); } } }