mirror of
https://github.com/ankitects/anki.git
synced 2025-09-20 23:12:21 -04:00
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:
parent
d1a43a2d42
commit
f3e81c8a95
20 changed files with 513 additions and 226 deletions
|
@ -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 }
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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"""
|
||||
|
|
|
@ -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"""
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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(())
|
||||
|
|
|
@ -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
25
rslib/src/tags/notes.rs
Normal 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)
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue