mirror of
https://github.com/ankitects/anki.git
synced 2025-09-19 14:32:22 -04:00
557 lines
16 KiB
Rust
557 lines
16 KiB
Rust
// 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<Cell>,
|
|
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<String>;
|
|
fn get_row_color(&self) -> Color;
|
|
fn get_row_font(&self) -> Result<Font>;
|
|
fn note(&self) -> &Note;
|
|
fn notetype(&self) -> &Notetype;
|
|
|
|
fn get_cell(&mut self, column: Column) -> Result<Cell> {
|
|
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<Row> {
|
|
Ok(Row {
|
|
cells: columns
|
|
.iter()
|
|
.map(|&column| self.get_cell(column))
|
|
.collect::<Result<_>>()?,
|
|
color: self.get_row_color(),
|
|
font: self.get_row_font()?,
|
|
})
|
|
}
|
|
}
|
|
|
|
struct CardRowContext<'a> {
|
|
col: &'a Collection,
|
|
card: Card,
|
|
note: Note,
|
|
notetype: Arc<Notetype>,
|
|
deck: Option<Deck>,
|
|
original_deck: Option<Option<Deck>>,
|
|
tr: &'a I18n,
|
|
timing: SchedTimingToday,
|
|
render_context: Option<RenderContext>,
|
|
}
|
|
|
|
/// 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<RenderedNode>,
|
|
}
|
|
|
|
struct NoteRowContext<'a> {
|
|
note: Note,
|
|
notetype: Arc<Notetype>,
|
|
cards: Vec<Card>,
|
|
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<TimestampSecs> {
|
|
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<Row> {
|
|
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<Note> {
|
|
// 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<Self> {
|
|
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<Self> {
|
|
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<Deck>> {
|
|
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<String> {
|
|
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<String> {
|
|
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<String> {
|
|
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<Font> {
|
|
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<Self> {
|
|
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<u16> = 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::<u16>() / 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<u32> = 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::<u32>() * 86400 / (intervals.len() as u32)) as f32,
|
|
self.tr,
|
|
false,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
impl RowContext for NoteRowContext<'_> {
|
|
fn get_cell_text(&mut self, column: Column) -> Result<String> {
|
|
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::<u32>().to_string(),
|
|
Column::NoteMod => self.note.mtime.date_string(),
|
|
Column::NoteReps => self.cards.iter().map(|c| c.reps).sum::<u32>().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<Font> {
|
|
Ok(Font {
|
|
name: "".to_owned(),
|
|
size: 0,
|
|
})
|
|
}
|
|
|
|
fn note(&self) -> &Note {
|
|
&self.note
|
|
}
|
|
|
|
fn notetype(&self) -> &Notetype {
|
|
&self.notetype
|
|
}
|
|
}
|