Add import and export to project word list (#1691)

This commit is contained in:
Veronica Berglyd Olsen
2024-02-11 19:37:35 +01:00
committed by GitHub
8 changed files with 160 additions and 51 deletions
@@ -59,6 +59,7 @@ fmt_subscript = nw_tb-subscript.svg
fmt_superscript = nw_tb-superscript.svg fmt_superscript = nw_tb-superscript.svg
fmt_underline = nw_tb-underline.svg fmt_underline = nw_tb-underline.svg
forward = typ_chevron-right.svg forward = typ_chevron-right.svg
import = mixed_import.svg
maximise = typ_arrow-maximise.svg maximise = typ_arrow-maximise.svg
menu = typ_th-dot-menu.svg menu = typ_th-dot-menu.svg
minimise = typ_arrow-minimise.svg minimise = typ_arrow-minimise.svg
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="24" height="24" version="1.2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="m3.1113 3.666c-0.61332 0-1.1094 0.49689-1.1094 1.1113v15.557h-0.0019531c0 0.61443 0.498 1.1094 1.1113 1.1094h15.557c0.61332 0 1.1113-0.49494 1.1113-1.1094v-8.2578c-0.61899 0.57364-1.3634 1.0131-2.252 1.2578-0.004058 2.0524-0.007162 4.0735 0.005859 5.8887h-13.311v-13.318c1.6116-0.00848 2.8992 0 4.4648 0 0.75712-0.75269 1.5907-1.5005 2.3418-2.2383h-7.918z" fill="#9c9"/>
<path d="m21.996 3.1106v-0.55556c-1.8789 2.8644-4 4.3922-6.6667 4.4444v-3.3333c0-0.61222-0.56778-1.1111-1.27-1.1111-0.40444 0-0.75 0.17556-0.98111 0.43444-2.1478 2.2544-6.6378 6.7878-6.6378 6.7878s4.49 4.5356 6.6389 6.8189c0.23111 0.22778 0.57556 0.40333 0.98 0.40333 0.70222 0 1.27-0.49667 1.27-1.1111v-3.3333c5.1778 0 6.6667-5.4122 6.6667-9.4444z" fill="#9c9" stroke-width="1.1111"/>
</svg>

After

Width:  |  Height:  |  Size: 912 B

@@ -59,6 +59,7 @@ fmt_subscript = nw_tb-subscript.svg
fmt_superscript = nw_tb-superscript.svg fmt_superscript = nw_tb-superscript.svg
fmt_underline = nw_tb-underline.svg fmt_underline = nw_tb-underline.svg
forward = typ_chevron-right.svg forward = typ_chevron-right.svg
import = mixed_import.svg
maximise = typ_arrow-maximise.svg maximise = typ_arrow-maximise.svg
menu = typ_th-dot-menu.svg menu = typ_th-dot-menu.svg
minimise = typ_arrow-minimise.svg minimise = typ_arrow-minimise.svg
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="24" height="24" version="1.2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="m3.1113 3.666c-0.61332 0-1.1094 0.49689-1.1094 1.1113v15.557h-0.0019531c0 0.61443 0.498 1.1094 1.1113 1.1094h15.557c0.61332 0 1.1113-0.49494 1.1113-1.1094v-8.2578c-0.61899 0.57364-1.3634 1.0131-2.252 1.2578-0.004058 2.0524-0.007162 4.0735 0.005859 5.8887h-13.311v-13.318c1.6116-0.00848 2.8992 0 4.4648 0 0.75712-0.75269 1.5907-1.5005 2.3418-2.2383h-7.918z" fill="#718c00"/>
<path d="m21.996 3.1106v-0.55556c-1.8789 2.8644-4 4.3922-6.6667 4.4444v-3.3333c0-0.61222-0.56778-1.1111-1.27-1.1111-0.40444 0-0.75 0.17556-0.98111 0.43444-2.1478 2.2544-6.6378 6.7878-6.6378 6.7878s4.49 4.5356 6.6389 6.8189c0.23111 0.22778 0.57556 0.40333 0.98 0.40333 0.70222 0 1.27-0.49667 1.27-1.1111v-3.3333c5.1778 0 6.6667-5.4122 6.6667-9.4444z" fill="#718c00" stroke-width="1.1111"/>
</svg>

After

Width:  |  Height:  |  Size: 918 B

+97 -39
View File
@@ -26,16 +26,18 @@ from __future__ import annotations
import logging import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from pathlib import Path
from PyQt5.QtGui import QCloseEvent from PyQt5.QtGui import QCloseEvent
from PyQt5.QtCore import Qt, pyqtSignal, pyqtSlot from PyQt5.QtCore import Qt, pyqtSignal, pyqtSlot
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QAbstractItemView, QDialog, QDialogButtonBox, QHBoxLayout, QLabel, QAbstractItemView, QDialog, QDialogButtonBox, QFileDialog, QHBoxLayout,
QLineEdit, QListWidget, QListWidgetItem, QPushButton, QVBoxLayout, qApp QLineEdit, QListWidget, QPushButton, QVBoxLayout, qApp
) )
from novelwriter import CONFIG, SHARED from novelwriter import CONFIG, SHARED
from novelwriter.core.spellcheck import UserDictionary from novelwriter.core.spellcheck import UserDictionary
from novelwriter.extensions.configlayout import NColourLabel
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
from novelwriter.guimain import GuiMain from novelwriter.guimain import GuiMain
@@ -57,30 +59,46 @@ class GuiWordList(QDialog):
mS = CONFIG.pxInt(250) mS = CONFIG.pxInt(250)
wW = CONFIG.pxInt(320) wW = CONFIG.pxInt(320)
wH = CONFIG.pxInt(340) wH = CONFIG.pxInt(340)
pOptions = SHARED.project.options
self.setMinimumWidth(mS) self.setMinimumWidth(mS)
self.setMinimumHeight(mS) self.setMinimumHeight(mS)
self.resize( self.resize(
CONFIG.pxInt(pOptions.getInt("GuiWordList", "winWidth", wW)), CONFIG.pxInt(SHARED.project.options.getInt("GuiWordList", "winWidth", wW)),
CONFIG.pxInt(pOptions.getInt("GuiWordList", "winHeight", wH)) CONFIG.pxInt(SHARED.project.options.getInt("GuiWordList", "winHeight", wH))
) )
# Main Widgets # Header
# ============ self.headLabel = NColourLabel(
"Project Word List", SHARED.theme.helpText, parent=self,
scale=NColourLabel.HEADER_SCALE
)
self.headLabel = QLabel("<b>%s</b>" % self.tr("Project Word List")) self.importButton = QPushButton(SHARED.theme.getIcon("import"), "", self)
self.importButton.setToolTip(self.tr("Import words from text file"))
self.importButton.clicked.connect(self._importWords)
self.listBox = QListWidget() 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.setDragDropMode(QAbstractItemView.NoDragDrop)
self.listBox.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.listBox.setSortingEnabled(True) self.listBox.setSortingEnabled(True)
# Add/Remove Form
self.newEntry = QLineEdit(self) 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.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.delButton.clicked.connect(self._doDelete)
self.editBox = QHBoxLayout() self.editBox = QHBoxLayout()
@@ -88,20 +106,19 @@ class GuiWordList(QDialog):
self.editBox.addWidget(self.addButton, 0) self.editBox.addWidget(self.addButton, 0)
self.editBox.addWidget(self.delButton, 0) self.editBox.addWidget(self.delButton, 0)
# Buttons
self.buttonBox = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Close) self.buttonBox = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Close)
self.buttonBox.accepted.connect(self._doSave) self.buttonBox.accepted.connect(self._doSave)
self.buttonBox.rejected.connect(self.close) self.buttonBox.rejected.connect(self.close)
# Assemble # Assemble
# ========
self.outerBox = QVBoxLayout() self.outerBox = QVBoxLayout()
self.outerBox.addWidget(self.headLabel) self.outerBox.addLayout(self.headerBox, 0)
self.outerBox.addSpacing(CONFIG.pxInt(8))
self.outerBox.addWidget(self.listBox, 1) self.outerBox.addWidget(self.listBox, 1)
self.outerBox.addLayout(self.editBox, 0) self.outerBox.addLayout(self.editBox, 0)
self.outerBox.addSpacing(CONFIG.pxInt(12)) self.outerBox.addSpacing(CONFIG.pxInt(12))
self.outerBox.addWidget(self.buttonBox, 0) self.outerBox.addWidget(self.buttonBox, 0)
self.outerBox.setSpacing(CONFIG.pxInt(4))
self.setLayout(self.outerBox) self.setLayout(self.outerBox)
@@ -134,45 +151,72 @@ class GuiWordList(QDialog):
def _doAdd(self) -> None: def _doAdd(self) -> None:
"""Add a new word to the word list.""" """Add a new word to the word list."""
word = self.newEntry.text().strip() 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.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 return
@pyqtSlot() @pyqtSlot()
def _doDelete(self) -> None: def _doDelete(self) -> None:
"""Delete the selected item.""" """Delete the selected items."""
selItem = self.listBox.selectedItems() for item in self.listBox.selectedItems():
if selItem: self.listBox.takeItem(self.listBox.row(item))
self.listBox.takeItem(self.listBox.row(selItem[0]))
return return
@pyqtSlot() @pyqtSlot()
def _doSave(self) -> None: def _doSave(self) -> None:
"""Save the new word list and close.""" """Save the new word list and close."""
userDict = UserDictionary(SHARED.project) userDict = UserDictionary(SHARED.project)
for i in range(self.listBox.count()): for word in self._listWords():
item = self.listBox.item(i) userDict.add(word)
if isinstance(item, QListWidgetItem):
word = item.text().strip()
if word:
userDict.add(word)
userDict.save() userDict.save()
self.newWordListReady.emit() self.newWordListReady.emit()
qApp.processEvents() qApp.processEvents()
self.close() self.close()
return 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 # Internal Functions
## ##
@@ -183,8 +227,7 @@ class GuiWordList(QDialog):
userDict.load() userDict.load()
self.listBox.clear() self.listBox.clear()
for word in userDict: for word in userDict:
if word: self.listBox.addItem(word)
self.listBox.addItem(word)
return return
def _saveGuiSettings(self) -> None: def _saveGuiSettings(self) -> None:
@@ -199,4 +242,19 @@ class GuiWordList(QDialog):
return 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 # END Class GuiWordList
+2 -2
View File
@@ -453,8 +453,8 @@ class GuiIcons:
# General Button Icons # General Button Icons
"add", "add_document", "backward", "bookmark", "browse", "checked", "close", "cross", "add", "add_document", "backward", "bookmark", "browse", "checked", "close", "cross",
"document", "down", "edit", "export", "forward", "maximise", "menu", "minimise", "more", "document", "down", "edit", "export", "forward", "import", "maximise", "menu", "minimise",
"noncheckable", "panel", "refresh", "remove", "revert", "search_replace", "search", "more", "noncheckable", "panel", "refresh", "remove", "revert", "search_replace", "search",
"settings", "star", "unchecked", "up", "view", "settings", "star", "unchecked", "up", "view",
# Switches # Switches
+4 -4
View File
@@ -665,10 +665,10 @@ class GuiMain(QMainWindow):
lastPath = CONFIG.lastPath() lastPath = CONFIG.lastPath()
extFilter = [ extFilter = [
self.tr("Text files ({0})").format("*.txt"), "{0} (*.txt)".format(self.tr("Text files")),
self.tr("Markdown files ({0})").format("*.md"), "{0} (*.md)".format(self.tr("Markdown files")),
self.tr("novelWriter files ({0})").format("*.nwd"), "{0} (*.nwd)".format(self.tr("novelWriter files")),
self.tr("All files ({0})").format("*"), "{0} (*)".format(self.tr("All files")),
] ]
loadFile, _ = QFileDialog.getOpenFileName( loadFile, _ = QFileDialog.getOpenFileName(
self, self.tr("Import File"), str(lastPath), filter=";;".join(extFilter) self, self.tr("Import File"), str(lastPath), filter=";;".join(extFilter)
+45 -6
View File
@@ -23,9 +23,10 @@ from __future__ import annotations
import pytest import pytest
from PyQt5.QtCore import Qt from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QDialog, QAction from PyQt5.QtWidgets import QDialog, QAction, QFileDialog
from tools import buildTestProject from tools import buildTestProject
from mocked import causeOSError
from novelwriter import SHARED from novelwriter import SHARED
from novelwriter.core.spellcheck import UserDictionary from novelwriter.core.spellcheck import UserDictionary
@@ -33,9 +34,8 @@ from novelwriter.dialogs.wordlist import GuiWordList
@pytest.mark.gui @pytest.mark.gui
def testDlgWordList_Dialog(qtbot, monkeypatch, nwGUI, projPath): def testDlgWordList_Dialog(qtbot, monkeypatch, nwGUI, fncPath, projPath):
"""test the word list editor. """test the word list editor."""
"""
buildTestProject(nwGUI, projPath) buildTestProject(nwGUI, projPath)
monkeypatch.setattr(GuiWordList, "exec_", lambda *a: None) 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.findItems("delete_me", Qt.MatchExactly) == []
assert wList.listBox.item(0).text() == "word_a" # type: ignore 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() wList._doSave()
userDict.load() userDict.load()
assert len(list(userDict)) == 6 assert len(list(userDict)) == 9
assert "word_a" in userDict assert "word_a" in userDict
assert "word_b" in userDict assert "word_b" in userDict
assert "word_c" in userDict assert "word_c" in userDict
assert "word_d" in userDict assert "word_d" in userDict
assert "word_e" in userDict
assert "word_f" in userDict assert "word_f" in userDict
assert "word_g" in userDict assert "word_g" in userDict
assert "word_h" in userDict
assert "word_i" in userDict
# qtbot.stop() # qtbot.stop()