From 46c363d4aa2e33b7b0352b166f016a1d61a347c0 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Fri, 15 May 2020 13:59:44 +1000 Subject: [PATCH] track changes in fields dialog as well And avoid bumping schema until user actually saves, but warn at start. --- pylib/anki/models.py | 105 ++++++++++++++++---------------- qt/aqt/clayout.py | 34 ++++++----- qt/aqt/fields.py | 51 ++++++++++++---- qt/aqt/main.py | 17 ++++++ qt/aqt/schema_change_tracker.py | 36 +++++++++++ 5 files changed, 166 insertions(+), 77 deletions(-) create mode 100644 qt/aqt/schema_change_tracker.py diff --git a/pylib/anki/models.py b/pylib/anki/models.py index ea7c48a7e..e53cfd34d 100644 --- a/pylib/anki/models.py +++ b/pylib/anki/models.py @@ -270,7 +270,6 @@ class ModelManager: self.remove(m["id"]) def remove_all_notetypes(self): - self.col.modSchema(check=True) for nt in self.all_names_and_ids(): self._remove_from_cache(nt.id) self.col.backend.remove_notetype(nt.id) @@ -345,42 +344,25 @@ class ModelManager: def sortIdx(self, m: NoteType) -> Any: return m["sortf"] - def setSortIdx(self, m: NoteType, idx: int) -> None: - assert 0 <= idx < len(m["flds"]) - self.col.modSchema(check=True) - - m["sortf"] = idx - - self.save(m) - # Adding & changing fields ################################################## - def newField(self, name: str) -> Field: + def new_field(self, name: str) -> Field: assert isinstance(name, str) f = defaultField.copy() f["name"] = name return f - def addField(self, m: NoteType, field: Field, save=True) -> None: - if m["id"]: - self.col.modSchema(check=True) - + def add_field(self, m: NoteType, field: Field) -> None: + "Modifies schema." m["flds"].append(field) - if m["id"] and save: - self.save(m) - - def remField(self, m: NoteType, field: Field, save=True) -> None: - self.col.modSchema(check=True) - + def remove_field(self, m: NoteType, field: Field) -> None: + "Modifies schema." m["flds"].remove(field) - if save: - self.save(m) - - def moveField(self, m: NoteType, field: Field, idx: int, save=True) -> None: - self.col.modSchema(check=True) + def reposition_field(self, m: NoteType, field: Field, idx: int) -> None: + "Modifies schema." oldidx = m["flds"].index(field) if oldidx == idx: return @@ -388,48 +370,55 @@ class ModelManager: m["flds"].remove(field) m["flds"].insert(idx, field) - if save: - self.save(m) - - def renameField(self, m: NoteType, field: Field, newName: str, save=True) -> None: + def rename_field(self, m: NoteType, field: Field, new_name: str) -> None: assert field in m["flds"] + field["name"] = new_name - field["name"] = newName + def set_sort_index(self, nt: NoteType, idx: int) -> None: + "Modifies schema." + assert 0 <= idx < len(nt["flds"]) + nt["sortf"] = idx - if save: + # legacy + + newField = new_field + + def addField(self, m: NoteType, field: Field) -> None: + self.add_field(m, field) + if m["id"]: self.save(m) + def remField(self, m: NoteType, field: Field) -> None: + self.remove_field(m, field) + self.save(m) + + def moveField(self, m: NoteType, field: Field, idx: int) -> None: + self.reposition_field(m, field, idx) + self.save(m) + + def renameField(self, m: NoteType, field: Field, newName: str) -> None: + self.rename_field(m, field, newName) + self.save(m) + # Adding & changing templates ################################################## - def newTemplate(self, name: str) -> Template: + def new_template(self, name: str) -> Template: t = defaultTemplate.copy() t["name"] = name return t - def addTemplate(self, m: NoteType, template: Template, save=True) -> None: - if m["id"]: - self.col.modSchema(check=True) - + def add_template(self, m: NoteType, template: Template) -> None: + "Modifies schema." m["tmpls"].append(template) - if save and m["id"]: - self.save(m) - - def remTemplate(self, m: NoteType, template: Template, save=True) -> None: + def remove_template(self, m: NoteType, template: Template) -> None: + "Modifies schema." assert len(m["tmpls"]) > 1 - self.col.modSchema(check=True) - m["tmpls"].remove(template) - if save: - self.save(m) - - def moveTemplate( - self, m: NoteType, template: Template, idx: int, save=True - ) -> None: - self.col.modSchema(check=True) - + def reposition_template(self, m: NoteType, template: Template, idx: int) -> None: + "Modifies schema." oldidx = m["tmpls"].index(template) if oldidx == idx: return @@ -437,9 +426,23 @@ class ModelManager: m["tmpls"].remove(template) m["tmpls"].insert(idx, template) - if save: + # legacy + + newTemplate = new_template + + def addTemplate(self, m: NoteType, template: Template) -> None: + self.add_template(m, template) + if m["id"]: self.save(m) + def remTemplate(self, m: NoteType, template: Template) -> None: + self.remove_template(m, template) + self.save(m) + + def moveTemplate(self, m: NoteType, template: Template, idx: int) -> None: + self.reposition_template(m, template, idx) + self.save(m) + # Model changing ########################################################################## # - maps are ord->ord, and there should not be duplicate targets diff --git a/qt/aqt/clayout.py b/qt/aqt/clayout.py index 6157aa287..38000e003 100644 --- a/qt/aqt/clayout.py +++ b/qt/aqt/clayout.py @@ -16,6 +16,7 @@ from anki.rsbackend import TemplateError from anki.template import TemplateRenderContext from aqt import AnkiQt, gui_hooks from aqt.qt import * +from aqt.schema_change_tracker import ChangeTracker from aqt.sound import av_player, play_clicked_audio from aqt.theme import theme_manager from aqt.utils import ( @@ -36,7 +37,6 @@ from aqt.webview import AnkiWebView # fixme: deck name on new cards # fixme: card count when removing -# fixme: change tracking and tooltip in fields # fixme: replay suppression @@ -61,7 +61,7 @@ class CardLayout(QDialog): self._want_fill_empty_on = fill_empty self.mm._remove_from_cache(self.model["id"]) self.mw.checkpoint(_("Card Types")) - self.changed = False + self.change_tracker = ChangeTracker(self.mw) self.setupTopArea() self.setupMainArea() self.setupButtons() @@ -389,7 +389,7 @@ class CardLayout(QDialog): if self.ignore_change_signals: return - self.changed = True + self.change_tracker.mark_basic() text = self.tform.edit_area.toPlainText() @@ -497,8 +497,9 @@ class CardLayout(QDialog): if not askUser(msg): return - self.changed = True - self.mm.remTemplate(self.model, template, save=False) + if not self.change_tracker.mark_schema(): + return + self.mm.remove_template(self.model, template) # ensure current ordinal is within bounds idx = self.ord @@ -513,7 +514,8 @@ class CardLayout(QDialog): if not name.strip(): return - self.changed = True + if not self.change_tracker.mark_schema(): + return template["name"] = name self.redraw_everything() @@ -535,8 +537,9 @@ class CardLayout(QDialog): if pos == current_pos: return new_idx = pos - 1 - self.changed = True - self.mm.moveTemplate(self.model, template, new_idx, save=False) + if not self.change_tracker.mark_schema(): + return + self.mm.reposition_template(self.model, template, new_idx) self.ord = new_idx self.redraw_everything() @@ -561,13 +564,14 @@ class CardLayout(QDialog): ) if not askUser(txt): return - self.changed = True + if not self.change_tracker.mark_schema(): + return name = self._newCardName() t = self.mm.newTemplate(name) old = self.current_template() t["qfmt"] = old["qfmt"] t["afmt"] = old["afmt"] - self.mm.addTemplate(self.model, t, save=False) + self.mm.add_template(self.model, t) self.ord = len(self.templates) - 1 self.redraw_everything() @@ -587,7 +591,7 @@ adjust the template manually to switch the question and answer.""" ) ) return - self.changed = True + self.change_tracker.mark_basic() dst["afmt"] = "{{FrontSide}}\n\n
\n\n%s" % src["qfmt"] dst["qfmt"] = m.group(2).strip() return True @@ -639,7 +643,7 @@ adjust the template manually to switch the question and answer.""" def onBrowserDisplayOk(self, f): t = self.current_template() - self.changed = True + self.change_tracker.mark_basic() t["bqfmt"] = f.qfmt.text().strip() t["bafmt"] = f.afmt.text().strip() if f.overrideFont.isChecked(): @@ -678,7 +682,7 @@ Enter deck to place new %s cards in, or leave blank:""" l.addWidget(bb) d.setLayout(l) d.exec_() - self.changed = True + self.change_tracker.mark_basic() if not te.text().strip(): t["did"] = None else: @@ -709,7 +713,7 @@ Enter deck to place new %s cards in, or leave blank:""" field, ) self.tform.edit_area.setPlainText(text) - self.changed = True + self.change_tracker.mark_basic() self.write_edits_to_template_and_redraw() # Closing & Help @@ -733,7 +737,7 @@ Enter deck to place new %s cards in, or leave blank:""" self.mw.taskman.with_progress(save, on_done) def reject(self) -> None: - if self.changed: + if self.change_tracker.changed(): if not askUser("Discard changes?"): return self.cleanup() diff --git a/qt/aqt/fields.py b/qt/aqt/fields.py index 4bade27a2..55b854063 100644 --- a/qt/aqt/fields.py +++ b/qt/aqt/fields.py @@ -8,18 +8,20 @@ from anki.models import NoteType from anki.rsbackend import TemplateError from aqt import AnkiQt from aqt.qt import * -from aqt.utils import askUser, getOnlyText, openHelp, showWarning +from aqt.schema_change_tracker import ChangeTracker +from aqt.utils import askUser, getOnlyText, openHelp, showWarning, tooltip class FieldDialog(QDialog): def __init__(self, mw: AnkiQt, nt: NoteType, parent=None): QDialog.__init__(self, parent or mw) - self.mw = mw.weakref() + self.mw = mw self.col = self.mw.col self.mm = self.mw.col.models self.model = nt self.mm._remove_from_cache(self.model["id"]) self.mw.checkpoint(_("Fields")) + self.change_tracker = ChangeTracker(self.mw) self.form = aqt.forms.fields.Ui_Dialog() self.form.setupUi(self) self.setWindowTitle(_("Fields for %s") % self.model["name"]) @@ -88,7 +90,9 @@ class FieldDialog(QDialog): name = self._uniqueName(_("New name:"), self.currentIdx, f["name"]) if not name: return - self.mm.renameField(self.model, f, name, save=False) + + self.change_tracker.mark_basic() + self.mm.rename_field(self.model, f, name) self.saveField() self.fillFields() self.form.fieldList.setCurrentRow(idx) @@ -97,9 +101,11 @@ class FieldDialog(QDialog): name = self._uniqueName(_("Field name:")) if not name: return + if not self.change_tracker.mark_schema(): + return self.saveField() f = self.mm.newField(name) - self.mm.addField(self.model, f, save=False) + self.mm.add_field(self.model, f) self.fillFields() self.form.fieldList.setCurrentRow(len(self.model["flds"]) - 1) @@ -110,8 +116,10 @@ class FieldDialog(QDialog): c = ngettext("%d note", "%d notes", c) % c if not askUser(_("Delete field from %s?") % c): return + if not self.change_tracker.mark_schema(): + return f = self.model["flds"][self.form.fieldList.currentRow()] - self.mm.remField(self.model, f, save=False) + self.mm.remove_field(self.model, f) self.fillFields() self.form.fieldList.setCurrentRow(0) @@ -130,14 +138,18 @@ class FieldDialog(QDialog): self.moveField(pos) def onSortField(self): + if not self.change_tracker.mark_schema(): + return False # don't allow user to disable; it makes no sense self.form.sortField.setChecked(True) - self.model["sortf"] = self.form.fieldList.currentRow() + self.mm.set_sort_index(self.model, self.form.fieldList.currentRow()) def moveField(self, pos): + if not self.change_tracker.mark_schema(): + return False self.saveField() f = self.model["flds"][self.currentIdx] - self.mm.moveField(self.model, f, pos - 1, save=False) + self.mm.reposition_field(self.model, f, pos - 1) self.fillFields() self.form.fieldList.setCurrentRow(pos - 1) @@ -158,12 +170,28 @@ class FieldDialog(QDialog): idx = self.currentIdx fld = self.model["flds"][idx] f = self.form - fld["font"] = f.fontFamily.currentFont().family() - fld["size"] = f.fontSize.value() - fld["sticky"] = f.sticky.isChecked() - fld["rtl"] = f.rtl.isChecked() + font = f.fontFamily.currentFont().family() + if fld["font"] != font: + fld["font"] = font + self.change_tracker.mark_basic() + size = f.fontSize.value() + if fld["size"] != size: + fld["size"] = size + self.change_tracker.mark_basic() + sticky = f.sticky.isChecked() + if fld["sticky"] != sticky: + fld["sticky"] = sticky + self.change_tracker.mark_basic() + rtl = f.rtl.isChecked() + if fld["rtl"] != rtl: + fld["rtl"] = rtl + self.change_tracker.mark_basic() def reject(self): + if self.change_tracker.changed(): + if not askUser("Discard changes?"): + return + QDialog.reject(self) def accept(self): @@ -180,6 +208,7 @@ class FieldDialog(QDialog): showWarning("Unable to save changes: " + str(e)) return self.mw.reset() + tooltip("Changes saved.", parent=self.mw) QDialog.accept(self) self.mw.taskman.with_progress(save, on_done, self) diff --git a/qt/aqt/main.py b/qt/aqt/main.py index 15646d080..fe355ff34 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -1282,6 +1282,7 @@ and if the problem comes up again, please ask on the support site.""" # Schema modifications ########################################################################## + # this will gradually be phased out def onSchemaMod(self, arg): assert self.inMainThread() progress_shown = self.progress.busy() @@ -1300,6 +1301,22 @@ will be lost. Continue?""" self.progress.start() return ret + # in favour of this + def confirm_schema_modification(self) -> bool: + """If schema unmodified, ask user to confirm change. + True if confirmed or already modified.""" + if self.col.schemaChanged(): + return True + return askUser( + _( + """\ +The requested change will require a full upload of the database when \ +you next synchronize your collection. If you have reviews or other changes \ +waiting on another device that haven't been synchronized here yet, they \ +will be lost. Continue?""" + ) + ) + # Advanced features ########################################################################## diff --git a/qt/aqt/schema_change_tracker.py b/qt/aqt/schema_change_tracker.py new file mode 100644 index 000000000..59e9c955c --- /dev/null +++ b/qt/aqt/schema_change_tracker.py @@ -0,0 +1,36 @@ +# Copyright: Ankitects Pty Ltd and contributors +# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +from __future__ import annotations + +import enum + +from aqt import AnkiQt + + +class Change(enum.Enum): + NO_CHANGE = 0 + BASIC_CHANGE = 1 + SCHEMA_CHANGE = 2 + + +class ChangeTracker: + _changed = Change.NO_CHANGE + + def __init__(self, mw: AnkiQt): + self.mw = mw + + def mark_basic(self): + if self._changed == Change.NO_CHANGE: + self._changed = Change.BASIC_CHANGE + + def mark_schema(self) -> bool: + "If false, processing should be aborted." + if self._changed != Change.SCHEMA_CHANGE: + if not self.mw.confirm_schema_modification(): + return False + self._changed = Change.SCHEMA_CHANGE + return True + + def changed(self) -> bool: + return self._changed != Change.NO_CHANGE