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-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-card-limit = Increase today's review card limit
custom-study-increase-todays-review-limit-by = Increase today's review limit by 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-new-cards-only = New cards only
custom-study-no-cards-matched-the-criteria-you = No cards matched the criteria you provided. custom-study-no-cards-matched-the-criteria-you = No cards matched the criteria you provided.
custom-study-ok = OK 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-ahead-by = Review ahead by
custom-study-review-cards-forgotten-in-last = Review cards forgotten in last custom-study-review-cards-forgotten-in-last = Review cards forgotten in last
custom-study-review-forgotten-cards = Review forgotten cards 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 = Select
custom-study-select-tags-to-exclude = Select tags to exclude: custom-study-select-tags-to-exclude = Select tags to exclude:
custom-study-selective-study = Selective Study custom-study-selective-study = Selective Study
custom-study-study-by-card-state-or-tag = Study by card state or tag 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 StateIsLeech(SchedulingState) returns (generic.Bool);
rpc UpgradeScheduler(generic.Empty) returns (generic.Empty); rpc UpgradeScheduler(generic.Empty) returns (generic.Empty);
rpc CustomStudy(CustomStudyRequest) returns (collection.OpChanges); rpc CustomStudy(CustomStudyRequest) returns (collection.OpChanges);
rpc CustomStudyDefaults(CustomStudyDefaultsRequest)
returns (CustomStudyDefaultsResponse);
} }
message SchedulingState { message SchedulingState {
@ -257,17 +259,36 @@ message CustomStudyRequest {
// cards must not match any of these // cards must not match any of these
repeated string tags_to_exclude = 4; repeated string tags_to_exclude = 4;
} }
int64 deck_id = 1;
oneof value { oneof value {
// increase new limit by x // increase new limit by x
int32 new_limit_delta = 1; int32 new_limit_delta = 2;
// increase review limit by x // increase review limit by x
int32 review_limit_delta = 2; int32 review_limit_delta = 3;
// repeat cards forgotten in the last x days // repeat cards forgotten in the last x days
uint32 forgot_days = 3; uint32 forgot_days = 4;
// review cards due in the next x days // 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 // preview new cards added in the last x days
uint32 preview_days = 5; uint32 preview_days = 6;
Cram cram = 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 UnburyDeck = scheduler_pb2.UnburyDeckRequest
BuryOrSuspend = scheduler_pb2.BuryOrSuspendCardsRequest BuryOrSuspend = scheduler_pb2.BuryOrSuspendCardsRequest
CustomStudyRequest = scheduler_pb2.CustomStudyRequest CustomStudyRequest = scheduler_pb2.CustomStudyRequest
CustomStudyDefaults = scheduler_pb2.CustomStudyDefaultsResponse
ScheduleCardsAsNew = scheduler_pb2.ScheduleCardsAsNewRequest ScheduleCardsAsNew = scheduler_pb2.ScheduleCardsAsNewRequest
ScheduleCardsAsNewDefaults = scheduler_pb2.ScheduleCardsAsNewDefaultsResponse ScheduleCardsAsNewDefaults = scheduler_pb2.ScheduleCardsAsNewDefaultsResponse
FilteredDeckForUpdate = decks_pb2.FilteredDeckForUpdate FilteredDeckForUpdate = decks_pb2.FilteredDeckForUpdate
@ -24,7 +25,7 @@ from typing import Sequence
from anki import config_pb2 from anki import config_pb2
from anki.cards import CardId 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.decks import DeckConfigDict, DeckId, DeckTreeNode
from anki.notes import NoteId from anki.notes import NoteId
from anki.utils import ids2str, int_time from anki.utils import ids2str, int_time
@ -78,21 +79,13 @@ class SchedulerBase(DeprecatedNamesMixin):
def custom_study(self, request: CustomStudyRequest) -> OpChanges: def custom_study(self, request: CustomStudyRequest) -> OpChanges:
return self.col._backend.custom_study(request) 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: def extend_limits(self, new: int, rev: int) -> None:
did = self.col.decks.current()["id"] did = self.col.decks.current()["id"]
self.col._backend.extend_limits(deck_id=did, new_delta=new, review_delta=rev) 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; # fixme: only used by total_rev_for_current_deck and old deck stats;
# schedv2 defines separate version # schedv2 defines separate version
def _deck_limit(self) -> str: def _deck_limit(self) -> str:

View file

@ -5,8 +5,13 @@
from typing import Optional from typing import Optional
from anki._legacy import deprecated
from anki.cards import Card, CardId 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.decks import DeckConfigDict, DeckId
from anki.notes import NoteId from anki.notes import NoteId
from anki.scheduler.base import SchedulerBase, UnburyDeck 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] 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 # legacy in v3 but used by unit tests; redefined in v2/v1
def _cardConf(self, card: Card) -> DeckConfigDict: 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 # pylint: disable=unused-import
import anki.collection import anki.collection
from anki import hooks, scheduler_pb2 from anki import hooks, scheduler_pb2
from anki._legacy import deprecated
from anki.cards import Card, CardId from anki.cards import Card, CardId
from anki.consts import * from anki.consts import *
from anki.decks import DeckConfigDict, DeckDict, DeckId 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) limit = max(0, c["new"]["perDay"] - self.counts_for_deck_today(g["id"]).new)
return hooks.scheduler_new_limit_for_single_deck(limit, g) return 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: def totalNewForCurrentDeck(self) -> int:
return self.col.db.scalar( return self.col.db.scalar(
f""" f"""

View file

@ -17,6 +17,7 @@ from __future__ import annotations
from typing import Literal, Optional, Sequence from typing import Literal, Optional, Sequence
from anki import scheduler_pb2 from anki import scheduler_pb2
from anki._legacy import deprecated
from anki.cards import Card from anki.cards import Card
from anki.collection import OpChanges from anki.collection import OpChanges
from anki.consts import * from anki.consts import *
@ -239,8 +240,7 @@ class Scheduler(SchedulerBaseWithLegacy):
except DBError: except DBError:
return [] return []
# used by custom study; will likely be rolled into a separate routine @deprecated(info="no longer used by Anki; will be removed in the future")
# in the future
def totalNewForCurrentDeck(self) -> int: def totalNewForCurrentDeck(self) -> int:
return self.col.db.scalar( return self.col.db.scalar(
f""" f"""

View file

@ -52,19 +52,6 @@ class TagManager(DeprecatedNamesMixin):
def clear_unused_tags(self) -> OpChangesWithCount: def clear_unused_tags(self) -> OpChangesWithCount:
return self.col._backend.clear_unused_tags() 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: def set_collapsed(self, tag: str, collapsed: bool) -> OpChanges:
"Set browser expansion state for tag, registering the tag if missing." "Set browser expansion state for tag, registering the tag if missing."
return self.col._backend.set_tag_collapsed(name=tag, collapsed=collapsed) 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: def _legacy_bulk_rem(self, ids: list[NoteId], tags: str) -> None:
self._legacy_bulk_add(ids, tags, False) 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( TagManager.register_deprecated_attributes(
registerNotes=(TagManager._legacy_register_notes, TagManager.clear_unused_tags), registerNotes=(TagManager._legacy_register_notes, TagManager.clear_unused_tags),

View file

@ -1,11 +1,17 @@
# Copyright: Ankitects Pty Ltd and contributors # Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from typing import Tuple
import aqt import aqt
import aqt.forms import aqt.forms
import aqt.operations import aqt.operations
from anki.collection import Collection
from anki.consts import * from anki.consts import *
from anki.decks import DeckId
from anki.scheduler import CustomStudyRequest 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.operations.scheduling import custom_study
from aqt.qt import * from aqt.qt import *
from aqt.taglimit import TagLimit from aqt.taglimit import TagLimit
@ -25,17 +31,39 @@ TYPE_ALL = 3
class CustomStudy(QDialog): 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) QDialog.__init__(self, mw)
self.mw = mw self.mw = mw
self.deck = self.mw.col.decks.current() self.deck_id = deck_id
self.conf = self.mw.col.decks.get_config(self.deck["conf"]) self.defaults = defaults
self.form = f = aqt.forms.customstudy.Ui_Dialog() self.form = aqt.forms.customstudy.Ui_Dialog()
self.created_custom_study = False self.form.setupUi(self)
f.setupUi(self)
disable_help_button(self) disable_help_button(self)
self.setupSignals() self.setupSignals()
f.radioNew.click() self.form.radioNew.click()
self.open() self.open()
def setupSignals(self) -> None: def setupSignals(self) -> None:
@ -48,82 +76,65 @@ class CustomStudy(QDialog):
qconnect(f.radioCram.clicked, lambda: self.onRadioChange(RADIO_CRAM)) qconnect(f.radioCram.clicked, lambda: self.onRadioChange(RADIO_CRAM))
def onRadioChange(self, idx: int) -> None: def onRadioChange(self, idx: int) -> None:
f = self.form form = self.form
sp = f.spin min_spinner_value = 1
smin = 1 max_spinner_value = DYN_MAX_SIZE
smax = DYN_MAX_SIZE current_spinner_value = 1
sval = 1 text_after_spinner = tr.custom_study_cards()
post = tr.custom_study_cards() title_text = ""
tit = "" show_cram_type = False
spShow = True
typeShow = False
ok = tr.custom_study_ok() 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: if idx == RADIO_NEW:
new = self.mw.col.sched.totalNewForCurrentDeck() title_text = tr.custom_study_available_new_cards(
# get the number of new cards in deck that exceed the new cards limit count=self.defaults.available_new
newUnderLearning = min(
new, self.conf["new"]["perDay"] - self.deck["newToday"][1]
) )
newExceeding = min(new, new - newUnderLearning) text_before_spinner = tr.custom_study_increase_todays_new_card_limit_by()
tit = tr.custom_study_new_cards_in_deck_over_today(val=plus(newExceeding)) current_spinner_value = self.defaults.extend_new
pre = tr.custom_study_increase_todays_new_card_limit_by() min_spinner_value = -DYN_MAX_SIZE
sval = min(new, self.deck.get("extendNew", 10))
smin = -DYN_MAX_SIZE
smax = newExceeding
elif idx == RADIO_REV: elif idx == RADIO_REV:
rev = self.mw.col.sched.total_rev_for_current_deck() title_text = tr.custom_study_available_review_cards(
# get the number of review due in deck that exceed the review due limit count=self.defaults.available_review
revUnderLearning = min(
rev, self.conf["rev"]["perDay"] - self.deck["revToday"][1]
) )
revExceeding = min(rev, rev - revUnderLearning) text_before_spinner = tr.custom_study_increase_todays_review_limit_by()
tit = tr.custom_study_reviews_due_in_deck_over_today(val=plus(revExceeding)) current_spinner_value = self.defaults.extend_review
pre = tr.custom_study_increase_todays_review_limit_by() min_spinner_value = -DYN_MAX_SIZE
sval = min(rev, self.deck.get("extendRev", 10))
smin = -DYN_MAX_SIZE
smax = revExceeding
elif idx == RADIO_FORGOT: elif idx == RADIO_FORGOT:
pre = tr.custom_study_review_cards_forgotten_in_last() text_before_spinner = tr.custom_study_review_cards_forgotten_in_last()
post = tr.scheduling_days() text_after_spinner = tr.scheduling_days()
smax = 30 max_spinner_value = 30
elif idx == RADIO_AHEAD: elif idx == RADIO_AHEAD:
pre = tr.custom_study_review_ahead_by() text_before_spinner = tr.custom_study_review_ahead_by()
post = tr.scheduling_days() text_after_spinner = tr.scheduling_days()
elif idx == RADIO_PREVIEW: elif idx == RADIO_PREVIEW:
pre = tr.custom_study_preview_new_cards_added_in_the() text_before_spinner = tr.custom_study_preview_new_cards_added_in_the()
post = tr.scheduling_days() text_after_spinner = tr.scheduling_days()
sval = 1 current_spinner_value = 1
elif idx == RADIO_CRAM: elif idx == RADIO_CRAM:
pre = tr.custom_study_select() text_before_spinner = tr.custom_study_select()
post = tr.custom_study_cards_from_the_deck() text_after_spinner = tr.custom_study_cards_from_the_deck()
# tit = _("After pressing OK, you can choose which tags to include.")
ok = tr.custom_study_choose_tags() ok = tr.custom_study_choose_tags()
sval = 100 current_spinner_value = 100
typeShow = True show_cram_type = True
sp.setVisible(spShow)
f.cardType.setVisible(typeShow) form.spin.setVisible(True)
f.title.setText(tit) form.cardType.setVisible(show_cram_type)
f.title.setVisible(not not tit) form.title.setText(title_text)
f.spin.setMinimum(smin) form.title.setVisible(not not title_text)
f.spin.setMaximum(smax) form.spin.setMinimum(min_spinner_value)
if smax > 0: form.spin.setMaximum(max_spinner_value)
f.spin.setEnabled(True) if max_spinner_value > 0:
form.spin.setEnabled(True)
else: else:
f.spin.setEnabled(False) form.spin.setEnabled(False)
f.spin.setValue(sval) form.spin.setValue(current_spinner_value)
f.preSpin.setText(pre) form.preSpin.setText(text_before_spinner)
f.postSpin.setText(post) form.postSpin.setText(text_after_spinner)
f.buttonBox.button(QDialogButtonBox.StandardButton.Ok).setText(ok) form.buttonBox.button(QDialogButtonBox.StandardButton.Ok).setText(ok)
self.radioIdx = idx self.radioIdx = idx
def accept(self) -> None: def accept(self) -> None:
request = CustomStudyRequest() request = CustomStudyRequest(deck_id=self.deck_id)
if self.radioIdx == RADIO_NEW: if self.radioIdx == RADIO_NEW:
request.new_limit_delta = self.form.spin.value() request.new_limit_delta = self.form.spin.value()
elif self.radioIdx == RADIO_REV: elif self.radioIdx == RADIO_REV:
@ -137,10 +148,6 @@ class CustomStudy(QDialog):
else: else:
request.cram.card_limit = self.form.spin.value() 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() cram_type = self.form.cardType.currentRow()
if cram_type == TYPE_NEW: if cram_type == TYPE_NEW:
request.cram.kind = CustomStudyRequest.Cram.CRAM_KIND_NEW request.cram.kind = CustomStudyRequest.Cram.CRAM_KIND_NEW
@ -151,15 +158,21 @@ class CustomStudy(QDialog):
else: else:
request.cram.kind = CustomStudyRequest.Cram.CRAM_KIND_ALL 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 # keep open on failure, as the cause was most likely an empty search
# result, which the user can remedy # result, which the user can remedy
custom_study(parent=self, request=request).success( custom_study(parent=self, request=request).success(
lambda _: QDialog.accept(self) lambda _: QDialog.accept(self)
).run_in_background() ).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: def onStudyMore(self) -> None:
import aqt.customstudy 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 __future__ import annotations
from typing import List, Optional, Tuple from typing import Sequence
import aqt import aqt
import aqt.customstudy import aqt.customstudy
import aqt.forms import aqt.forms
from anki.lang import with_collapsed_whitespace from anki.lang import with_collapsed_whitespace
from aqt.main import AnkiQt from anki.scheduler.base import CustomStudyDefaults
from aqt.qt import * from aqt.qt import *
from aqt.utils import disable_help_button, restoreGeom, saveGeom, showWarning, tr from aqt.utils import disable_help_button, restoreGeom, saveGeom, showWarning, tr
class TagLimit(QDialog): class TagLimit(QDialog):
@staticmethod def __init__(
def get_tags( self,
mw: AnkiQt, parent: aqt.customstudy.CustomStudy parent: QWidget,
) -> Tuple[List[str], List[str]]: tags: Sequence[CustomStudyDefaults.Tag],
"""Get two lists of tags to include/exclude.""" on_success: Callable[[list[str], list[str]], None],
return TagLimit(mw, parent).tags ) -> None:
"Ask user to select tags. on_success() will be called with selected included and excluded tags."
def __init__(self, mw: AnkiQt, parent: aqt.customstudy.CustomStudy) -> None:
QDialog.__init__(self, parent, Qt.WindowType.Window) QDialog.__init__(self, parent, Qt.WindowType.Window)
self.tags: Tuple[List[str], List[str]] = ([], []) self.tags = tags
self.tags_list: list[str] = [] self.form = aqt.forms.taglimit.Ui_Dialog()
self.mw = mw self.form.setupUi(self)
self.parent_: Optional[aqt.customstudy.CustomStudy] = parent self.on_success = on_success
self.deck = self.parent_.deck
self.dialog = aqt.forms.taglimit.Ui_Dialog()
self.dialog.setupUi(self)
disable_help_button(self) disable_help_button(self)
s = QShortcut( s = QShortcut(
QKeySequence("ctrl+d"), QKeySequence("ctrl+d"),
self.dialog.activeList, self.form.activeList,
context=Qt.ShortcutContext.WidgetShortcut, context=Qt.ShortcutContext.WidgetShortcut,
) )
qconnect(s.activated, self.dialog.activeList.clearSelection) qconnect(s.activated, self.form.activeList.clearSelection)
s = QShortcut( s = QShortcut(
QKeySequence("ctrl+d"), QKeySequence("ctrl+d"),
self.dialog.inactiveList, self.form.inactiveList,
context=Qt.ShortcutContext.WidgetShortcut, context=Qt.ShortcutContext.WidgetShortcut,
) )
qconnect(s.activated, self.dialog.inactiveList.clearSelection) qconnect(s.activated, self.form.inactiveList.clearSelection)
self.rebuildTagList() self.build_tag_lists()
restoreGeom(self, "tagLimit") restoreGeom(self, "tagLimit")
self.exec() self.open()
def rebuildTagList(self) -> None: def build_tag_lists(self) -> None:
usertags = self.mw.col.tags.by_deck(self.deck["id"], True) def add_tag(tag: str, select: bool, list: QListWidget) -> None:
yes = self.deck.get("activeTags", []) item = QListWidgetItem(tag.replace("_", " "))
no = self.deck.get("inactiveTags", []) list.addItem(item)
yesHash = {} if select:
noHash = {} idx = list.indexFromItem(item)
for y in yes: list.selectionModel().select(
yesHash[y] = True idx, QItemSelectionModel.SelectionFlag.Select
for n in no: )
noHash[n] = True
groupedTags = [] had_included_tag = False
usertags.sort()
groupedTags.append(usertags) for tag in self.tags:
self.tags_list = [] if tag.include:
for tags in groupedTags: had_included_tag = True
for t in tags: add_tag(tag.name, tag.include, self.form.activeList)
self.tags_list.append(t) add_tag(tag.name, tag.exclude, self.form.inactiveList)
item = QListWidgetItem(t.replace("_", " "))
self.dialog.activeList.addItem(item) if had_included_tag:
if t in yesHash: self.form.activeCheck.setChecked(True)
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 reject(self) -> None: def reject(self) -> None:
QDialog.reject(self) QDialog.reject(self)
def accept(self) -> None: def accept(self) -> None:
include_tags = exclude_tags = [] include_tags = []
# gather yes/no tags exclude_tags = []
for c in range(self.dialog.activeList.count()): want_active = self.form.activeCheck.isChecked()
for c, tag in enumerate(self.tags):
# active # active
if self.dialog.activeCheck.isChecked(): if want_active:
item = self.dialog.activeList.item(c) item = self.form.activeList.item(c)
idx = self.dialog.activeList.indexFromItem(item) idx = self.form.activeList.indexFromItem(item)
if self.dialog.activeList.selectionModel().isSelected(idx): if self.form.activeList.selectionModel().isSelected(idx):
include_tags.append(self.tags_list[c]) include_tags.append(tag.name)
# inactive # inactive
item = self.dialog.inactiveList.item(c) item = self.form.inactiveList.item(c)
idx = self.dialog.inactiveList.indexFromItem(item) idx = self.form.inactiveList.indexFromItem(item)
if self.dialog.inactiveList.selectionModel().isSelected(idx): if self.form.inactiveList.selectionModel().isSelected(idx):
exclude_tags.append(self.tags_list[c]) exclude_tags.append(tag.name)
if (len(include_tags) + len(exclude_tags)) > 100: if (len(include_tags) + len(exclude_tags)) > 100:
showWarning(with_collapsed_whitespace(tr.errors_100_tags_max())) showWarning(with_collapsed_whitespace(tr.errors_100_tags_max()))
return 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") saveGeom(self, "tagLimit")
QDialog.accept(self) 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> { fn custom_study(&self, input: pb::CustomStudyRequest) -> Result<pb::OpChanges> {
self.with_col(|col| col.custom_study(input)).map(Into::into) 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 { 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. /// Auxillary deck state, stored in the config table.
#[derive(Debug, Clone, Copy, IntoStaticStr)] #[derive(Debug, Clone, Copy, IntoStaticStr)]
#[strum(serialize_all = "camelCase")] #[strum(serialize_all = "camelCase")]
enum DeckConfigKey { pub enum DeckConfigKey {
LastNotetype, LastNotetype,
CustomStudyIncludeTags,
CustomStudyExcludeTags,
} }
impl DeckConfigKey { 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)) 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 slog::warn;
use strum::IntoStaticStr; 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::*}; use crate::{backend_proto::preferences::Backups, prelude::*};
/// Only used when updating/undoing. /// Only used when updating/undoing.
@ -112,10 +114,10 @@ impl Collection {
} }
// /// Get config item, returning default value if missing/invalid. // /// 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 where
T: DeserializeOwned + Default, T: DeserializeOwned + Default,
K: Into<&'static str>, K: Into<&'a str>,
{ {
self.get_config_optional(key).unwrap_or_default() 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> { impl DeckTreeNode {
if top.deck_id == target.0 { /// Locate provided deck in tree, and return it.
return Some(top); 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 { for child in self.children {
if let Some(node) = get_subnode(child, target) { if let Some(node) = child.get_deck(deck_id) {
return Some(node); return Some(node);
} }
} }
@ -257,6 +259,15 @@ fn get_subnode(top: DeckTreeNode, target: DeckId) -> Option<DeckTreeNode> {
None 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)] #[derive(Serialize_tuple)]
pub(crate) struct LegacyDueCounts { pub(crate) struct LegacyDueCounts {
name: String, name: String,
@ -330,7 +341,7 @@ impl Collection {
pub fn current_deck_tree(&mut self) -> Result<Option<DeckTreeNode>> { pub fn current_deck_tree(&mut self) -> Result<Option<DeckTreeNode>> {
let target = self.get_current_deck_id(); let target = self.get_current_deck_id();
let tree = self.deck_tree(Some(TimestampSecs::now()))?; let tree = self.deck_tree(Some(TimestampSecs::now()))?;
Ok(get_subnode(tree, target)) Ok(tree.get_deck(target))
} }
pub fn set_deck_collapsed( 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 struct StateChanges {
pub card: bool, pub card: bool,
pub note: bool, pub note: bool,
@ -105,12 +105,13 @@ pub struct StateChanges {
pub mtime: bool, pub mtime: bool,
} }
#[derive(Debug, Clone)] #[derive(Debug, PartialEq, Clone)]
pub struct OpChanges { pub struct OpChanges {
pub op: Op, pub op: Op,
pub changes: StateChanges, pub changes: StateChanges,
} }
#[derive(Debug, PartialEq)]
pub struct OpOutput<T> { pub struct OpOutput<T> {
pub output: T, pub output: T,
pub changes: OpChanges, pub changes: OpChanges,

View file

@ -1,12 +1,15 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use std::collections::HashSet;
use super::FilteredDeckForUpdate; use super::FilteredDeckForUpdate;
use crate::{ use crate::{
backend_proto::{ backend_proto::{
self as pb, self as pb,
custom_study_request::{cram::CramKind, Cram, Value as CustomStudyValue}, custom_study_request::{cram::CramKind, Cram, Value as CustomStudyValue},
}, },
config::DeckConfigKey,
decks::{FilteredDeck, FilteredSearchOrder, FilteredSearchTerm}, decks::{FilteredDeck, FilteredSearchOrder, FilteredSearchTerm},
error::{CustomStudyError, FilteredDeckError}, error::{CustomStudyError, FilteredDeckError},
prelude::*, prelude::*,
@ -17,11 +20,63 @@ impl Collection {
pub fn custom_study(&mut self, input: pb::CustomStudyRequest) -> Result<OpOutput<()>> { pub fn custom_study(&mut self, input: pb::CustomStudyRequest) -> Result<OpOutput<()>> {
self.transact(Op::CreateCustomStudy, |col| col.custom_study_inner(input)) 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 { impl Collection {
fn custom_study_inner(&mut self, input: pb::CustomStudyRequest) -> Result<()> { 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 match input
.value .value
@ -29,23 +84,48 @@ impl Collection {
{ {
CustomStudyValue::NewLimitDelta(delta) => { CustomStudyValue::NewLimitDelta(delta) => {
let today = self.current_due_day(0)?; 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) => { CustomStudyValue::ReviewLimitDelta(delta) => {
let today = self.current_due_day(0)?; 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) => { 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) => { 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) => { 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) => { 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> { fn cram_config(deck_name: String, cram: &Cram) -> Result<FilteredDeck> {
let (reschedule, nodes, order) = match CramKind::from_i32(cram.kind).unwrap_or_default() { let (reschedule, nodes, order) = match cram.kind() {
CramKind::New => ( CramKind::New => (
true, true,
SearchBuilder::from(StateKind::New), SearchBuilder::from(StateKind::New),
@ -158,8 +238,8 @@ fn cram_config(deck_name: String, cram: Cram) -> Result<FilteredDeck> {
let search = nodes let search = nodes
.and_join(&mut tags_to_nodes( .and_join(&mut tags_to_nodes(
cram.tags_to_include, &cram.tags_to_include,
cram.tags_to_exclude, &cram.tags_to_exclude,
)) ))
.and(SearchNode::from_deck_name(&deck_name)) .and(SearchNode::from_deck_name(&deck_name))
.write(); .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( let include_nodes = SearchBuilder::any(
tags_to_include tags_to_include
.iter() .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) 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 std::borrow::Cow;
use rusqlite::{params_from_iter, types::FromSql};
use sqlwriter::{RequiredTable, SqlWriter};
pub use builder::{Negated, SearchBuilder}; pub use builder::{Negated, SearchBuilder};
pub use parser::{ pub use parser::{
parse as parse_search, Node, PropertyKind, RatingKind, SearchNode, StateKind, TemplateKind, 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; pub use writer::replace_search_node;
use crate::{ use crate::{
@ -216,6 +215,29 @@ impl Collection {
.execute(params_from_iter(args)) .execute(params_from_iter(args))
.map_err(Into::into) .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. /// Add the order clause to the sql.

View file

@ -195,6 +195,20 @@ impl super::SqliteStorage {
self.clear_searched_notes_table()?; self.clear_searched_notes_table()?;
Ok(out) 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>> pub(crate) fn get_note_tags_by_predicate<F>(&mut self, want: F) -> Result<Vec<NoteTags>>
where where
@ -219,7 +233,7 @@ impl super::SqliteStorage {
Ok(()) Ok(())
} }
fn setup_searched_notes_table(&self) -> Result<()> { pub(crate) fn setup_searched_notes_table(&self) -> Result<()> {
self.db self.db
.execute_batch(include_str!("search_nids_setup.sql"))?; .execute_batch(include_str!("search_nids_setup.sql"))?;
Ok(()) Ok(())

View file

@ -5,6 +5,7 @@ mod bulkadd;
mod complete; mod complete;
mod findreplace; mod findreplace;
mod matcher; mod matcher;
mod notes;
mod register; mod register;
mod remove; mod remove;
mod rename; 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)
}
}