From 5a37b8e2af029e5a0da970a958325f65bf9a9719 Mon Sep 17 00:00:00 2001 From: RumovZ Date: Tue, 23 Feb 2021 22:31:04 +0100 Subject: [PATCH 001/117] Add direct col reference to dyndeckconf --- qt/aqt/dyndeckconf.py | 38 ++++++++++++++++++++------------------ 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/qt/aqt/dyndeckconf.py b/qt/aqt/dyndeckconf.py index 8c88df964..c05d28337 100644 --- a/qt/aqt/dyndeckconf.py +++ b/qt/aqt/dyndeckconf.py @@ -40,12 +40,13 @@ class DeckConf(QDialog): QDialog.__init__(self, mw) self.mw = mw + self.col = self.mw.col self.did: Optional[int] = None self.form = aqt.forms.dyndconf.Ui_Dialog() self.form.setupUi(self) self.mw.checkpoint(tr(TR.ACTIONS_OPTIONS)) self.initialSetup() - self.old_deck = self.mw.col.decks.current() + self.old_deck = self.col.decks.current() if deck and deck["dyn"]: # modify existing dyn deck @@ -83,7 +84,7 @@ class DeckConf(QDialog): without_unicode_isolation(tr(TR.ACTIONS_OPTIONS_FOR, val=self.deck["name"])) ) self.form.buttonBox.button(QDialogButtonBox.Ok).setText(label) - if self.mw.col.schedVer() == 1: + if self.col.schedVer() == 1: self.form.secondFilter.setVisible(False) restoreGeom(self, "dyndeckconf") @@ -100,23 +101,23 @@ class DeckConf(QDialog): def new_dyn_deck(self) -> None: suffix: int = 1 - while self.mw.col.decks.id_for_name( + while self.col.decks.id_for_name( without_unicode_isolation(tr(TR.QT_MISC_FILTERED_DECK, val=suffix)) ): suffix += 1 name: str = without_unicode_isolation(tr(TR.QT_MISC_FILTERED_DECK, val=suffix)) - self.did = self.mw.col.decks.new_filtered(name) - self.deck = self.mw.col.decks.current() + self.did = self.col.decks.new_filtered(name) + self.deck = self.col.decks.current() def set_default_searches(self, deck_name: str) -> None: self.form.search.setText( - self.mw.col.build_search_string( + self.col.build_search_string( SearchNode(deck=deck_name), SearchNode(card_state=SearchNode.CARD_STATE_DUE), ) ) self.form.search_2.setText( - self.mw.col.build_search_string( + self.col.build_search_string( SearchNode(deck=deck_name), SearchNode(card_state=SearchNode.CARD_STATE_NEW), ) @@ -152,7 +153,7 @@ class DeckConf(QDialog): def _on_search_button(self, line: QLineEdit) -> None: try: - search = self.mw.col.build_search_string(line.text()) + search = self.col.build_search_string(line.text()) except InvalidInput as err: line.setFocus() line.selectAll() @@ -162,7 +163,7 @@ class DeckConf(QDialog): def _onReschedToggled(self, _state: int) -> None: self.form.previewDelayWidget.setVisible( - not self.form.resched.isChecked() and self.mw.col.schedVer() > 1 + not self.form.resched.isChecked() and self.col.schedVer() > 1 ) def loadConf(self, deck: Optional[Deck] = None) -> None: @@ -175,7 +176,7 @@ class DeckConf(QDialog): search, limit, order = d["terms"][0] f.search.setText(search) - if self.mw.col.schedVer() == 1: + if self.col.schedVer() == 1: if d["delays"]: f.steps.setText(self.listToUser(d["delays"])) f.stepsOn.setChecked(True) @@ -205,35 +206,36 @@ class DeckConf(QDialog): d = self.deck if f.name.text() and d["name"] != f.name.text(): - self.mw.col.decks.rename(d, f.name.text()) + self.col.decks.rename(d, f.name.text()) gui_hooks.sidebar_should_refresh_decks() d["resched"] = f.resched.isChecked() d["delays"] = None - if self.mw.col.schedVer() == 1 and f.stepsOn.isChecked(): + if self.col.schedVer() == 1 and f.stepsOn.isChecked(): steps = self.userToList(f.steps) if steps: d["delays"] = steps else: d["delays"] = None - search = self.mw.col.build_search_string(f.search.text()) + search = self.col.build_search_string(f.search.text()) terms = [[search, f.limit.value(), f.order.currentIndex()]] if f.secondFilter.isChecked(): - search_2 = self.mw.col.build_search_string(f.search_2.text()) + search_2 = self.col.build_search_string(f.search_2.text()) terms.append([search_2, f.limit_2.value(), f.order_2.currentIndex()]) d["terms"] = terms d["previewDelay"] = f.previewDelay.value() - self.mw.col.decks.save(d) + self.col.decks.save(d) def reject(self) -> None: if self.did: - self.mw.col.decks.rem(self.did) - self.mw.col.decks.select(self.old_deck["id"]) + self.col.decks.rem(self.did) + self.col.decks.select(self.old_deck["id"]) + self.mw.reset() saveGeom(self, "dyndeckconf") QDialog.reject(self) aqt.dialogs.markClosed("DynDeckConfDialog") @@ -246,7 +248,7 @@ class DeckConf(QDialog): except DeckRenameError as err: showWarning(err.description) else: - if not self.mw.col.sched.rebuild_filtered_deck(self.deck["id"]): + if not self.col.sched.rebuild_filtered_deck(self.deck["id"]): if askUser(tr(TR.DECKS_THE_PROVIDED_SEARCH_DID_NOT_MATCH)): return saveGeom(self, "dyndeckconf") From ae88f7e5935b352a1081baea8077fee666ef2bf1 Mon Sep 17 00:00:00 2001 From: RumovZ Date: Tue, 23 Feb 2021 23:12:39 +0100 Subject: [PATCH 002/117] Add clickable hint to dyndeckconf --- ftl/core/decks.ftl | 1 + qt/aqt/dyndeckconf.py | 49 ++++++++++++++++++++++++++++++++++++++++ qt/aqt/forms/dyndconf.ui | 19 ++++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/ftl/core/decks.ftl b/ftl/core/decks.ftl index 222a993c2..22175d3e7 100644 --- a/ftl/core/decks.ftl +++ b/ftl/core/decks.ftl @@ -31,6 +31,7 @@ decks-reschedule-cards-based-on-my-answers = Reschedule cards based on my answer decks-study = Study decks-study-deck = Study Deck decks-the-provided-search-did-not-match = The provided search did not match any cards. Would you like to revise it? +decks-unmovable-cards = Some cards may be excluded despite matching the search. decks-it-has-card = { $count -> [one] It has { $count } card. diff --git a/qt/aqt/dyndeckconf.py b/qt/aqt/dyndeckconf.py index c05d28337..f64f82bd0 100644 --- a/qt/aqt/dyndeckconf.py +++ b/qt/aqt/dyndeckconf.py @@ -70,6 +70,7 @@ class DeckConf(QDialog): self.set_custom_searches(search, search_2) qconnect(self.form.search_button.clicked, self.on_search_button) qconnect(self.form.search_button_2.clicked, self.on_search_button_2) + qconnect(self.form.hint_button.clicked, self.on_hint_button) color = theme_manager.color(colors.LINK) self.setStyleSheet( f"""QPushButton[flat=true] {{ text-align: left; color: {color}; padding: 0; border: 0 }} @@ -161,6 +162,54 @@ class DeckConf(QDialog): else: aqt.dialogs.open("Browser", self.mw, search=(search,)) + def on_hint_button(self) -> None: + """Open the browser to show cards that match the typed-in filters but cannot be included + due to internal limitations. + """ + manual_filters = [self.form.search.text()] + if self.form.secondFilter.isChecked(): + manual_filters.append(self.form.search_2.text()) + + implicit_filters = [ + SearchNode(card_state=SearchNode.CARD_STATE_SUSPENDED), + SearchNode(card_state=SearchNode.CARD_STATE_BURIED), + ] + + if self.col.schedVer() == 1: + # v1 scheduler cannot include learning cards + if self.did is None: + # rebuild will reset learning cards from this deck so they can be included + implicit_filters.append( + self.col.group_searches( + SearchNode(card_state=SearchNode.CARD_STATE_LEARN), + SearchNode(negated=SearchNode(deck=self.deck["name"])), + ) + ) + else: + implicit_filters.append( + SearchNode(card_state=SearchNode.CARD_STATE_LEARN) + ) + + if self.did is None: + # rebuild; old filtered deck will be emptied, so cards can be included + implicit_filters.append( + self.col.group_searches( + SearchNode(deck="filtered"), + SearchNode(negated=SearchNode(deck=self.deck["name"])), + ) + ) + else: + implicit_filters.append(SearchNode(deck="filtered")) + + manual_filter = self.col.group_searches(*manual_filters, joiner="OR") + implicit_filter = self.col.group_searches(*implicit_filters, joiner="OR") + try: + search = self.col.build_search_string(manual_filter, implicit_filter) + except InvalidInput as err: + show_invalid_search_error(err) + else: + aqt.dialogs.open("Browser", self.mw, search=(search,)) + def _onReschedToggled(self, _state: int) -> None: self.form.previewDelayWidget.setVisible( not self.form.resched.isChecked() and self.col.schedVer() > 1 diff --git a/qt/aqt/forms/dyndconf.ui b/qt/aqt/forms/dyndconf.ui index 6e9eaa441..be2925ce9 100644 --- a/qt/aqt/forms/dyndconf.ui +++ b/qt/aqt/forms/dyndconf.ui @@ -250,6 +250,25 @@ + + + + Qt::NoFocus + + + SEARCH_VIEW_IN_BROWSER + + + DECKS_UNMOVABLE_CARDS + + + false + + + true + + + From d6b1c0cf3a0c4e98dcff00e0cf576780ae5ccdb0 Mon Sep 17 00:00:00 2001 From: RumovZ Date: Tue, 23 Feb 2021 23:34:05 +0100 Subject: [PATCH 003/117] Give dyndeck hint unique styling --- qt/aqt/dyndeckconf.py | 9 ++++++--- qt/aqt/forms/dyndconf.ui | 9 +++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/qt/aqt/dyndeckconf.py b/qt/aqt/dyndeckconf.py index f64f82bd0..916187d3b 100644 --- a/qt/aqt/dyndeckconf.py +++ b/qt/aqt/dyndeckconf.py @@ -71,10 +71,13 @@ class DeckConf(QDialog): qconnect(self.form.search_button.clicked, self.on_search_button) qconnect(self.form.search_button_2.clicked, self.on_search_button_2) qconnect(self.form.hint_button.clicked, self.on_hint_button) - color = theme_manager.color(colors.LINK) + blue = theme_manager.color(colors.LINK) + grey = theme_manager.color(colors.DISABLED) self.setStyleSheet( - f"""QPushButton[flat=true] {{ text-align: left; color: {color}; padding: 0; border: 0 }} - QPushButton[flat=true]:hover {{ text-decoration: underline }}""" + f"""QPushButton[label] {{ padding: 0; border: 0 }} + QPushButton[label]:hover {{ text-decoration: underline }} + QPushButton[label="search"] {{ text-align: left; color: {blue} }} + QPushButton[label="hint"] {{ text-align: right; color: {grey} }}""" ) disable_help_button(self) self.setWindowModality(Qt.WindowModal) diff --git a/qt/aqt/forms/dyndconf.ui b/qt/aqt/forms/dyndconf.ui index be2925ce9..3e91c0a96 100644 --- a/qt/aqt/forms/dyndconf.ui +++ b/qt/aqt/forms/dyndconf.ui @@ -79,6 +79,9 @@ true + + search + @@ -143,6 +146,9 @@ true + + search + @@ -267,6 +273,9 @@ true + + hint + From 234ca4d4967e66704b936e28789b73a4a6a19c0c Mon Sep 17 00:00:00 2001 From: RumovZ Date: Wed, 24 Feb 2021 11:14:33 +0100 Subject: [PATCH 004/117] Refactor dyndeckconf/on_hint_button --- qt/aqt/dyndeckconf.py | 72 +++++++++++++++++++++++-------------------- 1 file changed, 38 insertions(+), 34 deletions(-) diff --git a/qt/aqt/dyndeckconf.py b/qt/aqt/dyndeckconf.py index 916187d3b..4565188fe 100644 --- a/qt/aqt/dyndeckconf.py +++ b/qt/aqt/dyndeckconf.py @@ -1,6 +1,6 @@ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -from typing import Callable, List, Optional +from typing import Callable, List, Optional, Tuple import aqt from anki.collection import SearchNode @@ -169,41 +169,13 @@ class DeckConf(QDialog): """Open the browser to show cards that match the typed-in filters but cannot be included due to internal limitations. """ - manual_filters = [self.form.search.text()] - if self.form.secondFilter.isChecked(): - manual_filters.append(self.form.search_2.text()) - - implicit_filters = [ + manual_filters = (self.form.search.text(), *self._second_filter()) + implicit_filters = ( SearchNode(card_state=SearchNode.CARD_STATE_SUSPENDED), SearchNode(card_state=SearchNode.CARD_STATE_BURIED), - ] - - if self.col.schedVer() == 1: - # v1 scheduler cannot include learning cards - if self.did is None: - # rebuild will reset learning cards from this deck so they can be included - implicit_filters.append( - self.col.group_searches( - SearchNode(card_state=SearchNode.CARD_STATE_LEARN), - SearchNode(negated=SearchNode(deck=self.deck["name"])), - ) - ) - else: - implicit_filters.append( - SearchNode(card_state=SearchNode.CARD_STATE_LEARN) - ) - - if self.did is None: - # rebuild; old filtered deck will be emptied, so cards can be included - implicit_filters.append( - self.col.group_searches( - SearchNode(deck="filtered"), - SearchNode(negated=SearchNode(deck=self.deck["name"])), - ) - ) - else: - implicit_filters.append(SearchNode(deck="filtered")) - + *self._learning_search_node(), + *self._filtered_search_node(), + ) manual_filter = self.col.group_searches(*manual_filters, joiner="OR") implicit_filter = self.col.group_searches(*implicit_filters, joiner="OR") try: @@ -213,6 +185,38 @@ class DeckConf(QDialog): else: aqt.dialogs.open("Browser", self.mw, search=(search,)) + def _second_filter(self) -> Tuple[str]: + if self.form.secondFilter.isChecked(): + return (self.form.search_2.text(),) + return () + + def _learning_search_node(self) -> Tuple[SearchNode]: + """Return a search node that matches learning cards if the old scheduler is enabled. + If it's a rebuild, exclude cards from this filtered deck as those will be reset. + """ + if self.col.schedVer() == 1: + if self.did is None: + return ( + self.col.group_searches( + SearchNode(card_state=SearchNode.CARD_STATE_LEARN), + SearchNode(negated=SearchNode(deck=self.deck["name"])), + ), + ) + return (SearchNode(card_state=SearchNode.CARD_STATE_LEARN),) + return () + + def _filtered_search_node(self) -> Tuple[SearchNode]: + """Return a search node that matches cards in filtered decks, if applicable excluding those + in the deck being rebuild.""" + if self.did is None: + return ( + self.col.group_searches( + SearchNode(deck="filtered"), + SearchNode(negated=SearchNode(deck=self.deck["name"])), + ), + ) + return (SearchNode(deck="filtered"),) + def _onReschedToggled(self, _state: int) -> None: self.form.previewDelayWidget.setVisible( not self.form.resched.isChecked() and self.col.schedVer() > 1 From e95c2fa6cec798298d1e31b345870e4e66c210ad Mon Sep 17 00:00:00 2001 From: RumovZ Date: Wed, 24 Feb 2021 11:24:27 +0100 Subject: [PATCH 005/117] Fix type hints in dyndeckconf --- qt/aqt/dyndeckconf.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qt/aqt/dyndeckconf.py b/qt/aqt/dyndeckconf.py index 4565188fe..d1c4e8e2e 100644 --- a/qt/aqt/dyndeckconf.py +++ b/qt/aqt/dyndeckconf.py @@ -185,12 +185,12 @@ class DeckConf(QDialog): else: aqt.dialogs.open("Browser", self.mw, search=(search,)) - def _second_filter(self) -> Tuple[str]: + def _second_filter(self) -> Tuple[str, ...]: if self.form.secondFilter.isChecked(): return (self.form.search_2.text(),) return () - def _learning_search_node(self) -> Tuple[SearchNode]: + def _learning_search_node(self) -> Tuple[SearchNode, ...]: """Return a search node that matches learning cards if the old scheduler is enabled. If it's a rebuild, exclude cards from this filtered deck as those will be reset. """ From 8e43b298169361b53880529e3f760f7e83f915de Mon Sep 17 00:00:00 2001 From: RumovZ Date: Wed, 24 Feb 2021 13:57:44 +0100 Subject: [PATCH 006/117] Localise RenameDeckError --- ftl/core/errors.ftl | 2 ++ qt/aqt/deckbrowser.py | 6 +++--- qt/aqt/dyndeckconf.py | 3 ++- qt/aqt/sidebar.py | 6 +++--- qt/aqt/utils.py | 10 +++++++++- 5 files changed, 19 insertions(+), 8 deletions(-) diff --git a/ftl/core/errors.ftl b/ftl/core/errors.ftl index 5f5ea6eb5..8e549ecb1 100644 --- a/ftl/core/errors.ftl +++ b/ftl/core/errors.ftl @@ -1,3 +1,5 @@ +errors-invalid-deck-name = Invalid deck name: { $reason } errors-invalid-input-empty = Invalid input. errors-invalid-input-details = Invalid input: { $details } errors-parse-number-fail = A number was invalid or out of range. +errors-reason-filtered-parent = Filtered decks cannot be parent decks. diff --git a/qt/aqt/deckbrowser.py b/qt/aqt/deckbrowser.py index cd446fec9..05836f8dd 100644 --- a/qt/aqt/deckbrowser.py +++ b/qt/aqt/deckbrowser.py @@ -21,8 +21,8 @@ from aqt.utils import ( getOnlyText, openLink, shortcut, + show_rename_deck_error, showInfo, - showWarning, tr, ) @@ -272,8 +272,8 @@ class DeckBrowser: try: self.mw.col.decks.rename(deck, newName) gui_hooks.sidebar_should_refresh_decks() - except DeckRenameError as e: - showWarning(e.description) + except DeckRenameError as err: + show_rename_deck_error(err) return self.show() diff --git a/qt/aqt/dyndeckconf.py b/qt/aqt/dyndeckconf.py index 8c88df964..6f47480b7 100644 --- a/qt/aqt/dyndeckconf.py +++ b/qt/aqt/dyndeckconf.py @@ -19,6 +19,7 @@ from aqt.utils import ( restoreGeom, saveGeom, show_invalid_search_error, + show_rename_deck_error, showWarning, tr, ) @@ -244,7 +245,7 @@ class DeckConf(QDialog): except InvalidInput as err: show_invalid_search_error(err) except DeckRenameError as err: - showWarning(err.description) + show_rename_deck_error(err) else: if not self.mw.col.sched.rebuild_filtered_deck(self.deck["id"]): if askUser(tr(TR.DECKS_THE_PROVIDED_SEARCH_DID_NOT_MATCH)): diff --git a/qt/aqt/sidebar.py b/qt/aqt/sidebar.py index 41856e3c7..db90c0846 100644 --- a/qt/aqt/sidebar.py +++ b/qt/aqt/sidebar.py @@ -23,8 +23,8 @@ from aqt.utils import ( askUser, getOnlyText, show_invalid_search_error, + show_rename_deck_error, showInfo, - showWarning, tr, ) @@ -993,8 +993,8 @@ class SidebarTreeView(QTreeView): self.mw.checkpoint(tr(TR.ACTIONS_RENAME_DECK)) try: self.mw.col.decks.rename(deck, new_name) - except DeckRenameError as e: - showWarning(e.description) + except DeckRenameError as err: + show_rename_deck_error(err) return self.refresh() self.mw.deckBrowser.refresh() diff --git a/qt/aqt/utils.py b/qt/aqt/utils.py index 8d94aa5c7..5d57bbf0a 100644 --- a/qt/aqt/utils.py +++ b/qt/aqt/utils.py @@ -36,7 +36,7 @@ from PyQt5.QtWidgets import ( import anki import aqt from anki import Collection -from anki.errors import InvalidInput +from anki.errors import DeckRenameError, InvalidInput from anki.lang import TR # pylint: disable=unused-import from anki.utils import invalidFilename, isMac, isWin, noBundledLibs, versionWithBuild from aqt.qt import * @@ -146,6 +146,14 @@ def show_invalid_search_error(err: Exception) -> None: showWarning(text) +def show_rename_deck_error(err: DeckRenameError) -> None: + if err.description == "deck was filtered": + reason = tr(TR.ERRORS_REASON_FILTERED_PARENT) + else: + reason = "unknown reason." + showWarning(tr(TR.ERRORS_INVALID_DECK_NAME, reason=reason)) + + def showInfo( text: str, parent: Union[Literal[False], QDialog] = False, From 1dca43f40905cc2e27d9665a6f7d3e5765a5becc Mon Sep 17 00:00:00 2001 From: RumovZ Date: Wed, 24 Feb 2021 13:59:38 +0100 Subject: [PATCH 007/117] =?UTF-8?q?Fix=20#1024=20=E2=80=93=20catch=20deck?= =?UTF-8?q?=20rename=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- qt/aqt/deckbrowser.py | 17 ++++++++++++----- qt/aqt/studydeck.py | 8 +++++++- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/qt/aqt/deckbrowser.py b/qt/aqt/deckbrowser.py index 05836f8dd..36e5029a5 100644 --- a/qt/aqt/deckbrowser.py +++ b/qt/aqt/deckbrowser.py @@ -88,11 +88,7 @@ class DeckBrowser: elif cmd == "import": self.mw.onImport() elif cmd == "create": - deck = getOnlyText(tr(TR.DECKS_NAME_FOR_DECK)) - if deck: - self.mw.col.decks.id(deck) - gui_hooks.sidebar_should_refresh_decks() - self.refresh() + self._on_create() elif cmd == "drag": source, target = arg.split(",") self._handle_drag_and_drop(int(source), int(target or 0)) @@ -352,6 +348,17 @@ class DeckBrowser: def _onShared(self) -> None: openLink(f"{aqt.appShared}decks/") + def _on_create(self) -> None: + deck = getOnlyText(tr(TR.DECKS_NAME_FOR_DECK)) + if deck: + try: + self.mw.col.decks.id(deck) + except DeckRenameError as err: + show_rename_deck_error(err) + return + gui_hooks.sidebar_should_refresh_decks() + self.refresh() + ###################################################################### def _v1_upgrade_message(self) -> str: diff --git a/qt/aqt/studydeck.py b/qt/aqt/studydeck.py index d44ff1d26..9db4d7184 100644 --- a/qt/aqt/studydeck.py +++ b/qt/aqt/studydeck.py @@ -4,6 +4,7 @@ from typing import List, Optional import aqt +from anki.decks import DeckRenameError from aqt import gui_hooks from aqt.qt import * from aqt.utils import ( @@ -16,6 +17,7 @@ from aqt.utils import ( restoreGeom, saveGeom, shortcut, + show_rename_deck_error, showInfo, tr, ) @@ -164,7 +166,11 @@ class StudyDeck(QDialog): n = getOnlyText(tr(TR.DECKS_NEW_DECK_NAME), default=default) n = n.strip() if n: - did = self.mw.col.decks.id(n) + try: + did = self.mw.col.decks.id(n) + except DeckRenameError as err: + show_rename_deck_error(err) + return # deck name may not be the same as user input. ex: ", :: self.name = self.mw.col.decks.name(did) # make sure we clean up reset hook when manually exiting From c7d8700054e9cd5235aa761c36d124750c231625 Mon Sep 17 00:00:00 2001 From: bluegreenmagick Date: Thu, 25 Feb 2021 07:34:01 +0900 Subject: [PATCH 008/117] StudyDeck without add button --- qt/aqt/studydeck.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/qt/aqt/studydeck.py b/qt/aqt/studydeck.py index d44ff1d26..32c656f9e 100644 --- a/qt/aqt/studydeck.py +++ b/qt/aqt/studydeck.py @@ -37,8 +37,6 @@ class StudyDeck(QDialog): geomKey: str = "default", ) -> None: QDialog.__init__(self, parent or mw) - if buttons is None: - buttons = [] self.mw = mw self.form = aqt.forms.studydeck.Ui_Dialog() self.form.setupUi(self) @@ -52,7 +50,7 @@ class StudyDeck(QDialog): self.form.buttonBox.removeButton( self.form.buttonBox.button(QDialogButtonBox.Cancel) ) - if buttons: + if buttons is not None: for b in buttons: self.form.buttonBox.addButton(b, QDialogButtonBox.ActionRole) else: From d666b7e5b0dd18c1bcf1ce6049859173e0a3294e Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Fri, 26 Feb 2021 18:46:09 +1000 Subject: [PATCH 009/117] fix browser appearance defaults https://forums.ankiweb.net/t/small-bug-in-the-browser-appearance-window/7806 --- qt/aqt/clayout.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qt/aqt/clayout.py b/qt/aqt/clayout.py index 590330ec7..377fa2f47 100644 --- a/qt/aqt/clayout.py +++ b/qt/aqt/clayout.py @@ -700,8 +700,8 @@ class CardLayout(QDialog): f.afmt.setText(t.get("bafmt", "")) if t.get("bfont"): f.overrideFont.setChecked(True) - f.font.setCurrentFont(QFont(t.get("bfont", "Arial"))) - f.fontSize.setValue(t.get("bsize", 12)) + f.font.setCurrentFont(QFont(t.get("bfont") or "Arial")) + f.fontSize.setValue(t.get("bsize") or 12) qconnect(f.buttonBox.accepted, lambda: self.onBrowserDisplayOk(f)) d.exec_() From b8acf11f3e1c1e880779c2f51a50da1b659bc4f5 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Fri, 26 Feb 2021 18:48:32 +1000 Subject: [PATCH 010/117] remove left-click on saved searches Multiple users have thought it was a bug rather than an intentional feature, and it breaks double-clicking: https://forums.ankiweb.net/t/anki-2-1-41-beta/7305/51 --- qt/aqt/sidebar.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/qt/aqt/sidebar.py b/qt/aqt/sidebar.py index 41856e3c7..788193b64 100644 --- a/qt/aqt/sidebar.py +++ b/qt/aqt/sidebar.py @@ -616,11 +616,6 @@ class SidebarTreeView(QTreeView): type=SidebarItemType.SAVED_SEARCH_ROOT, ) - def on_click() -> None: - self.show_context_menu(root, None) - - root.on_click = on_click - for name, filt in sorted(saved.items()): item = SidebarItem( name, From c0b9285923c7fbfeedcaaf34b08bfb18dc3e7900 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Fri, 26 Feb 2021 19:16:18 +1000 Subject: [PATCH 011/117] move cards out of the new queue on filtered deck upgrade --- rslib/src/filtered.rs | 41 ++++++++++--------------- rslib/src/scheduler/bury_and_suspend.rs | 16 ++-------- rslib/src/scheduler/upgrade.rs | 17 +++++++++- 3 files changed, 35 insertions(+), 39 deletions(-) diff --git a/rslib/src/filtered.rs b/rslib/src/filtered.rs index eecf7731d..67902ed0e 100644 --- a/rslib/src/filtered.rs +++ b/rslib/src/filtered.rs @@ -18,6 +18,22 @@ use crate::{ }; impl Card { + pub(crate) fn restore_queue_from_type(&mut self) { + self.queue = match self.ctype { + CardType::Learn | CardType::Relearn => { + if self.due > 1_000_000_000 { + // unix timestamp + CardQueue::Learn + } else { + // day number + CardQueue::DayLearn + } + } + CardType::New => CardQueue::New, + CardType::Review => CardQueue::Review, + } + } + pub(crate) fn move_into_filtered_deck(&mut self, ctx: &DeckFilterContext, position: i32) { // filtered and v1 learning cards are excluded, so odue should be guaranteed to be zero if self.original_due != 0 { @@ -64,17 +80,6 @@ impl Card { } } - /// Returns original_due if set, else due. - /// original_due will be set in filtered decks, and in relearning in - /// the old scheduler. - pub(crate) fn original_or_current_due(&self) -> i32 { - if self.original_due > 0 { - self.original_due - } else { - self.due - } - } - pub(crate) fn original_or_current_deck_id(&self) -> DeckID { if self.original_deck_id.0 > 0 { self.original_deck_id @@ -116,19 +121,7 @@ impl Card { } if (self.queue as i8) >= 0 { - self.queue = match self.ctype { - CardType::Learn | CardType::Relearn => { - if self.due > 1_000_000_000 { - // unix timestamp - CardQueue::Learn - } else { - // day number - CardQueue::DayLearn - } - } - CardType::New => CardQueue::New, - CardType::Review => CardQueue::Review, - } + self.restore_queue_from_type(); } } } diff --git a/rslib/src/scheduler/bury_and_suspend.rs b/rslib/src/scheduler/bury_and_suspend.rs index fa114b9b5..432754eeb 100644 --- a/rslib/src/scheduler/bury_and_suspend.rs +++ b/rslib/src/scheduler/bury_and_suspend.rs @@ -3,7 +3,7 @@ use crate::{ backend_proto as pb, - card::{Card, CardID, CardQueue, CardType}, + card::{Card, CardID, CardQueue}, collection::Collection, config::SchedulerVersion, err::Result, @@ -22,19 +22,7 @@ impl Card { ) { false } else { - self.queue = match self.ctype { - CardType::Learn | CardType::Relearn => { - if self.original_or_current_due() > 1_000_000_000 { - // previous interval was in seconds - CardQueue::Learn - } else { - // previous interval was in days - CardQueue::DayLearn - } - } - CardType::New => CardQueue::New, - CardType::Review => CardQueue::Review, - }; + self.restore_queue_from_type(); true } } diff --git a/rslib/src/scheduler/upgrade.rs b/rslib/src/scheduler/upgrade.rs index c7a5db9c9..0f1c9bc03 100644 --- a/rslib/src/scheduler/upgrade.rs +++ b/rslib/src/scheduler/upgrade.rs @@ -39,7 +39,12 @@ impl Card { self.remaining_steps = self.remaining_steps.min(step_count); } - if !info.reschedule { + if info.reschedule { + // only new cards should be in the new queue + if self.queue == CardQueue::New && self.ctype != CardType::New { + self.restore_queue_from_type(); + } + } else { // preview cards start in the review queue in v2 if self.queue == CardQueue::New { self.queue = CardQueue::Review; @@ -182,5 +187,15 @@ mod test { })); assert_eq!(c.ctype, CardType::New); assert_eq!(c.queue, CardQueue::PreviewRepeat); + + // (early) reviews should be moved back from the new queue + c.ctype = CardType::Review; + c.queue = CardQueue::New; + c.upgrade_to_v2(Some(V1FilteredDeckInfo { + reschedule: true, + original_step_count: None, + })); + assert_eq!(c.ctype, CardType::Review); + assert_eq!(c.queue, CardQueue::Review); } } From ef925a88d6325bdddf91d4bad33f68ff9cb3e78d Mon Sep 17 00:00:00 2001 From: RumovZ Date: Fri, 26 Feb 2021 11:32:26 +0100 Subject: [PATCH 012/117] Add filtered deck error localisation on backend --- ftl/core/errors.ftl | 3 +-- pylib/anki/errors.py | 21 ++++++++++----------- rslib/src/err.rs | 1 + 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/ftl/core/errors.ftl b/ftl/core/errors.ftl index 8e549ecb1..23a8cc7a3 100644 --- a/ftl/core/errors.ftl +++ b/ftl/core/errors.ftl @@ -1,5 +1,4 @@ -errors-invalid-deck-name = Invalid deck name: { $reason } errors-invalid-input-empty = Invalid input. errors-invalid-input-details = Invalid input: { $details } errors-parse-number-fail = A number was invalid or out of range. -errors-reason-filtered-parent = Filtered decks cannot be parent decks. +errors-filtered-parent-deck = Invalid deck name: Filtered decks cannot be parent decks. diff --git a/pylib/anki/errors.py b/pylib/anki/errors.py index 0fd0a5abb..7ca87996d 100644 --- a/pylib/anki/errors.py +++ b/pylib/anki/errors.py @@ -47,7 +47,15 @@ class ExistsError(Exception): pass -class DeckIsFilteredError(Exception): +class DeckRenameError(Exception): + """Legacy error, use DeckIsFilteredError instead.""" + + def __init__(self, description: str, *args: object) -> None: + super().__init__(description, *args) + self.description = description + + +class DeckIsFilteredError(StringError, DeckRenameError): pass @@ -78,7 +86,7 @@ def backend_exception_to_pylib(err: _pb.BackendError) -> Exception: elif val == "exists": return ExistsError() elif val == "deck_is_filtered": - return DeckIsFilteredError() + return DeckIsFilteredError(err.localized) elif val == "proto_error": return StringError(err.localized) else: @@ -95,12 +103,3 @@ class AnkiError(Exception): def __str__(self) -> str: return self.type - - -class DeckRenameError(Exception): - def __init__(self, description: str) -> None: - super().__init__() - self.description = description - - def __str__(self) -> str: - return f"Couldn't rename deck: {self.description}" diff --git a/rslib/src/err.rs b/rslib/src/err.rs index 6a93df75b..77f714599 100644 --- a/rslib/src/err.rs +++ b/rslib/src/err.rs @@ -208,6 +208,7 @@ impl AnkiError { } } AnkiError::ParseNumError => i18n.tr(TR::ErrorsParseNumberFail).into(), + AnkiError::DeckIsFiltered => i18n.tr(TR::ErrorsFilteredParentDeck).into(), _ => format!("{:?}", self), } } From 92cbf168f6ec26c8e60550dd8089c3e9cd3cddcb Mon Sep 17 00:00:00 2001 From: RumovZ Date: Fri, 26 Feb 2021 11:32:40 +0100 Subject: [PATCH 013/117] Catch DeckIsFilteredError directly on frontend --- pylib/anki/decks.py | 11 ++++------- qt/aqt/deckbrowser.py | 12 ++++++------ qt/aqt/dyndeckconf.py | 9 ++++----- qt/aqt/sidebar.py | 8 ++++---- qt/aqt/studydeck.py | 8 ++++---- qt/aqt/utils.py | 10 +--------- 6 files changed, 23 insertions(+), 35 deletions(-) diff --git a/pylib/anki/decks.py b/pylib/anki/decks.py index 17efb366e..8e7b7c755 100644 --- a/pylib/anki/decks.py +++ b/pylib/anki/decks.py @@ -12,7 +12,7 @@ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Union import anki # pylint: disable=unused-import import anki._backend.backend_pb2 as _pb from anki.consts import * -from anki.errors import DeckIsFilteredError, DeckRenameError, NotFoundError +from anki.errors import NotFoundError from anki.utils import from_json_bytes, ids2str, intTime, to_json_bytes # public exports @@ -246,12 +246,9 @@ class DeckManager: def update(self, g: Deck, preserve_usn: bool = True) -> None: "Add or update an existing deck. Used for syncing and merging." - try: - g["id"] = self.col._backend.add_or_update_deck_legacy( - deck=to_json_bytes(g), preserve_usn_and_mtime=preserve_usn - ) - except DeckIsFilteredError as exc: - raise DeckRenameError("deck was filtered") from exc + g["id"] = self.col._backend.add_or_update_deck_legacy( + deck=to_json_bytes(g), preserve_usn_and_mtime=preserve_usn + ) def rename(self, g: Deck, newName: str) -> None: "Rename deck prefix to NAME if not exists. Updates children." diff --git a/qt/aqt/deckbrowser.py b/qt/aqt/deckbrowser.py index 36e5029a5..9b788bafb 100644 --- a/qt/aqt/deckbrowser.py +++ b/qt/aqt/deckbrowser.py @@ -9,7 +9,7 @@ from typing import Any import aqt from anki.decks import DeckTreeNode -from anki.errors import DeckRenameError +from anki.errors import DeckIsFilteredError from anki.utils import intTime from aqt import AnkiQt, gui_hooks from aqt.qt import * @@ -21,8 +21,8 @@ from aqt.utils import ( getOnlyText, openLink, shortcut, - show_rename_deck_error, showInfo, + showWarning, tr, ) @@ -268,8 +268,8 @@ class DeckBrowser: try: self.mw.col.decks.rename(deck, newName) gui_hooks.sidebar_should_refresh_decks() - except DeckRenameError as err: - show_rename_deck_error(err) + except DeckIsFilteredError as err: + showWarning(str(err)) return self.show() @@ -353,8 +353,8 @@ class DeckBrowser: if deck: try: self.mw.col.decks.id(deck) - except DeckRenameError as err: - show_rename_deck_error(err) + except DeckIsFilteredError as err: + showWarning(str(err)) return gui_hooks.sidebar_should_refresh_decks() self.refresh() diff --git a/qt/aqt/dyndeckconf.py b/qt/aqt/dyndeckconf.py index 6f47480b7..3f2e5ce35 100644 --- a/qt/aqt/dyndeckconf.py +++ b/qt/aqt/dyndeckconf.py @@ -4,8 +4,8 @@ from typing import Callable, List, Optional import aqt from anki.collection import SearchNode -from anki.decks import Deck, DeckRenameError -from anki.errors import InvalidInput +from anki.decks import Deck +from anki.errors import DeckIsFilteredError, InvalidInput from anki.lang import without_unicode_isolation from aqt import AnkiQt, colors, gui_hooks from aqt.qt import * @@ -19,7 +19,6 @@ from aqt.utils import ( restoreGeom, saveGeom, show_invalid_search_error, - show_rename_deck_error, showWarning, tr, ) @@ -244,8 +243,8 @@ class DeckConf(QDialog): self.saveConf() except InvalidInput as err: show_invalid_search_error(err) - except DeckRenameError as err: - show_rename_deck_error(err) + except DeckIsFilteredError as err: + showWarning(str(err)) else: if not self.mw.col.sched.rebuild_filtered_deck(self.deck["id"]): if askUser(tr(TR.DECKS_THE_PROVIDED_SEARCH_DID_NOT_MATCH)): diff --git a/qt/aqt/sidebar.py b/qt/aqt/sidebar.py index db90c0846..0821c4134 100644 --- a/qt/aqt/sidebar.py +++ b/qt/aqt/sidebar.py @@ -10,7 +10,7 @@ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, cast import aqt from anki.collection import Config, SearchNode from anki.decks import DeckTreeNode -from anki.errors import DeckRenameError, InvalidInput +from anki.errors import DeckIsFilteredError, InvalidInput from anki.tags import TagTreeNode from anki.types import assert_exhaustive from aqt import colors, gui_hooks @@ -23,8 +23,8 @@ from aqt.utils import ( askUser, getOnlyText, show_invalid_search_error, - show_rename_deck_error, showInfo, + showWarning, tr, ) @@ -993,8 +993,8 @@ class SidebarTreeView(QTreeView): self.mw.checkpoint(tr(TR.ACTIONS_RENAME_DECK)) try: self.mw.col.decks.rename(deck, new_name) - except DeckRenameError as err: - show_rename_deck_error(err) + except DeckIsFilteredError as err: + showWarning(str(err)) return self.refresh() self.mw.deckBrowser.refresh() diff --git a/qt/aqt/studydeck.py b/qt/aqt/studydeck.py index 9db4d7184..6dc62451c 100644 --- a/qt/aqt/studydeck.py +++ b/qt/aqt/studydeck.py @@ -4,7 +4,7 @@ from typing import List, Optional import aqt -from anki.decks import DeckRenameError +from anki.errors import DeckIsFilteredError from aqt import gui_hooks from aqt.qt import * from aqt.utils import ( @@ -17,8 +17,8 @@ from aqt.utils import ( restoreGeom, saveGeom, shortcut, - show_rename_deck_error, showInfo, + showWarning, tr, ) @@ -168,8 +168,8 @@ class StudyDeck(QDialog): if n: try: did = self.mw.col.decks.id(n) - except DeckRenameError as err: - show_rename_deck_error(err) + except DeckIsFilteredError as err: + showWarning(str(err)) return # deck name may not be the same as user input. ex: ", :: self.name = self.mw.col.decks.name(did) diff --git a/qt/aqt/utils.py b/qt/aqt/utils.py index 5d57bbf0a..8d94aa5c7 100644 --- a/qt/aqt/utils.py +++ b/qt/aqt/utils.py @@ -36,7 +36,7 @@ from PyQt5.QtWidgets import ( import anki import aqt from anki import Collection -from anki.errors import DeckRenameError, InvalidInput +from anki.errors import InvalidInput from anki.lang import TR # pylint: disable=unused-import from anki.utils import invalidFilename, isMac, isWin, noBundledLibs, versionWithBuild from aqt.qt import * @@ -146,14 +146,6 @@ def show_invalid_search_error(err: Exception) -> None: showWarning(text) -def show_rename_deck_error(err: DeckRenameError) -> None: - if err.description == "deck was filtered": - reason = tr(TR.ERRORS_REASON_FILTERED_PARENT) - else: - reason = "unknown reason." - showWarning(tr(TR.ERRORS_INVALID_DECK_NAME, reason=reason)) - - def showInfo( text: str, parent: Union[Literal[False], QDialog] = False, From 32af54cd4d413bc762353acba7c0c8f9fb206db7 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Mon, 1 Mar 2021 09:58:12 +1000 Subject: [PATCH 014/117] catch attempts to nest under a filtered deck; don't show traceback --- ftl/core/errors.ftl | 2 +- qt/aqt/deckbrowser.py | 6 +++++- qt/aqt/sidebar.py | 6 +++++- rslib/src/decks/mod.rs | 3 +++ 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/ftl/core/errors.ftl b/ftl/core/errors.ftl index 23a8cc7a3..cc3b138d0 100644 --- a/ftl/core/errors.ftl +++ b/ftl/core/errors.ftl @@ -1,4 +1,4 @@ errors-invalid-input-empty = Invalid input. errors-invalid-input-details = Invalid input: { $details } errors-parse-number-fail = A number was invalid or out of range. -errors-filtered-parent-deck = Invalid deck name: Filtered decks cannot be parent decks. +errors-filtered-parent-deck = Filtered decks can not have child decks. diff --git a/qt/aqt/deckbrowser.py b/qt/aqt/deckbrowser.py index 9b788bafb..3977edf7b 100644 --- a/qt/aqt/deckbrowser.py +++ b/qt/aqt/deckbrowser.py @@ -287,7 +287,11 @@ class DeckBrowser: self._renderPage(reuse=True) def _handle_drag_and_drop(self, source: int, target: int) -> None: - self.mw.col.decks.drag_drop_decks([source], target) + try: + self.mw.col.decks.drag_drop_decks([source], target) + except Exception as e: + showWarning(str(e)) + return gui_hooks.sidebar_should_refresh_decks() self.show() diff --git a/qt/aqt/sidebar.py b/qt/aqt/sidebar.py index ca2e26e79..513144057 100644 --- a/qt/aqt/sidebar.py +++ b/qt/aqt/sidebar.py @@ -472,7 +472,11 @@ class SidebarTreeView(QTreeView): def on_done(fut: Future) -> None: self.browser.model.endReset() - fut.result() + try: + fut.result() + except Exception as e: + showWarning(str(e)) + return self.refresh() self.mw.deckBrowser.refresh() diff --git a/rslib/src/decks/mod.rs b/rslib/src/decks/mod.rs index 9c69af06d..9ade97912 100644 --- a/rslib/src/decks/mod.rs +++ b/rslib/src/decks/mod.rs @@ -605,6 +605,9 @@ impl Collection { let mut target_name = None; if let Some(target) = target { if let Some(target) = col.storage.get_deck(target)? { + if target.is_filtered() { + return Err(AnkiError::DeckIsFiltered); + } target_deck = target; target_name = Some(target_deck.name.as_str()); } From e2186047085b667550297baebce18022ad539fa0 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Mon, 1 Mar 2021 10:16:12 +1000 Subject: [PATCH 015/117] monospace font in html editor https://forums.ankiweb.net/t/change-default-font-of-html-edit-mode-to-a-monospaced-font/7833 --- qt/aqt/editor.py | 3 +++ qt/aqt/forms/edithtml.ui | 31 ++++++++++++++----------------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/qt/aqt/editor.py b/qt/aqt/editor.py index b0a29856a..f699cef44 100644 --- a/qt/aqt/editor.py +++ b/qt/aqt/editor.py @@ -589,6 +589,9 @@ class Editor: qconnect( form.buttonBox.helpRequested, lambda: openHelp(HelpPage.EDITING_FEATURES) ) + font = QFont("Courier") + font.setStyleHint(QFont.TypeWriter) + form.textEdit.setFont(font) form.textEdit.setPlainText(self.note.fields[field]) d.show() form.textEdit.moveCursor(QTextCursor.End) diff --git a/qt/aqt/forms/edithtml.ui b/qt/aqt/forms/edithtml.ui index 0b29039f9..8c24751b6 100644 --- a/qt/aqt/forms/edithtml.ui +++ b/qt/aqt/forms/edithtml.ui @@ -1,7 +1,8 @@ - + + Dialog - - + + 0 0 @@ -9,23 +10,19 @@ 300 - + EDITING_HTML_EDITOR - + - - - false - - + - - + + Qt::Horizontal - + QDialogButtonBox::Close|QDialogButtonBox::Help @@ -40,11 +37,11 @@ Dialog accept() - + 248 254 - + 157 274 @@ -56,11 +53,11 @@ Dialog reject() - + 316 260 - + 286 274 From 3bddf99ba115e39ee98d15e4564ab4d45813ce67 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Mon, 1 Mar 2021 10:59:01 +1000 Subject: [PATCH 016/117] remove local tz test This was breaking some of the unit tests when they happened to complete in a particular order --- rslib/src/scheduler/cutoff.rs | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/rslib/src/scheduler/cutoff.rs b/rslib/src/scheduler/cutoff.rs index c7727e46c..d4972e143 100644 --- a/rslib/src/scheduler/cutoff.rs +++ b/rslib/src/scheduler/cutoff.rs @@ -213,16 +213,6 @@ mod test { today.days_elapsed } - #[test] - #[cfg(target_vendor = "apple")] - /// On Linux, TZ needs to be set prior to the process being started to take effect, - /// so we limit this test to Macs. - fn local_minutes_west() { - // -480 throughout the year - std::env::set_var("TZ", "Australia/Perth"); - assert_eq!(local_minutes_west_for_stamp(Utc::now().timestamp()), -480); - } - #[test] fn days_elapsed() { let local_offset = local_minutes_west_for_stamp(Utc::now().timestamp()); From 2c6b6734b578399d7e1f739f02ba975a6a74d990 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Mon, 1 Mar 2021 10:34:04 +1000 Subject: [PATCH 017/117] experimental queue building Still a work in progress, and hidden behind a feature flag. --- Cargo.lock | 1 + pylib/anki/collection.py | 23 +- pylib/anki/scheduler.py | 583 +++--------------- pylib/anki/schedv2.py | 2 +- pylib/tests/test_sched2021.py | 1 + pylib/tests/test_schedv2.py | 190 ++++-- pylib/tests/test_undo.py | 14 +- rslib/BUILD.bazel | 1 + rslib/Cargo.toml | 1 + rslib/backend.proto | 52 +- rslib/cargo/BUILD.bazel | 9 + rslib/src/backend/mod.rs | 23 +- rslib/src/card.rs | 4 + rslib/src/collection.rs | 3 +- rslib/src/config.rs | 21 +- rslib/src/deckconf/mod.rs | 18 +- rslib/src/deckconf/schema11.rs | 36 +- rslib/src/decks/tree.rs | 19 +- rslib/src/preferences.rs | 2 +- rslib/src/scheduler/answering/mod.rs | 5 +- rslib/src/scheduler/bury_and_suspend.rs | 2 +- rslib/src/scheduler/mod.rs | 7 +- .../src/scheduler/queue/builder/gathering.rs | 174 ++++++ .../scheduler/queue/builder/intersperser.rs | 123 ++++ rslib/src/scheduler/queue/builder/mod.rs | 224 +++++++ .../scheduler/queue/builder/sized_chain.rs | 80 +++ rslib/src/scheduler/queue/builder/sorting.rs | 71 +++ rslib/src/scheduler/queue/learning.rs | 133 ++++ rslib/src/scheduler/queue/limits.rs | 216 +++++++ rslib/src/scheduler/queue/main.rs | 37 ++ rslib/src/scheduler/queue/mod.rs | 252 ++++++++ rslib/src/scheduler/{cutoff.rs => timing.rs} | 7 + rslib/src/scheduler/upgrade.rs | 2 +- rslib/src/storage/card/due_cards.sql | 18 + rslib/src/storage/card/mod.rs | 66 +- rslib/src/storage/card/new_cards.sql | 8 + rslib/src/storage/deck/mod.rs | 32 + rslib/src/storage/sqlite.rs | 2 +- rslib/src/timestamp.rs | 4 + 39 files changed, 1868 insertions(+), 598 deletions(-) create mode 120000 pylib/tests/test_sched2021.py create mode 100644 rslib/src/scheduler/queue/builder/gathering.rs create mode 100644 rslib/src/scheduler/queue/builder/intersperser.rs create mode 100644 rslib/src/scheduler/queue/builder/mod.rs create mode 100644 rslib/src/scheduler/queue/builder/sized_chain.rs create mode 100644 rslib/src/scheduler/queue/builder/sorting.rs create mode 100644 rslib/src/scheduler/queue/learning.rs create mode 100644 rslib/src/scheduler/queue/limits.rs create mode 100644 rslib/src/scheduler/queue/main.rs create mode 100644 rslib/src/scheduler/queue/mod.rs rename rslib/src/scheduler/{cutoff.rs => timing.rs} (98%) create mode 100644 rslib/src/storage/card/due_cards.sql create mode 100644 rslib/src/storage/card/new_cards.sql diff --git a/Cargo.lock b/Cargo.lock index a1a6adf9b..65d7858dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -62,6 +62,7 @@ dependencies = [ "flate2", "fluent", "fluent-syntax", + "fnv", "futures", "hex", "htmlescape", diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index a1535ad79..e6fbd759a 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -139,7 +139,7 @@ class Collection: if ver == 1: self.sched = V1Scheduler(self) elif ver == 2: - if os.getenv("TEST_SCHEDULER"): + if self.is_2021_test_scheduler_enabled(): self.sched = V2TestScheduler(self) # type: ignore else: self.sched = V2Scheduler(self) @@ -149,6 +149,14 @@ class Collection: self.clearUndo() self._loadScheduler() + def is_2021_test_scheduler_enabled(self) -> bool: + return self.get_config_bool(Config.Bool.SCHED_2021) + + def set_2021_test_scheduler_enabled(self, enabled: bool) -> None: + if self.is_2021_test_scheduler_enabled() != enabled: + self.set_config_bool(Config.Bool.SCHED_2021, enabled) + self._loadScheduler() + # DB-related ########################################################################## @@ -774,11 +782,14 @@ table.review-log {{ {revlog_style} }} c.nid, ) # and finally, update daily counts - n = c.queue - if c.queue in (QUEUE_TYPE_DAY_LEARN_RELEARN, QUEUE_TYPE_PREVIEW): - n = QUEUE_TYPE_LRN - type = ("new", "lrn", "rev")[n] - self.sched._updateStats(c, type, -1) + if self.sched.is_2021: + self._backend.requeue_undone_card(c.id) + else: + n = c.queue + if c.queue in (QUEUE_TYPE_DAY_LEARN_RELEARN, QUEUE_TYPE_PREVIEW): + n = QUEUE_TYPE_LRN + type = ("new", "lrn", "rev")[n] + self.sched._updateStats(c, type, -1) self.sched.reps -= 1 return c.id diff --git a/pylib/anki/scheduler.py b/pylib/anki/scheduler.py index bf989c005..5ec1fde1c 100644 --- a/pylib/anki/scheduler.py +++ b/pylib/anki/scheduler.py @@ -8,458 +8,113 @@ used by Anki. from __future__ import annotations -import pprint -import random -import time from heapq import * -from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union +from typing import Any, List, Optional, Sequence, Tuple, Union import anki # pylint: disable=unused-import import anki._backend.backend_pb2 as _pb from anki import hooks from anki.cards import Card from anki.consts import * -from anki.decks import Deck, DeckConfig, DeckManager, DeckTreeNode, QueueConfig +from anki.decks import DeckConfig, DeckTreeNode, QueueConfig from anki.notes import Note from anki.types import assert_exhaustive from anki.utils import from_json_bytes, ids2str, intTime +QueuedCards = _pb.GetQueuedCardsOut.QueuedCards CongratsInfo = _pb.CongratsInfoOut -CountsForDeckToday = _pb.CountsForDeckTodayOut SchedTimingToday = _pb.SchedTimingTodayOut UnburyCurrentDeck = _pb.UnburyCardsInCurrentDeckIn BuryOrSuspend = _pb.BuryOrSuspendCardsIn +# fixme: reviewer.cardQueue/editCurrent/undo handling/retaining current card +# fixme: .reps + class Scheduler: - _burySiblingsOnAnswer = True + is_2021 = True def __init__(self, col: anki.collection.Collection) -> None: self.col = col.weakref() - self.queueLimit = 50 - self.reportLimit = 1000 - self.dynReportLimit = 99999 + # fixme: only used by the timeboxing code, and was double-incremented + # for ages - just move to gui? self.reps = 0 - self.today: Optional[int] = None - self._haveQueues = False - self._lrnCutoff = 0 - self._updateCutoff() - # Daily cutoff + # Timing ########################################################################## - def _updateCutoff(self) -> None: - timing = self._timing_today() - self.today = timing.days_elapsed - self.dayCutoff = timing.next_day_at - - def _checkDay(self) -> None: - # check if the day has rolled over - if time.time() > self.dayCutoff: - self.reset() - - def _timing_today(self) -> SchedTimingToday: + def timing_today(self) -> SchedTimingToday: return self.col._backend.sched_timing_today() + @property + def today(self) -> int: + return self.timing_today().days_elapsed + + @property + def dayCutoff(self) -> int: + return self.timing_today().next_day_at + # Fetching the next card ########################################################################## def reset(self) -> None: - self.col.decks.update_active() - self._updateCutoff() - self._reset_counts() - self._resetLrn() - self._resetRev() - self._resetNew() - self._haveQueues = True + self.col._backend.clear_card_queues() - def _reset_counts(self) -> None: - tree = self.deck_due_tree(self.col.decks.selected()) - node = self.col.decks.find_deck_in_tree(tree, int(self.col.conf["curDeck"])) - if not node: - # current deck points to a missing deck - self.newCount = 0 - self.revCount = 0 - self._immediate_learn_count = 0 + def get_queued_cards( + self, + *, + fetch_limit: int = 1, + intraday_learning_only: bool = False, + ) -> Union[QueuedCards, CongratsInfo]: + info = self.col._backend.get_queued_cards( + fetch_limit=fetch_limit, intraday_learning_only=intraday_learning_only + ) + kind = info.WhichOneof("value") + if kind == "queued_cards": + return info.queued_cards + elif kind == "congrats_info": + return info.congrats_info else: - self.newCount = node.new_count - self.revCount = node.review_count - self._immediate_learn_count = node.learn_count + assert_exhaustive(kind) + assert False def getCard(self) -> Optional[Card]: - """Pop the next card from the queue. None if finished.""" - self._checkDay() - if not self._haveQueues: - self.reset() - card = self._getCard() - if card: - self.col.log(card) - if not self._burySiblingsOnAnswer: - self._burySiblings(card) - self.reps += 1 + """Fetch the next card from the queue. None if finished.""" + response = self.get_queued_cards() + if isinstance(response, QueuedCards): + backend_card = response.cards[0].card + card = Card(self.col) + card._load_from_backend_card(backend_card) card.startTimer() return card - return None - - def _getCard(self) -> Optional[Card]: - """Return the next due card, or None.""" - # learning card due? - c = self._getLrnCard() - if c: - return c - - # new first, or time for one? - if self._timeForNewCard(): - c = self._getNewCard() - if c: - return c - - # day learning first and card due? - dayLearnFirst = self.col.conf.get("dayLearnFirst", False) - if dayLearnFirst: - c = self._getLrnDayCard() - if c: - return c - - # card due for review? - c = self._getRevCard() - if c: - return c - - # day learning card due? - if not dayLearnFirst: - c = self._getLrnDayCard() - if c: - return c - - # new cards left? - c = self._getNewCard() - if c: - return c - - # collapse or finish - return self._getLrnCard(collapse=True) - - # Fetching new cards - ########################################################################## - - def _resetNew(self) -> None: - self._newDids = self.col.decks.active()[:] - self._newQueue: List[int] = [] - self._updateNewCardRatio() - - def _fillNew(self, recursing: bool = False) -> bool: - if self._newQueue: - return True - if not self.newCount: - return False - while self._newDids: - did = self._newDids[0] - lim = min(self.queueLimit, self._deckNewLimit(did)) - if lim: - # fill the queue with the current did - self._newQueue = self.col.db.list( - f""" - select id from cards where did = ? and queue = {QUEUE_TYPE_NEW} order by due,ord limit ?""", - did, - lim, - ) - if self._newQueue: - self._newQueue.reverse() - return True - # nothing left in the deck; move to next - self._newDids.pop(0) - - # if we didn't get a card but the count is non-zero, - # we need to check again for any cards that were - # removed from the queue but not buried - if recursing: - print("bug: fillNew()") - return False - self._reset_counts() - self._resetNew() - return self._fillNew(recursing=True) - - def _getNewCard(self) -> Optional[Card]: - if self._fillNew(): - self.newCount -= 1 - return self.col.getCard(self._newQueue.pop()) - return None - - def _updateNewCardRatio(self) -> None: - if self.col.conf["newSpread"] == NEW_CARDS_DISTRIBUTE: - if self.newCount: - self.newCardModulus = (self.newCount + self.revCount) // self.newCount - # if there are cards to review, ensure modulo >= 2 - if self.revCount: - self.newCardModulus = max(2, self.newCardModulus) - return - self.newCardModulus = 0 - - def _timeForNewCard(self) -> Optional[bool]: - "True if it's time to display a new card when distributing." - if not self.newCount: - return False - if self.col.conf["newSpread"] == NEW_CARDS_LAST: - return False - elif self.col.conf["newSpread"] == NEW_CARDS_FIRST: - return True - elif self.newCardModulus: - return self.reps != 0 and self.reps % self.newCardModulus == 0 else: - # shouldn't reach return None - def _deckNewLimit( - self, did: int, fn: Optional[Callable[[Deck], int]] = None - ) -> int: - if not fn: - fn = self._deckNewLimitSingle - sel = self.col.decks.get(did) - lim = -1 - # for the deck and each of its parents - for g in [sel] + self.col.decks.parents(did): - rem = fn(g) - if lim == -1: - lim = rem - else: - lim = min(rem, lim) - return lim + def _is_finished(self) -> bool: + "Don't use this, it is a stop-gap until this code is refactored." + info = self.get_queued_cards() + return isinstance(info, CongratsInfo) - def _newForDeck(self, did: int, lim: int) -> int: - "New count for a single deck." - if not lim: - return 0 - lim = min(lim, self.reportLimit) - return self.col.db.scalar( - f""" -select count() from -(select 1 from cards where did = ? and queue = {QUEUE_TYPE_NEW} limit ?)""", - did, - lim, - ) + def counts(self, card: Optional[Card] = None) -> Tuple[int, int, int]: + info = self.get_queued_cards() + if isinstance(info, CongratsInfo): + counts = [0, 0, 0] + else: + counts = [info.new_count, info.learning_count, info.review_count] - def _deckNewLimitSingle(self, g: DeckConfig) -> int: - "Limit for deck without parent limits." - if g["dyn"]: - return self.dynReportLimit - c = self.col.decks.confForDid(g["id"]) - limit = max(0, c["new"]["perDay"] - self.counts_for_deck_today(g["id"]).new) - return hooks.scheduler_new_limit_for_single_deck(limit, g) + return tuple(counts) # type: ignore - def totalNewForCurrentDeck(self) -> int: - return self.col.db.scalar( - f""" -select count() from cards where id in ( -select id from cards where did in %s and queue = {QUEUE_TYPE_NEW} limit ?)""" - % self._deckLimit(), - self.reportLimit, - ) + @property + def newCount(self) -> int: + return self.counts()[0] - # Fetching learning cards - ########################################################################## + @property + def lrnCount(self) -> int: + return self.counts()[1] - # scan for any newly due learning cards every minute - def _updateLrnCutoff(self, force: bool) -> bool: - nextCutoff = intTime() + self.col.conf["collapseTime"] - if nextCutoff - self._lrnCutoff > 60 or force: - self._lrnCutoff = nextCutoff - return True - return False - - def _maybeResetLrn(self, force: bool) -> None: - if self._updateLrnCutoff(force): - self._resetLrn() - - def _resetLrnCount(self) -> None: - # sub-day - self.lrnCount = ( - self.col.db.scalar( - f""" -select count() from cards where did in %s and queue = {QUEUE_TYPE_LRN} -and due < ?""" - % (self._deckLimit()), - self._lrnCutoff, - ) - or 0 - ) - # day - self.lrnCount += self.col.db.scalar( - f""" -select count() from cards where did in %s and queue = {QUEUE_TYPE_DAY_LEARN_RELEARN} -and due <= ?""" - % (self._deckLimit()), - self.today, - ) - # previews - self.lrnCount += self.col.db.scalar( - f""" -select count() from cards where did in %s and queue = {QUEUE_TYPE_PREVIEW} -""" - % (self._deckLimit()) - ) - - def _resetLrn(self) -> None: - self._updateLrnCutoff(force=True) - self._resetLrnCount() - self._lrnQueue: List[Tuple[int, int]] = [] - self._lrnDayQueue: List[int] = [] - self._lrnDids = self.col.decks.active()[:] - - # sub-day learning - def _fillLrn(self) -> Union[bool, List[Any]]: - if not self.lrnCount: - return False - if self._lrnQueue: - return True - cutoff = intTime() + self.col.conf["collapseTime"] - self._lrnQueue = self.col.db.all( # type: ignore - f""" -select due, id from cards where -did in %s and queue in ({QUEUE_TYPE_LRN},{QUEUE_TYPE_PREVIEW}) and due < ? -limit %d""" - % (self._deckLimit(), self.reportLimit), - cutoff, - ) - for i in range(len(self._lrnQueue)): - self._lrnQueue[i] = (self._lrnQueue[i][0], self._lrnQueue[i][1]) - # as it arrives sorted by did first, we need to sort it - self._lrnQueue.sort() - return self._lrnQueue - - def _getLrnCard(self, collapse: bool = False) -> Optional[Card]: - self._maybeResetLrn(force=collapse and self.lrnCount == 0) - if self._fillLrn(): - cutoff = time.time() - if collapse: - cutoff += self.col.conf["collapseTime"] - if self._lrnQueue[0][0] < cutoff: - id = heappop(self._lrnQueue)[1] - card = self.col.getCard(id) - self.lrnCount -= 1 - return card - return None - - # daily learning - def _fillLrnDay(self) -> Optional[bool]: - if not self.lrnCount: - return False - if self._lrnDayQueue: - return True - while self._lrnDids: - did = self._lrnDids[0] - # fill the queue with the current did - self._lrnDayQueue = self.col.db.list( - f""" -select id from cards where -did = ? and queue = {QUEUE_TYPE_DAY_LEARN_RELEARN} and due <= ? limit ?""", - did, - self.today, - self.queueLimit, - ) - if self._lrnDayQueue: - # order - r = random.Random() - r.seed(self.today) - r.shuffle(self._lrnDayQueue) - # is the current did empty? - if len(self._lrnDayQueue) < self.queueLimit: - self._lrnDids.pop(0) - return True - # nothing left in the deck; move to next - self._lrnDids.pop(0) - # shouldn't reach here - return False - - def _getLrnDayCard(self) -> Optional[Card]: - if self._fillLrnDay(): - self.lrnCount -= 1 - return self.col.getCard(self._lrnDayQueue.pop()) - return None - - # Fetching reviews - ########################################################################## - - def _currentRevLimit(self) -> int: - d = self.col.decks.get(self.col.decks.selected(), default=False) - return self._deckRevLimitSingle(d) - - def _deckRevLimitSingle( - self, d: Dict[str, Any], parentLimit: Optional[int] = None - ) -> int: - # invalid deck selected? - if not d: - return 0 - - if d["dyn"]: - return self.dynReportLimit - - c = self.col.decks.confForDid(d["id"]) - lim = max(0, c["rev"]["perDay"] - self.counts_for_deck_today(d["id"]).review) - - if parentLimit is not None: - lim = min(parentLimit, lim) - elif "::" in d["name"]: - for parent in self.col.decks.parents(d["id"]): - # pass in dummy parentLimit so we don't do parent lookup again - lim = min(lim, self._deckRevLimitSingle(parent, parentLimit=lim)) - return hooks.scheduler_review_limit_for_single_deck(lim, d) - - def _revForDeck( - self, did: int, lim: int, childMap: DeckManager.childMapNode - ) -> Any: - dids = [did] + self.col.decks.childDids(did, childMap) - lim = min(lim, self.reportLimit) - return self.col.db.scalar( - f""" -select count() from -(select 1 from cards where did in %s and queue = {QUEUE_TYPE_REV} -and due <= ? limit ?)""" - % ids2str(dids), - self.today, - lim, - ) - - def _resetRev(self) -> None: - self._revQueue: List[int] = [] - - def _fillRev(self, recursing: bool = False) -> bool: - "True if a review card can be fetched." - if self._revQueue: - return True - if not self.revCount: - return False - - lim = min(self.queueLimit, self._currentRevLimit()) - if lim: - self._revQueue = self.col.db.list( - f""" -select id from cards where -did in %s and queue = {QUEUE_TYPE_REV} and due <= ? -order by due, random() -limit ?""" - % self._deckLimit(), - self.today, - lim, - ) - - if self._revQueue: - # preserve order - self._revQueue.reverse() - return True - - if recursing: - print("bug: fillRev2()") - return False - self._reset_counts() - self._resetRev() - return self._fillRev(recursing=True) - - def _getRevCard(self) -> Optional[Card]: - if self._fillRev(): - self.revCount -= 1 - return self.col.getCard(self._revQueue.pop()) - return None + @property + def reviewCount(self) -> int: + return self.counts()[2] # Answering a card ########################################################################## @@ -470,13 +125,11 @@ limit ?""" self.col.markReview(card) - if self._burySiblingsOnAnswer: - self._burySiblings(card) - new_state = self._answerCard(card, ease) - if not self._handle_leech(card, new_state): - self._maybe_requeue_card(card) + self._handle_leech(card, new_state) + + self.reps += 1 def _answerCard(self, card: Card, ease: int) -> _pb.SchedulingState: states = self.col._backend.get_next_card_states(card.id) @@ -523,45 +176,6 @@ limit ?""" else: return False - def _maybe_requeue_card(self, card: Card) -> None: - # preview cards - if card.queue == QUEUE_TYPE_PREVIEW: - # adjust the count immediately, and rely on the once a minute - # checks to requeue it - self.lrnCount += 1 - return - - # learning cards - if not card.queue == QUEUE_TYPE_LRN: - return - if card.due >= (intTime() + self.col.conf["collapseTime"]): - return - - # card is due within collapse time, so we'll want to add it - # back to the learning queue - self.lrnCount += 1 - - # if the queue is not empty and there's nothing else to do, make - # sure we don't put it at the head of the queue and end up showing - # it twice in a row - if self._lrnQueue and not self.revCount and not self.newCount: - smallestDue = self._lrnQueue[0][0] - card.due = max(card.due, smallestDue + 1) - - heappush(self._lrnQueue, (card.due, card.id)) - - def _cardConf(self, card: Card) -> DeckConfig: - return self.col.decks.confForDid(card.did) - - def _home_config(self, card: Card) -> DeckConfig: - return self.col.decks.confForDid(card.odid or card.did) - - def _deckLimit(self) -> str: - return ids2str(self.col.decks.active()) - - def counts_for_deck_today(self, deck_id: int) -> CountsForDeckToday: - return self.col._backend.counts_for_deck_today(deck_id) - # Next times ########################################################################## # fixme: move these into tests_schedv2 in the future @@ -618,52 +232,9 @@ limit ?""" return self._interval_for_state(new_state) - # Sibling spacing - ########################################################################## - - def _burySiblings(self, card: Card) -> None: - toBury: List[int] = [] - conf = self._home_config(card) - bury_new = conf["new"].get("bury", True) - bury_rev = conf["rev"].get("bury", True) - # loop through and remove from queues - for cid, queue in self.col.db.execute( - f""" -select id, queue from cards where nid=? and id!=? -and (queue={QUEUE_TYPE_NEW} or (queue={QUEUE_TYPE_REV} and due<=?))""", - card.nid, - card.id, - self.today, - ): - if queue == QUEUE_TYPE_REV: - queue_obj = self._revQueue - if bury_rev: - toBury.append(cid) - else: - queue_obj = self._newQueue - if bury_new: - toBury.append(cid) - - # even if burying disabled, we still discard to give same-day spacing - try: - queue_obj.remove(cid) - except ValueError: - pass - # then bury - if toBury: - self.bury_cards(toBury, manual=False) - # Review-related UI helpers ########################################################################## - def counts(self, card: Optional[Card] = None) -> Tuple[int, int, int]: - counts = [self.newCount, self.lrnCount, self.revCount] - if card: - idx = self.countIdx(card) - counts[idx] += 1 - new, lrn, rev = counts - return (new, lrn, rev) - def countIdx(self, card: Card) -> int: if card.queue in (QUEUE_TYPE_DAY_LEARN_RELEARN, QUEUE_TYPE_PREVIEW): return QUEUE_TYPE_LRN @@ -708,18 +279,14 @@ and (queue={QUEUE_TYPE_NEW} or (queue={QUEUE_TYPE_REV} and due<=?))""", did = self.col.decks.current()["id"] self.col._backend.extend_limits(deck_id=did, new_delta=new, review_delta=rev) - def _is_finished(self) -> bool: - "Don't use this, it is a stop-gap until this code is refactored." - return not any((self.newCount, self.revCount, self._immediate_learn_count)) - + # fixme: used by custom study def totalRevForCurrentDeck(self) -> int: return self.col.db.scalar( f""" select count() from cards where id in ( -select id from cards where did in %s and queue = {QUEUE_TYPE_REV} and due <= ? limit ?)""" +select id from cards where did in %s and queue = {QUEUE_TYPE_REV} and due <= ? limit 9999)""" % self._deckLimit(), self.today, - self.reportLimit, ) # Filtered deck handling @@ -832,11 +399,6 @@ select id from cards where did in %s and queue = {QUEUE_TYPE_REV} and due <= ? l ########################################################################## - def __repr__(self) -> str: - d = dict(self.__dict__) - del d["col"] - return f"{super().__repr__()} {pprint.pformat(d, width=300)}" - # unit tests def _fuzzIvlRange(self, ivl: int) -> Tuple[int, int]: return (ivl, ivl) @@ -844,6 +406,11 @@ select id from cards where did in %s and queue = {QUEUE_TYPE_REV} and due <= ? l # Legacy aliases and helpers ########################################################################## + # fixme: only used by totalRevForCurrentDeck and old deck stats + def _deckLimit(self) -> str: + self.col.decks.update_active() + return ids2str(self.col.decks.active()) + def reschedCards( self, card_ids: List[int], min_interval: int, max_interval: int ) -> None: @@ -943,6 +510,12 @@ due = (case when odue>0 then odue else due end), odue = 0, odid = 0, usn = ? whe ) return from_json_bytes(self.col._backend.deck_tree_legacy())[5] + def _cardConf(self, card: Card) -> DeckConfig: + return self.col.decks.confForDid(card.did) + + def _home_config(self, card: Card) -> DeckConfig: + return self.col.decks.confForDid(card.odid or card.did) + def _newConf(self, card: Card) -> QueueConfig: return self._home_config(card)["new"] diff --git a/pylib/anki/schedv2.py b/pylib/anki/schedv2.py index c152f7acc..46c740563 100644 --- a/pylib/anki/schedv2.py +++ b/pylib/anki/schedv2.py @@ -39,6 +39,7 @@ class Scheduler: haveCustomStudy = True _burySiblingsOnAnswer = True revCount: int + is_2021 = False def __init__(self, col: anki.collection.Collection) -> None: self.col = col.weakref() @@ -102,7 +103,6 @@ class Scheduler: self.col.log(card) if not self._burySiblingsOnAnswer: self._burySiblings(card) - self.reps += 1 card.startTimer() return card return None diff --git a/pylib/tests/test_sched2021.py b/pylib/tests/test_sched2021.py new file mode 120000 index 000000000..e0aedf8f1 --- /dev/null +++ b/pylib/tests/test_sched2021.py @@ -0,0 +1 @@ +test_schedv2.py \ No newline at end of file diff --git a/pylib/tests/test_schedv2.py b/pylib/tests/test_schedv2.py index 04c46ff33..bf84a8834 100644 --- a/pylib/tests/test_schedv2.py +++ b/pylib/tests/test_schedv2.py @@ -2,6 +2,9 @@ import copy import time +from typing import Tuple + +import pytest from anki import hooks from anki.consts import * @@ -10,10 +13,19 @@ from anki.schedv2 import UnburyCurrentDeck from anki.utils import intTime from tests.shared import getEmptyCol as getEmptyColOrig +# This file is used to exercise both the legacy Python 2.1 scheduler, +# and the experimental new one in Rust. Most tests run on both, but a few +# tests have been implemented separately where the behaviour differs. +is_2021 = "2021" in __file__ +new_sched_only = pytest.mark.skipif(not is_2021, reason="2021 only") +old_sched_only = pytest.mark.skipif(is_2021, reason="old only") + def getEmptyCol(): col = getEmptyColOrig() col.upgrade_to_v2_scheduler() + if is_2021: + col.set_2021_test_scheduler_enabled(True) return col @@ -183,6 +195,7 @@ def test_learn(): c.type = CARD_TYPE_NEW c.queue = QUEUE_TYPE_LRN c.flush() + col.sched.reset() col.sched.answerCard(c, 4) assert c.type == CARD_TYPE_REV assert c.queue == QUEUE_TYPE_REV @@ -274,6 +287,9 @@ def test_learn_day(): note = col.newNote() note["Front"] = "one" col.addNote(note) + note = col.newNote() + note["Front"] = "two" + col.addNote(note) col.sched.reset() c = col.sched.getCard() conf = col.sched._cardConf(c) @@ -283,11 +299,14 @@ def test_learn_day(): col.sched.answerCard(c, 3) # two reps to graduate, 1 more today assert c.left % 1000 == 3 - assert col.sched.counts() == (0, 1, 0) - c = col.sched.getCard() + assert col.sched.counts() == (1, 1, 0) + c.load() ni = col.sched.nextIvl assert ni(c, 3) == 86400 - # answering it will place it in queue 3 + # answer the other dummy card + col.sched.answerCard(col.sched.getCard(), 4) + # answering the first one will place it in queue 3 + c = col.sched.getCard() col.sched.answerCard(c, 3) assert c.due == col.sched.today + 1 assert c.queue == QUEUE_TYPE_DAY_LEARN_RELEARN @@ -296,7 +315,11 @@ def test_learn_day(): c.due -= 1 c.flush() col.reset() - assert col.sched.counts() == (0, 1, 0) + if is_2021: + # it appears in the review queue + assert col.sched.counts() == (0, 0, 1) + else: + assert col.sched.counts() == (0, 1, 0) c = col.sched.getCard() # nextIvl should work assert ni(c, 3) == 86400 * 2 @@ -408,7 +431,7 @@ def test_reviews(): assert "leech" in c.note().tags -def test_review_limits(): +def review_limits_setup() -> Tuple[anki.collection.Collection, Dict]: col = getEmptyCol() parent = col.decks.get(col.decks.id("parent")) @@ -442,6 +465,13 @@ def test_review_limits(): c.due = 0 c.flush() + return col, child + + +@old_sched_only +def test_review_limits(): + col, child = review_limits_setup() + tree = col.sched.deck_due_tree().children # (('parent', 1514457677462, 5, 0, 0, (('child', 1514457677463, 5, 0, 0, ()),))) assert tree[0].review_count == 5 # parent @@ -462,6 +492,29 @@ def test_review_limits(): assert tree[0].children[0].review_count == 9 # child +@new_sched_only +def test_review_limits_new(): + col, child = review_limits_setup() + + tree = col.sched.deck_due_tree().children + assert tree[0].review_count == 5 # parent + assert tree[0].children[0].review_count == 5 # child capped by parent + + # child .counts() are bound by parents + col.decks.select(child["id"]) + col.sched.reset() + assert col.sched.counts() == (0, 0, 5) + + # answering a card in the child should decrement both child and parent count + c = col.sched.getCard() + col.sched.answerCard(c, 3) + assert col.sched.counts() == (0, 0, 4) + + tree = col.sched.deck_due_tree().children + assert tree[0].review_count == 4 # parent + assert tree[0].children[0].review_count == 4 # child + + def test_button_spacing(): col = getEmptyCol() note = col.newNote() @@ -851,13 +904,20 @@ def test_ordcycle(): note["Back"] = "1" col.addNote(note) assert col.cardCount() == 3 + + conf = col.decks.get_config(1) + conf["new"]["bury"] = False + col.decks.save(conf) col.reset() + # ordinals should arrive in order - assert col.sched.getCard().ord == 0 - assert col.sched.getCard().ord == 1 - assert col.sched.getCard().ord == 2 + for i in range(3): + c = col.sched.getCard() + assert c.ord == i + col.sched.answerCard(c, 4) +@old_sched_only def test_counts_idx(): col = getEmptyCol() note = col.newNote() @@ -882,57 +942,87 @@ def test_counts_idx(): assert col.sched.counts() == (0, 1, 0) +@new_sched_only +def test_counts_idx_new(): + col = getEmptyCol() + note = col.newNote() + note["Front"] = "one" + note["Back"] = "two" + col.addNote(note) + note = col.newNote() + note["Front"] = "two" + note["Back"] = "two" + col.addNote(note) + col.reset() + assert col.sched.counts() == (2, 0, 0) + c = col.sched.getCard() + # getCard does not decrement counts + assert col.sched.counts() == (2, 0, 0) + assert col.sched.countIdx(c) == 0 + # answer to move to learn queue + col.sched.answerCard(c, 1) + assert col.sched.counts() == (1, 1, 0) + assert col.sched.countIdx(c) == 1 + # fetching next will not decrement the count + c = col.sched.getCard() + assert col.sched.counts() == (1, 1, 0) + assert col.sched.countIdx(c) == 0 + + def test_repCounts(): col = getEmptyCol() note = col.newNote() note["Front"] = "one" col.addNote(note) - col.reset() - # lrnReps should be accurate on pass/fail - assert col.sched.counts() == (1, 0, 0) - col.sched.answerCard(col.sched.getCard(), 1) - assert col.sched.counts() == (0, 1, 0) - col.sched.answerCard(col.sched.getCard(), 1) - assert col.sched.counts() == (0, 1, 0) - col.sched.answerCard(col.sched.getCard(), 3) - assert col.sched.counts() == (0, 1, 0) - col.sched.answerCard(col.sched.getCard(), 1) - assert col.sched.counts() == (0, 1, 0) - col.sched.answerCard(col.sched.getCard(), 3) - assert col.sched.counts() == (0, 1, 0) - col.sched.answerCard(col.sched.getCard(), 3) - assert col.sched.counts() == (0, 0, 0) note = col.newNote() note["Front"] = "two" col.addNote(note) col.reset() - # initial pass should be correct too - col.sched.answerCard(col.sched.getCard(), 3) - assert col.sched.counts() == (0, 1, 0) + # lrnReps should be accurate on pass/fail + assert col.sched.counts() == (2, 0, 0) col.sched.answerCard(col.sched.getCard(), 1) + assert col.sched.counts() == (1, 1, 0) + col.sched.answerCard(col.sched.getCard(), 1) + assert col.sched.counts() == (0, 2, 0) + col.sched.answerCard(col.sched.getCard(), 3) + assert col.sched.counts() == (0, 2, 0) + col.sched.answerCard(col.sched.getCard(), 1) + assert col.sched.counts() == (0, 2, 0) + col.sched.answerCard(col.sched.getCard(), 3) assert col.sched.counts() == (0, 1, 0) col.sched.answerCard(col.sched.getCard(), 4) assert col.sched.counts() == (0, 0, 0) - # immediate graduate should work note = col.newNote() note["Front"] = "three" col.addNote(note) + note = col.newNote() + note["Front"] = "four" + col.addNote(note) col.reset() + # initial pass and immediate graduate should be correct too + assert col.sched.counts() == (2, 0, 0) + col.sched.answerCard(col.sched.getCard(), 3) + assert col.sched.counts() == (1, 1, 0) + col.sched.answerCard(col.sched.getCard(), 4) + assert col.sched.counts() == (0, 1, 0) col.sched.answerCard(col.sched.getCard(), 4) assert col.sched.counts() == (0, 0, 0) # and failing a review should too note = col.newNote() - note["Front"] = "three" + note["Front"] = "five" col.addNote(note) c = note.cards()[0] c.type = CARD_TYPE_REV c.queue = QUEUE_TYPE_REV c.due = col.sched.today c.flush() + note = col.newNote() + note["Front"] = "six" + col.addNote(note) col.reset() - assert col.sched.counts() == (0, 0, 1) + assert col.sched.counts() == (1, 0, 1) col.sched.answerCard(col.sched.getCard(), 1) - assert col.sched.counts() == (0, 1, 0) + assert col.sched.counts() == (1, 1, 0) def test_timing(): @@ -968,12 +1058,25 @@ def test_collapse(): note = col.newNote() note["Front"] = "one" col.addNote(note) + # and another, so we don't get the same twice in a row + note = col.newNote() + note["Front"] = "two" + col.addNote(note) col.reset() - # test collapsing + # first note c = col.sched.getCard() col.sched.answerCard(c, 1) - c = col.sched.getCard() - col.sched.answerCard(c, 4) + # second note + c2 = col.sched.getCard() + assert c2.nid != c.nid + col.sched.answerCard(c2, 1) + # first should become available again, despite it being due in the future + c3 = col.sched.getCard() + assert c3.due > intTime() + col.sched.answerCard(c3, 4) + # answer other + c4 = col.sched.getCard() + col.sched.answerCard(c4, 4) assert not col.sched.getCard() @@ -1049,13 +1152,20 @@ def test_deckFlow(): note["Front"] = "three" default1 = note.model()["did"] = col.decks.id("Default::1") col.addNote(note) - # should get top level one first, then ::1, then ::2 col.reset() assert col.sched.counts() == (3, 0, 0) - for i in "one", "three", "two": - c = col.sched.getCard() - assert c.note()["Front"] == i - col.sched.answerCard(c, 3) + if is_2021: + # cards arrive in position order by default + for i in "one", "two", "three": + c = col.sched.getCard() + assert c.note()["Front"] == i + col.sched.answerCard(c, 3) + else: + # should get top level one first, then ::1, then ::2 + for i in "one", "three", "two": + c = col.sched.getCard() + assert c.note()["Front"] == i + col.sched.answerCard(c, 3) def test_reorder(): @@ -1120,13 +1230,13 @@ def test_resched(): note["Front"] = "one" col.addNote(note) c = note.cards()[0] - col.sched.reschedCards([c.id], 0, 0) + col.sched.set_due_date([c.id], "0") c.load() assert c.due == col.sched.today assert c.ivl == 1 assert c.queue == QUEUE_TYPE_REV and c.type == CARD_TYPE_REV # make it due tomorrow - col.sched.reschedCards([c.id], 1, 1) + col.sched.set_due_date([c.id], "1") c.load() assert c.due == col.sched.today + 1 assert c.ivl == 1 diff --git a/pylib/tests/test_undo.py b/pylib/tests/test_undo.py index eae507fec..fac70af27 100644 --- a/pylib/tests/test_undo.py +++ b/pylib/tests/test_undo.py @@ -50,31 +50,29 @@ def test_review(): note = col.newNote() note["Front"] = "one" col.addNote(note) + note = col.newNote() + note["Front"] = "two" + col.addNote(note) col.reset() assert not col.undoName() # answer - assert col.sched.counts() == (1, 0, 0) + assert col.sched.counts() == (2, 0, 0) c = col.sched.getCard() assert c.queue == QUEUE_TYPE_NEW col.sched.answerCard(c, 3) assert c.left % 1000 == 1 - assert col.sched.counts() == (0, 1, 0) + assert col.sched.counts() == (1, 1, 0) assert c.queue == QUEUE_TYPE_LRN # undo assert col.undoName() col.undo() col.reset() - assert col.sched.counts() == (1, 0, 0) + assert col.sched.counts() == (2, 0, 0) c.load() assert c.queue == QUEUE_TYPE_NEW assert c.left % 1000 != 1 assert not col.undoName() # we should be able to undo multiple answers too - note = col.newNote() - note["Front"] = "two" - col.addNote(note) - col.reset() - assert col.sched.counts() == (2, 0, 0) c = col.sched.getCard() col.sched.answerCard(c, 3) c = col.sched.getCard() diff --git a/rslib/BUILD.bazel b/rslib/BUILD.bazel index 52a886621..c2919a83c 100644 --- a/rslib/BUILD.bazel +++ b/rslib/BUILD.bazel @@ -84,6 +84,7 @@ rust_library( "//rslib/cargo:failure", "//rslib/cargo:flate2", "//rslib/cargo:fluent", + "//rslib/cargo:fnv", "//rslib/cargo:futures", "//rslib/cargo:hex", "//rslib/cargo:htmlescape", diff --git a/rslib/Cargo.toml b/rslib/Cargo.toml index 657cb372c..d970a316c 100644 --- a/rslib/Cargo.toml +++ b/rslib/Cargo.toml @@ -81,3 +81,4 @@ async-trait = "0.1.42" proc-macro-nested = "=0.1.6" ammonia = "3.1.0" pulldown-cmark = "0.8.0" +fnv = "1.0.7" diff --git a/rslib/backend.proto b/rslib/backend.proto index 4fe09b5fd..860b223d3 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -120,6 +120,9 @@ service BackendService { rpc StateIsLeech(SchedulingState) returns (Bool); rpc AnswerCard(AnswerCardIn) returns (Empty); rpc UpgradeScheduler(Empty) returns (Empty); + rpc GetQueuedCards(GetQueuedCardsIn) returns (GetQueuedCardsOut); + rpc ClearCardQueues(Empty) returns (Empty); + rpc RequeueUndoneCard(CardID) returns (Empty); // stats @@ -252,7 +255,17 @@ message DeckConfigInner { NEW_CARD_ORDER_DUE = 0; NEW_CARD_ORDER_RANDOM = 1; } - + enum ReviewCardOrder { + REVIEW_CARD_ORDER_SHUFFLED_BY_DAY = 0; + REVIEW_CARD_ORDER_SHUFFLED = 1; + REVIEW_CARD_ORDER_INTERVALS_ASCENDING = 2; + REVIEW_CARD_ORDER_INTERVALS_DESCENDING = 3; + } + enum ReviewMix { + REVIEW_MIX_MIX_WITH_REVIEWS = 0; + REVIEW_MIX_AFTER_REVIEWS = 1; + REVIEW_MIX_BEFORE_REVIEWS = 2; + } enum LeechAction { LEECH_ACTION_SUSPEND = 0; LEECH_ACTION_TAG_ONLY = 1; @@ -265,6 +278,7 @@ message DeckConfigInner { uint32 new_per_day = 9; uint32 reviews_per_day = 10; + uint32 new_per_day_minimum = 29; float initial_ease = 11; float easy_multiplier = 12; @@ -279,6 +293,10 @@ message DeckConfigInner { uint32 graduating_interval_easy = 19; NewCardOrder new_card_order = 20; + ReviewCardOrder review_order = 32; + + ReviewMix new_mix = 30; + ReviewMix interday_learning_mix = 31; LeechAction leech_action = 21; uint32 leech_threshold = 22; @@ -1243,6 +1261,7 @@ message Config { COLLAPSE_TODAY = 6; COLLAPSE_CARD_STATE = 7; COLLAPSE_FLAGS = 8; + SCHED_2021 = 9; } Key key = 1; } @@ -1341,3 +1360,34 @@ message AnswerCardIn { int64 answered_at_millis = 5; uint32 milliseconds_taken = 6; } + +message GetQueuedCardsIn { + uint32 fetch_limit = 1; + bool intraday_learning_only = 2; +} + +message GetQueuedCardsOut { + enum Queue { + New = 0; + Learning = 1; + Review = 2; + } + + message QueuedCard { + Card card = 1; + Queue queue = 5; + NextCardStates next_states = 6; + } + + message QueuedCards { + repeated QueuedCard cards = 1; + uint32 new_count = 2; + uint32 learning_count = 3; + uint32 review_count = 4; + } + + oneof value { + QueuedCards queued_cards = 1; + CongratsInfoOut congrats_info = 2; + } +} diff --git a/rslib/cargo/BUILD.bazel b/rslib/cargo/BUILD.bazel index 3e0b3dae5..e74595146 100644 --- a/rslib/cargo/BUILD.bazel +++ b/rslib/cargo/BUILD.bazel @@ -129,6 +129,15 @@ alias( ], ) +alias( + name = "fnv", + actual = "@raze__fnv__1_0_7//:fnv", + tags = [ + "cargo-raze", + "manual", + ], +) + alias( name = "futures", actual = "@raze__futures__0_3_12//:futures", diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs index 9dfece2c5..37faf752c 100644 --- a/rslib/src/backend/mod.rs +++ b/rslib/src/backend/mod.rs @@ -698,6 +698,25 @@ impl BackendService for Backend { .map(Into::into) } + fn get_queued_cards( + &self, + input: pb::GetQueuedCardsIn, + ) -> BackendResult { + self.with_col(|col| col.get_queued_cards(input.fetch_limit, input.intraday_learning_only)) + } + + fn clear_card_queues(&self, _input: pb::Empty) -> BackendResult { + self.with_col(|col| { + col.clear_queues(); + Ok(().into()) + }) + } + + fn requeue_undone_card(&self, input: pb::CardId) -> BackendResult { + self.with_col(|col| col.requeue_undone_card(input.into())) + .map(Into::into) + } + // statistics //----------------------------------------------- @@ -2059,8 +2078,8 @@ fn pbcard_to_native(c: pb::Card) -> Result { }) } -impl From for pb::SchedTimingTodayOut { - fn from(t: crate::scheduler::cutoff::SchedTimingToday) -> pb::SchedTimingTodayOut { +impl From for pb::SchedTimingTodayOut { + fn from(t: crate::scheduler::timing::SchedTimingToday) -> pb::SchedTimingTodayOut { pb::SchedTimingTodayOut { days_elapsed: t.days_elapsed, next_day_at: t.next_day_at, diff --git a/rslib/src/card.rs b/rslib/src/card.rs index dc8062a9a..0386404a5 100644 --- a/rslib/src/card.rs +++ b/rslib/src/card.rs @@ -118,6 +118,10 @@ impl Card { pub fn ease_factor(&self) -> f32 { (self.ease_factor as f32) / 1000.0 } + + pub fn is_intraday_learning(&self) -> bool { + matches!(self.queue, CardQueue::Learn | CardQueue::PreviewRepeat) + } } #[derive(Debug)] pub(crate) struct UpdateCardUndo(Card); diff --git a/rslib/src/collection.rs b/rslib/src/collection.rs index 7b652a7b8..13b046ce0 100644 --- a/rslib/src/collection.rs +++ b/rslib/src/collection.rs @@ -1,7 +1,6 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -use crate::err::Result; use crate::i18n::I18n; use crate::log::Logger; use crate::types::Usn; @@ -11,6 +10,7 @@ use crate::{ storage::SqliteStorage, undo::UndoManager, }; +use crate::{err::Result, scheduler::queue::CardQueues}; use std::{collections::HashMap, path::PathBuf, sync::Arc}; pub fn open_collection>( @@ -63,6 +63,7 @@ pub struct CollectionState { pub(crate) undo: UndoManager, pub(crate) notetype_cache: HashMap>, pub(crate) deck_cache: HashMap>, + pub(crate) card_queues: Option, } pub struct Collection { diff --git a/rslib/src/config.rs b/rslib/src/config.rs index 14ebf3fef..d88294b70 100644 --- a/rslib/src/config.rs +++ b/rslib/src/config.rs @@ -5,8 +5,8 @@ use crate::{ backend_proto as pb, collection::Collection, decks::DeckID, err::Result, notetype::NoteTypeID, timestamp::TimestampSecs, }; -use pb::config::bool::Key as BoolKey; -use pb::config::string::Key as StringKey; +pub use pb::config::bool::Key as BoolKey; +pub use pb::config::string::Key as StringKey; use serde::{de::DeserializeOwned, Serialize}; use serde_aux::field_attributes::deserialize_bool_from_anything; use serde_derive::Deserialize; @@ -63,6 +63,7 @@ pub(crate) enum ConfigKey { NormalizeNoteText, PreviewBothSides, Rollover, + Sched2021, SchedulerVersion, SetDueBrowser, SetDueReviewer, @@ -104,6 +105,7 @@ impl From for &'static str { ConfigKey::NormalizeNoteText => "normalize_note_text", ConfigKey::PreviewBothSides => "previewBothSides", ConfigKey::Rollover => "rollover", + ConfigKey::Sched2021 => "sched2021", ConfigKey::SchedulerVersion => "schedVer", ConfigKey::SetDueBrowser => "setDueBrowser", ConfigKey::SetDueReviewer => "setDueReviewer", @@ -126,6 +128,7 @@ impl From for ConfigKey { BoolKey::CollapseTags => ConfigKey::CollapseTags, BoolKey::CollapseToday => ConfigKey::CollapseToday, BoolKey::PreviewBothSides => ConfigKey::PreviewBothSides, + BoolKey::Sched2021 => ConfigKey::Sched2021, } } } @@ -365,7 +368,12 @@ impl Collection { #[allow(clippy::match_single_binding)] pub(crate) fn get_bool(&self, config: pb::config::Bool) -> bool { - match config.key() { + self.get_bool_key(config.key()) + } + + #[allow(clippy::match_single_binding)] + pub(crate) fn get_bool_key(&self, key: BoolKey) -> bool { + match key { // all options default to false at the moment other => self.get_config_default(ConfigKey::from(other)), } @@ -421,12 +429,19 @@ impl Default for SortKind { } } +// 2021 scheduler moves this into deck config pub(crate) enum NewReviewMix { Mix = 0, ReviewsFirst = 1, NewFirst = 2, } +impl Default for NewReviewMix { + fn default() -> Self { + NewReviewMix::Mix + } +} + #[derive(PartialEq, Serialize_repr, Deserialize_repr, Clone, Copy)] #[repr(u8)] pub(crate) enum Weekday { diff --git a/rslib/src/deckconf/mod.rs b/rslib/src/deckconf/mod.rs index f15203309..23c13abeb 100644 --- a/rslib/src/deckconf/mod.rs +++ b/rslib/src/deckconf/mod.rs @@ -11,7 +11,7 @@ use crate::{ }; pub use crate::backend_proto::{ - deck_config_inner::{LeechAction, NewCardOrder}, + deck_config_inner::{LeechAction, NewCardOrder, ReviewCardOrder, ReviewMix}, DeckConfigInner, }; pub use schema11::{DeckConfSchema11, NewCardOrderSchema11}; @@ -41,14 +41,9 @@ impl Default for DeckConf { inner: DeckConfigInner { learn_steps: vec![1.0, 10.0], relearn_steps: vec![10.0], - disable_autoplay: false, - cap_answer_time_to_secs: 60, - visible_timer_secs: 0, - skip_question_when_replaying_answer: false, new_per_day: 20, reviews_per_day: 200, - bury_new: false, - bury_reviews: false, + new_per_day_minimum: 0, initial_ease: 2.5, easy_multiplier: 1.3, hard_multiplier: 1.2, @@ -59,8 +54,17 @@ impl Default for DeckConf { graduating_interval_good: 1, graduating_interval_easy: 4, new_card_order: NewCardOrder::Due as i32, + review_order: ReviewCardOrder::ShuffledByDay as i32, + new_mix: ReviewMix::MixWithReviews as i32, + interday_learning_mix: ReviewMix::MixWithReviews as i32, leech_action: LeechAction::TagOnly as i32, leech_threshold: 8, + disable_autoplay: false, + cap_answer_time_to_secs: 60, + visible_timer_secs: 0, + skip_question_when_replaying_answer: false, + bury_new: false, + bury_reviews: false, other: vec![], }, } diff --git a/rslib/src/deckconf/schema11.rs b/rslib/src/deckconf/schema11.rs index 057dba532..1c56fcb90 100644 --- a/rslib/src/deckconf/schema11.rs +++ b/rslib/src/deckconf/schema11.rs @@ -32,6 +32,18 @@ pub struct DeckConfSchema11 { pub(crate) lapse: LapseConfSchema11, #[serde(rename = "dyn", default, deserialize_with = "default_on_invalid")] dynamic: bool, + + // 2021 scheduler options: these were not in schema 11, but we need to persist them + // so the settings are not lost on upgrade/downgrade + #[serde(default)] + new_mix: i32, + #[serde(default)] + new_per_day_minimum: u32, + #[serde(default)] + interday_learning_mix: i32, + #[serde(default)] + review_order: i32, + #[serde(flatten)] other: HashMap, } @@ -191,6 +203,10 @@ impl Default for DeckConfSchema11 { rev: Default::default(), lapse: Default::default(), other: Default::default(), + new_mix: 0, + new_per_day_minimum: 0, + interday_learning_mix: 0, + review_order: 0, } } } @@ -229,14 +245,9 @@ impl From for DeckConf { inner: DeckConfigInner { learn_steps: c.new.delays, relearn_steps: c.lapse.delays, - disable_autoplay: !c.autoplay, - cap_answer_time_to_secs: c.max_taken.max(0) as u32, - visible_timer_secs: c.timer as u32, - skip_question_when_replaying_answer: !c.replayq, new_per_day: c.new.per_day, reviews_per_day: c.rev.per_day, - bury_new: c.new.bury, - bury_reviews: c.rev.bury, + new_per_day_minimum: c.new_per_day_minimum, initial_ease: (c.new.initial_factor as f32) / 1000.0, easy_multiplier: c.rev.ease4, hard_multiplier: c.rev.hard_factor, @@ -250,8 +261,17 @@ impl From for DeckConf { NewCardOrderSchema11::Random => NewCardOrder::Random, NewCardOrderSchema11::Due => NewCardOrder::Due, } as i32, + review_order: c.review_order, + new_mix: c.new_mix, + interday_learning_mix: c.interday_learning_mix, leech_action: c.lapse.leech_action as i32, leech_threshold: c.lapse.leech_fails, + disable_autoplay: !c.autoplay, + cap_answer_time_to_secs: c.max_taken.max(0) as u32, + visible_timer_secs: c.timer as u32, + skip_question_when_replaying_answer: !c.replayq, + bury_new: c.new.bury, + bury_reviews: c.rev.bury, other: other_bytes, }, } @@ -332,6 +352,10 @@ impl From for DeckConfSchema11 { other: lapse_other, }, other: top_other, + new_mix: i.new_mix, + new_per_day_minimum: i.new_per_day_minimum, + interday_learning_mix: i.interday_learning_mix, + review_order: i.review_order, } } } diff --git a/rslib/src/decks/tree.rs b/rslib/src/decks/tree.rs index 4280dd647..0c8065b40 100644 --- a/rslib/src/decks/tree.rs +++ b/rslib/src/decks/tree.rs @@ -5,7 +5,7 @@ use super::{Deck, DeckKind, DueCounts}; use crate::{ backend_proto::DeckTreeNode, collection::Collection, - config::SchedulerVersion, + config::{BoolKey, SchedulerVersion}, deckconf::{DeckConf, DeckConfID}, decks::DeckID, err::Result, @@ -123,12 +123,11 @@ fn apply_limits( node.review_count = (node.review_count + child_rev_total).min(remaining_rev); } -/// Apply parent new limits to children, and add child counts to parents. -/// Unlike v1, reviews are not capped by their parents, and we return the -/// uncapped review amount to add to the parent. This is a bit of a hack, and -/// just tides us over until the v2 queue building code can be reworked. +/// Apply parent new limits to children, and add child counts to parents. Unlike +/// v1 and the 2021 scheduler, reviews are not capped by their parents, and we +/// return the uncapped review amount to add to the parent. /// Counts are (new, review). -fn apply_limits_v2( +fn apply_limits_v2_old( node: &mut DeckTreeNode, today: u32, decks: &HashMap, @@ -148,7 +147,7 @@ fn apply_limits_v2( let mut child_rev_total = 0; for child in &mut node.children { child_rev_total += - apply_limits_v2(child, today, decks, dconf, (remaining_new, remaining_rev)); + apply_limits_v2_old(child, today, decks, dconf, (remaining_new, remaining_rev)); child_new_total += child.new_count; // no limit on learning cards node.learn_count += child.learn_count; @@ -283,8 +282,10 @@ impl Collection { let counts = self.due_counts(days_elapsed, learn_cutoff, limit)?; let dconf = self.storage.get_deck_config_map()?; add_counts(&mut tree, &counts); - if self.scheduler_version() == SchedulerVersion::V2 { - apply_limits_v2( + if self.scheduler_version() == SchedulerVersion::V2 + && !self.get_bool_key(BoolKey::Sched2021) + { + apply_limits_v2_old( &mut tree, days_elapsed, &decks_map, diff --git a/rslib/src/preferences.rs b/rslib/src/preferences.rs index 1ec03c9e5..fcd82ffde 100644 --- a/rslib/src/preferences.rs +++ b/rslib/src/preferences.rs @@ -8,7 +8,7 @@ use crate::{ }, collection::Collection, err::Result, - scheduler::cutoff::local_minutes_west_for_stamp, + scheduler::timing::local_minutes_west_for_stamp, }; impl Collection { diff --git a/rslib/src/scheduler/answering/mod.rs b/rslib/src/scheduler/answering/mod.rs index 46c4d924e..2b0d2e1d8 100644 --- a/rslib/src/scheduler/answering/mod.rs +++ b/rslib/src/scheduler/answering/mod.rs @@ -20,11 +20,11 @@ use crate::{ use revlog::RevlogEntryPartial; use super::{ - cutoff::SchedTimingToday, states::{ steps::LearningSteps, CardState, FilteredState, NextCardStates, NormalState, StateContext, }, timespan::answer_button_time_collapsible, + timing::SchedTimingToday, }; #[derive(Copy, Clone)] @@ -239,6 +239,7 @@ impl Collection { self.add_partial_revlog(revlog_partial, usn, &answer)?; } self.update_deck_stats_from_answer(usn, &answer, &updater)?; + let timing = updater.timing; let mut card = updater.into_card(); self.update_card(&mut card, &original, usn)?; @@ -246,6 +247,8 @@ impl Collection { self.add_leech_tag(card.note_id)?; } + self.update_queues_after_answering_card(&card, timing)?; + Ok(()) } diff --git a/rslib/src/scheduler/bury_and_suspend.rs b/rslib/src/scheduler/bury_and_suspend.rs index 432754eeb..4fc93fa57 100644 --- a/rslib/src/scheduler/bury_and_suspend.rs +++ b/rslib/src/scheduler/bury_and_suspend.rs @@ -10,7 +10,7 @@ use crate::{ search::SortMode, }; -use super::cutoff::SchedTimingToday; +use super::timing::SchedTimingToday; use pb::unbury_cards_in_current_deck_in::Mode as UnburyDeckMode; impl Card { diff --git a/rslib/src/scheduler/mod.rs b/rslib/src/scheduler/mod.rs index 4a1bfc941..c5e678d43 100644 --- a/rslib/src/scheduler/mod.rs +++ b/rslib/src/scheduler/mod.rs @@ -6,20 +6,21 @@ use crate::{collection::Collection, config::SchedulerVersion, err::Result, prelu pub mod answering; pub mod bury_and_suspend; pub(crate) mod congrats; -pub mod cutoff; mod learning; pub mod new; +pub(crate) mod queue; mod reviews; pub mod states; pub mod timespan; +pub mod timing; mod upgrade; use chrono::FixedOffset; -use cutoff::{ +pub use reviews::parse_due_date_str; +use timing::{ sched_timing_today, v1_creation_date_adjusted_to_hour, v1_rollover_from_creation_stamp, SchedTimingToday, }; -pub use reviews::parse_due_date_str; impl Collection { pub fn timing_today(&self) -> Result { diff --git a/rslib/src/scheduler/queue/builder/gathering.rs b/rslib/src/scheduler/queue/builder/gathering.rs new file mode 100644 index 000000000..fdafa8b36 --- /dev/null +++ b/rslib/src/scheduler/queue/builder/gathering.rs @@ -0,0 +1,174 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use super::{super::limits::RemainingLimits, DueCard, NewCard, QueueBuilder}; +use crate::{card::CardQueue, prelude::*}; + +impl QueueBuilder { + /// Assumes cards will arrive sorted in (queue, due) order, so learning + /// cards come first, and reviews come before day-learning and preview cards. + pub(in super::super) fn add_due_card( + &mut self, + limit: &mut RemainingLimits, + queue: CardQueue, + card: DueCard, + ) -> bool { + let should_add = self.should_add_review_card(card.note_id); + + match queue { + CardQueue::Learn | CardQueue::PreviewRepeat => self.learning.push(card), + CardQueue::DayLearn => { + self.day_learning.push(card); + } + CardQueue::Review => { + if should_add { + self.review.push(card); + limit.review -= 1; + } + } + CardQueue::New + | CardQueue::Suspended + | CardQueue::SchedBuried + | CardQueue::UserBuried => { + unreachable!() + } + } + + limit.review != 0 + } + + pub(in super::super) fn add_new_card( + &mut self, + limit: &mut RemainingLimits, + card: NewCard, + ) -> bool { + let already_seen = self.have_seen_note_id(card.note_id); + if !already_seen { + self.new.push(card); + limit.new -= 1; + return limit.new != 0; + } + + // Cards will be arriving in (due, card_id) order, with all + // siblings sharing the same due number by default. In the + // common case, card ids will match template order, and nothing + // special is required. But if some cards have been generated + // after the initial note creation, they will have higher card + // ids, and the siblings will thus arrive in the wrong order. + // Sorting by ordinal in the DB layer is fairly costly, as it + // doesn't allow us to exit early when the daily limits have + // been met, so we want to enforce ordering as we add instead. + let previous_card_was_sibling_with_higher_ordinal = self + .new + .last() + .map(|previous| previous.note_id == card.note_id && previous.extra > card.extra) + .unwrap_or(false); + + if previous_card_was_sibling_with_higher_ordinal { + if self.bury_new { + // When burying is enabled, we replace the existing sibling + // with the lower ordinal one. + *self.new.last_mut().unwrap() = card; + } else { + // When burying disabled, we'll want to add this card as well, but + // not at the end of the list. + let target_idx = self + .new + .iter() + .enumerate() + .rev() + .filter_map(|(idx, queued_card)| { + if queued_card.note_id != card.note_id || queued_card.extra < card.extra { + Some(idx + 1) + } else { + None + } + }) + .next() + .unwrap_or(0); + self.new.insert(target_idx, card); + limit.new -= 1; + } + } else { + // card has arrived in expected order - add if burying disabled + if !self.bury_new { + self.new.push(card); + limit.new -= 1; + } + } + + limit.new != 0 + } + + fn should_add_review_card(&mut self, note_id: NoteID) -> bool { + !self.have_seen_note_id(note_id) || !self.bury_reviews + } + + /// Mark note seen, and return true if seen before. + fn have_seen_note_id(&mut self, note_id: NoteID) -> bool { + !self.seen_note_ids.insert(note_id) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn new_siblings() { + let mut builder = QueueBuilder::default(); + builder.bury_new = true; + let mut limits = RemainingLimits { + review: 0, + new: 100, + }; + + let cards = vec![ + NewCard { + id: CardID(1), + note_id: NoteID(1), + extra: 0, + ..Default::default() + }, + NewCard { + id: CardID(2), + note_id: NoteID(2), + extra: 1, + ..Default::default() + }, + NewCard { + id: CardID(3), + note_id: NoteID(2), + extra: 2, + ..Default::default() + }, + NewCard { + id: CardID(4), + note_id: NoteID(2), + extra: 0, + ..Default::default() + }, + ]; + + for card in &cards { + builder.add_new_card(&mut limits, card.clone()); + } + + assert_eq!(builder.new[0].id, CardID(1)); + assert_eq!(builder.new[1].id, CardID(4)); + assert_eq!(builder.new.len(), 2); + + // with burying disabled, we should get all siblings in order + builder.bury_new = false; + builder.new.truncate(0); + + for card in &cards { + builder.add_new_card(&mut limits, card.clone()); + } + + assert_eq!(builder.new[0].id, CardID(1)); + assert_eq!(builder.new[1].id, CardID(4)); + assert_eq!(builder.new[2].id, CardID(2)); + assert_eq!(builder.new[3].id, CardID(3)); + } +} diff --git a/rslib/src/scheduler/queue/builder/intersperser.rs b/rslib/src/scheduler/queue/builder/intersperser.rs new file mode 100644 index 000000000..4252f31ac --- /dev/null +++ b/rslib/src/scheduler/queue/builder/intersperser.rs @@ -0,0 +1,123 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +/// Adapter to evenly mix two iterators of varying lengths into one. +pub(crate) struct Intersperser +where + I: Iterator + ExactSizeIterator, +{ + one: I, + two: I2, + one_idx: usize, + two_idx: usize, + one_len: usize, + two_len: usize, + ratio: f32, +} + +impl Intersperser +where + I: ExactSizeIterator, + I2: ExactSizeIterator, +{ + pub fn new(one: I, two: I2) -> Self { + let one_len = one.len(); + let two_len = two.len(); + let ratio = one_len as f32 / two_len as f32; + Intersperser { + one, + two, + one_idx: 0, + two_idx: 0, + one_len, + two_len, + ratio, + } + } + + fn one_idx(&self) -> Option { + if self.one_idx == self.one_len { + None + } else { + Some(self.one_idx) + } + } + + fn two_idx(&self) -> Option { + if self.two_idx == self.two_len { + None + } else { + Some(self.two_idx) + } + } + + fn next_one(&mut self) -> Option { + self.one_idx += 1; + self.one.next() + } + + fn next_two(&mut self) -> Option { + self.two_idx += 1; + self.two.next() + } +} + +impl Iterator for Intersperser +where + I: ExactSizeIterator, + I2: ExactSizeIterator, +{ + type Item = I::Item; + + fn next(&mut self) -> Option { + match (self.one_idx(), self.two_idx()) { + (Some(idx1), Some(idx2)) => { + let relative_idx2 = idx2 as f32 * self.ratio; + if relative_idx2 < idx1 as f32 { + self.next_two() + } else { + self.next_one() + } + } + (Some(_), None) => self.next_one(), + (None, Some(_)) => self.next_two(), + (None, None) => None, + } + } + + fn size_hint(&self) -> (usize, Option) { + let remaining = (self.one_len + self.two_len) - (self.one_idx + self.two_idx); + (remaining, Some(remaining)) + } +} + +impl ExactSizeIterator for Intersperser +where + I: ExactSizeIterator, + I2: ExactSizeIterator, +{ +} + +#[cfg(test)] +mod test { + use super::Intersperser; + + fn intersperse(a: &[u32], b: &[u32]) -> Vec { + Intersperser::new(a.iter().cloned(), b.iter().cloned()).collect() + } + + #[test] + fn interspersing() { + let a = &[1, 2, 3]; + let b = &[11, 22, 33]; + assert_eq!(&intersperse(a, b), &[1, 11, 2, 22, 3, 33]); + + let b = &[11, 22]; + assert_eq!(&intersperse(a, b), &[1, 11, 2, 22, 3]); + + // when both lists have the same relative position, we add from + // list 1 even if list 2 has more elements + let b = &[11, 22, 33, 44, 55, 66]; + assert_eq!(&intersperse(a, b), &[1, 11, 22, 2, 33, 44, 3, 55, 66]); + } +} diff --git a/rslib/src/scheduler/queue/builder/mod.rs b/rslib/src/scheduler/queue/builder/mod.rs new file mode 100644 index 000000000..5ad845215 --- /dev/null +++ b/rslib/src/scheduler/queue/builder/mod.rs @@ -0,0 +1,224 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +mod gathering; +pub(crate) mod intersperser; +pub(crate) mod sized_chain; +mod sorting; + +use std::{ + cmp::Reverse, + collections::{BinaryHeap, HashSet, VecDeque}, +}; + +use super::{ + limits::{remaining_limits_capped_to_parents, RemainingLimits}, + CardQueues, LearningQueueEntry, QueueEntry, QueueEntryKind, +}; +use crate::deckconf::{NewCardOrder, ReviewCardOrder, ReviewMix}; +use crate::prelude::*; +use {intersperser::Intersperser, sized_chain::SizedChain}; + +/// Temporary holder for review cards that will be built into a queue. +#[derive(Debug, Default, Clone)] +pub(crate) struct DueCard { + pub id: CardID, + pub note_id: NoteID, + pub mtime: TimestampSecs, + pub due: i32, + /// Used to store interval, and for shuffling + pub extra: u64, +} + +/// Temporary holder for new cards that will be built into a queue. +#[derive(Debug, Default, Clone)] +pub(crate) struct NewCard { + pub id: CardID, + pub note_id: NoteID, + pub mtime: TimestampSecs, + pub due: i32, + /// Used to store template_idx, and for shuffling + pub extra: u64, +} + +impl From for QueueEntry { + fn from(c: DueCard) -> Self { + QueueEntry { + id: c.id, + mtime: c.mtime, + kind: QueueEntryKind::Review, + } + } +} + +impl From for QueueEntry { + fn from(c: NewCard) -> Self { + QueueEntry { + id: c.id, + mtime: c.mtime, + kind: QueueEntryKind::New, + } + } +} + +impl From for LearningQueueEntry { + fn from(c: DueCard) -> Self { + LearningQueueEntry { + due: TimestampSecs(c.due as i64), + id: c.id, + mtime: c.mtime, + } + } +} + +#[derive(Default)] +pub(super) struct QueueBuilder { + pub(super) new: Vec, + pub(super) review: Vec, + pub(super) learning: Vec, + pub(super) day_learning: Vec, + pub(super) seen_note_ids: HashSet, + pub(super) new_order: NewCardOrder, + pub(super) review_order: ReviewCardOrder, + pub(super) day_learn_mix: ReviewMix, + pub(super) new_review_mix: ReviewMix, + pub(super) bury_new: bool, + pub(super) bury_reviews: bool, +} + +impl QueueBuilder { + pub(super) fn build( + mut self, + top_deck_limits: RemainingLimits, + learn_ahead_secs: u32, + selected_deck: DeckID, + current_day: u32, + ) -> CardQueues { + self.sort_new(); + self.sort_reviews(); + + // split and sort learning + let learn_ahead_secs = learn_ahead_secs as i64; + let (due_learning, later_learning) = split_learning(self.learning, learn_ahead_secs); + let learn_count = due_learning.len(); + + // merge day learning in, and cap to parent review count + let main_iter = merge_day_learning(self.review, self.day_learning, self.day_learn_mix); + let main_iter = main_iter.take(top_deck_limits.review as usize); + let review_count = main_iter.len(); + + // cap to parent new count, note down the new count, then merge new in + self.new.truncate(top_deck_limits.new as usize); + let new_count = self.new.len(); + let main_iter = merge_new(main_iter, self.new, self.new_review_mix); + + CardQueues { + new_count, + review_count, + learn_count, + main: main_iter.collect(), + due_learning, + later_learning, + learn_ahead_secs, + selected_deck, + current_day, + } + } +} + +fn merge_day_learning( + reviews: Vec, + day_learning: Vec, + mode: ReviewMix, +) -> Box> { + let day_learning_iter = day_learning.into_iter().map(Into::into); + let reviews_iter = reviews.into_iter().map(Into::into); + + match mode { + ReviewMix::AfterReviews => Box::new(SizedChain::new(reviews_iter, day_learning_iter)), + ReviewMix::BeforeReviews => Box::new(SizedChain::new(day_learning_iter, reviews_iter)), + ReviewMix::MixWithReviews => Box::new(Intersperser::new(reviews_iter, day_learning_iter)), + } +} + +fn merge_new( + review_iter: impl ExactSizeIterator + 'static, + new: Vec, + mode: ReviewMix, +) -> Box> { + let new_iter = new.into_iter().map(Into::into); + + match mode { + ReviewMix::BeforeReviews => Box::new(SizedChain::new(new_iter, review_iter)), + ReviewMix::AfterReviews => Box::new(SizedChain::new(review_iter, new_iter)), + ReviewMix::MixWithReviews => Box::new(Intersperser::new(review_iter, new_iter)), + } +} + +/// Split the learning queue into cards due within limit, and cards due later +/// today. Learning does not need to be sorted in advance, as the sorting is +/// done as the heaps/dequeues are built. +fn split_learning( + learning: Vec, + learn_ahead_secs: i64, +) -> ( + VecDeque, + BinaryHeap>, +) { + let cutoff = TimestampSecs(TimestampSecs::now().0 + learn_ahead_secs); + + // split learning into now and later + let (mut now, later): (Vec<_>, Vec<_>) = learning + .into_iter() + .map(LearningQueueEntry::from) + .partition(|c| c.due <= cutoff); + + // sort due items in ascending order, as we pop the deque from the front + now.sort_unstable_by(|a, b| a.due.cmp(&b.due)); + // partition() requires both outputs to be the same, so we need to create the deque + // separately + let now = VecDeque::from(now); + + // build the binary min heap + let later: BinaryHeap<_> = later.into_iter().map(Reverse).collect(); + + (now, later) +} + +impl Collection { + pub(crate) fn build_queues(&mut self, deck_id: DeckID) -> Result { + let now = TimestampSecs::now(); + let timing = self.timing_for_timestamp(now)?; + let (decks, parent_count) = self.storage.deck_with_parents_and_children(deck_id)?; + let config = self.storage.get_deck_config_map()?; + let limits = remaining_limits_capped_to_parents(&decks, &config); + let selected_deck_limits = limits[parent_count]; + + let mut queues = QueueBuilder::default(); + + for (deck, mut limit) in decks.iter().zip(limits).skip(parent_count) { + if limit.review > 0 { + self.storage.for_each_due_card_in_deck( + timing.days_elapsed, + timing.next_day_at, + deck.id, + |queue, card| queues.add_due_card(&mut limit, queue, card), + )?; + } + if limit.new > 0 { + self.storage.for_each_new_card_in_deck(deck.id, |card| { + queues.add_new_card(&mut limit, card) + })?; + } + } + + let queues = queues.build( + selected_deck_limits, + self.learn_ahead_secs(), + deck_id, + timing.days_elapsed, + ); + + Ok(queues) + } +} diff --git a/rslib/src/scheduler/queue/builder/sized_chain.rs b/rslib/src/scheduler/queue/builder/sized_chain.rs new file mode 100644 index 000000000..9cb34da9e --- /dev/null +++ b/rslib/src/scheduler/queue/builder/sized_chain.rs @@ -0,0 +1,80 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +/// The standard Rust chain does not implement ExactSizeIterator, and we need +/// to keep track of size so we can intersperse. +pub(crate) struct SizedChain { + one: I, + two: I2, + one_idx: usize, + two_idx: usize, + one_len: usize, + two_len: usize, +} + +impl SizedChain +where + I: ExactSizeIterator, + I2: ExactSizeIterator, +{ + pub fn new(one: I, two: I2) -> Self { + let one_len = one.len(); + let two_len = two.len(); + SizedChain { + one, + two, + one_idx: 0, + two_idx: 0, + one_len, + two_len, + } + } +} + +impl Iterator for SizedChain +where + I: ExactSizeIterator, + I2: ExactSizeIterator, +{ + type Item = I::Item; + + fn next(&mut self) -> Option { + if self.one_idx < self.one_len { + self.one_idx += 1; + self.one.next() + } else if self.two_idx < self.two_len { + self.two_idx += 1; + self.two.next() + } else { + None + } + } + + fn size_hint(&self) -> (usize, Option) { + let remaining = (self.one_len + self.two_len) - (self.one_idx + self.two_idx); + (remaining, Some(remaining)) + } +} + +impl ExactSizeIterator for SizedChain +where + I: ExactSizeIterator, + I2: ExactSizeIterator, +{ +} + +#[cfg(test)] +mod test { + use super::SizedChain; + + fn chain(a: &[u32], b: &[u32]) -> Vec { + SizedChain::new(a.iter().cloned(), b.iter().cloned()).collect() + } + + #[test] + fn sized_chain() { + let a = &[1, 2, 3]; + let b = &[11, 22, 33]; + assert_eq!(&chain(a, b), &[1, 2, 3, 11, 22, 33]); + } +} diff --git a/rslib/src/scheduler/queue/builder/sorting.rs b/rslib/src/scheduler/queue/builder/sorting.rs new file mode 100644 index 000000000..a95f65bbb --- /dev/null +++ b/rslib/src/scheduler/queue/builder/sorting.rs @@ -0,0 +1,71 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use super::{DueCard, NewCard, NewCardOrder, QueueBuilder, ReviewCardOrder}; +use fnv::FnvHasher; +use std::{cmp::Ordering, hash::Hasher}; + +impl QueueBuilder { + pub(super) fn sort_new(&mut self) { + match self.new_order { + NewCardOrder::Random => { + self.new.iter_mut().for_each(NewCard::hash_id_and_mtime); + self.new.sort_unstable_by(|a, b| a.extra.cmp(&b.extra)); + } + NewCardOrder::Due => { + self.new.sort_unstable_by(|a, b| a.due.cmp(&b.due)); + } + } + } + + pub(super) fn sort_reviews(&mut self) { + self.review.iter_mut().for_each(DueCard::hash_id_and_mtime); + self.day_learning + .iter_mut() + .for_each(DueCard::hash_id_and_mtime); + + match self.review_order { + ReviewCardOrder::ShuffledByDay => { + self.review.sort_unstable_by(shuffle_by_day); + self.day_learning.sort_unstable_by(shuffle_by_day); + } + ReviewCardOrder::Shuffled => { + self.review.sort_unstable_by(|a, b| a.extra.cmp(&b.extra)); + self.day_learning + .sort_unstable_by(|a, b| a.extra.cmp(&b.extra)); + } + ReviewCardOrder::IntervalsAscending => { + // fixme: implement; may require separate field if we want + // to shuffle cards that share an interval + } + ReviewCardOrder::IntervalsDescending => { + // fixme: implement; may require separate field if we want + // to shuffle cards that share an interval + } + } + } +} + +// We sort based on a hash so that if the queue is rebuilt, remaining +// cards come back in the same order. +impl DueCard { + fn hash_id_and_mtime(&mut self) { + let mut hasher = FnvHasher::default(); + hasher.write_i64(self.id.0); + hasher.write_i64(self.mtime.0); + self.extra = hasher.finish(); + } +} + +impl NewCard { + fn hash_id_and_mtime(&mut self) { + let mut hasher = FnvHasher::default(); + hasher.write_i64(self.id.0); + hasher.write_i64(self.mtime.0); + self.extra = hasher.finish(); + } +} + +fn shuffle_by_day(a: &DueCard, b: &DueCard) -> Ordering { + (a.due, a.extra).cmp(&(b.due, b.extra)) +} diff --git a/rslib/src/scheduler/queue/learning.rs b/rslib/src/scheduler/queue/learning.rs new file mode 100644 index 000000000..c2f9cd83d --- /dev/null +++ b/rslib/src/scheduler/queue/learning.rs @@ -0,0 +1,133 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use std::{ + cmp::{Ordering, Reverse}, + collections::VecDeque, +}; + +use super::{CardQueues, LearningQueueEntry}; +use crate::{prelude::*, scheduler::timing::SchedTimingToday}; + +impl CardQueues { + /// Check for any newly due cards, and then return the first, if any, + /// that is due before now. + pub(super) fn next_learning_entry_due_before_now( + &mut self, + now: TimestampSecs, + ) -> Option { + let learn_ahead_cutoff = now.adding_secs(self.learn_ahead_secs); + self.check_for_newly_due_learning_cards(learn_ahead_cutoff); + self.next_learning_entry_learning_ahead() + .filter(|c| c.due <= now) + } + + /// Check for due learning cards up to the learn ahead limit. + /// Does not check for newly due cards, as that is already done by + /// next_learning_entry_due_before_now() + pub(super) fn next_learning_entry_learning_ahead(&self) -> Option { + self.due_learning.front().copied() + } + + pub(super) fn pop_learning_entry(&mut self, id: CardID) -> Option { + if let Some(top) = self.due_learning.front() { + if top.id == id { + self.learn_count -= 1; + return self.due_learning.pop_front(); + } + } + + // fixme: remove this in the future + // the current python unit tests answer learning cards before they're due, + // so for now we also check the head of the later_due queue + if let Some(top) = self.later_learning.peek() { + if top.0.id == id { + // self.learn_count -= 1; + return self.later_learning.pop().map(|c| c.0); + } + } + + None + } + + /// Given the just-answered `card`, place it back in the learning queues if it's still + /// due today. Avoid placing it in a position where it would be shown again immediately. + pub(super) fn maybe_requeue_learning_card(&mut self, card: &Card, timing: SchedTimingToday) { + if !card.is_intraday_learning() { + return; + } + + let learn_ahead_limit = timing.now.adding_secs(self.learn_ahead_secs); + + if card.due < learn_ahead_limit.0 as i32 { + let mut entry = LearningQueueEntry { + due: TimestampSecs(card.due as i64), + id: card.id, + mtime: card.mtime, + }; + + if self.learning_collapsed() { + if let Some(next) = self.due_learning.front() { + if next.due >= entry.due { + // the earliest due card is due later than this one; make this one + // due after that one + entry.due = next.due.adding_secs(1); + } + self.push_due_learning_card(entry); + } else { + // nothing else waiting to review; make this due in a minute + entry.due = learn_ahead_limit.adding_secs(60); + self.later_learning.push(Reverse(entry)); + } + } else { + // not collapsed; can add normally + self.push_due_learning_card(entry); + } + } else if card.due < timing.next_day_at as i32 { + self.later_learning.push(Reverse(LearningQueueEntry { + due: TimestampSecs(card.due as i64), + id: card.id, + mtime: card.mtime, + })); + }; + } + + fn learning_collapsed(&self) -> bool { + self.main.is_empty() + } + + /// Adds card, maintaining correct sort order, and increments learning count. + pub(super) fn push_due_learning_card(&mut self, entry: LearningQueueEntry) { + self.learn_count += 1; + let target_idx = + binary_search_by(&self.due_learning, |e| e.due.cmp(&entry.due)).unwrap_or_else(|e| e); + self.due_learning.insert(target_idx, entry); + } + + fn check_for_newly_due_learning_cards(&mut self, cutoff: TimestampSecs) { + while let Some(earliest) = self.later_learning.peek() { + if earliest.0.due > cutoff { + break; + } + let entry = self.later_learning.pop().unwrap().0; + self.push_due_learning_card(entry); + } + } +} + +/// Adapted from the Rust stdlib VecDeque implementation; we can drop this when the following +/// lands: https://github.com/rust-lang/rust/issues/78021 +fn binary_search_by<'a, F, T>(deque: &'a VecDeque, mut f: F) -> Result +where + F: FnMut(&'a T) -> Ordering, +{ + let (front, back) = deque.as_slices(); + + match back.first().map(|elem| f(elem)) { + Some(Ordering::Less) | Some(Ordering::Equal) => back + .binary_search_by(f) + .map(|idx| idx + front.len()) + .map_err(|idx| idx + front.len()), + _ => front.binary_search_by(f), + } +} diff --git a/rslib/src/scheduler/queue/limits.rs b/rslib/src/scheduler/queue/limits.rs new file mode 100644 index 000000000..8559f44a0 --- /dev/null +++ b/rslib/src/scheduler/queue/limits.rs @@ -0,0 +1,216 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use super::{Deck, DeckKind}; +use crate::deckconf::{DeckConf, DeckConfID}; +use std::collections::HashMap; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct RemainingLimits { + pub review: u32, + pub new: u32, +} + +impl RemainingLimits { + pub(crate) fn new(deck: &Deck, config: Option<&DeckConf>) -> Self { + if let Some(config) = config { + RemainingLimits { + review: ((config.inner.reviews_per_day as i32) - deck.common.review_studied).max(0) + as u32, + new: ((config.inner.new_per_day as i32) - deck.common.new_studied).max(0) as u32, + } + } else { + RemainingLimits { + review: std::u32::MAX, + new: std::u32::MAX, + } + } + } + + fn limit_to_parent(&mut self, parent: RemainingLimits) { + self.review = self.review.min(parent.review); + self.new = self.new.min(parent.new); + } +} + +pub(super) fn remaining_limits_capped_to_parents( + decks: &[Deck], + config: &HashMap, +) -> Vec { + let mut limits = get_remaining_limits(decks, config); + cap_limits_to_parents(decks.iter().map(|d| d.name.as_str()), &mut limits); + limits +} + +/// Return the remaining limits for each of the provided decks, in +/// the provided deck order. +fn get_remaining_limits( + decks: &[Deck], + config: &HashMap, +) -> Vec { + decks + .iter() + .map(move |deck| { + // get deck config if not filtered + let config = if let DeckKind::Normal(normal) = &deck.kind { + config.get(&DeckConfID(normal.config_id)) + } else { + None + }; + RemainingLimits::new(deck, config) + }) + .collect() +} + +/// Given a sorted list of deck names and their current limits, +/// cap child limits to their parents. +fn cap_limits_to_parents<'a>( + names: impl IntoIterator, + limits: &'a mut Vec, +) { + let mut parent_limits = vec![]; + let mut last_limit = None; + let mut last_level = 0; + + names + .into_iter() + .zip(limits.iter_mut()) + .for_each(|(name, limits)| { + let level = name.matches('\x1f').count() + 1; + if last_limit.is_none() { + // top-level deck + last_limit = Some(*limits); + last_level = level; + } else { + // add/remove parent limits if descending/ascending + let mut target = level; + while target != last_level { + if target < last_level { + // current deck is at higher level than previous + parent_limits.pop(); + target += 1; + } else { + // current deck is at a lower level than previous. this + // will push the same remaining counts multiple times if + // the deck tree is missing a parent + parent_limits.push(last_limit.unwrap()); + target -= 1; + } + } + + // apply current parent limit + limits.limit_to_parent(*parent_limits.last().unwrap()); + last_level = level; + last_limit = Some(*limits); + } + }) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn limits() { + let limits_map = vec![ + ( + "A", + RemainingLimits { + review: 100, + new: 20, + }, + ), + ( + "A\x1fB", + RemainingLimits { + review: 50, + new: 30, + }, + ), + ( + "A\x1fC", + RemainingLimits { + review: 10, + new: 10, + }, + ), + ("A\x1fC\x1fD", RemainingLimits { review: 5, new: 30 }), + ( + "A\x1fE", + RemainingLimits { + review: 200, + new: 100, + }, + ), + ]; + let (names, mut limits): (Vec<_>, Vec<_>) = limits_map.into_iter().unzip(); + cap_limits_to_parents(names.into_iter(), &mut limits); + assert_eq!( + &limits, + &[ + RemainingLimits { + review: 100, + new: 20 + }, + RemainingLimits { + review: 50, + new: 20 + }, + RemainingLimits { + review: 10, + new: 10 + }, + RemainingLimits { review: 5, new: 10 }, + RemainingLimits { + review: 100, + new: 20 + } + ] + ); + + // missing parents should not break it + let limits_map = vec![ + ( + "A", + RemainingLimits { + review: 100, + new: 20, + }, + ), + ( + "A\x1fB\x1fC\x1fD", + RemainingLimits { + review: 50, + new: 30, + }, + ), + ( + "A\x1fC", + RemainingLimits { + review: 100, + new: 100, + }, + ), + ]; + + let (names, mut limits): (Vec<_>, Vec<_>) = limits_map.into_iter().unzip(); + cap_limits_to_parents(names.into_iter(), &mut limits); + assert_eq!( + &limits, + &[ + RemainingLimits { + review: 100, + new: 20 + }, + RemainingLimits { + review: 50, + new: 20 + }, + RemainingLimits { + review: 100, + new: 20 + }, + ] + ); + } +} diff --git a/rslib/src/scheduler/queue/main.rs b/rslib/src/scheduler/queue/main.rs new file mode 100644 index 000000000..60626d29f --- /dev/null +++ b/rslib/src/scheduler/queue/main.rs @@ -0,0 +1,37 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use super::{CardQueues, QueueEntry, QueueEntryKind}; +use crate::prelude::*; + +impl CardQueues { + pub(super) fn next_main_entry(&self) -> Option { + self.main.front().copied() + } + + pub(super) fn pop_main_entry(&mut self, id: CardID) -> Option { + if let Some(last) = self.main.front() { + if last.id == id { + match last.kind { + QueueEntryKind::New => self.new_count -= 1, + QueueEntryKind::Review => self.review_count -= 1, + QueueEntryKind::Learning => unreachable!(), + } + return self.main.pop_front(); + } + } + + None + } + + /// Add an undone card back to the 'front' of the list, and update + /// the counts. + pub(super) fn push_main_entry(&mut self, entry: QueueEntry) { + match entry.kind { + QueueEntryKind::New => self.new_count += 1, + QueueEntryKind::Review => self.review_count += 1, + QueueEntryKind::Learning => unreachable!(), + } + self.main.push_front(entry); + } +} diff --git a/rslib/src/scheduler/queue/mod.rs b/rslib/src/scheduler/queue/mod.rs new file mode 100644 index 000000000..ab8fd2d22 --- /dev/null +++ b/rslib/src/scheduler/queue/mod.rs @@ -0,0 +1,252 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +mod builder; +mod learning; +mod limits; +mod main; + +use std::{ + cmp::Reverse, + collections::{BinaryHeap, VecDeque}, +}; + +use crate::{backend_proto as pb, card::CardQueue, prelude::*, timestamp::TimestampSecs}; +pub(crate) use builder::{DueCard, NewCard}; + +use super::timing::SchedTimingToday; + +#[derive(Debug)] +pub(crate) struct CardQueues { + new_count: usize, + review_count: usize, + learn_count: usize, + + main: VecDeque, + due_learning: VecDeque, + later_learning: BinaryHeap>, + + selected_deck: DeckID, + current_day: u32, + learn_ahead_secs: i64, +} + +#[derive(Debug)] +pub(crate) struct Counts { + new: usize, + learning: usize, + review: usize, +} + +impl CardQueues { + /// Get the next due card, if there is one. + fn next_entry(&mut self, now: TimestampSecs) -> Option { + self.next_learning_entry_due_before_now(now) + .map(Into::into) + .or_else(|| self.next_main_entry()) + .or_else(|| self.next_learning_entry_learning_ahead().map(Into::into)) + } + + /// Remove the provided card from the top of the learning or main queues. + /// If it was not at the top, return an error. + fn pop_answered(&mut self, id: CardID) -> Result<()> { + if self.pop_main_entry(id).is_none() && self.pop_learning_entry(id).is_none() { + Err(AnkiError::invalid_input("not at top of queue")) + } else { + Ok(()) + } + } + + fn counts(&self) -> Counts { + Counts { + new: self.new_count, + learning: self.learn_count, + review: self.review_count, + } + } + + fn is_stale(&self, deck: DeckID, current_day: u32) -> bool { + self.selected_deck != deck || self.current_day != current_day + } + + fn update_after_answering_card(&mut self, card: &Card, timing: SchedTimingToday) -> Result<()> { + self.pop_answered(card.id)?; + self.maybe_requeue_learning_card(card, timing); + Ok(()) + } + + /// Add a just-undone card back to the appropriate queue, updating counts. + pub(crate) fn push_undone_card(&mut self, card: &Card) { + if card.is_intraday_learning() { + self.push_due_learning_card(LearningQueueEntry { + due: TimestampSecs(card.due as i64), + id: card.id, + mtime: card.mtime, + }) + } else { + self.push_main_entry(card.into()) + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct QueueEntry { + id: CardID, + mtime: TimestampSecs, + kind: QueueEntryKind, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum QueueEntryKind { + New, + /// Includes day-learning cards + Review, + Learning, +} + +impl PartialOrd for QueueEntry { + fn partial_cmp(&self, other: &Self) -> Option { + self.id.partial_cmp(&other.id) + } +} + +impl From<&Card> for QueueEntry { + fn from(card: &Card) -> Self { + let kind = match card.queue { + CardQueue::Learn | CardQueue::PreviewRepeat => QueueEntryKind::Learning, + CardQueue::New => QueueEntryKind::New, + CardQueue::Review | CardQueue::DayLearn => QueueEntryKind::Review, + CardQueue::Suspended | CardQueue::SchedBuried | CardQueue::UserBuried => { + unreachable!() + } + }; + QueueEntry { + id: card.id, + mtime: card.mtime, + kind, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Eq, Ord)] +struct LearningQueueEntry { + // due comes first, so the derived ordering sorts by due + due: TimestampSecs, + id: CardID, + mtime: TimestampSecs, +} + +impl From for QueueEntry { + fn from(e: LearningQueueEntry) -> Self { + Self { + id: e.id, + mtime: e.mtime, + kind: QueueEntryKind::Learning, + } + } +} + +impl Collection { + pub(crate) fn get_queued_cards( + &mut self, + fetch_limit: u32, + intraday_learning_only: bool, + ) -> Result { + if let Some(next_cards) = self.next_cards(fetch_limit, intraday_learning_only)? { + Ok(pb::GetQueuedCardsOut { + value: Some(pb::get_queued_cards_out::Value::QueuedCards(next_cards)), + }) + } else { + Ok(pb::GetQueuedCardsOut { + value: Some(pb::get_queued_cards_out::Value::CongratsInfo( + self.congrats_info()?, + )), + }) + } + } + + pub(crate) fn clear_queues(&mut self) { + self.state.card_queues = None; + } + + /// FIXME: remove this once undoing is moved into backend + pub(crate) fn requeue_undone_card(&mut self, card_id: CardID) -> Result<()> { + let card = self.storage.get_card(card_id)?.ok_or(AnkiError::NotFound)?; + self.get_queues()?.push_undone_card(&card); + Ok(()) + } + + pub(crate) fn update_queues_after_answering_card( + &mut self, + card: &Card, + timing: SchedTimingToday, + ) -> Result<()> { + if let Some(queues) = &mut self.state.card_queues { + queues.update_after_answering_card(card, timing) + } else { + // we currenly allow the queues to be empty for unit tests + Ok(()) + } + } + + fn get_queues(&mut self) -> Result<&mut CardQueues> { + let timing = self.timing_today()?; + let deck = self.get_current_deck_id(); + let need_rebuild = self + .state + .card_queues + .as_ref() + .map(|q| q.is_stale(deck, timing.days_elapsed)) + .unwrap_or(true); + if need_rebuild { + self.state.card_queues = Some(self.build_queues(deck)?); + } + + Ok(self.state.card_queues.as_mut().unwrap()) + } + + fn next_cards( + &mut self, + _fetch_limit: u32, + _intraday_learning_only: bool, + ) -> Result> { + let queues = self.get_queues()?; + let mut cards = vec![]; + if let Some(entry) = queues.next_entry(TimestampSecs::now()) { + let card = self + .storage + .get_card(entry.id)? + .ok_or(AnkiError::NotFound)?; + if card.mtime != entry.mtime { + return Err(AnkiError::invalid_input( + "bug: card modified without updating queue", + )); + } + + // fixme: pass in card instead of id + let next_states = self.get_next_card_states(card.id)?; + + cards.push(pb::get_queued_cards_out::QueuedCard { + card: Some(card.into()), + next_states: Some(next_states.into()), + queue: match entry.kind { + QueueEntryKind::New => 0, + QueueEntryKind::Learning => 1, + QueueEntryKind::Review => 2, + }, + }); + } + + if cards.is_empty() { + Ok(None) + } else { + let counts = self.get_queues()?.counts(); + Ok(Some(pb::get_queued_cards_out::QueuedCards { + cards, + new_count: counts.new as u32, + learning_count: counts.learning as u32, + review_count: counts.review as u32, + })) + } + } +} diff --git a/rslib/src/scheduler/cutoff.rs b/rslib/src/scheduler/timing.rs similarity index 98% rename from rslib/src/scheduler/cutoff.rs rename to rslib/src/scheduler/timing.rs index d4972e143..e5de473c5 100644 --- a/rslib/src/scheduler/cutoff.rs +++ b/rslib/src/scheduler/timing.rs @@ -6,6 +6,7 @@ use chrono::{Date, Duration, FixedOffset, Local, TimeZone, Timelike}; #[derive(Debug, PartialEq, Clone, Copy)] pub struct SchedTimingToday { + pub now: TimestampSecs, /// The number of days that have passed since the collection was created. pub days_elapsed: u32, /// Timestamp of the next day rollover. @@ -43,6 +44,7 @@ pub fn sched_timing_today_v2_new( let days_elapsed = days_elapsed(created_date, today, rollover_passed); SchedTimingToday { + now: current_secs, days_elapsed, next_day_at, } @@ -119,6 +121,7 @@ fn sched_timing_today_v1(crt: TimestampSecs, now: TimestampSecs) -> SchedTimingT let days_elapsed = (now.0 - crt.0) / 86_400; let next_day_at = crt.0 + (days_elapsed + 1) * 86_400; SchedTimingToday { + now, days_elapsed: days_elapsed as u32, next_day_at, } @@ -147,6 +150,7 @@ fn sched_timing_today_v2_legacy( } SchedTimingToday { + now, days_elapsed: days_elapsed as u32, next_day_at, } @@ -351,6 +355,7 @@ mod test { assert_eq!( sched_timing_today_v1(TimestampSecs(1575226800), now), SchedTimingToday { + now, days_elapsed: 107, next_day_at: 1584558000 } @@ -359,6 +364,7 @@ mod test { assert_eq!( sched_timing_today_v2_legacy(TimestampSecs(1533564000), 0, now, aest_offset()), SchedTimingToday { + now, days_elapsed: 589, next_day_at: 1584540000 } @@ -367,6 +373,7 @@ mod test { assert_eq!( sched_timing_today_v2_legacy(TimestampSecs(1524038400), 4, now, aest_offset()), SchedTimingToday { + now, days_elapsed: 700, next_day_at: 1584554400 } diff --git a/rslib/src/scheduler/upgrade.rs b/rslib/src/scheduler/upgrade.rs index 0f1c9bc03..f981943dc 100644 --- a/rslib/src/scheduler/upgrade.rs +++ b/rslib/src/scheduler/upgrade.rs @@ -10,7 +10,7 @@ use crate::{ search::SortMode, }; -use super::cutoff::local_minutes_west_for_stamp; +use super::timing::local_minutes_west_for_stamp; struct V1FilteredDeckInfo { /// True if the filtered deck had rescheduling enabled. diff --git a/rslib/src/storage/card/due_cards.sql b/rslib/src/storage/card/due_cards.sql new file mode 100644 index 000000000..96241c48e --- /dev/null +++ b/rslib/src/storage/card/due_cards.sql @@ -0,0 +1,18 @@ +SELECT queue, + id, + nid, + due, + cast(ivl AS integer), + cast(mod AS integer) +FROM cards +WHERE did = ?1 + AND ( + ( + queue IN (2, 3) + AND due <= ?2 + ) + OR ( + queue IN (1, 4) + AND due <= ?3 + ) + ) \ No newline at end of file diff --git a/rslib/src/storage/card/mod.rs b/rslib/src/storage/card/mod.rs index 4b9fa7168..0cd3b0ad2 100644 --- a/rslib/src/storage/card/mod.rs +++ b/rslib/src/storage/card/mod.rs @@ -7,7 +7,10 @@ use crate::{ decks::{Deck, DeckID, DeckKind}, err::Result, notes::NoteID, - scheduler::congrats::CongratsInfo, + scheduler::{ + congrats::CongratsInfo, + queue::{DueCard, NewCard}, + }, timestamp::{TimestampMillis, TimestampSecs}, types::Usn, }; @@ -159,6 +162,67 @@ impl super::SqliteStorage { Ok(()) } + /// Call func() for each due card, stopping when it returns false + /// or no more cards found. + pub(crate) fn for_each_due_card_in_deck( + &self, + day_cutoff: u32, + learn_cutoff: i64, + deck: DeckID, + mut func: F, + ) -> Result<()> + where + F: FnMut(CardQueue, DueCard) -> bool, + { + let mut stmt = self.db.prepare_cached(include_str!("due_cards.sql"))?; + let mut rows = stmt.query(params![ + // with many subdecks, avoiding named params shaves off a few milliseconds + deck, + day_cutoff, + learn_cutoff + ])?; + while let Some(row) = rows.next()? { + let queue: CardQueue = row.get(0)?; + if !func( + queue, + DueCard { + id: row.get(1)?, + note_id: row.get(2)?, + due: row.get(3).ok().unwrap_or_default(), + extra: row.get::<_, u32>(4)? as u64, + mtime: row.get(5)?, + }, + ) { + break; + } + } + + Ok(()) + } + + /// Call func() for each new card, stopping when it returns false + /// or no more cards found. Cards will arrive in (deck_id, due) order. + pub(crate) fn for_each_new_card_in_deck(&self, deck: DeckID, mut func: F) -> Result<()> + where + F: FnMut(NewCard) -> bool, + { + let mut stmt = self.db.prepare_cached(include_str!("new_cards.sql"))?; + let mut rows = stmt.query(params![deck])?; + while let Some(row) = rows.next()? { + if !func(NewCard { + id: row.get(0)?, + note_id: row.get(1)?, + due: row.get(2)?, + extra: row.get::<_, u32>(3)? as u64, + mtime: row.get(4)?, + }) { + break; + } + } + + Ok(()) + } + /// Fix some invalid card properties, and return number of changed cards. pub(crate) fn fix_card_properties( &self, diff --git a/rslib/src/storage/card/new_cards.sql b/rslib/src/storage/card/new_cards.sql new file mode 100644 index 000000000..aa8ec5987 --- /dev/null +++ b/rslib/src/storage/card/new_cards.sql @@ -0,0 +1,8 @@ +SELECT id, + nid, + due, + ord, + cast(mod AS integer) +FROM cards +WHERE did = ? + AND queue = 0 \ No newline at end of file diff --git a/rslib/src/storage/deck/mod.rs b/rslib/src/storage/deck/mod.rs index b69224c0c..f0e0c5045 100644 --- a/rslib/src/storage/deck/mod.rs +++ b/rslib/src/storage/deck/mod.rs @@ -162,6 +162,38 @@ impl SqliteStorage { .collect() } + /// Return the provided deck with its parents and children in an ordered list, and + /// the number of parent decks that need to be skipped to get to the chosen deck. + pub(crate) fn deck_with_parents_and_children( + &self, + deck_id: DeckID, + ) -> Result<(Vec, usize)> { + let deck = self.get_deck(deck_id)?.ok_or(AnkiError::NotFound)?; + let mut parents = self.parent_decks(&deck)?; + parents.reverse(); + let parent_count = parents.len(); + + let prefix_start = format!("{}\x1f", deck.name); + let prefix_end = format!("{}\x20", deck.name); + parents.push(deck); + + let decks = parents + .into_iter() + .map(Result::Ok) + .chain( + self.db + .prepare_cached(concat!( + include_str!("get_deck.sql"), + " where name > ? and name < ?" + ))? + .query_and_then(&[prefix_start, prefix_end], row_to_deck)?, + ) + .collect::>()?; + + Ok((decks, parent_count)) + } + + /// Return the parents of `child`, with the most immediate parent coming first. pub(crate) fn parent_decks(&self, child: &Deck) -> Result> { let mut decks: Vec = vec![]; while let Some(parent_name) = diff --git a/rslib/src/storage/sqlite.rs b/rslib/src/storage/sqlite.rs index 4030de0aa..99c41ef64 100644 --- a/rslib/src/storage/sqlite.rs +++ b/rslib/src/storage/sqlite.rs @@ -5,7 +5,7 @@ use crate::config::schema11_config_as_string; use crate::err::Result; use crate::err::{AnkiError, DBErrorKind}; use crate::timestamp::{TimestampMillis, TimestampSecs}; -use crate::{i18n::I18n, scheduler::cutoff::v1_creation_date, text::without_combining}; +use crate::{i18n::I18n, scheduler::timing::v1_creation_date, text::without_combining}; use regex::Regex; use rusqlite::{functions::FunctionFlags, params, Connection, NO_PARAMS}; use std::cmp::Ordering; diff --git a/rslib/src/timestamp.rs b/rslib/src/timestamp.rs index ebadf757e..a037abae8 100644 --- a/rslib/src/timestamp.rs +++ b/rslib/src/timestamp.rs @@ -35,6 +35,10 @@ impl TimestampSecs { pub fn datetime(self, utc_offset: FixedOffset) -> DateTime { utc_offset.timestamp(self.0, 0) } + + pub fn adding_secs(self, secs: i64) -> Self { + TimestampSecs(self.0 + secs) + } } impl TimestampMillis { From 8f0c8b6f8a4ac9c5e79612ae79c8f99775763d34 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Mon, 1 Mar 2021 12:47:39 +1000 Subject: [PATCH 018/117] use different approach to running tests twice The symlink approach was breaking on Windows --- pylib/tests/test_sched2021.py | 5 ++++- pylib/tests/test_schedv2.py | 25 +++++++++++++++---------- 2 files changed, 19 insertions(+), 11 deletions(-) mode change 120000 => 100644 pylib/tests/test_sched2021.py diff --git a/pylib/tests/test_sched2021.py b/pylib/tests/test_sched2021.py deleted file mode 120000 index e0aedf8f1..000000000 --- a/pylib/tests/test_sched2021.py +++ /dev/null @@ -1 +0,0 @@ -test_schedv2.py \ No newline at end of file diff --git a/pylib/tests/test_sched2021.py b/pylib/tests/test_sched2021.py new file mode 100644 index 000000000..5d698d2ce --- /dev/null +++ b/pylib/tests/test_sched2021.py @@ -0,0 +1,4 @@ +# Copyright: Ankitects Pty Ltd and contributors +# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +from .test_schedv2 import * diff --git a/pylib/tests/test_schedv2.py b/pylib/tests/test_schedv2.py index bf84a8834..09738bacb 100644 --- a/pylib/tests/test_schedv2.py +++ b/pylib/tests/test_schedv2.py @@ -1,6 +1,7 @@ # coding: utf-8 import copy +import os import time from typing import Tuple @@ -13,18 +14,18 @@ from anki.schedv2 import UnburyCurrentDeck from anki.utils import intTime from tests.shared import getEmptyCol as getEmptyColOrig + # This file is used to exercise both the legacy Python 2.1 scheduler, # and the experimental new one in Rust. Most tests run on both, but a few # tests have been implemented separately where the behaviour differs. -is_2021 = "2021" in __file__ -new_sched_only = pytest.mark.skipif(not is_2021, reason="2021 only") -old_sched_only = pytest.mark.skipif(is_2021, reason="old only") +def is_2021() -> bool: + return "2021" in os.getenv("PYTEST_CURRENT_TEST") def getEmptyCol(): col = getEmptyColOrig() col.upgrade_to_v2_scheduler() - if is_2021: + if is_2021(): col.set_2021_test_scheduler_enabled(True) return col @@ -315,7 +316,7 @@ def test_learn_day(): c.due -= 1 c.flush() col.reset() - if is_2021: + if is_2021(): # it appears in the review queue assert col.sched.counts() == (0, 0, 1) else: @@ -468,8 +469,9 @@ def review_limits_setup() -> Tuple[anki.collection.Collection, Dict]: return col, child -@old_sched_only def test_review_limits(): + if is_2021(): + pytest.skip("old sched only") col, child = review_limits_setup() tree = col.sched.deck_due_tree().children @@ -492,8 +494,9 @@ def test_review_limits(): assert tree[0].children[0].review_count == 9 # child -@new_sched_only def test_review_limits_new(): + if not is_2021(): + pytest.skip("new sched only") col, child = review_limits_setup() tree = col.sched.deck_due_tree().children @@ -917,8 +920,9 @@ def test_ordcycle(): col.sched.answerCard(c, 4) -@old_sched_only def test_counts_idx(): + if is_2021(): + pytest.skip("old sched only") col = getEmptyCol() note = col.newNote() note["Front"] = "one" @@ -942,8 +946,9 @@ def test_counts_idx(): assert col.sched.counts() == (0, 1, 0) -@new_sched_only def test_counts_idx_new(): + if not is_2021(): + pytest.skip("new sched only") col = getEmptyCol() note = col.newNote() note["Front"] = "one" @@ -1154,7 +1159,7 @@ def test_deckFlow(): col.addNote(note) col.reset() assert col.sched.counts() == (3, 0, 0) - if is_2021: + if is_2021(): # cards arrive in position order by default for i in "one", "two", "three": c = col.sched.getCard() From 88e2aba93c98c1913baa8ae96f6e736df7076627 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Mon, 1 Mar 2021 13:36:36 +1000 Subject: [PATCH 019/117] fix inconsistent test handling on Windows Bazel sets TZ to UTC when running tests, so the tests are reproducible. But it seems like the Rust time crate was not honoring it, and using the configured timezone instead. "Fix" by forcing UTC when testing, as we already special-case a test run. --- rslib/src/timestamp.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rslib/src/timestamp.rs b/rslib/src/timestamp.rs index a037abae8..f8aadd1df 100644 --- a/rslib/src/timestamp.rs +++ b/rslib/src/timestamp.rs @@ -66,7 +66,7 @@ fn elapsed() -> time::Duration { let mut elap = time::SystemTime::now() .duration_since(time::SystemTime::UNIX_EPOCH) .unwrap(); - let now = Local::now(); + let now = Utc::now(); if now.hour() >= 2 && now.hour() < 4 { elap -= time::Duration::from_secs(60 * 60 * 2); } From fb7ee0355ff5cffe1b6624464add8dd7b1697f7f Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Mon, 1 Mar 2021 13:48:56 +1000 Subject: [PATCH 020/117] turn the 'cards may be excluded' sentence into an action --- ftl/core/decks.ftl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ftl/core/decks.ftl b/ftl/core/decks.ftl index 22175d3e7..3ffe2a470 100644 --- a/ftl/core/decks.ftl +++ b/ftl/core/decks.ftl @@ -31,7 +31,7 @@ decks-reschedule-cards-based-on-my-answers = Reschedule cards based on my answer decks-study = Study decks-study-deck = Study Deck decks-the-provided-search-did-not-match = The provided search did not match any cards. Would you like to revise it? -decks-unmovable-cards = Some cards may be excluded despite matching the search. +decks-unmovable-cards = Show any excluded cards decks-it-has-card = { $count -> [one] It has { $count } card. From 14cc81f837755cb89f1afbf1cd2da7e8bc4a0f5f Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Mon, 1 Mar 2021 13:49:14 +1000 Subject: [PATCH 021/117] update translations --- repos.bzl | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repos.bzl b/repos.bzl index 4de5f80a6..f586863e9 100644 --- a/repos.bzl +++ b/repos.bzl @@ -132,12 +132,12 @@ def register_repos(): ################ core_i18n_repo = "anki-core-i18n" - core_i18n_commit = "6ab5e0ee365c411701ded28d612dfe58e84abd0e" - core_i18n_zip_csum = "43b787f7e984ae75f43c0da2ba05aa3f85cdcad16232ebed060abf3d36f22545" + core_i18n_commit = "a68fea4ca0a361cf98bf8986a48dc161585933c3" + core_i18n_zip_csum = "97580f3ea5e8a45235b45e75e07eb9ba9d7c79a3b00731870156cc65ad6f914c" qtftl_i18n_repo = "anki-desktop-ftl" - qtftl_i18n_commit = "5310d51de506f3f99705d9cc88a64eafa0bb2062" - qtftl_i18n_zip_csum = "0d6ded85e02bedaa4b279122f6cffa43f24adb10cba9865c6417973d779fd725" + qtftl_i18n_commit = "567e9c80e9741f57a42e1306dc40888ab00939e6" + qtftl_i18n_zip_csum = "a50df014c10d057d377756bdc827a21ed584a33f3e27a6ecf7bed3a26d01cac9" i18n_build_content = """ filegroup( From f7647539204ebb617516b8714542ba0d7593113a Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Mon, 1 Mar 2021 15:32:31 +1000 Subject: [PATCH 022/117] show the empty card message on the back of the card as well Otherwise when viewing the back side, user can end up with an empty screen. --- rslib/src/template.rs | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/rslib/src/template.rs b/rslib/src/template.rs index 46000bf4d..129253afd 100644 --- a/rslib/src/template.rs +++ b/rslib/src/template.rs @@ -558,27 +558,29 @@ pub fn render_card( .map_err(|e| template_error_to_anki_error(e, true, i18n))?; // check if the front side was empty - if is_cloze { - if cloze_is_empty(field_map, card_ord) { - let info = format!( - "
{}
{}
", - i18n.trn( - TR::CardTemplateRenderingMissingCloze, - tr_args!["number"=>card_ord+1] - ), - TEMPLATE_BLANK_CLOZE_LINK, - i18n.tr(TR::CardTemplateRenderingMoreInfo) - ); - qnodes.push(RenderedNode::Text { text: info }); - } - } else if !qtmpl.renders_with_fields(context.nonempty_fields) { - let info = format!( + let empty_message = if is_cloze && cloze_is_empty(field_map, card_ord) { + Some(format!( + "
{}
{}
", + i18n.trn( + TR::CardTemplateRenderingMissingCloze, + tr_args!["number"=>card_ord+1] + ), + TEMPLATE_BLANK_CLOZE_LINK, + i18n.tr(TR::CardTemplateRenderingMoreInfo) + )) + } else if !is_cloze && !qtmpl.renders_with_fields(context.nonempty_fields) { + Some(format!( "
{}
{}
", i18n.tr(TR::CardTemplateRenderingEmptyFront), TEMPLATE_BLANK_LINK, i18n.tr(TR::CardTemplateRenderingMoreInfo) - ); - qnodes.push(RenderedNode::Text { text: info }); + )) + } else { + None + }; + if let Some(text) = empty_message { + qnodes.push(RenderedNode::Text { text: text.clone() }); + return Ok((qnodes, vec![RenderedNode::Text { text }])); } // answer side From e29bd4479b4ce91799560d6f5fcf3a9b9636ae29 Mon Sep 17 00:00:00 2001 From: RumovZ Date: Mon, 1 Mar 2021 11:26:52 +0100 Subject: [PATCH 023/117] Make clickable dyndeck labels fixed in size --- qt/aqt/dyndeckconf.py | 4 +- qt/aqt/forms/dyndconf.ui | 103 +++++++++++++++++++++++---------------- 2 files changed, 63 insertions(+), 44 deletions(-) diff --git a/qt/aqt/dyndeckconf.py b/qt/aqt/dyndeckconf.py index 0c42dd44a..79a743e04 100644 --- a/qt/aqt/dyndeckconf.py +++ b/qt/aqt/dyndeckconf.py @@ -76,8 +76,8 @@ class DeckConf(QDialog): self.setStyleSheet( f"""QPushButton[label] {{ padding: 0; border: 0 }} QPushButton[label]:hover {{ text-decoration: underline }} - QPushButton[label="search"] {{ text-align: left; color: {blue} }} - QPushButton[label="hint"] {{ text-align: right; color: {grey} }}""" + QPushButton[label="search"] {{ color: {blue} }} + QPushButton[label="hint"] {{ color: {grey} }}""" ) disable_help_button(self) self.setWindowModality(Qt.WindowModal) diff --git a/qt/aqt/forms/dyndconf.ui b/qt/aqt/forms/dyndconf.ui index 3e91c0a96..71352606a 100644 --- a/qt/aqt/forms/dyndconf.ui +++ b/qt/aqt/forms/dyndconf.ui @@ -22,12 +22,18 @@ + + + 0 + 0 + + ACTIONS_NAME - + @@ -37,22 +43,6 @@ - - - - Qt::Horizontal - - - QSizePolicy::Minimum - - - - 40 - 20 - - - -
@@ -64,6 +54,12 @@ + + + 0 + 0 + + Qt::NoFocus @@ -129,8 +125,17 @@ DECKS_FILTER_2
+ + + + + + 0 + 0 + + Qt::NoFocus @@ -151,8 +156,8 @@
- - + + @@ -177,9 +182,6 @@ - - - @@ -257,26 +259,43 @@ - - - Qt::NoFocus - - - SEARCH_VIEW_IN_BROWSER - - - DECKS_UNMOVABLE_CARDS - - - false - - - true - - - hint - - + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Qt::NoFocus + + + SEARCH_VIEW_IN_BROWSER + + + DECKS_UNMOVABLE_CARDS + + + false + + + true + + + hint + + + + From e36a9c78e397e1731229b5b52dad559f92c9fc70 Mon Sep 17 00:00:00 2001 From: Henrik Giesel Date: Mon, 1 Mar 2021 12:28:45 +0100 Subject: [PATCH 024/117] Fix focus handling when coming from top left buttons --- ts/editor/focusHandlers.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ts/editor/focusHandlers.ts b/ts/editor/focusHandlers.ts index fde2ca7cc..b6cfb5c7f 100644 --- a/ts/editor/focusHandlers.ts +++ b/ts/editor/focusHandlers.ts @@ -1,7 +1,7 @@ /* Copyright: Ankitects Pty Ltd and contributors * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */ -import type { EditingArea } from "."; +import { EditingArea } from "."; import { bridgeCommand } from "./lib"; import { enableButtons, disableButtons } from "./toolbar"; @@ -30,7 +30,10 @@ export function onFocus(evt: FocusEvent): void { const currentField = evt.currentTarget as EditingArea; const previousFocus = evt.relatedTarget as EditingArea; - if (previousFocus === previousActiveElement || !previousFocus) { + if ( + previousFocus === previousActiveElement || + !(previousFocus instanceof EditingArea) + ) { focusField(currentField); } } From 4387e3ed86df6681f18e87ef320c988d099b283e Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Mon, 1 Mar 2021 21:47:44 +1000 Subject: [PATCH 025/117] fix reps updating in v2, but do it in answerCard instead of getCard --- pylib/anki/collection.py | 10 ++++++++-- pylib/anki/sched.py | 1 + pylib/anki/scheduler.py | 4 +--- pylib/anki/schedv2.py | 1 + 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index e6fbd759a..914c46809 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -702,13 +702,19 @@ table.review-log {{ {revlog_style} }} # Timeboxing ########################################################################## + # fixme: there doesn't seem to be a good reason why this code is in main.py + # instead of covered in reviewer, and the reps tracking is covered by both + # the scheduler and reviewer.py. in the future, we should probably move + # reps tracking to reviewer.py, and remove the startTimebox() calls from + # other locations like overview.py. We just need to make sure not to reset + # the count on things like edits, which we probably could do by checking + # the previous state in moveToState. def startTimebox(self) -> None: self._startTime = time.time() self._startReps = self.sched.reps - # FIXME: Use Literal[False] when on Python 3.8 - def timeboxReached(self) -> Union[bool, Tuple[Any, int]]: + def timeboxReached(self) -> Union[Literal[False], Tuple[Any, int]]: "Return (elapsedTime, reps) if timebox reached, or False." if not self.conf["timeLim"]: # timeboxing disabled diff --git a/pylib/anki/sched.py b/pylib/anki/sched.py index 7e98e9c6e..3b4ceada1 100644 --- a/pylib/anki/sched.py +++ b/pylib/anki/sched.py @@ -49,6 +49,7 @@ class Scheduler(V2): if self._burySiblingsOnAnswer: self._burySiblings(card) card.reps += 1 + self.reps += 1 # former is for logging new cards, latter also covers filt. decks card.wasNew = card.type == CARD_TYPE_NEW # type: ignore wasNewQ = card.queue == QUEUE_TYPE_NEW diff --git a/pylib/anki/scheduler.py b/pylib/anki/scheduler.py index 5ec1fde1c..c35e17970 100644 --- a/pylib/anki/scheduler.py +++ b/pylib/anki/scheduler.py @@ -28,7 +28,6 @@ UnburyCurrentDeck = _pb.UnburyCardsInCurrentDeckIn BuryOrSuspend = _pb.BuryOrSuspendCardsIn # fixme: reviewer.cardQueue/editCurrent/undo handling/retaining current card -# fixme: .reps class Scheduler: @@ -36,8 +35,7 @@ class Scheduler: def __init__(self, col: anki.collection.Collection) -> None: self.col = col.weakref() - # fixme: only used by the timeboxing code, and was double-incremented - # for ages - just move to gui? + # don't rely on this, it will likely be removed out in the future self.reps = 0 # Timing diff --git a/pylib/anki/schedv2.py b/pylib/anki/schedv2.py index 46c740563..1c141c757 100644 --- a/pylib/anki/schedv2.py +++ b/pylib/anki/schedv2.py @@ -489,6 +489,7 @@ limit ?""" self._answerCardPreview(card, ease) return + self.reps += 1 card.reps += 1 new_delta = 0 From c74a71a6d7cc1a23818c7f205a7dbed79f4ecdd4 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Mon, 1 Mar 2021 21:20:13 +1000 Subject: [PATCH 026/117] interval sorting --- rslib/src/scheduler/queue/builder/mod.rs | 4 +- rslib/src/scheduler/queue/builder/sorting.rs | 42 +++++++++++++------- rslib/src/storage/card/mod.rs | 3 +- 3 files changed, 32 insertions(+), 17 deletions(-) diff --git a/rslib/src/scheduler/queue/builder/mod.rs b/rslib/src/scheduler/queue/builder/mod.rs index 5ad845215..f943965f7 100644 --- a/rslib/src/scheduler/queue/builder/mod.rs +++ b/rslib/src/scheduler/queue/builder/mod.rs @@ -26,8 +26,8 @@ pub(crate) struct DueCard { pub note_id: NoteID, pub mtime: TimestampSecs, pub due: i32, - /// Used to store interval, and for shuffling - pub extra: u64, + pub interval: u32, + pub hash: u64, } /// Temporary holder for new cards that will be built into a queue. diff --git a/rslib/src/scheduler/queue/builder/sorting.rs b/rslib/src/scheduler/queue/builder/sorting.rs index a95f65bbb..2ba28c54a 100644 --- a/rslib/src/scheduler/queue/builder/sorting.rs +++ b/rslib/src/scheduler/queue/builder/sorting.rs @@ -10,7 +10,7 @@ impl QueueBuilder { match self.new_order { NewCardOrder::Random => { self.new.iter_mut().for_each(NewCard::hash_id_and_mtime); - self.new.sort_unstable_by(|a, b| a.extra.cmp(&b.extra)); + self.new.sort_unstable_by(shuffle_new_card); } NewCardOrder::Due => { self.new.sort_unstable_by(|a, b| a.due.cmp(&b.due)); @@ -30,30 +30,48 @@ impl QueueBuilder { self.day_learning.sort_unstable_by(shuffle_by_day); } ReviewCardOrder::Shuffled => { - self.review.sort_unstable_by(|a, b| a.extra.cmp(&b.extra)); - self.day_learning - .sort_unstable_by(|a, b| a.extra.cmp(&b.extra)); + self.review.sort_unstable_by(shuffle_due_card); + self.day_learning.sort_unstable_by(shuffle_due_card); } ReviewCardOrder::IntervalsAscending => { - // fixme: implement; may require separate field if we want - // to shuffle cards that share an interval + self.review.sort_unstable_by(intervals_ascending); + self.day_learning.sort_unstable_by(shuffle_due_card); } ReviewCardOrder::IntervalsDescending => { - // fixme: implement; may require separate field if we want - // to shuffle cards that share an interval + self.review + .sort_unstable_by(|a, b| intervals_ascending(b, a)); + self.day_learning.sort_unstable_by(shuffle_due_card); } } } } +fn shuffle_new_card(a: &NewCard, b: &NewCard) -> Ordering { + a.extra.cmp(&b.extra) +} + +fn shuffle_by_day(a: &DueCard, b: &DueCard) -> Ordering { + (a.due, a.hash).cmp(&(b.due, b.hash)) +} + +fn shuffle_due_card(a: &DueCard, b: &DueCard) -> Ordering { + a.hash.cmp(&b.hash) +} + +fn intervals_ascending(a: &DueCard, b: &DueCard) -> Ordering { + (a.interval, a.hash).cmp(&(a.interval, b.hash)) +} + // We sort based on a hash so that if the queue is rebuilt, remaining -// cards come back in the same order. +// cards come back in the same approximate order (mixing + due learning cards +// may still result in a different card) + impl DueCard { fn hash_id_and_mtime(&mut self) { let mut hasher = FnvHasher::default(); hasher.write_i64(self.id.0); hasher.write_i64(self.mtime.0); - self.extra = hasher.finish(); + self.hash = hasher.finish(); } } @@ -65,7 +83,3 @@ impl NewCard { self.extra = hasher.finish(); } } - -fn shuffle_by_day(a: &DueCard, b: &DueCard) -> Ordering { - (a.due, a.extra).cmp(&(b.due, b.extra)) -} diff --git a/rslib/src/storage/card/mod.rs b/rslib/src/storage/card/mod.rs index 0cd3b0ad2..932af4e90 100644 --- a/rslib/src/storage/card/mod.rs +++ b/rslib/src/storage/card/mod.rs @@ -189,8 +189,9 @@ impl super::SqliteStorage { id: row.get(1)?, note_id: row.get(2)?, due: row.get(3).ok().unwrap_or_default(), - extra: row.get::<_, u32>(4)? as u64, + interval: row.get(4)?, mtime: row.get(5)?, + hash: 0, }, ) { break; From de7baa80bdb6604a791f6893b99a57fdb4e5d1d5 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Mon, 1 Mar 2021 23:47:00 +1000 Subject: [PATCH 027/117] switch to 4 buttons when previewing in test scheduler - Currently we just use 1.5x and 2x the normal preview delay; we could change this in the future. - Don't try to capture the current state; just use a flag to denote exit status. - Show (end) when exiting --- pylib/anki/scheduler.py | 3 - pylib/tests/test_schedv2.py | 13 +++- rslib/backend.proto | 2 +- rslib/src/backend/scheduler/states/preview.rs | 4 +- rslib/src/scheduler/answering/current.rs | 2 +- rslib/src/scheduler/answering/mod.rs | 64 ++++++++++++----- rslib/src/scheduler/answering/preview.rs | 70 +++++++++++++------ .../answering/rescheduling_filter.rs | 20 ------ rslib/src/scheduler/answering/review.rs | 1 - rslib/src/scheduler/states/filtered.rs | 2 +- rslib/src/scheduler/states/normal.rs | 4 ++ rslib/src/scheduler/states/preview_filter.rs | 23 ++++-- rslib/src/scheduler/timespan.rs | 4 +- 13 files changed, 132 insertions(+), 80 deletions(-) delete mode 100644 rslib/src/scheduler/answering/rescheduling_filter.rs diff --git a/pylib/anki/scheduler.py b/pylib/anki/scheduler.py index c35e17970..8ac969d48 100644 --- a/pylib/anki/scheduler.py +++ b/pylib/anki/scheduler.py @@ -239,9 +239,6 @@ class Scheduler: return card.queue def answerButtons(self, card: Card) -> int: - conf = self._cardConf(card) - if card.odid and not conf["resched"]: - return 2 return 4 def nextIvlStr(self, card: Card, ease: int, short: bool = False) -> str: diff --git a/pylib/tests/test_schedv2.py b/pylib/tests/test_schedv2.py index 09738bacb..3d39dc816 100644 --- a/pylib/tests/test_schedv2.py +++ b/pylib/tests/test_schedv2.py @@ -857,9 +857,16 @@ def test_preview(): col.reset() # grab the first card c = col.sched.getCard() - assert col.sched.answerButtons(c) == 2 + + if is_2021(): + passing_grade = 4 + else: + passing_grade = 2 + + assert col.sched.answerButtons(c) == passing_grade assert col.sched.nextIvl(c, 1) == 600 - assert col.sched.nextIvl(c, 2) == 0 + assert col.sched.nextIvl(c, passing_grade) == 0 + # failing it will push its due time back due = c.due col.sched.answerCard(c, 1) @@ -870,7 +877,7 @@ def test_preview(): assert c2.id != c.id # passing it will remove it - col.sched.answerCard(c2, 2) + col.sched.answerCard(c2, passing_grade) assert c2.queue == QUEUE_TYPE_NEW assert c2.reps == 0 assert c2.type == CARD_TYPE_NEW diff --git a/rslib/backend.proto b/rslib/backend.proto index 860b223d3..b31c939d4 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -1319,7 +1319,7 @@ message SchedulingState { } message Preview { uint32 scheduled_secs = 1; - Normal original_state = 2; + bool finished = 2; } message ReschedulingFilter { Normal original_state = 1; diff --git a/rslib/src/backend/scheduler/states/preview.rs b/rslib/src/backend/scheduler/states/preview.rs index c09876820..24d3c2877 100644 --- a/rslib/src/backend/scheduler/states/preview.rs +++ b/rslib/src/backend/scheduler/states/preview.rs @@ -7,7 +7,7 @@ impl From for PreviewState { fn from(state: pb::scheduling_state::Preview) -> Self { PreviewState { scheduled_secs: state.scheduled_secs, - original_state: state.original_state.unwrap_or_default().into(), + finished: state.finished, } } } @@ -16,7 +16,7 @@ impl From for pb::scheduling_state::Preview { fn from(state: PreviewState) -> Self { pb::scheduling_state::Preview { scheduled_secs: state.scheduled_secs, - original_state: Some(state.original_state.into()), + finished: state.finished, } } } diff --git a/rslib/src/scheduler/answering/current.rs b/rslib/src/scheduler/answering/current.rs index 2b62024a4..ff2d3bf46 100644 --- a/rslib/src/scheduler/answering/current.rs +++ b/rslib/src/scheduler/answering/current.rs @@ -49,7 +49,7 @@ impl CardStateUpdater { } else { PreviewState { scheduled_secs: filtered.preview_delay * 60, - original_state: normal_state, + finished: false, } .into() } diff --git a/rslib/src/scheduler/answering/mod.rs b/rslib/src/scheduler/answering/mod.rs index 2b0d2e1d8..3b4f24435 100644 --- a/rslib/src/scheduler/answering/mod.rs +++ b/rslib/src/scheduler/answering/mod.rs @@ -5,7 +5,6 @@ mod current; mod learning; mod preview; mod relearning; -mod rescheduling_filter; mod review; mod revlog; @@ -44,7 +43,6 @@ pub struct CardAnswer { pub milliseconds_taken: u32, } -// fixme: 4 buttons for previewing // fixme: log preview review // fixme: undo @@ -107,25 +105,52 @@ impl CardStateUpdater { current: CardState, next: CardState, ) -> Result> { - // any non-preview answer resets card.odue and increases reps - if !matches!(current, CardState::Filtered(FilteredState::Preview(_))) { - self.card.reps += 1; - self.card.original_due = 0; - } + let revlog = match next { + CardState::Normal(normal) => { + // transitioning from filtered state? + if let CardState::Filtered(filtered) = ¤t { + match filtered { + FilteredState::Preview(_) => { + return Err(AnkiError::invalid_input( + "should set finished=true, not return different state", + )); + } + FilteredState::Rescheduling(_) => { + // card needs to be removed from normal filtered deck, then scheduled normally + self.card.remove_from_filtered_deck_before_reschedule(); + } + } + } + // apply normal scheduling + self.apply_normal_study_state(current, normal) + } + CardState::Filtered(filtered) => { + self.ensure_filtered()?; + match filtered { + FilteredState::Preview(next) => self.apply_preview_state(current, next), + FilteredState::Rescheduling(next) => { + self.apply_normal_study_state(current, next.original_state) + } + } + } + }?; + + Ok(revlog) + } + + fn apply_normal_study_state( + &mut self, + current: CardState, + next: NormalState, + ) -> Result> { + self.card.reps += 1; + self.card.original_due = 0; let revlog = match next { - CardState::Normal(normal) => match normal { - NormalState::New(next) => self.apply_new_state(current, next), - NormalState::Learning(next) => self.apply_learning_state(current, next), - NormalState::Review(next) => self.apply_review_state(current, next), - NormalState::Relearning(next) => self.apply_relearning_state(current, next), - }, - CardState::Filtered(filtered) => match filtered { - FilteredState::Preview(next) => self.apply_preview_state(current, next), - FilteredState::Rescheduling(next) => { - self.apply_rescheduling_filter_state(current, next) - } - }, + NormalState::New(next) => self.apply_new_state(current, next), + NormalState::Learning(next) => self.apply_learning_state(current, next), + NormalState::Review(next) => self.apply_review_state(current, next), + NormalState::Relearning(next) => self.apply_relearning_state(current, next), }?; if next.leeched() && self.config.inner.leech_action() == LeechAction::Suspend { @@ -173,6 +198,7 @@ impl Collection { let now = TimestampSecs::now(); let timing = self.timing_for_timestamp(now)?; let secs_until_rollover = (timing.next_day_at - now.0).max(0) as u32; + Ok(vec![ answer_button_time_collapsible( choices diff --git a/rslib/src/scheduler/answering/preview.rs b/rslib/src/scheduler/answering/preview.rs index 9bf385477..4dd1e9df8 100644 --- a/rslib/src/scheduler/answering/preview.rs +++ b/rslib/src/scheduler/answering/preview.rs @@ -3,6 +3,7 @@ use crate::{ card::CardQueue, + config::SchedulerVersion, prelude::*, scheduler::states::{CardState, IntervalKind, PreviewState}, }; @@ -17,7 +18,12 @@ impl CardStateUpdater { current: CardState, next: PreviewState, ) -> Result> { - self.ensure_filtered()?; + if next.finished { + self.card + .remove_from_filtered_deck_restoring_queue(SchedulerVersion::V2); + return Ok(None); + } + self.card.queue = CardQueue::PreviewRepeat; let interval = next.interval_kind(); @@ -48,7 +54,7 @@ mod test { card::CardType, scheduler::{ answering::{CardAnswer, Rating}, - states::{CardState, FilteredState, LearnState, NormalState}, + states::{CardState, FilteredState}, }, timestamp::TimestampMillis, }; @@ -56,42 +62,35 @@ mod test { #[test] fn preview() -> Result<()> { let mut col = open_test_collection(); - dbg!(col.scheduler_version()); let mut c = Card { deck_id: DeckID(1), ctype: CardType::Learn, - queue: CardQueue::Learn, + queue: CardQueue::DayLearn, remaining_steps: 2, + due: 123, ..Default::default() }; col.add_card(&mut c)?; - // set the first (current) step to a day - let deck = col.storage.get_deck(DeckID(1))?.unwrap(); - let mut conf = col - .get_deck_config(DeckConfID(deck.normal()?.config_id), false)? - .unwrap(); - *conf.inner.learn_steps.get_mut(0).unwrap() = 24.0 * 60.0; - col.add_or_update_deck_config(&mut conf, false)?; - // pull the card into a preview deck let mut filtered_deck = Deck::new_filtered(); filtered_deck.filtered_mut()?.reschedule = false; col.add_or_update_deck(&mut filtered_deck)?; assert_eq!(col.rebuild_filtered_deck(filtered_deck.id)?, 1); - // the original state reflects the learning steps, not the card properties let next = col.get_next_card_states(c.id)?; - assert_eq!( + assert!(matches!( next.current, + CardState::Filtered(FilteredState::Preview(_)) + )); + // the exit state should have a 0 second interval, which will show up as (end) + assert!(matches!( + next.easy, CardState::Filtered(FilteredState::Preview(PreviewState { - scheduled_secs: 600, - original_state: NormalState::Learning(LearnState { - remaining_steps: 2, - scheduled_secs: 86_400, - }), + scheduled_secs: 0, + finished: true })) - ); + )); // use Again on the preview col.answer_card(&CardAnswer { @@ -106,8 +105,7 @@ mod test { c = col.storage.get_card(c.id)?.unwrap(); assert_eq!(c.queue, CardQueue::PreviewRepeat); - // and then it should return to its old state once passed - // (based on learning steps) + // hard let next = col.get_next_card_states(c.id)?; col.answer_card(&CardAnswer { card_id: c.id, @@ -118,8 +116,34 @@ mod test { milliseconds_taken: 0, })?; c = col.storage.get_card(c.id)?.unwrap(); + assert_eq!(c.queue, CardQueue::PreviewRepeat); + + // good + let next = col.get_next_card_states(c.id)?; + col.answer_card(&CardAnswer { + card_id: c.id, + current_state: next.current, + new_state: next.good, + rating: Rating::Good, + answered_at: TimestampMillis::now(), + milliseconds_taken: 0, + })?; + c = col.storage.get_card(c.id)?.unwrap(); + assert_eq!(c.queue, CardQueue::PreviewRepeat); + + // and then it should return to its old state once easy selected + let next = col.get_next_card_states(c.id)?; + col.answer_card(&CardAnswer { + card_id: c.id, + current_state: next.current, + new_state: next.easy, + rating: Rating::Easy, + answered_at: TimestampMillis::now(), + milliseconds_taken: 0, + })?; + c = col.storage.get_card(c.id)?.unwrap(); assert_eq!(c.queue, CardQueue::DayLearn); - assert_eq!(c.due, 1); + assert_eq!(c.due, 123); Ok(()) } diff --git a/rslib/src/scheduler/answering/rescheduling_filter.rs b/rslib/src/scheduler/answering/rescheduling_filter.rs deleted file mode 100644 index e5b331cfc..000000000 --- a/rslib/src/scheduler/answering/rescheduling_filter.rs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright: Ankitects Pty Ltd and contributors -// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html - -use crate::{ - prelude::*, - scheduler::states::{CardState, ReschedulingFilterState}, -}; - -use super::{CardStateUpdater, RevlogEntryPartial}; - -impl CardStateUpdater { - pub(super) fn apply_rescheduling_filter_state( - &mut self, - current: CardState, - next: ReschedulingFilterState, - ) -> Result> { - self.ensure_filtered()?; - self.apply_study_state(current, next.original_state.into()) - } -} diff --git a/rslib/src/scheduler/answering/review.rs b/rslib/src/scheduler/answering/review.rs index 6df913c29..b948b43c2 100644 --- a/rslib/src/scheduler/answering/review.rs +++ b/rslib/src/scheduler/answering/review.rs @@ -15,7 +15,6 @@ impl CardStateUpdater { current: CardState, next: ReviewState, ) -> Result> { - self.card.remove_from_filtered_deck_before_reschedule(); self.card.queue = CardQueue::Review; self.card.ctype = CardType::Review; self.card.interval = next.scheduled_days; diff --git a/rslib/src/scheduler/states/filtered.rs b/rslib/src/scheduler/states/filtered.rs index a74966df0..b0de406a9 100644 --- a/rslib/src/scheduler/states/filtered.rs +++ b/rslib/src/scheduler/states/filtered.rs @@ -37,7 +37,7 @@ impl FilteredState { pub(crate) fn review_state(self) -> Option { match self { - FilteredState::Preview(state) => state.original_state.review_state(), + FilteredState::Preview(_) => None, FilteredState::Rescheduling(state) => state.original_state.review_state(), } } diff --git a/rslib/src/scheduler/states/normal.rs b/rslib/src/scheduler/states/normal.rs index 9b59c1b05..25c47cb81 100644 --- a/rslib/src/scheduler/states/normal.rs +++ b/rslib/src/scheduler/states/normal.rs @@ -64,6 +64,10 @@ impl NormalState { NormalState::Relearning(RelearnState { review, .. }) => Some(review), } } + + pub(crate) fn leeched(self) -> bool { + self.review_state().map(|r| r.leeched).unwrap_or_default() + } } impl From for NormalState { diff --git a/rslib/src/scheduler/states/preview_filter.rs b/rslib/src/scheduler/states/preview_filter.rs index 97173211c..b22cb2445 100644 --- a/rslib/src/scheduler/states/preview_filter.rs +++ b/rslib/src/scheduler/states/preview_filter.rs @@ -1,12 +1,12 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -use super::{IntervalKind, NextCardStates, NormalState, StateContext}; +use super::{IntervalKind, NextCardStates, StateContext}; #[derive(Debug, Clone, Copy, PartialEq)] pub struct PreviewState { pub scheduled_secs: u32, - pub original_state: NormalState, + pub finished: bool, } impl PreviewState { @@ -22,9 +22,22 @@ impl PreviewState { ..self } .into(), - hard: self.original_state.into(), - good: self.original_state.into(), - easy: self.original_state.into(), + hard: PreviewState { + // ~15 minutes with the default setting + scheduled_secs: ctx.with_learning_fuzz(ctx.preview_step * 90), + ..self + } + .into(), + good: PreviewState { + scheduled_secs: ctx.with_learning_fuzz(ctx.preview_step * 120), + ..self + } + .into(), + easy: PreviewState { + scheduled_secs: 0, + finished: true, + } + .into(), } } } diff --git a/rslib/src/scheduler/timespan.rs b/rslib/src/scheduler/timespan.rs index 4c5ce2749..5c1f34d55 100644 --- a/rslib/src/scheduler/timespan.rs +++ b/rslib/src/scheduler/timespan.rs @@ -22,7 +22,9 @@ pub fn answer_button_time(seconds: f32, i18n: &I18n) -> String { /// Times within the collapse time are represented like '<10m' pub fn answer_button_time_collapsible(seconds: u32, collapse_secs: u32, i18n: &I18n) -> String { let string = answer_button_time(seconds as f32, i18n); - if seconds < collapse_secs { + if seconds == 0 { + i18n.tr(TR::SchedulingEnd).into() + } else if seconds < collapse_secs { format!("<{}", string) } else { string From 5f9792392a953271eee21004e3a49b591999b9a7 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Tue, 2 Mar 2021 10:23:06 +1000 Subject: [PATCH 028/117] don't cap child counts to parents when reviewing in v2 https://forums.ankiweb.net/t/anki-2-1-41-beta/7305/59 When originally implemented in 21023ed3e561910f41dd709e0b7a160f3cc8665a, a given deck's limit was bound by its parents. This lead to a deck list that seemed more logical in the parent limit < child limit case, as child counts couldn't exceed a parent's, but it obscured the fact that child decks could still be clicked on to show cards. And in the parent limit > child limit case, the count shown for the child on the deck list did not reflect how many cards were actually available and would be delivered. This change updates the reviewer to ignore parent limits when getting review counts for the deck, which makes the behaviour consistent with the deck list, which was recently changed to ignore parent limits. Neither solution is ideal - this was a tradeoff v2 made in order to keep fetching of review cards from multiple decks reasonably performant. The experimental scheduling work moves back to respecting limits on individual children, so this should hopefully improve in the future. Also removed _revForDeck(), which was unused. --- pylib/anki/sched.py | 21 +-------------------- pylib/anki/schedv2.py | 27 ++------------------------- 2 files changed, 3 insertions(+), 45 deletions(-) diff --git a/pylib/anki/sched.py b/pylib/anki/sched.py index 3b4ceada1..844353551 100644 --- a/pylib/anki/sched.py +++ b/pylib/anki/sched.py @@ -12,7 +12,7 @@ import anki from anki import hooks from anki.cards import Card from anki.consts import * -from anki.decks import Deck, QueueConfig +from anki.decks import QueueConfig from anki.schedv2 import Scheduler as V2 from anki.utils import ids2str, intTime @@ -428,25 +428,6 @@ and due <= ? limit ?)""", def _deckRevLimit(self, did: int) -> int: return self._deckNewLimit(did, self._deckRevLimitSingle) - def _deckRevLimitSingle(self, d: Deck) -> int: # type: ignore[override] - if d["dyn"]: - return self.reportLimit - c = self.col.decks.confForDid(d["id"]) - limit = max(0, c["rev"]["perDay"] - self.counts_for_deck_today(d["id"]).review) - return hooks.scheduler_review_limit_for_single_deck(limit, d) - - def _revForDeck(self, did: int, lim: int) -> int: # type: ignore[override] - lim = min(lim, self.reportLimit) - return self.col.db.scalar( - f""" -select count() from -(select 1 from cards where did = ? and queue = {QUEUE_TYPE_REV} -and due <= ? limit ?)""", - did, - self.today, - lim, - ) - def _resetRev(self) -> None: self._revQueue: List[Any] = [] self._revDids = self.col.decks.active()[:] diff --git a/pylib/anki/schedv2.py b/pylib/anki/schedv2.py index 1c141c757..cae96be64 100644 --- a/pylib/anki/schedv2.py +++ b/pylib/anki/schedv2.py @@ -14,7 +14,7 @@ import anki._backend.backend_pb2 as _pb from anki import hooks from anki.cards import Card from anki.consts import * -from anki.decks import Deck, DeckConfig, DeckManager, DeckTreeNode, QueueConfig +from anki.decks import Deck, DeckConfig, DeckTreeNode, QueueConfig from anki.lang import FormatTimeSpan from anki.notes import Note from anki.utils import from_json_bytes, ids2str, intTime @@ -390,9 +390,7 @@ did = ? and queue = {QUEUE_TYPE_DAY_LEARN_RELEARN} and due <= ? limit ?""", d = self.col.decks.get(self.col.decks.selected(), default=False) return self._deckRevLimitSingle(d) - def _deckRevLimitSingle( - self, d: Dict[str, Any], parentLimit: Optional[int] = None - ) -> int: + def _deckRevLimitSingle(self, d: Dict[str, Any]) -> int: # invalid deck selected? if not d: return 0 @@ -403,29 +401,8 @@ did = ? and queue = {QUEUE_TYPE_DAY_LEARN_RELEARN} and due <= ? limit ?""", c = self.col.decks.confForDid(d["id"]) lim = max(0, c["rev"]["perDay"] - self.counts_for_deck_today(d["id"]).review) - if parentLimit is not None: - lim = min(parentLimit, lim) - elif "::" in d["name"]: - for parent in self.col.decks.parents(d["id"]): - # pass in dummy parentLimit so we don't do parent lookup again - lim = min(lim, self._deckRevLimitSingle(parent, parentLimit=lim)) return hooks.scheduler_review_limit_for_single_deck(lim, d) - def _revForDeck( - self, did: int, lim: int, childMap: DeckManager.childMapNode - ) -> Any: - dids = [did] + self.col.decks.childDids(did, childMap) - lim = min(lim, self.reportLimit) - return self.col.db.scalar( - f""" -select count() from -(select 1 from cards where did in %s and queue = {QUEUE_TYPE_REV} -and due <= ? limit ?)""" - % ids2str(dids), - self.today, - lim, - ) - def _resetRev(self) -> None: self._revQueue: List[int] = [] From 69006b5872e781a381ac1cddf6aeb1c8b8f102d5 Mon Sep 17 00:00:00 2001 From: bluegreenmagick Date: Wed, 3 Mar 2021 10:34:43 +0900 Subject: [PATCH 029/117] add dialog to choose addons to update --- ftl/qt/addons.ftl | 1 + qt/aqt/addons.py | 147 +++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 133 insertions(+), 15 deletions(-) diff --git a/ftl/qt/addons.ftl b/ftl/qt/addons.ftl index 43894125d..07ed57887 100644 --- a/ftl/qt/addons.ftl +++ b/ftl/qt/addons.ftl @@ -64,3 +64,4 @@ addons-delete-the-numd-selected-addon = [one] Delete the { $count } selected add-on? *[other] Delete the { $count } selected add-ons? } +addons-choose-update-window-title = Update Add-ons diff --git a/qt/aqt/addons.py b/qt/aqt/addons.py index 0166276c9..9957780de 100644 --- a/qt/aqt/addons.py +++ b/qt/aqt/addons.py @@ -10,6 +10,7 @@ import zipfile from collections import defaultdict from concurrent.futures import Future from dataclasses import dataclass +from datetime import datetime from typing import IO, Any, Callable, Dict, Iterable, List, Optional, Tuple, Union from urllib.parse import parse_qs, urlparse from zipfile import ZipFile @@ -551,19 +552,19 @@ class AddonManager: if updated: self.write_addon_meta(addon) - def updates_required(self, items: List[UpdateInfo]) -> List[int]: + def updates_required(self, items: List[UpdateInfo]) -> List[UpdateInfo]: """Return ids of add-ons requiring an update.""" need_update = [] for item in items: addon = self.addon_meta(str(item.id)) # update if server mtime is newer if not addon.is_latest(item.suitable_branch_last_modified): - need_update.append(item.id) + need_update.append(item) elif not addon.compatible() and item.suitable_branch_last_modified > 0: # Addon is currently disabled, and a suitable branch was found on the # server. Ignore our stored mtime (which may have been set incorrectly # in the past) and require an update. - need_update.append(item.id) + need_update.append(item) return need_update @@ -1132,6 +1133,128 @@ def download_addons( ###################################################################### +class ChooseAddonsToUpdateList(QListWidget): + ADDON_ID_ROLE = 101 + + def __init__( + self, + parent: QWidget, + mgr: AddonManager, + updated_addons: List[UpdateInfo], + ) -> None: + QListWidget.__init__(self, parent) + self.mgr = mgr + self.updated_addons = sorted( + updated_addons, key=lambda addon: addon.suitable_branch_last_modified + ) + self.setup() + qconnect(self.itemClicked, self.toggle_check) + qconnect(self.itemDoubleClicked, self.double_click) + + def setup(self) -> None: + check_state = Qt.Unchecked + header_item = QListWidgetItem("", self) + header_item.setFlags(Qt.ItemFlag(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)) + header_item.setBackground(Qt.lightGray) + header_item.setCheckState(check_state) + self.header_item = header_item + for update_info in self.updated_addons: + addon_id = update_info.id + addon_name = self.mgr.addon_meta(str(addon_id)).human_name() + update_timestamp = update_info.suitable_branch_last_modified + update_time = datetime.fromtimestamp(update_timestamp) + + addon_label = f"{update_time:%Y-%m-%d} {addon_name}" + item = QListWidgetItem(addon_label, self) + # Not user checkable because it overlaps with itemClicked signal + item.setFlags(Qt.ItemFlag(Qt.ItemIsEnabled)) + item.setCheckState(check_state) + item.setData(self.ADDON_ID_ROLE, addon_id) + + def toggle_check(self, item: QListWidgetItem) -> None: + if item == self.header_item: + if item.checkState() == Qt.Checked: + check = Qt.Checked + else: + check = Qt.Unchecked + self.check_all_items(check) + return + # Normal Item + if item.checkState() == Qt.Checked: + item.setCheckState(Qt.Unchecked) + self.header_item.setCheckState(Qt.Unchecked) + else: + item.setCheckState(Qt.Checked) + if self.every_item_is_checked(): + self.header_item.setCheckState(Qt.Checked) + + def double_click(self, item: QListWidgetItem) -> None: + if item == self.header_item: + if item.checkState() == Qt.Checked: + check = Qt.Unchecked + else: + check = Qt.Checked + self.header_item.setCheckState(check) + self.check_all_items(check) + + def check_all_items(self, check: Qt.CheckState = Qt.Checked) -> None: + for i in range(1, self.count()): + self.item(i).setCheckState(check) + + def every_item_is_checked(self) -> bool: + for i in range(1, self.count()): + item = self.item(i) + if item.checkState() == Qt.Unchecked: + return False + return True + + def get_selected_addon_ids(self) -> List[int]: + addon_ids = [] + for i in range(1, self.count()): + item = self.item(i) + if item.checkState() == Qt.Checked: + addon_ids.append(item.data(self.ADDON_ID_ROLE)) + return addon_ids + + +class ChooseAddonsToUpdateDialog(QDialog): + def __init__( + self, parent: QWidget, mgr: AddonManager, updated_addons: List[UpdateInfo] + ) -> None: + QDialog.__init__(self, parent) + self.setWindowTitle(tr(TR.ADDONS_CHOOSE_UPDATE_WINDOW_TITLE)) + self.setWindowModality(Qt.WindowModal) + self.mgr = mgr + self.updated_addons = updated_addons + self.setup() + restoreGeom(self, "addonsChooseUpdate") + + def setup(self) -> None: + layout = QVBoxLayout() + label = QLabel(tr(TR.ADDONS_THE_FOLLOWING_ADDONS_HAVE_UPDATES_AVAILABLE)) + layout.addWidget(label) + addons_list_widget = ChooseAddonsToUpdateList( + self, self.mgr, self.updated_addons + ) + layout.addWidget(addons_list_widget) + self.addons_list_widget = addons_list_widget + + button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) # type: ignore + qconnect(button_box.button(QDialogButtonBox.Ok).clicked, self.accept) + qconnect(button_box.button(QDialogButtonBox.Cancel).clicked, self.reject) + layout.addWidget(button_box) + self.setLayout(layout) + + def ask(self) -> List[int]: + "Returns a list of selected addons' ids" + ret = self.exec_() + saveGeom(self, "addonsChooseUpdate") + if ret == QDialog.Accepted: + return self.addons_list_widget.get_selected_addon_ids() + else: + return [] + + def fetch_update_info(client: HttpClient, ids: List[int]) -> List[Dict]: """Fetch update info from AnkiWeb in one or more batches.""" all_info: List[Dict] = [] @@ -1239,31 +1362,25 @@ def handle_update_info( update_info = mgr.extract_update_info(items) mgr.update_supported_versions(update_info) - updated_ids = mgr.updates_required(update_info) + updated_addons = mgr.updates_required(update_info) - if not updated_ids: + if not updated_addons: on_done([]) return - prompt_to_update(parent, mgr, client, updated_ids, on_done) + prompt_to_update(parent, mgr, client, updated_addons, on_done) def prompt_to_update( parent: QWidget, mgr: AddonManager, client: HttpClient, - ids: List[int], + updated_addons: List[UpdateInfo], on_done: Callable[[List[DownloadLogEntry]], None], ) -> None: - names = map(lambda x: mgr.addonName(str(x)), ids) - if not askUser( - tr(TR.ADDONS_THE_FOLLOWING_ADDONS_HAVE_UPDATES_AVAILABLE) - + "\n\n" - + "\n".join(names) - ): - # on_done is not called if the user cancels + ids = ChooseAddonsToUpdateDialog(parent, mgr, updated_addons).ask() + if not ids: return - download_addons(parent, mgr, ids, on_done, client) From 65acf70df4ab3a2a35f10dc6a54826a206a55b95 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Wed, 3 Mar 2021 18:57:41 +1000 Subject: [PATCH 030/117] handle case when .ftl file doesn't exist yet --- ftl/duplicate-string.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ftl/duplicate-string.py b/ftl/duplicate-string.py index 32a38d2a7..9f44d8b7a 100644 --- a/ftl/duplicate-string.py +++ b/ftl/duplicate-string.py @@ -46,10 +46,10 @@ def write_entry(fname, key, entry): entry.id.name = key if not os.path.exists(fname): - return - - with open(fname) as file: - orig = file.read() + orig = "" + else: + with open(fname) as file: + orig = file.read() obj = parse(orig) for ent in obj.body: if isinstance(ent, Junk): From edaabee94239b86a29428fdb2908f37452c03087 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Wed, 3 Mar 2021 18:57:45 +1000 Subject: [PATCH 031/117] add undo.ftl --- ftl/core/undo.ftl | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 ftl/core/undo.ftl diff --git a/ftl/core/undo.ftl b/ftl/core/undo.ftl new file mode 100644 index 000000000..7758ede67 --- /dev/null +++ b/ftl/core/undo.ftl @@ -0,0 +1,9 @@ +# eg "Undo Answer Card" +undo-undo-action = Undo { $val } +# eg "Answer Card Undone" +undo-action-undone = { $action } Undone +undo-redo-action = Redo { $action } +undo-action-redone = { $action } Redone + +undo-answer-card = Answer Card + From 07989afa3815bc361032ce2c00329558aa36fc81 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Thu, 4 Mar 2021 11:02:48 +1000 Subject: [PATCH 032/117] fix infinite loop on card answer when parent is missing deck --- rslib/src/storage/deck/mod.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/rslib/src/storage/deck/mod.rs b/rslib/src/storage/deck/mod.rs index f0e0c5045..aaf19ad69 100644 --- a/rslib/src/storage/deck/mod.rs +++ b/rslib/src/storage/deck/mod.rs @@ -202,6 +202,9 @@ impl SqliteStorage { if let Some(parent_did) = self.get_deck_id(parent_name)? { let parent = self.get_deck(parent_did)?.unwrap(); decks.push(parent); + } else { + // missing parent + break; } } From 40093f813f65ec17bafb2a0fb3c4ab53a3905bff Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Thu, 4 Mar 2021 11:08:11 +1000 Subject: [PATCH 033/117] update translations --- repos.bzl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/repos.bzl b/repos.bzl index f586863e9..a6f28533e 100644 --- a/repos.bzl +++ b/repos.bzl @@ -132,8 +132,8 @@ def register_repos(): ################ core_i18n_repo = "anki-core-i18n" - core_i18n_commit = "a68fea4ca0a361cf98bf8986a48dc161585933c3" - core_i18n_zip_csum = "97580f3ea5e8a45235b45e75e07eb9ba9d7c79a3b00731870156cc65ad6f914c" + core_i18n_commit = "14a08142ab9c545685219d826b38ab92c503f9c3" + core_i18n_zip_csum = "b5f7a96e72f9f384261e282b402bce5433f64d6126b08029e381252602fa7aa4" qtftl_i18n_repo = "anki-desktop-ftl" qtftl_i18n_commit = "567e9c80e9741f57a42e1306dc40888ab00939e6" From 59ec48585246a230006a36d99e17048ffb6f25af Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Thu, 4 Mar 2021 11:32:18 +1000 Subject: [PATCH 034/117] handle duplicate keys in schema 11 deckconf --- rslib/src/deckconf/schema11.rs | 24 ++++++++++++++++++++++-- rslib/src/storage/deckconf/mod.rs | 22 +++++++++++++++------- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/rslib/src/deckconf/schema11.rs b/rslib/src/deckconf/schema11.rs index 1c56fcb90..7ed5fedab 100644 --- a/rslib/src/deckconf/schema11.rs +++ b/rslib/src/deckconf/schema11.rs @@ -34,7 +34,8 @@ pub struct DeckConfSchema11 { dynamic: bool, // 2021 scheduler options: these were not in schema 11, but we need to persist them - // so the settings are not lost on upgrade/downgrade + // so the settings are not lost on upgrade/downgrade. + // NOTE: if adding new ones, make sure to update clear_other_duplicates() #[serde(default)] new_mix: i32, #[serde(default)] @@ -278,7 +279,7 @@ impl From for DeckConf { } } -// schema 15 -> schema 11 +// latest schema -> schema 11 impl From for DeckConfSchema11 { fn from(c: DeckConf) -> DeckConfSchema11 { // split extra json up @@ -290,6 +291,7 @@ impl From for DeckConfSchema11 { top_other = Default::default(); } else { top_other = serde_json::from_slice(&c.inner.other).unwrap_or_default(); + clear_other_duplicates(&mut top_other); if let Some(new) = top_other.remove("new") { let val: HashMap = serde_json::from_value(new).unwrap_or_default(); new_other = val; @@ -359,3 +361,21 @@ impl From for DeckConfSchema11 { } } } + +fn clear_other_duplicates(top_other: &mut HashMap) { + // Older clients may have received keys from a newer client when + // syncing, which get bundled into `other`. If they then upgrade, then + // downgrade their collection to schema11, serde will serialize the + // new default keys, but then add them again from `other`, leading + // to the keys being duplicated in the resulting json - which older + // clients then can't read. So we need to strip out any new keys we + // add. + for key in &[ + "newMix", + "newPerDayMinimum", + "interdayLearningMix", + "reviewOrder", + ] { + top_other.remove(*key); + } +} diff --git a/rslib/src/storage/deckconf/mod.rs b/rslib/src/storage/deckconf/mod.rs index b57917dbf..17facb35d 100644 --- a/rslib/src/storage/deckconf/mod.rs +++ b/rslib/src/storage/deckconf/mod.rs @@ -9,6 +9,7 @@ use crate::{ }; use prost::Message; use rusqlite::{params, Row, NO_PARAMS}; +use serde_json::Value; use std::collections::HashMap; fn row_to_deckconf(row: &Row) -> Result { @@ -139,13 +140,20 @@ impl SqliteStorage { } pub(super) fn upgrade_deck_conf_to_schema14(&self) -> Result<()> { - let conf = self - .db - .query_row_and_then("select dconf from col", NO_PARAMS, |row| { - let conf: Result> = - serde_json::from_str(row.get_raw(0).as_str()?).map_err(Into::into); - conf - })?; + let conf: HashMap = + self.db + .query_row_and_then("select dconf from col", NO_PARAMS, |row| -> Result<_> { + let text = row.get_raw(0).as_str()?; + // try direct parse + serde_json::from_str(text) + .or_else(|_| { + // failed, and could be caused by duplicate keys. Serialize into + // a value first to discard them, then try again + let conf: Value = serde_json::from_str(text)?; + serde_json::from_value(conf) + }) + .map_err(Into::into) + })?; for (_, mut conf) in conf.into_iter() { self.add_deck_conf_schema14(&mut conf)?; } From 3ba08d1189999c38f983288e980f39494c1c0057 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Thu, 4 Mar 2021 11:07:57 +1000 Subject: [PATCH 035/117] more undo strings --- ftl/core/undo.ftl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ftl/core/undo.ftl b/ftl/core/undo.ftl index 7758ede67..ac2a2af65 100644 --- a/ftl/core/undo.ftl +++ b/ftl/core/undo.ftl @@ -4,6 +4,6 @@ undo-undo-action = Undo { $val } undo-action-undone = { $action } Undone undo-redo-action = Redo { $action } undo-action-redone = { $action } Redone - undo-answer-card = Answer Card - +undo-undo = Undo +undo-redo = Redo From a066cee3269b061a8a07ce4ee1d98c9be71b758f Mon Sep 17 00:00:00 2001 From: Benjamin K Date: Thu, 4 Mar 2021 19:39:43 +0100 Subject: [PATCH 036/117] The old delimiter is now kept, when cancel button is clicked --- qt/aqt/importing.py | 53 +++++++++++++++++++++++++++++---------------- 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/qt/aqt/importing.py b/qt/aqt/importing.py index d1cecc330..005143ac9 100644 --- a/qt/aqt/importing.py +++ b/qt/aqt/importing.py @@ -10,6 +10,8 @@ import zipfile from concurrent.futures import Future from typing import Any, Dict, Optional +from qt.aqt.utils import getText + import anki.importing as importing import aqt.deckchooser import aqt.forms @@ -82,6 +84,8 @@ class ChangeMap(QDialog): class ImportDialog(QDialog): + _DEFAULT_FILE_DELIMITER = "\t" + def __init__(self, mw: AnkiQt, importer: Any) -> None: QDialog.__init__(self, mw, Qt.Window) self.mw = mw @@ -122,28 +126,39 @@ class ImportDialog(QDialog): self.showMapping() def onDelimiter(self) -> None: - str = ( - getOnlyText( - tr(TR.IMPORTING_BY_DEFAULT_ANKI_WILL_DETECT_THE), - self, - help=HelpPage.IMPORTING, - ) - or "\t" + + # Open a modal dialog to enter an delimiter + # Todo/Idea Constrain the maximum width, so it doesnt take up that much screen space + delim, ok = getText( + tr(TR.IMPORTING_BY_DEFAULT_ANKI_WILL_DETECT_THE), + self, + help=HelpPage.IMPORTING, ) - str = str.replace("\\t", "\t") - if len(str) > 1: - showWarning( - tr(TR.IMPORTING_MULTICHARACTER_SEPARATORS_ARE_NOT_SUPPORTED_PLEASE) - ) - return - self.hideMapping() - def updateDelim() -> None: - self.importer.delimiter = str - self.importer.updateDelimiter() + # If the modal dialog has been confirmed, update the delimiter + if ok: + # Check if the entered value is valid and if not fallback to default + # at the moment every single character entry as well as '\t' is valid - self.showMapping(hook=updateDelim) - self.updateDelimiterButtonText() + delim = delim if len(delim) > 0 else self._DEFAULT_FILE_DELIMITER + delim = delim.replace("\\t", "\t") # un-escape it + if len(delim) > 1: + showWarning( + tr(TR.IMPORTING_MULTICHARACTER_SEPARATORS_ARE_NOT_SUPPORTED_PLEASE) + ) + return + self.hideMapping() + + def updateDelim() -> None: + self.importer.delimiter = delim + self.importer.updateDelimiter() + self.updateDelimiterButtonText() + + self.showMapping(hook=updateDelim) + + else: + # If the operation has been canceled, do not do anything + pass def updateDelimiterButtonText(self) -> None: if not self.importer.needDelimiter: From 5d880f6e31a354a7061135edefaffa4161ed3758 Mon Sep 17 00:00:00 2001 From: Benjamin K Date: Thu, 4 Mar 2021 19:55:35 +0100 Subject: [PATCH 037/117] Clean up imports --- qt/aqt/importing.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/qt/aqt/importing.py b/qt/aqt/importing.py index 005143ac9..2d00fbcde 100644 --- a/qt/aqt/importing.py +++ b/qt/aqt/importing.py @@ -10,8 +10,6 @@ import zipfile from concurrent.futures import Future from typing import Any, Dict, Optional -from qt.aqt.utils import getText - import anki.importing as importing import aqt.deckchooser import aqt.forms @@ -25,7 +23,7 @@ from aqt.utils import ( askUser, disable_help_button, getFile, - getOnlyText, + getText, openHelp, showInfo, showText, From aa3ba383926eb0c65c7b5c085354a1c66dd9c578 Mon Sep 17 00:00:00 2001 From: Henrik Giesel Date: Sat, 6 Mar 2021 17:57:04 +0100 Subject: [PATCH 038/117] Do not execute caretToEnd when Alt-tabbing back to application --- ts/editor/focusHandlers.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/ts/editor/focusHandlers.ts b/ts/editor/focusHandlers.ts index b6cfb5c7f..abc7095bb 100644 --- a/ts/editor/focusHandlers.ts +++ b/ts/editor/focusHandlers.ts @@ -20,7 +20,6 @@ function focusField(field: EditingArea) { field.focusEditable(); bridgeCommand(`focus:${field.ord}`); enableButtons(); - caretToEnd(field); } // For distinguishing focus by refocusing window from deliberate focus @@ -31,10 +30,14 @@ export function onFocus(evt: FocusEvent): void { const previousFocus = evt.relatedTarget as EditingArea; if ( - previousFocus === previousActiveElement || - !(previousFocus instanceof EditingArea) + !(previousFocus instanceof EditingArea) || + previousFocus === previousActiveElement ) { focusField(currentField); + + if (previousFocus) { + caretToEnd(currentField); + } } } From 0452ed0241169b27ee64a7c209583c18bed6276d Mon Sep 17 00:00:00 2001 From: Benjamin K Date: Sun, 7 Mar 2021 11:05:41 +0100 Subject: [PATCH 039/117] Update CONTRIBUTORS --- CONTRIBUTORS | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTRIBUTORS b/CONTRIBUTORS index 083e82d3e..c443d2878 100644 --- a/CONTRIBUTORS +++ b/CONTRIBUTORS @@ -75,6 +75,7 @@ Meredith Derecho Daniel Wallgren Kerrick Staley Maksim Abramchuk +Benjamin Kulnik ******************** From 79dcfc84befcb5394fb861e1a1e3390e3bf4517c Mon Sep 17 00:00:00 2001 From: abdo Date: Sun, 7 Mar 2021 18:12:42 +0300 Subject: [PATCH 040/117] Fix editor RTL check --- ts/editor/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ts/editor/index.ts b/ts/editor/index.ts index 5861b3da4..8e178d3fa 100644 --- a/ts/editor/index.ts +++ b/ts/editor/index.ts @@ -189,7 +189,9 @@ export class EditingArea extends HTMLDivElement { } isRightToLeft(): boolean { - return this.editable.style.direction === "rtl"; + const styleSheet = this.baseStyle.sheet as CSSStyleSheet; + const firstRule = styleSheet.cssRules[0] as CSSStyleRule; + return firstRule.style.direction === "rtl"; } getSelection(): Selection { From b81e2c0265aeaf6b6af3cd4d55ab215ecd1f904c Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Mon, 8 Mar 2021 10:39:18 +1000 Subject: [PATCH 041/117] Ensure we purge caches when rolling back Fixes #1056 --- pylib/anki/collection.py | 8 ++++++-- rslib/src/backend/dbproxy.rs | 17 +++++++++-------- rslib/src/backend/mod.rs | 2 +- rslib/src/collection.rs | 5 +++++ 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index 914c46809..ee195cfcd 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -245,7 +245,7 @@ class Collection: self.save(trx=False) else: self.db.rollback() - self.models._clear_cache() + self._clear_caches() self._backend.close_collection(downgrade_to_schema11=downgrade) self.db = None self.media.close() @@ -255,15 +255,19 @@ class Collection: # save and cleanup, but backend will take care of collection close if self.db: self.save(trx=False) - self.models._clear_cache() + self._clear_caches() self.db = None self.media.close() self._closeLog() def rollback(self) -> None: + self._clear_caches() self.db.rollback() self.db.begin() + def _clear_caches(self) -> None: + self.models._clear_cache() + def reopen(self, after_full_sync: bool = False) -> None: assert not self.db assert self.path.endswith(".anki2") diff --git a/rslib/src/backend/dbproxy.rs b/rslib/src/backend/dbproxy.rs index beb0318b0..68e8d95d1 100644 --- a/rslib/src/backend/dbproxy.rs +++ b/rslib/src/backend/dbproxy.rs @@ -1,8 +1,8 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -use crate::err::Result; use crate::storage::SqliteStorage; +use crate::{collection::Collection, err::Result}; use rusqlite::types::{FromSql, FromSqlError, ToSql, ToSqlOutput, ValueRef}; use rusqlite::OptionalExtension; use serde_derive::{Deserialize, Serialize}; @@ -67,7 +67,7 @@ impl FromSql for SqlValue { } } -pub(super) fn db_command_bytes(ctx: &SqliteStorage, input: &[u8]) -> Result> { +pub(super) fn db_command_bytes(col: &mut Collection, input: &[u8]) -> Result> { let req: DBRequest = serde_json::from_slice(input)?; let resp = match req { DBRequest::Query { @@ -76,24 +76,25 @@ pub(super) fn db_command_bytes(ctx: &SqliteStorage, input: &[u8]) -> Result { if first_row_only { - db_query_row(ctx, &sql, &args)? + db_query_row(&col.storage, &sql, &args)? } else { - db_query(ctx, &sql, &args)? + db_query(&col.storage, &sql, &args)? } } DBRequest::Begin => { - ctx.begin_trx()?; + col.storage.begin_trx()?; DBResult::None } DBRequest::Commit => { - ctx.commit_trx()?; + col.storage.commit_trx()?; DBResult::None } DBRequest::Rollback => { - ctx.rollback_trx()?; + col.clear_caches(); + col.storage.rollback_trx()?; DBResult::None } - DBRequest::ExecuteMany { sql, args } => db_execute_many(ctx, &sql, &args)?, + DBRequest::ExecuteMany { sql, args } => db_execute_many(&col.storage, &sql, &args)?, }; Ok(serde_json::to_vec(&resp)?) } diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs index 37faf752c..105aa3c7b 100644 --- a/rslib/src/backend/mod.rs +++ b/rslib/src/backend/mod.rs @@ -1851,7 +1851,7 @@ impl Backend { } pub fn db_command(&self, input: &[u8]) -> Result> { - self.with_col(|col| db_command_bytes(&col.storage, input)) + self.with_col(|col| db_command_bytes(col, input)) } pub fn run_db_command_bytes(&self, input: &[u8]) -> std::result::Result, Vec> { diff --git a/rslib/src/collection.rs b/rslib/src/collection.rs index 13b046ce0..433ba2742 100644 --- a/rslib/src/collection.rs +++ b/rslib/src/collection.rs @@ -139,4 +139,9 @@ impl Collection { })?; self.storage.optimize() } + + pub(crate) fn clear_caches(&mut self) { + self.state.deck_cache.clear(); + self.state.notetype_cache.clear(); + } } From 6db78976013c2919142f63dd4fedfa342516aa4d Mon Sep 17 00:00:00 2001 From: Henrik Giesel Date: Mon, 8 Mar 2021 14:20:06 +0100 Subject: [PATCH 042/117] Improve focus handling * Ported from #1046: * disabling buttons will clear button highlight * enabling button will set button highlight * move caret to end executed before enabling buttons (so button highlight will be for actual position of caret) * move caret to end will also be executed if previousActiveElement is null, which will only be the case before the first onBlur was executed: * so that caret will be moved to end on opening editor --- ts/editor/focusHandlers.ts | 14 +++++++++----- ts/editor/toolbar.ts | 39 ++++++++++++++++++++++++++++++-------- 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/ts/editor/focusHandlers.ts b/ts/editor/focusHandlers.ts index abc7095bb..cd0b630f4 100644 --- a/ts/editor/focusHandlers.ts +++ b/ts/editor/focusHandlers.ts @@ -16,9 +16,14 @@ function caretToEnd(currentField: EditingArea): void { selection.addRange(range); } -function focusField(field: EditingArea) { +function focusField(field: EditingArea, moveCaretToEnd: boolean): void { field.focusEditable(); bridgeCommand(`focus:${field.ord}`); + + if (moveCaretToEnd) { + caretToEnd(field); + } + enableButtons(); } @@ -33,11 +38,10 @@ export function onFocus(evt: FocusEvent): void { !(previousFocus instanceof EditingArea) || previousFocus === previousActiveElement ) { - focusField(currentField); + const moveCaretToEnd = + Boolean(previousFocus) || !Boolean(previousActiveElement); - if (previousFocus) { - caretToEnd(currentField); - } + focusField(currentField, moveCaretToEnd); } } diff --git a/ts/editor/toolbar.ts b/ts/editor/toolbar.ts index aa0b7ad5c..a4d87819b 100644 --- a/ts/editor/toolbar.ts +++ b/ts/editor/toolbar.ts @@ -1,9 +1,10 @@ /* Copyright: Ankitects Pty Ltd and contributors * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */ +const highlightButtons = ["bold", "italic", "underline", "superscript", "subscript"]; + export function updateButtonState(): void { - const buts = ["bold", "italic", "underline", "superscript", "subscript"]; - for (const name of buts) { + for (const name of highlightButtons) { const elem = document.querySelector(`#${name}`) as HTMLElement; elem.classList.toggle("highlighted", document.queryCommandState(name)); } @@ -12,6 +13,13 @@ export function updateButtonState(): void { // 'col': document.queryCommandValue("forecolor") } +function clearButtonHighlight(): void { + for (const name of highlightButtons) { + const elem = document.querySelector(`#${name}`) as HTMLElement; + elem.classList.remove("highlighted"); + } +} + export function preventButtonFocus(): void { for (const element of document.querySelectorAll("button.linkb")) { element.addEventListener("mousedown", (evt: Event) => { @@ -20,19 +28,34 @@ export function preventButtonFocus(): void { } } -export function disableButtons(): void { - $("button.linkb:not(.perm)").prop("disabled", true); +export function enableButtons(): void { + const buttons = document.querySelectorAll( + "button.linkb" + ) as NodeListOf; + buttons.forEach((elem: HTMLButtonElement): void => { + elem.disabled = false; + }); + updateButtonState(); } -export function enableButtons(): void { - $("button.linkb").prop("disabled", false); +export function disableButtons(): void { + const buttons = document.querySelectorAll( + "button.linkb:not(.perm)" + ) as NodeListOf; + buttons.forEach((elem: HTMLButtonElement): void => { + elem.disabled = true; + }); + clearButtonHighlight(); } export function setFGButton(col: string): void { document.getElementById("forecolor")!.style.backgroundColor = col; } -export function toggleEditorButton(buttonid: string): void { - const button = $(buttonid)[0]; +export function toggleEditorButton(buttonOrId: string | HTMLElement): void { + const button = + typeof buttonOrId === "string" + ? (document.getElementById(buttonOrId) as HTMLElement) + : buttonOrId; button.classList.toggle("highlighted"); } From 972993b42e9754ad011c4a82de71d7d86d4021ed Mon Sep 17 00:00:00 2001 From: Henrik Giesel Date: Mon, 8 Mar 2021 20:40:23 +0100 Subject: [PATCH 043/117] Move caretToEnd logic out of focus handling --- ts/editor/changeTimer.ts | 6 +----- ts/editor/focusHandlers.ts | 44 ++++++-------------------------------- ts/editor/helpers.ts | 11 ++++++++++ ts/editor/index.ts | 4 +++- ts/editor/inputHandlers.ts | 16 +++++++++++++- 5 files changed, 36 insertions(+), 45 deletions(-) diff --git a/ts/editor/changeTimer.ts b/ts/editor/changeTimer.ts index 5b7ce8153..7a123773b 100644 --- a/ts/editor/changeTimer.ts +++ b/ts/editor/changeTimer.ts @@ -6,16 +6,12 @@ import type { EditingArea } from "."; import { getCurrentField } from "."; import { bridgeCommand } from "./lib"; import { getNoteId } from "./noteId"; -import { updateButtonState } from "./toolbar"; let changeTimer: number | null = null; export function triggerChangeTimer(currentField: EditingArea): void { clearChangeTimer(); - changeTimer = setTimeout(function () { - updateButtonState(); - saveField(currentField, "key"); - }, 600); + changeTimer = setTimeout(() => saveField(currentField, "key"), 600); } function clearChangeTimer(): void { diff --git a/ts/editor/focusHandlers.ts b/ts/editor/focusHandlers.ts index cd0b630f4..1828d957d 100644 --- a/ts/editor/focusHandlers.ts +++ b/ts/editor/focusHandlers.ts @@ -1,55 +1,23 @@ /* Copyright: Ankitects Pty Ltd and contributors * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */ -import { EditingArea } from "."; +import type { EditingArea } from "."; import { bridgeCommand } from "./lib"; import { enableButtons, disableButtons } from "./toolbar"; import { saveField } from "./changeTimer"; -function caretToEnd(currentField: EditingArea): void { - const range = document.createRange(); - range.selectNodeContents(currentField.editable); - range.collapse(false); - const selection = currentField.getSelection(); - selection.removeAllRanges(); - selection.addRange(range); -} - -function focusField(field: EditingArea, moveCaretToEnd: boolean): void { - field.focusEditable(); - bridgeCommand(`focus:${field.ord}`); - - if (moveCaretToEnd) { - caretToEnd(field); - } - - enableButtons(); -} - -// For distinguishing focus by refocusing window from deliberate focus -let previousActiveElement: EditingArea | null = null; - export function onFocus(evt: FocusEvent): void { const currentField = evt.currentTarget as EditingArea; - const previousFocus = evt.relatedTarget as EditingArea; - - if ( - !(previousFocus instanceof EditingArea) || - previousFocus === previousActiveElement - ) { - const moveCaretToEnd = - Boolean(previousFocus) || !Boolean(previousActiveElement); - - focusField(currentField, moveCaretToEnd); - } + currentField.focusEditable(); + bridgeCommand(`focus:${currentField.ord}`); + enableButtons(); } export function onBlur(evt: FocusEvent): void { const previousFocus = evt.currentTarget as EditingArea; + const currentFieldUnchanged = previousFocus === document.activeElement; - saveField(previousFocus, previousFocus === document.activeElement ? "key" : "blur"); - // other widget or window focused; current field unchanged - previousActiveElement = previousFocus; + saveField(previousFocus, currentFieldUnchanged ? "key" : "blur"); disableButtons(); } diff --git a/ts/editor/helpers.ts b/ts/editor/helpers.ts index da7cb5d27..80a9897ce 100644 --- a/ts/editor/helpers.ts +++ b/ts/editor/helpers.ts @@ -1,6 +1,8 @@ /* Copyright: Ankitects Pty Ltd and contributors * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */ +import type { EditingArea } from "."; + export function nodeIsElement(node: Node): node is Element { return node.nodeType === Node.ELEMENT_NODE; } @@ -66,3 +68,12 @@ const INLINE_TAGS = [ export function nodeIsInline(node: Node): boolean { return !nodeIsElement(node) || INLINE_TAGS.includes(node.tagName); } + +export function caretToEnd(currentField: EditingArea): void { + const range = document.createRange(); + range.selectNodeContents(currentField.editable); + range.collapse(false); + const selection = currentField.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); +} diff --git a/ts/editor/index.ts b/ts/editor/index.ts index 5861b3da4..341657026 100644 --- a/ts/editor/index.ts +++ b/ts/editor/index.ts @@ -1,7 +1,7 @@ /* Copyright: Ankitects Pty Ltd and contributors * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */ -import { nodeIsInline } from "./helpers"; +import { nodeIsInline, caretToEnd } from "./helpers"; import { bridgeCommand } from "./lib"; import { saveField } from "./changeTimer"; import { filterHTML } from "./htmlFilter"; @@ -34,6 +34,8 @@ export function focusField(n: number): void { if (field) { field.editingArea.focusEditable(); + caretToEnd(field.editingArea); + updateButtonState(); } } diff --git a/ts/editor/inputHandlers.ts b/ts/editor/inputHandlers.ts index f153d1968..bed09d66c 100644 --- a/ts/editor/inputHandlers.ts +++ b/ts/editor/inputHandlers.ts @@ -2,8 +2,9 @@ * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */ import { EditingArea } from "."; -import { nodeIsElement } from "./helpers"; +import { caretToEnd, nodeIsElement } from "./helpers"; import { triggerChangeTimer } from "./changeTimer"; +import { updateButtonState } from "./toolbar"; function inListItem(currentField: EditingArea): boolean { const anchor = currentField.getSelection()!.anchorNode!; @@ -21,6 +22,7 @@ function inListItem(currentField: EditingArea): boolean { export function onInput(event: Event): void { // make sure IME changes get saved triggerChangeTimer(event.currentTarget as EditingArea); + updateButtonState(); } export function onKey(evt: KeyboardEvent): void { @@ -59,6 +61,18 @@ export function onKey(evt: KeyboardEvent): void { triggerChangeTimer(currentField); } +globalThis.addEventListener("keydown", (evt: KeyboardEvent) => { + if (evt.code === "Tab") { + globalThis.addEventListener("focusin", (evt: FocusEvent) => { + const newFocusTarget = evt.target; + if (newFocusTarget instanceof EditingArea) { + caretToEnd(newFocusTarget); + updateButtonState(); + } + }, { once: true }) + } +}) + export function onKeyUp(evt: KeyboardEvent): void { const currentField = evt.currentTarget as EditingArea; From 76260c3f8d1b3ce590b7cc04c0191903783c5d46 Mon Sep 17 00:00:00 2001 From: Henrik Giesel Date: Mon, 8 Mar 2021 20:55:04 +0100 Subject: [PATCH 044/117] Satisfy formatter --- ts/editor/inputHandlers.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/ts/editor/inputHandlers.ts b/ts/editor/inputHandlers.ts index bed09d66c..052575614 100644 --- a/ts/editor/inputHandlers.ts +++ b/ts/editor/inputHandlers.ts @@ -63,15 +63,19 @@ export function onKey(evt: KeyboardEvent): void { globalThis.addEventListener("keydown", (evt: KeyboardEvent) => { if (evt.code === "Tab") { - globalThis.addEventListener("focusin", (evt: FocusEvent) => { - const newFocusTarget = evt.target; - if (newFocusTarget instanceof EditingArea) { - caretToEnd(newFocusTarget); - updateButtonState(); - } - }, { once: true }) + globalThis.addEventListener( + "focusin", + (evt: FocusEvent) => { + const newFocusTarget = evt.target; + if (newFocusTarget instanceof EditingArea) { + caretToEnd(newFocusTarget); + updateButtonState(); + } + }, + { once: true } + ); } -}) +}); export function onKeyUp(evt: KeyboardEvent): void { const currentField = evt.currentTarget as EditingArea; From 7673a52a02930909edb4f4328c568981642bb791 Mon Sep 17 00:00:00 2001 From: abdo Date: Tue, 9 Mar 2021 03:15:08 +0300 Subject: [PATCH 045/117] Strip HTML comments from external pastes Fix a regression caused by https://github.com/ankitects/anki/commit/150de7a683348198183166ddda5791eb5ff6bc28 --- ts/editor/htmlFilter.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ts/editor/htmlFilter.ts b/ts/editor/htmlFilter.ts index 39a2cad9e..d9fc260f6 100644 --- a/ts/editor/htmlFilter.ts +++ b/ts/editor/htmlFilter.ts @@ -132,13 +132,17 @@ let filterInternalNode = function (elem: Element) { // filtering from external sources let filterNode = function (node: Node, extendedMode: boolean): void { + if (node.nodeType === Node.COMMENT_NODE) { + node.parentNode.removeChild(node); + return; + } if (!nodeIsElement(node)) { return; } // descend first, and take a copy of the child nodes as the loop will skip // elements due to node modifications otherwise - for (const child of [...node.children]) { + for (const child of [...node.childNodes]) { filterNode(child, extendedMode); } From 7ce75479b2b7509bb19a1b9df3e03aed019a7f5f Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Tue, 9 Mar 2021 11:44:49 +1000 Subject: [PATCH 046/117] fix sync download failing when temp dir on different mount https://forums.ankiweb.net/t/problems-with-2-1-41-on-arch-linux/8103 --- rslib/src/backend/http_sync_server.rs | 2 +- rslib/src/sync/http_client.rs | 20 ++++++++++++++------ rslib/src/sync/mod.rs | 5 ++++- rslib/src/sync/server.rs | 12 ++++++++++-- 4 files changed, 29 insertions(+), 10 deletions(-) diff --git a/rslib/src/backend/http_sync_server.rs b/rslib/src/backend/http_sync_server.rs index 971e21ddd..efdc9f3ab 100644 --- a/rslib/src/backend/http_sync_server.rs +++ b/rslib/src/backend/http_sync_server.rs @@ -189,7 +189,7 @@ impl Backend { fn download(&self) -> Result> { let server = Box::new(self.col_into_server()?); let mut rt = Runtime::new().unwrap(); - let file = rt.block_on(server.full_download())?; + let file = rt.block_on(server.full_download(None))?; let path = file.into_temp_path().keep()?; Ok(path.to_str().expect("path was not in utf8").into()) } diff --git a/rslib/src/sync/http_client.rs b/rslib/src/sync/http_client.rs index 5b5faf3a7..bd1b47d9f 100644 --- a/rslib/src/sync/http_client.rs +++ b/rslib/src/sync/http_client.rs @@ -142,11 +142,19 @@ impl SyncServer for HTTPSyncClient { Ok(()) } - /// Download collection into a temporary file, returning it. - /// Caller should persist the file in the correct path after checking it. - /// Progress func must be set first. - async fn full_download(mut self: Box) -> Result { - let mut temp_file = NamedTempFile::new()?; + /// Download collection into a temporary file, returning it. Caller should + /// persist the file in the correct path after checking it. Progress func + /// must be set first. The caller should pass the collection's folder in as + /// the temp folder if it wishes to atomically .persist() it. + async fn full_download( + mut self: Box, + col_folder: Option<&Path>, + ) -> Result { + let mut temp_file = if let Some(folder) = col_folder { + NamedTempFile::new_in(folder) + } else { + NamedTempFile::new() + }?; let (size, mut stream) = self.download_inner().await?; let mut progress = FullSyncProgress { transferred_bytes: 0, @@ -410,7 +418,7 @@ mod test { syncer.set_full_sync_progress_fn(Some(Box::new(|progress, _throttle| { println!("progress: {:?}", progress); }))); - let out_path = syncer.full_download().await?; + let out_path = syncer.full_download(None).await?; let mut syncer = Box::new(HTTPSyncClient::new(None, 0)); syncer.set_full_sync_progress_fn(Some(Box::new(|progress, _throttle| { diff --git a/rslib/src/sync/mod.rs b/rslib/src/sync/mod.rs index 48baa82f0..49a4578fd 100644 --- a/rslib/src/sync/mod.rs +++ b/rslib/src/sync/mod.rs @@ -672,8 +672,11 @@ impl Collection { pub(crate) async fn full_download_inner(self, server: Box) -> Result<()> { let col_path = self.col_path.clone(); + let col_folder = col_path + .parent() + .ok_or_else(|| AnkiError::invalid_input("couldn't get col_folder"))?; self.close(false)?; - let out_file = server.full_download().await?; + let out_file = server.full_download(Some(col_folder)).await?; // check file ok let db = open_and_check_sqlite_file(out_file.path())?; db.execute_batch("update col set ls=mod")?; diff --git a/rslib/src/sync/server.rs b/rslib/src/sync/server.rs index 93822c063..cdb55af6f 100644 --- a/rslib/src/sync/server.rs +++ b/rslib/src/sync/server.rs @@ -36,7 +36,10 @@ pub trait SyncServer { /// If `can_consume` is true, the local server will move or remove the file, instead /// creating a copy. The remote server ignores this argument. async fn full_upload(self: Box, col_path: &Path, can_consume: bool) -> Result<()>; - async fn full_download(self: Box) -> Result; + /// If the calling code intends to .persist() the named temp file to + /// atomically update the collection, it should pass in the collection's + /// folder, as .persist() can't work across filesystems. + async fn full_download(self: Box, temp_folder: Option<&Path>) -> Result; } pub struct LocalServer { @@ -199,7 +202,12 @@ impl SyncServer for LocalServer { fs::rename(col_path, &target_col_path).map_err(Into::into) } - async fn full_download(mut self: Box) -> Result { + /// The provided folder is ignored, as in the server case the local data + /// will be sent over the network, instead of written into a local file. + async fn full_download( + mut self: Box, + _col_folder: Option<&Path>, + ) -> Result { // bump usn/mod & close self.col.transact(None, |col| col.storage.increment_usn())?; let col_path = self.col.col_path.clone(); From ef9da3808cfd5ed6c02bebc3aea6f0f3b9b26844 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Tue, 9 Mar 2021 14:44:20 +1000 Subject: [PATCH 047/117] bump version --- defs.bzl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/defs.bzl b/defs.bzl index 12e2288aa..a06ad2c15 100644 --- a/defs.bzl +++ b/defs.bzl @@ -11,7 +11,7 @@ load("@build_bazel_rules_svelte//:defs.bzl", "rules_svelte_dependencies") load("@com_github_ali5h_rules_pip//:defs.bzl", "pip_import") load("//pip/pyqt5:defs.bzl", "install_pyqt5") -anki_version = "2.1.41" +anki_version = "2.1.42" def setup_deps(): bazel_skylib_workspace() From aeda64a89017c962c59bb5f3aed3773e1188463b Mon Sep 17 00:00:00 2001 From: Henrik Giesel Date: Sat, 27 Feb 2021 04:01:33 +0100 Subject: [PATCH 048/117] Add bootstrap, bootstrap-icons, and popperjs --- qt/aqt/data/web/css/BUILD.bazel | 1 + qt/aqt/data/web/css/vendor/BUILD.bazel | 20 +++++++++++ qt/aqt/data/web/js/vendor/BUILD.bazel | 16 ++++++++- ts/licenses.json | 23 +++++++++++++ ts/package.json | 3 ++ ts/vendor.bzl | 46 ++++++++++++++++++++++++++ ts/yarn.lock | 15 +++++++++ 7 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 qt/aqt/data/web/css/vendor/BUILD.bazel diff --git a/qt/aqt/data/web/css/BUILD.bazel b/qt/aqt/data/web/css/BUILD.bazel index ff0045e68..f27b9d463 100644 --- a/qt/aqt/data/web/css/BUILD.bazel +++ b/qt/aqt/data/web/css/BUILD.bazel @@ -32,6 +32,7 @@ filegroup( "core.css", "css_local", "editor", + "//qt/aqt/data/web/css/vendor", ], visibility = ["//qt:__subpackages__"], ) diff --git a/qt/aqt/data/web/css/vendor/BUILD.bazel b/qt/aqt/data/web/css/vendor/BUILD.bazel new file mode 100644 index 000000000..23b604f63 --- /dev/null +++ b/qt/aqt/data/web/css/vendor/BUILD.bazel @@ -0,0 +1,20 @@ +load("//ts:vendor.bzl", "copy_bootstrap_css", "copy_bootstrap_icons") + +copy_bootstrap_css(name = "bootstrap") + +copy_bootstrap_icons(name = "bootstrap-icons") + +files = [ + "bootstrap", + "bootstrap-icons", +] + +directories = [] + +filegroup( + name = "vendor", + srcs = glob(["*.css"]) + + ["//qt/aqt/data/web/css/vendor:{}".format(file) for file in files] + + ["//qt/aqt/data/web/css/vendor/{}".format(dir) for dir in directories], + visibility = ["//qt:__subpackages__"], +) diff --git a/qt/aqt/data/web/js/vendor/BUILD.bazel b/qt/aqt/data/web/js/vendor/BUILD.bazel index f12e3be27..45a0b1005 100644 --- a/qt/aqt/data/web/js/vendor/BUILD.bazel +++ b/qt/aqt/data/web/js/vendor/BUILD.bazel @@ -1,4 +1,12 @@ -load("//ts:vendor.bzl", "copy_css_browser_selector", "copy_jquery", "copy_jquery_ui", "copy_protobufjs") +load( + "//ts:vendor.bzl", + "copy_css_browser_selector", + "copy_jquery", + "copy_jquery_ui", + "copy_protobufjs", + "copy_bootstrap_js", + "copy_popperjs", +) copy_jquery(name = "jquery") @@ -8,11 +16,17 @@ copy_protobufjs(name = "protobufjs") copy_css_browser_selector(name = "css-browser-selector") +copy_bootstrap_js(name = "bootstrap") + +copy_popperjs(name = "popperjs") + files = [ "jquery", "jquery-ui", "protobufjs", "css-browser-selector", + "bootstrap", + "popperjs", ] directories = [ diff --git a/ts/licenses.json b/ts/licenses.json index c6dcc95dc..22c188171 100644 --- a/ts/licenses.json +++ b/ts/licenses.json @@ -7,6 +7,14 @@ "path": "node_modules/@fluent/bundle", "licenseFile": "node_modules/@fluent/bundle/README.md" }, + "@popperjs/core@2.8.6": { + "licenses": "MIT", + "repository": "https://github.com/popperjs/popper-core", + "publisher": "Federico Zivolo", + "email": "federico.zivolo@gmail.com", + "path": "node_modules/@popperjs/core", + "licenseFile": "node_modules/@popperjs/core/LICENSE.md" + }, "@protobufjs/aspromise@1.1.2": { "licenses": "BSD-3-Clause", "repository": "https://github.com/dcodeIO/protobuf.js", @@ -99,6 +107,21 @@ "path": "node_modules/protobufjs/node_modules/@types/node", "licenseFile": "node_modules/protobufjs/node_modules/@types/node/LICENSE" }, + "bootstrap-icons@1.4.0": { + "licenses": "MIT", + "repository": "https://github.com/twbs/icons", + "publisher": "mdo", + "path": "node_modules/bootstrap-icons", + "licenseFile": "node_modules/bootstrap-icons/LICENSE.md" + }, + "bootstrap@5.0.0-beta2": { + "licenses": "MIT", + "repository": "https://github.com/twbs/bootstrap", + "publisher": "The Bootstrap Authors", + "url": "https://github.com/twbs/bootstrap/graphs/contributors", + "path": "node_modules/bootstrap", + "licenseFile": "node_modules/bootstrap/LICENSE" + }, "commander@2.20.3": { "licenses": "MIT", "repository": "https://github.com/tj/commander.js", diff --git a/ts/package.json b/ts/package.json index 84dbfc85c..c6fd8c300 100644 --- a/ts/package.json +++ b/ts/package.json @@ -50,6 +50,9 @@ }, "dependencies": { "@fluent/bundle": "^0.15.1", + "@popperjs/core": "^2.8.6", + "bootstrap": "^5.0.0-beta2", + "bootstrap-icons": "^1.4.0", "css-browser-selector": "^0.6.5", "d3": "^6.5.0", "intl-pluralrules": "^1.2.2", diff --git a/ts/vendor.bzl b/ts/vendor.bzl index c1cbb5bb6..cfe5b758f 100644 --- a/ts/vendor.bzl +++ b/ts/vendor.bzl @@ -94,3 +94,49 @@ def copy_css_browser_selector(name = "css-browser-selector", visibility = ["//vi ], visibility = visibility, ) + +def copy_bootstrap_js(name = "bootstrap-js", visibility = ["//visibility:public"]): + vendor_js_lib( + name = name, + pkg = _pkg_from_name(name), + include = [ + "dist/js/bootstrap.bundle.min.js", + ], + strip_prefix = "dist/js/", + visibility = visibility, + ) + +def copy_bootstrap_css(name = "bootstrap-css", visibility = ["//visibility:public"]): + vendor_js_lib( + name = name, + pkg = _pkg_from_name(name), + include = [ + "dist/css/bootstrap.min.css", + ], + strip_prefix = "dist/css/", + visibility = visibility, + ) + +def copy_bootstrap_icons(name = "bootstrap-icons", visibility = ["//visibility:public"]): + vendor_js_lib( + name = name, + pkg = _pkg_from_name(name), + include = [ + "font/bootstrap-icons.css", + "font/fonts/bootstrap-icons.woff", + "font/fonts/bootstrap-icons.woff2", + ], + strip_prefix = "font/", + visibility = visibility, + ) + +def copy_popperjs(name = "popperjs", visibility = ["//visibility:public"]): + vendor_js_lib( + name = name, + pkg = "@npm//@popperjs/core:core__files", + include = [ + "external/npm/node_modules/@popperjs/core/dist/umd/popper.min.js", + ], + strip_prefix = "external/npm/node_modules/@popperjs/core/dist/umd/", + visibility = visibility, + ) diff --git a/ts/yarn.lock b/ts/yarn.lock index f73eb1287..cd2cdde78 100644 --- a/ts/yarn.lock +++ b/ts/yarn.lock @@ -53,6 +53,11 @@ resolved "https://registry.yarnpkg.com/@fluent/bundle/-/bundle-0.15.1.tgz#95d3b9f836ac138b6ee8480ef8d0547dd59195b1" integrity sha512-uhDGjpEwTMBNxYMSXyjXFBG5LY7dqoNatle6mnghu5lFOrf0JyblY/Y0al2GzDKFuYbtOSbJvUkxzjtYX3odkw== +"@popperjs/core@^2.8.6": + version "2.8.6" + resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.8.6.tgz#ad75ebe8dbecfa145af3c7e4d0ae98016458d005" + integrity sha512-1oXH2bAFXz9SttE1v/0Jp+2ZVePsPEAPGIuPKrmljWZcS3FPBEn2Q4WcANozZC0YiCjTWOF55k0g6rbSZS39ew== + "@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf" @@ -699,6 +704,16 @@ bluebird@^3.7.2: resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== +bootstrap-icons@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/bootstrap-icons/-/bootstrap-icons-1.4.0.tgz#ea08e2c8bc1535576ad267312cca9ee84ea73343" + integrity sha512-EynaOv/G/X/sQgPUqkdLJoxPrWk73wwsVjVR3cDNYO0jMS58poq7DOC2CraBWlBt1AberEmt0blfw4ony2/ZIg== + +bootstrap@^5.0.0-beta2: + version "5.0.0-beta2" + resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-5.0.0-beta2.tgz#ab1504a12807fa58e5e41408e35fcea42461e84b" + integrity sha512-e+uPbPHqTQWKyCX435uVlOmgH9tUt0xtjvyOC7knhKgOS643BrQKuTo+KecGpPV7qlmOyZgCfaM4xxPWtDEN/g== + brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" From f2cc85a3bc1937365d2a605a943c690410ddb942 Mon Sep 17 00:00:00 2001 From: Henrik Giesel Date: Sat, 27 Feb 2021 16:34:22 +0100 Subject: [PATCH 049/117] Include bootstrap in editor --- qt/aqt/editor.py | 11 +++++++++-- ts/editor/editor.scss | 4 ++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/qt/aqt/editor.py b/qt/aqt/editor.py index f699cef44..4bde86c78 100644 --- a/qt/aqt/editor.py +++ b/qt/aqt/editor.py @@ -222,8 +222,15 @@ class Editor: # then load page self.web.stdHtml( _html % (bgcol, topbuts, tr(TR.EDITING_SHOW_DUPLICATES)), - css=["css/editor.css"], - js=["js/vendor/jquery.min.js", "js/editor.js"], + css=[ + "css/vendor/bootstrap.min.css", + "css/editor.css", + ], + js=[ + "js/vendor/jquery.min.js", + "js/vendor/bootstrap.bundle.min.js", + "js/editor.js", + ], context=self, ) self.web.eval("preventButtonFocus();") diff --git a/ts/editor/editor.scss b/ts/editor/editor.scss index 30b0af96b..42b84cf27 100644 --- a/ts/editor/editor.scss +++ b/ts/editor/editor.scss @@ -1,8 +1,8 @@ /* Copyright: Ankitects Pty Ltd and contributors * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */ -html { - background: var(--bg-color); +body { + background-color: var(--bg-color); } #fields { From 037539dbf4c020ffa4b70213d1a254338bc5c7f1 Mon Sep 17 00:00:00 2001 From: Henrik Giesel Date: Sat, 27 Feb 2021 16:41:58 +0100 Subject: [PATCH 050/117] Remove field margins * Bootstrap solves it more elegantly with line-height * body { margin: 0 } is also defined by bootstrap --- ts/editor/editor.scss | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/ts/editor/editor.scss b/ts/editor/editor.scss index 42b84cf27..14ae103a0 100644 --- a/ts/editor/editor.scss +++ b/ts/editor/editor.scss @@ -9,19 +9,6 @@ body { display: flex; flex-direction: column; margin: 5px; - - & > *, - & > * > * { - margin: 1px 0; - - &:first-child { - margin-top: 0; - } - - &:last-child { - margin-bottom: 0; - } - } } .field { @@ -38,10 +25,6 @@ body { padding: 0; } -body { - margin: 0; -} - #topbutsOuter { display: flex; flex-wrap: wrap; From 81d1f2906a3cbe0bfa17eb1c1c56649a94d083ce Mon Sep 17 00:00:00 2001 From: Henrik Giesel Date: Sat, 27 Feb 2021 16:49:47 +0100 Subject: [PATCH 051/117] Fix highlight for topright buttons --- ts/editor/editor.scss | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ts/editor/editor.scss b/ts/editor/editor.scss index 14ae103a0..59f57f26c 100644 --- a/ts/editor/editor.scss +++ b/ts/editor/editor.scss @@ -105,10 +105,11 @@ button.highlighted { #topbutsright & { border-bottom: 3px solid black; - } + border-radius: 3px; - .nightMode #topbutsright & { - border-bottom: 3px solid white; + .nightMode & { + border-bottom-color: white; + } } } From a66b0fbd84ad30f322cc2978f74974d310008b6c Mon Sep 17 00:00:00 2001 From: Henrik Giesel Date: Sat, 27 Feb 2021 17:22:55 +0100 Subject: [PATCH 052/117] Fix positioning of buttons --- qt/aqt/editor.py | 13 +++++-------- ts/editor/editor.scss | 6 ++++++ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/qt/aqt/editor.py b/qt/aqt/editor.py index 4bde86c78..afdd62e6c 100644 --- a/qt/aqt/editor.py +++ b/qt/aqt/editor.py @@ -179,19 +179,16 @@ class Editor: "colour", tr(TR.EDITING_SET_FOREGROUND_COLOUR_F7), """ -
""", + +""" ), self._addButton( None, "changeCol", tr(TR.EDITING_CHANGE_COLOUR_F8), """ -
""", + +""" ), self._addButton( "text_cloze", "cloze", tr(TR.EDITING_CLOZE_DELETION_CTRLANDSHIFTANDC) @@ -321,7 +318,7 @@ class Editor: else: imgelm = "" if label or not imgelm: - labelelm = f"""{label or cmd}""" + labelelm = label or cmd else: labelelm = "" if id: diff --git a/ts/editor/editor.scss b/ts/editor/editor.scss index 59f57f26c..1569cf29a 100644 --- a/ts/editor/editor.scss +++ b/ts/editor/editor.scss @@ -56,9 +56,15 @@ body { } .topbut { + display: inline-block; width: 16px; height: 16px; margin-top: 4px; + vertical-align: -.125em; +} + +.topbut--rounded { + border-radius: 5px; } .rainbow { From fb5763afb6e7228587a34b1742e706fabc7302b3 Mon Sep 17 00:00:00 2001 From: Henrik Giesel Date: Sat, 27 Feb 2021 17:25:40 +0100 Subject: [PATCH 053/117] Change font-size in topbar buttons Bootstrap sets font-size to inherit, so it's not inherited from top. Before, it defaulted to 13, so this should prevent add-ons, which feature text in their buttons (like