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