From 23b7baf62c5bd661bfcb4a0e483ddc2b5466811d Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 11 Feb 2024 00:52:20 +0100 Subject: [PATCH 1/4] Add import and export feature to word list --- novelwriter/dialogs/wordlist.py | 133 ++++++++++++++++++++++---------- novelwriter/guimain.py | 8 +- 2 files changed, 98 insertions(+), 43 deletions(-) diff --git a/novelwriter/dialogs/wordlist.py b/novelwriter/dialogs/wordlist.py index 72e19bb04..3532fa74b 100644 --- a/novelwriter/dialogs/wordlist.py +++ b/novelwriter/dialogs/wordlist.py @@ -26,12 +26,13 @@ import logging from typing import TYPE_CHECKING +from pathlib import Path from PyQt5.QtGui import QCloseEvent from PyQt5.QtCore import Qt, pyqtSignal, pyqtSlot from PyQt5.QtWidgets import ( - QAbstractItemView, QDialog, QDialogButtonBox, QHBoxLayout, QLabel, - QLineEdit, QListWidget, QListWidgetItem, QPushButton, QVBoxLayout, qApp + QAbstractItemView, QDialog, QDialogButtonBox, QFileDialog, QHBoxLayout, + QLabel, QLineEdit, QListWidget, QPushButton, QVBoxLayout, qApp ) from novelwriter import CONFIG, SHARED @@ -57,30 +58,44 @@ def __init__(self, mainGui: GuiMain) -> None: mS = CONFIG.pxInt(250) wW = CONFIG.pxInt(320) wH = CONFIG.pxInt(340) - pOptions = SHARED.project.options + options = SHARED.project.options self.setMinimumWidth(mS) self.setMinimumHeight(mS) self.resize( - CONFIG.pxInt(pOptions.getInt("GuiWordList", "winWidth", wW)), - CONFIG.pxInt(pOptions.getInt("GuiWordList", "winHeight", wH)) + CONFIG.pxInt(options.getInt("GuiWordList", "winWidth", wW)), + CONFIG.pxInt(options.getInt("GuiWordList", "winHeight", wH)) ) - # Main Widgets - # ============ - + # Header self.headLabel = QLabel("%s" % self.tr("Project Word List")) - self.listBox = QListWidget() + self.importButton = QPushButton(SHARED.theme.getIcon("add_document"), "", self) + self.importButton.setToolTip(self.tr("Import words from text file")) + self.importButton.clicked.connect(self._importWords) + + self.exportButton = QPushButton(SHARED.theme.getIcon("export"), "", self) + self.exportButton.setToolTip(self.tr("Export words to text file")) + self.exportButton.clicked.connect(self._exportWords) + + self.headerBox = QHBoxLayout() + self.headerBox.addWidget(self.headLabel, 1) + self.headerBox.addWidget(self.importButton, 0) + self.headerBox.addWidget(self.exportButton, 0) + + # List Box + self.listBox = QListWidget(self) self.listBox.setDragDropMode(QAbstractItemView.NoDragDrop) + self.listBox.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) self.listBox.setSortingEnabled(True) + # Add/Remove Form self.newEntry = QLineEdit(self) - self.addButton = QPushButton(SHARED.theme.getIcon("add"), "") + self.addButton = QPushButton(SHARED.theme.getIcon("add"), "", self) self.addButton.clicked.connect(self._doAdd) - self.delButton = QPushButton(SHARED.theme.getIcon("remove"), "") + self.delButton = QPushButton(SHARED.theme.getIcon("remove"), "", self) self.delButton.clicked.connect(self._doDelete) self.editBox = QHBoxLayout() @@ -88,20 +103,19 @@ def __init__(self, mainGui: GuiMain) -> None: self.editBox.addWidget(self.addButton, 0) self.editBox.addWidget(self.delButton, 0) + # Buttons self.buttonBox = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Close) self.buttonBox.accepted.connect(self._doSave) self.buttonBox.rejected.connect(self.close) # Assemble - # ======== - self.outerBox = QVBoxLayout() - self.outerBox.addWidget(self.headLabel) - self.outerBox.addSpacing(CONFIG.pxInt(8)) + self.outerBox.addLayout(self.headerBox, 0) self.outerBox.addWidget(self.listBox, 1) self.outerBox.addLayout(self.editBox, 0) self.outerBox.addSpacing(CONFIG.pxInt(12)) self.outerBox.addWidget(self.buttonBox, 0) + self.outerBox.setSpacing(CONFIG.pxInt(4)) self.setLayout(self.outerBox) @@ -134,45 +148,72 @@ def closeEvent(self, event: QCloseEvent) -> None: def _doAdd(self) -> None: """Add a new word to the word list.""" word = self.newEntry.text().strip() - if word == "": - SHARED.error(self.tr("Cannot add a blank word.")) - return - - if self.listBox.findItems(word, Qt.MatchExactly): - SHARED.error(self.tr( - "The word '{0}' is already in the word list." - ).format(word)) - return - - self.listBox.addItem(word) self.newEntry.setText("") - + self.listBox.clearSelection() + self._addWord(word) + if items := self.listBox.findItems(word, Qt.MatchExactly): + self.listBox.setCurrentItem(items[0]) + self.listBox.scrollToItem(items[0], QAbstractItemView.ScrollHint.PositionAtCenter) return @pyqtSlot() def _doDelete(self) -> None: - """Delete the selected item.""" - selItem = self.listBox.selectedItems() - if selItem: - self.listBox.takeItem(self.listBox.row(selItem[0])) + """Delete the selected items.""" + for item in self.listBox.selectedItems(): + self.listBox.takeItem(self.listBox.row(item)) return @pyqtSlot() def _doSave(self) -> None: """Save the new word list and close.""" userDict = UserDictionary(SHARED.project) - for i in range(self.listBox.count()): - item = self.listBox.item(i) - if isinstance(item, QListWidgetItem): - word = item.text().strip() - if word: - userDict.add(word) + for word in self._listWords(): + userDict.add(word) userDict.save() self.newWordListReady.emit() qApp.processEvents() self.close() return + @pyqtSlot() + def _importWords(self) -> None: + """Import words from file.""" + SHARED.info(self.tr( + "Note: The import file must be a plain text file with UTF-8 or ASCII encoding." + )) + extFilter = [ + "{0} (*.txt)".format(self.tr("Text files")), + "{0} (*)".format(self.tr("All files")), + ] + path, _ = QFileDialog.getOpenFileName( + self, self.tr("Import File"), str(Path.home()), filter=";;".join(extFilter) + ) + if path: + try: + with open(path, mode="r", encoding="utf-8") as fo: + words = set(w.strip() for w in fo.read().split()) + except Exception as exc: + SHARED.error("Could not read file.", exc=exc) + return + for word in words: + self._addWord(word) + return + + @pyqtSlot() + def _exportWords(self) -> None: + """Export words to file.""" + path, _ = QFileDialog.getSaveFileName( + self, self.tr("Export File"), str(Path.home()) + ) + if path: + try: + path = Path(path).with_suffix(".txt") + with open(path, mode="w", encoding="utf-8") as fo: + fo.write("\n".join(self._listWords())) + except Exception as exc: + SHARED.error("Could not write file.", exc=exc) + return + ## # Internal Functions ## @@ -183,8 +224,7 @@ def _loadWordList(self) -> None: userDict.load() self.listBox.clear() for word in userDict: - if word: - self.listBox.addItem(word) + self.listBox.addItem(word) return def _saveGuiSettings(self) -> None: @@ -199,4 +239,19 @@ def _saveGuiSettings(self) -> None: return + def _addWord(self, word: str) -> None: + """Add a single word to the list.""" + if word and not self.listBox.findItems(word, Qt.MatchExactly): + self.listBox.addItem(word) + self._changed = True + return + + def _listWords(self) -> list[str]: + """List all words in the list box.""" + result = [] + for i in range(self.listBox.count()): + if (item := self.listBox.item(i)) and (word := item.text().strip()): + result.append(word) + return result + # END Class GuiWordList diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index 9480d5dab..0f3fa7199 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -665,10 +665,10 @@ def importDocument(self) -> bool: lastPath = CONFIG.lastPath() extFilter = [ - self.tr("Text files ({0})").format("*.txt"), - self.tr("Markdown files ({0})").format("*.md"), - self.tr("novelWriter files ({0})").format("*.nwd"), - self.tr("All files ({0})").format("*"), + "{0} (*.txt)".format(self.tr("Text files")), + "{0} (*.md)".format(self.tr("Markdown files")), + "{0} (*.nwd)".format(self.tr("novelWriter files")), + "{0} (*)".format(self.tr("All files")), ] loadFile, _ = QFileDialog.getOpenFileName( self, self.tr("Import File"), str(lastPath), filter=";;".join(extFilter) From ce3a8f56c8cc22a2f8b8b55d62a0cbc931188612 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 11 Feb 2024 18:55:27 +0100 Subject: [PATCH 2/4] Add import icon --- novelwriter/assets/icons/typicons_dark/icons.conf | 1 + novelwriter/assets/icons/typicons_dark/mixed_import.svg | 5 +++++ novelwriter/assets/icons/typicons_light/icons.conf | 1 + novelwriter/assets/icons/typicons_light/mixed_import.svg | 5 +++++ novelwriter/dialogs/wordlist.py | 2 +- novelwriter/gui/theme.py | 4 ++-- 6 files changed, 15 insertions(+), 3 deletions(-) create mode 100644 novelwriter/assets/icons/typicons_dark/mixed_import.svg create mode 100644 novelwriter/assets/icons/typicons_light/mixed_import.svg diff --git a/novelwriter/assets/icons/typicons_dark/icons.conf b/novelwriter/assets/icons/typicons_dark/icons.conf index 32261d871..44504e575 100644 --- a/novelwriter/assets/icons/typicons_dark/icons.conf +++ b/novelwriter/assets/icons/typicons_dark/icons.conf @@ -59,6 +59,7 @@ fmt_subscript = nw_tb-subscript.svg fmt_superscript = nw_tb-superscript.svg fmt_underline = nw_tb-underline.svg forward = typ_chevron-right.svg +import = mixed_import.svg maximise = typ_arrow-maximise.svg menu = typ_th-dot-menu.svg minimise = typ_arrow-minimise.svg diff --git a/novelwriter/assets/icons/typicons_dark/mixed_import.svg b/novelwriter/assets/icons/typicons_dark/mixed_import.svg new file mode 100644 index 000000000..262e2e5a4 --- /dev/null +++ b/novelwriter/assets/icons/typicons_dark/mixed_import.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/novelwriter/assets/icons/typicons_light/icons.conf b/novelwriter/assets/icons/typicons_light/icons.conf index 24474c1c6..9af4e5f2a 100644 --- a/novelwriter/assets/icons/typicons_light/icons.conf +++ b/novelwriter/assets/icons/typicons_light/icons.conf @@ -59,6 +59,7 @@ fmt_subscript = nw_tb-subscript.svg fmt_superscript = nw_tb-superscript.svg fmt_underline = nw_tb-underline.svg forward = typ_chevron-right.svg +import = mixed_import.svg maximise = typ_arrow-maximise.svg menu = typ_th-dot-menu.svg minimise = typ_arrow-minimise.svg diff --git a/novelwriter/assets/icons/typicons_light/mixed_import.svg b/novelwriter/assets/icons/typicons_light/mixed_import.svg new file mode 100644 index 000000000..c06c6cd89 --- /dev/null +++ b/novelwriter/assets/icons/typicons_light/mixed_import.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/novelwriter/dialogs/wordlist.py b/novelwriter/dialogs/wordlist.py index 3532fa74b..1454dc344 100644 --- a/novelwriter/dialogs/wordlist.py +++ b/novelwriter/dialogs/wordlist.py @@ -70,7 +70,7 @@ def __init__(self, mainGui: GuiMain) -> None: # Header self.headLabel = QLabel("%s" % self.tr("Project Word List")) - self.importButton = QPushButton(SHARED.theme.getIcon("add_document"), "", self) + self.importButton = QPushButton(SHARED.theme.getIcon("import"), "", self) self.importButton.setToolTip(self.tr("Import words from text file")) self.importButton.clicked.connect(self._importWords) diff --git a/novelwriter/gui/theme.py b/novelwriter/gui/theme.py index 69f51292b..5c38ca333 100644 --- a/novelwriter/gui/theme.py +++ b/novelwriter/gui/theme.py @@ -453,8 +453,8 @@ class GuiIcons: # General Button Icons "add", "add_document", "backward", "bookmark", "browse", "checked", "close", "cross", - "document", "down", "edit", "export", "forward", "maximise", "menu", "minimise", "more", - "noncheckable", "panel", "refresh", "remove", "revert", "search_replace", "search", + "document", "down", "edit", "export", "forward", "import", "maximise", "menu", "minimise", + "more", "noncheckable", "panel", "refresh", "remove", "revert", "search_replace", "search", "settings", "star", "unchecked", "up", "view", # Switches From e185f8787540b1bf851ed1176a1baaa0957b4a85 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 11 Feb 2024 19:09:40 +0100 Subject: [PATCH 3/4] Add test coverage --- novelwriter/dialogs/wordlist.py | 5 +-- tests/test_dialogs/test_dlg_wordlist.py | 51 ++++++++++++++++++++++--- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/novelwriter/dialogs/wordlist.py b/novelwriter/dialogs/wordlist.py index 1454dc344..71aaa2753 100644 --- a/novelwriter/dialogs/wordlist.py +++ b/novelwriter/dialogs/wordlist.py @@ -58,13 +58,12 @@ def __init__(self, mainGui: GuiMain) -> None: mS = CONFIG.pxInt(250) wW = CONFIG.pxInt(320) wH = CONFIG.pxInt(340) - options = SHARED.project.options self.setMinimumWidth(mS) self.setMinimumHeight(mS) self.resize( - CONFIG.pxInt(options.getInt("GuiWordList", "winWidth", wW)), - CONFIG.pxInt(options.getInt("GuiWordList", "winHeight", wH)) + CONFIG.pxInt(SHARED.project.options.getInt("GuiWordList", "winWidth", wW)), + CONFIG.pxInt(SHARED.project.options.getInt("GuiWordList", "winHeight", wH)) ) # Header diff --git a/tests/test_dialogs/test_dlg_wordlist.py b/tests/test_dialogs/test_dlg_wordlist.py index e1b7d0ec6..857230541 100644 --- a/tests/test_dialogs/test_dlg_wordlist.py +++ b/tests/test_dialogs/test_dlg_wordlist.py @@ -23,7 +23,8 @@ import pytest from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QDialog, QAction +from PyQt5.QtWidgets import QDialog, QAction, QFileDialog +from tests.mocked import causeOSError from tools import buildTestProject @@ -33,9 +34,8 @@ @pytest.mark.gui -def testDlgWordList_Dialog(qtbot, monkeypatch, nwGUI, projPath): - """test the word list editor. - """ +def testDlgWordList_Dialog(qtbot, monkeypatch, nwGUI, fncPath, projPath): + """test the word list editor.""" buildTestProject(nwGUI, projPath) monkeypatch.setattr(GuiWordList, "exec_", lambda *a: None) @@ -110,16 +110,55 @@ def testDlgWordList_Dialog(qtbot, monkeypatch, nwGUI, projPath): assert wList.listBox.findItems("delete_me", Qt.MatchExactly) == [] assert wList.listBox.item(0).text() == "word_a" # type: ignore - # Save files + # Import/Export + # ============= + expFile = fncPath / "wordlist_export.txt" + impFile = fncPath / "wordlist_import.txt" + wList.show() + + # Export File, OS Error + with monkeypatch.context() as mp: + mp.setattr(QFileDialog, "getSaveFileName", lambda *a, **k: (str(expFile), "")) + mp.setattr("builtins.open", causeOSError) + wList.exportButton.click() + assert not expFile.exists() + + # Export File, OK + with monkeypatch.context() as mp: + mp.setattr(QFileDialog, "getSaveFileName", lambda *a, **k: (str(expFile), "")) + wList.exportButton.click() + assert expFile.exists() + assert expFile.read_text() == "word_a\nword_b\nword_c\nword_d\nword_f\nword_g" + + # Write File + impFile.write_text("word_d\nword_e\nword_f\tword_g word_h word_i\n\n\n") + + # Import File, OS Error + with monkeypatch.context() as mp: + mp.setattr(QFileDialog, "getOpenFileName", lambda *a, **k: (str(impFile), "")) + mp.setattr("builtins.open", causeOSError) + wList.importButton.click() + assert wList.listBox.count() == 6 + + # Import File, OK + with monkeypatch.context() as mp: + mp.setattr(QFileDialog, "getOpenFileName", lambda *a, **k: (str(impFile), "")) + wList.importButton.click() + assert wList.listBox.count() == 9 + + # Save and Check List wList._doSave() userDict.load() - assert len(list(userDict)) == 6 + assert len(list(userDict)) == 9 assert "word_a" in userDict assert "word_b" in userDict assert "word_c" in userDict assert "word_d" in userDict + assert "word_e" in userDict assert "word_f" in userDict assert "word_g" in userDict + assert "word_h" in userDict + assert "word_i" in userDict # qtbot.stop() From edfb4e60c613d922b2994b5143fb9e36aadccf93 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 11 Feb 2024 19:15:18 +0100 Subject: [PATCH 4/4] Use the same header label style as on other dialogs --- novelwriter/dialogs/wordlist.py | 8 ++++++-- tests/test_dialogs/test_dlg_wordlist.py | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/novelwriter/dialogs/wordlist.py b/novelwriter/dialogs/wordlist.py index 71aaa2753..536418303 100644 --- a/novelwriter/dialogs/wordlist.py +++ b/novelwriter/dialogs/wordlist.py @@ -32,11 +32,12 @@ from PyQt5.QtCore import Qt, pyqtSignal, pyqtSlot from PyQt5.QtWidgets import ( QAbstractItemView, QDialog, QDialogButtonBox, QFileDialog, QHBoxLayout, - QLabel, QLineEdit, QListWidget, QPushButton, QVBoxLayout, qApp + QLineEdit, QListWidget, QPushButton, QVBoxLayout, qApp ) from novelwriter import CONFIG, SHARED from novelwriter.core.spellcheck import UserDictionary +from novelwriter.extensions.configlayout import NColourLabel if TYPE_CHECKING: # pragma: no cover from novelwriter.guimain import GuiMain @@ -67,7 +68,10 @@ def __init__(self, mainGui: GuiMain) -> None: ) # Header - self.headLabel = QLabel("%s" % self.tr("Project Word List")) + self.headLabel = NColourLabel( + "Project Word List", SHARED.theme.helpText, parent=self, + scale=NColourLabel.HEADER_SCALE + ) self.importButton = QPushButton(SHARED.theme.getIcon("import"), "", self) self.importButton.setToolTip(self.tr("Import words from text file")) diff --git a/tests/test_dialogs/test_dlg_wordlist.py b/tests/test_dialogs/test_dlg_wordlist.py index 857230541..8f7483a2c 100644 --- a/tests/test_dialogs/test_dlg_wordlist.py +++ b/tests/test_dialogs/test_dlg_wordlist.py @@ -24,9 +24,9 @@ from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QDialog, QAction, QFileDialog -from tests.mocked import causeOSError from tools import buildTestProject +from mocked import causeOSError from novelwriter import SHARED from novelwriter.core.spellcheck import UserDictionary