"""
novelWriter – GUI Dictionary Downloader
=======================================
File History:
Created: 2023-11-19 [2.2rc1] GuiDictionaries
This file is a part of novelWriter
Copyright 2018–2024, 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 zipfile import ZipFile
from PyQt5.QtCore import pyqtSlot
from PyQt5.QtGui import QCloseEvent, QTextCursor
from PyQt5.QtWidgets import (
QApplication, QDialogButtonBox, QFileDialog, QFrame, QHBoxLayout, QLabel,
QLineEdit, QPlainTextEdit, QPushButton, QVBoxLayout, QWidget
)
from novelwriter import CONFIG, SHARED
from novelwriter.common import cssCol, formatFileFilter, formatInt, getFileSize, openExternalPath
from novelwriter.error import formatException
from novelwriter.extensions.modified import NIconToolButton, NNonBlockingDialog
from novelwriter.types import QtDialogClose
logger = logging.getLogger(__name__)
class GuiDictionaries(NNonBlockingDialog):
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()
iSz = SHARED.theme.baseIconSize
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("
".join([
self.tr("Download a dictionary from one of the links, and add it below."),
f" \u203a {foUrl}",
f" \u203a {loUrl}",
]), self)
self.huInfo.setOpenExternalLinks(True)
self.huInfo.setWordWrap(True)
self.huInput = QLineEdit(self)
self.huBrowse = NIconToolButton(self, iSz, "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)
self.inPath = QLineEdit(self)
self.inPath.setReadOnly(True)
self.inBrowse = NIconToolButton(self, iSz, "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(QtDialogClose, self)
self.buttonBox.rejected.connect(self.reject)
# 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())
self._installPath = Path(path).resolve()
self._installPath.mkdir(exist_ok=True, parents=True)
except Exception:
logger.error("Could not get enchant path")
return False
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)))
QApplication.processEvents()
self.adjustSize()
return True
##
# Events
##
def closeEvent(self, event: QCloseEvent) -> None:
"""Capture the user closing the window."""
event.accept()
self.softDelete()
return
##
# Private Slots
##
@pyqtSlot()
def _doBrowseHunspell(self) -> None:
"""Browse for a Free/Libre Office dictionary."""
ffilter = formatFileFilter([
(self.tr("Free or Libre Office extension"), "*.sox *.oxt"), "*"
])
soxFile, _ = QFileDialog.getOpenFileName(
self, self.tr("Browse Files"), str(CONFIG.homePath()), filter=ffilter
)
if soxFile:
path = Path(soxFile).absolute()
self.huInput.setText(str(path))
return
@pyqtSlot()
def _doImportHunspell(self) -> None:
"""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():
try:
hunspell = self._installPath / "hunspell"
hunspell.mkdir(exist_ok=True)
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."""
if not openExternalPath(Path(self.inPath.text())):
SHARED.error("Path not found.")
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")
textCol = cssCol(SHARED.theme.errorText if err else self.palette().text().color())
cursor.insertHtml(f"{text}")
cursor.movePosition(QTextCursor.MoveOperation.End)
cursor.deleteChar()
self.infoBox.setTextCursor(cursor)
return