Exemple #1
0
class FinalTouchesWindow(QWidget):
    """
    Window 3/3, allows the user to set deck and tags.
    """
    def __init__(self, vocab_words: List[Term]):
        super().__init__(mw, flags=QtCore.Qt.Window)
        self.vocab_words = vocab_words
        self.init_layout()

    def init_layout(self):
        self.setWindowTitle(" Prestudy")

        vbox = QVBoxLayout()

        vbox.addWidget(QLabel("Select deck to add notes to:"))
        self.combo_box = QComboBox(self)
        self.combo_box.addItems(self.deck_names)
        vbox.addWidget(self.combo_box)

        vbox.addWidget(
            QLabel(
                "(Optional) Enter tag(s) to add to notes, separated by spaces:"
            ))
        self.tags_box = QLineEdit()
        vbox.addWidget(self.tags_box)

        hbox = QHBoxLayout()
        self.finish_button = QPushButton("Add Notes")
        hbox.addStretch(1)
        hbox.addWidget(self.finish_button)
        vbox.addLayout(hbox)

        self.finish_button.clicked.connect(lambda: self.add_notes_action())

        self.setLayout(vbox)

    @property
    def deck_names(self):
        return [d["name"] for d in self.decks]

    @property
    def decks(self):
        return sorted(list(mw.col.decks.decks.values()),
                      key=lambda d: d["name"])

    def add_notes_action(self):
        # Checkpoint so user can undo later
        mw.checkpoint("Add Prestudy Notes")

        add_notes(self.vocab_words, self.combo_box.currentText(),
                  self.tags_box.text().split())

        # Refresh main window view
        mw.reset()

        self.close()
