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) + ); + } +}