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:
Damien Elmes 2021-06-10 21:30:39 +10:00
parent 40dc2e217c
commit 61e86cc29d
28 changed files with 1125 additions and 374 deletions

View 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.

View file

@ -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):

View file

@ -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
##########################################################################

View file

@ -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

View file

@ -14,6 +14,7 @@ ignored-classes=
UnburyDeckIn,
CardAnswer,
QueuedCards,
ChangeNotetypeIn,
[REPORTS]
output-format=colorized

View file

@ -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,

View file

@ -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()

View file

@ -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
View 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
)

View file

@ -4,6 +4,7 @@ _pages = [
"graphs",
"congrats",
"deckoptions",
"ChangeNotetype",
]
[copy_files_into_group(

View file

@ -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,

View file

@ -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))

View file

@ -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;

View file

@ -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)

View file

@ -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(),
}
}

View file

@ -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(&note_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 {

View file

@ -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(&note_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(&note_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);

View 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",
],
)

View 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");
}

View 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>

View 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>

View 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}

View 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>

View 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>

View 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>

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

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