Exemple #2
0
class TextCleanerDialogBase(QDialog):
    """Base class for dialogs"""
    def __init__(self, browser, nids, description, title):
        super().__init__(parent=browser)
        self.browser = browser
        self.nids = nids
        self.description = description
        self.title = title
        self.changelog = ChangeLog()
        self._setup_ui()

    def _setup_ui(self):
        self.setWindowTitle(self.title)
        self.setMinimumWidth(600)
        self.setMinimumHeight(400)

        vbox = QVBoxLayout()
        vbox.addLayout(self._ui_top_row())
        vbox.addLayout(self._ui_field_select_row())
        vbox.addWidget(self._ui_log())
        vbox.addLayout(self._ui_bottom_row())

        self.setLayout(vbox)

    def _ui_top_row(self):
        hbox = QHBoxLayout()
        hbox.addWidget(QLabel(self.description))
        return hbox

    def _ui_field_select_row(self):
        hbox = QHBoxLayout()
        hbox.setAlignment(Qt.AlignLeft)
        hbox.addWidget(QLabel("Field:"))

        model = self.browser.mw.col.getNote(self.nids[0]).model()
        field_names = self.browser.mw.col.models.fieldNames(model)

        self.field_selection = QComboBox()
        self.field_selection.addItems(field_names)
        hbox.addWidget(self.field_selection)

        return hbox

    def _ui_log(self):
        self.log = QPlainTextEdit()
        self.log.setTabChangesFocus(False)
        self.log.setReadOnly(True)

        font = QFontDatabase.systemFont(QFontDatabase.FixedFont)
        font.setPointSize(self.log.font().pointSize() - 2)
        self.log.setFont(font)
        return self.log

    def _ui_bottom_row(self):
        hbox = QHBoxLayout()

        buttons = QDialogButtonBox(Qt.Horizontal, self)

        # Button to check if content needs to be changed
        check_btn = buttons.addButton("&Check", QDialogButtonBox.ActionRole)
        check_btn.setToolTip("Check")
        check_btn.clicked.connect(lambda _: self.onCheck())

        # Button to generate diff of proposed content changes
        diff_btn = buttons.addButton("&Diff", QDialogButtonBox.ActionRole)
        diff_btn.setToolTip("Show diff")
        diff_btn.clicked.connect(lambda _: self.onDiff())

        # Button to make the proposed changes
        fix_btn = buttons.addButton("&Fix", QDialogButtonBox.ActionRole)
        fix_btn.setToolTip("Fix")
        fix_btn.clicked.connect(lambda _: self.onFix())

        # Button to close this dialog
        close_btn = buttons.addButton("&Close", QDialogButtonBox.RejectRole)
        close_btn.clicked.connect(self.close)

        hbox.addWidget(buttons)
        return hbox

    def clean_content(self, content, output_html_diff=False):
        raise NotImplementedError("clean_content")

    def onCheck(self):
        """Checks which notes need to be updated for the selected field"""
        append_to_log = self.log.appendPlainText

        try:
            self.log.clear()
            nids = self.nids
            field_name = self.field_selection.currentText()

            checked = 0
            need_clean = 0
            failed_notes = []
            for nid in nids:
                note = self.browser.mw.col.getNote(nid)
                if field_name in note:
                    content = note[field_name]
                    try:
                        cleaned_content = self.clean_content(content)
                        if content != cleaned_content:
                            append_to_log(
                                "Need to update note for nid {}:".format(nid))
                            append_to_log("{}\n=>\n{}\n".format(
                                content, cleaned_content))
                            need_clean += 1
                    except TextProcessingError as e:
                        failed_notes.append((nid, content, str(e)))
                    checked += 1
            if failed_notes:
                append_to_log(
                    "Found {} notes that failed to be processed:".format(
                        len(failed_notes)))
                for nid, _, exc in failed_notes:
                    append_to_log("{}: {}\n".format(nid, exc))
            append_to_log("Checked {} notes".format(checked))
            append_to_log("Found {} notes ({:.0f}%) need to be updated".format(
                need_clean,
                0 if not checked else 100.0 * need_clean / checked))
            if failed_notes:
                append_to_log(
                    "Found {} notes that failed to be processed".format(
                        len(failed_notes)))

        except Exception:
            append_to_log("Failed while checking notes:\n{}".format(
                traceback.format_exc()))

        # Ensure QPlainTextEdit refreshes (not clear why this is necessary)
        self.log.repaint()

    def onDiff(self):
        """Produces HTML diff of the updates that would be made"""
        append_to_log = self.log.appendPlainText

        lines = []
        try:
            self.log.clear()
            nids = self.nids
            field_name = self.field_selection.currentText()

            cnt = 0
            need_clean = 0
            failed_notes = []
            for nid in nids:
                note = self.browser.mw.col.getNote(nid)
                if field_name in note:
                    content = note[field_name]
                    try:
                        cleaned_content = self.clean_content(
                            content, output_html_diff=True)
                        if content != cleaned_content:
                            lines.append((nid, cleaned_content))
                            need_clean += 1
                    except TextProcessingError as e:
                        failed_notes.append((nid, content, str(e)))
                    cnt += 1
            if failed_notes:
                append_to_log(
                    "Found {} notes that failed to be processed:".format(
                        len(failed_notes)))
                for nid, _, exc in failed_notes:
                    append_to_log("{}: {}\n".format(nid, exc))
            append_to_log(
                "Checked {} notes. Found {} notes need updating.".format(
                    cnt, need_clean))
            if failed_notes:
                append_to_log(
                    "Found {} notes that failed to be processed".format(
                        len(failed_notes)))

            if len(lines) > 0:
                ext = ".html"
                default_path = QStandardPaths.writableLocation(
                    QStandardPaths.DocumentsLocation)
                path = os.path.join(default_path, f"diff{ext}")

                options = QFileDialog.Options()

                # native doesn't seem to works
                options |= QFileDialog.DontUseNativeDialog

                # we'll confirm ourselves
                options |= QFileDialog.DontConfirmOverwrite

                result = QFileDialog.getSaveFileName(self,
                                                     "Save HTML diff",
                                                     path,
                                                     f"HTML (*{ext})",
                                                     options=options)

                if not isinstance(result, tuple):
                    raise Exception("Expected a tuple from save dialog")
                file = result[0]
                if file:
                    do_save = True
                    if not file.lower().endswith(ext):
                        file += ext
                    if os.path.exists(file):
                        if not askUser(
                                "{} already exists. Are you sure you want to overwrite it?"
                                .format(file),
                                parent=self):
                            do_save = False
                    if do_save:
                        append_to_log("Saving to {}".format(file))
                        with open(file, "w", encoding="utf-8") as outf:
                            outf.write(DIFF_PRE)
                            for nid, line in lines:
                                outf.write("<p>nid {}:</p>\n".format(nid))
                                outf.write("<p>{}</p>\n".format(line))
                            outf.write(DIFF_POST)
                        append_to_log("Done")

            # Ensure QPlainTextEdit refreshes (not clear why this is necessary)
            self.log.repaint()

        except Exception:
            append_to_log("Failed while checking notes:\n{}".format(
                traceback.format_exc()))

    def onFix(self):
        """Updates the selected notes where the content needs to be updated"""
        append_to_log = self.log.appendPlainText

        try:
            self.log.clear()
            nids = self.nids
            field_name = self.field_selection.currentText()

            append_to_log("Checking how many notes need to be updated")
            checked = 0
            note_changes = []
            failed_notes = []
            for nid in nids:
                note = self.browser.mw.col.getNote(nid)
                if field_name in note:
                    content = note[field_name]
                    try:
                        cleaned_content = self.clean_content(content)
                        if content != cleaned_content:
                            note_changes.append(
                                NoteChange(nid=nid,
                                           old=content,
                                           new=cleaned_content))
                    except TextProcessingError as e:
                        failed_notes.append((nid, content, str(e)))
                    checked += 1

            if failed_notes:
                append_to_log(
                    "Found {} notes that failed to be processed:".format(
                        len(failed_notes)))
                for nid, _, exc in failed_notes:
                    append_to_log("{}: {}\n".format(nid, exc))

            append_to_log("{} of {} notes will be updated".format(
                len(note_changes), checked))

            if failed_notes:
                append_to_log(
                    "Found {} notes that failed to be processed.".format(
                        len(failed_notes)))

            self.log.repaint()

            if askUser(
                    "{} of {} notes will be updated.  Are you sure you want to do this?"
                    .format(len(note_changes), checked),
                    parent=self):

                append_to_log("Beginning update")

                self.browser.mw.checkpoint("{} ({} {})".format(
                    self.checkpoint_name, len(note_changes),
                    "notes" if len(note_changes) > 1 else "note"))
                self.browser.model.beginReset()

                cleaned = 0
                try:
                    init_ts = int(time.time() * 1000)
                    for note_change in note_changes:
                        note = self.browser.mw.col.getNote(note_change.nid)
                        content = note[field_name]

                        # content should not have changed
                        if content != note_change.old:
                            # this should never happen
                            raise NoteFixError(
                                "nid {} old and new content do not match".
                                format(note_change.nid))

                        cleaned_content = note_change.new

                        append_to_log("Updating note for nid {}:".format(nid))
                        append_to_log("{}\n=>\n{}\n".format(
                            content, cleaned_content))

                        ts = int(time.time() * 1000)

                        note[field_name] = cleaned_content
                        note.flush()

                        self.changelog.record_change(
                            self.op, init_ts,
                            ChangeLogEntry(ts=ts,
                                           nid=nid,
                                           fld=field_name,
                                           old=content,
                                           new=cleaned_content))

                        cleaned += 1

                    append_to_log("Updated {} notes ({:.0f}%)".format(
                        cleaned,
                        0 if not checked else 100.0 * cleaned / checked))

                finally:
                    if cleaned:
                        self.changelog.commit_changes()
                        self.browser.mw.requireReset()
                    self.browser.model.endReset()

            else:
                append_to_log("User aborted update")

        except Exception:
            append_to_log("Failed while checking notes:\n{}".format(
                traceback.format_exc()))

        # Ensure QPlainTextEdit refreshes (not clear why this is necessary)
        self.log.repaint()

    def close(self):
        self.changelog.close()
        super().close()
