From f3e81c8a950495cc6855728ea432cf944d14971f Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Thu, 10 Mar 2022 16:06:55 +1000 Subject: [PATCH] Move custom study tag and limit gathering+saving into the backend Ideally this would have been in beta 6 :-) No add-ons appear to be using customstudy.py/taglimit.py though, so it should hopefully not be disruptive. In the earlier custom study changes, we didn't get around to addressing issue #1136. Now instead of trying to determine the maximum increase to allow (which doesn't work correctly with nested decks), we just present the total available to the user again, and let them decide. There's plenty of room for improvement here still, but further work here might be better done once we look into decoupling deck limits from deck presets. Tags and available cards are fetched prior to showing the dialog now, and will show a progress dialog if things take a while. Tags are stored in an aux var now, so they don't inflate the deck object size. --- ftl/core/custom-study.ftl | 9 +- proto/anki/scheduler.proto | 33 ++- pylib/anki/scheduler/base.py | 17 +- pylib/anki/scheduler/legacy.py | 18 +- pylib/anki/scheduler/v2.py | 2 + pylib/anki/scheduler/v3.py | 4 +- pylib/anki/tags.py | 27 +-- qt/aqt/customstudy.py | 171 ++++++++-------- qt/aqt/overview.py | 2 +- qt/aqt/taglimit.py | 132 +++++------- rslib/src/backend/scheduler/mod.rs | 7 + rslib/src/config/deck.rs | 6 +- rslib/src/config/mod.rs | 8 +- rslib/src/decks/tree.rs | 29 ++- rslib/src/ops.rs | 5 +- rslib/src/scheduler/filtered/custom_study.rs | 199 +++++++++++++++++-- rslib/src/search/mod.rs | 28 ++- rslib/src/storage/note/mod.rs | 16 +- rslib/src/tags/mod.rs | 1 + rslib/src/tags/notes.rs | 25 +++ 20 files changed, 513 insertions(+), 226 deletions(-) create mode 100644 rslib/src/tags/notes.rs diff --git a/ftl/core/custom-study.ftl b/ftl/core/custom-study.ftl index 7355ae166..254338546 100644 --- a/ftl/core/custom-study.ftl +++ b/ftl/core/custom-study.ftl @@ -10,7 +10,6 @@ custom-study-increase-todays-new-card-limit = Increase today's new card limit custom-study-increase-todays-new-card-limit-by = Increase today's new card limit by custom-study-increase-todays-review-card-limit = Increase today's review card limit custom-study-increase-todays-review-limit-by = Increase today's review limit by -custom-study-new-cards-in-deck-over-today = New cards in deck over today limit: { $val } custom-study-new-cards-only = New cards only custom-study-no-cards-matched-the-criteria-you = No cards matched the criteria you provided. custom-study-ok = OK @@ -21,8 +20,14 @@ custom-study-review-ahead = Review ahead custom-study-review-ahead-by = Review ahead by custom-study-review-cards-forgotten-in-last = Review cards forgotten in last custom-study-review-forgotten-cards = Review forgotten cards -custom-study-reviews-due-in-deck-over-today = Reviews due in deck over today limit: { $val } custom-study-select = Select custom-study-select-tags-to-exclude = Select tags to exclude: custom-study-selective-study = Selective Study custom-study-study-by-card-state-or-tag = Study by card state or tag +custom-study-available-new-cards = Available new cards: { $count } +custom-study-available-review-cards = Available review cards: { $count } + +## DEPRECATED - you do not need to translate these. + +custom-study-new-cards-in-deck-over-today = New cards in deck over today limit: { $val } +custom-study-reviews-due-in-deck-over-today = Reviews due in deck over today limit: { $val } diff --git a/proto/anki/scheduler.proto b/proto/anki/scheduler.proto index ce6860d9f..c8bbf419f 100644 --- a/proto/anki/scheduler.proto +++ b/proto/anki/scheduler.proto @@ -40,6 +40,8 @@ service SchedulerService { rpc StateIsLeech(SchedulingState) returns (generic.Bool); rpc UpgradeScheduler(generic.Empty) returns (generic.Empty); rpc CustomStudy(CustomStudyRequest) returns (collection.OpChanges); + rpc CustomStudyDefaults(CustomStudyDefaultsRequest) + returns (CustomStudyDefaultsResponse); } message SchedulingState { @@ -257,17 +259,36 @@ message CustomStudyRequest { // cards must not match any of these repeated string tags_to_exclude = 4; } + int64 deck_id = 1; oneof value { // increase new limit by x - int32 new_limit_delta = 1; + int32 new_limit_delta = 2; // increase review limit by x - int32 review_limit_delta = 2; + int32 review_limit_delta = 3; // repeat cards forgotten in the last x days - uint32 forgot_days = 3; + uint32 forgot_days = 4; // review cards due in the next x days - uint32 review_ahead_days = 4; + uint32 review_ahead_days = 5; // preview new cards added in the last x days - uint32 preview_days = 5; - Cram cram = 6; + uint32 preview_days = 6; + Cram cram = 7; } } + +message CustomStudyDefaultsRequest { + int64 deck_id = 1; +} + +message CustomStudyDefaultsResponse { + message Tag { + string name = 1; + bool include = 2; + bool exclude = 3; + } + + repeated Tag tags = 1; + uint32 extend_new = 2; + uint32 extend_review = 3; + uint32 available_new = 4; + uint32 available_review = 5; +} diff --git a/pylib/anki/scheduler/base.py b/pylib/anki/scheduler/base.py index 6f3c4f6c6..a5d63f829 100644 --- a/pylib/anki/scheduler/base.py +++ b/pylib/anki/scheduler/base.py @@ -15,6 +15,7 @@ CongratsInfo = scheduler_pb2.CongratsInfoResponse UnburyDeck = scheduler_pb2.UnburyDeckRequest BuryOrSuspend = scheduler_pb2.BuryOrSuspendCardsRequest CustomStudyRequest = scheduler_pb2.CustomStudyRequest +CustomStudyDefaults = scheduler_pb2.CustomStudyDefaultsResponse ScheduleCardsAsNew = scheduler_pb2.ScheduleCardsAsNewRequest ScheduleCardsAsNewDefaults = scheduler_pb2.ScheduleCardsAsNewDefaultsResponse FilteredDeckForUpdate = decks_pb2.FilteredDeckForUpdate @@ -24,7 +25,7 @@ from typing import Sequence from anki import config_pb2 from anki.cards import CardId -from anki.consts import CARD_TYPE_NEW, NEW_CARDS_RANDOM, QUEUE_TYPE_NEW, QUEUE_TYPE_REV +from anki.consts import CARD_TYPE_NEW, NEW_CARDS_RANDOM, QUEUE_TYPE_NEW from anki.decks import DeckConfigDict, DeckId, DeckTreeNode from anki.notes import NoteId from anki.utils import ids2str, int_time @@ -78,21 +79,13 @@ class SchedulerBase(DeprecatedNamesMixin): def custom_study(self, request: CustomStudyRequest) -> OpChanges: return self.col._backend.custom_study(request) + def custom_study_defaults(self, deck_id: DeckId) -> CustomStudyDefaults: + return self.col._backend.custom_study_defaults(deck_id=deck_id) + def extend_limits(self, new: int, rev: int) -> None: did = self.col.decks.current()["id"] self.col._backend.extend_limits(deck_id=did, new_delta=new, review_delta=rev) - # fixme: used by custom study - def total_rev_for_current_deck(self) -> int: - assert self.col.db - 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 9999)""" - % self._deck_limit(), - self.today, - ) - # fixme: only used by total_rev_for_current_deck and old deck stats; # schedv2 defines separate version def _deck_limit(self) -> str: diff --git a/pylib/anki/scheduler/legacy.py b/pylib/anki/scheduler/legacy.py index 99088ddb6..8bfee1bd7 100644 --- a/pylib/anki/scheduler/legacy.py +++ b/pylib/anki/scheduler/legacy.py @@ -5,8 +5,13 @@ from typing import Optional +from anki._legacy import deprecated from anki.cards import Card, CardId -from anki.consts import CARD_TYPE_RELEARNING, QUEUE_TYPE_DAY_LEARN_RELEARN +from anki.consts import ( + CARD_TYPE_RELEARNING, + QUEUE_TYPE_DAY_LEARN_RELEARN, + QUEUE_TYPE_REV, +) from anki.decks import DeckConfigDict, DeckId from anki.notes import NoteId from anki.scheduler.base import SchedulerBase, UnburyDeck @@ -111,6 +116,17 @@ 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] + @deprecated(info="no longer used by Anki; will be removed in the future") + def total_rev_for_current_deck(self) -> int: + assert self.col.db + 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 9999)""" + % self._deck_limit(), + self.today, + ) + # legacy in v3 but used by unit tests; redefined in v2/v1 def _cardConf(self, card: Card) -> DeckConfigDict: diff --git a/pylib/anki/scheduler/v2.py b/pylib/anki/scheduler/v2.py index 5918e2f17..58f0a05cb 100644 --- a/pylib/anki/scheduler/v2.py +++ b/pylib/anki/scheduler/v2.py @@ -13,6 +13,7 @@ from typing import Any, Callable, cast import anki # pylint: disable=unused-import import anki.collection from anki import hooks, scheduler_pb2 +from anki._legacy import deprecated from anki.cards import Card, CardId from anki.consts import * from anki.decks import DeckConfigDict, DeckDict, DeckId @@ -254,6 +255,7 @@ select count() from limit = max(0, c["new"]["perDay"] - self.counts_for_deck_today(g["id"]).new) return hooks.scheduler_new_limit_for_single_deck(limit, g) + @deprecated(info="no longer used by Anki; will be removed in the future") def totalNewForCurrentDeck(self) -> int: return self.col.db.scalar( f""" diff --git a/pylib/anki/scheduler/v3.py b/pylib/anki/scheduler/v3.py index af4fcaed8..10b588562 100644 --- a/pylib/anki/scheduler/v3.py +++ b/pylib/anki/scheduler/v3.py @@ -17,6 +17,7 @@ from __future__ import annotations from typing import Literal, Optional, Sequence from anki import scheduler_pb2 +from anki._legacy import deprecated from anki.cards import Card from anki.collection import OpChanges from anki.consts import * @@ -239,8 +240,7 @@ class Scheduler(SchedulerBaseWithLegacy): except DBError: return [] - # used by custom study; will likely be rolled into a separate routine - # in the future + @deprecated(info="no longer used by Anki; will be removed in the future") def totalNewForCurrentDeck(self) -> int: return self.col.db.scalar( f""" diff --git a/pylib/anki/tags.py b/pylib/anki/tags.py index 0e894ec75..3f75ca793 100644 --- a/pylib/anki/tags.py +++ b/pylib/anki/tags.py @@ -52,19 +52,6 @@ class TagManager(DeprecatedNamesMixin): def clear_unused_tags(self) -> OpChangesWithCount: return self.col._backend.clear_unused_tags() - def by_deck(self, did: DeckId, children: bool = False) -> list[str]: - basequery = "select n.tags from cards c, notes n WHERE c.nid = n.id" - if not children: - query = f"{basequery} AND c.did=?" - res = self.col.db.list(query, did) - return list(set(self.split(" ".join(res)))) - dids = [did] - for name, id in self.col.decks.children(did): - dids.append(id) - query = f"{basequery} AND c.did IN {ids2str(dids)}" - res = self.col.db.list(query) - return list(set(self.split(" ".join(res)))) - def set_collapsed(self, tag: str, collapsed: bool) -> OpChanges: "Set browser expansion state for tag, registering the tag if missing." return self.col._backend.set_tag_collapsed(name=tag, collapsed=collapsed) @@ -181,6 +168,20 @@ class TagManager(DeprecatedNamesMixin): def _legacy_bulk_rem(self, ids: list[NoteId], tags: str) -> None: self._legacy_bulk_add(ids, tags, False) + @deprecated(info="no longer used by Anki, and will be removed in the future") + def by_deck(self, did: DeckId, children: bool = False) -> list[str]: + basequery = "select n.tags from cards c, notes n WHERE c.nid = n.id" + if not children: + query = f"{basequery} AND c.did=?" + res = self.col.db.list(query, did) + return list(set(self.split(" ".join(res)))) + dids = [did] + for name, id in self.col.decks.children(did): + dids.append(id) + query = f"{basequery} AND c.did IN {ids2str(dids)}" + res = self.col.db.list(query) + return list(set(self.split(" ".join(res)))) + TagManager.register_deprecated_attributes( registerNotes=(TagManager._legacy_register_notes, TagManager.clear_unused_tags), diff --git a/qt/aqt/customstudy.py b/qt/aqt/customstudy.py index 315dbab30..1c43ca69d 100644 --- a/qt/aqt/customstudy.py +++ b/qt/aqt/customstudy.py @@ -1,11 +1,17 @@ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +from typing import Tuple + import aqt import aqt.forms import aqt.operations +from anki.collection import Collection from anki.consts import * +from anki.decks import DeckId from anki.scheduler import CustomStudyRequest +from anki.scheduler.base import CustomStudyDefaults +from aqt.operations import QueryOp from aqt.operations.scheduling import custom_study from aqt.qt import * from aqt.taglimit import TagLimit @@ -25,17 +31,39 @@ TYPE_ALL = 3 class CustomStudy(QDialog): - def __init__(self, mw: aqt.AnkiQt) -> None: + @staticmethod + def fetch_data_and_show(mw: aqt.AnkiQt) -> None: + def fetch_data( + col: Collection, + ) -> Tuple[DeckId, CustomStudyDefaults]: + deck_id = mw.col.decks.get_current_id() + defaults = col.sched.custom_study_defaults(deck_id) + return (deck_id, defaults) + + def show_dialog(data: Tuple[DeckId, CustomStudyDefaults]) -> None: + deck_id, defaults = data + CustomStudy(mw=mw, deck_id=deck_id, defaults=defaults) + + QueryOp( + parent=mw, op=fetch_data, success=show_dialog + ).with_progress().run_in_background() + + def __init__( + self, + mw: aqt.AnkiQt, + deck_id: DeckId, + defaults: CustomStudyDefaults, + ) -> None: + "Don't call this directly; use CustomStudy.fetch_data_and_show()." QDialog.__init__(self, mw) self.mw = mw - self.deck = self.mw.col.decks.current() - self.conf = self.mw.col.decks.get_config(self.deck["conf"]) - self.form = f = aqt.forms.customstudy.Ui_Dialog() - self.created_custom_study = False - f.setupUi(self) + self.deck_id = deck_id + self.defaults = defaults + self.form = aqt.forms.customstudy.Ui_Dialog() + self.form.setupUi(self) disable_help_button(self) self.setupSignals() - f.radioNew.click() + self.form.radioNew.click() self.open() def setupSignals(self) -> None: @@ -48,82 +76,65 @@ class CustomStudy(QDialog): qconnect(f.radioCram.clicked, lambda: self.onRadioChange(RADIO_CRAM)) def onRadioChange(self, idx: int) -> None: - f = self.form - sp = f.spin - smin = 1 - smax = DYN_MAX_SIZE - sval = 1 - post = tr.custom_study_cards() - tit = "" - spShow = True - typeShow = False + form = self.form + min_spinner_value = 1 + max_spinner_value = DYN_MAX_SIZE + current_spinner_value = 1 + text_after_spinner = tr.custom_study_cards() + title_text = "" + show_cram_type = False ok = tr.custom_study_ok() - def plus(num: Union[int, str]) -> str: - if num == 1000: - num = "1000+" - return f"{str(num)}" - if idx == RADIO_NEW: - new = self.mw.col.sched.totalNewForCurrentDeck() - # get the number of new cards in deck that exceed the new cards limit - newUnderLearning = min( - new, self.conf["new"]["perDay"] - self.deck["newToday"][1] + title_text = tr.custom_study_available_new_cards( + count=self.defaults.available_new ) - newExceeding = min(new, new - newUnderLearning) - tit = tr.custom_study_new_cards_in_deck_over_today(val=plus(newExceeding)) - pre = tr.custom_study_increase_todays_new_card_limit_by() - sval = min(new, self.deck.get("extendNew", 10)) - smin = -DYN_MAX_SIZE - smax = newExceeding + text_before_spinner = tr.custom_study_increase_todays_new_card_limit_by() + current_spinner_value = self.defaults.extend_new + min_spinner_value = -DYN_MAX_SIZE elif idx == RADIO_REV: - rev = self.mw.col.sched.total_rev_for_current_deck() - # get the number of review due in deck that exceed the review due limit - revUnderLearning = min( - rev, self.conf["rev"]["perDay"] - self.deck["revToday"][1] + title_text = tr.custom_study_available_review_cards( + count=self.defaults.available_review ) - revExceeding = min(rev, rev - revUnderLearning) - tit = tr.custom_study_reviews_due_in_deck_over_today(val=plus(revExceeding)) - pre = tr.custom_study_increase_todays_review_limit_by() - sval = min(rev, self.deck.get("extendRev", 10)) - smin = -DYN_MAX_SIZE - smax = revExceeding + text_before_spinner = tr.custom_study_increase_todays_review_limit_by() + current_spinner_value = self.defaults.extend_review + min_spinner_value = -DYN_MAX_SIZE elif idx == RADIO_FORGOT: - pre = tr.custom_study_review_cards_forgotten_in_last() - post = tr.scheduling_days() - smax = 30 + text_before_spinner = tr.custom_study_review_cards_forgotten_in_last() + text_after_spinner = tr.scheduling_days() + max_spinner_value = 30 elif idx == RADIO_AHEAD: - pre = tr.custom_study_review_ahead_by() - post = tr.scheduling_days() + text_before_spinner = tr.custom_study_review_ahead_by() + text_after_spinner = tr.scheduling_days() elif idx == RADIO_PREVIEW: - pre = tr.custom_study_preview_new_cards_added_in_the() - post = tr.scheduling_days() - sval = 1 + text_before_spinner = tr.custom_study_preview_new_cards_added_in_the() + text_after_spinner = tr.scheduling_days() + current_spinner_value = 1 elif idx == RADIO_CRAM: - pre = tr.custom_study_select() - post = tr.custom_study_cards_from_the_deck() - # tit = _("After pressing OK, you can choose which tags to include.") + text_before_spinner = tr.custom_study_select() + text_after_spinner = tr.custom_study_cards_from_the_deck() ok = tr.custom_study_choose_tags() - sval = 100 - typeShow = True - sp.setVisible(spShow) - f.cardType.setVisible(typeShow) - f.title.setText(tit) - f.title.setVisible(not not tit) - f.spin.setMinimum(smin) - f.spin.setMaximum(smax) - if smax > 0: - f.spin.setEnabled(True) + current_spinner_value = 100 + show_cram_type = True + + form.spin.setVisible(True) + form.cardType.setVisible(show_cram_type) + form.title.setText(title_text) + form.title.setVisible(not not title_text) + form.spin.setMinimum(min_spinner_value) + form.spin.setMaximum(max_spinner_value) + if max_spinner_value > 0: + form.spin.setEnabled(True) else: - f.spin.setEnabled(False) - f.spin.setValue(sval) - f.preSpin.setText(pre) - f.postSpin.setText(post) - f.buttonBox.button(QDialogButtonBox.StandardButton.Ok).setText(ok) + form.spin.setEnabled(False) + form.spin.setValue(current_spinner_value) + form.preSpin.setText(text_before_spinner) + form.postSpin.setText(text_after_spinner) + form.buttonBox.button(QDialogButtonBox.StandardButton.Ok).setText(ok) self.radioIdx = idx def accept(self) -> None: - request = CustomStudyRequest() + request = CustomStudyRequest(deck_id=self.deck_id) if self.radioIdx == RADIO_NEW: request.new_limit_delta = self.form.spin.value() elif self.radioIdx == RADIO_REV: @@ -137,10 +148,6 @@ class CustomStudy(QDialog): else: request.cram.card_limit = self.form.spin.value() - tags = TagLimit.get_tags(self.mw, self) - request.cram.tags_to_include.extend(tags[0]) - request.cram.tags_to_exclude.extend(tags[1]) - cram_type = self.form.cardType.currentRow() if cram_type == TYPE_NEW: request.cram.kind = CustomStudyRequest.Cram.CRAM_KIND_NEW @@ -151,15 +158,21 @@ class CustomStudy(QDialog): else: request.cram.kind = CustomStudyRequest.Cram.CRAM_KIND_ALL + def on_done(include: list[str], exclude: list[str]) -> None: + request.cram.tags_to_include.extend(include) + request.cram.tags_to_exclude.extend(exclude) + self._create_and_close(request) + + # continues in background + TagLimit(self, self.defaults.tags, on_done) + return + + # other cases are synchronous + self._create_and_close(request) + + def _create_and_close(self, request: CustomStudyRequest) -> None: # keep open on failure, as the cause was most likely an empty search # result, which the user can remedy custom_study(parent=self, request=request).success( lambda _: QDialog.accept(self) ).run_in_background() - - def reject(self) -> None: - if self.created_custom_study: - # set the original deck back to current - self.mw.col.decks.select(self.deck["id"]) - # fixme: clean up the empty custom study deck - QDialog.reject(self) diff --git a/qt/aqt/overview.py b/qt/aqt/overview.py index 59b51a28f..442af6d68 100644 --- a/qt/aqt/overview.py +++ b/qt/aqt/overview.py @@ -304,4 +304,4 @@ class Overview: def onStudyMore(self) -> None: import aqt.customstudy - aqt.customstudy.CustomStudy(self.mw) + aqt.customstudy.CustomStudy.fetch_data_and_show(self.mw) diff --git a/qt/aqt/taglimit.py b/qt/aqt/taglimit.py index ef87bb173..5cc172a66 100644 --- a/qt/aqt/taglimit.py +++ b/qt/aqt/taglimit.py @@ -3,117 +3,93 @@ from __future__ import annotations -from typing import List, Optional, Tuple +from typing import Sequence import aqt import aqt.customstudy import aqt.forms from anki.lang import with_collapsed_whitespace -from aqt.main import AnkiQt +from anki.scheduler.base import CustomStudyDefaults from aqt.qt import * from aqt.utils import disable_help_button, restoreGeom, saveGeom, showWarning, tr class TagLimit(QDialog): - @staticmethod - def get_tags( - mw: AnkiQt, parent: aqt.customstudy.CustomStudy - ) -> Tuple[List[str], List[str]]: - """Get two lists of tags to include/exclude.""" - return TagLimit(mw, parent).tags - - def __init__(self, mw: AnkiQt, parent: aqt.customstudy.CustomStudy) -> None: + def __init__( + self, + parent: QWidget, + tags: Sequence[CustomStudyDefaults.Tag], + on_success: Callable[[list[str], list[str]], None], + ) -> None: + "Ask user to select tags. on_success() will be called with selected included and excluded tags." QDialog.__init__(self, parent, Qt.WindowType.Window) - self.tags: Tuple[List[str], List[str]] = ([], []) - self.tags_list: list[str] = [] - self.mw = mw - self.parent_: Optional[aqt.customstudy.CustomStudy] = parent - self.deck = self.parent_.deck - self.dialog = aqt.forms.taglimit.Ui_Dialog() - self.dialog.setupUi(self) + self.tags = tags + self.form = aqt.forms.taglimit.Ui_Dialog() + self.form.setupUi(self) + self.on_success = on_success disable_help_button(self) s = QShortcut( QKeySequence("ctrl+d"), - self.dialog.activeList, + self.form.activeList, context=Qt.ShortcutContext.WidgetShortcut, ) - qconnect(s.activated, self.dialog.activeList.clearSelection) + qconnect(s.activated, self.form.activeList.clearSelection) s = QShortcut( QKeySequence("ctrl+d"), - self.dialog.inactiveList, + self.form.inactiveList, context=Qt.ShortcutContext.WidgetShortcut, ) - qconnect(s.activated, self.dialog.inactiveList.clearSelection) - self.rebuildTagList() + qconnect(s.activated, self.form.inactiveList.clearSelection) + self.build_tag_lists() restoreGeom(self, "tagLimit") - self.exec() + self.open() - def rebuildTagList(self) -> None: - usertags = self.mw.col.tags.by_deck(self.deck["id"], True) - yes = self.deck.get("activeTags", []) - no = self.deck.get("inactiveTags", []) - yesHash = {} - noHash = {} - for y in yes: - yesHash[y] = True - for n in no: - noHash[n] = True - groupedTags = [] - usertags.sort() - groupedTags.append(usertags) - self.tags_list = [] - for tags in groupedTags: - for t in tags: - self.tags_list.append(t) - item = QListWidgetItem(t.replace("_", " ")) - self.dialog.activeList.addItem(item) - if t in yesHash: - mode = QItemSelectionModel.SelectionFlag.Select - self.dialog.activeCheck.setChecked(True) - else: - mode = QItemSelectionModel.SelectionFlag.Deselect - idx = self.dialog.activeList.indexFromItem(item) - self.dialog.activeList.selectionModel().select(idx, mode) - # inactive - item = QListWidgetItem(t.replace("_", " ")) - self.dialog.inactiveList.addItem(item) - if t in noHash: - mode = QItemSelectionModel.SelectionFlag.Select - else: - mode = QItemSelectionModel.SelectionFlag.Deselect - idx = self.dialog.inactiveList.indexFromItem(item) - self.dialog.inactiveList.selectionModel().select(idx, mode) + def build_tag_lists(self) -> None: + def add_tag(tag: str, select: bool, list: QListWidget) -> None: + item = QListWidgetItem(tag.replace("_", " ")) + list.addItem(item) + if select: + idx = list.indexFromItem(item) + list.selectionModel().select( + idx, QItemSelectionModel.SelectionFlag.Select + ) + + had_included_tag = False + + for tag in self.tags: + if tag.include: + had_included_tag = True + add_tag(tag.name, tag.include, self.form.activeList) + add_tag(tag.name, tag.exclude, self.form.inactiveList) + + if had_included_tag: + self.form.activeCheck.setChecked(True) def reject(self) -> None: QDialog.reject(self) def accept(self) -> None: - include_tags = exclude_tags = [] - # gather yes/no tags - for c in range(self.dialog.activeList.count()): + include_tags = [] + exclude_tags = [] + want_active = self.form.activeCheck.isChecked() + for c, tag in enumerate(self.tags): # active - if self.dialog.activeCheck.isChecked(): - item = self.dialog.activeList.item(c) - idx = self.dialog.activeList.indexFromItem(item) - if self.dialog.activeList.selectionModel().isSelected(idx): - include_tags.append(self.tags_list[c]) + if want_active: + item = self.form.activeList.item(c) + idx = self.form.activeList.indexFromItem(item) + if self.form.activeList.selectionModel().isSelected(idx): + include_tags.append(tag.name) # inactive - item = self.dialog.inactiveList.item(c) - idx = self.dialog.inactiveList.indexFromItem(item) - if self.dialog.inactiveList.selectionModel().isSelected(idx): - exclude_tags.append(self.tags_list[c]) + item = self.form.inactiveList.item(c) + idx = self.form.inactiveList.indexFromItem(item) + if self.form.inactiveList.selectionModel().isSelected(idx): + exclude_tags.append(tag.name) if (len(include_tags) + len(exclude_tags)) > 100: showWarning(with_collapsed_whitespace(tr.errors_100_tags_max())) return - self.hide() - self.tags = (include_tags, exclude_tags) - - # save in the deck for future invocations - self.deck["activeTags"] = include_tags - self.deck["inactiveTags"] = exclude_tags - self.mw.col.decks.save(self.deck) - saveGeom(self, "tagLimit") QDialog.accept(self) + + self.on_success(include_tags, exclude_tags) diff --git a/rslib/src/backend/scheduler/mod.rs b/rslib/src/backend/scheduler/mod.rs index 341431f3b..91024fff2 100644 --- a/rslib/src/backend/scheduler/mod.rs +++ b/rslib/src/backend/scheduler/mod.rs @@ -201,6 +201,13 @@ impl SchedulerService for Backend { fn custom_study(&self, input: pb::CustomStudyRequest) -> Result { self.with_col(|col| col.custom_study(input)).map(Into::into) } + + fn custom_study_defaults( + &self, + input: pb::CustomStudyDefaultsRequest, + ) -> Result { + self.with_col(|col| col.custom_study_defaults(input.deck_id.into())) + } } impl From for pb::SchedTimingTodayResponse { diff --git a/rslib/src/config/deck.rs b/rslib/src/config/deck.rs index 8c5a7e153..d78d7d387 100644 --- a/rslib/src/config/deck.rs +++ b/rslib/src/config/deck.rs @@ -8,12 +8,14 @@ use crate::prelude::*; /// Auxillary deck state, stored in the config table. #[derive(Debug, Clone, Copy, IntoStaticStr)] #[strum(serialize_all = "camelCase")] -enum DeckConfigKey { +pub enum DeckConfigKey { LastNotetype, + CustomStudyIncludeTags, + CustomStudyExcludeTags, } impl DeckConfigKey { - fn for_deck(self, did: DeckId) -> String { + pub fn for_deck(self, did: DeckId) -> String { build_aux_deck_key(did, <&'static str>::from(self)) } } diff --git a/rslib/src/config/mod.rs b/rslib/src/config/mod.rs index 955d65832..c4c8ccc4c 100644 --- a/rslib/src/config/mod.rs +++ b/rslib/src/config/mod.rs @@ -13,7 +13,9 @@ use serde_repr::{Deserialize_repr, Serialize_repr}; use slog::warn; use strum::IntoStaticStr; -pub use self::{bool::BoolKey, notetype::get_aux_notetype_config_key, string::StringKey}; +pub use self::{ + bool::BoolKey, deck::DeckConfigKey, notetype::get_aux_notetype_config_key, string::StringKey, +}; use crate::{backend_proto::preferences::Backups, prelude::*}; /// Only used when updating/undoing. @@ -112,10 +114,10 @@ impl Collection { } // /// Get config item, returning default value if missing/invalid. - pub(crate) fn get_config_default(&self, key: K) -> T + pub(crate) fn get_config_default<'a, T, K>(&self, key: K) -> T where T: DeserializeOwned + Default, - K: Into<&'static str>, + K: Into<&'a str>, { self.get_config_optional(key).unwrap_or_default() } diff --git a/rslib/src/decks/tree.rs b/rslib/src/decks/tree.rs index 9979c598b..e96da4267 100644 --- a/rslib/src/decks/tree.rs +++ b/rslib/src/decks/tree.rs @@ -244,17 +244,28 @@ fn hide_default_deck(node: &mut DeckTreeNode) { } } -fn get_subnode(top: DeckTreeNode, target: DeckId) -> Option { - if top.deck_id == target.0 { - return Some(top); - } - for child in top.children { - if let Some(node) = get_subnode(child, target) { - return Some(node); +impl DeckTreeNode { + /// Locate provided deck in tree, and return it. + pub fn get_deck(self, deck_id: DeckId) -> Option { + if self.deck_id == deck_id.0 { + return Some(self); } + for child in self.children { + if let Some(node) = child.get_deck(deck_id) { + return Some(node); + } + } + + None } - None + pub(crate) fn sum(&self, map: fn(&DeckTreeNode) -> T) -> T { + let mut output = map(self); + for child in &self.children { + output += child.sum(map); + } + output + } } #[derive(Serialize_tuple)] @@ -330,7 +341,7 @@ impl Collection { pub fn current_deck_tree(&mut self) -> Result> { let target = self.get_current_deck_id(); let tree = self.deck_tree(Some(TimestampSecs::now()))?; - Ok(get_subnode(tree, target)) + Ok(tree.get_deck(target)) } pub fn set_deck_collapsed( diff --git a/rslib/src/ops.rs b/rslib/src/ops.rs index 2a2ecaf66..5daa2c582 100644 --- a/rslib/src/ops.rs +++ b/rslib/src/ops.rs @@ -93,7 +93,7 @@ impl Op { } } -#[derive(Debug, Default, Clone, Copy)] +#[derive(Debug, PartialEq, Default, Clone, Copy)] pub struct StateChanges { pub card: bool, pub note: bool, @@ -105,12 +105,13 @@ pub struct StateChanges { pub mtime: bool, } -#[derive(Debug, Clone)] +#[derive(Debug, PartialEq, Clone)] pub struct OpChanges { pub op: Op, pub changes: StateChanges, } +#[derive(Debug, PartialEq)] pub struct OpOutput { pub output: T, pub changes: OpChanges, diff --git a/rslib/src/scheduler/filtered/custom_study.rs b/rslib/src/scheduler/filtered/custom_study.rs index a617fe5f2..c224c44dd 100644 --- a/rslib/src/scheduler/filtered/custom_study.rs +++ b/rslib/src/scheduler/filtered/custom_study.rs @@ -1,12 +1,15 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +use std::collections::HashSet; + use super::FilteredDeckForUpdate; use crate::{ backend_proto::{ self as pb, custom_study_request::{cram::CramKind, Cram, Value as CustomStudyValue}, }, + config::DeckConfigKey, decks::{FilteredDeck, FilteredSearchOrder, FilteredSearchTerm}, error::{CustomStudyError, FilteredDeckError}, prelude::*, @@ -17,11 +20,63 @@ impl Collection { pub fn custom_study(&mut self, input: pb::CustomStudyRequest) -> Result> { self.transact(Op::CreateCustomStudy, |col| col.custom_study_inner(input)) } + + pub fn custom_study_defaults( + &mut self, + deck_id: DeckId, + ) -> Result { + // daily counts + let deck = self.get_deck(deck_id)?.ok_or(AnkiError::NotFound)?; + let normal = deck.normal()?; + let extend_new = normal.extend_new; + let extend_review = normal.extend_review; + let subtree = self + .deck_tree(Some(TimestampSecs::now()))? + .get_deck(deck_id) + .ok_or(AnkiError::NotFound)?; + let available_new = subtree.sum(|node| node.new_uncapped); + let available_review = subtree.sum(|node| node.review_uncapped); + // tags + let include_tags: HashSet = self.get_config_default( + DeckConfigKey::CustomStudyIncludeTags + .for_deck(deck_id) + .as_str(), + ); + let exclude_tags: HashSet = self.get_config_default( + DeckConfigKey::CustomStudyExcludeTags + .for_deck(deck_id) + .as_str(), + ); + let mut all_tags: Vec<_> = self.all_tags_in_deck(deck_id)?.into_iter().collect(); + all_tags.sort_unstable(); + let tags: Vec = all_tags + .into_iter() + .map(|tag| { + let tag = tag.into_inner(); + pb::custom_study_defaults_response::Tag { + include: include_tags.contains(&tag), + exclude: exclude_tags.contains(&tag), + name: tag, + } + }) + .collect(); + + Ok(pb::CustomStudyDefaultsResponse { + tags, + extend_new, + extend_review, + available_new, + available_review, + }) + } } impl Collection { fn custom_study_inner(&mut self, input: pb::CustomStudyRequest) -> Result<()> { - let current_deck = self.get_current_deck()?; + let mut deck = self + .storage + .get_deck(input.deck_id.into())? + .ok_or(AnkiError::NotFound)?; match input .value @@ -29,23 +84,48 @@ impl Collection { { CustomStudyValue::NewLimitDelta(delta) => { let today = self.current_due_day(0)?; - self.extend_limits(today, self.usn()?, current_deck.id, delta, 0) + self.extend_limits(today, self.usn()?, deck.id, delta, 0)?; + if delta > 0 { + let original = deck.clone(); + deck.normal_mut()?.extend_new = delta as u32; + self.update_deck_inner(&mut deck, original, self.usn()?)?; + } + Ok(()) } CustomStudyValue::ReviewLimitDelta(delta) => { let today = self.current_due_day(0)?; - self.extend_limits(today, self.usn()?, current_deck.id, 0, delta) + self.extend_limits(today, self.usn()?, deck.id, 0, delta)?; + if delta > 0 { + let original = deck.clone(); + deck.normal_mut()?.extend_review = delta as u32; + self.update_deck_inner(&mut deck, original, self.usn()?)?; + } + Ok(()) } CustomStudyValue::ForgotDays(days) => { - self.create_custom_study_deck(forgot_config(current_deck.human_name(), days)) + self.create_custom_study_deck(forgot_config(deck.human_name(), days)) } CustomStudyValue::ReviewAheadDays(days) => { - self.create_custom_study_deck(ahead_config(current_deck.human_name(), days)) + self.create_custom_study_deck(ahead_config(deck.human_name(), days)) } CustomStudyValue::PreviewDays(days) => { - self.create_custom_study_deck(preview_config(current_deck.human_name(), days)) + self.create_custom_study_deck(preview_config(deck.human_name(), days)) } CustomStudyValue::Cram(cram) => { - self.create_custom_study_deck(cram_config(current_deck.human_name(), cram)?) + self.create_custom_study_deck(cram_config(deck.human_name(), &cram)?)?; + self.set_config( + DeckConfigKey::CustomStudyIncludeTags + .for_deck(deck.id) + .as_str(), + &cram.tags_to_include, + )?; + self.set_config( + DeckConfigKey::CustomStudyExcludeTags + .for_deck(deck.id) + .as_str(), + &cram.tags_to_exclude, + )?; + Ok(()) } } } @@ -136,8 +216,8 @@ fn preview_config(deck_name: String, days: u32) -> FilteredDeck { ) } -fn cram_config(deck_name: String, cram: Cram) -> Result { - let (reschedule, nodes, order) = match CramKind::from_i32(cram.kind).unwrap_or_default() { +fn cram_config(deck_name: String, cram: &Cram) -> Result { + let (reschedule, nodes, order) = match cram.kind() { CramKind::New => ( true, SearchBuilder::from(StateKind::New), @@ -158,8 +238,8 @@ fn cram_config(deck_name: String, cram: Cram) -> Result { let search = nodes .and_join(&mut tags_to_nodes( - cram.tags_to_include, - cram.tags_to_exclude, + &cram.tags_to_include, + &cram.tags_to_exclude, )) .and(SearchNode::from_deck_name(&deck_name)) .write(); @@ -172,7 +252,7 @@ fn cram_config(deck_name: String, cram: Cram) -> Result { )) } -fn tags_to_nodes(tags_to_include: Vec, tags_to_exclude: Vec) -> SearchBuilder { +fn tags_to_nodes(tags_to_include: &[String], tags_to_exclude: &[String]) -> SearchBuilder { let include_nodes = SearchBuilder::any( tags_to_include .iter() @@ -186,3 +266,98 @@ fn tags_to_nodes(tags_to_include: Vec, tags_to_exclude: Vec) -> include_nodes.group().and_join(&mut exclude_nodes) } + +#[cfg(test)] +mod test { + use super::*; + use crate::{ + backend_proto::{ + scheduler::custom_study_request::{cram::CramKind, Cram, Value}, + CustomStudyRequest, + }, + collection::open_test_collection, + }; + + #[test] + fn tag_remembering() -> Result<()> { + let mut col = open_test_collection(); + + let nt = col.get_notetype_by_name("Basic")?.unwrap(); + let mut note = nt.new_note(); + note.tags + .extend_from_slice(&["3".to_string(), "1".to_string(), "2::two".to_string()]); + col.add_note(&mut note, DeckId(1))?; + let mut note = nt.new_note(); + note.tags + .extend_from_slice(&["1".to_string(), "2::two".to_string()]); + col.add_note(&mut note, DeckId(1))?; + + fn get_defaults(col: &mut Collection) -> Result> { + Ok(col + .custom_study_defaults(DeckId(1))? + .tags + .into_iter() + .map(|tag| { + ( + // cheekily leak the string so we have a static ref for comparison + &*Box::leak(tag.name.into_boxed_str()), + tag.include, + tag.exclude, + ) + }) + .collect()) + } + + // nothing should be included/excluded by default + assert_eq!( + &get_defaults(&mut col)?, + &[ + ("1", false, false), + ("2::two", false, false), + ("3", false, false) + ] + ); + + // if filtered deck creation fails, inclusions/exclusions don't change + let mut cram = Cram { + kind: CramKind::All as i32, + card_limit: 0, + tags_to_include: vec!["2::two".to_string()], + tags_to_exclude: vec!["3".to_string()], + }; + assert_eq!( + col.custom_study(CustomStudyRequest { + deck_id: 1, + value: Some(Value::Cram(cram.clone())), + }), + Err(AnkiError::CustomStudyError( + CustomStudyError::NoMatchingCards + )) + ); + assert_eq!( + &get_defaults(&mut col)?, + &[ + ("1", false, false), + ("2::two", false, false), + ("3", false, false) + ] + ); + + // a successful build should update tags + cram.card_limit = 100; + col.custom_study(CustomStudyRequest { + deck_id: 1, + value: Some(Value::Cram(cram)), + })?; + assert_eq!( + &get_defaults(&mut col)?, + &[ + ("1", false, false), + ("2::two", true, false), + ("3", false, true) + ] + ); + + Ok(()) + } +} diff --git a/rslib/src/search/mod.rs b/rslib/src/search/mod.rs index b47ba074b..c2c5f06c0 100644 --- a/rslib/src/search/mod.rs +++ b/rslib/src/search/mod.rs @@ -8,13 +8,12 @@ pub(crate) mod writer; use std::borrow::Cow; -use rusqlite::{params_from_iter, types::FromSql}; -use sqlwriter::{RequiredTable, SqlWriter}; - pub use builder::{Negated, SearchBuilder}; pub use parser::{ parse as parse_search, Node, PropertyKind, RatingKind, SearchNode, StateKind, TemplateKind, }; +use rusqlite::{params_from_iter, types::FromSql}; +use sqlwriter::{RequiredTable, SqlWriter}; pub use writer::replace_search_node; use crate::{ @@ -216,6 +215,29 @@ impl Collection { .execute(params_from_iter(args)) .map_err(Into::into) } + + /// Place the matched note ids into a temporary 'search_nids' table + /// instead of returning them. Use clear_searched_notes() to remove it. + /// Returns number of added notes. + pub(crate) fn search_notes_into_table(&mut self, search: N) -> Result + where + N: TryIntoSearch, + { + let top_node = search.try_into_search()?; + let writer = SqlWriter::new(self, ReturnItemType::Notes); + let mode = SortMode::NoOrder; + + let (sql, args) = writer.build_query(&top_node, mode.required_table())?; + + self.storage.setup_searched_notes_table()?; + let sql = format!("insert into search_nids {}", sql); + + self.storage + .db + .prepare(&sql)? + .execute(params_from_iter(args)) + .map_err(Into::into) + } } /// Add the order clause to the sql. diff --git a/rslib/src/storage/note/mod.rs b/rslib/src/storage/note/mod.rs index df12ae806..4c7c20bc8 100644 --- a/rslib/src/storage/note/mod.rs +++ b/rslib/src/storage/note/mod.rs @@ -195,6 +195,20 @@ impl super::SqliteStorage { self.clear_searched_notes_table()?; Ok(out) } + pub(crate) fn for_each_note_tag_in_searched_notes(&self, mut func: F) -> Result<()> + where + F: FnMut(&str), + { + let mut stmt = self + .db + .prepare_cached("select tags from notes where id in (select nid from search_nids)")?; + let mut rows = stmt.query(params![])?; + while let Some(row) = rows.next()? { + func(row.get_ref(0)?.as_str()?); + } + + Ok(()) + } pub(crate) fn get_note_tags_by_predicate(&mut self, want: F) -> Result> where @@ -219,7 +233,7 @@ impl super::SqliteStorage { Ok(()) } - fn setup_searched_notes_table(&self) -> Result<()> { + pub(crate) fn setup_searched_notes_table(&self) -> Result<()> { self.db .execute_batch(include_str!("search_nids_setup.sql"))?; Ok(()) diff --git a/rslib/src/tags/mod.rs b/rslib/src/tags/mod.rs index 7cb83b793..e9ef57b34 100644 --- a/rslib/src/tags/mod.rs +++ b/rslib/src/tags/mod.rs @@ -5,6 +5,7 @@ mod bulkadd; mod complete; mod findreplace; mod matcher; +mod notes; mod register; mod remove; mod rename; diff --git a/rslib/src/tags/notes.rs b/rslib/src/tags/notes.rs new file mode 100644 index 000000000..85edef63e --- /dev/null +++ b/rslib/src/tags/notes.rs @@ -0,0 +1,25 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use std::collections::HashSet; + +use unicase::UniCase; + +use super::split_tags; +use crate::{prelude::*, search::SearchNode}; + +impl Collection { + pub(crate) fn all_tags_in_deck(&mut self, deck_id: DeckId) -> Result>> { + self.search_notes_into_table(SearchNode::DeckIdWithChildren(deck_id))?; + let mut all_tags: HashSet> = HashSet::new(); + self.storage.for_each_note_tag_in_searched_notes(|tags| { + for tag in split_tags(tags) { + // A benchmark on a large deck indicates that nothing is gained by using a Cow and skipping + // an allocation in the duplicate case, and this approach is simpler. + all_tags.insert(UniCase::new(tag.to_string())); + } + })?; + self.storage.clear_searched_notes_table()?; + Ok(all_tags) + } +}