Add a tool to download spell check dictionaries (#1611)

This commit is contained in:
Veronica Berglyd Olsen
2023-11-20 18:22:09 +01:00
committed by GitHub
12 changed files with 501 additions and 24 deletions
+8
View File
@@ -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
# =============================================================================================== #
+2
View File
@@ -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")
+3
View File
@@ -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
+25 -14
View File
@@ -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
+1 -1
View File
@@ -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]" % (
+5
View File
@@ -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()
+16 -1
View File
@@ -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
+275
View File
@@ -0,0 +1,275 @@
"""
novelWriter GUI Dictionary Downloader
=======================================
File History:
Created: 2023-11-19 [2.2rc1]
This file is a part of novelWriter
Copyright 20182023, 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 <https://www.gnu.org/licenses/>.
"""
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, QDesktopServices, QTextCursor
from PyQt5.QtCore import QUrl, pyqtSlot
from PyQt5.QtWidgets import (
QDialog, QDialogButtonBox, QFileDialog, QFrame, QHBoxLayout, QLabel,
QLineEdit, QPlainTextEdit, QPushButton, QVBoxLayout, QWidget, qApp
)
from novelwriter import CONFIG, SHARED
from novelwriter.common import formatInt, getFileSize
from novelwriter.error import formatException
logger = logging.getLogger(__name__)
class GuiDictionaries(QDialog):
def __init__(self, parent: QWidget) -> None:
super().__init__(parent=parent)
logger.debug("Create: GuiDictionaries")
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(300))
# Hunspell Dictionaries
foUrl = "https://www.freeoffice.com/en/download/dictionaries"
loUrl = "https://extensions.libreoffice.org"
self.huInfo = QLabel("<br>".join([
self.tr("Download a dictionary from one of the links, and add it below."),
f"&nbsp;\u203a <a href='{foUrl}'>{foUrl}</a>",
f"&nbsp;\u203a <a href='{loUrl}'>{loUrl}</a>",
]))
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.addLayout(self.innerBox, 0)
self.outerBox.addStretch(1)
self.outerBox.addWidget(self.buttonBox, 0)
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
self._installPath = Path(path).resolve()
if path.is_dir():
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(
"Additional dictionaries found: {0}"
).format(len(self._currDicts)))
qApp.processEvents()
self.adjustSize()
return True
##
# Events
##
def closeEvent(self, event: QCloseEvent) -> None:
"""Capture the user closing the window."""
event.accept()
self.deleteLater()
return
##
# 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."""
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()
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 _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."""
cursor = self.infoBox.textCursor()
cursor.movePosition(QTextCursor.MoveOperation.End)
if cursor.position() > 0:
cursor.insertText("\n")
if err:
cursor.insertHtml(f"<font color='red'>{text}</font>")
else:
cursor.insertText(text)
cursor.movePosition(QTextCursor.MoveOperation.End)
cursor.deleteChar()
self.infoBox.setTextCursor(cursor)
return
# END Class GuiDictionaries
+3 -2
View File
@@ -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.
"""
+1
View File
@@ -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()
+3 -6
View File
@@ -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
+159
View File
@@ -0,0 +1,159 @@
"""
novelWriter Dictionary Downloader Tester
==========================================
This file is a part of novelWriter
Copyright 20182023, 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 <https://www.gnu.org/licenses/>.
"""
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