mirror of
https://github.com/ankitects/anki.git
synced 2025-09-24 16:56:36 -04:00
commit
5fd79d9246
15 changed files with 638 additions and 268 deletions
|
@ -4,6 +4,7 @@ persistent = no
|
||||||
|
|
||||||
[TYPECHECK]
|
[TYPECHECK]
|
||||||
ignored-classes=
|
ignored-classes=
|
||||||
|
BrowserRow,
|
||||||
FormatTimespanIn,
|
FormatTimespanIn,
|
||||||
AnswerCardIn,
|
AnswerCardIn,
|
||||||
UnburyCardsInCurrentDeckIn,
|
UnburyCardsInCurrentDeckIn,
|
||||||
|
|
|
@ -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
|
||||||
##########################################################################
|
##########################################################################
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
46
rslib/src/backend/browser_rows.rs
Normal file
46
rslib/src/backend/browser_rows.rs
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
356
rslib/src/browser_rows.rs
Normal 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, ¬e, ¬etype)?)
|
||||||
|
} 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
|
|
@ -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(¬e, &card, &nt, template, browser)
|
self.render_card(¬e, &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,
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
10
rslib/src/storage/note/get_without_fields.sql
Normal file
10
rslib/src/storage/note/get_without_fields.sql
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
SELECT id,
|
||||||
|
guid,
|
||||||
|
mid,
|
||||||
|
mod,
|
||||||
|
usn,
|
||||||
|
tags,
|
||||||
|
"",
|
||||||
|
cast(sfld AS text),
|
||||||
|
csum
|
||||||
|
FROM notes
|
|
@ -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);
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
Loading…
Reference in a new issue