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] 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