From ce243c2cae8dec30c88ef7e70851dd8ef2fe2a98 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Mon, 8 Mar 2021 23:23:24 +1000 Subject: [PATCH] Simplify note adding and the deck/notetype choosers The existing code was really difficult to reason about: - The default notetype depended on the selected deck, and vice versa, and this logic was buried in the deck and notetype choosing screens, and models.py. - Changes to the notetype were not passed back directly, but were fired via a hook, which changed any screen in the app that had a notetype selector. It also wasn't great for performance, as the most recent deck and tags were embedded in the notetype, which can be expensive to save and sync for large notetypes. To address these points: - The current deck for a notetype, and notetype for a deck, are now stored in separate config variables, instead of directly in the deck or notetype. These are cheap to read and write, and we'll be able to sync them individually in the future once config syncing is updated in the future. I seem to recall some users not wanting the tag saving behaviour, so I've dropped that for now, but if people end up missing it, it would be simple to add as an extra auxiliary config variable. - The logic for getting the starting deck and notetype has been moved into the backend. It should be the same as the older Python code, with one exception: when "change deck depending on notetype" is enabled in the preferences, it will start with the current notetype ("curModel"), instead of first trying to get a deck-specific notetype. - ModelChooser has been duplicated into notetypechooser.py, and it has been updated to solely be concerned with keeping track of a selected notetype - it no longer alters global state. --- pylib/.pylintrc | 3 +- pylib/anki/collection.py | 47 ++++++-- pylib/anki/decks.py | 3 +- pylib/anki/notes.py | 7 +- pylib/tests/test_collection.py | 6 +- qt/aqt/addcards.py | 190 +++++++++++++++++++------------- qt/aqt/deckchooser.py | 126 ++++++++++----------- qt/aqt/editor.py | 13 +-- qt/aqt/importing.py | 2 +- qt/aqt/main.py | 2 +- qt/aqt/modelchooser.py | 2 + qt/aqt/notetypechooser.py | 145 ++++++++++++++++++++++++ rslib/backend.proto | 14 ++- rslib/src/adding.rs | 113 +++++++++++++++++++ rslib/src/backend/adding.rs | 14 +++ rslib/src/backend/config.rs | 1 + rslib/src/backend/generic.rs | 6 + rslib/src/backend/mod.rs | 20 ++++ rslib/src/config/deck.rs | 45 ++++++++ rslib/src/config/mod.rs | 29 ++--- rslib/src/config/notetype.rs | 52 +++++++++ rslib/src/decks/mod.rs | 1 + rslib/src/lib.rs | 1 + rslib/src/notes/mod.rs | 5 +- rslib/src/notetype/mod.rs | 1 + rslib/src/prelude.rs | 2 +- rslib/src/storage/config/mod.rs | 11 ++ 27 files changed, 678 insertions(+), 183 deletions(-) create mode 100644 qt/aqt/notetypechooser.py create mode 100644 rslib/src/adding.rs create mode 100644 rslib/src/backend/adding.rs create mode 100644 rslib/src/config/deck.rs create mode 100644 rslib/src/config/notetype.rs diff --git a/pylib/.pylintrc b/pylib/.pylintrc index 08be0d577..53b937e12 100644 --- a/pylib/.pylintrc +++ b/pylib/.pylintrc @@ -7,7 +7,8 @@ ignored-classes= FormatTimespanIn, AnswerCardIn, UnburyCardsInCurrentDeckIn, - BuryOrSuspendCardsIn + BuryOrSuspendCardsIn, + NoteIsDuplicateOrEmptyOut [MESSAGES CONTROL] disable=C,R, diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index 73b3b1a5c..6212ef34a 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -28,7 +28,7 @@ from anki.decks import DeckManager from anki.errors import AnkiError, DBError from anki.lang import TR, FormatTimeSpan from anki.media import MediaManager, media_paths_from_col_path -from anki.models import ModelManager +from anki.models import ModelManager, NoteType from anki.notes import Note from anki.sched import Scheduler as V1Scheduler from anki.scheduler import Scheduler as V2TestScheduler @@ -55,6 +55,7 @@ GraphPreferences = _pb.GraphPreferences BuiltinSort = _pb.SortOrder.Builtin Preferences = _pb.Preferences UndoStatus = _pb.UndoStatus +DefaultsForAdding = _pb.DeckAndNotetype @dataclass @@ -360,12 +361,8 @@ class Collection: # Notes ########################################################################## - def noteCount(self) -> Any: - return self.db.scalar("select count() from notes") - - def newNote(self, forDeck: bool = True) -> Note: - "Return a new note with the current model." - return Note(self, self.models.current(forDeck)) + def new_note(self, notetype: NoteType) -> Note: + return Note(self, notetype) def add_note(self, note: Note, deck_id: int) -> None: note.id = self._backend.add_note(note=note._to_backend_note(), deck_id=deck_id) @@ -385,8 +382,44 @@ class Collection: def card_ids_of_note(self, note_id: int) -> Sequence[int]: return self._backend.cards_of_note(note_id) + def defaults_for_adding( + self, *, current_review_card: Optional[Card] + ) -> DefaultsForAdding: + """Get starting deck and notetype for add screen. + An option in the preferences controls whether this will be based on the current deck + or current notetype. + """ + if card := current_review_card: + home_deck = card.odid or card.did + else: + home_deck = 0 + + return self._backend.defaults_for_adding( + home_deck_of_current_review_card=home_deck, + ) + + def default_deck_for_notetype(self, notetype_id: int) -> Optional[int]: + """If 'change deck depending on notetype' is enabled in the preferences, + return the last deck used with the provided notetype, if any..""" + if self.get_config_bool(Config.Bool.ADDING_DEFAULTS_TO_CURRENT_DECK): + return None + + return ( + self._backend.default_deck_for_notetype( + ntid=notetype_id, + ) + or None + ) + # legacy + def noteCount(self) -> int: + return self.db.scalar("select count() from notes") + + def newNote(self, forDeck: bool = True) -> Note: + "Return a new note with the current model." + return Note(self, self.models.current(forDeck)) + def addNote(self, note: Note) -> int: self.add_note(note, note.model()["did"]) return len(note.cards()) diff --git a/pylib/anki/decks.py b/pylib/anki/decks.py index 8e7b7c755..41be21647 100644 --- a/pylib/anki/decks.py +++ b/pylib/anki/decks.py @@ -374,7 +374,7 @@ class DeckManager: return deck["name"] return self.col.tr(TR.DECKS_NO_DECK) - def nameOrNone(self, did: int) -> Optional[str]: + def name_if_exists(self, did: int) -> Optional[str]: deck = self.get(did, default=False) if deck: return deck["name"] @@ -562,3 +562,4 @@ class DeckManager: # legacy newDyn = new_filtered + nameOrNone = name_if_exists diff --git a/pylib/anki/notes.py b/pylib/anki/notes.py index 3e6a3af91..b438937db 100644 --- a/pylib/anki/notes.py +++ b/pylib/anki/notes.py @@ -14,6 +14,8 @@ from anki.consts import MODEL_STD from anki.models import NoteType, Template from anki.utils import joinFields +DuplicateOrEmptyResult = _pb.NoteIsDuplicateOrEmptyOut.State + class Note: # not currently exposed @@ -186,8 +188,9 @@ class Note: # Unique/duplicate check ################################################## - def dupeOrEmpty(self) -> int: - "1 if first is empty; 2 if first is a duplicate, 0 otherwise." + def duplicate_or_empty(self) -> DuplicateOrEmptyResult.V: return self.col._backend.note_is_duplicate_or_empty( self._to_backend_note() ).state + + dupeOrEmpty = duplicate_or_empty diff --git a/pylib/tests/test_collection.py b/pylib/tests/test_collection.py index 59d78461b..1a0c64822 100644 --- a/pylib/tests/test_collection.py +++ b/pylib/tests/test_collection.py @@ -71,15 +71,15 @@ def test_noteAddDelete(): c0 = note.cards()[0] assert "three" in c0.q() # it should not be a duplicate - assert not note.dupeOrEmpty() + assert not note.duplicate_or_empty() # now let's make a duplicate note2 = col.newNote() note2["Front"] = "one" note2["Back"] = "" - assert note2.dupeOrEmpty() + assert note2.duplicate_or_empty() # empty first field should not be permitted either note2["Front"] = " " - assert note2.dupeOrEmpty() + assert note2.duplicate_or_empty() def test_fieldChecksum(): diff --git a/qt/aqt/addcards.py b/qt/aqt/addcards.py index 01a9821b3..6c38f16e3 100644 --- a/qt/aqt/addcards.py +++ b/qt/aqt/addcards.py @@ -1,17 +1,18 @@ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -from typing import Any, Callable, List, Optional + +from typing import Callable, List, Optional import aqt.deckchooser import aqt.editor import aqt.forms -import aqt.modelchooser from anki.collection import SearchNode from anki.consts import MODEL_CLOZE -from anki.notes import Note +from anki.notes import DuplicateOrEmptyResult, Note from anki.utils import htmlToTextLine, isMac from aqt import AnkiQt, gui_hooks from aqt.main import ResetReason +from aqt.notetypechooser import NoteTypeChooser from aqt.qt import * from aqt.sound import av_player from aqt.utils import ( @@ -42,15 +43,13 @@ class AddCards(QDialog): disable_help_button(self) self.setMinimumHeight(300) self.setMinimumWidth(400) - self.setupChoosers() + self.setup_choosers() self.setupEditor() self.setupButtons() - self.onReset() + self._load_new_note() self.history: List[int] = [] - self.previousNote: Optional[Note] = None + self._last_added_note: Optional[Note] = None restoreGeom(self, "add") - gui_hooks.state_did_reset.append(self.onReset) - gui_hooks.current_note_type_did_change.append(self.onModelChange) addCloseShortcut(self) gui_hooks.add_cards_did_init(self) self.show() @@ -58,11 +57,20 @@ class AddCards(QDialog): def setupEditor(self) -> None: self.editor = aqt.editor.Editor(self.mw, self.form.fieldsArea, self, True) - def setupChoosers(self) -> None: - self.modelChooser = aqt.modelchooser.ModelChooser( - self.mw, self.form.modelArea, on_activated=self.show_notetype_selector + def setup_choosers(self) -> None: + defaults = self.mw.col.defaults_for_adding( + current_review_card=self.mw.reviewer.card + ) + self.notetype_chooser = NoteTypeChooser( + mw=self.mw, + widget=self.form.modelArea, + starting_notetype_id=defaults.notetype_id, + on_button_activated=self.show_notetype_selector, + on_notetype_changed=self.on_notetype_change, + ) + self.deck_chooser = aqt.deckchooser.DeckChooser( + self.mw, self.form.deckArea, starting_deck_id=defaults.deck_id ) - self.deckChooser = aqt.deckchooser.DeckChooser(self.mw, self.form.deckArea) def helpRequested(self) -> None: openHelp(HelpPage.ADDING_CARD_AND_NOTE) @@ -72,7 +80,7 @@ class AddCards(QDialog): ar = QDialogButtonBox.ActionRole # add self.addButton = bb.addButton(tr(TR.ACTIONS_ADD), ar) - qconnect(self.addButton.clicked, self.addCards) + qconnect(self.addButton.clicked, self.add_current_note) self.addButton.setShortcut(QKeySequence("Ctrl+Return")) self.addButton.setToolTip(shortcut(tr(TR.ADDING_ADD_SHORTCUT_CTRLANDENTER))) # close @@ -99,42 +107,52 @@ class AddCards(QDialog): self.editor.setNote(note, focusTo=0) def show_notetype_selector(self) -> None: - self.editor.saveNow(self.modelChooser.onModelChange) + self.editor.saveNow(self.notetype_chooser.choose_notetype) - def onModelChange(self, unused: Any = None) -> None: - oldNote = self.editor.note - note = self.mw.col.newNote() - self.previousNote = None - if oldNote: - oldFields = list(oldNote.keys()) - newFields = list(note.keys()) - for n, f in enumerate(note.model()["flds"]): - fieldName = f["name"] + def on_notetype_change(self, notetype_id: int) -> None: + # need to adjust current deck? + if deck_id := self.mw.col.default_deck_for_notetype(notetype_id): + self.deck_chooser.selected_deck_id = deck_id + + # only used for detecting changed sticky fields on close + self._last_added_note = None + + # copy fields into new note with the new notetype + old = self.editor.note + new = self._new_note() + if old: + old_fields = list(old.keys()) + new_fields = list(new.keys()) + for n, f in enumerate(new.model()["flds"]): + field_name = f["name"] # copy identical fields - if fieldName in oldFields: - note[fieldName] = oldNote[fieldName] - elif n < len(oldNote.model()["flds"]): + if field_name in old_fields: + new[field_name] = old[field_name] + elif n < len(old.model()["flds"]): # set non-identical fields by field index - oldFieldName = oldNote.model()["flds"][n]["name"] - if oldFieldName not in newFields: - note.fields[n] = oldNote.fields[n] - self.editor.note = note - # When on model change is called, reset is necessarily called. - # Reset load note, so it is not required to load it here. + old_field_name = old.model()["flds"][n]["name"] + if old_field_name not in new_fields: + new.fields[n] = old.fields[n] - def onReset(self, model: None = None, keep: bool = False) -> None: - oldNote = self.editor.note - note = self.mw.col.newNote() - flds = note.model()["flds"] - # copy fields from old note - if oldNote: - for n in range(min(len(note.fields), len(oldNote.fields))): - if not keep or flds[n]["sticky"]: - note.fields[n] = oldNote.fields[n] + # and update editor state + self.editor.note = new + self.editor.loadNote() + + def _load_new_note(self, sticky_fields_from: Optional[Note] = None) -> None: + note = self._new_note() + if old_note := sticky_fields_from: + flds = note.model()["flds"] + # copy fields from old note + if old_note: + for n in range(min(len(note.fields), len(old_note.fields))): + if flds[n]["sticky"]: + note.fields[n] = old_note.fields[n] self.setAndFocusNote(note) - def removeTempNote(self, note: Note) -> None: - print("removeTempNote() will go away") + def _new_note(self) -> Note: + return self.mw.col.new_note( + self.mw.col.models.get(self.notetype_chooser.selected_notetype_id) + ) def addHistory(self, note: Note) -> None: self.history.insert(0, note.id) @@ -163,42 +181,55 @@ class AddCards(QDialog): def editHistory(self, nid: int) -> None: aqt.dialogs.open("Browser", self.mw, search=(SearchNode(nid=nid),)) - def addNote(self, note: Note) -> Optional[Note]: - note.model()["did"] = self.deckChooser.selectedId() - ret = note.dupeOrEmpty() - problem = None - if ret == 1: - problem = tr(TR.ADDING_THE_FIRST_FIELD_IS_EMPTY) - problem = gui_hooks.add_cards_will_add_note(problem, note) - if problem is not None: - showWarning(problem, help=HelpPage.ADDING_CARD_AND_NOTE) - return None - if note.model()["type"] == MODEL_CLOZE: - if not note.cloze_numbers_in_fields(): - if not askUser(tr(TR.ADDING_YOU_HAVE_A_CLOZE_DELETION_NOTE)): - return None - self.mw.col.add_note(note, self.deckChooser.selectedId()) - self.addHistory(note) - self.previousNote = note - self.mw.requireReset(reason=ResetReason.AddCardsAddNote, context=self) - gui_hooks.add_cards_did_add_note(note) - return note + def add_current_note(self) -> None: + self.editor.saveNow(self._add_current_note) - def addCards(self) -> None: - self.editor.saveNow(self._addCards) + def _add_current_note(self) -> None: + note = self.editor.note - def _addCards(self) -> None: - self.editor.saveAddModeVars() - if not self.addNote(self.editor.note): + if not self._note_can_be_added(note): return + target_deck_id = self.deck_chooser.selected_deck_id + self.mw.col.add_note(note, target_deck_id) + + # only used for detecting changed sticky fields on close + self._last_added_note = note + + self.addHistory(note) + self.mw.requireReset(reason=ResetReason.AddCardsAddNote, context=self) + # workaround for PyQt focus bug self.editor.hideCompleters() tooltip(tr(TR.ADDING_ADDED), period=500) av_player.stop_and_clear_queue() - self.onReset(keep=True) - self.mw.col.autosave() + self._load_new_note(sticky_fields_from=note) + self.mw.col.autosave() # fixme: + + gui_hooks.add_cards_did_add_note(note) + + def _note_can_be_added(self, note: Note) -> bool: + result = note.duplicate_or_empty() + if result == DuplicateOrEmptyResult.EMPTY: + problem = tr(TR.ADDING_THE_FIRST_FIELD_IS_EMPTY) + else: + # duplicate entries are allowed these days + problem = None + + # filter problem through add-ons + problem = gui_hooks.add_cards_will_add_note(problem, note) + if problem is not None: + showWarning(problem, help=HelpPage.ADDING_CARD_AND_NOTE) + return False + + # missing cloze deletion? + if note.model()["type"] == MODEL_CLOZE: + if not note.cloze_numbers_in_fields(): + if not askUser(tr(TR.ADDING_YOU_HAVE_A_CLOZE_DELETION_NOTE)): + return False + + return True def keyPressEvent(self, evt: QKeyEvent) -> None: "Show answer on RET or register answer." @@ -211,12 +242,9 @@ class AddCards(QDialog): self.ifCanClose(self._reject) def _reject(self) -> None: - gui_hooks.state_did_reset.remove(self.onReset) - gui_hooks.current_note_type_did_change.remove(self.onModelChange) av_player.stop_and_clear_queue() self.editor.cleanup() - self.modelChooser.cleanup() - self.deckChooser.cleanup() + self.notetype_chooser.cleanup() self.mw.maybeReset() saveGeom(self, "add") aqt.dialogs.markClosed("AddCards") @@ -224,7 +252,7 @@ class AddCards(QDialog): def ifCanClose(self, onOk: Callable) -> None: def afterSave() -> None: - ok = self.editor.fieldsAreBlank(self.previousNote) or askUser( + ok = self.editor.fieldsAreBlank(self._last_added_note) or askUser( tr(TR.ADDING_CLOSE_AND_LOSE_CURRENT_INPUT), defaultno=True ) if ok: @@ -238,3 +266,15 @@ class AddCards(QDialog): cb() self.ifCanClose(doClose) + + # legacy aliases + + addCards = add_current_note + _addCards = _add_current_note + onModelChange = on_notetype_change + + def addNote(self, note: Note) -> None: + print("addNote() is obsolete") + + def removeTempNote(self, note: Note) -> None: + print("removeTempNote() will go away") diff --git a/qt/aqt/deckchooser.py b/qt/aqt/deckchooser.py index d7f806c4a..70056aaa0 100644 --- a/qt/aqt/deckchooser.py +++ b/qt/aqt/deckchooser.py @@ -1,61 +1,79 @@ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -from typing import Any -from aqt import AnkiQt, gui_hooks +from typing import Optional + +from aqt import AnkiQt from aqt.qt import * from aqt.utils import TR, HelpPage, shortcut, tr class DeckChooser(QHBoxLayout): def __init__( - self, mw: AnkiQt, widget: QWidget, label: bool = True, start: Any = None + self, + mw: AnkiQt, + widget: QWidget, + label: bool = True, + starting_deck_id: Optional[int] = None, ) -> None: QHBoxLayout.__init__(self) self._widget = widget # type: ignore self.mw = mw - self.label = label + self._setup_ui(show_label=label) + + self._selected_deck_id = 0 + # default to current deck if starting id not provided + if starting_deck_id is None: + starting_deck_id = self.mw.col.get_config("curDeck", default=1) or 1 + self.selected_deck_id = starting_deck_id + + def _setup_ui(self, show_label: bool) -> None: self.setContentsMargins(0, 0, 0, 0) self.setSpacing(8) - self.setupDecks() - self._widget.setLayout(self) - gui_hooks.current_note_type_did_change.append(self.onModelChangeNew) - def setupDecks(self) -> None: - if self.label: + # text label before button? + if show_label: self.deckLabel = QLabel(tr(TR.DECKS_DECK)) self.addWidget(self.deckLabel) + # decks box - self.deck = QPushButton(clicked=self.onDeckChange) # type: ignore + self.deck = QPushButton() + qconnect(self.deck.clicked, self.choose_deck) self.deck.setAutoDefault(False) self.deck.setToolTip(shortcut(tr(TR.QT_MISC_TARGET_DECK_CTRLANDD))) - QShortcut(QKeySequence("Ctrl+D"), self._widget, activated=self.onDeckChange) # type: ignore - self.addWidget(self.deck) - # starting label - if self.mw.col.conf.get("addToCur", True): - col = self.mw.col - did = col.conf["curDeck"] - if col.decks.isDyn(did): - # if they're reviewing, try default to current card - c = self.mw.reviewer.card - if self.mw.state == "review" and c: - if not c.odid: - did = c.did - else: - did = c.odid - else: - did = 1 - self.setDeckName( - self.mw.col.decks.nameOrNone(did) or tr(TR.QT_MISC_DEFAULT) - ) - else: - self.setDeckName( - self.mw.col.decks.nameOrNone(self.mw.col.models.current()["did"]) - or tr(TR.QT_MISC_DEFAULT) - ) - # layout + qconnect( + QShortcut(QKeySequence("Ctrl+D"), self._widget).activated, self.choose_deck + ) sizePolicy = QSizePolicy(QSizePolicy.Policy(7), QSizePolicy.Policy(0)) self.deck.setSizePolicy(sizePolicy) + self.addWidget(self.deck) + + self._widget.setLayout(self) + + def selected_deck_name(self) -> str: + return ( + self.mw.col.decks.name_if_exists(self.selected_deck_id) or "missing default" + ) + + @property + def selected_deck_id(self) -> int: + self._ensure_selected_deck_valid() + + return self._selected_deck_id + + @selected_deck_id.setter + def selected_deck_id(self, id: int) -> None: + if id != self._selected_deck_id: + self._selected_deck_id = id + self._ensure_selected_deck_valid() + self._update_button_label() + + def _ensure_selected_deck_valid(self) -> None: + if not self.mw.col.decks.get(self._selected_deck_id, default=False): + self.selected_deck_id = 1 + + def _update_button_label(self) -> None: + self.deck.setText(self.selected_deck_name().replace("&", "&&")) def show(self) -> None: self._widget.show() # type: ignore @@ -63,23 +81,10 @@ class DeckChooser(QHBoxLayout): def hide(self) -> None: self._widget.hide() # type: ignore - def cleanup(self) -> None: - gui_hooks.current_note_type_did_change.remove(self.onModelChangeNew) - - def onModelChangeNew(self, unused: Any = None) -> None: - self.onModelChange() - - def onModelChange(self) -> None: - if not self.mw.col.conf.get("addToCur", True): - self.setDeckName( - self.mw.col.decks.nameOrNone(self.mw.col.models.current()["did"]) - or tr(TR.QT_MISC_DEFAULT) - ) - - def onDeckChange(self) -> None: + def choose_deck(self) -> None: from aqt.studydeck import StudyDeck - current = self.deckName() + current = self.selected_deck_name() ret = StudyDeck( self.mw, current=current, @@ -91,20 +96,15 @@ class DeckChooser(QHBoxLayout): geomKey="selectDeck", ) if ret.name: - self.setDeckName(ret.name) + self.selected_deck_id = self.mw.col.decks.byName(ret.name)["id"] - def setDeckName(self, name: str) -> None: - self.deck.setText(name.replace("&", "&&")) - self._deckName = name + # legacy - def deckName(self) -> str: - return self._deckName + onDeckChange = choose_deck + deckName = selected_deck_name def selectedId(self) -> int: - # save deck name - name = self.deckName() - if not name.strip(): - did = 1 - else: - did = self.mw.col.decks.id(name) - return did + return self.selected_deck_id + + def cleanup(self) -> None: + pass diff --git a/qt/aqt/editor.py b/qt/aqt/editor.py index 7d66028a0..3346dfbc6 100644 --- a/qt/aqt/editor.py +++ b/qt/aqt/editor.py @@ -563,7 +563,7 @@ class Editor: def checkValid(self) -> None: cols = [""] * len(self.note.fields) - err = self.note.dupeOrEmpty() + err = self.note.duplicate_or_empty() if err == 2: cols[0] = "dupe" @@ -680,19 +680,16 @@ class Editor: self._save_current_note() gui_hooks.editor_did_update_tags(self.note) - def saveAddModeVars(self) -> None: - if self.addMode: - # save tags to model - m = self.note.model() - m["tags"] = self.note.tags - self.mw.col.models.save(m, updateReqs=False) - def hideCompleters(self) -> None: self.tags.hideCompleter() def onFocusTags(self) -> None: self.tags.setFocus() + # legacy + def saveAddModeVars(self) -> None: + pass + # Format buttons ###################################################################### diff --git a/qt/aqt/importing.py b/qt/aqt/importing.py index 2d00fbcde..c80baffe1 100644 --- a/qt/aqt/importing.py +++ b/qt/aqt/importing.py @@ -191,7 +191,7 @@ class ImportDialog(QDialog): self.mw.pm.profile["allowHTML"] = self.importer.allowHTML self.importer.tagModified = self.frm.tagModified.text() self.mw.pm.profile["tagModified"] = self.importer.tagModified - did = self.deck.selectedId() + did = self.deck.selected_deck_id self.importer.model["did"] = did self.mw.col.models.save(self.importer.model, updateReqs=False) self.mw.col.decks.select(did) diff --git a/qt/aqt/main.py b/qt/aqt/main.py index 1fe2af6bb..6a99be3cc 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -662,7 +662,7 @@ class AnkiQt(QMainWindow): def _selectedDeck(self) -> Optional[Deck]: did = self.col.decks.selected() - if not self.col.decks.nameOrNone(did): + if not self.col.decks.name_if_exists(did): showInfo(tr(TR.QT_MISC_PLEASE_SELECT_A_DECK)) return None return self.col.decks.get(did) diff --git a/qt/aqt/modelchooser.py b/qt/aqt/modelchooser.py index cf86deb0e..0ffe9b34f 100644 --- a/qt/aqt/modelchooser.py +++ b/qt/aqt/modelchooser.py @@ -8,6 +8,8 @@ from aqt.utils import TR, HelpPage, shortcut, tr class ModelChooser(QHBoxLayout): + "New code should prefer NoteTypeChooser." + def __init__( self, mw: AnkiQt, diff --git a/qt/aqt/notetypechooser.py b/qt/aqt/notetypechooser.py new file mode 100644 index 000000000..dfddd5bd1 --- /dev/null +++ b/qt/aqt/notetypechooser.py @@ -0,0 +1,145 @@ +# Copyright: Ankitects Pty Ltd and contributors +# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +from typing import List, Optional + +from aqt import AnkiQt, gui_hooks +from aqt.qt import * +from aqt.utils import TR, HelpPage, shortcut, tr + + +class NoteTypeChooser(QHBoxLayout): + """ + Unlike the older modelchooser, this does not modify the "current model", + so changes made here do not affect other parts of the UI. To read the + currently selected notetype id, use .selected_notetype_id. + + By default, a chooser will pop up when the button is pressed. You can + override this by providing `on_button_activated`. Call .choose_notetype() + to run the normal behaviour. + + `on_notetype_changed` will be called with the new notetype ID if the user + selects a different notetype, or if the currently-selected notetype is + deleted. + """ + + def __init__( + self, + *, + mw: AnkiQt, + widget: QWidget, + starting_notetype_id: int, + on_button_activated: Optional[Callable[[], None]] = None, + on_notetype_changed: Optional[Callable[[int], None]] = None, + show_prefix_label: bool = True, + ) -> None: + QHBoxLayout.__init__(self) + self._widget = widget # type: ignore + self.mw = mw + if on_button_activated: + self.on_button_activated = on_button_activated + else: + self.on_button_activated = self.choose_notetype + self._setup_ui(show_label=show_prefix_label) + gui_hooks.state_did_reset.append(self.reset_state) + self._selected_notetype_id = 0 + # triggers UI update; avoid firing changed hook on startup + self.on_notetype_changed = None + self.selected_notetype_id = starting_notetype_id + self.on_notetype_changed = on_notetype_changed + + def _setup_ui(self, show_label: bool) -> None: + self.setContentsMargins(0, 0, 0, 0) + self.setSpacing(8) + + if show_label: + self.label = QLabel(tr(TR.NOTETYPES_TYPE)) + self.addWidget(self.label) + + # button + self.button = QPushButton() + self.button.setToolTip(shortcut(tr(TR.QT_MISC_CHANGE_NOTE_TYPE_CTRLANDN))) + qconnect( + QShortcut(QKeySequence("Ctrl+N"), self._widget).activated, + self.on_button_activated, + ) + self.button.setAutoDefault(False) + self.addWidget(self.button) + qconnect(self.button.clicked, self.on_button_activated) + sizePolicy = QSizePolicy(QSizePolicy.Policy(7), QSizePolicy.Policy(0)) + self.button.setSizePolicy(sizePolicy) + self._widget.setLayout(self) + + def cleanup(self) -> None: + gui_hooks.state_did_reset.remove(self.reset_state) + + def reset_state(self) -> None: + self._ensure_selected_notetype_valid() + + def show(self) -> None: + self._widget.show() # type: ignore + + def hide(self) -> None: + self._widget.hide() # type: ignore + + def onEdit(self) -> None: + import aqt.models + + aqt.models.Models(self.mw, self._widget) + + def choose_notetype(self) -> None: + from aqt.studydeck import StudyDeck + + current = self.selected_notetype_name() + + # edit button + edit = QPushButton(tr(TR.QT_MISC_MANAGE)) + qconnect(edit.clicked, self.onEdit) + + def nameFunc() -> List[str]: + return sorted(self.mw.col.models.allNames()) + + ret = StudyDeck( + self.mw, + names=nameFunc, + accept=tr(TR.ACTIONS_CHOOSE), + title=tr(TR.QT_MISC_CHOOSE_NOTE_TYPE), + help=HelpPage.NOTE_TYPE, + current=current, + parent=self._widget, + buttons=[edit], + cancel=True, + geomKey="selectModel", + ) + if not ret.name: + return + + notetype = self.mw.col.models.byName(ret.name) + if (id := notetype["id"]) != self._selected_notetype_id: + self.selected_notetype_id = id + + @property + def selected_notetype_id(self) -> int: + # theoretically this should not be necessary, as we're listening to + # resets + self._ensure_selected_notetype_valid() + + return self._selected_notetype_id + + @selected_notetype_id.setter + def selected_notetype_id(self, id: int) -> None: + if id != self._selected_notetype_id: + self._selected_notetype_id = id + self._ensure_selected_notetype_valid() + self._update_button_label() + if func := self.on_notetype_changed: + func(self._selected_notetype_id) + + def selected_notetype_name(self) -> str: + return self.mw.col.models.get(self.selected_notetype_id)["name"] + + def _ensure_selected_notetype_valid(self) -> None: + if not self.mw.col.models.get(self._selected_notetype_id): + self.selected_notetype_id = self.mw.col.models.all_names_and_ids()[0].id + + def _update_button_label(self) -> None: + self.button.setText(self.selected_notetype_name().replace("&", "&&")) diff --git a/rslib/backend.proto b/rslib/backend.proto index f9743326f..608a52e6e 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -170,6 +170,8 @@ service BackendService { rpc NewNote(NoteTypeID) returns (Note); rpc AddNote(AddNoteIn) returns (NoteID); + rpc DefaultsForAdding(DefaultsForAddingIn) returns (DeckAndNotetype); + rpc DefaultDeckForNotetype(NoteTypeID) returns (DeckID); rpc UpdateNote(UpdateNoteIn) returns (Empty); rpc GetNote(NoteID) returns (Note); rpc RemoveNotes(RemoveNotesIn) returns (Empty); @@ -403,7 +405,7 @@ message NoteTypeConfig { Kind kind = 1; uint32 sort_field_idx = 2; string css = 3; - int64 target_deck_id = 4; + int64 target_deck_id = 4; // moved into config var string latex_pre = 5; string latex_post = 6; bool latex_svg = 7; @@ -1272,6 +1274,7 @@ message Config { COLLAPSE_CARD_STATE = 7; COLLAPSE_FLAGS = 8; SCHED_2021 = 9; + ADDING_DEFAULTS_TO_CURRENT_DECK = 10; } Key key = 1; } @@ -1406,3 +1409,12 @@ message UndoStatus { string undo = 1; string redo = 2; } + +message DefaultsForAddingIn { + int64 home_deck_of_current_review_card = 1; +} + +message DeckAndNotetype { + int64 deck_id = 1; + int64 notetype_id = 2; +} diff --git a/rslib/src/adding.rs b/rslib/src/adding.rs new file mode 100644 index 000000000..090c54a2a --- /dev/null +++ b/rslib/src/adding.rs @@ -0,0 +1,113 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use std::sync::Arc; + +use crate::prelude::*; + +pub struct DeckAndNotetype { + pub deck_id: DeckID, + pub notetype_id: NoteTypeID, +} + +impl Collection { + /// An option in the preferences screen governs the behaviour here. + /// + /// - When 'default to the current deck' is enabled, we use the current deck + /// if it's normal, the provided reviewer card's deck as a fallback, and + /// Default as a final fallback. We then fetch the last used notetype stored + /// in the deck, falling back to the global notetype, or the first available one. + /// + /// - Otherwise, each note type remembers the last deck cards were added to, + /// and we use that, defaulting to the current deck if missing, and + /// Default otherwise. + pub fn defaults_for_adding( + &mut self, + home_deck_of_reviewer_card: DeckID, + ) -> Result { + let deck_id; + let notetype_id; + if self.get_bool(BoolKey::AddingDefaultsToCurrentDeck) { + deck_id = self + .get_current_deck_for_adding(home_deck_of_reviewer_card)? + .id; + notetype_id = self.default_notetype_for_deck(deck_id)?.id; + } else { + notetype_id = self.get_current_notetype_for_adding()?.id; + deck_id = if let Some(deck_id) = self.default_deck_for_notetype(notetype_id)? { + deck_id + } else { + // default not set in notetype; fall back to current deck + self.get_current_deck_for_adding(home_deck_of_reviewer_card)? + .id + }; + } + + Ok(DeckAndNotetype { + deck_id, + notetype_id, + }) + } + + /// The currently selected deck, the home deck of the provided card, or the default deck. + fn get_current_deck_for_adding( + &mut self, + home_deck_of_reviewer_card: DeckID, + ) -> Result> { + // current deck, if not filtered + if let Some(current) = self.get_deck(self.get_current_deck_id())? { + if !current.is_filtered() { + return Ok(current); + } + } + // provided reviewer card's home deck + if let Some(home_deck) = self.get_deck(home_deck_of_reviewer_card)? { + return Ok(home_deck); + } + // default deck + self.get_deck(DeckID(1))?.ok_or(AnkiError::NotFound) + } + + fn get_current_notetype_for_adding(&mut self) -> Result> { + // try global 'current' notetype + if let Some(ntid) = self.get_current_notetype_id() { + if let Some(nt) = self.get_notetype(ntid)? { + return Ok(nt); + } + } + // try first available notetype + if let Some((ntid, _)) = self.storage.get_all_notetype_names()?.first() { + Ok(self.get_notetype(*ntid)?.unwrap()) + } else { + Err(AnkiError::NotFound) + } + } + + fn default_notetype_for_deck(&mut self, deck: DeckID) -> Result> { + // try last notetype used by deck + if let Some(ntid) = self.get_last_notetype_for_deck(deck) { + if let Some(nt) = self.get_notetype(ntid)? { + return Ok(nt); + } + } + + // fall back + self.get_current_notetype_for_adding() + } + + /// Returns the last deck added to with this notetype, provided it is valid. + /// This is optional due to the inconsistent handling, where changes in notetype + /// may need to update the current deck, but not vice versa. If a previous deck is + /// not set, we want to keep the current selection, instead of resetting it. + pub(crate) fn default_deck_for_notetype(&mut self, ntid: NoteTypeID) -> Result> { + if let Some(last_deck_id) = self.get_last_deck_added_to_for_notetype(ntid) { + if let Some(deck) = self.get_deck(last_deck_id)? { + if !deck.is_filtered() { + return Ok(Some(deck.id)); + } + } + } + + Ok(None) + } +} diff --git a/rslib/src/backend/adding.rs b/rslib/src/backend/adding.rs new file mode 100644 index 000000000..50d24512c --- /dev/null +++ b/rslib/src/backend/adding.rs @@ -0,0 +1,14 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use crate::adding::DeckAndNotetype; +use crate::backend_proto::DeckAndNotetype as DeckAndNotetypeProto; + +impl From for DeckAndNotetypeProto { + fn from(s: DeckAndNotetype) -> Self { + DeckAndNotetypeProto { + deck_id: s.deck_id.0, + notetype_id: s.notetype_id.0, + } + } +} diff --git a/rslib/src/backend/config.rs b/rslib/src/backend/config.rs index 9304dc4fd..fcac7d7cc 100644 --- a/rslib/src/backend/config.rs +++ b/rslib/src/backend/config.rs @@ -21,6 +21,7 @@ impl From for BoolKey { BoolKeyProto::CollapseCardState => BoolKey::CollapseCardState, BoolKeyProto::CollapseFlags => BoolKey::CollapseFlags, BoolKeyProto::Sched2021 => BoolKey::Sched2021, + BoolKeyProto::AddingDefaultsToCurrentDeck => BoolKey::AddingDefaultsToCurrentDeck, } } } diff --git a/rslib/src/backend/generic.rs b/rslib/src/backend/generic.rs index 0b3665f62..b3ea2be60 100644 --- a/rslib/src/backend/generic.rs +++ b/rslib/src/backend/generic.rs @@ -69,6 +69,12 @@ impl From for DeckID { } } +impl From for pb::DeckId { + fn from(did: DeckID) -> Self { + pb::DeckId { did: did.0 } + } +} + impl From for DeckConfID { fn from(dcid: pb::DeckConfigId) -> Self { DeckConfID(dcid.dcid) diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs index 14f3e5b28..aea3b0102 100644 --- a/rslib/src/backend/mod.rs +++ b/rslib/src/backend/mod.rs @@ -1,6 +1,7 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +mod adding; mod card; mod config; mod dbproxy; @@ -1037,6 +1038,25 @@ impl BackendService for Backend { }) } + fn defaults_for_adding( + &self, + input: pb::DefaultsForAddingIn, + ) -> BackendResult { + self.with_col(|col| { + let home_deck: DeckID = input.home_deck_of_current_review_card.into(); + col.defaults_for_adding(home_deck).map(Into::into) + }) + } + + fn default_deck_for_notetype(&self, input: pb::NoteTypeId) -> BackendResult { + self.with_col(|col| { + Ok(col + .default_deck_for_notetype(input.into())? + .unwrap_or(DeckID(0)) + .into()) + }) + } + fn update_note(&self, input: pb::UpdateNoteIn) -> BackendResult { self.with_col(|col| { let op = if input.skip_undo_entry { diff --git a/rslib/src/config/deck.rs b/rslib/src/config/deck.rs new file mode 100644 index 000000000..b11b7b5ca --- /dev/null +++ b/rslib/src/config/deck.rs @@ -0,0 +1,45 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use super::ConfigKey; +use crate::prelude::*; + +use strum::IntoStaticStr; + +/// Auxillary deck state, stored in the config table. +#[derive(Debug, Clone, Copy, IntoStaticStr)] +#[strum(serialize_all = "camelCase")] +enum DeckConfigKey { + LastNotetype, +} + +impl DeckConfigKey { + fn for_deck(self, did: DeckID) -> String { + build_aux_deck_key(did, <&'static str>::from(self)) + } +} + +impl Collection { + pub(crate) fn get_current_deck_id(&self) -> DeckID { + self.get_config_optional(ConfigKey::CurrentDeckID) + .unwrap_or(DeckID(1)) + } + + pub(crate) fn clear_aux_config_for_deck(&self, ntid: DeckID) -> Result<()> { + self.remove_config_prefix(&build_aux_deck_key(ntid, "")) + } + + pub(crate) fn get_last_notetype_for_deck(&self, id: DeckID) -> Option { + let key = DeckConfigKey::LastNotetype.for_deck(id); + self.get_config_optional(key.as_str()) + } + + pub(crate) fn set_last_notetype_for_deck(&self, did: DeckID, ntid: NoteTypeID) -> Result<()> { + let key = DeckConfigKey::LastNotetype.for_deck(did); + self.set_config(key.as_str(), &ntid) + } +} + +fn build_aux_deck_key(deck: DeckID, key: &str) -> String { + format!("_deck_{deck}_{key}", deck = deck, key = key) +} diff --git a/rslib/src/config/mod.rs b/rslib/src/config/mod.rs index 0dd795794..a70553234 100644 --- a/rslib/src/config/mod.rs +++ b/rslib/src/config/mod.rs @@ -2,14 +2,13 @@ // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html mod bool; +mod deck; +mod notetype; pub(crate) mod schema11; mod string; pub use self::{bool::BoolKey, string::StringKey}; -use crate::{ - collection::Collection, decks::DeckID, err::Result, notetype::NoteTypeID, - timestamp::TimestampSecs, -}; +use crate::prelude::*; use serde::{de::DeserializeOwned, Serialize}; use serde_derive::Deserialize; use serde_repr::{Deserialize_repr, Serialize_repr}; @@ -92,13 +91,16 @@ impl Collection { self.storage.remove_config(key.into()) } - pub(crate) fn get_browser_sort_kind(&self) -> SortKind { - self.get_config_default(ConfigKey::BrowserSortKind) + /// Remove all keys starting with provided prefix, which must end with '_'. + pub(crate) fn remove_config_prefix(&self, key: &str) -> Result<()> { + for (key, _val) in self.storage.get_config_prefix(key)? { + self.storage.remove_config(&key)?; + } + Ok(()) } - pub(crate) fn get_current_deck_id(&self) -> DeckID { - self.get_config_optional(ConfigKey::CurrentDeckID) - .unwrap_or(DeckID(1)) + pub(crate) fn get_browser_sort_kind(&self) -> SortKind { + self.get_config_default(ConfigKey::BrowserSortKind) } pub(crate) fn get_creation_utc_offset(&self) -> Option { @@ -130,15 +132,6 @@ impl Collection { self.set_config(ConfigKey::Rollover, &hour) } - #[allow(dead_code)] - pub(crate) fn get_current_notetype_id(&self) -> Option { - self.get_config_optional(ConfigKey::CurrentNoteTypeID) - } - - pub(crate) fn set_current_notetype_id(&self, id: NoteTypeID) -> Result<()> { - self.set_config(ConfigKey::CurrentNoteTypeID, &id) - } - pub(crate) fn get_next_card_position(&self) -> u32 { self.get_config_default(ConfigKey::NextNewCardPosition) } diff --git a/rslib/src/config/notetype.rs b/rslib/src/config/notetype.rs new file mode 100644 index 000000000..42b7d1e50 --- /dev/null +++ b/rslib/src/config/notetype.rs @@ -0,0 +1,52 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use super::ConfigKey; +use crate::prelude::*; + +use strum::IntoStaticStr; + +/// Notetype config packed into a collection config key. This may change +/// frequently, and we want to avoid the potentially expensive notetype +/// write/sync. +#[derive(Debug, Clone, Copy, IntoStaticStr)] +#[strum(serialize_all = "camelCase")] +enum NoteTypeConfigKey { + #[strum(to_string = "lastDeck")] + LastDeckAddedTo, +} + +impl NoteTypeConfigKey { + fn for_notetype(self, ntid: NoteTypeID) -> String { + build_aux_notetype_key(ntid, <&'static str>::from(self)) + } +} + +impl Collection { + #[allow(dead_code)] + pub(crate) fn get_current_notetype_id(&self) -> Option { + self.get_config_optional(ConfigKey::CurrentNoteTypeID) + } + + pub(crate) fn set_current_notetype_id(&self, ntid: NoteTypeID) -> Result<()> { + self.set_config(ConfigKey::CurrentNoteTypeID, &ntid) + } + + pub(crate) fn clear_aux_config_for_notetype(&self, ntid: NoteTypeID) -> Result<()> { + self.remove_config_prefix(&build_aux_notetype_key(ntid, "")) + } + + pub(crate) fn get_last_deck_added_to_for_notetype(&self, id: NoteTypeID) -> Option { + let key = NoteTypeConfigKey::LastDeckAddedTo.for_notetype(id); + self.get_config_optional(key.as_str()) + } + + pub(crate) fn set_last_deck_for_notetype(&self, id: NoteTypeID, did: DeckID) -> Result<()> { + let key = NoteTypeConfigKey::LastDeckAddedTo.for_notetype(id); + self.set_config(key.as_str(), &did) + } +} + +fn build_aux_notetype_key(ntid: NoteTypeID, key: &str) -> String { + format!("_nt_{ntid}_{key}", ntid = ntid, key = key) +} diff --git a/rslib/src/decks/mod.rs b/rslib/src/decks/mod.rs index 1ba819837..8a70f5470 100644 --- a/rslib/src/decks/mod.rs +++ b/rslib/src/decks/mod.rs @@ -468,6 +468,7 @@ impl Collection { DeckKind::Normal(_) => self.delete_all_cards_in_normal_deck(deck.id)?, DeckKind::Filtered(_) => self.return_all_cards_in_filtered_deck(deck.id)?, } + self.clear_aux_config_for_deck(deck.id)?; if deck.id.0 == 1 { let mut deck = deck.to_owned(); // fixme: separate key diff --git a/rslib/src/lib.rs b/rslib/src/lib.rs index 59ba18842..c3a138ad0 100644 --- a/rslib/src/lib.rs +++ b/rslib/src/lib.rs @@ -3,6 +3,7 @@ #![deny(unused_must_use)] +pub mod adding; pub mod backend; mod backend_proto; pub mod card; diff --git a/rslib/src/notes/mod.rs b/rslib/src/notes/mod.rs index c8d239738..c2a66246b 100644 --- a/rslib/src/notes/mod.rs +++ b/rslib/src/notes/mod.rs @@ -321,7 +321,10 @@ impl Collection { note.prepare_for_update(&ctx.notetype, normalize_text)?; note.set_modified(ctx.usn); self.add_note_only_undoable(note)?; - self.generate_cards_for_new_note(ctx, note, did) + self.generate_cards_for_new_note(ctx, note, did)?; + self.set_last_deck_for_notetype(note.notetype_id, did)?; + self.set_last_notetype_for_deck(did, note.notetype_id)?; + self.set_current_notetype_id(note.notetype_id) } #[cfg(test)] diff --git a/rslib/src/notetype/mod.rs b/rslib/src/notetype/mod.rs index 8d03243a4..e0477f8a6 100644 --- a/rslib/src/notetype/mod.rs +++ b/rslib/src/notetype/mod.rs @@ -499,6 +499,7 @@ impl Collection { self.transact(None, |col| { col.storage.set_schema_modified()?; col.state.notetype_cache.remove(&ntid); + col.clear_aux_config_for_notetype(ntid)?; col.storage.remove_notetype(ntid)?; let all = col.storage.get_all_notetype_names()?; if all.is_empty() { diff --git a/rslib/src/prelude.rs b/rslib/src/prelude.rs index 141173da2..c44c7e73a 100644 --- a/rslib/src/prelude.rs +++ b/rslib/src/prelude.rs @@ -10,7 +10,7 @@ pub use crate::{ err::{AnkiError, Result}, i18n::{tr_args, tr_strs, TR}, notes::{Note, NoteID}, - notetype::NoteTypeID, + notetype::{NoteType, NoteTypeID}, revlog::RevlogID, timestamp::{TimestampMillis, TimestampSecs}, types::Usn, diff --git a/rslib/src/storage/config/mod.rs b/rslib/src/storage/config/mod.rs index e12ad8186..832377a4b 100644 --- a/rslib/src/storage/config/mod.rs +++ b/rslib/src/storage/config/mod.rs @@ -41,6 +41,17 @@ impl SqliteStorage { .transpose() } + /// Prefix is expected to end with '_'. + pub(crate) fn get_config_prefix(&self, prefix: &str) -> Result)>> { + let mut end = prefix.to_string(); + assert_eq!(end.pop(), Some('_')); + end.push(std::char::from_u32('_' as u32 + 1).unwrap()); + self.db + .prepare("select key, val from config where key > ? and key < ?")? + .query_and_then(params![prefix, &end], |row| Ok((row.get(0)?, row.get(1)?)))? + .collect() + } + pub(crate) fn get_all_config(&self) -> Result> { self.db .prepare("select key, val from config")?