From 61e86cc29d3687a0b3bfffcb86b6d1e84e5ff4ff Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Thu, 10 Jun 2021 21:30:39 +1000 Subject: [PATCH] new change notetype implementation for the frontend - changes can now be undone - the same field can now be mapped to multiple target fields, allowing fields to be cloned - the old Qt dialog has been removed - the old col.models.change() API calls the new code, to avoid breaking existing consumers. It requires the field map to always be passed in, but that appears to have been the common case. - closes #1175 --- ftl/core/change-notetype.ftl | 11 + pylib/anki/_backend/genbackend.py | 3 +- pylib/anki/models.py | 147 ++++++------- pylib/tests/test_models.py | 5 +- qt/.pylintrc | 1 + qt/aqt/browser/__init__.py | 1 - qt/aqt/browser/browser.py | 15 +- qt/aqt/browser/change_notetype.py | 207 ------------------ qt/aqt/changenotetype.py | 101 +++++++++ qt/aqt/data/web/pages/BUILD.bazel | 1 + qt/aqt/mediasrv.py | 29 +++ qt/aqt/operations/notetype.py | 8 +- rslib/backend.proto | 17 +- rslib/src/backend/generic.rs | 6 + rslib/src/backend/notetypes.rs | 23 +- rslib/src/notetype/mod.rs | 26 +++ rslib/src/notetype/notetypechange.rs | 110 +++++----- ts/ChangeNotetype/BUILD.bazel | 136 ++++++++++++ ts/ChangeNotetype/ChangeNotetype-base.scss | 43 ++++ ts/ChangeNotetype/ChangeNotetype.html | 23 ++ ts/ChangeNotetype/ChangeNotetypePage.svelte | 34 +++ ts/ChangeNotetype/Mapper.svelte | 46 ++++ ts/ChangeNotetype/MapperRow.svelte | 36 ++++ ts/ChangeNotetype/NotetypeSelector.svelte | 51 +++++ ts/ChangeNotetype/SaveButton.svelte | 36 ++++ ts/ChangeNotetype/index.ts | 37 ++++ ts/ChangeNotetype/lib.test.ts | 126 +++++++++++ ts/ChangeNotetype/lib.ts | 220 ++++++++++++++++++++ 28 files changed, 1125 insertions(+), 374 deletions(-) create mode 100644 ftl/core/change-notetype.ftl delete mode 100644 qt/aqt/browser/change_notetype.py create mode 100644 qt/aqt/changenotetype.py create mode 100644 ts/ChangeNotetype/BUILD.bazel create mode 100644 ts/ChangeNotetype/ChangeNotetype-base.scss create mode 100644 ts/ChangeNotetype/ChangeNotetype.html create mode 100644 ts/ChangeNotetype/ChangeNotetypePage.svelte create mode 100644 ts/ChangeNotetype/Mapper.svelte create mode 100644 ts/ChangeNotetype/MapperRow.svelte create mode 100644 ts/ChangeNotetype/NotetypeSelector.svelte create mode 100644 ts/ChangeNotetype/SaveButton.svelte create mode 100644 ts/ChangeNotetype/index.ts create mode 100644 ts/ChangeNotetype/lib.test.ts create mode 100644 ts/ChangeNotetype/lib.ts diff --git a/ftl/core/change-notetype.ftl b/ftl/core/change-notetype.ftl new file mode 100644 index 000000000..82df06249 --- /dev/null +++ b/ftl/core/change-notetype.ftl @@ -0,0 +1,11 @@ +change-notetype-current = Current +change-notetype-new = New +change-notetype-will-discard-content = Will discard content on the following fields: +change-notetype-will-discard-cards = Will remove the following cards: +change-notetype-fields = Fields +change-notetype-templates = Templates +change-notetype-to-from-cloze = + When changing to or from a Cloze notetype, card numbers remain unchanged. + + If changing to a regular notetype, and there are more cloze deletions + than available card templates, any extra cards will be removed. diff --git a/pylib/anki/_backend/genbackend.py b/pylib/anki/_backend/genbackend.py index 018ee45c0..15d000d93 100755 --- a/pylib/anki/_backend/genbackend.py +++ b/pylib/anki/_backend/genbackend.py @@ -39,10 +39,11 @@ SKIP_UNROLL_INPUT = { "SetPreferences", "UpdateDeckConfigs", "AnswerCard", + "ChangeNotetype", } SKIP_UNROLL_OUTPUT = {"GetPreferences"} -SKIP_DECODE = {"Graphs", "GetGraphPreferences"} +SKIP_DECODE = {"Graphs", "GetGraphPreferences", "GetChangeNotetypeInfo"} def python_type(field): diff --git a/pylib/anki/models.py b/pylib/anki/models.py index 3b064399f..c093a4a36 100644 --- a/pylib/anki/models.py +++ b/pylib/anki/models.py @@ -17,20 +17,14 @@ from anki.consts import * from anki.errors import NotFoundError from anki.lang import without_unicode_isolation from anki.stdmodels import StockNotetypeKind -from anki.utils import ( - checksum, - from_json_bytes, - ids2str, - intTime, - joinFields, - splitFields, - to_json_bytes, -) +from anki.utils import checksum, from_json_bytes, to_json_bytes # public exports NotetypeNameId = _pb.NotetypeNameId NotetypeNameIdUseCount = _pb.NotetypeNameIdUseCount - +NotetypeNames = _pb.NotetypeNames +ChangeNotetypeInfo = _pb.ChangeNotetypeInfo +ChangeNotetypeIn = _pb.ChangeNotetypeIn # legacy types NotetypeDict = Dict[str, Any] @@ -436,89 +430,82 @@ and notes.mid = ? and cards.ord = ?""", ord, ) - # Model changing + # Changing notetypes of notes ########################################################################## - # - maps are ord->ord, and there should not be duplicate targets - # - newModel should be self if model is not changing + + def get_single_notetype_of_notes( + self, note_ids: Sequence[anki.notes.NoteId] + ) -> NotetypeId: + return NotetypeId( + self.col._backend.get_single_notetype_of_notes(note_ids=note_ids) + ) + + def change_notetype_info( + self, *, old_notetype_id: NotetypeId, new_notetype_id: NotetypeId + ) -> bytes: + return self.col._backend.get_change_notetype_info( + old_notetype_id=old_notetype_id, new_notetype_id=new_notetype_id + ) + + def change_notetype_of_notes(self, input: ChangeNotetypeIn) -> OpChanges: + """Assign a new notetype, optionally altering field/template order. + + To get defaults, use + + input = ChangeNotetypeIn() + input.ParseFromString(col.models.change_notetype_info(...)) + input.note_ids.extend([...]) + + The new_fields and new_templates lists are relative to the new notetype's + field/template count. Each value represents the index in the previous + notetype. -1 indicates the original value will be discarded. + """ + return self.col._backend.change_notetype(input) + + # legacy API - used by unit tests and add-ons def change( self, m: NotetypeDict, nids: List[anki.notes.NoteId], newModel: NotetypeDict, - fmap: Optional[Dict[int, Union[None, int]]], - cmap: Optional[Dict[int, Union[None, int]]], + fmap: Dict[int, Optional[int]], + cmap: Optional[Dict[int, Optional[int]]], ) -> None: + # - maps are ord->ord, and there should not be duplicate targets self.col.modSchema(check=True) - assert newModel["id"] == m["id"] or (fmap and cmap) - if fmap: - self._changeNotes(nids, newModel, fmap) - if cmap: - self._changeCards(nids, m, newModel, cmap) - self.col.after_note_updates(nids, mark_modified=True) + assert fmap + field_map = self._convert_legacy_map(fmap, len(newModel["flds"])) + if not cmap or newModel["type"] == MODEL_CLOZE or m["type"] == MODEL_CLOZE: + template_map = [] + else: + template_map = self._convert_legacy_map(cmap, len(newModel["tmpls"])) - def _changeNotes( - self, - nids: List[anki.notes.NoteId], - newModel: NotetypeDict, - map: Dict[int, Union[None, int]], - ) -> None: - d = [] - nfields = len(newModel["flds"]) - for (nid, flds) in self.col.db.execute( - f"select id, flds from notes where id in {ids2str(nids)}" - ): - newflds = {} - flds = splitFields(flds) - for old, new in list(map.items()): - newflds[new] = flds[old] - flds = [] - for c in range(nfields): - flds.append(newflds.get(c, "")) - flds = joinFields(flds) - d.append( - ( - flds, - newModel["id"], - intTime(), - self.col.usn(), - nid, - ) + self.col._backend.change_notetype( + ChangeNotetypeIn( + note_ids=nids, + new_fields=field_map, + new_templates=template_map, + old_notetype_id=m["id"], + new_notetype_id=newModel["id"], + current_schema=self.col.db.scalar("select scm from col"), ) - self.col.db.executemany( - "update notes set flds=?,mid=?,mod=?,usn=? where id = ?", d ) - def _changeCards( - self, - nids: List[anki.notes.NoteId], - oldModel: NotetypeDict, - newModel: NotetypeDict, - map: Dict[int, Union[None, int]], - ) -> None: - d = [] - deleted = [] - for (cid, ord) in self.col.db.execute( - f"select id, ord from cards where nid in {ids2str(nids)}" - ): - # if the src model is a cloze, we ignore the map, as the gui - # doesn't currently support mapping them - if oldModel["type"] == MODEL_CLOZE: - new = ord - if newModel["type"] != MODEL_CLOZE: - # if we're mapping to a regular note, we need to check if - # the destination ord is valid - if len(newModel["tmpls"]) <= ord: - new = None - else: - # mapping from a regular note, so the map should be valid - new = map[ord] - if new is not None: - d.append((new, self.col.usn(), intTime(), cid)) - else: - deleted.append(cid) - self.col.db.executemany("update cards set ord=?,usn=?,mod=? where id=?", d) - self.col.remove_cards_and_orphaned_notes(deleted) + def _convert_legacy_map( + self, old_to_new: Dict[int, Optional[int]], new_count: int + ) -> List[int]: + "Convert old->new map to list of old indexes" + new_to_old = {v: k for k, v in old_to_new.items() if v is not None} + out = [] + for idx in range(new_count): + try: + val = new_to_old[idx] + except KeyError: + val = -1 + + out.append(val) + return out # Schema hash ########################################################################## diff --git a/pylib/tests/test_models.py b/pylib/tests/test_models.py index a1bc0d4be..89080edd0 100644 --- a/pylib/tests/test_models.py +++ b/pylib/tests/test_models.py @@ -301,6 +301,7 @@ def test_modelChange(): col.addNote(note) # switch fields map = {0: 1, 1: 0} + noop = {0: 0, 1: 1} col.models.change(basic, [note.id], basic, map, None) note.load() assert note["Front"] == "b123" @@ -312,7 +313,7 @@ def test_modelChange(): assert "note" in c1.q() assert c0.ord == 0 assert c1.ord == 1 - col.models.change(basic, [note.id], basic, None, map) + col.models.change(basic, [note.id], basic, noop, map) note.load() c0.load() c1.load() @@ -327,7 +328,7 @@ def test_modelChange(): if isWin: # The low precision timer on Windows reveals a race condition time.sleep(0.05) - col.models.change(basic, [note.id], basic, None, map) + col.models.change(basic, [note.id], basic, noop, map) note.load() c0.load() # the card was deleted diff --git a/qt/.pylintrc b/qt/.pylintrc index 6eae30507..594dc4be5 100644 --- a/qt/.pylintrc +++ b/qt/.pylintrc @@ -14,6 +14,7 @@ ignored-classes= UnburyDeckIn, CardAnswer, QueuedCards, + ChangeNotetypeIn, [REPORTS] output-format=colorized diff --git a/qt/aqt/browser/__init__.py b/qt/aqt/browser/__init__.py index f62b8e4e3..ffff667e7 100644 --- a/qt/aqt/browser/__init__.py +++ b/qt/aqt/browser/__init__.py @@ -9,7 +9,6 @@ import aqt from .browser import Browser, PreviewDialog # aliases for legacy pathnames -from .change_notetype import ChangeModel from .sidebar import ( SidebarItem, SidebarItemType, diff --git a/qt/aqt/browser/browser.py b/qt/aqt/browser/browser.py index ca126087b..0e2faae5f 100644 --- a/qt/aqt/browser/browser.py +++ b/qt/aqt/browser/browser.py @@ -14,7 +14,7 @@ from anki.errors import NotFoundError from anki.lang import without_unicode_isolation from anki.notes import NoteId from anki.tags import MARKED_TAG -from anki.utils import ids2str, isMac +from anki.utils import isMac from aqt import AnkiQt, gui_hooks from aqt.editor import Editor from aqt.exporting import ExportDialog @@ -58,8 +58,8 @@ from aqt.utils import ( tr, ) +from ..changenotetype import change_notetype_dialog from .card_info import CardInfoDialog -from .change_notetype import ChangeModel from .find_and_replace import FindAndReplaceDialog from .previewer import BrowserPreviewer as PreviewDialog from .previewer import Previewer @@ -511,16 +511,7 @@ class Browser(QMainWindow): @ensure_editor_saved def onChangeModel(self) -> None: ids = self.selected_notes() - if self._is_one_notetype(ids): - ChangeModel(self, ids) - else: - showInfo(tr.browsing_please_select_cards_from_only_one()) - - def _is_one_notetype(self, ids: Sequence[NoteId]) -> bool: - query = f"select count(distinct mid) from notes where id in {ids2str(ids)}" - if self.col.db.scalar(query) == 1: - return True - return False + change_notetype_dialog(parent=self, note_ids=ids) def createFilteredDeck(self) -> None: search = self.current_search() diff --git a/qt/aqt/browser/change_notetype.py b/qt/aqt/browser/change_notetype.py deleted file mode 100644 index 1c4c33933..000000000 --- a/qt/aqt/browser/change_notetype.py +++ /dev/null @@ -1,207 +0,0 @@ -# Copyright: Ankitects Pty Ltd and contributors -# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html - -from __future__ import annotations - -from typing import Any, Dict, List, Optional, Sequence - -import aqt -from anki.consts import * -from anki.models import NotetypeDict -from anki.notes import NoteId -from aqt import QWidget, gui_hooks -from aqt.qt import * -from aqt.utils import ( - HelpPage, - askUser, - disable_help_button, - openHelp, - qconnect, - restoreGeom, - saveGeom, - tr, -) - - -class ChangeModel(QDialog): - def __init__(self, browser: aqt.browser.Browser, nids: Sequence[NoteId]) -> None: - QDialog.__init__(self, browser) - self.browser = browser - self.nids = nids - self.oldModel = browser.card.note().model() - self.form = aqt.forms.changemodel.Ui_Dialog() - self.form.setupUi(self) - disable_help_button(self) - self.setWindowModality(Qt.WindowModal) - # ugh - these are set dynamically by rebuildTemplateMap() - self.tcombos: List[QComboBox] = [] - self.fcombos: List[QComboBox] = [] - self.setup() - restoreGeom(self, "changeModel") - gui_hooks.state_did_reset.append(self.onReset) - gui_hooks.current_note_type_did_change.append(self.on_note_type_change) - self.exec_() - - def on_note_type_change(self, notetype: NotetypeDict) -> None: - self.onReset() - - def setup(self) -> None: - # maps - self.flayout = QHBoxLayout() - self.flayout.setContentsMargins(0, 0, 0, 0) - self.fwidg = None - self.form.fieldMap.setLayout(self.flayout) - self.tlayout = QHBoxLayout() - self.tlayout.setContentsMargins(0, 0, 0, 0) - self.twidg = None - self.form.templateMap.setLayout(self.tlayout) - if self.style().objectName() == "gtk+": - # gtk+ requires margins in inner layout - self.form.verticalLayout_2.setContentsMargins(0, 11, 0, 0) - self.form.verticalLayout_3.setContentsMargins(0, 11, 0, 0) - # model chooser - import aqt.modelchooser - - self.oldModel = self.browser.col.models.get( - self.browser.col.db.scalar( - "select mid from notes where id = ?", self.nids[0] - ) - ) - self.form.oldModelLabel.setText(self.oldModel["name"]) - self.modelChooser = aqt.modelchooser.ModelChooser( - self.browser.mw, self.form.modelChooserWidget, label=False - ) - self.modelChooser.models.setFocus() - qconnect(self.form.buttonBox.helpRequested, self.onHelp) - self.modelChanged(self.browser.mw.col.models.current()) - self.pauseUpdate = False - - def onReset(self) -> None: - self.modelChanged(self.browser.col.models.current()) - - def modelChanged(self, model: Dict[str, Any]) -> None: - self.targetModel = model - self.rebuildTemplateMap() - self.rebuildFieldMap() - - def rebuildTemplateMap( - self, key: Optional[str] = None, attr: Optional[str] = None - ) -> None: - if not key: - key = "t" - attr = "tmpls" - map = getattr(self, key + "widg") - lay = getattr(self, key + "layout") - src = self.oldModel[attr] - dst = self.targetModel[attr] - if map: - lay.removeWidget(map) - map.deleteLater() - setattr(self, key + "MapWidget", None) - map = QWidget() - l = QGridLayout() - combos = [] - targets = [x["name"] for x in dst] + [tr.browsing_nothing()] - indices = {} - for i, x in enumerate(src): - l.addWidget(QLabel(tr.browsing_change_to(val=x["name"])), i, 0) - cb = QComboBox() - cb.addItems(targets) - idx = min(i, len(targets) - 1) - cb.setCurrentIndex(idx) - indices[cb] = idx - qconnect( - cb.currentIndexChanged, - lambda i, cb=cb, key=key: self.onComboChanged(i, cb, key), - ) - combos.append(cb) - l.addWidget(cb, i, 1) - map.setLayout(l) - lay.addWidget(map) - setattr(self, key + "widg", map) - setattr(self, key + "layout", lay) - setattr(self, key + "combos", combos) - setattr(self, key + "indices", indices) - - def rebuildFieldMap(self) -> None: - return self.rebuildTemplateMap(key="f", attr="flds") - - def onComboChanged(self, i: int, cb: QComboBox, key: str) -> None: - indices = getattr(self, key + "indices") - if self.pauseUpdate: - indices[cb] = i - return - combos = getattr(self, key + "combos") - if i == cb.count() - 1: - # set to 'nothing' - return - # find another combo with same index - for c in combos: - if c == cb: - continue - if c.currentIndex() == i: - self.pauseUpdate = True - c.setCurrentIndex(indices[cb]) - self.pauseUpdate = False - break - indices[cb] = i - - def getTemplateMap( - self, - old: Optional[List[Dict[str, Any]]] = None, - combos: Optional[List[QComboBox]] = None, - new: Optional[List[Dict[str, Any]]] = None, - ) -> Dict[int, Optional[int]]: - if not old: - old = self.oldModel["tmpls"] - combos = self.tcombos - new = self.targetModel["tmpls"] - template_map: Dict[int, Optional[int]] = {} - for i, f in enumerate(old): - idx = combos[i].currentIndex() - if idx == len(new): - # ignore - template_map[f["ord"]] = None - else: - f2 = new[idx] - template_map[f["ord"]] = f2["ord"] - return template_map - - def getFieldMap(self) -> Dict[int, Optional[int]]: - return self.getTemplateMap( - old=self.oldModel["flds"], combos=self.fcombos, new=self.targetModel["flds"] - ) - - def cleanup(self) -> None: - gui_hooks.state_did_reset.remove(self.onReset) - gui_hooks.current_note_type_did_change.remove(self.on_note_type_change) - self.modelChooser.cleanup() - saveGeom(self, "changeModel") - - def reject(self) -> None: - self.cleanup() - return QDialog.reject(self) - - def accept(self) -> None: - # check maps - fmap = self.getFieldMap() - cmap = self.getTemplateMap() - if any(True for c in list(cmap.values()) if c is None): - if not askUser(tr.browsing_any_cards_mapped_to_nothing_will()): - return - self.browser.mw.checkpoint(tr.browsing_change_note_type()) - b = self.browser - b.mw.col.modSchema(check=True) - b.mw.progress.start() - b.begin_reset() - mm = b.mw.col.models - mm.change(self.oldModel, list(self.nids), self.targetModel, fmap, cmap) - b.search() - b.end_reset() - b.mw.progress.finish() - b.mw.reset() - self.cleanup() - QDialog.accept(self) - - def onHelp(self) -> None: - openHelp(HelpPage.BROWSING_NOTES_MENU) diff --git a/qt/aqt/changenotetype.py b/qt/aqt/changenotetype.py new file mode 100644 index 000000000..7fe81b3d6 --- /dev/null +++ b/qt/aqt/changenotetype.py @@ -0,0 +1,101 @@ +# Copyright: Ankitects Pty Ltd and contributors +# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +from __future__ import annotations + +from typing import Sequence + +import aqt +import aqt.deckconf +from anki.collection import OpChanges +from anki.models import ChangeNotetypeIn, NotetypeId +from anki.notes import NoteId +from aqt.operations.notetype import change_notetype_of_notes +from aqt.qt import * +from aqt.utils import ( + addCloseShortcut, + disable_help_button, + restoreGeom, + saveGeom, + showWarning, + tooltip, + tr, +) +from aqt.webview import AnkiWebView + + +class ChangeNotetypeDialog(QDialog): + + TITLE = "changeNotetype" + silentlyClose = True + + def __init__( + self, + parent: QWidget, + mw: aqt.main.AnkiQt, + note_ids: Sequence[NoteId], + notetype_id: NotetypeId, + ) -> None: + QDialog.__init__(self, parent) + self.mw = mw + self._note_ids = note_ids + self._setup_ui(notetype_id) + self.show() + + def _setup_ui(self, notetype_id: NotetypeId) -> None: + self.setWindowModality(Qt.ApplicationModal) + self.mw.garbage_collect_on_dialog_finish(self) + self.setMinimumWidth(400) + disable_help_button(self) + restoreGeom(self, self.TITLE) + addCloseShortcut(self) + + self.web = AnkiWebView(title=self.TITLE) + self.web.setVisible(False) + self.web.load_ts_page("ChangeNotetype") + layout = QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.web) + self.setLayout(layout) + + self.web.eval( + f"""anki.changeNotetypePage( + document.getElementById('main'), {notetype_id}, {notetype_id});""" + ) + self.setWindowTitle(tr.browsing_change_notetype()) + + def reject(self) -> None: + self.web = None + saveGeom(self, self.TITLE) + QDialog.reject(self) + + def save(self, data: bytes) -> None: + input = ChangeNotetypeIn() + input.ParseFromString(data) + + if not self.mw.confirm_schema_modification(): + return + + def on_done(op: OpChanges) -> None: + tooltip( + tr.browsing_notes_updated(count=len(input.note_ids)), + parent=self.parentWidget(), + ) + self.reject() + + input.note_ids.extend(self._note_ids) + change_notetype_of_notes(parent=self, input=input).success( + on_done + ).run_in_background() + + +def change_notetype_dialog(parent: QWidget, note_ids: Sequence[NoteId]) -> None: + try: + notetype_id = aqt.mw.col.models.get_single_notetype_of_notes(note_ids) + except Exception as e: + showWarning(str(e), parent=parent) + return + + ChangeNotetypeDialog( + parent=parent, mw=aqt.mw, note_ids=note_ids, notetype_id=notetype_id + ) diff --git a/qt/aqt/data/web/pages/BUILD.bazel b/qt/aqt/data/web/pages/BUILD.bazel index b28df67e0..8b24cbe54 100644 --- a/qt/aqt/data/web/pages/BUILD.bazel +++ b/qt/aqt/data/web/pages/BUILD.bazel @@ -4,6 +4,7 @@ _pages = [ "graphs", "congrats", "deckoptions", + "ChangeNotetype", ] [copy_files_into_group( diff --git a/qt/aqt/mediasrv.py b/qt/aqt/mediasrv.py index 2db39f80c..c094413a8 100644 --- a/qt/aqt/mediasrv.py +++ b/qt/aqt/mediasrv.py @@ -22,8 +22,10 @@ import aqt from anki import hooks from anki.collection import GraphPreferences, OpChanges from anki.decks import UpdateDeckConfigs +from anki.models import NotetypeNames from anki.scheduler.v3 import NextStates from anki.utils import devMode, from_json_bytes +from aqt.changenotetype import ChangeNotetypeDialog from aqt.deckoptions import DeckOptionsDialog from aqt.operations.deck import update_deck_configs from aqt.qt import * @@ -323,6 +325,30 @@ def set_next_card_states() -> bytes: return b"" +def notetype_names() -> bytes: + msg = NotetypeNames(entries=aqt.mw.col.models.all_names_and_ids()) + return msg.SerializeToString() + + +def change_notetype_info() -> bytes: + args = from_json_bytes(request.data) + return aqt.mw.col.models.change_notetype_info( + old_notetype_id=args["oldNotetypeId"], new_notetype_id=args["newNotetypeId"] + ) + + +def change_notetype() -> bytes: + data = request.data + + def handle_on_main() -> None: + window = aqt.mw.app.activeWindow() + if isinstance(window, ChangeNotetypeDialog): + window.save(data) + + aqt.mw.taskman.run_on_main(handle_on_main) + return b"" + + post_handlers = { "graphData": graph_data, "graphPreferences": graph_preferences, @@ -331,6 +357,9 @@ post_handlers = { "updateDeckConfigs": update_deck_configs_request, "nextCardStates": next_card_states, "setNextCardStates": set_next_card_states, + "changeNotetypeInfo": change_notetype_info, + "notetypeNames": notetype_names, + "changeNotetype": change_notetype, # pylint: disable=unnecessary-lambda "i18nResources": i18n_resources, "congratsInfo": congrats_info, diff --git a/qt/aqt/operations/notetype.py b/qt/aqt/operations/notetype.py index dbd16f266..908a61a19 100644 --- a/qt/aqt/operations/notetype.py +++ b/qt/aqt/operations/notetype.py @@ -4,7 +4,7 @@ from __future__ import annotations from anki.collection import OpChanges, OpChangesWithId -from anki.models import NotetypeDict, NotetypeId +from anki.models import ChangeNotetypeIn, NotetypeDict, NotetypeId from aqt import QWidget from aqt.operations import CollectionOp @@ -31,3 +31,9 @@ def remove_notetype( notetype_id: NotetypeId, ) -> CollectionOp[OpChanges]: return CollectionOp(parent, lambda col: col.models.remove(notetype_id)) + + +def change_notetype_of_notes( + *, parent: QWidget, input: ChangeNotetypeIn +) -> CollectionOp[OpChanges]: + return CollectionOp(parent, lambda col: col.models.change_notetype_of_notes(input)) diff --git a/rslib/backend.proto b/rslib/backend.proto index 79c87b6e0..0ebfb68f3 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -17,10 +17,6 @@ message OptionalUInt32 { uint32 val = 1; } -message OptionalUInt32Wrapper { - OptionalUInt32 inner = 1; -} - message Int32 { sint32 val = 1; } @@ -70,6 +66,10 @@ message NoteId { int64 nid = 1; } +message NoteIds { + repeated int64 note_ids = 1; +} + message CardId { int64 cid = 1; } @@ -220,6 +220,7 @@ service NotetypesService { rpc RemoveNotetype(NotetypeId) returns (OpChanges); rpc GetAuxNotetypeConfigKey(GetAuxConfigKeyIn) returns (String); rpc GetAuxTemplateConfigKey(GetAuxTemplateConfigKeyIn) returns (String); + rpc GetSingleNotetypeOfNotes(NoteIds) returns (NotetypeId); rpc GetChangeNotetypeInfo(GetChangeNotetypeInfoIn) returns (ChangeNotetypeInfo); rpc ChangeNotetype(ChangeNotetypeIn) returns (OpChanges); @@ -1645,14 +1646,16 @@ message GetAuxTemplateConfigKeyIn { } message GetChangeNotetypeInfoIn { - repeated int64 note_ids = 1; + int64 old_notetype_id = 1; int64 new_notetype_id = 2; } message ChangeNotetypeIn { repeated int64 note_ids = 1; - repeated OptionalUInt32Wrapper new_fields = 2; - repeated OptionalUInt32Wrapper new_templates = 3; + // -1 is used to represent null, as nullable repeated fields + // are unwieldy in protobuf + repeated int32 new_fields = 2; + repeated int32 new_templates = 3; int64 old_notetype_id = 4; int64 new_notetype_id = 5; int64 current_schema = 6; diff --git a/rslib/src/backend/generic.rs b/rslib/src/backend/generic.rs index c78d80e3f..4e1ecea89 100644 --- a/rslib/src/backend/generic.rs +++ b/rslib/src/backend/generic.rs @@ -69,6 +69,12 @@ impl From for NotetypeId { } } +impl From for pb::NotetypeId { + fn from(ntid: NotetypeId) -> Self { + pb::NotetypeId { ntid: ntid.0 } + } +} + impl From for DeckConfigId { fn from(dcid: pb::DeckConfigId) -> Self { DeckConfigId(dcid.dcid) diff --git a/rslib/src/backend/notetypes.rs b/rslib/src/backend/notetypes.rs index 40c420b9b..ec96b9c1a 100644 --- a/rslib/src/backend/notetypes.rs +++ b/rslib/src/backend/notetypes.rs @@ -1,7 +1,7 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -use super::{notes::to_note_ids, Backend}; +use super::Backend; pub(super) use crate::backend_proto::notetypes_service::Service as NotetypesService; use crate::{ backend_proto as pb, @@ -156,12 +156,19 @@ impl NotetypesService for Backend { }) } + fn get_single_notetype_of_notes(&self, input: pb::NoteIds) -> Result { + self.with_col(|col| { + col.get_single_notetype_of_notes(&input.note_ids.into_newtype(NoteId)) + .map(Into::into) + }) + } + fn get_change_notetype_info( &self, input: pb::GetChangeNotetypeInfoIn, ) -> Result { self.with_col(|col| { - col.notetype_change_info(to_note_ids(input.note_ids), input.new_notetype_id.into()) + col.notetype_change_info(input.old_notetype_id.into(), input.new_notetype_id.into()) .map(Into::into) }) } @@ -206,13 +213,13 @@ impl From for ChangeNotetypeInput { new_fields: i .new_fields .into_iter() - .map(|wrapper| wrapper.inner.map(|v| v.val as usize)) + .map(|v| if v == -1 { None } else { Some(v as usize) }) .collect(), new_templates: { let v: Vec<_> = i .new_templates .into_iter() - .map(|wrapper| wrapper.inner.map(|v| v.val as usize)) + .map(|v| if v == -1 { None } else { Some(v as usize) }) .collect(); if v.is_empty() { None @@ -234,17 +241,13 @@ impl From for pb::ChangeNotetypeIn { new_fields: i .new_fields .into_iter() - .map(|idx| pb::OptionalUInt32Wrapper { - inner: idx.map(|idx| pb::OptionalUInt32 { val: idx as u32 }), - }) + .map(|idx| idx.map(|v| v as i32).unwrap_or(-1)) .collect(), new_templates: i .new_templates .unwrap_or_default() .into_iter() - .map(|idx| pb::OptionalUInt32Wrapper { - inner: idx.map(|idx| pb::OptionalUInt32 { val: idx as u32 }), - }) + .map(|idx| idx.map(|v| v as i32).unwrap_or(-1)) .collect(), } } diff --git a/rslib/src/notetype/mod.rs b/rslib/src/notetype/mod.rs index a8158a569..13aa16cc6 100644 --- a/rslib/src/notetype/mod.rs +++ b/rslib/src/notetype/mod.rs @@ -43,6 +43,8 @@ use crate::{ define_newtype, error::{TemplateSaveError, TemplateSaveErrorDetails}, prelude::*, + search::{Node, SearchNode}, + storage::comma_separated_ids, template::{FieldRequirements, ParsedTemplate}, text::ensure_string_in_nfc, }; @@ -190,6 +192,30 @@ impl Collection { pub fn remove_notetype(&mut self, ntid: NotetypeId) -> Result> { self.transact(Op::RemoveNotetype, |col| col.remove_notetype_inner(ntid)) } + + /// Return the notetype used by `note_ids`, or an error if not exactly 1 + /// notetype is in use. + pub fn get_single_notetype_of_notes(&mut self, note_ids: &[NoteId]) -> Result { + if note_ids.is_empty() { + return Err(AnkiError::NotFound); + } + + let nids_node: Node = SearchNode::NoteIds(comma_separated_ids(¬e_ids)).into(); + let note1 = self + .storage + .get_note(*note_ids.first().unwrap())? + .ok_or(AnkiError::NotFound)?; + + if self + .search_notes_unordered(match_all![note1.notetype_id, nids_node])? + .len() + != note_ids.len() + { + Err(AnkiError::MultipleNotetypesSelected) + } else { + Ok(note1.notetype_id) + } + } } impl Notetype { diff --git a/rslib/src/notetype/notetypechange.rs b/rslib/src/notetype/notetypechange.rs index 205437df8..802bf4e08 100644 --- a/rslib/src/notetype/notetypechange.rs +++ b/rslib/src/notetype/notetypechange.rs @@ -38,7 +38,7 @@ pub struct TemplateMap { } impl TemplateMap { - fn new(new_templates: Vec>, old_template_count: usize) -> Result { + fn new(new_templates: Vec>, old_template_count: usize) -> Self { let mut seen: HashSet = HashSet::new(); let remapped: HashMap<_, _> = new_templates .iter() @@ -58,23 +58,17 @@ impl TemplateMap { let removed: Vec<_> = (0..old_template_count) .filter(|idx| !seen.contains(&idx)) .collect(); - if removed.len() == new_templates.len() { - return Err(AnkiError::invalid_input( - "at least one template must be mapped", - )); - } - Ok(TemplateMap { removed, remapped }) + TemplateMap { removed, remapped } } } impl Collection { pub fn notetype_change_info( &mut self, - note_ids: Vec, + old_notetype_id: NotetypeId, new_notetype_id: NotetypeId, ) -> Result { - let old_notetype_id = self.get_single_notetype_of_notes(¬e_ids)?; let old_notetype = self .get_notetype(old_notetype_id)? .ok_or(AnkiError::NotFound)?; @@ -89,7 +83,7 @@ impl Collection { Ok(NotetypeChangeInfo { input: ChangeNotetypeInput { current_schema, - note_ids, + note_ids: vec![], old_notetype_id, new_notetype_id, new_fields, @@ -207,30 +201,6 @@ fn default_field_map(current_notetype: &Notetype, new_notetype: &Notetype) -> Ve } impl Collection { - /// Return the notetype used by `note_ids`, or an error if not exactly 1 - /// notetype is in use. - fn get_single_notetype_of_notes(&mut self, note_ids: &[NoteId]) -> Result { - if note_ids.is_empty() { - return Err(AnkiError::NotFound); - } - - let nids_node: Node = SearchNode::NoteIds(comma_separated_ids(¬e_ids)).into(); - let note1 = self - .storage - .get_note(*note_ids.first().unwrap())? - .ok_or(AnkiError::NotFound)?; - - if self - .search_notes_unordered(match_all![note1.notetype_id, nids_node])? - .len() - != note_ids.len() - { - Err(AnkiError::MultipleNotetypesSelected) - } else { - Ok(note1.notetype_id) - } - } - fn change_notetype_of_notes_inner(&mut self, input: ChangeNotetypeInput) -> Result<()> { if input.current_schema != self.storage.get_collection_timestamps()?.schema_change { return Err(AnkiError::invalid_input("schema changed")); @@ -304,7 +274,7 @@ impl Collection { usn: Usn, ) -> Result<()> { let nids: Node = SearchNode::NoteIds(comma_separated_ids(note_ids)).into(); - let map = TemplateMap::new(new_templates, old_template_count)?; + let map = TemplateMap::new(new_templates, old_template_count); self.remove_unmapped_cards(&map, nids.clone(), usn)?; self.rewrite_remapped_cards(&map, nids, usn)?; @@ -454,11 +424,11 @@ mod test { } #[test] - fn template_map() -> Result<()> { + fn template_map() { let new_templates = vec![None, Some(0)]; assert_eq!( - TemplateMap::new(new_templates.clone(), 1)?, + TemplateMap::new(new_templates.clone(), 1), TemplateMap { removed: vec![], remapped: vec![(0, 1)].into_iter().collect() @@ -466,14 +436,12 @@ mod test { ); assert_eq!( - TemplateMap::new(new_templates, 2)?, + TemplateMap::new(new_templates, 2), TemplateMap { removed: vec![1], remapped: vec![(0, 1)].into_iter().collect() } ); - - Ok(()) } #[test] @@ -488,14 +456,17 @@ mod test { let basic2 = col .get_notetype_by_name("Basic (and reversed card)")? .unwrap(); - let mut info = col.notetype_change_info(vec![note.id], basic2.id)?; - // switch the existing card to ordinal 2 let first_card = col.storage.all_cards_of_note(note.id)?[0].clone(); assert_eq!(first_card.template_idx, 0); - let templates = info.input.new_templates.as_mut().unwrap(); - *templates = vec![None, Some(0)]; - col.change_notetype_of_notes(info.input)?; + + // switch the existing card to ordinal 2 + let input = ChangeNotetypeInput { + note_ids: vec![note.id], + new_templates: Some(vec![None, Some(0)]), + ..col.notetype_change_info(basic.id, basic2.id)?.input + }; + col.change_notetype_of_notes(input)?; // cards arrive in creation order, so the existing card will come first let cards = col.storage.all_cards_of_note(note.id)?; @@ -509,6 +480,27 @@ mod test { Ok(()) } + #[test] + fn field_count_change() -> Result<()> { + let mut col = open_test_collection(); + let basic = col.get_notetype_by_name("Basic")?.unwrap(); + let mut note = basic.new_note(); + note.set_field(0, "1")?; + note.set_field(1, "2")?; + col.add_note(&mut note, DeckId(1))?; + + let basic2 = col + .get_notetype_by_name("Basic (optional reversed card)")? + .unwrap(); + let input = ChangeNotetypeInput { + note_ids: vec![note.id], + ..col.notetype_change_info(basic.id, basic2.id)?.input + }; + col.change_notetype_of_notes(input)?; + + Ok(()) + } + #[test] fn cloze() -> Result<()> { let mut col = open_test_collection(); @@ -523,24 +515,36 @@ mod test { let cloze = col.get_notetype_by_name("Cloze")?.unwrap(); // changing to cloze should leave all the existing cards alone - let info = col.notetype_change_info(vec![note.id], cloze.id)?; - col.change_notetype_of_notes(info.input)?; + let input = ChangeNotetypeInput { + note_ids: vec![note.id], + ..col.notetype_change_info(basic.id, cloze.id)?.input + }; + col.change_notetype_of_notes(input)?; let cards = col.storage.all_cards_of_note(note.id)?; assert_eq!(cards.len(), 2); // and back again should also work - let info = col.notetype_change_info(vec![note.id], basic.id)?; - col.change_notetype_of_notes(info.input)?; + let input = ChangeNotetypeInput { + note_ids: vec![note.id], + ..col.notetype_change_info(cloze.id, basic.id)?.input + }; + col.change_notetype_of_notes(input)?; let cards = col.storage.all_cards_of_note(note.id)?; assert_eq!(cards.len(), 2); // but any cards above the available templates should be removed when converting from cloze->normal - let info = col.notetype_change_info(vec![note.id], cloze.id)?; - col.change_notetype_of_notes(info.input)?; + let input = ChangeNotetypeInput { + note_ids: vec![note.id], + ..col.notetype_change_info(basic.id, cloze.id)?.input + }; + col.change_notetype_of_notes(input)?; let basic1 = col.get_notetype_by_name("Basic")?.unwrap(); - let info = col.notetype_change_info(vec![note.id], basic1.id)?; - col.change_notetype_of_notes(info.input)?; + let input = ChangeNotetypeInput { + note_ids: vec![note.id], + ..col.notetype_change_info(cloze.id, basic1.id)?.input + }; + col.change_notetype_of_notes(input)?; let cards = col.storage.all_cards_of_note(note.id)?; assert_eq!(cards.len(), 1); diff --git a/ts/ChangeNotetype/BUILD.bazel b/ts/ChangeNotetype/BUILD.bazel new file mode 100644 index 000000000..be24c1eac --- /dev/null +++ b/ts/ChangeNotetype/BUILD.bazel @@ -0,0 +1,136 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_library") +load("//ts:prettier.bzl", "prettier_test") +load("//ts:eslint.bzl", "eslint_test") +load("//ts/svelte:svelte.bzl", "compile_svelte", "svelte_check") +load("//ts:esbuild.bzl", "esbuild") +load("//ts:compile_sass.bzl", "compile_sass") +load("//ts:jest.bzl", "jest_test") + +compile_sass( + srcs = ["ChangeNotetype-base.scss"], + group = "base_css", + visibility = ["//visibility:public"], + deps = [ + "//ts/sass:base_lib", + "//ts/sass:scrollbar_lib", + "//ts/sass/bootstrap", + ], +) + +svelte_files = glob(["*.svelte"]) + +svelte_names = [f.replace(".svelte", "") for f in svelte_files] + +compile_svelte( + name = "svelte", + srcs = svelte_files, + deps = [ + "//ts/components", + "//ts/sveltelib", + "@npm//@types/bootstrap", + ], +) + +ts_library( + name = "index", + srcs = ["index.ts"], + deps = [ + "ChangeNotetypePage", + "lib", + "//ts/components", + "//ts/lib", + "@npm//svelte2tsx", + ], +) + +ts_library( + name = "lib", + srcs = [ + "lib.ts", + ], + module_name = "ChangeNotetype", + deps = [ + "//ts/components", + "//ts/lib", + "//ts/lib:backend_proto", + "//ts/sveltelib", + "@npm//lodash-es", + "@npm//svelte", + ], +) + +esbuild( + name = "ChangeNotetype", + srcs = [ + "//ts:protobuf-shim.js", + ], + args = [ + "--global-name=anki", + "--inject:$(location //ts:protobuf-shim.js)", + "--resolve-extensions=.mjs,.js", + "--log-level=warning", + "--loader:.svg=text", + ], + entry_point = "index.ts", + external = [ + "protobufjs/light", + ], + output_css = "ChangeNotetype.css", + visibility = ["//visibility:public"], + deps = [ + "index", + "//ts/lib", + "//ts/lib:backend_proto", + "@npm//bootstrap", + ":base_css", + "//ts/sveltelib", + "@npm//marked", + "//ts/components", + "//ts/components:svelte_components", + ] + svelte_names, +) + +exports_files(["ChangeNotetype.html"]) + +# Tests +################ + +prettier_test( + name = "format_check", + srcs = glob([ + "*.ts", + "*.svelte", + ]), +) + +eslint_test( + name = "eslint", + srcs = glob([ + "*.ts", + ]), +) + +svelte_check( + name = "svelte_check", + srcs = glob([ + "*.ts", + "*.svelte", + ]) + [ + "//ts/sass:button_mixins_lib", + "//ts/sass/bootstrap", + "@npm//@types/bootstrap", + "@npm//@types/lodash-es", + "@npm//@types/marked", + "//ts/components", + ], +) + +jest_test( + protobuf = True, + deps = [ + ":lib", + "//ts/lib:backend_proto", + "@npm//protobufjs", + "@npm//svelte", + ], +) diff --git a/ts/ChangeNotetype/ChangeNotetype-base.scss b/ts/ChangeNotetype/ChangeNotetype-base.scss new file mode 100644 index 000000000..b39c636e7 --- /dev/null +++ b/ts/ChangeNotetype/ChangeNotetype-base.scss @@ -0,0 +1,43 @@ +@use "ts/sass/vars"; +@use "ts/sass/scrollbar"; +@use "ts/sass/bootstrap-dark"; + +@import "ts/sass/base"; + +@import "ts/sass/bootstrap/alert"; +@import "ts/sass/bootstrap/forms"; +@import "ts/sass/bootstrap/buttons"; +@import "ts/sass/bootstrap/button-group"; +@import "ts/sass/bootstrap/close"; +@import "ts/sass/bootstrap/grid"; + +.night-mode { + @include scrollbar.night-mode; + @include bootstrap-dark.night-mode; +} + +// the unprefixed version wasn't added until Chrome 81 +.form-select { + -webkit-appearance: none; +} + +body { + width: min(100vw, 35em); + margin: 0 auto; + // leave some space for rounded screens + margin-bottom: 2em; +} + +html { + overflow-x: hidden; +} + +#main { + padding: 0.5em; + padding-top: 0; +} + +// override the default down arrow colour in + {#each $info.getOldNamesIncludingNothing(ctx) as name, idx} + + {/each} + + +
+ {$info.getNewName(ctx, newIndex)} +
+ diff --git a/ts/ChangeNotetype/NotetypeSelector.svelte b/ts/ChangeNotetype/NotetypeSelector.svelte new file mode 100644 index 000000000..0f272b3f9 --- /dev/null +++ b/ts/ChangeNotetype/NotetypeSelector.svelte @@ -0,0 +1,51 @@ + + + + + + + + + + {#each $notetypes as entry} + + {entry.name} + + {/each} + + + + + + + + + + diff --git a/ts/ChangeNotetype/SaveButton.svelte b/ts/ChangeNotetype/SaveButton.svelte new file mode 100644 index 000000000..1528d314c --- /dev/null +++ b/ts/ChangeNotetype/SaveButton.svelte @@ -0,0 +1,36 @@ + + + + + + + save()} + tooltip={shortcutLabel} + on:mount={createShortcut}>{tr.actionsSave()} + + + diff --git a/ts/ChangeNotetype/index.ts b/ts/ChangeNotetype/index.ts new file mode 100644 index 000000000..89482144d --- /dev/null +++ b/ts/ChangeNotetype/index.ts @@ -0,0 +1,37 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +/* eslint +@typescript-eslint/no-explicit-any: "off", + */ + +import { ChangeNotetypeState, getChangeNotetypeInfo, getNotetypeNames } from "./lib"; +import { setupI18n, ModuleName } from "lib/i18n"; +import { checkNightMode } from "lib/nightmode"; +import ChangeNotetypePage from "./ChangeNotetypePage.svelte"; +import { nightModeKey } from "components/contextKeys"; + +export async function changeNotetypePage( + target: HTMLDivElement, + oldNotetypeId: number, + newNotetypeId: number +): Promise { + const [info, names] = await Promise.all([ + getChangeNotetypeInfo(oldNotetypeId, newNotetypeId), + getNotetypeNames(), + setupI18n({ + modules: [ModuleName.ACTIONS, ModuleName.CHANGE_NOTETYPE], + }), + ]); + + const nightMode = checkNightMode(); + const context = new Map(); + context.set(nightModeKey, nightMode); + + const state = new ChangeNotetypeState(names, info); + return new ChangeNotetypePage({ + target, + props: { state }, + context, + } as any); +} diff --git a/ts/ChangeNotetype/lib.test.ts b/ts/ChangeNotetype/lib.test.ts new file mode 100644 index 000000000..b9597debe --- /dev/null +++ b/ts/ChangeNotetype/lib.test.ts @@ -0,0 +1,126 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +/* eslint +@typescript-eslint/no-explicit-any: "off", + */ + +import * as pb from "lib/backend_proto"; +import { ChangeNotetypeState, negativeOneToNull, MapContext } from "./lib"; +import { get } from "svelte/store"; + +const exampleNames = { + entries: [ + { + id: "1623289129847", + name: "Basic", + }, + { + id: "1623289129848", + name: "Basic (and reversed card)", + }, + { + id: "1623289129849", + name: "Basic (optional reversed card)", + }, + { + id: "1623289129850", + name: "Basic (type in the answer)", + }, + { + id: "1623289129851", + name: "Cloze", + }, + ], +}; + +const exampleInfoDifferent = { + oldFieldNames: ["Front", "Back"], + oldTemplateNames: ["Card 1"], + newFieldNames: ["Front", "Back", "Add Reverse"], + newTemplateNames: ["Card 1", "Card 2"], + input: { + newFields: [0, 1, -1], + newTemplates: [0, -1], + oldNotetypeId: "1623289129847", + newNotetypeId: "1623289129849", + currentSchema: "1623302002316", + }, +}; + +const exampleInfoSame = { + oldFieldNames: ["Front", "Back"], + oldTemplateNames: ["Card 1"], + newFieldNames: ["Front", "Back"], + newTemplateNames: ["Card 1"], + input: { + newFields: [0, 1], + newTemplates: [0], + oldNotetypeId: "1623289129847", + newNotetypeId: "1623289129847", + currentSchema: "1623302002316", + }, +}; + +function differentState(): ChangeNotetypeState { + return new ChangeNotetypeState( + pb.BackendProto.NotetypeNames.fromObject(exampleNames), + pb.BackendProto.ChangeNotetypeInfo.fromObject(exampleInfoDifferent) + ); +} + +function sameState(): ChangeNotetypeState { + return new ChangeNotetypeState( + pb.BackendProto.NotetypeNames.fromObject(exampleNames), + pb.BackendProto.ChangeNotetypeInfo.fromObject(exampleInfoSame) + ); +} + +test("proto conversion", () => { + const state = differentState(); + expect(get(state.info).fields).toStrictEqual([0, 1, null]); + expect(negativeOneToNull(state.dataForSaving().newFields)).toStrictEqual([ + 0, + 1, + null, + ]); +}); + +test("mapping", () => { + const state = differentState(); + expect(get(state.info).getNewName(MapContext.Field, 0)).toBe("Front"); + expect(get(state.info).getNewName(MapContext.Field, 1)).toBe("Back"); + expect(get(state.info).getNewName(MapContext.Field, 2)).toBe("Add Reverse"); + expect(get(state.info).getOldNamesIncludingNothing(MapContext.Field)).toStrictEqual( + ["Front", "Back", "(Nothing)"] + ); + expect(get(state.info).getOldIndex(MapContext.Field, 0)).toBe(0); + expect(get(state.info).getOldIndex(MapContext.Field, 1)).toBe(1); + expect(get(state.info).getOldIndex(MapContext.Field, 2)).toBe(2); + state.setOldIndex(MapContext.Field, 2, 0); + expect(get(state.info).getOldIndex(MapContext.Field, 2)).toBe(0); + + // the same template shouldn't be mappable twice + expect( + get(state.info).getOldNamesIncludingNothing(MapContext.Template) + ).toStrictEqual(["Card 1", "(Nothing)"]); + expect(get(state.info).getOldIndex(MapContext.Template, 0)).toBe(0); + expect(get(state.info).getOldIndex(MapContext.Template, 1)).toBe(1); + state.setOldIndex(MapContext.Template, 1, 0); + expect(get(state.info).getOldIndex(MapContext.Template, 0)).toBe(1); + expect(get(state.info).getOldIndex(MapContext.Template, 1)).toBe(0); +}); + +test("unused", () => { + const state = differentState(); + expect(get(state.info).unusedItems(MapContext.Field)).toStrictEqual([]); + state.setOldIndex(MapContext.Field, 0, 2); + expect(get(state.info).unusedItems(MapContext.Field)).toStrictEqual(["Front"]); +}); + +test("unchanged", () => { + let state = differentState(); + expect(get(state.info).unchanged()).toBe(false); + state = sameState(); + expect(get(state.info).unchanged()).toBe(true); +}); diff --git a/ts/ChangeNotetype/lib.ts b/ts/ChangeNotetype/lib.ts new file mode 100644 index 000000000..8a59101f4 --- /dev/null +++ b/ts/ChangeNotetype/lib.ts @@ -0,0 +1,220 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +/* eslint +@typescript-eslint/no-non-null-assertion: "off", + */ + +import pb from "lib/backend_proto"; +import { postRequest } from "lib/postrequest"; +import { readable, Readable } from "svelte/store"; +import { isEqual } from "lodash-es"; + +export async function getNotetypeNames(): Promise { + return pb.BackendProto.NotetypeNames.decode( + await postRequest("/_anki/notetypeNames", "") + ); +} + +export async function getChangeNotetypeInfo( + oldNotetypeId: number, + newNotetypeId: number +): Promise { + return pb.BackendProto.ChangeNotetypeInfo.decode( + await postRequest( + "/_anki/changeNotetypeInfo", + JSON.stringify({ oldNotetypeId, newNotetypeId }) + ) + ); +} + +export async function changeNotetype( + input: pb.BackendProto.ChangeNotetypeIn +): Promise { + const data: Uint8Array = pb.BackendProto.ChangeNotetypeIn.encode(input).finish(); + await postRequest("/_anki/changeNotetype", data); + return; +} + +function nullToNegativeOne(list: (number | null)[]): number[] { + return list.map((val) => val ?? -1); +} + +/// Public only for tests. +export function negativeOneToNull(list: number[]): (number | null)[] { + return list.map((val) => (val === -1 ? null : val)); +} + +/// Wrapper for the protobuf message to make it more ergonomic. +export class ChangeNotetypeInfoWrapper { + fields: (number | null)[]; + templates?: (number | null)[]; + readonly info: pb.BackendProto.ChangeNotetypeInfo; + + constructor(info: pb.BackendProto.ChangeNotetypeInfo) { + this.info = info; + const templates = info.input!.newTemplates!; + if (templates.length > 0) { + this.templates = negativeOneToNull(templates); + } + this.fields = negativeOneToNull(info.input!.newFields!); + } + + /// A list with an entry for each field/template in the new notetype, with + /// the values pointing back to indexes in the original notetype. + mapForContext(ctx: MapContext): (number | null)[] { + return ctx == MapContext.Template ? this.templates ?? [] : this.fields; + } + + /// Return index of old fields/templates, with null values mapped to "Nothing" + /// at the end. + getOldIndex(ctx: MapContext, newIdx: number): number { + const map = this.mapForContext(ctx); + const val = map[newIdx]; + return val ?? this.getOldNamesIncludingNothing(ctx).length - 1; + } + + /// Return all the old names, with "Nothing" at the end. + getOldNamesIncludingNothing(ctx: MapContext): string[] { + return [...this.getOldNames(ctx), "(Nothing)"]; + } + + /// Old names without "Nothing" at the end. + getOldNames(ctx: MapContext): string[] { + return ctx == MapContext.Template + ? this.info.oldTemplateNames + : this.info.oldFieldNames; + } + + getNewName(ctx: MapContext, idx: number): string { + return ( + ctx == MapContext.Template + ? this.info.newTemplateNames + : this.info.newFieldNames + )[idx]; + } + + unusedItems(ctx: MapContext): string[] { + const usedEntries = new Set(this.mapForContext(ctx).filter((v) => v !== null)); + const oldNames = this.getOldNames(ctx); + const unusedIdxs = [...Array(oldNames.length).keys()].filter( + (idx) => !usedEntries.has(idx) + ); + const unusedNames = unusedIdxs.map((idx) => oldNames[idx]); + unusedNames.sort(); + return unusedNames; + } + + unchanged(): boolean { + return ( + this.input().newNotetypeId === this.input().oldNotetypeId && + isEqual(this.fields, [...Array(this.fields.length).keys()]) && + isEqual(this.templates, [...Array(this.templates?.length ?? 0).keys()]) + ); + } + + input(): pb.BackendProto.ChangeNotetypeIn { + return this.info.input as pb.BackendProto.ChangeNotetypeIn; + } + + /// Pack changes back into input message for saving. + intoInput(): pb.BackendProto.ChangeNotetypeIn { + const input = this.info.input as pb.BackendProto.ChangeNotetypeIn; + input.newFields = nullToNegativeOne(this.fields); + if (this.templates) { + input.newTemplates = nullToNegativeOne(this.templates); + } + + return input; + } +} + +export interface NotetypeListEntry { + idx: number; + name: string; + current: boolean; +} + +export enum MapContext { + Field, + Template, +} +export class ChangeNotetypeState { + readonly info: Readable; + readonly notetypes: Readable; + + private info_: ChangeNotetypeInfoWrapper; + private infoSetter!: (val: ChangeNotetypeInfoWrapper) => void; + private notetypeNames: pb.BackendProto.NotetypeNames; + private notetypesSetter!: (val: NotetypeListEntry[]) => void; + + constructor( + notetypes: pb.BackendProto.NotetypeNames, + info: pb.BackendProto.ChangeNotetypeInfo + ) { + this.info_ = new ChangeNotetypeInfoWrapper(info); + this.info = readable(this.info_, (set) => { + this.infoSetter = set; + }); + this.notetypeNames = notetypes; + this.notetypes = readable(this.buildNotetypeList(), (set) => { + this.notetypesSetter = set; + return; + }); + } + + async setTargetNotetypeIndex(idx: number): Promise { + this.info_.input().newNotetypeId = this.notetypeNames.entries[idx].id!; + this.notetypesSetter(this.buildNotetypeList()); + const newInfo = await getChangeNotetypeInfo( + this.info_.input().oldNotetypeId, + this.info_.input().newNotetypeId + ); + + this.info_ = new ChangeNotetypeInfoWrapper(newInfo); + this.info_.unusedItems(MapContext.Field); + this.infoSetter(this.info_); + } + + setOldIndex(ctx: MapContext, newIdx: number, oldIdx: number): void { + const list = this.info_.mapForContext(ctx); + const realOldIdx = oldIdx < list.length ? oldIdx : null; + const allowDupes = ctx == MapContext.Field; + + // remove any existing references? + if (!allowDupes && realOldIdx !== null) { + for (let i = 0; i < list.length; i++) { + if (list[i] === realOldIdx) { + list[i] = null; + } + } + } + + list[newIdx] = realOldIdx; + this.infoSetter(this.info_); + } + + async save(): Promise { + if (this.info_.unchanged()) { + alert("No changes to save"); + return; + } + await changeNotetype(this.dataForSaving()); + } + + dataForSaving(): pb.BackendProto.ChangeNotetypeIn { + return this.info_.intoInput(); + } + + private buildNotetypeList(): NotetypeListEntry[] { + const currentId = this.info_.input().newNotetypeId; + return this.notetypeNames.entries.map( + (entry, idx) => + ({ + idx, + name: entry.name, + current: entry.id === currentId, + } as NotetypeListEntry) + ); + } +}