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]
ignored-classes=
BrowserRow,
FormatTimespanIn,
AnswerCardIn,
UnburyCardsInCurrentDeckIn,

View file

@ -3,7 +3,7 @@
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
@ -19,6 +19,7 @@ OpChanges = _pb.OpChanges
OpChangesWithCount = _pb.OpChangesWithCount
OpChangesWithID = _pb.OpChangesWithID
DefaultsForAdding = _pb.DeckAndNotetype
BrowserRow = _pb.BrowserRow
import copy
import os
@ -683,6 +684,20 @@ class Collection:
else:
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
##########################################################################

View file

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

View file

@ -5,21 +5,32 @@ from __future__ import annotations
import html
import time
from dataclasses import dataclass, field
from dataclasses import dataclass
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.forms
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.errors import InvalidInput, NotFoundError
from anki.lang import without_unicode_isolation
from anki.models import NoteType
from anki.stats import CardStats
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.card_ops import set_card_deck, set_card_flag
from aqt.editor import Editor
@ -91,25 +102,63 @@ class SearchContext:
# Data model
##########################################################################
# temporary cache to avoid hitting the database on redraw
@dataclass
class Cell:
text: str = ""
font: Optional[Tuple[str, int]] = None
is_rtl: bool = False
text: str
is_rtl: bool
@dataclass
class CellRow:
columns: List[Cell]
refreshed_at: float = field(default_factory=time.time)
card_flag: int = 0
marked: bool = False
suspended: bool = False
def __init__(
self,
cells: Generator[Tuple[str, bool], None, None],
color: BrowserRow.Color.V,
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:
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):
def __init__(self, browser: Browser) -> None:
@ -121,73 +170,45 @@ class DataModel(QAbstractTableModel):
"activeCols", ["noteFld", "template", "cardDue", "deck"]
)
self.cards: Sequence[int] = []
self.cardObjs: Dict[int, Card] = {}
self._row_cache: Dict[int, CellRow] = {}
self._rows: Dict[int, CellRow] = {}
self._last_refresh = 0.0
# serve stale content to avoid hitting the DB?
self.block_updates = False
def getCard(self, index: QModelIndex) -> Optional[Card]:
return self._get_card_by_row(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_id(self, index: QModelIndex) -> int:
return self.cards[index.row()]
def get_cell(self, index: QModelIndex) -> Cell:
row = self.get_row(index.row())
return row.columns[index.column()]
return self.get_row(index).cells[index.column()]
def get_row(self, row: int) -> CellRow:
if entry := self._row_cache.get(row):
if not self.block_updates and entry.is_stale(self._last_refresh):
def get_row(self, index: QModelIndex) -> CellRow:
cid = self.get_id(index)
if row := self._rows.get(cid):
if not self.block_updates and row.is_stale(self._last_refresh):
# need to refresh
entry = self._build_cell_row(row)
self._row_cache[row] = entry
return entry
else:
# return entry, even if it's stale
return entry
elif self.block_updates:
# blank entry until we unblock
return CellRow(columns=[Cell(text="...")] * len(self.activeCols))
else:
# missing entry, need to build
entry = self._build_cell_row(row)
self._row_cache[row] = entry
return entry
self._rows[cid] = self._fetch_row_from_backend(cid)
return self._rows[cid]
# return row, even if it's stale
return row
if self.block_updates:
# blank row until we unblock
return CellRow.placeholder(len(self.activeCols))
# missing row, need to build
self._rows[cid] = self._fetch_row_from_backend(cid)
return self._rows[cid]
def _build_cell_row(self, row: int) -> CellRow:
if not (card := self._get_card_by_row(row)):
cell = Cell(text=tr(TR.BROWSING_ROW_DELETED))
return CellRow(columns=[cell] * len(self.activeCols))
def _fetch_row_from_backend(self, cid: int) -> CellRow:
try:
return CellRow(*self.col.browser_row_for_card(cid))
except:
return CellRow.deleted(len(self.activeCols))
return CellRow(
columns=[
Cell(
text=self._column_data(card, column_type),
font=self._font(card, column_type),
is_rtl=self._is_rtl(card, column_type),
)
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,
)
def getCard(self, index: QModelIndex) -> Optional[Card]:
"""Try to return the indicated, possibly deleted card."""
try:
return self.col.getCard(self.get_id(index))
except:
return None
# Model interface
######################################################################
@ -204,17 +225,16 @@ class DataModel(QAbstractTableModel):
def data(self, index: QModelIndex = QModelIndex(), role: int = 0) -> Any:
if not index.isValid():
return
return QVariant()
if role == Qt.FontRole:
if font := self.get_cell(index).font:
qfont = QFont()
qfont.setFamily(font[0])
qfont.setPixelSize(font[1])
return qfont
else:
return None
elif role == Qt.TextAlignmentRole:
if self.activeCols[index.column()] not in ("question", "answer", "noteFld"):
return QVariant()
qfont = QFont()
row = self.get_row(index)
qfont.setFamily(row.font_name)
qfont.setPixelSize(row.font_size)
return qfont
if role == Qt.TextAlignmentRole:
align: Union[Qt.AlignmentFlag, int] = Qt.AlignVCenter
if self.activeCols[index.column()] not in (
"question",
@ -227,10 +247,9 @@ class DataModel(QAbstractTableModel):
):
align |= Qt.AlignHCenter
return align
elif role == Qt.DisplayRole or role == Qt.EditRole:
if role in (Qt.DisplayRole, Qt.ToolTipRole):
return self.get_cell(index).text
else:
return
return QVariant()
def headerData(
self, section: int, orientation: Qt.Orientation, role: int = 0
@ -238,7 +257,7 @@ class DataModel(QAbstractTableModel):
if orientation == Qt.Vertical:
return None
elif role == Qt.DisplayRole and section < len(self.activeCols):
type = self.columnType(section)
type = self.activeCols[section]
txt = None
for stype, name in self.browser.columns:
if type == stype:
@ -291,8 +310,7 @@ class DataModel(QAbstractTableModel):
self.browser.mw.progress.start()
self.saveSelection()
self.beginResetModel()
self.cardObjs = {}
self._row_cache = {}
self._rows = {}
def endReset(self) -> None:
self.endResetModel()
@ -366,8 +384,7 @@ class DataModel(QAbstractTableModel):
def op_executed(self, op: OpChanges, focused: bool) -> None:
print("op executed")
if op.card or op.note or op.deck or op.notetype:
# clear card cache
self.cardObjs = {}
self._rows = {}
if focused:
self.redraw_cells()
@ -378,148 +395,6 @@ class DataModel(QAbstractTableModel):
self.block_updates = False
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
######################################################################
@ -534,27 +409,13 @@ class StatusDelegate(QItemDelegate):
def paint(
self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex
) -> None:
row = self.model.get_row(index.row())
cell = row.columns[index.column()]
if cell.is_rtl:
if self.model.get_cell(index).is_rtl:
option.direction = Qt.RightToLeft
if row.card_flag:
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))
if row_color := self.model.get_row(index).color:
brush = QBrush(theme_manager.qcolor(row_color))
painter.save()
painter.fillRect(option.rect, brush)
painter.restore()
return QItemDelegate.paint(self, painter, option, index)

View file

@ -108,6 +108,7 @@ enum ServiceIndex {
SERVICE_INDEX_I18N = 12;
SERVICE_INDEX_COLLECTION = 13;
SERVICE_INDEX_CARDS = 14;
SERVICE_INDEX_BROWSER_ROWS = 15;
}
service SchedulingService {
@ -282,6 +283,10 @@ service CardsService {
rpc SetFlag(SetFlagIn) returns (OpChanges);
}
service BrowserRowsService {
rpc BrowserRowForCard(CardID) returns (BrowserRow);
}
// Protobuf stored in .anki2 files
// These should be moved to a separate file in the future
///////////////////////////////////////////////////////////
@ -1044,6 +1049,26 @@ message FindAndReplaceIn {
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 {
repeated int64 nids = 1;
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
mod adding;
mod browser_rows;
mod card;
mod cardrendering;
mod collection;
@ -24,6 +25,7 @@ mod sync;
mod tags;
use self::{
browser_rows::BrowserRowsService,
card::CardsService,
cardrendering::CardRenderingService,
collection::CollectionService,
@ -137,6 +139,9 @@ impl Backend {
pb::ServiceIndex::I18n => I18nService::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::BrowserRows => {
BrowserRowsService::run_method(self, method, input)
}
})
.map_err(|err| {
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 backend;
mod backend_proto;
pub mod browser_rows;
pub mod card;
pub mod cloze;
pub mod collection;

View file

@ -37,7 +37,7 @@ impl Collection {
}
.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.
@ -59,7 +59,7 @@ impl Collection {
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(
@ -82,7 +82,7 @@ impl Collection {
})
}
fn render_card_inner(
pub fn render_card(
&mut self,
note: &Note,
card: &Card,

View file

@ -127,32 +127,28 @@ impl Collection {
}
fn card_stats_to_string(&mut self, cs: CardStats) -> Result<String> {
let offset = self.local_utc_offset_for_user()?;
let i18n = &self.i18n;
let mut stats = vec![(
i18n.tr(TR::CardStatsAdded).to_string(),
cs.added.date_string(offset),
cs.added.date_string(),
)];
if let Some(first) = cs.first_review {
stats.push((
i18n.tr(TR::CardStatsFirstReview).into(),
first.date_string(offset),
first.date_string(),
))
}
if let Some(last) = cs.latest_review {
stats.push((
i18n.tr(TR::CardStatsLatestReview).into(),
last.date_string(offset),
last.date_string(),
))
}
match cs.due {
Due::Time(secs) => {
stats.push((
i18n.tr(TR::StatisticsDueDate).into(),
secs.date_string(offset),
));
stats.push((i18n.tr(TR::StatisticsDueDate).into(), secs.date_string()));
}
Due::Position(pos) => {
stats.push((
@ -203,7 +199,7 @@ impl Collection {
.revlog
.into_iter()
.rev()
.map(|e| revlog_to_text(e, i18n, offset))
.map(|e| revlog_to_text(e, i18n))
.collect();
let revlog_titles = RevlogText {
time: i18n.tr(TR::CardStatsReviewLogDate).into(),
@ -226,8 +222,8 @@ impl Collection {
}
}
fn revlog_to_text(e: RevlogEntry, i18n: &I18n, offset: FixedOffset) -> RevlogText {
let dt = offset.timestamp(e.id.as_secs().0, 0);
fn revlog_to_text(e: RevlogEntry, i18n: &I18n) -> RevlogText {
let dt = Local.timestamp(e.id.as_secs().0, 0);
let time = dt.format("<b>%Y-%m-%d</b> @ %H:%M").to_string();
let kind = match e.review_kind {
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()
}
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.
pub(crate) fn update_note(&self, note: &Note) -> Result<()> {
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,
};
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)]
pub enum AVTag {
SoundOrVideo(String),
@ -72,6 +92,29 @@ lazy_static! {
(.*?) # 3 - field text
\[/anki:tts\]
"#).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> {
@ -95,10 +138,9 @@ pub fn strip_html_preserving_entities(html: &str) -> Cow<str> {
pub fn decode_entities(html: &str) -> Cow<str> {
if html.contains('&') {
match htmlescape::decode_html(html) {
Ok(text) => text.replace('\u{a0}', " "),
Err(e) => format!("{:?}", e),
Ok(text) => text.replace('\u{a0}', " ").into(),
Err(_) => html.into(),
}
.into()
} else {
// nothing to do
html.into()

View file

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