// Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::sync::Arc; use itertools::Itertools; use serde_repr::{Deserialize_repr, Serialize_repr}; use crate::error::{AnkiError, Result}; use crate::i18n::I18n; use crate::{ card::{Card, CardId, CardQueue, CardType}, collection::Collection, config::BoolKey, decks::{Deck, DeckId}, notes::{Note, NoteId}, notetype::{CardTemplate, Notetype, NotetypeKind}, scheduler::{timespan::time_span, timing::SchedTimingToday}, template::RenderedNode, text::{extract_av_tags, html_to_text_line}, timestamp::{TimestampMillis, TimestampSecs}, }; #[derive(Serialize_repr, Deserialize_repr, Debug, PartialEq, Clone, Copy)] #[repr(u8)] pub enum Column { Custom, Question, Answer, CardDeck, CardDue, CardEase, CardLapses, CardInterval, CardMod, CardReps, CardTemplate, NoteCards, NoteCreation, NoteDue, NoteEase, NoteField, NoteInterval, NoteLapses, NoteMod, NoteReps, NoteTags, Notetype, } #[derive(Debug, PartialEq)] pub struct Row { pub cells: Vec, pub color: Color, pub font: Font, } #[derive(Debug, PartialEq)] pub struct Cell { pub text: String, pub is_rtl: bool, } #[derive(Debug, PartialEq)] pub enum Color { Default, Marked, Suspended, FlagRed, FlagOrange, FlagGreen, FlagBlue, } #[derive(Debug, PartialEq)] pub struct Font { pub name: String, pub size: u32, } trait RowContext { fn get_cell_text(&mut self, column: Column) -> Result; fn get_row_color(&self) -> Color; fn get_row_font(&self) -> Result; fn note(&self) -> &Note; fn notetype(&self) -> &Notetype; fn get_cell(&mut self, column: Column) -> Result { Ok(Cell { text: self.get_cell_text(column)?, is_rtl: self.get_is_rtl(column), }) } fn note_creation_str(&self) -> String { TimestampMillis(self.note().id.into()) .as_secs() .date_string() } fn note_field_str(&self) -> String { let index = self.notetype().config.sort_field_idx as usize; html_to_text_line(&self.note().fields()[index]).into() } fn get_is_rtl(&self, column: Column) -> bool { match column { Column::NoteField => { let index = self.notetype().config.sort_field_idx as usize; self.notetype().fields[index].config.rtl } _ => false, } } fn browser_row_for_id(&mut self, columns: &[Column]) -> Result { Ok(Row { cells: columns .iter() .map(|&column| self.get_cell(column)) .collect::>()?, color: self.get_row_color(), font: self.get_row_font()?, }) } } struct CardRowContext<'a> { col: &'a Collection, card: Card, note: Note, notetype: Arc, deck: Option, original_deck: Option>, tr: &'a I18n, timing: SchedTimingToday, render_context: Option, } /// The answer string needs the question string but not the other way around, so only build the /// answer string when needed. struct RenderContext { question: String, answer_nodes: Vec, } struct NoteRowContext<'a> { note: Note, notetype: Arc, cards: Vec, tr: &'a I18n, timing: SchedTimingToday, } fn card_render_required(columns: &[Column]) -> bool { columns .iter() .any(|c| matches!(c, Column::Question | Column::Answer)) } impl Card { fn is_new_type_or_queue(&self) -> bool { self.queue == CardQueue::New || self.ctype == CardType::New } fn is_filtered_deck(&self) -> bool { self.original_deck_id != DeckId(0) } /// Returns true if the card can not be due as it's buried or suspended. fn is_undue_queue(&self) -> bool { (self.queue as i8) < 0 } /// Returns true if the card has a due date in terms of days. fn is_due_in_days(&self) -> bool { matches!(self.queue, CardQueue::DayLearn | CardQueue::Review) || (self.ctype == CardType::Review && self.is_undue_queue()) } /// Returns the card's due date as a timestamp if it has one. fn due_time(&self, timing: &SchedTimingToday) -> Option { if self.queue == CardQueue::Learn { Some(TimestampSecs(self.due as i64)) } else if self.is_due_in_days() { Some( TimestampSecs::now() .adding_secs(((self.due - timing.days_elapsed as i32) * 86400) as i64), ) } else { None } } } impl Note { fn is_marked(&self) -> bool { self.tags .iter() .any(|tag| tag.eq_ignore_ascii_case("marked")) } } impl Collection { pub fn browser_row_for_id(&mut self, id: i64) -> Result { if self.get_bool(BoolKey::BrowserTableShowNotesMode) { let columns = self .get_desktop_browser_note_columns() .ok_or(AnkiError::invalid_input("Note columns not set."))?; NoteRowContext::new(self, id)?.browser_row_for_id(&columns) } else { let columns = self .get_desktop_browser_card_columns() .ok_or(AnkiError::invalid_input("Card columns not set."))?; CardRowContext::new(self, id, card_render_required(&columns))? .browser_row_for_id(&columns) } } fn get_note_maybe_with_fields(&self, id: NoteId, _with_fields: bool) -> Result { // todo: After note.sort_field has been modified so it can be displayed in the browser, // we can update note_field_str() and only load the note with fields if a card render is // necessary (see #1082). if true { self.storage.get_note(id)? } else { self.storage.get_note_without_fields(id)? } .ok_or(AnkiError::NotFound) } } impl RenderContext { fn new(col: &mut Collection, card: &Card, note: &Note, notetype: &Notetype) -> Result { let render = col.render_card( note, card, notetype, notetype.get_template(card.template_idx)?, true, )?; let qnodes_text = render .qnodes .iter() .map(|node| match node { RenderedNode::Text { text } => text, RenderedNode::Replacement { field_name: _, current_text, filters: _, } => current_text, }) .join(""); let question = extract_av_tags(&qnodes_text, true).0.to_string(); Ok(RenderContext { question, answer_nodes: render.anodes, }) } } impl<'a> CardRowContext<'a> { fn new(col: &'a mut Collection, id: i64, with_card_render: bool) -> Result { let card = col .storage .get_card(CardId(id))? .ok_or(AnkiError::NotFound)?; let note = col.get_note_maybe_with_fields(card.note_id, with_card_render)?; let notetype = col .get_notetype(note.notetype_id)? .ok_or(AnkiError::NotFound)?; let timing = col.timing_today()?; let render_context = if with_card_render { Some(RenderContext::new(col, &card, ¬e, ¬etype)?) } else { None }; Ok(CardRowContext { col, card, note, notetype, deck: None, original_deck: None, tr: &col.tr, timing, render_context, }) } fn template(&self) -> Result<&CardTemplate> { self.notetype.get_template(self.card.template_idx) } fn deck(&mut self) -> Result<&Deck> { if self.deck.is_none() { self.deck = Some( self.col .storage .get_deck(self.card.deck_id)? .ok_or(AnkiError::NotFound)?, ); } Ok(self.deck.as_ref().unwrap()) } fn original_deck(&mut self) -> Result<&Option> { if self.original_deck.is_none() { self.original_deck = Some(self.col.storage.get_deck(self.card.original_deck_id)?); } Ok(self.original_deck.as_ref().unwrap()) } fn answer_str(&self) -> String { let render_context = self.render_context.as_ref().unwrap(); let answer = render_context .answer_nodes .iter() .map(|node| match node { RenderedNode::Text { text } => text, RenderedNode::Replacement { field_name: _, current_text, filters: _, } => current_text, }) .join(""); let answer = extract_av_tags(&answer, false).0; html_to_text_line( if let Some(stripped) = answer.strip_prefix(&render_context.question) { stripped } else { &answer }, ) .to_string() } fn card_due_str(&mut self) -> String { let due = if self.card.is_filtered_deck() { self.tr.browsing_filtered() } else if self.card.is_new_type_or_queue() { self.tr.statistics_due_for_new_card(self.card.due) } else if let Some(time) = self.card.due_time(&self.timing) { time.date_string().into() } else { return "".into(); }; if self.card.is_undue_queue() { format!("({})", due) } else { due.into() } } fn card_ease_str(&self) -> String { match self.card.ctype { CardType::New => self.tr.browsing_new().into(), _ => format!("{}%", self.card.ease_factor / 10), } } fn card_interval_str(&self) -> String { match self.card.ctype { CardType::New => self.tr.browsing_new().into(), CardType::Learn => self.tr.browsing_learning().into(), _ => time_span((self.card.interval * 86400) as f32, self.tr, false), } } fn deck_str(&mut self) -> Result { let deck_name = self.deck()?.human_name(); Ok(if let Some(original_deck) = self.original_deck()? { format!("{} ({})", &deck_name, &original_deck.human_name()) } else { deck_name }) } fn template_str(&self) -> Result { let name = &self.template()?.name; Ok(match self.notetype.config.kind() { NotetypeKind::Normal => name.to_owned(), NotetypeKind::Cloze => format!("{} {}", name, self.card.template_idx + 1), }) } fn question_str(&self) -> String { html_to_text_line(&self.render_context.as_ref().unwrap().question).to_string() } } impl RowContext for CardRowContext<'_> { fn get_cell_text(&mut self, column: Column) -> Result { Ok(match column { Column::Question => self.question_str(), Column::Answer => self.answer_str(), Column::CardDeck => self.deck_str()?, Column::CardDue => self.card_due_str(), Column::CardEase => self.card_ease_str(), Column::CardInterval => self.card_interval_str(), Column::CardLapses => self.card.lapses.to_string(), Column::CardMod => self.card.mtime.date_string(), Column::CardReps => self.card.reps.to_string(), Column::CardTemplate => self.template_str()?, Column::NoteCreation => self.note_creation_str(), Column::NoteField => self.note_field_str(), Column::NoteMod => self.note.mtime.date_string(), Column::NoteTags => self.note.tags.join(" "), Column::Notetype => self.notetype.name.to_owned(), _ => "".to_string(), }) } fn get_row_color(&self) -> Color { match self.card.flags { 1 => Color::FlagRed, 2 => Color::FlagOrange, 3 => Color::FlagGreen, 4 => Color::FlagBlue, _ => { if self.note.is_marked() { Color::Marked } else if self.card.queue == CardQueue::Suspended { Color::Suspended } else { Color::Default } } } } fn get_row_font(&self) -> Result { Ok(Font { name: self.template()?.config.browser_font_name.to_owned(), size: self.template()?.config.browser_font_size, }) } fn note(&self) -> &Note { &self.note } fn notetype(&self) -> &Notetype { &self.notetype } } impl<'a> NoteRowContext<'a> { fn new(col: &'a mut Collection, id: i64) -> Result { let note = col.get_note_maybe_with_fields(NoteId(id), false)?; let notetype = col .get_notetype(note.notetype_id)? .ok_or(AnkiError::NotFound)?; let cards = col.storage.all_cards_of_note(note.id)?; let timing = col.timing_today()?; Ok(NoteRowContext { note, notetype, cards, tr: &col.tr, timing, }) } /// Returns the average ease of the non-new cards or a hint if there aren't any. fn note_ease_str(&self) -> String { let eases: Vec = self .cards .iter() .filter(|c| c.ctype != CardType::New) .map(|c| c.ease_factor) .collect(); if eases.is_empty() { self.tr.browsing_new().into() } else { format!("{}%", eases.iter().sum::() / eases.len() as u16 / 10) } } /// Returns the due date of the next due card that is not in a filtered deck, new, suspended or /// buried or the empty string if there is no such card. fn note_due_str(&self) -> String { self.cards .iter() .filter(|c| !(c.is_filtered_deck() || c.is_new_type_or_queue() || c.is_undue_queue())) .filter_map(|c| c.due_time(&self.timing)) .min() .map(|time| time.date_string()) .unwrap_or_else(|| "".into()) } /// Returns the average interval of the review and relearn cards or the empty string if there /// aren't any. fn note_interval_str(&self) -> String { let intervals: Vec = self .cards .iter() .filter(|c| matches!(c.ctype, CardType::Review | CardType::Relearn)) .map(|c| c.interval) .collect(); if intervals.is_empty() { "".into() } else { time_span( (intervals.iter().sum::() * 86400 / (intervals.len() as u32)) as f32, self.tr, false, ) } } } impl RowContext for NoteRowContext<'_> { fn get_cell_text(&mut self, column: Column) -> Result { Ok(match column { Column::NoteCards => self.cards.len().to_string(), Column::NoteCreation => self.note_creation_str(), Column::NoteDue => self.note_due_str(), Column::NoteEase => self.note_ease_str(), Column::NoteField => self.note_field_str(), Column::NoteInterval => self.note_interval_str(), Column::NoteLapses => self.cards.iter().map(|c| c.lapses).sum::().to_string(), Column::NoteMod => self.note.mtime.date_string(), Column::NoteReps => self.cards.iter().map(|c| c.reps).sum::().to_string(), Column::NoteTags => self.note.tags.join(" "), Column::Notetype => self.notetype.name.to_owned(), _ => "".to_string(), }) } fn get_row_color(&self) -> Color { if self.note.is_marked() { Color::Marked } else { Color::Default } } fn get_row_font(&self) -> Result { Ok(Font { name: "".to_owned(), size: 0, }) } fn note(&self) -> &Note { &self.note } fn notetype(&self) -> &Notetype { &self.notetype } }