Exemple #3
0
class BatchCleanDialog(QDialog):
    """Browser batch editing dialog"""
    def __init__(self, browser, nids):
        QDialog.__init__(self, parent=browser)
        self.browser = browser
        self.nids = nids
        self._setupUi()

    def _setupUi(self):
        flabel = QLabel("In this field:")
        self.fsel = QComboBox()
        fields = self._getFields()
        self.fsel.addItems(fields)
        self.cb = QCheckBox()
        self.cb.setText("transform to plain text")
        f_hbox = QHBoxLayout()
        f_hbox.addWidget(flabel)
        f_hbox.addWidget(self.fsel)
        f_hbox.addWidget(self.cb)
        f_hbox.setAlignment(Qt.AlignLeft)

        button_box = QDialogButtonBox(
            QDialogButtonBox.Ok | QDialogButtonBox.Cancel,
            orientation=Qt.Horizontal,
            parent=self,
        )

        bottom_hbox = QHBoxLayout()
        bottom_hbox.addWidget(button_box)

        vbox_main = QVBoxLayout()
        vbox_main.addLayout(f_hbox)
        vbox_main.addLayout(bottom_hbox)
        self.setLayout(vbox_main)
        self.setWindowTitle("Batch Clean Selected Notes")
        button_box.rejected.connect(self.reject)
        button_box.accepted.connect(self.accept)
        self.rejected.connect(self.reject)
        self.accepted.connect(self.accept)
        self.fsel.setFocus()

    def _getFields(self):
        nid = self.nids[0]
        mw = self.browser.mw
        model = mw.col.getNote(nid).model()
        fields = mw.col.models.fieldNames(model)
        return fields

    def reject(self):
        self.close()

    def accept(self):
        if self.cb.isChecked():
            func = stripHTML
        else:
            func = cleanHtml_regular_use
        self.browser.mw.checkpoint("batch edit")
        self.browser.mw.progress.start()
        self.browser.model.beginReset()
        fld_name = self.fsel.currentText()
        cnt = 0
        for nid in self.nids:
            note = self.browser.mw.col.getNote(nid)
            cleaned = func(note[fld_name])
            if cleaned != note[fld_name]:
                cnt += 1
                note[fld_name] = cleaned
                note.flush()
        self.browser.model.endReset()
        self.browser.mw.requireReset()
        self.browser.mw.progress.finish()
        self.browser.mw.reset()
        self.close()
        tooltip(f"{cnt} notes cleaned")

    def closeEvent(self, evt):
        evt.accept()
