mirror of
https://github.com/ankitects/anki.git
synced 2025-09-21 07:22:23 -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-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 }
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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"""
|
||||||
|
|
|
@ -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"""
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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)
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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(())
|
||||||
|
|
|
@ -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
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