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")?