Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add import and export to project word list #1691

Merged
merged 4 commits into from
Feb 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions novelwriter/assets/icons/typicons_dark/icons.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions novelwriter/assets/icons/typicons_dark/mixed_import.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions novelwriter/assets/icons/typicons_light/icons.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions novelwriter/assets/icons/typicons_light/mixed_import.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
136 changes: 97 additions & 39 deletions novelwriter/dialogs/wordlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,18 @@
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,
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
Expand All @@ -57,51 +59,66 @@ def __init__(self, mainGui: GuiMain) -> None:
mS = CONFIG.pxInt(250)
wW = CONFIG.pxInt(320)
wH = CONFIG.pxInt(340)
pOptions = 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(SHARED.project.options.getInt("GuiWordList", "winWidth", wW)),
CONFIG.pxInt(SHARED.project.options.getInt("GuiWordList", "winHeight", wH))
)

# Header
self.headLabel = NColourLabel(
"Project Word List", SHARED.theme.helpText, parent=self,
scale=NColourLabel.HEADER_SCALE
)

# Main Widgets
# ============
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.headLabel = QLabel("<b>%s</b>" % self.tr("Project Word List"))
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.listBox = QListWidget()
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()
self.editBox.addWidget(self.newEntry, 1)
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)

Expand Down Expand Up @@ -134,45 +151,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
##
Expand All @@ -183,8 +227,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:
Expand All @@ -199,4 +242,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
4 changes: 2 additions & 2 deletions novelwriter/gui/theme.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions novelwriter/guimain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
51 changes: 45 additions & 6 deletions tests/test_dialogs/test_dlg_wordlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,19 @@
import pytest

from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QDialog, QAction
from PyQt5.QtWidgets import QDialog, QAction, QFileDialog

from tools import buildTestProject
from mocked import causeOSError

from novelwriter import SHARED
from novelwriter.core.spellcheck import UserDictionary
from novelwriter.dialogs.wordlist import GuiWordList


@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)
Expand Down Expand Up @@ -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()

Expand Down
Loading