class BatchUpdateDialog(QDialog):
    """Base class for dialogs"""
    def __init__(self, browser, nids, file):
        super().__init__(parent=browser)
        self.browser = browser
        self.nids = nids
        self.title = "Batch Update Selected Notes"
        self.changelog = ChangeLog()
        self.checkpoint_name = "Batch Update"
        self.file = file

        # note field names and model id
        first_note = self.browser.mw.col.getNote(self.nids[0])
        model = first_note.model()
        self.model_id = first_note.mid
        self.note_field_names = self.browser.mw.col.models.fieldNames(model)

        # file field names
        with open(self.file, encoding="utf-8") as inf:
            reader = csv.DictReader(inf)

            # load field names as list of strings
            self.file_field_names = reader.fieldnames

        self._setup_ui()

    def _setup_ui(self):
        self.setWindowTitle(self.title)
        self.setMinimumWidth(600)
        self.setMinimumHeight(400)

        vbox = QVBoxLayout()
        for row in self._ui_join_keys_row():
            vbox.addLayout(row)
        scroll_area = QScrollArea()
        inner = QFrame(scroll_area)
        vbox_scrollable = QVBoxLayout()
        inner.setLayout(vbox_scrollable)
        for row in self._ui_field_select_rows():
            vbox_scrollable.addLayout(row)
        scroll_area.setWidget(inner)

        splitter = QSplitter()
        splitter.setOrientation(Qt.Vertical)
        splitter.addWidget(scroll_area)
        splitter.addWidget(self._ui_log())
        vbox.addWidget(splitter)
        vbox.addLayout(self._ui_bottom_row())

        self.setLayout(vbox)

    def _ui_join_keys_row(self):
        def _fix_width(cb):
            width = cb.minimumSizeHint().width()
            cb.view().setMinimumWidth(width)

        # first row consists of join keys for notes and file
        hbox = QHBoxLayout()
        hbox.setAlignment(Qt.AlignLeft)

        # file join key
        hbox.addWidget(QLabel("File Join Key:"))
        self.file_join_key_selection = QComboBox()
        self.file_join_key_selection.addItems(self.file_field_names)
        _fix_width(self.file_join_key_selection)
        if "nid" in self.file_field_names:
            self.file_join_key_selection.setCurrentText("nid")
        else:
            self.file_join_key_selection.setCurrentText(
                self.file_field_names[0])
        self.file_join_key_selection.currentIndexChanged.connect(
            lambda _: self._combobox_changed(self.file_join_key_selection))
        hbox.addWidget(self.file_join_key_selection)

        # note join key
        hbox.addWidget(QLabel("Note Join Key:"))
        self.note_join_key_selection = QComboBox()
        expanded_note_field_names = ["nid"] + self.note_field_names
        self.note_join_key_selection.addItems(expanded_note_field_names)
        _fix_width(self.note_join_key_selection)
        self.note_join_key_selection_default_value = "nid"
        if self.file_join_key_selection.currentText(
        ) in expanded_note_field_names:
            self.note_join_key_selection.setCurrentText(
                self.file_join_key_selection.currentText())
        else:
            self.note_join_key_selection.setCurrentText(
                self.note_join_key_selection_default_value)
        self.note_join_key_selection.currentIndexChanged.connect(
            lambda _: self._combobox_changed(self.note_join_key_selection))
        hbox.addWidget(self.note_join_key_selection)

        yield hbox

        hbox = QHBoxLayout()
        hbox.addWidget(
            QLabel("Define the mapping from file fields to note fields. "
                   "Any file fields mapping to nothing will be ignored."))
        yield hbox

    def _ui_field_select_rows(self):

        # combo boxes to select mapping for remaining fields
        self.mapping_field_selections = []
        for field_name in self.file_field_names:
            # nid can only be used as a join key
            if field_name == "nid":
                continue
            hbox = QHBoxLayout()
            hbox.setAlignment(Qt.AlignLeft)
            hbox.addWidget(QLabel("{} -> ".format(field_name)))
            field_selection = QComboBox()
            field_selection.addItems([NOTHING_VALUE] + self.note_field_names)
            width = field_selection.minimumSizeHint().width()
            field_selection.view().setMinimumWidth(width)
            if field_name in self.note_field_names and \
                    field_name != self.note_join_key_selection.currentText():
                field_selection.setCurrentText(field_name)
            else:
                field_selection.setCurrentText(NOTHING_VALUE)
            field_selection.currentIndexChanged.connect(
                lambda _, fs=field_selection: self._combobox_changed(fs))
            hbox.addWidget(field_selection)
            self.mapping_field_selections.append(field_selection)
            yield hbox

    def _combobox_changed(self, updated_cb):
        new_text = updated_cb.currentText()

        # We only need to check for non-nothing values, because multiple fields can
        # be set to nothing.
        if new_text != NOTHING_VALUE and updated_cb is not self.file_join_key_selection:
            # We only need to check the note mappings.  We exclude the file join key combobox.
            note_field_combos = [self.note_join_key_selection
                                 ] + self.mapping_field_selections

            for cb in note_field_combos:
                if cb is updated_cb:
                    continue
                else:
                    if cb.currentText() == new_text:
                        if cb is self.note_join_key_selection:
                            cb.setCurrentText(
                                self.note_join_key_selection_default_value)
                        else:
                            cb.setCurrentText(NOTHING_VALUE)

    def _ui_log(self):
        self.log = QPlainTextEdit()
        self.log.setTabChangesFocus(False)
        self.log.setReadOnly(True)

        font = QFontDatabase.systemFont(QFontDatabase.FixedFont)
        font.setPointSize(self.log.font().pointSize() - 2)
        self.log.setFont(font)
        return self.log

    def _ui_bottom_row(self):
        hbox = QHBoxLayout()

        buttons = QDialogButtonBox(Qt.Horizontal, self)

        # Button to check if content needs to be changed
        check_btn = buttons.addButton("&Dry-run", QDialogButtonBox.ActionRole)
        check_btn.setToolTip("Dry-run")
        check_btn.clicked.connect(lambda _: self.onCheck(mode="dryrun"))

        # Button to diff the proposed changes
        diff_btn = buttons.addButton("&Diff", QDialogButtonBox.ActionRole)
        diff_btn.setToolTip("Diff")
        diff_btn.clicked.connect(lambda _: self.onCheck(mode="diff"))

        # Button to make the proposed changes
        fix_btn = buttons.addButton("&Update", QDialogButtonBox.ActionRole)
        fix_btn.setToolTip("Update")
        fix_btn.clicked.connect(lambda _: self.onCheck(mode="update"))

        # Button to close this dialog
        close_btn = buttons.addButton("&Close", QDialogButtonBox.RejectRole)
        close_btn.clicked.connect(self.close)

        hbox.addWidget(buttons)
        return hbox

    def onCheck(self, *, mode):
        self.log.clear()
        try:
            # Mapping from field name in file to field combo boxes for notes.
            # We need to check each of the selections for the combo boxes.
            zipped_fields = zip(
                [fn for fn in self.file_field_names if fn != "nid"],
                self.mapping_field_selections)

            # mapping from file join key name to note join key name
            file_join_key_name = self.file_join_key_selection.currentText()
            note_join_key_name = self.note_join_key_selection.currentText()

            self.log.appendPlainText(
                "Join key: File field '{}' -> Note field '{}'".format(
                    file_join_key_name, note_join_key_name))

            # Check which of the field combo boxes having a non-nothing selection and
            # build the mapping from fields in file to fields in notes.
            file_to_note_mappings = {}
            for file_field_name, note_field_cb in zipped_fields:
                note_field_name = note_field_cb.currentText()
                if note_field_name != NOTHING_VALUE:
                    file_to_note_mappings[file_field_name] = note_field_name
                    self.log.appendPlainText(
                        "File field '{}' -> Note field '{}'".format(
                            file_field_name, note_field_name))
            if not file_to_note_mappings:
                self.log.appendPlainText("ERROR: No mappings selected")
                return

            # Check which key values exist and to make sure there are no duplicate values.
            # Build a mapping form these key values to the row which contains the field values.
            file_key_to_values = {}
            duplicate_file_key_values = set()
            with open(self.file, encoding="utf-8") as inf:
                reader = csv.DictReader(inf)
                for row in reader:
                    join_key_val = row[file_join_key_name]
                    if join_key_val in file_key_to_values:
                        duplicate_file_key_values.add(join_key_val)
                    else:
                        file_key_to_values[join_key_val] = row
            if duplicate_file_key_values:
                self.log.appendPlainText(
                    "ERROR: Found {} key values for '{}' that appear more than once:"
                    .format(len(duplicate_file_key_values),
                            file_join_key_name))
                for val in duplicate_file_key_values:
                    self.log.appendPlainText(val)
                return
            self.log.appendPlainText("Found {} records for '{}' in {}".format(
                len(file_key_to_values), file_join_key_name, self.file))

            # When we aren't joining by nid, we need to create an additional mapping from the join
            # key to the nid value, because we can only look up by nid.
            note_join_key_to_nid = {}
            if note_join_key_name != "nid":
                self.log.appendPlainText(
                    "Joining to notes by '{}', so finding all values.".format(
                        note_join_key_name))
                for nid in self.nids:
                    note = self.browser.mw.col.getNote(nid)
                    if note.mid != self.model_id:
                        self.log.appendPlainText(
                            "ERROR: Note {} has different model ID {} than expected {} based on first note. "
                            .format(nid, note.mid, self.model_id) +
                            "Please only select notes of the same model.")
                        return
                    if note_join_key_name in note:
                        if note[note_join_key_name] in note_join_key_to_nid:
                            self.log.appendPlainText(
                                "ERROR: Value '{}' already exists in notes".
                                format(note[note_join_key_name]))
                            return
                        else:
                            note_join_key_to_nid[
                                note[note_join_key_name]] = nid
                    else:
                        self.log.appendPlainText(
                            "ERROR: Field '{}' not found in note {}".format(
                                note_join_key_name, nid))
                        return

            # these store the changes we will propose to make (grouped by nid)
            note_changes = defaultdict(list)

            # track join keys that were not found in notes
            missing_note_keys = set()

            # how many fields being updated are empty
            empty_note_field_count = 0
            notes_with_empty_fields = set()

            for file_key, file_values in file_key_to_values.items():

                if note_join_key_name == "nid":
                    nid = file_key
                else:
                    if file_key in note_join_key_to_nid:
                        nid = note_join_key_to_nid[file_key]
                        self.log.appendPlainText(
                            "Found note {} with value {} for '{}'".format(
                                nid, file_key, note_join_key_name))
                    else:
                        self.log.appendPlainText(
                            "Could not find note with value {} for '{}'".
                            format(file_key, note_join_key_name))
                        missing_note_keys.add(file_key)
                        continue

                self.log.appendPlainText("Checking note {}".format(nid))

                try:
                    note = self.browser.mw.col.getNote(nid)
                except TypeError:
                    self.log.appendPlainText(
                        "ERROR: Note {} was not found".format(nid))
                    return

                # Get the current values for fields we're updating in the note.
                note_values = {}
                for note_field_name in file_to_note_mappings.values():
                    if note_field_name in note:
                        note_values[note_field_name] = note[note_field_name]
                    else:
                        self.log.appendPlainText(
                            "ERROR: Field '{}' not found in note {}".format(
                                note_field_name, nid))
                        return

                # Compare the file field values to the note field values and see if anything is different
                # and therefore needs to be updated.
                for file_field_name, note_field_name in file_to_note_mappings.items(
                ):
                    file_value = file_values[file_field_name]
                    note_value = note_values[note_field_name]
                    if file_value != note_value:
                        self.log.appendPlainText(
                            "Need to update note field '{}':".format(
                                note_field_name))
                        self.log.appendPlainText("{}\n=>\n{}".format(
                            note_value or "<empty>", file_value))
                        note_changes[nid].append(
                            NoteChange(nid=nid,
                                       fld=note_field_name,
                                       old=note_value,
                                       new=file_value))
                        if not note_value:
                            empty_note_field_count += 1
                            notes_with_empty_fields.add(nid)

            if missing_note_keys:
                self.log.appendPlainText(
                    "ERROR: {} values were not found in notes for field '{}'".
                    format(len(missing_note_keys), note_join_key_name))
                return

            if note_changes:
                self.log.appendPlainText(
                    "Need to make changes to {} notes".format(
                        len(note_changes)))
                if empty_note_field_count:
                    self.log.appendPlainText(
                        "{} fields across {} notes are empty".format(
                            empty_note_field_count,
                            len(notes_with_empty_fields)))

                if mode == "dryrun":
                    # nothing to do
                    pass
                elif mode == "diff":
                    ext = ".html"
                    default_path = QStandardPaths.writableLocation(
                        QStandardPaths.DocumentsLocation)
                    path = os.path.join(default_path, f"diff{ext}")

                    options = QFileDialog.Options()

                    # native doesn't seem to works
                    options |= QFileDialog.DontUseNativeDialog

                    # we'll confirm ourselves
                    options |= QFileDialog.DontConfirmOverwrite

                    result = QFileDialog.getSaveFileName(self,
                                                         "Save HTML diff",
                                                         path,
                                                         f"HTML (*{ext})",
                                                         options=options)

                    if not isinstance(result, tuple):
                        raise Exception("Expected a tuple from save dialog")
                    file = result[0]
                    if file:
                        do_save = True
                        if not file.lower().endswith(ext):
                            file += ext
                        if os.path.exists(file):
                            if not askUser(
                                    "{} already exists. Are you sure you want to overwrite it?"
                                    .format(file),
                                    parent=self):
                                do_save = False
                        if do_save:
                            self.log.appendPlainText(
                                "Saving to {}".format(file))
                            with open(file, "w", encoding="utf-8") as outf:
                                outf.write(DIFF_PRE)
                                for nid, changes in note_changes.items():
                                    outf.write("<p>nid {}:</p>\n".format(nid))
                                    for change in changes:
                                        outf.write("<p>{}: {}</p>\n".format(
                                            change.fld,
                                            html_diff(html.escape(change.old),
                                                      html.escape(
                                                          change.new))))
                                outf.write(DIFF_POST)
                            self.log.appendPlainText("Done")
                elif mode == "update":
                    if askUser(
                            "{} notes will be updated.  Are you sure you want to do this?"
                            .format(len(note_changes)),
                            parent=self):
                        self.log.appendPlainText("Beginning update")

                        self.browser.mw.checkpoint("{} ({} {})".format(
                            self.checkpoint_name, len(note_changes),
                            "notes" if len(note_changes) > 1 else "note"))
                        self.browser.model.beginReset()
                        updated_count = 0
                        try:
                            init_ts = int(time.time() * 1000)

                            for nid, changes in note_changes.items():
                                note = self.browser.mw.col.getNote(nid)
                                for change in changes:
                                    ts = int(time.time() * 1000)
                                    note[change.fld] = change.new
                                    self.changelog.record_change(
                                        "batch_update", init_ts,
                                        ChangeLogEntry(ts=ts,
                                                       nid=nid,
                                                       fld=change.fld,
                                                       old=change.old,
                                                       new=change.new))
                                note.flush()
                                updated_count += 1
                            self.log.appendPlainText(
                                "Updated {} notes".format(updated_count))
                        finally:
                            if updated_count:
                                self.changelog.commit_changes()
                                self.browser.mw.requireReset()
                            self.browser.model.endReset()
                else:
                    self.log.appendPlainText(
                        "ERROR: Unexpected mode: {}".format(mode))
                    return
            else:
                self.log.appendPlainText("No changes need to be made")

        except Exception:
            self.log.appendPlainText("Failed during dry run:\n{}".format(
                traceback.format_exc()))

        finally:
            # Ensure QPlainTextEdit refreshes (not clear why this is necessary)
            self.log.repaint()

    def close(self):
        self.changelog.close()
        super().close()