Merge pull request #1082 from RumovZ/backend-rows

Backend rows
This commit is contained in:
Damien Elmes 2021-03-23 18:31:42 +10:00 committed by GitHub
commit 5fd79d9246
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 638 additions and 268 deletions

View file

@ -4,6 +4,7 @@ persistent = no
[TYPECHECK] [TYPECHECK]
ignored-classes= ignored-classes=
BrowserRow,
FormatTimespanIn, FormatTimespanIn,
AnswerCardIn, AnswerCardIn,
UnburyCardsInCurrentDeckIn, UnburyCardsInCurrentDeckIn,

View file

@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, List, Literal, Optional, Sequence, Tuple, Union from typing import Any, Generator, List, Literal, Optional, Sequence, Tuple, Union
import anki._backend.backend_pb2 as _pb import anki._backend.backend_pb2 as _pb
@ -19,6 +19,7 @@ OpChanges = _pb.OpChanges
OpChangesWithCount = _pb.OpChangesWithCount OpChangesWithCount = _pb.OpChangesWithCount
OpChangesWithID = _pb.OpChangesWithID OpChangesWithID = _pb.OpChangesWithID
DefaultsForAdding = _pb.DeckAndNotetype DefaultsForAdding = _pb.DeckAndNotetype
BrowserRow = _pb.BrowserRow
import copy import copy
import os import os
@ -683,6 +684,20 @@ class Collection:
else: else:
return SearchNode.Group.Joiner.OR return SearchNode.Group.Joiner.OR
# Browser rows
##########################################################################
def browser_row_for_card(
self, cid: int
) -> Tuple[Generator[Tuple[str, bool], None, None], BrowserRow.Color.V, str, int]:
row = self._backend.browser_row_for_card(cid)
return (
((cell.text, cell.is_rtl) for cell in row.cells),
row.color,
row.font_name,
row.font_size,
)
# Config # Config
########################################################################## ##########################################################################

View file

@ -6,6 +6,7 @@ ignore = forms,hooks_gen.py
[TYPECHECK] [TYPECHECK]
ignored-modules=win32file,pywintypes,socket,win32pipe,winrt,pyaudio ignored-modules=win32file,pywintypes,socket,win32pipe,winrt,pyaudio
ignored-classes= ignored-classes=
BrowserRow,
SearchNode, SearchNode,
Config, Config,
OpChanges OpChanges

View file

