From 30b4e5511a843360499472f31b5b31ba494d181b Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 23 Nov 2023 16:43:56 +0100 Subject: [PATCH 1/3] Add a common function to open file paths externally --- novelwriter/common.py | 15 ++++++++++++++- novelwriter/core/project.py | 4 ++-- novelwriter/gui/mainmenu.py | 9 +++------ novelwriter/tools/dictionaries.py | 15 ++++----------- 4 files changed, 23 insertions(+), 20 deletions(-) diff --git a/novelwriter/common.py b/novelwriter/common.py index 2627a1b0..0f61bd89 100644 --- a/novelwriter/common.py +++ b/novelwriter/common.py @@ -33,8 +33,11 @@ from typing import Any, Literal from pathlib import Path from datetime import datetime from configparser import ConfigParser +from urllib.parse import urljoin +from urllib.request import pathname2url -from PyQt5.QtCore import QCoreApplication +from PyQt5.QtGui import QDesktopServices +from PyQt5.QtCore import QCoreApplication, QUrl from PyQt5.QtWidgets import QWidget, qApp from novelwriter.enum import nwItemClass, nwItemType, nwItemLayout @@ -495,6 +498,16 @@ def getFileSize(path: Path) -> int: return -1 +def openExternalPath(path: Path) -> bool: + """Open a path by passing it to the desktop environment.""" + if Path(path).exists(): + QDesktopServices.openUrl( + QUrl(urljoin("file:", pathname2url(str(path)))) + ) + return True + return False + + # =============================================================================================== # # Other Functions # =============================================================================================== # diff --git a/novelwriter/core/project.py b/novelwriter/core/project.py index 4cba0c82..f66f4549 100644 --- a/novelwriter/core/project.py +++ b/novelwriter/core/project.py @@ -45,7 +45,7 @@ from novelwriter.core.sessions import NWSessionLog from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter, XMLReadState from novelwriter.core.projectdata import NWProjectData from novelwriter.common import ( - checkStringNone, formatInt, formatTimeStamp, hexToInt, makeFileNameSafe, minmax + checkStringNone, formatInt, formatTimeStamp, getFileSize, hexToInt, makeFileNameSafe, minmax ) if TYPE_CHECKING: # pragma: no cover @@ -420,7 +420,7 @@ class NWProject: timeStamp = formatTimeStamp(time(), fileSafe=True) archName = baseDir / f"{cleanName} {timeStamp}.zip" if self._storage.zipIt(archName, compression=2): - size = formatInt(archName.stat().st_size) + size = formatInt(getFileSize(archName)) if doNotify: SHARED.info( self.tr("Created a backup of your project of size {0}B.").format(size), diff --git a/novelwriter/gui/mainmenu.py b/novelwriter/gui/mainmenu.py index 8c74f270..9238d3f1 100644 --- a/novelwriter/gui/mainmenu.py +++ b/novelwriter/gui/mainmenu.py @@ -27,15 +27,14 @@ import logging from typing import TYPE_CHECKING from pathlib import Path -from urllib.parse import urljoin -from urllib.request import pathname2url -from PyQt5.QtCore import QUrl, pyqtSignal, pyqtSlot from PyQt5.QtGui import QDesktopServices +from PyQt5.QtCore import QUrl, pyqtSignal, pyqtSlot from PyQt5.QtWidgets import QMenuBar, QAction from novelwriter import CONFIG, SHARED from novelwriter.enum import nwDocAction, nwDocInsert, nwWidget +from novelwriter.common import openExternalPath from novelwriter.constants import nwConst, trConst, nwKeyWords, nwLabels, nwUnicode if TYPE_CHECKING: # pragma: no cover @@ -111,9 +110,7 @@ class GuiMainMenu(QMenuBar): def _openUserManualFile(self) -> None: """Open the documentation in PDF format.""" if isinstance(CONFIG.pdfDocs, Path): - QDesktopServices.openUrl( - QUrl(urljoin("file:", pathname2url(str(CONFIG.pdfDocs)))) - ) + openExternalPath(CONFIG.pdfDocs) return @pyqtSlot(str) diff --git a/novelwriter/tools/dictionaries.py b/novelwriter/tools/dictionaries.py index 847925a0..c14bd163 100644 --- a/novelwriter/tools/dictionaries.py +++ b/novelwriter/tools/dictionaries.py @@ -27,19 +27,17 @@ 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.QtGui import QCloseEvent, QTextCursor +from PyQt5.QtCore import 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 +from novelwriter.common import openExternalPath, formatInt, getFileSize logger = logging.getLogger(__name__) @@ -217,12 +215,7 @@ class GuiDictionaries(QDialog): @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: + if not openExternalPath(Path(self.inPath.text())): SHARED.error("Path not found.") return From 4bd830f0d62ac9c3692a8ed560ad23f3b7950fbd Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 23 Nov 2023 16:45:08 +0100 Subject: [PATCH 2/3] Add an "Open Folder" button to build dialog (#1554) --- novelwriter/tools/manusbuild.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/novelwriter/tools/manusbuild.py b/novelwriter/tools/manusbuild.py index 70235ebf..3decd730 100644 --- a/novelwriter/tools/manusbuild.py +++ b/novelwriter/tools/manusbuild.py @@ -37,7 +37,7 @@ from PyQt5.QtWidgets import ( from novelwriter import CONFIG, SHARED from novelwriter.enum import nwBuildFmt -from novelwriter.common import makeFileNameSafe +from novelwriter.common import makeFileNameSafe, openExternalPath from novelwriter.constants import nwLabels from novelwriter.core.item import NWItem from novelwriter.core.docbuild import NWBuildDocument @@ -176,9 +176,11 @@ class GuiManuscriptBuild(QDialog): self.buildBox.setVerticalSpacing(sp4) # Dialog Buttons + self.btnOpen = QPushButton(SHARED.theme.getIcon("browse"), self.tr("Open Folder")) self.btnBuild = QPushButton(SHARED.theme.getIcon("export"), self.tr("&Build")) - self.dlgButtons = QDialogButtonBox(QDialogButtonBox.Close) - self.dlgButtons.addButton(self.btnBuild, QDialogButtonBox.ActionRole) + self.dlgButtons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) + self.dlgButtons.addButton(self.btnOpen, QDialogButtonBox.ButtonRole.ActionRole) + self.dlgButtons.addButton(self.btnBuild, QDialogButtonBox.ButtonRole.ActionRole) # Assemble GUI # ============ @@ -254,7 +256,10 @@ class GuiManuscriptBuild(QDialog): """Handle button clicks from the dialog button box.""" role = self.dlgButtons.buttonRole(button) if role == QDialogButtonBox.ActionRole: - self._runBuild() + if button == self.btnBuild: + self._runBuild() + elif button == self.btnOpen: + self._openOutputFolder() elif role == QDialogButtonBox.RejectRole: self.close() return @@ -386,4 +391,9 @@ class GuiManuscriptBuild(QDialog): return + def _openOutputFolder(self): + """Open the build folder in the system's file explorer.""" + openExternalPath(Path(self.buildPath.text())) + return + # END Class GuiManuscriptBuild From 2998f2c3631fc7138a7297ae5302224e762674a3 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 23 Nov 2023 16:45:14 +0100 Subject: [PATCH 3/3] Update tests --- tests/test_base/test_base_common.py | 41 ++++++++++++++++++++--- tests/test_tools/test_tools_manusbuild.py | 15 +++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/tests/test_base/test_base_common.py b/tests/test_base/test_base_common.py index 8ac8f357..08e8b3e9 100644 --- a/tests/test_base/test_base_common.py +++ b/tests/test_base/test_base_common.py @@ -20,6 +20,8 @@ along with this program. If not, see . """ import time +from PyQt5.QtCore import QUrl +from PyQt5.QtGui import QDesktopServices import pytest from pathlib import Path @@ -32,10 +34,10 @@ from novelwriter.guimain import GuiMain from novelwriter.common import ( checkBool, checkFloat, checkHandle, checkInt, checkIntTuple, checkPath, checkString, checkStringNone, checkUuid, formatInt, formatTime, - formatTimeStamp, fuzzyTime, getGuiItem, hexToInt, isHandle, isItemClass, - isItemLayout, isItemType, isTitleTag, jsonEncode, makeFileNameSafe, minmax, - numberToRoman, NWConfigParser, readTextFile, simplified, transferCase, - xmlIndent, yesNo + formatTimeStamp, fuzzyTime, getFileSize, getGuiItem, hexToInt, isHandle, + isItemClass, isItemLayout, isItemType, isTitleTag, jsonEncode, + makeFileNameSafe, minmax, numberToRoman, NWConfigParser, openExternalPath, + readTextFile, simplified, transferCase, xmlIndent, yesNo ) @@ -621,6 +623,37 @@ def testBaseCommon_makeFileNameSafe(): # END Test testBaseCommon_makeFileNameSafe +@pytest.mark.base +def testBaseCommon_getFileSize(fncPath): + """Test the getFileSize function.""" + (fncPath / "one.txt").write_bytes(b"foobar") + (fncPath / "two.txt").touch() + + assert getFileSize(fncPath / "nope.txt") == -1 + assert getFileSize(fncPath / "one.txt") == 6 + assert getFileSize(fncPath / "two.txt") == 0 + +# END Test testBaseCommon_getFileSize + + +@pytest.mark.base +def testBaseCommon_openExternalPath(monkeypatch, tstPaths): + """Test the openExternalPath function.""" + lastUrl = "" + + def mockOpenUrl(url: QUrl) -> None: + nonlocal lastUrl + lastUrl = url.toString() + return + + monkeypatch.setattr(QDesktopServices, "openUrl", mockOpenUrl) + assert openExternalPath(Path("/foo/bar")) is False + assert openExternalPath(tstPaths.tmpDir) is True + assert lastUrl.startswith("file://") + +# END Test testBaseCommon_openExternalPath + + @pytest.mark.base def testBaseCommon_getGuiItem(nwGUI): """Check the GUI item function.""" diff --git a/tests/test_tools/test_tools_manusbuild.py b/tests/test_tools/test_tools_manusbuild.py index 271f9b0f..a23f383e 100644 --- a/tests/test_tools/test_tools_manusbuild.py +++ b/tests/test_tools/test_tools_manusbuild.py @@ -19,6 +19,8 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ +from PyQt5.QtCore import QUrl +from PyQt5.QtGui import QDesktopServices import pytest from pathlib import Path @@ -132,6 +134,19 @@ def testManuscriptBuild_Main( mp.setattr(QMessageBox, "result", lambda *a: QMessageBox.No) assert manus._runBuild() is False + # Test that the open button works + lastUrl = "" + + def mockOpenUrl(url: QUrl) -> None: + nonlocal lastUrl + lastUrl = url.toString() + return + + with monkeypatch.context() as mp: + mp.setattr(QDesktopServices, "openUrl", mockOpenUrl) + manus.btnOpen.click() + assert lastUrl.startswith("file://") + # Finish manus._dialogButtonClicked(manus.dlgButtons.button(QDialogButtonBox.Close)) # qtbot.stop()