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