mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 22:12:21 -04:00
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
This commit is contained in:
parent
40dc2e217c
commit
61e86cc29d
28 changed files with 1125 additions and 374 deletions
11
ftl/core/change-notetype.ftl
Normal file
11
ftl/core/change-notetype.ftl
Normal file
|
@ -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.
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
##########################################################################
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -14,6 +14,7 @@ ignored-classes=
|
|||
UnburyDeckIn,
|
||||
CardAnswer,
|
||||
QueuedCards,
|
||||
ChangeNotetypeIn,
|
||||
|
||||
[REPORTS]
|
||||
output-format=colorized
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
101
qt/aqt/changenotetype.py
Normal file
101
qt/aqt/changenotetype.py
Normal file
|
@ -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
|
||||
)
|
|
@ -4,6 +4,7 @@ _pages = [
|
|||
"graphs",
|
||||
"congrats",
|
||||
"deckoptions",
|
||||
"ChangeNotetype",
|
||||
]
|
||||
|
||||
[copy_files_into_group(
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -69,6 +69,12 @@ impl From<pb::NotetypeId> for NotetypeId {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<NotetypeId> for pb::NotetypeId {
|
||||
fn from(ntid: NotetypeId) -> Self {
|
||||
pb::NotetypeId { ntid: ntid.0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<pb::DeckConfigId> for DeckConfigId {
|
||||
fn from(dcid: pb::DeckConfigId) -> Self {
|
||||
DeckConfigId(dcid.dcid)
|
||||
|
|
|
@ -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<pb::NotetypeId> {
|
||||
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<pb::ChangeNotetypeInfo> {
|
||||
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<pb::ChangeNotetypeIn> 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<ChangeNotetypeInput> 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(),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<OpOutput<()>> {
|
||||
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<NotetypeId> {
|
||||
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 {
|
||||
|
|
|
@ -38,7 +38,7 @@ pub struct TemplateMap {
|
|||
}
|
||||
|
||||
impl TemplateMap {
|
||||
fn new(new_templates: Vec<Option<usize>>, old_template_count: usize) -> Result<Self> {
|
||||
fn new(new_templates: Vec<Option<usize>>, old_template_count: usize) -> Self {
|
||||
let mut seen: HashSet<usize> = 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<NoteId>,
|
||||
old_notetype_id: NotetypeId,
|
||||
new_notetype_id: NotetypeId,
|
||||
) -> Result<NotetypeChangeInfo> {
|
||||
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<NotetypeId> {
|
||||
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);
|
||||
|
||||
|
|
136
ts/ChangeNotetype/BUILD.bazel
Normal file
136
ts/ChangeNotetype/BUILD.bazel
Normal file
|
@ -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",
|
||||
],
|
||||
)
|
43
ts/ChangeNotetype/ChangeNotetype-base.scss
Normal file
43
ts/ChangeNotetype/ChangeNotetype-base.scss
Normal file
|
@ -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 <select> elements
|
||||
.night-mode select {
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23FFFFFF' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e");
|
||||
}
|
23
ts/ChangeNotetype/ChangeNotetype.html
Normal file
23
ts/ChangeNotetype/ChangeNotetype.html
Normal file
|
@ -0,0 +1,23 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" id="viewport" content="width=device-width" />
|
||||
<link href="ChangeNotetype-base.css" rel="stylesheet" />
|
||||
<link href="ChangeNotetype.css" rel="stylesheet" />
|
||||
<script src="../js/vendor/protobuf.min.js"></script>
|
||||
<script src="../js/vendor/bootstrap.bundle.min.js"></script>
|
||||
<script src="ChangeNotetype.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="main"></div>
|
||||
|
||||
<script>
|
||||
// use #testXXXX where XXXX is notetype ID to test
|
||||
if (window.location.hash.startsWith("#test")) {
|
||||
const ntid = parseInt(window.location.hash.substr("#test".length), 10);
|
||||
anki.changeNotetypePage(document.getElementById("main"), ntid, ntid);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
34
ts/ChangeNotetype/ChangeNotetypePage.svelte
Normal file
34
ts/ChangeNotetype/ChangeNotetypePage.svelte
Normal file
|
@ -0,0 +1,34 @@
|
|||
<!--
|
||||
Copyright: Ankitects Pty Ltd and contributors
|
||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script lang="ts">
|
||||
import * as tr from "lib/i18n";
|
||||
import NotetypeSelector from "./NotetypeSelector.svelte";
|
||||
import Mapper from "./Mapper.svelte";
|
||||
import { ChangeNotetypeState, MapContext } from "./lib";
|
||||
import marked from "marked";
|
||||
|
||||
export let state: ChangeNotetypeState;
|
||||
let info = state.info;
|
||||
</script>
|
||||
|
||||
<NotetypeSelector {state} />
|
||||
|
||||
<h5>{tr.changeNotetypeFields()}</h5>
|
||||
|
||||
<Mapper {state} ctx={MapContext.Field} />
|
||||
|
||||
<h5>{tr.changeNotetypeTemplates()}</h5>
|
||||
|
||||
{#if $info.templates}
|
||||
<Mapper {state} ctx={MapContext.Template} />
|
||||
{:else}
|
||||
<div>{@html marked(tr.changeNotetypeToFromCloze())}</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
h5 {
|
||||
margin-top: 1em;
|
||||
}
|
||||
</style>
|
46
ts/ChangeNotetype/Mapper.svelte
Normal file
46
ts/ChangeNotetype/Mapper.svelte
Normal file
|
@ -0,0 +1,46 @@
|
|||
<!--
|
||||
Copyright: Ankitects Pty Ltd and contributors
|
||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script lang="ts">
|
||||
import * as tr from "lib/i18n";
|
||||
import MapperRow from "./MapperRow.svelte";
|
||||
import { ChangeNotetypeState, MapContext } from "./lib";
|
||||
import { slide } from "svelte/transition";
|
||||
|
||||
export let state: ChangeNotetypeState;
|
||||
export let ctx: MapContext;
|
||||
|
||||
let info = state.info;
|
||||
|
||||
let unused: string[];
|
||||
let unusedMsg: string;
|
||||
$: {
|
||||
unused = $info.unusedItems(ctx);
|
||||
unusedMsg =
|
||||
ctx === MapContext.Field
|
||||
? tr.changeNotetypeWillDiscardContent()
|
||||
: tr.changeNotetypeWillDiscardCards();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container m-1">
|
||||
<div class="row">
|
||||
<div class="col"><b>{tr.changeNotetypeCurrent()}</b></div>
|
||||
<div class="col"><b>{tr.changeNotetypeNew()}</b></div>
|
||||
</div>
|
||||
{#each $info.mapForContext(ctx) as _, newIndex}
|
||||
<MapperRow {state} {ctx} {newIndex} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if unused.length > 0}
|
||||
<div class="alert alert-warning" in:slide out:slide>
|
||||
{unusedMsg}
|
||||
<ul>
|
||||
{#each unused as entry}
|
||||
<li>{entry}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
36
ts/ChangeNotetype/MapperRow.svelte
Normal file
36
ts/ChangeNotetype/MapperRow.svelte
Normal file
|
@ -0,0 +1,36 @@
|
|||
<!--
|
||||
Copyright: Ankitects Pty Ltd and contributors
|
||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { ChangeNotetypeState, MapContext } from "./lib";
|
||||
|
||||
export let state: ChangeNotetypeState;
|
||||
export let ctx: MapContext;
|
||||
export let newIndex: number;
|
||||
|
||||
let info = state.info;
|
||||
|
||||
function onChange(evt: Event) {
|
||||
const oldIdx = parseInt((evt.target as HTMLSelectElement).value, 10);
|
||||
state.setOldIndex(ctx, newIndex, oldIdx);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<!-- svelte-ignore a11y-no-onchange -->
|
||||
<select
|
||||
value={$info.getOldIndex(ctx, newIndex)}
|
||||
class="form-select"
|
||||
on:change={onChange}
|
||||
>
|
||||
{#each $info.getOldNamesIncludingNothing(ctx) as name, idx}
|
||||
<option value={idx}>{name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col align-self-center">
|
||||
{$info.getNewName(ctx, newIndex)}
|
||||
</div>
|
||||
</div>
|
51
ts/ChangeNotetype/NotetypeSelector.svelte
Normal file
51
ts/ChangeNotetype/NotetypeSelector.svelte
Normal file
|
@ -0,0 +1,51 @@
|
|||
<!--
|
||||
Copyright: Ankitects Pty Ltd and contributors
|
||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { ChangeNotetypeState } from "./lib";
|
||||
|
||||
import StickyBar from "components/StickyBar.svelte";
|
||||
import ButtonToolbar from "components/ButtonToolbar.svelte";
|
||||
import ButtonToolbarItem from "components/ButtonToolbarItem.svelte";
|
||||
import ButtonGroup from "components/ButtonGroup.svelte";
|
||||
import ButtonGroupItem from "components/ButtonGroupItem.svelte";
|
||||
|
||||
import SelectButton from "components/SelectButton.svelte";
|
||||
import SelectOption from "components/SelectOption.svelte";
|
||||
import SaveButton from "./SaveButton.svelte";
|
||||
|
||||
export let state: ChangeNotetypeState;
|
||||
let notetypes = state.notetypes;
|
||||
|
||||
async function blur(event: Event): Promise<void> {
|
||||
await state.setTargetNotetypeIndex(
|
||||
parseInt((event.target! as HTMLSelectElement).value)
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<StickyBar>
|
||||
<ButtonToolbar class="justify-content-between" size={2.3} wrap={false}>
|
||||
<ButtonToolbarItem>
|
||||
<ButtonGroup class="flex-grow-1">
|
||||
<ButtonGroupItem>
|
||||
<SelectButton class="flex-grow-1" on:change={blur}>
|
||||
{#each $notetypes as entry}
|
||||
<SelectOption
|
||||
value={String(entry.idx)}
|
||||
selected={entry.current}
|
||||
>
|
||||
{entry.name}
|
||||
</SelectOption>
|
||||
{/each}
|
||||
</SelectButton>
|
||||
</ButtonGroupItem>
|
||||
</ButtonGroup>
|
||||
</ButtonToolbarItem>
|
||||
|
||||
<ButtonToolbarItem>
|
||||
<SaveButton {state} />
|
||||
</ButtonToolbarItem>
|
||||
</ButtonToolbar>
|
||||
</StickyBar>
|
36
ts/ChangeNotetype/SaveButton.svelte
Normal file
36
ts/ChangeNotetype/SaveButton.svelte
Normal file
|
@ -0,0 +1,36 @@
|
|||
<!--
|
||||
Copyright: Ankitects Pty Ltd and contributors
|
||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script lang="ts">
|
||||
import * as tr from "lib/i18n";
|
||||
import type { ChangeNotetypeState } from "./lib";
|
||||
|
||||
import ButtonGroup from "components/ButtonGroup.svelte";
|
||||
import ButtonGroupItem from "components/ButtonGroupItem.svelte";
|
||||
|
||||
import LabelButton from "components/LabelButton.svelte";
|
||||
import WithShortcut from "components/WithShortcut.svelte";
|
||||
|
||||
export let state: ChangeNotetypeState;
|
||||
|
||||
function save(): void {
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
state.save();
|
||||
}
|
||||
</script>
|
||||
|
||||
<ButtonGroup>
|
||||
<ButtonGroupItem>
|
||||
<WithShortcut shortcut={"Control+Enter"} let:createShortcut let:shortcutLabel>
|
||||
<LabelButton
|
||||
theme="primary"
|
||||
on:click={() => save()}
|
||||
tooltip={shortcutLabel}
|
||||
on:mount={createShortcut}>{tr.actionsSave()}</LabelButton
|
||||
>
|
||||
</WithShortcut>
|
||||
</ButtonGroupItem>
|
||||
</ButtonGroup>
|
37
ts/ChangeNotetype/index.ts
Normal file
37
ts/ChangeNotetype/index.ts
Normal file
|
@ -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<ChangeNotetypePage> {
|
||||
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);
|
||||
}
|
126
ts/ChangeNotetype/lib.test.ts
Normal file
126
ts/ChangeNotetype/lib.test.ts
Normal file
|
@ -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);
|
||||
});
|
220
ts/ChangeNotetype/lib.ts
Normal file
220
ts/ChangeNotetype/lib.ts
Normal file
|
@ -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<pb.BackendProto.NotetypeNames> {
|
||||
return pb.BackendProto.NotetypeNames.decode(
|
||||
await postRequest("/_anki/notetypeNames", "")
|
||||
);
|
||||
}
|
||||
|
||||
export async function getChangeNotetypeInfo(
|
||||
oldNotetypeId: number,
|
||||
newNotetypeId: number
|
||||
): Promise<pb.BackendProto.ChangeNotetypeInfo> {
|
||||
return pb.BackendProto.ChangeNotetypeInfo.decode(
|
||||
await postRequest(
|
||||
"/_anki/changeNotetypeInfo",
|
||||
JSON.stringify({ oldNotetypeId, newNotetypeId })
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function changeNotetype(
|
||||
input: pb.BackendProto.ChangeNotetypeIn
|
||||
): Promise<void> {
|
||||
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<ChangeNotetypeInfoWrapper>;
|
||||
readonly notetypes: Readable<NotetypeListEntry[]>;
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue