diff --git a/nw/dialogs/__init__.py b/nw/dialogs/__init__.py index 9f9e5bf8..e278bbb9 100644 --- a/nw/dialogs/__init__.py +++ b/nw/dialogs/__init__.py @@ -27,6 +27,7 @@ from nw.dialogs.preferences import GuiPreferences from nw.dialogs.projload import GuiProjectLoad from nw.dialogs.projsettings import GuiProjectSettings from nw.dialogs.quotes import GuiQuoteSelect +from nw.dialogs.updates import GuiUpdates from nw.dialogs.wordlist import GuiWordList __all__ = [ @@ -38,5 +39,6 @@ __all__ = [ "GuiProjectLoad", "GuiProjectSettings", "GuiQuoteSelect", + "GuiUpdates", "GuiWordList", ] diff --git a/nw/dialogs/updates.py b/nw/dialogs/updates.py new file mode 100644 index 00000000..d3a30632 --- /dev/null +++ b/nw/dialogs/updates.py @@ -0,0 +1,170 @@ +""" +novelWriter – GUI Updates +========================= +A dialog box for checking for latest updates + +File History: +Created: 2021-08-21 [1.5-alpah0] + +This file is a part of novelWriter +Copyright 2018–2021, 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 . +""" + +import nw +import json +import logging + +from datetime import datetime +from urllib.request import Request, urlopen + +from PyQt5.QtGui import QCursor +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import ( + qApp, QDialog, QHBoxLayout, QVBoxLayout, QDialogButtonBox, QLabel +) + +from nw.common import logException + +logger = logging.getLogger(__name__) + + +class GuiUpdates(QDialog): + + def __init__(self, theParent): + QDialog.__init__(self, theParent) + + logger.debug("Initialising GuiUpdates ...") + self.setObjectName("GuiUpdates") + + self.mainConf = nw.CONFIG + self.theParent = theParent + + self.setWindowTitle(self.tr("Check for Updates")) + + nPx = self.mainConf.pxInt(96) + sPx = self.mainConf.pxInt(16) + tPx = self.mainConf.pxInt(8) + mPx = self.mainConf.pxInt(4) + + # Left Box + self.nwIcon = QLabel() + self.nwIcon.setPixmap(self.theParent.theTheme.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" % nw.__version__, + datetime.strptime(nw.__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.Ok) + self.buttonBox.accepted.connect(self._doClose) + + # 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("GuiUpdates initialisation complete") + + return + + def checkLatest(self): + """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("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", None) + + 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'{nw.__url__}' + )) + + qApp.restoreOverrideCursor() + + return + + ## + # Internal Functions + ## + + def _doClose(self): + self.close() + return + +# END Class GuiUpdates diff --git a/nw/gui/mainmenu.py b/nw/gui/mainmenu.py index b243372e..344f2573 100644 --- a/nw/gui/mainmenu.py +++ b/nw/gui/mainmenu.py @@ -1125,14 +1125,6 @@ class GuiMainMenu(QMenuBar): self.aQuestion.triggered.connect(lambda: self._openWebsite(nw.__helpurl__)) self.helpMenu.addAction(self.aQuestion) - # Document > Latest Release - self.aRelease = QAction(self.tr("Latest Release (GitHub)"), self) - self.aRelease.setStatusTip( - self.tr("Open the Releases page on GitHub at {0}").format(nw.__releaseurl__) - ) - self.aRelease.triggered.connect(lambda: self._openWebsite(nw.__releaseurl__)) - self.helpMenu.addAction(self.aRelease) - # Document > Main Website self.aWebsite = QAction(self.tr("The novelWriter Website"), self) self.aWebsite.setStatusTip( @@ -1141,6 +1133,15 @@ class GuiMainMenu(QMenuBar): self.aWebsite.triggered.connect(lambda: self._openWebsite(nw.__url__)) self.helpMenu.addAction(self.aWebsite) + # Help > Separator + self.helpMenu.addSeparator() + + # Document > Check for Updates + self.aUpdates = QAction(self.tr("Check for New Release"), self) + self.aUpdates.setStatusTip(self.tr("Check for latest release of novelWriter")) + self.aUpdates.triggered.connect(lambda: self.theParent.showUpdatesDialog()) + self.helpMenu.addAction(self.aUpdates) + return # END Class GuiMainMenu diff --git a/nw/guimain.py b/nw/guimain.py index 61422a2d..bcb8cb1b 100644 --- a/nw/guimain.py +++ b/nw/guimain.py @@ -44,7 +44,7 @@ from nw.gui import ( ) from nw.dialogs import ( GuiAbout, GuiDocMerge, GuiDocSplit, GuiItemEditor, GuiPreferences, - GuiProjectLoad, GuiProjectSettings, GuiWordList + GuiProjectLoad, GuiProjectSettings, GuiUpdates, GuiWordList ) from nw.tools import GuiBuildNovel, GuiProjectWizard, GuiWritingStats from nw.core import NWProject, NWIndex @@ -289,7 +289,7 @@ class GuiMain(QMainWindow): logger.debug("GUI initialisation complete") - if nw.__hexversion__[-2] == "a": + if nw.__hexversion__[-2] == "a" and logger.getEffectiveLevel() > logging.DEBUG: self.makeAlert(self.tr( "You are running an untested development version of novelWriter. " "Please be careful when working on a live project " @@ -1096,6 +1096,21 @@ class GuiMain(QMainWindow): msgBox.aboutQt(self, "About Qt") return + def showUpdatesDialog(self): + """Show the updates dialog for novelWriter. + """ + dlgUpdate = getGuiItem("GuiUpdates") + if dlgUpdate is None: + dlgUpdate = GuiUpdates(self) + + dlgUpdate.setModal(True) + dlgUpdate.show() + dlgUpdate.raise_() + qApp.processEvents() + dlgUpdate.checkLatest() + + return + def makeAlert(self, theMessage, theLevel=nwAlert.INFO): """Alert both the user and the logger at the same time. Message can be either a string or an array of strings. diff --git a/tests/test_dialogs/test_dlg_dialogs.py b/tests/test_dialogs/test_dlg_dialogs.py index 1d8033b0..6a652eb4 100644 --- a/tests/test_dialogs/test_dlg_dialogs.py +++ b/tests/test_dialogs/test_dlg_dialogs.py @@ -22,9 +22,9 @@ along with this program. If not, see . import pytest from PyQt5.QtCore import QItemSelectionModel -from PyQt5.QtWidgets import QListWidgetItem, QDialog, QMessageBox +from PyQt5.QtWidgets import QAction, QListWidgetItem, QDialog, QMessageBox -from nw.dialogs import GuiQuoteSelect +from nw.dialogs import GuiQuoteSelect, GuiUpdates keyDelay = 2 typeDelay = 1 @@ -32,7 +32,7 @@ stepDelay = 20 @pytest.mark.gui -def testDlgOther_QuoteSelect(monkeypatch, nwGUI): +def testDlgOther_QuoteSelect(qtbot, monkeypatch, nwGUI): """Test the quote symbols dialog. """ # Block message box @@ -59,3 +59,45 @@ def testDlgOther_QuoteSelect(monkeypatch, nwGUI): nwQuot.close() # END Test testDlgOther_QuoteSelect + + +@pytest.mark.gui +def testDlgOther_Updates(qtbot, monkeypatch, nwGUI): + """Test the check for updates dialog. + """ + # Block message box + monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) + + 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("nw.dialogs.updates.urlopen", mockUrlopenA) + nwUpdate.checkLatest() + + # Valid Return + monkeypatch.setattr("nw.dialogs.updates.urlopen", mockUrlopenB) + nwUpdate.checkLatest() + assert nwUpdate.latestValue.text().startswith("novelWriter v1.0") + + # Trigger from Menu + nwGUI.mainMenu.aUpdates.activate(QAction.Trigger) + + # qtbot.stopForInteraction() + nwUpdate._doClose() + +# END Test testDlgOther_Updates diff --git a/tests/test_gui/test_gui_mainmenu.py b/tests/test_gui/test_gui_mainmenu.py index 22adcbd1..4bbef2c5 100644 --- a/tests/test_gui/test_gui_mainmenu.py +++ b/tests/test_gui/test_gui_mainmenu.py @@ -379,7 +379,7 @@ def testGuiMenu_ContextMenus(qtbot, monkeypatch, nwGUI, nwLipsum): """Test the context menus. """ # Block message box - monkeypatch.setattr(QMessageBox, "question", lambda *args: QMessageBox.Yes) + monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) nwGUI.theProject.projTree.setSeed(42) assert nwGUI.openProject(nwLipsum) @@ -637,7 +637,7 @@ def testGuiMenu_Insert(qtbot, monkeypatch, nwGUI, fncDir, fncProj): # Faulty Keyword Inserts assert not nwGUI.docEditor.insertKeyWord("blabla") with monkeypatch.context() as mp: - mp.setattr(QTextBlock, "isValid", lambda *args, **kwards: False) + mp.setattr(QTextBlock, "isValid", lambda *a, **k: False) assert not nwGUI.docEditor.insertKeyWord(nwKeyWords.TAG_KEY) nwGUI.docEditor.clear() @@ -667,16 +667,16 @@ def testGuiMenu_Insert(qtbot, monkeypatch, nwGUI, fncDir, fncProj): nwGUI.closeDocument() # First, with no path - monkeypatch.setattr(QFileDialog, "getOpenFileName", lambda *args, **kwards: ("", "")) + monkeypatch.setattr(QFileDialog, "getOpenFileName", lambda *a, **k: ("", "")) assert not nwGUI.importDocument() # Then with a path, but an invalid one - monkeypatch.setattr(QFileDialog, "getOpenFileName", lambda *args, **kwards: (" ", "")) + monkeypatch.setattr(QFileDialog, "getOpenFileName", lambda *a, **k: (" ", "")) assert not nwGUI.importDocument() # Then a valid path, but bot a file that exists theFile = os.path.join(fncDir, "import.txt") - monkeypatch.setattr(QFileDialog, "getOpenFileName", lambda *args, **kwards: (theFile, "")) + monkeypatch.setattr(QFileDialog, "getOpenFileName", lambda *a, **k: (theFile, "")) assert not nwGUI.importDocument() # Create the file and try again, but with no target document open @@ -689,12 +689,12 @@ def testGuiMenu_Insert(qtbot, monkeypatch, nwGUI, fncDir, fncProj): assert nwGUI.docEditor.getText() == "Bar" # The document isn't empty, so the message box should pop - monkeypatch.setattr(QMessageBox, "question", lambda *args, **kwargs: QMessageBox.No) + monkeypatch.setattr(QMessageBox, "question", lambda *a, **k: QMessageBox.No) assert not nwGUI.importDocument() assert nwGUI.docEditor.getText() == "Bar" # Finally, accept the replaced text, this time we use the menu entry to trigger it - monkeypatch.setattr(QMessageBox, "question", lambda *args, **kwargs: QMessageBox.Yes) + monkeypatch.setattr(QMessageBox, "question", lambda *a, **k: QMessageBox.Yes) nwGUI.mainMenu.aImportFile.activate(QAction.Trigger) assert nwGUI.docEditor.getText() == "Foo"