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) + } +}