anki/rslib/src/browser_table.rs

579 lines
18 KiB
Rust
Raw Normal View History

2021-03-20 12:01:53 +01:00
// 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;
2021-04-09 19:09:48 +02:00
use strum::{Display, EnumIter, EnumString, IntoEnumIterator};
2021-03-20 12:01:53 +01:00
use crate::{
card::{CardQueue, CardType},
card_rendering::prettify_av_tags,
notetype::{CardTemplate, NotetypeKind},
pb,
prelude::*,
2021-03-20 12:01:53 +01:00
scheduler::{timespan::time_span, timing::SchedTimingToday},
template::RenderedNode,
text::html_to_text_line,
2021-03-20 12:01:53 +01:00
};
#[derive(Debug, PartialEq, Eq, Clone, Copy, Display, EnumIter, EnumString)]
2021-04-06 16:54:09 +02:00
#[strum(serialize_all = "camelCase")]
2021-03-30 11:59:52 +02:00
pub enum Column {
2021-04-06 16:54:09 +02:00
#[strum(serialize = "")]
2021-03-30 21:39:15 +02:00
Custom,
Answer,
CardMod,
#[strum(serialize = "template")]
Cards,
Deck,
#[strum(serialize = "cardDue")]
Due,
#[strum(serialize = "cardEase")]
Ease,
#[strum(serialize = "cardLapses")]
Lapses,
2021-04-06 16:54:09 +02:00
#[strum(serialize = "cardIvl")]
Interval,
2021-04-06 16:54:09 +02:00
#[strum(serialize = "noteCrt")]
2021-03-30 21:39:15 +02:00
NoteCreation,
NoteMod,
2021-04-06 16:54:09 +02:00
#[strum(serialize = "note")]
2021-03-30 21:39:15 +02:00
Notetype,
Question,
#[strum(serialize = "cardReps")]
Reps,
#[strum(serialize = "noteFld")]
SortField,
2021-04-09 18:03:29 +02:00
#[strum(serialize = "noteTags")]
Tags,
2021-03-30 11:59:52 +02:00
}
2021-04-06 16:54:09 +02:00
impl Default for Column {
fn default() -> Self {
Column::Custom
}
}
2021-04-08 13:29:32 +02:00
struct RowContext {
notes_mode: bool,
cards: Vec<Card>,
2021-03-20 12:01:53 +01:00
note: Note,
notetype: Arc<Notetype>,
deck: Arc<Deck>,
original_deck: Option<Arc<Deck>>,
tr: I18n,
2021-03-20 12:01:53 +01:00
timing: SchedTimingToday,
render_context: RenderContext,
2021-03-21 18:46:04 +01:00
}
enum RenderContext {
// The answer string needs the question string, but not the other way around,
// so only build the answer string when needed.
Ok {
question: String,
answer_nodes: Vec<RenderedNode>,
},
Err(String),
Unset,
2021-03-20 12:01:53 +01:00
}
2021-03-30 11:59:52 +02:00
fn card_render_required(columns: &[Column]) -> bool {
columns
.iter()
2021-03-30 11:59:52 +02:00
.any(|c| matches!(c, Column::Question | Column::Answer))
}
2021-03-30 20:50:09 +02:00
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
}
2021-03-30 21:40:35 +02:00
/// Returns true if the card has a due date in terms of days.
2021-03-30 20:50:09 +02:00
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).saturating_mul(86400)) as i64,
))
2021-03-30 20:50:09 +02:00
} else {
None
}
}
}
2021-03-31 08:56:54 +02:00
impl Note {
fn is_marked(&self) -> bool {
self.tags
.iter()
.any(|tag| tag.eq_ignore_ascii_case("marked"))
}
}
2021-04-09 19:09:48 +02:00
impl Column {
pub fn cards_mode_label(self, tr: &I18n) -> String {
2021-04-09 19:09:48 +02:00
match self {
Self::Answer => tr.browsing_answer(),
Self::CardMod => tr.search_card_modified(),
Self::Cards => tr.browsing_card(),
Self::Deck => tr.decks_deck(),
Self::Due => tr.statistics_due_date(),
Self::Custom => tr.browsing_addon(),
Self::Ease => tr.browsing_ease(),
Self::Interval => tr.browsing_interval(),
Self::Lapses => tr.scheduling_lapses(),
Self::NoteCreation => tr.browsing_created(),
Self::NoteMod => tr.search_note_modified(),
Self::Notetype => tr.browsing_note(),
Self::Question => tr.browsing_question(),
Self::Reps => tr.scheduling_reviews(),
Self::SortField => tr.browsing_sort_field(),
Self::Tags => tr.editing_tags(),
2021-04-09 19:09:48 +02:00
}
.into()
}
pub fn notes_mode_label(self, tr: &I18n) -> String {
2021-04-09 19:09:48 +02:00
match self {
Self::CardMod => tr.search_card_modified(),
Self::Cards => tr.editing_cards(),
Self::Ease => tr.browsing_average_ease(),
Self::Interval => tr.browsing_average_interval(),
Self::Reps => tr.scheduling_reviews(),
_ => return self.cards_mode_label(tr),
2021-04-09 19:09:48 +02:00
}
.into()
}
pub fn cards_mode_tooltip(self, tr: &I18n) -> String {
2021-09-30 12:45:05 +02:00
match self {
Self::Answer => tr.browsing_tooltip_answer(),
Self::CardMod => tr.browsing_tooltip_card_modified(),
Self::Cards => tr.browsing_tooltip_card(),
Self::NoteMod => tr.browsing_tooltip_note_modified(),
Self::Notetype => tr.browsing_tooltip_notetype(),
Self::Question => tr.browsing_tooltip_question(),
2021-09-30 12:45:05 +02:00
_ => "".into(),
}
.into()
}
pub fn notes_mode_tooltip(self, tr: &I18n) -> String {
2021-09-30 12:45:05 +02:00
match self {
Self::Cards => tr.browsing_tooltip_cards(),
_ => return self.cards_mode_label(tr),
2021-09-30 12:45:05 +02:00
}
.into()
}
pub fn default_order(self) -> pb::browser_columns::Sorting {
2021-04-09 19:09:48 +02:00
use pb::browser_columns::Sorting;
match self {
Column::Question | Column::Answer | Column::Custom => Sorting::None,
Column::SortField | Column::Tags | Column::Notetype | Column::Deck => {
Sorting::Ascending
}
Column::CardMod
| Column::Cards
| Column::Due
| Column::Ease
| Column::Lapses
| Column::Interval
| Column::NoteCreation
| Column::NoteMod
| Column::Reps => Sorting::Descending,
2021-04-09 19:09:48 +02:00
}
}
pub fn uses_cell_font(self) -> bool {
matches!(self, Self::Question | Self::Answer | Self::SortField)
}
pub fn alignment(self) -> pb::browser_columns::Alignment {
use pb::browser_columns::Alignment;
match self {
Self::Question
| Self::Answer
| Self::Cards
| Self::Deck
| Self::SortField
| Self::Notetype
| Self::Tags => Alignment::Start,
_ => Alignment::Center,
}
}
}
impl Collection {
2021-04-09 19:09:48 +02:00
pub fn all_browser_columns(&self) -> pb::BrowserColumns {
let mut columns: Vec<pb::browser_columns::Column> = Column::iter()
.filter(|&c| c != Column::Custom)
.map(|c| c.to_pb_column(&self.tr))
.collect();
2021-04-10 09:14:20 +02:00
columns.sort_by(|c1, c2| c1.cards_mode_label.cmp(&c2.cards_mode_label));
2021-04-09 19:09:48 +02:00
pb::BrowserColumns { columns }
}
pub fn browser_row_for_id(&mut self, id: i64) -> Result<pb::BrowserRow> {
let notes_mode = self.get_config_bool(BoolKey::BrowserTableShowNotesMode);
let columns = Arc::clone(
self.state
.active_browser_columns
.as_ref()
.or_invalid("Active browser columns not set.")?,
);
2021-04-08 13:29:32 +02:00
RowContext::new(self, id, notes_mode, card_render_required(&columns))?.browser_row(&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)?
}
.or_not_found(id)
}
}
2021-03-21 18:46:04 +01:00
impl RenderContext {
fn new(col: &mut Collection, card: &Card, note: &Note, notetype: &Notetype) -> Self {
match notetype
.get_template(card.template_idx)
.and_then(|template| col.render_card(note, card, notetype, template, true))
{
Ok(render) => RenderContext::Ok {
question: rendered_nodes_to_str(&render.qnodes),
answer_nodes: render.anodes,
},
Err(err) => RenderContext::Err(err.message(&col.tr)),
}
}
fn side_str(&self, is_answer: bool) -> String {
let back;
let html = match self {
Self::Ok {
question,
answer_nodes,
} => {
if is_answer {
back = rendered_nodes_to_str(answer_nodes);
back.strip_prefix(question).unwrap_or(&back)
} else {
question
}
}
Self::Err(err) => err,
Self::Unset => "Invalid input: RenderContext unset",
};
html_to_text_line(html, true).into()
2021-03-21 18:46:04 +01:00
}
}
fn rendered_nodes_to_str(nodes: &[RenderedNode]) -> String {
let txt = nodes
.iter()
.map(|node| match node {
RenderedNode::Text { text } => text,
RenderedNode::Replacement { current_text, .. } => current_text,
})
.join("");
prettify_av_tags(txt)
}
2021-04-08 13:29:32 +02:00
impl RowContext {
fn new(
col: &mut Collection,
id: i64,
notes_mode: bool,
with_card_render: bool,
) -> Result<Self> {
let cards;
let note;
if notes_mode {
note = col
.get_note_maybe_with_fields(NoteId(id), with_card_render)
.map_err(|e| match e {
AnkiError::NotFound { .. } => AnkiError::Deleted,
_ => e,
})?;
2021-04-08 13:29:32 +02:00
cards = col.storage.all_cards_of_note(note.id)?;
if cards.is_empty() {
return Err(AnkiError::DatabaseCheckRequired);
}
2021-04-08 13:29:32 +02:00
} else {
cards = vec![col
.storage
.get_card(CardId(id))?
.ok_or(AnkiError::Deleted)?];
2021-04-08 13:29:32 +02:00
note = col.get_note_maybe_with_fields(cards[0].note_id, with_card_render)?;
}
let notetype = col
.get_notetype(note.notetype_id)?
.or_not_found(note.notetype_id)?;
let deck = col
.get_deck(cards[0].deck_id)?
.or_not_found(cards[0].deck_id)?;
2021-04-08 13:29:32 +02:00
let original_deck = if cards[0].original_deck_id.0 != 0 {
Some(
2021-04-08 13:29:32 +02:00
col.get_deck(cards[0].original_deck_id)?
.or_not_found(cards[0].original_deck_id)?,
)
} else {
None
};
let timing = col.timing_today()?;
2021-03-21 18:46:04 +01:00
let render_context = if with_card_render {
RenderContext::new(col, &cards[0], &note, &notetype)
} else {
RenderContext::Unset
};
2021-04-08 13:29:32 +02:00
Ok(RowContext {
notes_mode,
cards,
note,
notetype,
deck,
original_deck,
tr: col.tr.clone(),
timing,
2021-03-21 18:46:04 +01:00
render_context,
})
}
fn browser_row(&self, columns: &[Column]) -> Result<pb::BrowserRow> {
Ok(pb::BrowserRow {
2021-04-08 13:29:32 +02:00
cells: columns
.iter()
.map(|&column| self.get_cell(column))
.collect::<Result<_>>()?,
color: self.get_row_color() as i32,
font_name: self.get_row_font_name()?,
font_size: self.get_row_font_size()?,
2021-04-08 13:29:32 +02:00
})
}
fn get_cell(&self, column: Column) -> Result<pb::browser_row::Cell> {
Ok(pb::browser_row::Cell {
2021-04-08 13:29:32 +02:00
text: self.get_cell_text(column)?,
is_rtl: self.get_is_rtl(column),
})
}
2021-04-09 22:51:18 +02:00
fn get_cell_text(&self, column: Column) -> Result<String> {
2021-04-08 13:29:32 +02:00
Ok(match column {
Column::Question => self.render_context.side_str(false),
Column::Answer => self.render_context.side_str(true),
Column::Deck => self.deck_str(),
Column::Due => self.due_str(),
Column::Ease => self.ease_str(),
Column::Interval => self.interval_str(),
Column::Lapses => self.cards.iter().map(|c| c.lapses).sum::<u32>().to_string(),
2021-04-08 20:14:10 +02:00
Column::CardMod => self.card_mod_str(),
Column::Reps => self.cards.iter().map(|c| c.reps).sum::<u32>().to_string(),
Column::Cards => self.cards_str()?,
2021-04-08 13:29:32 +02:00
Column::NoteCreation => self.note_creation_str(),
Column::SortField => self.note_field_str(),
2021-04-08 13:29:32 +02:00
Column::NoteMod => self.note.mtime.date_string(),
Column::Tags => self.note.tags.join(" "),
2021-04-08 13:29:32 +02:00
Column::Notetype => self.notetype.name.to_owned(),
Column::Custom => "".to_string(),
})
}
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;
Plaintext import/export (#1850) * Add crate csv * Add start of csv importing on backend * Add Menomosyne serializer * Add csv and json importing on backend * Add plaintext importing on frontend * Add csv metadata extraction on backend * Add csv importing with GUI * Fix missing dfa file in build Added compile_data_attr, then re-ran cargo/update.py. * Don't use doubly buffered reader in csv * Escape HTML entities if CSV is not HTML Also use name 'is_html' consistently. * Use decimal number as foreign ease (like '2.5') * ForeignCard.ivl → ForeignCard.interval * Only allow fixed set of CSV delimiters * Map timestamp of ForeignCard to native due time * Don't trim CSV records * Document use of empty strings for defaults * Avoid creating CardGenContexts for every note This requires CardGenContext to be generic, so it works both with an owned and borrowed notetype. * Show all accepted file types in import file picker * Add import_json_file() * factor → ease_factor * delimter_from_value → delimiter_from_value * Map columns to fields, not the other way around * Fallback to current config for csv metadata * Add start of new import csv screen * Temporary fix for compilation issue on Linux/Mac * Disable jest bazel action for import-csv Jest fails with an error code if no tests are available, but this would not be noticable on Windows as Jest is not run there. * Fix field mapping issue * Revert "Temporary fix for compilation issue on Linux/Mac" This reverts commit 21f8a261408cdae49ec031aa21a1b659c4f66d82. * Add HtmlSwitch and move Switch to components * Fix spacing and make selectors consistent * Fix shortcut tooltip * Place import button at the top with path * Fix meta column indices * Remove NotetypeForString * Fix queue and type of foreign cards * Support different dupe resolution strategies * Allow dupe resolution selection when importing CSV * Test import of unnormalized text Close #1863. * Fix logging of foreign notes * Implement CSV exports * Use db_scalar() in notes_table_len() * Rework CSV metadata - Notetypes and decks are either defined by a global id or by a column. - If a notetype id is provided, its field map must also be specified. - If a notetype column is provided, fields are now mapped by index instead of name at import time. So the first non-meta column is used for the first field of every note, regardless of notetype. This makes importing easier and should improve compatiblity with files without a notetype column. - Ensure first field can be mapped to a column. - Meta columns must be defined as `#[meta name]:[column index]` instead of in the `#columns` tag. - Column labels contain the raw names defined by the file and must be prettified by the frontend. * Adjust frontend to new backend column mapping * Add force flags for is_html and delimiter * Detect if CSV is HTML by field content * Update dupe resolution labels * Simplify selectors * Fix coalescence of oneofs in TS * Disable meta columns from selection Plus a lot of refactoring. * Make import button stick to the bottom * Write delimiter and html flag into csv * Refetch field map after notetype change * Fix log labels for csv import * Log notes whose deck/notetype was missing * Fix hiding of empty log queues * Implement adding tags to all notes of a csv * Fix dupe resolution not being set in log * Implement adding tags to updated notes of a csv * Check first note field is not empty * Temporary fix for build on Linux/Mac * Fix inverted html check (dae) * Remove unused ftl string * Delimiter → Separator * Remove commented-out line * Don't accept .json files * Tweak tag ftl strings * Remove redundant blur call * Strip sound and add spaces in csv export * Export HTML by default * Fix unset deck in Mnemosyne import Also accept both numbers and strings for notetypes and decks in JSON. * Make DupeResolution::Update the default * Fix missing dot in extension * Make column indices 1-based * Remove StickContainer from TagEditor Fixes line breaking, border and z index on ImportCsvPage. * Assign different key combos to tag editors * Log all updated duplicates Add a log field for the true number of found notes. * Show identical notes as skipped * Split tag-editor into separate ts module (dae) * Add progress for CSV export * Add progress for text import * Tidy-ups after tag-editor split (dae) - import-csv no longer depends on editor - remove some commented lines
2022-06-01 12:26:16 +02:00
html_to_text_line(&self.note.fields()[index], true).into()
2021-04-08 13:29:32 +02:00
}
fn get_is_rtl(&self, column: Column) -> bool {
match column {
Column::SortField => {
2021-04-08 13:29:32 +02:00
let index = self.notetype.config.sort_field_idx as usize;
self.notetype.fields[index].config.rtl
}
_ => false,
}
}
2021-03-20 12:01:53 +01:00
fn template(&self) -> Result<&CardTemplate> {
2021-04-08 13:29:32 +02:00
self.notetype.get_template(self.cards[0].template_idx)
2021-03-20 12:01:53 +01:00
}
2021-04-09 22:51:18 +02:00
fn due_str(&self) -> String {
2021-04-08 20:45:47 +02:00
if self.notes_mode {
self.note_due_str()
} else {
self.card_due_str()
}
}
2021-04-09 22:51:18 +02:00
fn card_due_str(&self) -> String {
2021-04-08 13:29:32 +02:00
let due = if self.cards[0].is_filtered_deck() {
self.tr.browsing_filtered()
2021-04-08 13:29:32 +02:00
} else if self.cards[0].is_new_type_or_queue() {
self.tr.statistics_due_for_new_card(self.cards[0].due)
} else if let Some(time) = self.cards[0].due_time(&self.timing) {
2021-03-30 20:50:09 +02:00
time.date_string().into()
} else {
2021-03-30 20:50:09 +02:00
return "".into();
2021-03-21 21:18:56 +01:00
};
2021-04-08 13:29:32 +02:00
if self.cards[0].is_undue_queue() {
2021-03-21 21:18:56 +01:00
format!("({})", due)
} else {
2021-03-27 01:39:53 +01:00
due.into()
2021-03-21 21:18:56 +01:00
}
2021-03-20 12:01:53 +01:00
}
2021-04-08 20:45:47 +02:00
/// 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 ease of the non-new cards or a hint if there aren't any.
fn 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)
}
2021-03-20 12:01:53 +01:00
}
2021-04-08 20:45:47 +02:00
/// Returns the average interval of the review and relearn cards if there are any.
fn interval_str(&self) -> String {
if !self.notes_mode {
match self.cards[0].ctype {
CardType::New => return self.tr.browsing_new().into(),
CardType::Learn => return self.tr.browsing_learning().into(),
CardType::Review | CardType::Relearn => (),
}
}
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,
)
}
}
2021-03-20 12:01:53 +01:00
2021-04-08 20:14:10 +02:00
fn card_mod_str(&self) -> String {
self.cards
.iter()
.map(|c| c.mtime)
.max()
.expect("cards missing from RowContext")
2021-04-08 20:14:10 +02:00
.date_string()
}
2021-04-09 22:51:18 +02:00
fn deck_str(&self) -> String {
2021-04-08 19:46:06 +02:00
if self.notes_mode {
let decks = self.cards.iter().map(|c| c.deck_id).unique().count();
if decks > 1 {
return format!("({})", decks);
}
}
let deck_name = self.deck.human_name();
if let Some(original_deck) = &self.original_deck {
format!("{} ({})", &deck_name, &original_deck.human_name())
} else {
deck_name
}
}
2021-03-20 12:01:53 +01:00
2021-04-08 20:45:47 +02:00
fn cards_str(&self) -> Result<String> {
Ok(if self.notes_mode {
self.cards.len().to_string()
} else {
let name = &self.template()?.name;
match self.notetype.config.kind() {
NotetypeKind::Normal => name.to_owned(),
NotetypeKind::Cloze => format!("{} {}", name, self.cards[0].template_idx + 1),
}
2021-03-20 12:01:53 +01:00
})
}
2021-03-20 12:01:53 +01:00
fn get_row_font_name(&self) -> Result<String> {
Ok(self.template()?.config.browser_font_name.to_owned())
}
fn get_row_font_size(&self) -> Result<u32> {
Ok(self.template()?.config.browser_font_size)
}
fn get_row_color(&self) -> pb::browser_row::Color {
use pb::browser_row::Color;
2021-04-08 13:29:32 +02:00
if self.notes_mode {
if self.note.is_marked() {
Color::Marked
} else {
Color::Default
}
} else {
2021-04-08 13:29:32 +02:00
match self.cards[0].flags {
1 => Color::FlagRed,
2 => Color::FlagOrange,
3 => Color::FlagGreen,
4 => Color::FlagBlue,
2021-06-01 18:14:43 +02:00
5 => Color::FlagPink,
2021-05-31 12:03:30 +02:00
6 => Color::FlagTurquoise,
7 => Color::FlagPurple,
2021-04-08 13:29:32 +02:00
_ => {
if self.note.is_marked() {
Color::Marked
} else if self.cards[0].queue == CardQueue::Suspended {
Color::Suspended
} else {
Color::Default
}
}
}
}
}
2021-03-20 12:01:53 +01:00
}