@ -5,21 +5,32 @@ from __future__ import annotations
import html import html
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass
from operator import itemgetter from operator import itemgetter
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union, cast from typing import (
Any,
Callable,
Dict,
Generator,
List,
Optional,
Sequence,
Tuple,
Union,
cast,
)
import aqt import aqt
import aqt.forms import aqt.forms
from anki.cards import Card from anki.cards import Card
from anki.collection import Collection, Config, OpChanges, SearchNode from anki.collection import BrowserRow, Collection, Config, OpChanges, SearchNode
from anki.consts import * from anki.consts import *
from anki.errors import InvalidInput, NotFoundError from anki.errors import InvalidInput, NotFoundError
from anki.lang import without_unicode_isolation from anki.lang import without_unicode_isolation
from anki.models import NoteType from anki.models import NoteType
from anki.stats import CardStats from anki.stats import CardStats
from anki.tags import MARKED_TAG from anki.tags import MARKED_TAG
from anki.utils import htmlToTextLine, ids2str, isMac, isWin from anki.utils import ids2str, isMac, isWin
from aqt import AnkiQt, colors, gui_hooks from aqt import AnkiQt, colors, gui_hooks
from aqt.card_ops import set_card_deck, set_card_flag from aqt.card_ops import set_card_deck, set_card_flag
from aqt.editor import Editor from aqt.editor import Editor
@ -91,25 +102,63 @@ class SearchContext:
# Data model # Data model
########################################################################## ##########################################################################
# temporary cache to avoid hitting the database on redraw
@dataclass @dataclass
class Cell: class Cell:
text: str = "" text: str
font: Optional[Tuple[str, int]] = None is_rtl: bool
is_rtl: bool = False
@dataclass
class CellRow: class CellRow:
columns: List[Cell] def __init__(
refreshed_at: float = field(default_factory=time.time) self,
card_flag: int = 0 cells: Generator[Tuple[str, bool], None, None],
marked: bool = False color: BrowserRow.Color.V,
suspended: bool = False font_name: str,
font_size: int,
) -> None:
self.refreshed_at: float = time.time()
self.cells: Tuple[Cell, ...] = tuple(Cell(*cell) for cell in cells)
self.color: Optional[Tuple[str, str]] = backend_color_to_aqt_color(color)
self.font_name: str = font_name or "arial"
self.font_size: int = font_size if font_size > 0 else 12
def is_stale(self, threshold: float) -> bool: def is_stale(self, threshold: float) -> bool:
return self.refreshed_at < threshold return self.refreshed_at < threshold
@staticmethod
def generic(length: int, cell_text: str) -> CellRow:
return CellRow(
((cell_text, False) for cell in range(length)),
BrowserRow.COLOR_DEFAULT,
"arial",
12,
)
@staticmethod
def placeholder(length: int) -> CellRow:
return CellRow.generic(length, "...")
@staticmethod
def deleted(length: int) -> CellRow:
return CellRow.generic(length, tr(TR.BROWSING_ROW_DELETED))
def backend_color_to_aqt_color(color: BrowserRow.Color.V) -> Optional[Tuple[str, str]]:
if color == BrowserRow.COLOR_MARKED:
return colors.MARKED_BG
if color == BrowserRow.COLOR_SUSPENDED:
return colors.SUSPENDED_BG
if color == BrowserRow.COLOR_FLAG_RED:
return colors.FLAG1_BG
if color == BrowserRow.COLOR_FLAG_ORANGE:
return colors.FLAG2_BG
if color == BrowserRow.COLOR_FLAG_GREEN:
return colors.FLAG3_BG
if color == BrowserRow.COLOR_FLAG_BLUE:
return colors.FLAG4_BG
return None
class DataModel(QAbstractTableModel): class DataModel(QAbstractTableModel):
def __init__(self, browser: Browser) -> None: def __init__(self, browser: Browser) -> None:
@ -121,73 +170,45 @@ class DataModel(QAbstractTableModel):
"activeCols", ["noteFld", "template", "cardDue", "deck"] "activeCols", ["noteFld", "template", "cardDue", "deck"]
) )
self.cards: Sequence[int] = [] self.cards: Sequence[int] = []
self.cardObjs: Dict[int, Card] = {} self._rows: Dict[int, CellRow] = {}
self._row_cache: Dict[int, CellRow] = {}
self._last_refresh = 0.0 self._last_refresh = 0.0
# serve stale content to avoid hitting the DB? # serve stale content to avoid hitting the DB?
self.block_updates = False self.block_updates = False
def getCard(self, index: QModelIndex) -> Optional[Card]: def get_id(self, index: QModelIndex) -> int:
return self._get_card_by_row(index.row()) return self.cards[index.row()]
def _get_card_by_row(self, row: int) -> Optional[Card]:
"None if card is not in DB."
id = self.cards[row]
if not id in self.cardObjs:
try:
card = self.col.getCard(id)
except NotFoundError:
# deleted
card = None
self.cardObjs[id] = card
return self.cardObjs[id]
# Card and cell data cache
######################################################################
# Stopgap until we can fetch this data a row at a time from Rust.
def get_cell(self, index: QModelIndex) -> Cell: def get_cell(self, index: QModelIndex) -> Cell:
row = self.get_row(index.row()) return self.get_row(index).cells[index.column()]
return row.columns[index.column()]
def get_row(self, row: int) -> CellRow: def get_row(self, index: QModelIndex) -> CellRow:
if entry := self._row_cache.get(row): cid = self.get_id(index)
if not self.block_updates and entry.is_stale(self._last_refresh): if row := self._rows.get(cid):
if not self.block_updates and row.is_stale(self._last_refresh):
# need to refresh # need to refresh
entry = self._build_cell_row(row) self._rows[cid] = self._fetch_row_from_backend(cid)
self._row_cache[row] = entry return self._rows[cid]
return entry # return row, even if it's stale
else: return row
# return entry, even if it's stale if self.block_updates:
return entry # blank row until we unblock
elif self.block_updates: return CellRow.placeholder(len(self.activeCols))
# blank entry until we unblock # missing row, need to build
return CellRow(columns=[Cell(text="...")] * len(self.activeCols)) self._rows[cid] = self._fetch_row_from_backend(cid)
else: return self._rows[cid]
# missing entry, need to build
entry = self._build_cell_row(row)
self._row_cache[row] = entry
return entry
def _build_cell_row(self, row: int) -> CellRow: def _fetch_row_from_backend(self, cid: int) -> CellRow:
if not (card := self._get_card_by_row(row)): try:
cell = Cell(text=tr(TR.BROWSING_ROW_DELETED)) return CellRow(*self.col.browser_row_for_card(cid))
return CellRow(columns=[cell] * len(self.activeCols)) except:
return CellRow.deleted(len(self.activeCols))
return CellRow( def getCard(self, index: QModelIndex) -> Optional[Card]:
columns=[ """Try to return the indicated, possibly deleted card."""
Cell( try:
text=self._column_data(card, column_type), return self.col.getCard(self.get_id(index))
font=self._font(card, column_type), except:
is_rtl=self._is_rtl(card, column_type), return None
)
for column_type in self.activeCols
],
# should probably make these an enum instead?
card_flag=card.user_flag(),
marked=card.note().has_tag(MARKED_TAG),
suspended=card.queue == QUEUE_TYPE_SUSPENDED,
)
# Model interface # Model interface
###################################################################### ######################################################################
@ -204,17 +225,16 @@ class DataModel(QAbstractTableModel):
def data(self, index: QModelIndex = QModelIndex(), role: int = 0) -> Any: def data(self, index: QModelIndex = QModelIndex(), role: int = 0) -> Any:
if not index.isValid(): if not index.isValid():
return return QVariant()
if role == Qt.FontRole: if role == Qt.FontRole:
if font := self.get_cell(index).font: if self.activeCols[index.column()] not in ("question", "answer", "noteFld"):
qfont = QFont() return QVariant()
qfont.setFamily(font[0]) qfont = QFont()
qfont.setPixelSize(font[1]) row = self.get_row(index)
return qfont qfont.setFamily(row.font_name)
else: qfont.setPixelSize(row.font_size)
return None return qfont
if role == Qt.TextAlignmentRole:
elif role == Qt.TextAlignmentRole:
align: Union[Qt.AlignmentFlag, int] = Qt.AlignVCenter align: Union[Qt.AlignmentFlag, int] = Qt.AlignVCenter
if self.activeCols[index.column()] not in ( if self.activeCols[index.column()] not in (
"question", "question",
@ -227,10 +247,9 @@ class DataModel(QAbstractTableModel):
): ):
align |= Qt.AlignHCenter align |= Qt.AlignHCenter
return align return align
elif role == Qt.DisplayRole or role == Qt.EditRole: if role in (Qt.DisplayRole, Qt.ToolTipRole):
return self.get_cell(index).text return self.get_cell(index).text
else: return QVariant()
return
def headerData( def headerData(
self, section: int, orientation: Qt.Orientation, role: int = 0 self, section: int, orientation: Qt.Orientation, role: int = 0
@ -238,7 +257,7 @@ class DataModel(QAbstractTableModel):
if orientation == Qt.Vertical: if orientation == Qt.Vertical:
return None return None
elif role == Qt.DisplayRole and section < len(self.activeCols): elif role == Qt.DisplayRole and section < len(self.activeCols):
type = self.columnType(section) type = self.activeCols[section]
txt = None txt = None
for stype, name in self.browser.columns: for stype, name in self.browser.columns:
if type == stype: if type == stype:
@ -291,8 +310,7 @@ class DataModel(QAbstractTableModel):
self.browser.mw.progress.start() self.browser.mw.progress.start()
self.saveSelection() self.saveSelection()
self.beginResetModel() self.beginResetModel()
self.cardObjs = {} self._rows = {}
self._row_cache = {}
def endReset(self) -> None: def endReset(self) -> None:
self.endResetModel() self.endResetModel()
@ -366,8 +384,7 @@ class DataModel(QAbstractTableModel):
def op_executed(self, op: OpChanges, focused: bool) -> None: def op_executed(self, op: OpChanges, focused: bool) -> None:
print("op executed") print("op executed")
if op.card or op.note or op.deck or op.notetype: if op.card or op.note or op.deck or op.notetype:
# clear card cache self._rows = {}
self.cardObjs = {}
if focused: if focused:
self.redraw_cells() self.redraw_cells()
@ -378,148 +395,6 @@ class DataModel(QAbstractTableModel):
self.block_updates = False self.block_updates = False
self.redraw_cells() self.redraw_cells()
# Column data
######################################################################
def columnType(self, column: int) -> str:
return self.activeCols[column]
def time_format(self) -> str:
return "%Y-%m-%d"
def _font(self, card: Card, column_type: str) -> Optional[Tuple[str, int]]:
if column_type not in ("question", "answer", "noteFld"):
return None
template = card.template()
if not template.get("bfont"):
return None
return (
cast(str, template.get("bfont", "arial")),
cast(int, template.get("bsize", 12)),
)
# legacy
def columnData(self, index: QModelIndex) -> str:
col = index.column()
type = self.columnType(col)
c = self.getCard(index)
if not c:
return tr(TR.BROWSING_ROW_DELETED)
else:
return self._column_data(c, type)
def _column_data(self, card: Card, column_type: str) -> str:
type = column_type
if type == "question":
return self.question(card)
elif type == "answer":
return self.answer(card)
elif type == "noteFld":
f = card.note()
return htmlToTextLine(f.fields[self.col.models.sortIdx(f.model())])
elif type == "template":
t = card.template()["name"]
if card.model()["type"] == MODEL_CLOZE:
t = f"{t} {card.ord + 1}"
return cast(str, t)
elif type == "cardDue":
# catch invalid dates
try:
t = self._next_due(card)
except:
t = ""
if card.queue < 0:
t = f"({t})"
return t
elif type == "noteCrt":
return time.strftime(
self.time_format(), time.localtime(card.note().id / 1000)
)
elif type == "noteMod":
return time.strftime(self.time_format(), time.localtime(card.note().mod))
elif type == "cardMod":
return time.strftime(self.time_format(), time.localtime(card.mod))
elif type == "cardReps":
return str(card.reps)
elif type == "cardLapses":
return str(card.lapses)
elif type == "noteTags":
return " ".join(card.note().tags)
elif type == "note":
return card.model()["name"]
elif type == "cardIvl":
if card.type == CARD_TYPE_NEW:
return tr(TR.BROWSING_NEW)
elif card.type == CARD_TYPE_LRN:
return tr(TR.BROWSING_LEARNING)
return self.col.format_timespan(card.ivl * 86400)
elif type == "cardEase":
if card.type == CARD_TYPE_NEW:
return tr(TR.BROWSING_NEW)
return "%d%%" % (card.factor / 10)
elif type == "deck":
if card.odid:
# in a cram deck
return "%s (%s)" % (
self.browser.mw.col.decks.name(card.did),
self.browser.mw.col.decks.name(card.odid),
)
# normal deck
return self.browser.mw.col.decks.name(card.did)
else:
return ""
def question(self, c: Card) -> str:
return htmlToTextLine(c.q(browser=True))
def answer(self, c: Card) -> str:
if c.template().get("bafmt"):
# they have provided a template, use it verbatim
c.q(browser=True)
return htmlToTextLine(c.a())
# need to strip question from answer
q = self.question(c)
a = htmlToTextLine(c.a())
if a.startswith(q):
return a[len(q) :].strip()
return a
# legacy
def nextDue(self, c: Card, index: QModelIndex) -> str:
return self._next_due(c)
def _next_due(self, card: Card) -> str:
date: float
if card.odid:
return tr(TR.BROWSING_FILTERED)
elif card.queue == QUEUE_TYPE_LRN:
date = card.due
elif card.queue == QUEUE_TYPE_NEW or card.type == CARD_TYPE_NEW:
return tr(TR.STATISTICS_DUE_FOR_NEW_CARD, number=card.due)
elif card.queue in (QUEUE_TYPE_REV, QUEUE_TYPE_DAY_LEARN_RELEARN) or (
card.type == CARD_TYPE_REV and card.queue < 0
):
date = time.time() + ((card.due - self.col.sched.today) * 86400)
else:
return ""
return time.strftime(self.time_format(), time.localtime(date))
# legacy
def isRTL(self, index: QModelIndex) -> bool:
col = index.column()
type = self.columnType(col)
c = self.getCard(index)
return self._is_rtl(c, type)
def _is_rtl(self, card: Card, column_type: str) -> bool:
if column_type != "noteFld":
return False
nt = card.note().model()
return nt["flds"][self.col.models.sortIdx(nt)]["rtl"]
# Line painter # Line painter
###################################################################### ######################################################################
@ -534,27 +409,13 @@ class StatusDelegate(QItemDelegate):
def paint( def paint(
self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex
) -> None: ) -> None:
row = self.model.get_row(index.row()) if self.model.get_cell(index).is_rtl:
cell = row.columns[index.column()]
if cell.is_rtl:
option.direction = Qt.RightToLeft option.direction = Qt.RightToLeft
if row_color := self.model.get_row(index).color:
if row.card_flag: brush = QBrush(theme_manager.qcolor(row_color))
color = getattr(colors, f"FLAG{row.card_flag}_BG")
elif row.marked:
color = colors.MARKED_BG
elif row.suspended:
color = colors.SUSPENDED_BG
else:
color = None
if color:
brush = QBrush(theme_manager.qcolor(color))
painter.save() painter.save()
painter.fillRect(option.rect, brush) painter.fillRect(option.rect, brush)
painter.restore() painter.restore()
return QItemDelegate.paint(self, painter, option, index) return QItemDelegate.paint(self, painter, option, index)

View file

@ -108,6 +108,7 @@ enum ServiceIndex {
SERVICE_INDEX_I18N = 12; SERVICE_INDEX_I18N = 12;
SERVICE_INDEX_COLLECTION = 13; SERVICE_INDEX_COLLECTION = 13;
SERVICE_INDEX_CARDS = 14; SERVICE_INDEX_CARDS = 14;
SERVICE_INDEX_BROWSER_ROWS = 15;
} }
service SchedulingService { service SchedulingService {
@ -282,6 +283,10 @@ service CardsService {
rpc SetFlag(SetFlagIn) returns (OpChanges); rpc SetFlag(SetFlagIn) returns (OpChanges);
} }
service BrowserRowsService {
rpc BrowserRowForCard(CardID) returns (BrowserRow);
}
// Protobuf stored in .anki2 files // Protobuf stored in .anki2 files
// These should be moved to a separate file in the future // These should be moved to a separate file in the future
/////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////
@ -1044,6 +1049,26 @@ message FindAndReplaceIn {
string field_name = 6; string field_name = 6;
} }
message BrowserRow {
message Cell {
string text = 1;
bool is_rtl = 2;
}
enum Color {
COLOR_DEFAULT = 0;
COLOR_MARKED = 1;
COLOR_SUSPENDED = 2;
COLOR_FLAG_RED = 3;
COLOR_FLAG_ORANGE = 4;
COLOR_FLAG_GREEN = 5;
COLOR_FLAG_BLUE = 6;
}
repeated Cell cells = 1;
Color color = 2;
string font_name = 3;
uint32 font_size = 4;
}
message AfterNoteUpdatesIn { message AfterNoteUpdatesIn {
repeated int64 nids = 1; repeated int64 nids = 1;
bool mark_notes_modified = 2; bool mark_notes_modified = 2;

View file

@ -0,0 +1,46 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use super::Backend;
use crate::{backend_proto as pb, browser_rows, prelude::*};
pub(super) use pb::browserrows_service::Service as BrowserRowsService;
impl BrowserRowsService for Backend {
fn browser_row_for_card(&self, input: pb::CardId) -> Result<pb::BrowserRow> {
self.with_col(|col| col.browser_row_for_card(input.cid.into()).map(Into::into))
}
}
impl From<browser_rows::Row> for pb::BrowserRow {
fn from(row: browser_rows::Row) -> Self {
pb::BrowserRow {
cells: row.cells.into_iter().map(Into::into).collect(),
color: row.color.into(),
font_name: row.font.name,
font_size: row.font.size,
}
}
}
impl From<browser_rows::Cell> for pb::browser_row::Cell {
fn from(cell: browser_rows::Cell) -> Self {
pb::browser_row::Cell {
text: cell.text,
is_rtl: cell.is_rtl,
}
}
}
impl From<browser_rows::Color> for i32 {
fn from(color: browser_rows::Color) -> Self {
match color {
browser_rows::Color::Default => pb::browser_row::Color::Default as i32,
browser_rows::Color::Marked => pb::browser_row::Color::Marked as i32,
browser_rows::Color::Suspended => pb::browser_row::Color::Suspended as i32,
browser_rows::Color::FlagRed => pb::browser_row::Color::FlagRed as i32,
browser_rows::Color::FlagOrange => pb::browser_row::Color::FlagOrange as i32,
browser_rows::Color::FlagGreen => pb::browser_row::Color::FlagGreen as i32,
browser_rows::Color::FlagBlue => pb::browser_row::Color::FlagBlue as i32,
}
}
}

View file

@ -2,6 +2,7 @@
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
mod adding; mod adding;
mod browser_rows;
mod card; mod card;
mod cardrendering; mod cardrendering;
mod collection; mod collection;
@ -24,6 +25,7 @@ mod sync;
mod tags; mod tags;
use self::{ use self::{
browser_rows::BrowserRowsService,
card::CardsService, card::CardsService,
cardrendering::CardRenderingService, cardrendering::CardRenderingService,
collection::CollectionService, collection::CollectionService,
@ -137,6 +139,9 @@ impl Backend {
pb::ServiceIndex::I18n => I18nService::run_method(self, method, input), pb::ServiceIndex::I18n => I18nService::run_method(self, method, input),
pb::ServiceIndex::Collection => CollectionService::run_method(self, method, input), pb::ServiceIndex::Collection => CollectionService::run_method(self, method, input),
pb::ServiceIndex::Cards => CardsService::run_method(self, method, input), pb::ServiceIndex::Cards => CardsService::run_method(self, method, input),
pb::ServiceIndex::BrowserRows => {
BrowserRowsService::run_method(self, method, input)
}
}) })
.map_err(|err| { .map_err(|err| {
let backend_err = anki_error_to_proto_error(err, &self.i18n); let backend_err = anki_error_to_proto_error(err, &self.i18n);

356
rslib/src/browser_rows.rs Normal file
View file

@ -0,0 +1,356 @@
// 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 crate::err::{AnkiError, Result};
use crate::i18n::{tr_args, I18n, TR};
use crate::{
card::{Card, CardID, CardQueue, CardType},
collection::Collection,
decks::{Deck, DeckID},
notes::Note,
notetype::{CardTemplate, NoteType, NoteTypeKind},
scheduler::{timespan::time_span, timing::SchedTimingToday},
template::RenderedNode,
text::{extract_av_tags, html_to_text_line},
timestamp::{TimestampMillis, TimestampSecs},
};
const CARD_RENDER_COLUMNS: [&str; 2] = ["question", "answer"];
#[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,
}
struct RowContext<'a> {
col: &'a Collection,
card: Card,
note: Note,
notetype: Arc<NoteType>,
deck: Option<Deck>,
original_deck: Option<Option<Deck>>,
i18n: &'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>,
}
fn card_render_required(columns: &[String]) -> bool {
columns
.iter()
.any(|c| CARD_RENDER_COLUMNS.contains(&c.as_str()))
}
impl Collection {
pub fn browser_row_for_card(&mut self, id: CardID) -> Result<Row> {
let columns: Vec<String> = self.get_config_optional("activeCols").unwrap_or_else(|| {
vec![
"noteFld".to_string(),
"template".to_string(),
"cardDue".to_string(),
"deck".to_string(),
]
});
let mut context = RowContext::new(self, id, card_render_required(&columns))?;
Ok(Row {
cells: columns
.iter()
.map(|column| context.get_cell(column))
.collect::<Result<_>>()?,
color: context.get_row_color(),
font: context.get_row_font()?,
})
}
}
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> RowContext<'a> {
fn new(col: &'a mut Collection, id: CardID, with_card_render: bool) -> Result<Self> {
let card = col.storage.get_card(id)?.ok_or(AnkiError::NotFound)?;
// 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).
let note = if true {
col.storage.get_note(card.note_id)?
} else {
col.storage.get_note_without_fields(card.note_id)?
}
.ok_or(AnkiError::NotFound)?;
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, &note, &notetype)?)
} else {
None
};
Ok(RowContext {
col,
card,
note,
notetype,
deck: None,
original_deck: None,
i18n: &col.i18n,
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 get_cell(&mut self, column: &str) -> Result<Cell> {
Ok(Cell {
text: self.get_cell_text(column)?,
is_rtl: self.get_is_rtl(column),
})
}
fn get_cell_text(&mut self, column: &str) -> Result<String> {
Ok(match column {
"answer" => self.answer_str(),
"cardDue" => self.card_due_str(),
"cardEase" => self.card_ease_str(),
"cardIvl" => self.card_interval_str(),
"cardLapses" => self.card.lapses.to_string(),
"cardMod" => self.card.mtime.date_string(),
"cardReps" => self.card.reps.to_string(),
"deck" => self.deck_str()?,
"note" => self.notetype.name.to_owned(),
"noteCrt" => self.note_creation_str(),
"noteFld" => self.note_field_str(),
"noteMod" => self.note.mtime.date_string(),
"noteTags" => self.note.tags.join(" "),
"question" => self.question_str(),
"template" => self.template_str()?,
_ => "".to_string(),
})
}
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.original_deck_id != DeckID(0) {
self.i18n.tr(TR::BrowsingFiltered).into()
} else if self.card.queue == CardQueue::New || self.card.ctype == CardType::New {
self.i18n.trn(
TR::StatisticsDueForNewCard,
tr_args!["number"=>self.card.due],
)
} else {
let date = if self.card.queue == CardQueue::Learn {
TimestampSecs(self.card.due as i64)
} else if self.card.queue == CardQueue::DayLearn
|| self.card.queue == CardQueue::Review
|| (self.card.ctype == CardType::Review && (self.card.queue as i8) < 0)
{
TimestampSecs::now()
.adding_secs(((self.card.due - self.timing.days_elapsed as i32) * 86400) as i64)
} else {
return "".into();
};
date.date_string()
};
if (self.card.queue as i8) < 0 {
format!("({})", due)
} else {
due
}
}
fn card_ease_str(&self) -> String {
match self.card.ctype {
CardType::New => self.i18n.tr(TR::BrowsingNew).into(),
_ => format!("{}%", self.card.ease_factor / 10),
}
}
fn card_interval_str(&self) -> String {
match self.card.ctype {
CardType::New => self.i18n.tr(TR::BrowsingNew).into(),
CardType::Learn => self.i18n.tr(TR::BrowsingLearning).into(),
_ => time_span((self.card.interval * 86400) as f32, self.i18n, 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 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 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()
}
fn get_is_rtl(&self, column: &str) -> bool {
match column {
"noteFld" => {
let index = self.notetype.config.sort_field_idx as usize;
self.notetype.fields[index].config.rtl
}
_ => false,
}
}
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
.tags
.iter()
.any(|tag| tag.eq_ignore_ascii_case("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,
})
}
}

View file

@ -6,6 +6,7 @@
pub mod adding; pub mod adding;
pub mod backend; pub mod backend;
mod backend_proto; mod backend_proto;
pub mod browser_rows;
pub mod card; pub mod card;
pub mod cloze; pub mod cloze;
pub mod collection; pub mod collection;

View file

@ -37,7 +37,7 @@ impl Collection {
} }
.ok_or_else(|| AnkiError::invalid_input("missing template"))?; .ok_or_else(|| AnkiError::invalid_input("missing template"))?;
self.render_card_inner(&note, &card, &nt, template, browser) self.render_card(&note, &card, &nt, template, browser)
} }
/// Render a card that may not yet have been added. /// Render a card that may not yet have been added.
@ -59,7 +59,7 @@ impl Collection {
fill_empty_fields(note, &template.config.q_format, &nt, &self.i18n); fill_empty_fields(note, &template.config.q_format, &nt, &self.i18n);
} }
self.render_card_inner(note, &card, &nt, template, false) self.render_card(note, &card, &nt, template, false)
} }
fn existing_or_synthesized_card( fn existing_or_synthesized_card(
@ -82,7 +82,7 @@ impl Collection {
}) })
} }
fn render_card_inner( pub fn render_card(
&mut self, &mut self,
note: &Note, note: &Note,
card: &Card, card: &Card,

View file

@ -127,32 +127,28 @@ impl Collection {
} }
fn card_stats_to_string(&mut self, cs: CardStats) -> Result<String> { fn card_stats_to_string(&mut self, cs: CardStats) -> Result<String> {
let offset = self.local_utc_offset_for_user()?;
let i18n = &self.i18n; let i18n = &self.i18n;
let mut stats = vec![( let mut stats = vec![(
i18n.tr(TR::CardStatsAdded).to_string(), i18n.tr(TR::CardStatsAdded).to_string(),
cs.added.date_string(offset), cs.added.date_string(),
)]; )];
if let Some(first) = cs.first_review { if let Some(first) = cs.first_review {
stats.push(( stats.push((
i18n.tr(TR::CardStatsFirstReview).into(), i18n.tr(TR::CardStatsFirstReview).into(),
first.date_string(offset), first.date_string(),
)) ))
} }
if let Some(last) = cs.latest_review { if let Some(last) = cs.latest_review {
stats.push(( stats.push((
i18n.tr(TR::CardStatsLatestReview).into(), i18n.tr(TR::CardStatsLatestReview).into(),
last.date_string(offset), last.date_string(),
)) ))
} }
match cs.due { match cs.due {
Due::Time(secs) => { Due::Time(secs) => {
stats.push(( stats.push((i18n.tr(TR::StatisticsDueDate).into(), secs.date_string()));
i18n.tr(TR::StatisticsDueDate).into(),
secs.date_string(offset),
));
} }
Due::Position(pos) => { Due::Position(pos) => {
stats.push(( stats.push((
@ -203,7 +199,7 @@ impl Collection {
.revlog .revlog
.into_iter() .into_iter()
.rev() .rev()
.map(|e| revlog_to_text(e, i18n, offset)) .map(|e| revlog_to_text(e, i18n))
.collect(); .collect();
let revlog_titles = RevlogText { let revlog_titles = RevlogText {
time: i18n.tr(TR::CardStatsReviewLogDate).into(), time: i18n.tr(TR::CardStatsReviewLogDate).into(),
@ -226,8 +222,8 @@ impl Collection {
} }
} }
fn revlog_to_text(e: RevlogEntry, i18n: &I18n, offset: FixedOffset) -> RevlogText { fn revlog_to_text(e: RevlogEntry, i18n: &I18n) -> RevlogText {
let dt = offset.timestamp(e.id.as_secs().0, 0); let dt = Local.timestamp(e.id.as_secs().0, 0);
let time = dt.format("<b>%Y-%m-%d</b> @ %H:%M").to_string(); let time = dt.format("<b>%Y-%m-%d</b> @ %H:%M").to_string();
let kind = match e.review_kind { let kind = match e.review_kind {
RevlogReviewKind::Learning => i18n.tr(TR::CardStatsReviewLogTypeLearn).into(), RevlogReviewKind::Learning => i18n.tr(TR::CardStatsReviewLogTypeLearn).into(),

View file

@ -0,0 +1,10 @@
SELECT id,
guid,
mid,
mod,
usn,
tags,
"",
cast(sfld AS text),
csum
FROM notes

View file

@ -29,6 +29,17 @@ impl super::SqliteStorage {
.transpose() .transpose()
} }
pub fn get_note_without_fields(&self, nid: NoteID) -> Result<Option<Note>> {
self.db
.prepare_cached(concat!(
include_str!("get_without_fields.sql"),
" where id = ?"
))?
.query_and_then(params![nid], row_to_note)?
.next()
.transpose()
}
/// If fields have been modified, caller must call note.prepare_for_update() prior to calling this. /// If fields have been modified, caller must call note.prepare_for_update() prior to calling this.
pub(crate) fn update_note(&self, note: &Note) -> Result<()> { pub(crate) fn update_note(&self, note: &Note) -> Result<()> {
assert!(note.id.0 != 0); assert!(note.id.0 != 0);

View file

@ -10,6 +10,26 @@ use unicode_normalization::{
char::is_combining_mark, is_nfc, is_nfkd_quick, IsNormalized, UnicodeNormalization, char::is_combining_mark, is_nfc, is_nfkd_quick, IsNormalized, UnicodeNormalization,
}; };
pub trait Trimming {
fn trim(self) -> Self;
}
impl Trimming for Cow<'_, str> {
fn trim(self) -> Self {
match self {
Cow::Borrowed(text) => text.trim().into(),
Cow::Owned(text) => {
let trimmed = text.as_str().trim();
if trimmed.len() == text.len() {
text.into()
} else {
trimmed.to_string().into()
}
}
}
}
}
#[derive(Debug, PartialEq)] #[derive(Debug, PartialEq)]
pub enum AVTag { pub enum AVTag {
SoundOrVideo(String), SoundOrVideo(String),
@ -72,6 +92,29 @@ lazy_static! {
(.*?) # 3 - field text (.*?) # 3 - field text
\[/anki:tts\] \[/anki:tts\]
"#).unwrap(); "#).unwrap();
static ref PERSISTENT_HTML_SPACERS: Regex = Regex::new("<br>|<br />|<div>|\n").unwrap();
static ref UNPRINTABLE_TAGS: Regex = Regex::new(
r"(?xs)
\[sound:[^]]+\]
|
\[\[type:[^]]+\]\]
").unwrap();
}
pub fn html_to_text_line(html: &str) -> Cow<str> {
let mut out: Cow<str> = html.into();
if let Cow::Owned(o) = PERSISTENT_HTML_SPACERS.replace_all(&out, " ") {
out = o.into();
}
if let Cow::Owned(o) = UNPRINTABLE_TAGS.replace_all(&out, "") {
out = o.into();
}
if let Cow::Owned(o) = strip_html_preserving_media_filenames(&out) {
out = o.into();
}
out.trim()
} }
pub fn strip_html(html: &str) -> Cow<str> { pub fn strip_html(html: &str) -> Cow<str> {
@ -95,10 +138,9 @@ pub fn strip_html_preserving_entities(html: &str) -> Cow<str> {
pub fn decode_entities(html: &str) -> Cow<str> { pub fn decode_entities(html: &str) -> Cow<str> {
if html.contains('&') { if html.contains('&') {
match htmlescape::decode_html(html) { match htmlescape::decode_html(html) {
Ok(text) => text.replace('\u{a0}', " "), Ok(text) => text.replace('\u{a0}', " ").into(),
Err(e) => format!("{:?}", e), Err(_) => html.into(),
} }
.into()
} else { } else {
// nothing to do // nothing to do
html.into() html.into()

View file

@ -24,8 +24,8 @@ impl TimestampSecs {
} }
/// YYYY-mm-dd /// YYYY-mm-dd
pub(crate) fn date_string(self, offset: FixedOffset) -> String { pub(crate) fn date_string(self) -> String {
offset.timestamp(self.0, 0).format("%Y-%m-%d").to_string() Local.timestamp(self.0, 0).format("%Y-%m-%d").to_string()
} }
pub fn local_utc_offset(self) -> FixedOffset { pub fn local_utc_offset(self) -> FixedOffset {