From f8d57539d4ab15f1aa7c7e3e96d560ef3993ffce Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Wed, 14 Feb 2024 18:32:09 +0100
Subject: [PATCH 1/6] Add a version info widget
---
novelwriter/dialogs/about.py | 42 ++------
novelwriter/extensions/configlayout.py | 17 ++--
novelwriter/extensions/versioninfo.py | 131 +++++++++++++++++++++++++
novelwriter/tools/welcome.py | 12 +--
4 files changed, 152 insertions(+), 50 deletions(-)
create mode 100644 novelwriter/extensions/versioninfo.py
diff --git a/novelwriter/dialogs/about.py b/novelwriter/dialogs/about.py
index 4b205441..3cd6abc3 100644
--- a/novelwriter/dialogs/about.py
+++ b/novelwriter/dialogs/about.py
@@ -25,8 +25,6 @@ from __future__ import annotations
import logging
-from datetime import datetime
-
from PyQt5.QtGui import QCloseEvent, QColor
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (
@@ -34,10 +32,10 @@ from PyQt5.QtWidgets import (
QWidget
)
-from novelwriter import CONFIG, SHARED, __version__, __date__
-from novelwriter.common import formatVersion, readTextFile
-from novelwriter.constants import nwConst, nwUnicode
+from novelwriter import CONFIG, SHARED
+from novelwriter.common import readTextFile
from novelwriter.extensions.configlayout import NColourLabel
+from novelwriter.extensions.versioninfo import VersionInfoWidget
logger = logging.getLogger(__name__)
@@ -68,12 +66,7 @@ class GuiAbout(QDialog):
self.nwLabel = QLabel(self)
self.nwLabel.setPixmap(self.nwImage)
- self.nwInfo = QLabel(self.tr("Version {0} {1} Released on {2} {1} {3}").format(
- formatVersion(__version__), nwUnicode.U_ENDASH,
- datetime.strptime(__date__, "%Y-%m-%d").strftime("%x"),
- "{1}".format(nwConst.URL_RELEASES, self.tr("Release Notes"))
- ))
- self.nwInfo.setOpenExternalLinks(True)
+ self.nwInfo = VersionInfoWidget(self)
self.nwLicence = QLabel(self.tr("This application is licenced under {0}".format(
"GPL v3.0"
@@ -82,8 +75,7 @@ class GuiAbout(QDialog):
# Credits
self.lblCredits = NColourLabel(
- self.tr("Credits"), SHARED.theme.helpText,
- scale=NColourLabel.HEADER_SCALE, parent=self
+ self.tr("Credits"), scale=1.6, parent=self, bold=True
)
self.txtCredits = QTextBrowser(self)
@@ -100,6 +92,7 @@ class GuiAbout(QDialog):
self.innerBox.addSpacing(hB)
self.innerBox.addWidget(self.nwLabel)
self.innerBox.addWidget(self.nwInfo)
+ self.innerBox.addSpacing(hA)
self.innerBox.addWidget(self.nwLicence)
self.innerBox.addSpacing(hA)
self.innerBox.addWidget(self.lblCredits)
@@ -156,33 +149,10 @@ class GuiAbout(QDialog):
def _setStyleSheet(self) -> None:
"""Set stylesheet for all browser tabs."""
- colHead = SHARED.theme.colHead
- colKey = SHARED.theme.colKey
- styleSheet = (
- "h1, h2, h3, h4 {{"
- " color: rgb({hColR}, {hColG}, {hColB});"
- "}}\n"
- "a {{"
- " color: rgb({hColR}, {hColG}, {hColB});"
- "}}\n"
- ".alt {{"
- " color: rgb({kColR}, {kColG}, {kColB});"
- "}}\n"
- ).format(
- hColR=colHead.red(),
- hColG=colHead.green(),
- hColB=colHead.blue(),
- kColR=colKey.red(),
- kColG=colKey.green(),
- kColB=colKey.blue(),
- )
- self.txtCredits.document().setDefaultStyleSheet(styleSheet)
-
baseCol = self.palette().window().color()
self.txtCredits.setStyleSheet((
"QTextBrowser {{border: none; background: rgb({r},{g},{b});}} "
).format(r=baseCol.red(), g=baseCol.green(), b=baseCol.blue()))
-
return
# END Class GuiAbout
diff --git a/novelwriter/extensions/configlayout.py b/novelwriter/extensions/configlayout.py
index fb4cd37d..aa168778 100644
--- a/novelwriter/extensions/configlayout.py
+++ b/novelwriter/extensions/configlayout.py
@@ -26,7 +26,7 @@ along with this program. If not, see .
"""
from __future__ import annotations
-from PyQt5.QtGui import QColor, QPalette
+from PyQt5.QtGui import QColor, QFont, QPalette
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (
QAbstractButton, QFrame, QHBoxLayout, QLabel, QLayout, QScrollArea,
@@ -198,7 +198,7 @@ class NScrollableForm(QScrollArea):
if helpText:
qHelp = NColourLabel(
- str(helpText), self._helpCol, parent=self,
+ str(helpText), color=self._helpCol, parent=self,
scale=self._fontScale, wrap=True, indent=self._indent
)
labelBox = QVBoxLayout()
@@ -249,16 +249,19 @@ class NColourLabel(QLabel):
HELP_SCALE = DEFAULT_SCALE
HEADER_SCALE = 1.25
- def __init__(self, text: str, color: QColor, parent: QWidget | None = None,
- scale: float = HELP_SCALE, wrap: bool = False, indent: int = 0) -> None:
+ def __init__(self, text: str, color: QColor | None = None, parent: QWidget | None = None,
+ scale: float = HELP_SCALE, wrap: bool = False, indent: int = 0,
+ bold: bool = False) -> None:
super().__init__(text, parent=parent)
font = self.font()
font.setPointSizeF(scale*font.pointSizeF())
- colour = self.palette()
- colour.setColor(QPalette.WindowText, color)
+ font.setWeight(QFont.Weight.Bold if bold else QFont.Weight.Normal)
+ if color:
+ colour = self.palette()
+ colour.setColor(QPalette.WindowText, color)
+ self.setPalette(colour)
- self.setPalette(colour)
self.setFont(font)
self.setIndent(indent)
self.setWordWrap(wrap)
diff --git a/novelwriter/extensions/versioninfo.py b/novelwriter/extensions/versioninfo.py
new file mode 100644
index 00000000..09321e3e
--- /dev/null
+++ b/novelwriter/extensions/versioninfo.py
@@ -0,0 +1,131 @@
+"""
+novelWriter – Custom Widget: Version Info
+=========================================
+
+File History:
+Created: 2024-02-14 [2.3b1] VersionInfoWidget
+
+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 json
+import logging
+
+from random import shuffle
+from datetime import datetime
+from urllib.request import Request, urlopen
+
+from PyQt5.QtGui import QDesktopServices
+from PyQt5.QtCore import QUrl, pyqtSlot
+from PyQt5.QtWidgets import QLabel, QVBoxLayout, QWidget
+
+from novelwriter import CONFIG, __version__, __date__, __domain__
+from novelwriter.common import formatVersion
+from novelwriter.constants import nwConst
+
+logger = logging.getLogger(__name__)
+
+LOOKUPS = [
+ "https://novelwriter.io/release-latest.json",
+ "https://vkbo.github.io/novelWriter.io/release-latest.json",
+ "https://raw.githubusercontent.com/vkbo/novelWriter.io/main/source/_extra/release-latest.json",
+]
+
+
+class VersionInfoWidget(QWidget):
+
+ def __init__(self, parent: QWidget) -> None:
+ super().__init__(parent=parent)
+
+ # Labels
+ self._lblInfo = QLabel("{0} {1} \u2013 {2} {3} \u2013 {4}".format(
+ self.tr("Version"), formatVersion(__version__),
+ self.tr("Released on"), datetime.strptime(__date__, "%Y-%m-%d").strftime("%x"),
+ "{0}".format(self.tr("Release Notes")),
+ ), self)
+ self._lblInfo.linkActivated.connect(self._processLink)
+ self._lblRelease = QLabel(self.tr("Latest Version: {0}").format(
+ "{0}".format(self.tr("Check Now"))
+ ), self)
+ self._lblRelease.linkActivated.connect(self._processLink)
+
+ # New Release
+ self._trRelease = self.tr("Latest Version: {0} {1} Download from {2}")
+ self._trFail = self.tr("Could not retrieve version information.")
+
+ # Assemble
+ self._layout = QVBoxLayout()
+ self._layout.addWidget(self._lblInfo)
+ self._layout.addWidget(self._lblRelease)
+ self._layout.setSpacing(CONFIG.pxInt(2))
+ self._layout.setContentsMargins(0, 0, 0, 0)
+
+ self.setLayout(self._layout)
+
+ return
+
+ ##
+ # Private Slots
+ ##
+
+ @pyqtSlot(str)
+ def _processLink(self, link: str) -> None:
+ """Process an activated link."""
+ if link == "#notes":
+ QDesktopServices.openUrl(QUrl(nwConst.URL_RELEASES))
+ elif link == "#website":
+ QDesktopServices.openUrl(QUrl(nwConst.URL_WEB))
+ elif link == "#update":
+ shuffle(LOOKUPS)
+ for url in LOOKUPS:
+ if result := self._pullJson(url):
+ self._updateReleaseInfo(result)
+ break
+ else:
+ self._updateReleaseInfo({})
+ return
+
+ ##
+ # Internal Functions
+ ##
+
+ def _updateReleaseInfo(self, result: dict) -> None:
+ """Update the widget release info."""
+ if version := result.get("version"):
+ download = f"{__domain__}"
+ self._lblRelease.setText(self._trRelease.format(version, "\u2013", download))
+ else:
+ self._lblRelease.setText(self._trFail)
+ return
+
+ def _pullJson(self, url: str) -> dict | None:
+ """Pull a JSON file from a URL."""
+ urlReq = Request(url)
+ urlReq.add_header("User-Agent", nwConst.USER_AGENT)
+ urlReq.add_header("Accept", "application/json")
+
+ try:
+ logger.info("Contacting: %s", url)
+ urlData = urlopen(urlReq, timeout=10)
+ return json.loads(urlData.read().decode()).get("release")
+ except Exception:
+ logger.error("Failed to retrieve data")
+
+ return None
+
+# END Class VersionInfoWidget
diff --git a/novelwriter/tools/welcome.py b/novelwriter/tools/welcome.py
index 25386771..d0085f38 100644
--- a/novelwriter/tools/welcome.py
+++ b/novelwriter/tools/welcome.py
@@ -40,13 +40,14 @@ from PyQt5.QtWidgets import (
QToolButton, QVBoxLayout, QWidget, qApp
)
-from novelwriter import CONFIG, SHARED, __version__, __date__
+from novelwriter import CONFIG, SHARED
from novelwriter.enum import nwItemClass
-from novelwriter.common import formatInt, formatVersion, makeFileNameSafe
-from novelwriter.constants import nwFiles, nwUnicode
+from novelwriter.common import formatInt, makeFileNameSafe
+from novelwriter.constants import nwFiles
from novelwriter.core.coretools import ProjectBuilder
from novelwriter.extensions.switch import NSwitch
from novelwriter.extensions.modified import NComboBox, NSpinBox
+from novelwriter.extensions.versioninfo import VersionInfoWidget
logger = logging.getLogger(__name__)
@@ -90,10 +91,7 @@ class GuiWelcome(QDialog):
self.nwLabel = QLabel(self)
self.nwLabel.setPixmap(self.nwImage)
- self.nwInfo = QLabel(self.tr("Version {0} {1} Released on {2}").format(
- formatVersion(__version__), nwUnicode.U_ENDASH,
- datetime.strptime(__date__, "%Y-%m-%d").strftime("%x")
- ))
+ self.nwInfo = VersionInfoWidget(self)
self.tabOpen = _OpenProjectPage(self)
self.tabOpen.openProjectRequest.connect(self._openProjectPath)
From bdf100e608ef36f3aa5a34f0ad1d64a0a1140777 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Wed, 14 Feb 2024 18:32:28 +0100
Subject: [PATCH 2/6] Add more subtle link colours to themes
---
novelwriter/assets/themes/default_dark.conf | 4 ++--
novelwriter/assets/themes/default_light.conf | 4 ++--
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/novelwriter/assets/themes/default_dark.conf b/novelwriter/assets/themes/default_dark.conf
index ed8e0485..85fbf183 100644
--- a/novelwriter/assets/themes/default_dark.conf
+++ b/novelwriter/assets/themes/default_dark.conf
@@ -21,8 +21,8 @@ buttontext = 204, 204, 204
brighttext = 62, 62, 62
highlight = 44, 152, 247
highlightedtext = 255, 255, 255
-link = 44, 152, 247
-linkvisited = 44, 152, 247
+link = 102, 153, 204
+linkvisited = 102, 153, 204
[GUI]
helptext = 164, 164, 164
diff --git a/novelwriter/assets/themes/default_light.conf b/novelwriter/assets/themes/default_light.conf
index 6c28f979..9c67488b 100644
--- a/novelwriter/assets/themes/default_light.conf
+++ b/novelwriter/assets/themes/default_light.conf
@@ -21,8 +21,8 @@ buttontext = 0, 0, 0
brighttext = 255, 255, 255
highlight = 48, 135, 198
highlightedtext = 255, 255, 255
-link = 0, 84, 255
-linkvisited = 0, 84, 255
+link = 66, 113, 174
+linkvisited = 66, 113, 174
[GUI]
helptext = 92, 92, 92
From 3a1f77b8f8887880b76fe4ecd2f0ba47a91357e4 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Wed, 14 Feb 2024 18:55:01 +0100
Subject: [PATCH 3/6] Make the new version lookup run in the background
---
novelwriter/extensions/versioninfo.py | 95 ++++++++++++++++++---------
1 file changed, 63 insertions(+), 32 deletions(-)
diff --git a/novelwriter/extensions/versioninfo.py b/novelwriter/extensions/versioninfo.py
index 09321e3e..a60db5fe 100644
--- a/novelwriter/extensions/versioninfo.py
+++ b/novelwriter/extensions/versioninfo.py
@@ -31,10 +31,10 @@ from datetime import datetime
from urllib.request import Request, urlopen
from PyQt5.QtGui import QDesktopServices
-from PyQt5.QtCore import QUrl, pyqtSlot
+from PyQt5.QtCore import QObject, QRunnable, QUrl, pyqtSignal, pyqtSlot
from PyQt5.QtWidgets import QLabel, QVBoxLayout, QWidget
-from novelwriter import CONFIG, __version__, __date__, __domain__
+from novelwriter import CONFIG, SHARED, __version__, __date__, __domain__
from novelwriter.common import formatVersion
from novelwriter.constants import nwConst
@@ -52,6 +52,11 @@ class VersionInfoWidget(QWidget):
def __init__(self, parent: QWidget) -> None:
super().__init__(parent=parent)
+ # Label Strings
+ self._trLatest = self.tr("Latest Version: {0}")
+ self._trChecking = self.tr("Checking ...")
+ self._trDownload = self.tr("Download from {0}")
+
# Labels
self._lblInfo = QLabel("{0} {1} \u2013 {2} {3} \u2013 {4}".format(
self.tr("Version"), formatVersion(__version__),
@@ -59,15 +64,11 @@ class VersionInfoWidget(QWidget):
"{0}".format(self.tr("Release Notes")),
), self)
self._lblInfo.linkActivated.connect(self._processLink)
- self._lblRelease = QLabel(self.tr("Latest Version: {0}").format(
+ self._lblRelease = QLabel(self._trLatest.format(
"{0}".format(self.tr("Check Now"))
), self)
self._lblRelease.linkActivated.connect(self._processLink)
- # New Release
- self._trRelease = self.tr("Latest Version: {0} {1} Download from {2}")
- self._trFail = self.tr("Could not retrieve version information.")
-
# Assemble
self._layout = QVBoxLayout()
self._layout.addWidget(self._lblInfo)
@@ -91,41 +92,71 @@ class VersionInfoWidget(QWidget):
elif link == "#website":
QDesktopServices.openUrl(QUrl(nwConst.URL_WEB))
elif link == "#update":
- shuffle(LOOKUPS)
- for url in LOOKUPS:
- if result := self._pullJson(url):
- self._updateReleaseInfo(result)
- break
- else:
- self._updateReleaseInfo({})
+ self._lblRelease.setText(self._trLatest.format(self._trChecking))
+ lookup = _Retriever()
+ lookup.signals.dataReady.connect(self._updateReleaseInfo)
+ SHARED.runInThreadPool(lookup)
return
##
- # Internal Functions
+ # Private Slots
##
+ @pyqtSlot(dict)
def _updateReleaseInfo(self, result: dict) -> None:
"""Update the widget release info."""
if version := result.get("version"):
download = f"{__domain__}"
- self._lblRelease.setText(self._trRelease.format(version, "\u2013", download))
+ self._lblRelease.setText(self._trLatest.format(
+ f"{version} \u2013 {self._trDownload.format(download)}"
+ ))
else:
- self._lblRelease.setText(self._trFail)
+ self._lblRelease.setText(self._trLatest.format(self.tr("Failed")))
return
- def _pullJson(self, url: str) -> dict | None:
- """Pull a JSON file from a URL."""
- urlReq = Request(url)
- urlReq.add_header("User-Agent", nwConst.USER_AGENT)
- urlReq.add_header("Accept", "application/json")
-
- try:
- logger.info("Contacting: %s", url)
- urlData = urlopen(urlReq, timeout=10)
- return json.loads(urlData.read().decode()).get("release")
- except Exception:
- logger.error("Failed to retrieve data")
-
- return None
-
# END Class VersionInfoWidget
+
+
+class _Retriever(QRunnable):
+
+ def __init__(self) -> None:
+ super().__init__()
+ self._isRunning = False
+ self.signals = _RetrieverSignal()
+ return
+
+ def isRunning(self) -> bool:
+ return self._isRunning
+
+ @pyqtSlot()
+ def run(self) -> None:
+ """Poll the urls in the background."""
+ self._isRunning = True
+
+ shuffle(LOOKUPS)
+ for url in LOOKUPS:
+ req = Request(url)
+ req.add_header("User-Agent", nwConst.USER_AGENT)
+ req.add_header("Accept", "application/json")
+ try:
+ logger.info("Contacting: %s", url)
+ data = urlopen(req, timeout=10).read().decode()
+ if isinstance(release := json.loads(data).get("release"), dict):
+ self.signals.dataReady.emit(release)
+ break
+ except Exception:
+ logger.error("Failed to retrieve release info")
+ else:
+ self.signals.dataReady.emit({})
+
+ self._isRunning = False
+
+ return
+
+# END Class _Retriever
+
+
+class _RetrieverSignal(QObject):
+ dataReady = pyqtSignal(dict)
+
+# END Class _RetrieverSignal
From 5917c9da085015dd5fb91b78030ce1827cbfdd78 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Wed, 14 Feb 2024 21:46:10 +0100
Subject: [PATCH 4/6] Change version checker to use regular GitHub API
---
novelwriter/extensions/versioninfo.py | 62 ++++++++++++---------------
1 file changed, 27 insertions(+), 35 deletions(-)
diff --git a/novelwriter/extensions/versioninfo.py b/novelwriter/extensions/versioninfo.py
index a60db5fe..02fb86e4 100644
--- a/novelwriter/extensions/versioninfo.py
+++ b/novelwriter/extensions/versioninfo.py
@@ -26,8 +26,9 @@ from __future__ import annotations
import json
import logging
-from random import shuffle
+from time import sleep
from datetime import datetime
+from urllib.error import HTTPError
from urllib.request import Request, urlopen
from PyQt5.QtGui import QDesktopServices
@@ -40,11 +41,7 @@ from novelwriter.constants import nwConst
logger = logging.getLogger(__name__)
-LOOKUPS = [
- "https://novelwriter.io/release-latest.json",
- "https://vkbo.github.io/novelWriter.io/release-latest.json",
- "https://raw.githubusercontent.com/vkbo/novelWriter.io/main/source/_extra/release-latest.json",
-]
+API_URL = "https://api.github.com/repos/vkbo/novelwriter/releases/latest"
class VersionInfoWidget(QWidget):
@@ -102,16 +99,16 @@ class VersionInfoWidget(QWidget):
# Private Slots
##
- @pyqtSlot(dict)
- def _updateReleaseInfo(self, result: dict) -> None:
+ @pyqtSlot(str, str)
+ def _updateReleaseInfo(self, tag: str, reason: str) -> None:
"""Update the widget release info."""
- if version := result.get("version"):
+ if version := tag.lstrip("v"):
download = f"{__domain__}"
self._lblRelease.setText(self._trLatest.format(
f"{version} \u2013 {self._trDownload.format(download)}"
))
else:
- self._lblRelease.setText(self._trLatest.format(self.tr("Failed")))
+ self._lblRelease.setText(self._trLatest.format(reason or self.tr("Failed")))
return
# END Class VersionInfoWidget
@@ -125,38 +122,33 @@ class _Retriever(QRunnable):
self.signals = _RetrieverSignal()
return
- def isRunning(self) -> bool:
- return self._isRunning
-
@pyqtSlot()
def run(self) -> None:
- """Poll the urls in the background."""
- self._isRunning = True
-
- shuffle(LOOKUPS)
- for url in LOOKUPS:
- req = Request(url)
- req.add_header("User-Agent", nwConst.USER_AGENT)
- req.add_header("Accept", "application/json")
- try:
- logger.info("Contacting: %s", url)
- data = urlopen(req, timeout=10).read().decode()
- if isinstance(release := json.loads(data).get("release"), dict):
- self.signals.dataReady.emit(release)
- break
- except Exception:
- logger.error("Failed to retrieve release info")
- else:
- self.signals.dataReady.emit({})
-
- self._isRunning = False
-
+ """Poll the GitHub API in the background.
+ Note: The GitHub API is rate limited at 60 requests per hour.
+ """
+ logger.info("Contacting: %s", API_URL)
+ req = Request(API_URL)
+ req.add_header("User-Agent", nwConst.USER_AGENT)
+ req.add_header("Accept", "application/vnd.github.v3+json")
+ sleep(0.2)
+ try:
+ with urlopen(req, timeout=15) as ret:
+ if isinstance(raw := json.loads(ret.read().decode()), dict):
+ self.signals.dataReady.emit(raw.get("tag_name", ""), "")
+ except HTTPError as e:
+ reason = f"{e.reason.capitalize()} (HTTP {e.code})"
+ logger.error(reason)
+ self.signals.dataReady.emit("", reason)
+ except Exception as e:
+ logger.error("Failed to retrieve release info")
+ self.signals.dataReady.emit("", str(e))
return
# END Class _Retriever
class _RetrieverSignal(QObject):
- dataReady = pyqtSignal(dict)
+ dataReady = pyqtSignal(str, str)
# END Class _RetrieverSignal
From ed7dde2aaa3084e24a9d45dffe4060909944859c Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Wed, 14 Feb 2024 23:19:25 +0100
Subject: [PATCH 5/6] Add test coverage
---
novelwriter/extensions/versioninfo.py | 1 -
tests/test_ext/test_ext_modified.py | 4 +-
tests/test_ext/test_ext_versioninfo.py | 155 +++++++++++++++++++++++++
3 files changed, 157 insertions(+), 3 deletions(-)
create mode 100644 tests/test_ext/test_ext_versioninfo.py
diff --git a/novelwriter/extensions/versioninfo.py b/novelwriter/extensions/versioninfo.py
index 02fb86e4..08cbc74e 100644
--- a/novelwriter/extensions/versioninfo.py
+++ b/novelwriter/extensions/versioninfo.py
@@ -118,7 +118,6 @@ class _Retriever(QRunnable):
def __init__(self) -> None:
super().__init__()
- self._isRunning = False
self.signals = _RetrieverSignal()
return
diff --git a/tests/test_ext/test_ext_modified.py b/tests/test_ext/test_ext_modified.py
index c1d0e3c2..7ea40755 100644
--- a/tests/test_ext/test_ext_modified.py
+++ b/tests/test_ext/test_ext_modified.py
@@ -1,6 +1,6 @@
"""
-novelWriter – Wheel Event Filter Tester
-=======================================
+novelWriter – Modified Widgets Tester
+=====================================
This file is a part of novelWriter
Copyright 2018–2024, Veronica Berglyd Olsen
diff --git a/tests/test_ext/test_ext_versioninfo.py b/tests/test_ext/test_ext_versioninfo.py
new file mode 100644
index 00000000..b30fb19c
--- /dev/null
+++ b/tests/test_ext/test_ext_versioninfo.py
@@ -0,0 +1,155 @@
+"""
+novelWriter – Version Info Widget Tester
+========================================
+
+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 pytest
+
+from urllib.error import HTTPError
+
+from tools import SimpleDialog
+
+from PyQt5.QtCore import QUrl
+
+from novelwriter import SHARED
+from novelwriter.constants import nwConst
+from novelwriter.extensions.versioninfo import VersionInfoWidget, _Retriever, _RetrieverSignal
+
+
+class MockRetriever:
+ signals = _RetrieverSignal()
+
+
+class MockDesktopServices:
+
+ url = None
+
+ @staticmethod
+ def openUrl(url):
+ MockDesktopServices.url = url
+ return
+
+
+class MockData:
+ pass
+
+
+class MockPayload:
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *args):
+ return
+
+ def read(self):
+ data = MockData()
+ data.decode = lambda *a: '{"tag_name": "v1.0"}'
+ return data
+
+
+class MockHTTPError:
+
+ def __enter__(self):
+ raise HTTPError("http://example.com", 403, "Rate limit", None, None)
+
+ def __exit__(self, *args):
+ return
+
+
+class MockException:
+
+ def __enter__(self):
+ raise Exception("Oh noes!")
+
+ def __exit__(self, *args):
+ return
+
+
+@pytest.mark.gui
+def testExtVersionInfo_Main(qtbot, monkeypatch):
+ """Test the VersionInfoWidget class."""
+ version = VersionInfoWidget(None)
+ dialog = SimpleDialog(version)
+ dialog.show()
+
+ with monkeypatch.context() as mp:
+ mp.setattr("novelwriter.extensions.versioninfo.QDesktopServices", MockDesktopServices)
+ version._processLink("#notes")
+ assert MockDesktopServices.url == QUrl(nwConst.URL_RELEASES)
+
+ with monkeypatch.context() as mp:
+ mp.setattr("novelwriter.extensions.versioninfo.QDesktopServices", MockDesktopServices)
+ version._processLink("#website")
+ assert MockDesktopServices.url == QUrl(nwConst.URL_WEB)
+
+ with monkeypatch.context() as mp:
+ mp.setattr("novelwriter.extensions.versioninfo._Retriever", MockRetriever)
+ mp.setattr(SHARED, "runInThreadPool", lambda *a: None)
+ version._processLink("#update")
+ assert version._lblRelease.text() == "Latest Version: Checking ..."
+
+ version._updateReleaseInfo("v2.0", "")
+ assert version._lblRelease.text() == (
+ "Latest Version: 2.0 – Download from novelwriter.io"
+ )
+
+ version._updateReleaseInfo("", "Error")
+ assert version._lblRelease.text() == "Latest Version: Error"
+
+ version._updateReleaseInfo("", "")
+ assert version._lblRelease.text() == "Latest Version: Failed"
+
+ dialog.close()
+ # qtbot.stop()
+
+# END Test testExtVersionInfo_Main
+
+
+@pytest.mark.gui
+def testExtVersionInfo_Retriever(qtbot, monkeypatch):
+ """Test the _Retriever class."""
+ task = _Retriever()
+
+ # Valid Data
+ with monkeypatch.context() as mp:
+ mp.setattr("novelwriter.extensions.versioninfo.urlopen", lambda *a, **k: MockPayload())
+ with qtbot.waitSignal(task.signals.dataReady) as signal:
+ task.run()
+ assert signal.args == ["v1.0", ""]
+
+ def httpErr():
+ raise HTTPError("http://example.com", 403, "Rate limit")
+
+ # HTTP Error
+ with monkeypatch.context() as mp:
+ mp.setattr("novelwriter.extensions.versioninfo.urlopen", lambda *a, **k: MockHTTPError())
+ with qtbot.waitSignal(task.signals.dataReady) as signal:
+ task.run()
+ assert signal.args == ["", "Rate limit (HTTP 403)"]
+
+ # Other Error
+ with monkeypatch.context() as mp:
+ mp.setattr("novelwriter.extensions.versioninfo.urlopen", lambda *a, **k: MockException())
+ with qtbot.waitSignal(task.signals.dataReady) as signal:
+ task.run()
+ assert signal.args == ["", "Oh noes!"]
+
+# END Test testExtVersionInfo_Retriever
From c6524d02ea16951703c9557d70182d290ed6e269 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Wed, 14 Feb 2024 23:22:43 +0100
Subject: [PATCH 6/6] Delete the old updates dialog
---
novelwriter/dialogs/updates.py | 172 -------------------------
novelwriter/gui/mainmenu.py | 7 -
novelwriter/guimain.py | 12 --
tests/test_dialogs/test_dlg_dialogs.py | 41 +-----
4 files changed, 1 insertion(+), 231 deletions(-)
delete mode 100644 novelwriter/dialogs/updates.py
diff --git a/novelwriter/dialogs/updates.py b/novelwriter/dialogs/updates.py
deleted file mode 100644
index ef0b0c1f..00000000
--- a/novelwriter/dialogs/updates.py
+++ /dev/null
@@ -1,172 +0,0 @@
-"""
-novelWriter – GUI Updates
-=========================
-
-File History:
-Created: 2021-08-21 [1.5b1] GuiUpdates
-
-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 json
-import logging
-
-from datetime import datetime
-from urllib.request import Request, urlopen
-
-from PyQt5.QtGui import QCloseEvent, QCursor
-from PyQt5.QtCore import Qt
-from PyQt5.QtWidgets import (
- QWidget, qApp, QDialog, QHBoxLayout, QVBoxLayout, QDialogButtonBox, QLabel
-)
-
-from novelwriter import CONFIG, SHARED, __version__, __date__
-from novelwriter.common import logException
-from novelwriter.constants import nwConst
-
-logger = logging.getLogger(__name__)
-
-
-class GuiUpdates(QDialog):
-
- def __init__(self, parent: QWidget) -> None:
- super().__init__(parent=parent)
-
- logger.debug("Create: GuiUpdates")
- self.setObjectName("GuiUpdates")
- self.setWindowTitle(self.tr("Check for Updates"))
-
- nPx = CONFIG.pxInt(96)
- sPx = CONFIG.pxInt(16)
- tPx = CONFIG.pxInt(8)
- mPx = CONFIG.pxInt(4)
-
- # Left Box
- self.nwIcon = QLabel()
- self.nwIcon.setPixmap(SHARED.theme.getPixmap("novelwriter", (nPx, nPx)))
-
- self.leftBox = QVBoxLayout()
- self.leftBox.addWidget(self.nwIcon)
- self.leftBox.addStretch(1)
-
- # Right Box
- self.currentLabel = QLabel(self.tr("Current Release"))
- self.currentValue = QLabel(self.tr(
- "novelWriter {0} released on {1}"
- ).format(
- "v%s" % __version__,
- datetime.strptime(__date__, "%Y-%m-%d").strftime("%x"))
- )
-
- self.latestLabel = QLabel(self.tr("Latest Release"))
- self.latestValue = QLabel(self.tr("Checking ..."))
- self.latestLink = QLabel("")
- self.latestLink.setOpenExternalLinks(True)
-
- self.rightBox = QVBoxLayout()
- self.rightBox.addWidget(self.currentLabel)
- self.rightBox.addWidget(self.currentValue)
- self.rightBox.addSpacing(tPx)
- self.rightBox.addWidget(self.latestLabel)
- self.rightBox.addWidget(self.latestValue)
- self.rightBox.addSpacing(tPx)
- self.rightBox.addWidget(self.latestLink)
- self.rightBox.setSpacing(mPx)
-
- hFont = self.currentLabel.font()
- hFont.setBold(True)
- self.currentLabel.setFont(hFont)
- self.latestLabel.setFont(hFont)
-
- # Buttons
- self.buttonBox = QDialogButtonBox(QDialogButtonBox.Close)
- self.buttonBox.rejected.connect(self.close)
-
- # Assemble
- self.innerBox = QHBoxLayout()
- self.innerBox.addLayout(self.leftBox)
- self.innerBox.addLayout(self.rightBox)
- self.innerBox.setSpacing(sPx)
-
- self.outerBox = QVBoxLayout()
- self.outerBox.addLayout(self.innerBox)
- self.outerBox.addWidget(self.buttonBox)
- self.outerBox.setSpacing(sPx)
-
- self.setLayout(self.outerBox)
-
- logger.debug("Ready: GuiUpdates")
-
- return
-
- def __del__(self) -> None: # pragma: no cover
- logger.debug("Delete: GuiUpdates")
- return
-
- 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", nwConst.USER_AGENT)
- urlReq.add_header("Accept", "application/vnd.github.v3+json")
-
- rawData = {}
- try:
- urlData = urlopen(urlReq, timeout=10)
- rawData = json.loads(urlData.read().decode())
- except Exception:
- logger.error("Failed to contact GitHub API")
- logException()
-
- relVersion = rawData.get("tag_name", "Unknown")
- relDate = rawData.get("created_at", "")
-
- try:
- relDate = datetime.strptime(relDate[:10], "%Y-%m-%d").strftime("%x")
- except Exception:
- relDate = "Unknown"
- logException()
-
- self.latestValue.setText(self.tr(
- "novelWriter {0} released on {1}"
- ).format(
- relVersion, relDate
- ))
-
- self.latestLink.setText(self.tr(
- "Download: {0}"
- ).format(
- f'{nwConst.URL_WEB}'
- ))
-
- qApp.restoreOverrideCursor()
-
- return
-
- ##
- # Events
- ##
-
- def closeEvent(self, event: QCloseEvent) -> None:
- """Capture the user closing the window."""
- event.accept()
- self.deleteLater()
- return
-
-# END Class GuiUpdates
diff --git a/novelwriter/gui/mainmenu.py b/novelwriter/gui/mainmenu.py
index 0465bed5..ae99f6c2 100644
--- a/novelwriter/gui/mainmenu.py
+++ b/novelwriter/gui/mainmenu.py
@@ -964,13 +964,6 @@ class GuiMainMenu(QMenuBar):
self.aWebsite = self.helpMenu.addAction(self.tr("The novelWriter Website"))
self.aWebsite.triggered.connect(lambda: self._openWebsite(nwConst.URL_WEB))
- # Help > Separator
- self.helpMenu.addSeparator()
-
- # Document > Check for Updates
- self.aUpdates = self.helpMenu.addAction(self.tr("Check for New Release"))
- self.aUpdates.triggered.connect(self.mainGui.showUpdatesDialog)
-
return
# END Class GuiMainMenu
diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py
index c14b7576..2f99ed86 100644
--- a/novelwriter/guimain.py
+++ b/novelwriter/guimain.py
@@ -51,7 +51,6 @@ from novelwriter.gui.statusbar import GuiMainStatus
from novelwriter.gui.itemdetails import GuiItemDetails
from novelwriter.gui.docviewerpanel import GuiDocViewerPanel
from novelwriter.dialogs.about import GuiAbout
-from novelwriter.dialogs.updates import GuiUpdates
from novelwriter.dialogs.wordlist import GuiWordList
from novelwriter.dialogs.preferences import GuiPreferences
from novelwriter.dialogs.projectsettings import GuiProjectSettings
@@ -884,17 +883,6 @@ class GuiMain(QMainWindow):
msgBox.aboutQt(self, "About Qt")
return
- @pyqtSlot()
- def showUpdatesDialog(self) -> None:
- """Show the check for updates dialog."""
- dialog = GuiUpdates(self)
- dialog.setModal(True)
- dialog.show()
- dialog.raise_()
- qApp.processEvents()
- dialog.checkLatest()
- return
-
@pyqtSlot()
def showDictionariesDialog(self) -> None:
"""Show the download dictionaries dialog."""
diff --git a/tests/test_dialogs/test_dlg_dialogs.py b/tests/test_dialogs/test_dlg_dialogs.py
index 94d488ed..7cf9d9f6 100644
--- a/tests/test_dialogs/test_dlg_dialogs.py
+++ b/tests/test_dialogs/test_dlg_dialogs.py
@@ -23,10 +23,9 @@ from __future__ import annotations
import pytest
from PyQt5.QtCore import QItemSelectionModel
-from PyQt5.QtWidgets import QAction, QListWidgetItem, QDialog
+from PyQt5.QtWidgets import QListWidgetItem, QDialog
from novelwriter.dialogs.quotes import GuiQuoteSelect
-from novelwriter.dialogs.updates import GuiUpdates
from novelwriter.dialogs.editlabel import GuiEditLabel
@@ -66,44 +65,6 @@ def testDlgOther_QuoteSelect(qtbot, monkeypatch, nwGUI):
# END Test testDlgOther_QuoteSelect
-@pytest.mark.gui
-def testDlgOther_Updates(qtbot, monkeypatch, nwGUI):
- """Test the check for updates dialog."""
- nwUpdate = GuiUpdates(nwGUI)
- nwUpdate.show()
-
- class mockData:
- def decode(self):
- return '{"tag_name": "v1.0", "created_at": "2021-01-01T12:00:00Z"}'
-
- class mockPayload:
- def read(self):
- return mockData()
-
- def mockUrlopenA(*a, **k):
- return None
-
- def mockUrlopenB(*a, **k):
- return mockPayload()
-
- # Faulty Return
- monkeypatch.setattr("novelwriter.dialogs.updates.urlopen", mockUrlopenA)
- nwUpdate.checkLatest()
-
- # Valid Return
- monkeypatch.setattr("novelwriter.dialogs.updates.urlopen", mockUrlopenB)
- nwUpdate.checkLatest()
- assert nwUpdate.latestValue.text().startswith("novelWriter v1.0")
-
- # Trigger from Menu
- nwGUI.mainMenu.aUpdates.activate(QAction.Trigger)
-
- # qtbot.stop()
- nwUpdate.close()
-
-# END Test testDlgOther_Updates
-
-
@pytest.mark.gui
def testDlgOther_EditLabel(qtbot, monkeypatch):
"""Test the label editor dialog."""