From e00e64dc460ec21bb1410f295f2cefc6966d038e Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Mon, 20 Nov 2023 17:24:49 +0100
Subject: [PATCH 1/3] Copy changes from download_dicts branch
---
novelwriter/common.py | 8 +++
novelwriter/config.py | 2 +
novelwriter/constants.py | 3 +
novelwriter/dialogs/updates.py | 39 +++++++----
novelwriter/gui/docviewer.py | 2 +-
novelwriter/gui/mainmenu.py | 5 ++
novelwriter/guimain.py | 17 ++++-
novelwriter/tools/dictionaries.py | 112 ++++++++++++++++++++++++++++++
novelwriter/tools/manusbuild.py | 5 +-
tests/conftest.py | 1 +
10 files changed, 176 insertions(+), 18 deletions(-)
create mode 100644 novelwriter/tools/dictionaries.py
diff --git a/novelwriter/common.py b/novelwriter/common.py
index f914a6d1..2627a1b0 100644
--- a/novelwriter/common.py
+++ b/novelwriter/common.py
@@ -487,6 +487,14 @@ def makeFileNameSafe(text: str) -> str:
return "".join(c for c in text if c.isalnum() or c in allowed)
+def getFileSize(path: Path) -> int:
+ """Return the size of a file."""
+ try:
+ return path.stat().st_size
+ except Exception:
+ return -1
+
+
# =============================================================================================== #
# Other Functions
# =============================================================================================== #
diff --git a/novelwriter/config.py b/novelwriter/config.py
index ba422efa..39035371 100644
--- a/novelwriter/config.py
+++ b/novelwriter/config.py
@@ -225,6 +225,7 @@ class Config:
# Other System Info
self.hostName = QSysInfo.machineHostName()
self.kernelVer = QSysInfo.kernelVersion()
+ self.isDebug = False
# Packages
self.hasEnchant = False # The pyenchant package
@@ -484,6 +485,7 @@ class Config:
self._recentObj.loadCache()
self._checkOptionalPackages()
+ self.isDebug = logger.getEffectiveLevel() == logging.DEBUG
logger.debug("Config instance initialised")
diff --git a/novelwriter/constants.py b/novelwriter/constants.py
index d8ce3562..4afa7781 100644
--- a/novelwriter/constants.py
+++ b/novelwriter/constants.py
@@ -50,6 +50,9 @@ class nwConst:
URL_HELP = "https://github.com/vkbo/novelWriter/discussions"
URL_RELEASE = "https://github.com/vkbo/novelWriter/releases/latest"
+ # Requests
+ USER_AGENT = "Mozilla/5.0 (compatible; novelWriter (Python))"
+
# Gui Settings
STATUS_MSG_TIMEOUT = 15000 # milliseconds
diff --git a/novelwriter/dialogs/updates.py b/novelwriter/dialogs/updates.py
index c39a31cc..bbdaf038 100644
--- a/novelwriter/dialogs/updates.py
+++ b/novelwriter/dialogs/updates.py
@@ -29,10 +29,10 @@ import logging
from datetime import datetime
from urllib.request import Request, urlopen
-from PyQt5.QtGui import QCursor
-from PyQt5.QtCore import Qt
+from PyQt5.QtGui import QCloseEvent, QCursor
+from PyQt5.QtCore import Qt, pyqtSlot
from PyQt5.QtWidgets import (
- qApp, QDialog, QHBoxLayout, QVBoxLayout, QDialogButtonBox, QLabel
+ QWidget, qApp, QDialog, QHBoxLayout, QVBoxLayout, QDialogButtonBox, QLabel
)
from novelwriter import CONFIG, SHARED, __version__, __date__
@@ -44,8 +44,8 @@ logger = logging.getLogger(__name__)
class GuiUpdates(QDialog):
- def __init__(self, mainGui):
- super().__init__(parent=mainGui)
+ def __init__(self, parent: QWidget) -> None:
+ super().__init__(parent=parent)
logger.debug("Create: GuiUpdates")
self.setObjectName("GuiUpdates")
@@ -94,8 +94,8 @@ class GuiUpdates(QDialog):
self.latestLabel.setFont(hFont)
# Buttons
- self.buttonBox = QDialogButtonBox(QDialogButtonBox.Ok)
- self.buttonBox.accepted.connect(self._doClose)
+ self.buttonBox = QDialogButtonBox(QDialogButtonBox.Close)
+ self.buttonBox.rejected.connect(self._doClose)
# Assemble
self.innerBox = QHBoxLayout()
@@ -114,17 +114,16 @@ class GuiUpdates(QDialog):
return
- def __del__(self): # pragma: no cover
+ def __del__(self) -> None: # pragma: no cover
logger.debug("Delete: GuiUpdates")
return
- def checkLatest(self):
- """Check for latest release.
- """
+ def checkLatest(self) -> None:
+ """Check for latest release."""
qApp.setOverrideCursor(QCursor(Qt.WaitCursor))
urlReq = Request("https://api.github.com/repos/vkbo/novelwriter/releases/latest")
- urlReq.add_header("User-Agent", "Mozilla/5.0 (compatible; novelWriter (Python))")
+ urlReq.add_header("User-Agent", nwConst.USER_AGENT)
urlReq.add_header("Accept", "application/vnd.github.v3+json")
rawData = {}
@@ -161,10 +160,22 @@ class GuiUpdates(QDialog):
return
##
- # Internal Functions
+ # Events
##
- def _doClose(self):
+ def closeEvent(self, event: QCloseEvent) -> None:
+ """Capture the user closing the window."""
+ event.accept()
+ self.deleteLater()
+ return
+
+ ##
+ # Private Slots
+ ##
+
+ @pyqtSlot()
+ def _doClose(self) -> None:
+ """Close the dialog."""
self.close()
return
diff --git a/novelwriter/gui/docviewer.py b/novelwriter/gui/docviewer.py
index 8992505d..a0e2c3b9 100644
--- a/novelwriter/gui/docviewer.py
+++ b/novelwriter/gui/docviewer.py
@@ -620,7 +620,7 @@ class GuiDocViewHistory:
"""Debug function to dump history to the logger. Since it is a
for loop, it is skipped entirely if log level isn't DEBUG.
"""
- if logger.getEffectiveLevel() == logging.DEBUG: # pragma: no cover
+ if CONFIG.isDebug: # pragma: no cover
for i, (h, p) in enumerate(zip(self._navHistory, self._posHistory)):
logger.debug(
"History %02d: %s %13s [x:%d]" % (
diff --git a/novelwriter/gui/mainmenu.py b/novelwriter/gui/mainmenu.py
index a0420144..8c74f270 100644
--- a/novelwriter/gui/mainmenu.py
+++ b/novelwriter/gui/mainmenu.py
@@ -870,6 +870,11 @@ class GuiMainMenu(QMenuBar):
self.aEditWordList = self.toolsMenu.addAction(self.tr("Project Word List"))
self.aEditWordList.triggered.connect(lambda: self.mainGui.showProjectWordListDialog())
+ # Tools > Add Dictionaries
+ if CONFIG.osWindows or CONFIG.isDebug:
+ self.aAddDicts = self.toolsMenu.addAction(self.tr("Add Dictionaries"))
+ self.aAddDicts.triggered.connect(self.mainGui.showDictionariesDialog)
+
# Tools > Separator
self.toolsMenu.addSeparator()
diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py
index 8e02e0e6..635e9b8f 100644
--- a/novelwriter/guimain.py
+++ b/novelwriter/guimain.py
@@ -59,6 +59,7 @@ from novelwriter.dialogs.projsettings import GuiProjectSettings
from novelwriter.tools.lipsum import GuiLipsum
from novelwriter.tools.manuscript import GuiManuscript
from novelwriter.tools.projwizard import GuiProjectWizard
+from novelwriter.tools.dictionaries import GuiDictionaries
from novelwriter.tools.writingstats import GuiWritingStats
from novelwriter.core.coretools import ProjectBuilder
@@ -340,7 +341,7 @@ class GuiMain(QMainWindow):
logger.debug("Ready: GUI")
- if __hexversion__[-2] == "a" and logger.getEffectiveLevel() > logging.DEBUG:
+ if __hexversion__[-2] == "a" and not CONFIG.isDebug:
SHARED.warn(self.tr(
"You are running an untested development version of novelWriter. "
"Please be careful when working on a live project "
@@ -1065,6 +1066,20 @@ class GuiMain(QMainWindow):
return
+ @pyqtSlot()
+ def showDictionariesDialog(self) -> None:
+ """Show the download dictionaries dialog."""
+ dlgDicts = GuiDictionaries(self)
+ dlgDicts.setModal(True)
+ dlgDicts.show()
+ dlgDicts.raise_()
+ qApp.processEvents()
+ if not dlgDicts.initDialog():
+ dlgDicts.close()
+ SHARED.error(self.tr("Could not initialise the dialog."))
+
+ return
+
def reportConfErr(self) -> bool:
"""Checks if the Config module has any errors to report, and let
the user know if this is the case. The Config module caches
diff --git a/novelwriter/tools/dictionaries.py b/novelwriter/tools/dictionaries.py
new file mode 100644
index 00000000..bbf3d6b0
--- /dev/null
+++ b/novelwriter/tools/dictionaries.py
@@ -0,0 +1,112 @@
+"""
+novelWriter – GUI Dictionary Downloader
+=======================================
+
+File History:
+Created: 2023-11-19 [2.2rc1]
+
+This file is a part of novelWriter
+Copyright 2018–2023, Veronica Berglyd Olsen
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful, but
+WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+"""
+from __future__ import annotations
+
+import logging
+
+from pathlib import Path
+
+from PyQt5.QtGui import QCloseEvent
+from PyQt5.QtCore import pyqtSlot
+from PyQt5.QtWidgets import (
+ QDialog, QDialogButtonBox, QVBoxLayout, QWidget
+)
+
+from novelwriter import CONFIG
+
+logger = logging.getLogger(__name__)
+
+
+class GuiDictionaries(QDialog):
+
+ C_CODE = 0
+ C_NAME = 1
+ C_STATE = 2
+
+ def __init__(self, parent: QWidget) -> None:
+ super().__init__(parent=parent)
+
+ logger.debug("Create: GuiDictionaries")
+ self.setObjectName("GuiDictionaries")
+ self.setWindowTitle(self.tr("Add Dictionaries"))
+
+ sPx = CONFIG.pxInt(16)
+
+ self.setMinimumWidth(CONFIG.pxInt(500))
+ self.setMinimumHeight(CONFIG.pxInt(200))
+
+ # Buttons
+ self.buttonBox = QDialogButtonBox(QDialogButtonBox.Close)
+ self.buttonBox.rejected.connect(self._doClose)
+
+ # Assemble
+ self.outerBox = QVBoxLayout()
+ self.outerBox.addWidget(self.buttonBox)
+ self.outerBox.setSpacing(sPx)
+
+ self.setLayout(self.outerBox)
+
+ logger.debug("Ready: GuiDictionaries")
+
+ return
+
+ def __del__(self) -> None: # pragma: no cover
+ logger.debug("Delete: GuiDictionaries")
+ return
+
+ def initDialog(self) -> bool:
+ """Prepare and check that we can proceed."""
+ try:
+ import enchant
+ path = Path(enchant.get_user_config_dir())
+ except Exception:
+ logger.error("Could not get enchant path")
+ return False
+
+ if path.is_dir():
+ pass
+
+ return True
+
+ ##
+ # Events
+ ##
+
+ def closeEvent(self, event: QCloseEvent) -> None:
+ """Capture the user closing the window."""
+ event.accept()
+ self.deleteLater()
+ return
+
+ ##
+ # Private Slots
+ ##
+
+ @pyqtSlot()
+ def _doClose(self) -> None:
+ """Close the dialog."""
+ self.close()
+ return
+
+# END Class GuiDictionaries
diff --git a/novelwriter/tools/manusbuild.py b/novelwriter/tools/manusbuild.py
index 390d626c..70235ebf 100644
--- a/novelwriter/tools/manusbuild.py
+++ b/novelwriter/tools/manusbuild.py
@@ -27,6 +27,7 @@ import logging
from pathlib import Path
+from PyQt5.QtGui import QCloseEvent
from PyQt5.QtCore import QSize, QTimer, Qt, pyqtSlot
from PyQt5.QtWidgets import (
QAbstractButton, QAbstractItemView, QDialog, QDialogButtonBox, QFileDialog,
@@ -227,7 +228,7 @@ class GuiManuscriptBuild(QDialog):
return
- def __del__(self): # pragma: no cover
+ def __del__(self) -> None: # pragma: no cover
logger.debug("Delete: GuiManuscriptBuild")
return
@@ -235,7 +236,7 @@ class GuiManuscriptBuild(QDialog):
# Events
##
- def closeEvent(self, event):
+ def closeEvent(self, event: QCloseEvent) -> None:
"""Capture the user closing the window so we can save GUI
settings.
"""
diff --git a/tests/conftest.py b/tests/conftest.py
index 1969567e..1886bfd4 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -159,6 +159,7 @@ def nwGUI(qtbot, monkeypatch, functionFixture):
nwGUI = main(["--testmode", f"--config={_TMP_CONF}", f"--data={_TMP_CONF}"])
qtbot.addWidget(nwGUI)
resetConfigVars()
+ SHARED._alert = None
nwGUI.docEditor.initEditor()
nwGUI.show()
From 71d17da48371ffd1397bfd9aeeffbf2ae626457c Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Mon, 20 Nov 2023 17:26:12 +0100
Subject: [PATCH 2/3] Complete new Add Dictionaries tool
---
novelwriter/tools/dictionaries.py | 165 ++++++++++++++++++++++++++--
tests/test_gui/test_gui_mainmenu.py | 9 +-
2 files changed, 157 insertions(+), 17 deletions(-)
diff --git a/novelwriter/tools/dictionaries.py b/novelwriter/tools/dictionaries.py
index bbf3d6b0..4edfb4aa 100644
--- a/novelwriter/tools/dictionaries.py
+++ b/novelwriter/tools/dictionaries.py
@@ -26,24 +26,25 @@ from __future__ import annotations
import logging
from pathlib import Path
+from zipfile import ZipFile
+from urllib.parse import urljoin
+from urllib.request import pathname2url
-from PyQt5.QtGui import QCloseEvent
-from PyQt5.QtCore import pyqtSlot
+from PyQt5.QtGui import QCloseEvent, QDesktopServices, QTextCursor
+from PyQt5.QtCore import QUrl, pyqtSlot
from PyQt5.QtWidgets import (
- QDialog, QDialogButtonBox, QVBoxLayout, QWidget
+ QDialog, QDialogButtonBox, QFileDialog, QFrame, QHBoxLayout, QLabel,
+ QLineEdit, QPlainTextEdit, QPushButton, QVBoxLayout, QWidget, qApp
)
-from novelwriter import CONFIG
+from novelwriter import CONFIG, SHARED
+from novelwriter.common import formatInt, getFileSize
logger = logging.getLogger(__name__)
class GuiDictionaries(QDialog):
- C_CODE = 0
- C_NAME = 1
- C_STATE = 2
-
def __init__(self, parent: QWidget) -> None:
super().__init__(parent=parent)
@@ -51,18 +52,80 @@ class GuiDictionaries(QDialog):
self.setObjectName("GuiDictionaries")
self.setWindowTitle(self.tr("Add Dictionaries"))
+ self._installPath = None
+ self._currDicts = set()
+
+ iPx = CONFIG.pxInt(4)
+ mPx = CONFIG.pxInt(8)
sPx = CONFIG.pxInt(16)
self.setMinimumWidth(CONFIG.pxInt(500))
- self.setMinimumHeight(CONFIG.pxInt(200))
+ self.setMinimumHeight(CONFIG.pxInt(300))
+
+ # Hunspell Dictionaries
+ foUrl = "https://www.freeoffice.com/en/download/dictionaries"
+ loUrl = "https://extensions.libreoffice.org"
+ self.huInfo = QLabel("
".join([
+ self.tr("Download a dictionary from one of the links, and add it below."),
+ f" \u203a {foUrl}",
+ f" \u203a {loUrl}",
+ ]))
+ self.huInfo.setOpenExternalLinks(True)
+ self.huInfo.setWordWrap(True)
+ self.huInput = QLineEdit(self)
+ self.huBrowse = QPushButton(self)
+ self.huBrowse.setIcon(SHARED.theme.getIcon("browse"))
+ self.huBrowse.clicked.connect(self._doBrowseHunspell)
+ self.huImport = QPushButton(self.tr("Add Dictionary"), self)
+ self.huImport.setIcon(SHARED.theme.getIcon("add"))
+ self.huImport.clicked.connect(self._doImportHunspell)
+
+ self.huPathBox = QHBoxLayout()
+ self.huPathBox.addWidget(self.huInput)
+ self.huPathBox.addWidget(self.huBrowse)
+ self.huPathBox.setSpacing(iPx)
+ self.huAddBox = QHBoxLayout()
+ self.huAddBox.addStretch(1)
+ self.huAddBox.addWidget(self.huImport)
+
+ # Install Path
+ self.inInfo = QLabel(self.tr("Dictionary install location"))
+ self.inPath = QLineEdit(self)
+ self.inPath.setReadOnly(True)
+ self.inBrowse = QPushButton(self)
+ self.inBrowse.setIcon(SHARED.theme.getIcon("browse"))
+ self.inBrowse.clicked.connect(self._doOpenInstallLocation)
+
+ self.inBox = QHBoxLayout()
+ self.inBox.addWidget(self.inPath)
+ self.inBox.addWidget(self.inBrowse)
+ self.inBox.setSpacing(iPx)
+
+ # Info Box
+ self.infoBox = QPlainTextEdit(self)
+ self.infoBox.setReadOnly(True)
+ self.infoBox.setFixedHeight(4*SHARED.theme.fontPixelSize)
+ self.infoBox.setFrameStyle(QFrame.Shape.NoFrame)
# Buttons
self.buttonBox = QDialogButtonBox(QDialogButtonBox.Close)
self.buttonBox.rejected.connect(self._doClose)
# Assemble
+ self.innerBox = QVBoxLayout()
+ self.innerBox.addWidget(self.huInfo)
+ self.innerBox.addLayout(self.huPathBox)
+ self.innerBox.addLayout(self.huAddBox)
+ self.innerBox.addSpacing(mPx)
+ self.innerBox.addWidget(self.inInfo)
+ self.innerBox.addLayout(self.inBox)
+ self.innerBox.addWidget(self.infoBox)
+ self.innerBox.setSpacing(iPx)
+
self.outerBox = QVBoxLayout()
- self.outerBox.addWidget(self.buttonBox)
+ self.outerBox.addLayout(self.innerBox, 0)
+ self.outerBox.addStretch(1)
+ self.outerBox.addWidget(self.buttonBox, 0)
self.outerBox.setSpacing(sPx)
self.setLayout(self.outerBox)
@@ -84,8 +147,20 @@ class GuiDictionaries(QDialog):
logger.error("Could not get enchant path")
return False
+ self._installPath = Path(path).resolve()
if path.is_dir():
- pass
+ self.inPath.setText(str(path))
+ hunspell = path / "hunspell"
+ if hunspell.is_dir():
+ self._currDicts = set(
+ i.stem for i in hunspell.iterdir() if i.is_file() and i.suffix == ".aff"
+ )
+ self._appendLog(self.tr(
+ "{0} additional dictionaries currently installed"
+ ).format(len(self._currDicts)))
+
+ qApp.processEvents()
+ self.adjustSize()
return True
@@ -103,10 +178,78 @@ class GuiDictionaries(QDialog):
# Private Slots
##
+ @pyqtSlot()
+ def _doBrowseHunspell(self):
+ """Browse for a Free/Libre Office dictionary."""
+ extFilter = [
+ self.tr("Free or Libre Office extension ({0})").format("*.sox *.oxt"),
+ self.tr("All files ({0})").format("*"),
+ ]
+ soxFile, _ = QFileDialog.getOpenFileName(
+ self, self.tr("Browse Files"), "", filter=";;".join(extFilter)
+ )
+ if soxFile:
+ path = Path(soxFile).absolute()
+ self.huInput.setText(str(path))
+ return
+
+ @pyqtSlot()
+ def _doImportHunspell(self):
+ """Import a hunspell dictionary from .sox or .oxt file."""
+ temp = self.huInput.text()
+ output = self._installPath
+ if not output:
+ return
+ if output and temp and (path := Path(temp)).is_file():
+ hunspell = output / "hunspell"
+ hunspell.mkdir(exist_ok=True)
+ try:
+ with ZipFile(path, mode="r") as zipObj:
+ for item in zipObj.namelist():
+ zPath = Path(item)
+ if zPath.suffix in (".aff", ".dic"):
+ with zipObj.open(item) as zF:
+ oPath = hunspell / zPath.name
+ oPath.write_bytes(zF.read())
+ size = getFileSize(oPath)
+ self._appendLog(self.tr(
+ "Added: {0} [{1}B]"
+ ).format(zPath.name, formatInt(size)))
+ except Exception as exc:
+ SHARED.error(self.tr("Could not process dictionary file."), exc=exc)
+ else:
+ SHARED.error(self.tr("File not found."))
+ return
+
+ @pyqtSlot()
+ def _doOpenInstallLocation(self) -> None:
+ """Open the dictionary folder."""
+ path = self.inPath.text()
+ if Path(path).is_dir():
+ QDesktopServices.openUrl(
+ QUrl(urljoin("file:", pathname2url(path)))
+ )
+ else:
+ SHARED.error("Path not found.")
+ return
+
@pyqtSlot()
def _doClose(self) -> None:
"""Close the dialog."""
self.close()
return
+ ##
+ # Internal Functions
+ ##
+
+ def _appendLog(self, text: str) -> None:
+ """Append a line to the log output."""
+ self.infoBox.moveCursor(QTextCursor.MoveOperation.End)
+ if self.infoBox.textCursor().position() > 0:
+ self.infoBox.insertPlainText("\n")
+ self.infoBox.insertPlainText(text)
+ self.infoBox.moveCursor(QTextCursor.MoveOperation.End)
+ return
+
# END Class GuiDictionaries
diff --git a/tests/test_gui/test_gui_mainmenu.py b/tests/test_gui/test_gui_mainmenu.py
index e66e5dc6..c00fe9da 100644
--- a/tests/test_gui/test_gui_mainmenu.py
+++ b/tests/test_gui/test_gui_mainmenu.py
@@ -35,8 +35,7 @@ from novelwriter.gui.doceditor import GuiDocEditor
@pytest.mark.gui
def testGuiMenu_EditFormat(qtbot, monkeypatch, nwGUI, prjLipsum):
- """Test the main menu Edit and Format entries.
- """
+ """Test the main menu Edit and Format entries."""
monkeypatch.setattr(GuiDocEditor, "hasFocus", lambda *a: True)
# Test Document Action with No Project
@@ -343,8 +342,7 @@ def testGuiMenu_EditFormat(qtbot, monkeypatch, nwGUI, prjLipsum):
@pytest.mark.gui
def testGuiMenu_ContextMenus(qtbot, nwGUI, prjLipsum):
- """Test the context menus.
- """
+ """Test the context menus."""
assert nwGUI.openProject(prjLipsum)
assert nwGUI.openDocument("4c4f28287af27")
@@ -423,8 +421,7 @@ def testGuiMenu_ContextMenus(qtbot, nwGUI, prjLipsum):
@pytest.mark.gui
def testGuiMenu_Insert(qtbot, monkeypatch, nwGUI, fncPath, projPath, mockRnd):
- """Test the Insert menu.
- """
+ """Test the Insert menu."""
buildTestProject(nwGUI, projPath)
assert nwGUI.projView.projTree._getTreeItem(C.hSceneDoc) is not None
From d741a75de8965309c3f0b364005bbbf2736f8752 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Mon, 20 Nov 2023 18:14:00 +0100
Subject: [PATCH 3/3] Clean up and add test coverage
---
novelwriter/tools/dictionaries.py | 80 ++++++----
tests/test_tools/test_tools_dictionaries.py | 159 ++++++++++++++++++++
2 files changed, 209 insertions(+), 30 deletions(-)
create mode 100644 tests/test_tools/test_tools_dictionaries.py
diff --git a/novelwriter/tools/dictionaries.py b/novelwriter/tools/dictionaries.py
index 4edfb4aa..847925a0 100644
--- a/novelwriter/tools/dictionaries.py
+++ b/novelwriter/tools/dictionaries.py
@@ -39,6 +39,7 @@ from PyQt5.QtWidgets import (
from novelwriter import CONFIG, SHARED
from novelwriter.common import formatInt, getFileSize
+from novelwriter.error import formatException
logger = logging.getLogger(__name__)
@@ -156,7 +157,7 @@ class GuiDictionaries(QDialog):
i.stem for i in hunspell.iterdir() if i.is_file() and i.suffix == ".aff"
)
self._appendLog(self.tr(
- "{0} additional dictionaries currently installed"
+ "Additional dictionaries found: {0}"
).format(len(self._currDicts)))
qApp.processEvents()
@@ -196,29 +197,21 @@ class GuiDictionaries(QDialog):
@pyqtSlot()
def _doImportHunspell(self):
"""Import a hunspell dictionary from .sox or .oxt file."""
- temp = self.huInput.text()
- output = self._installPath
- if not output:
- return
- if output and temp and (path := Path(temp)).is_file():
- hunspell = output / "hunspell"
- hunspell.mkdir(exist_ok=True)
- try:
- with ZipFile(path, mode="r") as zipObj:
- for item in zipObj.namelist():
- zPath = Path(item)
- if zPath.suffix in (".aff", ".dic"):
- with zipObj.open(item) as zF:
- oPath = hunspell / zPath.name
- oPath.write_bytes(zF.read())
- size = getFileSize(oPath)
- self._appendLog(self.tr(
- "Added: {0} [{1}B]"
- ).format(zPath.name, formatInt(size)))
- except Exception as exc:
- SHARED.error(self.tr("Could not process dictionary file."), exc=exc)
- else:
- SHARED.error(self.tr("File not found."))
+ procErr = self.tr("Could not process dictionary file")
+ if self._installPath:
+ temp = self.huInput.text()
+ if temp and (path := Path(temp)).is_file():
+ hunspell = self._installPath / "hunspell"
+ hunspell.mkdir(exist_ok=True)
+ try:
+ nAff, nDic = self._extractDicts(path, hunspell)
+ if nAff == 0 or nDic == 0:
+ self._appendLog(procErr, err=True)
+ except Exception as exc:
+ self._appendLog(procErr, err=True)
+ self._appendLog(formatException(exc), err=True)
+ else:
+ self._appendLog(procErr, err=True)
return
@pyqtSlot()
@@ -243,13 +236,40 @@ class GuiDictionaries(QDialog):
# Internal Functions
##
- def _appendLog(self, text: str) -> None:
+ def _extractDicts(self, path: Path, output: Path) -> tuple[int, int]:
+ """Extract a zip archive and return the number of .aff and .dic
+ files found in it.
+ """
+ nAff = nDic = 0
+ with ZipFile(path, mode="r") as zipObj:
+ for item in zipObj.namelist():
+ zPath = Path(item)
+ if zPath.suffix not in (".aff", ".dic"):
+ continue
+ nAff += 1 if zPath.suffix == ".aff" else 0
+ nDic += 1 if zPath.suffix == ".dic" else 0
+ with zipObj.open(item) as zF:
+ oPath = output / zPath.name
+ oPath.write_bytes(zF.read())
+ size = getFileSize(oPath)
+ self._appendLog(self.tr(
+ "Added: {0} [{1}B]"
+ ).format(zPath.name, formatInt(size)))
+ return nAff, nDic
+
+ def _appendLog(self, text: str, err: bool = False) -> None:
"""Append a line to the log output."""
- self.infoBox.moveCursor(QTextCursor.MoveOperation.End)
- if self.infoBox.textCursor().position() > 0:
- self.infoBox.insertPlainText("\n")
- self.infoBox.insertPlainText(text)
- self.infoBox.moveCursor(QTextCursor.MoveOperation.End)
+ cursor = self.infoBox.textCursor()
+ cursor.movePosition(QTextCursor.MoveOperation.End)
+ if cursor.position() > 0:
+ cursor.insertText("\n")
+ if err:
+ cursor.insertHtml(f"{text}")
+ else:
+ cursor.insertText(text)
+ cursor.movePosition(QTextCursor.MoveOperation.End)
+ cursor.deleteChar()
+ self.infoBox.setTextCursor(cursor)
return
# END Class GuiDictionaries
diff --git a/tests/test_tools/test_tools_dictionaries.py b/tests/test_tools/test_tools_dictionaries.py
new file mode 100644
index 00000000..5c3b471e
--- /dev/null
+++ b/tests/test_tools/test_tools_dictionaries.py
@@ -0,0 +1,159 @@
+"""
+novelWriter – Dictionary Downloader Tester
+==========================================
+
+This file is a part of novelWriter
+Copyright 2018–2023, Veronica Berglyd Olsen
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful, but
+WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+"""
+
+import pytest
+import enchant
+
+from zipfile import ZipFile
+
+from tools import getGuiItem
+from mocked import causeException
+
+from PyQt5.QtGui import QDesktopServices
+from PyQt5.QtWidgets import QFileDialog
+
+from novelwriter import SHARED
+from novelwriter.tools.dictionaries import GuiDictionaries
+
+
+@pytest.mark.gui
+def testToolDictionaries_Main(qtbot, monkeypatch, nwGUI, fncPath):
+ """Test the Dictionaries downloader tool."""
+ monkeypatch.setattr(enchant, "get_user_config_dir", lambda *a: str(fncPath))
+
+ # Fail to open
+ with monkeypatch.context() as mp:
+ mp.setattr(enchant, "get_user_config_dir", lambda *a: causeException)
+ nwGUI.showDictionariesDialog()
+ assert SHARED.alert is not None
+ assert SHARED.alert.logMessage == "Could not initialise the dialog."
+
+ # Open the tool
+ nwGUI.showDictionariesDialog()
+ qtbot.waitUntil(lambda: getGuiItem("GuiDictionaries") is not None, timeout=1000)
+
+ nwDicts = getGuiItem("GuiDictionaries")
+ assert isinstance(nwDicts, GuiDictionaries)
+ assert nwDicts.isVisible()
+ assert nwDicts.inPath.text() == str(fncPath)
+
+ # Allow Open Dir
+ SHARED._alert = None
+ with monkeypatch.context() as mp:
+ mp.setattr(QDesktopServices, "openUrl", lambda *a: None)
+ nwDicts._doOpenInstallLocation()
+ assert SHARED.alert is None
+
+ # Fail Open Dir
+ nwDicts.inPath.setText("/foo/bar")
+ nwDicts._doOpenInstallLocation()
+ assert SHARED.alert is not None
+ assert SHARED.alert.logMessage == "Path not found."
+ nwDicts.inPath.setText(str(fncPath))
+
+ # Create Mock Dicts
+ foDict = fncPath / "freeoffice.sox"
+ with ZipFile(foDict, mode="w") as zipObj:
+ zipObj.writestr("en_GB.aff", "foobar")
+ zipObj.writestr("en_GB.dic", "foobar")
+ zipObj.writestr("README.txt", "foobar")
+
+ loDict = fncPath / "libreoffice.oxt"
+ with ZipFile(loDict, mode="w") as zipObj:
+ zipObj.writestr("en_US/en_US.aff", "foobar")
+ zipObj.writestr("en_US/en_US.dic", "foobar")
+ zipObj.writestr("README.txt", "foobar")
+
+ emDict = fncPath / "empty.oxt"
+ with ZipFile(emDict, mode="w") as zipObj:
+ zipObj.writestr("README.txt", "foobar")
+
+ noFile = fncPath / "foobar.oxt"
+ noDict = fncPath / "foobar.sox"
+ noDict.write_bytes(b"foobar")
+
+ assert nwDicts.infoBox.toPlainText().splitlines()[-1] == (
+ "Additional dictionaries found: 0"
+ )
+
+ # Import Free Office Dictionary
+ with monkeypatch.context() as mp:
+ mp.setattr(QFileDialog, "getOpenFileName", lambda *a, **k: (str(foDict), ""))
+ nwDicts._doBrowseHunspell()
+ assert nwDicts.huInput.text() == str(foDict)
+ nwDicts._doImportHunspell()
+ assert (fncPath / "hunspell").is_dir()
+ assert (fncPath / "hunspell" / "en_GB.aff").is_file()
+ assert (fncPath / "hunspell" / "en_GB.dic").is_file()
+ assert nwDicts.infoBox.blockCount() == 3
+
+ # Import Libre Office Dictionary
+ with monkeypatch.context() as mp:
+ mp.setattr(QFileDialog, "getOpenFileName", lambda *a, **k: (str(loDict), ""))
+ nwDicts._doBrowseHunspell()
+ assert nwDicts.huInput.text() == str(loDict)
+ nwDicts._doImportHunspell()
+ assert (fncPath / "hunspell").is_dir()
+ assert (fncPath / "hunspell" / "en_US.aff").is_file()
+ assert (fncPath / "hunspell" / "en_US.dic").is_file()
+ assert nwDicts.infoBox.blockCount() == 5
+
+ # Handle Unreadable File
+ with monkeypatch.context() as mp:
+ mp.setattr(QFileDialog, "getOpenFileName", lambda *a, **k: (str(noDict), ""))
+ nwDicts._doBrowseHunspell()
+ assert nwDicts.huInput.text() == str(noDict)
+ nwDicts._doImportHunspell()
+ assert nwDicts.infoBox.blockCount() == 7
+
+ # Handle File w/No Dicts
+ with monkeypatch.context() as mp:
+ mp.setattr(QFileDialog, "getOpenFileName", lambda *a, **k: (str(emDict), ""))
+ nwDicts._doBrowseHunspell()
+ assert nwDicts.huInput.text() == str(emDict)
+ nwDicts._doImportHunspell()
+ assert nwDicts.infoBox.blockCount() == 8
+ assert nwDicts.infoBox.toPlainText().splitlines()[-1] == (
+ "Could not process dictionary file"
+ )
+
+ # Handle Non-Existing File
+ with monkeypatch.context() as mp:
+ mp.setattr(QFileDialog, "getOpenFileName", lambda *a, **k: (str(noFile), ""))
+ nwDicts._doBrowseHunspell()
+ nwDicts._doImportHunspell()
+ assert nwDicts.infoBox.blockCount() == 9
+ assert nwDicts.infoBox.toPlainText().splitlines()[-1] == (
+ "Could not process dictionary file"
+ )
+
+ # Re-init
+ nwDicts.initDialog()
+ assert nwDicts.infoBox.blockCount() == 10
+ assert nwDicts.infoBox.toPlainText().splitlines()[-1] == (
+ "Additional dictionaries found: 2"
+ )
+
+ # Close
+ nwDicts._doClose()
+ # qtbot.stop()
+
+# END Test testToolDictionaries_Main