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.
This commit is contained in:
Damien Elmes 2022-03-10 16:06:55 +10:00
parent d1a43a2d42
commit f3e81c8a95
20 changed files with 513 additions and 226 deletions

View file

@ -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 }

View file

@ -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;
}

View file

@ -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:

View file

@ -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:

View file

@ -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"""

View file

@ -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"""

View file

@ -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),

View file

@ -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"<b>{str(num)}</b>"
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)

View file

@ -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)

View file

@ -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)

View file

@ -201,6 +201,13 @@ impl SchedulerService for Backend {
fn custom_study(&self, input: pb::CustomStudyRequest) -> Result<pb::OpChanges> {
self.with_col(|col| col.custom_study(input)).map(Into::into)
}
fn custom_study_defaults(
&self,
input: pb::CustomStudyDefaultsRequest,
) -> Result<pb::CustomStudyDefaultsResponse> {
self.with_col(|col| col.custom_study_defaults(input.deck_id.into()))
}
}
impl From<crate::scheduler::timing::SchedTimingToday> for pb::SchedTimingTodayResponse {

View file

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

View file

@ -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<T, K>(&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()
}

View file

@ -244,12 +244,14 @@ fn hide_default_deck(node: &mut DeckTreeNode) {
}
}
fn get_subnode(top: DeckTreeNode, target: DeckId) -> Option<DeckTreeNode> {
if top.deck_id == target.0 {
return Some(top);
impl DeckTreeNode {
/// Locate provided deck in tree, and return it.
pub fn get_deck(self, deck_id: DeckId) -> Option<DeckTreeNode> {
if self.deck_id == deck_id.0 {
return Some(self);
}
for child in top.children {
if let Some(node) = get_subnode(child, target) {
for child in self.children {
if let Some(node) = child.get_deck(deck_id) {
return Some(node);
}
}
@ -257,6 +259,15 @@ fn get_subnode(top: DeckTreeNode, target: DeckId) -> Option<DeckTreeNode> {
None
}
pub(crate) fn sum<T: AddAssign>(&self, map: fn(&DeckTreeNode) -> T) -> T {
let mut output = map(self);
for child in &self.children {
output += child.sum(map);
}
output
}
}
#[derive(Serialize_tuple)]
pub(crate) struct LegacyDueCounts {
name: String,
@ -330,7 +341,7 @@ impl Collection {
pub fn current_deck_tree(&mut self) -> Result<Option<DeckTreeNode>> {
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(

View file

@ -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<T> {
pub output: T,
pub changes: OpChanges,

View file

@ -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<OpOutput<()>> {
self.transact(Op::CreateCustomStudy, |col| col.custom_study_inner(input))
}
pub fn custom_study_defaults(
&mut self,
deck_id: DeckId,
) -> Result<pb::CustomStudyDefaultsResponse> {
// 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<String> = self.get_config_default(
DeckConfigKey::CustomStudyIncludeTags
.for_deck(deck_id)
.as_str(),
);
let exclude_tags: HashSet<String> = 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<pb::custom_study_defaults_response::Tag> = 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<FilteredDeck> {
let (reschedule, nodes, order) = match CramKind::from_i32(cram.kind).unwrap_or_default() {
fn cram_config(deck_name: String, cram: &Cram) -> Result<FilteredDeck> {
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<FilteredDeck> {
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<FilteredDeck> {
))
}
fn tags_to_nodes(tags_to_include: Vec<String>, tags_to_exclude: Vec<String>) -> 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<String>, tags_to_exclude: Vec<String>) ->
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<Vec<(&'static str, bool, bool)>> {
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(())
}
}

View file

@ -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<N>(&mut self, search: N) -> Result<usize>
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.

View file

@ -195,6 +195,20 @@ impl super::SqliteStorage {
self.clear_searched_notes_table()?;
Ok(out)
}
pub(crate) fn for_each_note_tag_in_searched_notes<F>(&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<F>(&mut self, want: F) -> Result<Vec<NoteTags>>
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(())

View file

@ -5,6 +5,7 @@ mod bulkadd;
mod complete;
mod findreplace;
mod matcher;
mod notes;
mod register;
mod remove;
mod rename;

25
rslib/src/tags/notes.rs Normal file
View file

@ -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<HashSet<UniCase<String>>> {
self.search_notes_into_table(SearchNode::DeckIdWithChildren(deck_id))?;
let mut all_tags: HashSet<UniCase<String>> = 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)
}
}