diff --git a/nw/config.py b/nw/config.py index 3149edef..94c30f71 100644 --- a/nw/config.py +++ b/nw/config.py @@ -20,7 +20,7 @@ from os import path, mkdir, unlink, rename from datetime import datetime from PyQt5.Qt import PYQT_VERSION_STR -from PyQt5.QtCore import QT_VERSION_STR, QStandardPaths +from PyQt5.QtCore import QT_VERSION_STR, QStandardPaths, QSysInfo from nw.constants import nwFiles, nwUnicode from nw.common import splitVersionNumber @@ -156,6 +156,10 @@ class Config: else: self.osUnknown = True + # Other System Info + self.hostName = QSysInfo.machineHostName() + self.kernelVer = QSysInfo.kernelVersion() + # Packages self.hasEnchant = False self.hasSymSpell = False diff --git a/nw/constants/constants.py b/nw/constants/constants.py index 4cc7f040..b39830bc 100644 --- a/nw/constants/constants.py +++ b/nw/constants/constants.py @@ -23,6 +23,7 @@ class nwFiles(): APP_ICON = "novelWriter.svg" PROJ_FILE = "nwProject.nwx" PROJ_DICT = "wordlist.txt" + PROJ_LOCK = "nwProject.lock" SESS_INFO = "sessionInfo.log" INDEX_FILE = "tagsIndex.json" OPTS_FILE = "guiOptions.json" diff --git a/nw/guimain.py b/nw/guimain.py index a9cd7edb..a34f8f28 100644 --- a/nw/guimain.py +++ b/nw/guimain.py @@ -15,6 +15,7 @@ import time import nw from os import path +from datetime import datetime from PyQt5.QtCore import Qt, QTimer from PyQt5.QtGui import QIcon, QPixmap, QColor, QKeySequence @@ -49,9 +50,9 @@ class GuiMain(QMainWindow): self.hasProject = False self.isZenMode = False - logger.info("OS: %s" % ( - self.mainConf.osType) - ) + logger.info("OS: %s" % self.mainConf.osType) + logger.info("Kernel: %s" % self.mainConf.kernelVer) + logger.info("Host: %s" % self.mainConf.hostName) logger.info("Qt5 Version: %s (%d)" % ( self.mainConf.verQtString, self.mainConf.verQtValue) ) @@ -319,7 +320,42 @@ class GuiMain(QMainWindow): # Try to open the project if not self.theProject.openProject(projFile): - return False + if self.theProject.lockedBy is not None: + if self.mainConf.showGUI: + try: + lockDetails = ( + "

The project was locked by the computer " + "'%s' (%s %s), last active on %s" + ) % ( + self.theProject.lockedBy[0], + self.theProject.lockedBy[1], + self.theProject.lockedBy[2], + datetime.fromtimestamp( + int(self.theProject.lockedBy[3]) + ).strftime("%x %X") + ) + except: + lockDetails = "" + + msgBox = QMessageBox() + msgRes = msgBox.warning( + self, "Project Locked", ( + "The project is already open by another instance of %s, and is " + "therefore locked. Override lock and continue anyway?

" + "Note: If the program or the computer previously crashed, the lock " + "can safely be overridden. If, however, another instance of %s has " + "the project open, overriding the lock may corrupt the project, and " + "is not recommended.%s" + ) % (nw.__package__, nw.__package__, lockDetails), + QMessageBox.Yes | QMessageBox.No, QMessageBox.No + ) + if msgRes == QMessageBox.Yes: + if not self.theProject.openProject(projFile, overrideLock=True): + return False + else: + return False + else: + return False # project is loaded self.hasProject = True diff --git a/nw/project/backup.py b/nw/project/backup.py index bab5ffcc..1f2bd985 100644 --- a/nw/project/backup.py +++ b/nw/project/backup.py @@ -57,7 +57,9 @@ class NWBackup(): baseName = path.join(self.mainConf.backupPath, archName) try: + self.theProject._clearLockFile() make_archive(baseName, "zip", self.theProject.projPath, ".") + self.theProject._writeLockFile() except Exception as e: self.theParent.makeAlert( ["Could not write backup archive.",str(e)], diff --git a/nw/project/project.py b/nw/project/project.py index 887fe38c..afde1e02 100644 --- a/nw/project/project.py +++ b/nw/project/project.py @@ -40,6 +40,7 @@ class NWProject(): self.projOpened = None # The time stamp of when the project file was opened self.projChanged = None # The project has unsaved changes self.projAltered = None # The project has been altered this session + self.lockedBy = None # Data on which computer has the project open # Debug self.handleSeed = None @@ -178,7 +179,7 @@ class NWProject(): return - def openProject(self, fileName): + def openProject(self, fileName, overrideLock=False): """Open the project file provided, or if doesn't exist, assume it is a folder, and look for the file within it. If successful, parse the XML of the file and populate the project variables and @@ -201,6 +202,21 @@ class NWProject(): if not self._checkFolder(self.projMeta): return + if overrideLock: + self._clearLockFile() + + lockStatus = self._readLockFile() + if len(lockStatus) > 0: + if lockStatus[0] == "ERROR": + logger.warning("Failed to check lock file") + else: + logger.error("Project is locked, so not opening") + self.lockedBy = lockStatus + self.clearProject() + return False + else: + logger.verbose("Project is not locked") + try: projectMaintenance(self) except Exception as E: @@ -308,6 +324,7 @@ class NWProject(): self.setProjectChanged(False) self.projOpened = time() self.projAltered = False + self._writeLockFile() return True @@ -398,14 +415,19 @@ class NWProject(): self.mainConf.updateRecentCache(self.projPath, self.projName, self.currWCount, saveTime) self.mainConf.saveRecentCache() + self._writeLockFile() self.theParent.setStatus("Saved Project: %s" % self.projName) self.setProjectChanged(False) return True def closeProject(self): + """Close the current project and clear all meta data. + """ self._appendSessionStats() + self._clearLockFile() self.clearProject() + self.lockedBy = None return True ## @@ -645,6 +667,73 @@ class NWProject(): # Internal Functions ## + def _readLockFile(self): + """Reads the lock file in the project folder. + """ + + if self.projPath is None: + return ["ERROR"] + + lockFile = path.join(self.projPath, nwFiles.PROJ_LOCK) + if not path.isfile(lockFile): + return [] + + try: + with open(lockFile, mode="r", encoding="utf8") as inFile: + theData = inFile.read() + theLines = theData.splitlines() + if len(theLines) == 4: + return theLines + else: + return ["ERROR"] + + except Exception as e: + logger.error("Failed to read project lockfile") + logger.error(str(e)) + return ["ERROR"] + + return ["ERROR"] + + def _writeLockFile(self): + """Writes a lock file to the project folder. + """ + + if self.projPath is None: + return False + + lockFile = path.join(self.projPath, nwFiles.PROJ_LOCK) + try: + with open(lockFile, mode="w+", encoding="utf8") as outFile: + outFile.write("%s\n" % self.mainConf.hostName) + outFile.write("%s\n" % self.mainConf.osType) + outFile.write("%s\n" % self.mainConf.kernelVer) + outFile.write("%d\n" % time()) + + except Exception as e: + logger.error("Failed to write project lockfile") + logger.error(str(e)) + return False + + return True + + def _clearLockFile(self): + """Remove the lock file, if it exists. + """ + if self.projPath is None: + return False + + lockFile = path.join(self.projPath, nwFiles.PROJ_LOCK) + if path.isfile(lockFile): + try: + unlink(lockFile) + return True + except Exception as e: + logger.error("Failed to remove project lockfile") + logger.error(str(e)) + return False + + return None + def _checkFolder(self, thePath): if not path.isdir(thePath): try: diff --git a/tests/reference/proj/2_nwProject.nwx b/tests/reference/proj/2_nwProject.nwx index b78c14a4..0a8e6b4d 100644 --- a/tests/reference/proj/2_nwProject.nwx +++ b/tests/reference/proj/2_nwProject.nwx @@ -1,5 +1,5 @@ - + @@ -72,28 +72,28 @@ 0 0 - + Timeline ROOT TIMELINE New False - + Object ROOT OBJECT New False - + Custom1 ROOT CUSTOM New False - + Custom2 ROOT CUSTOM diff --git a/tests/test_project.py b/tests/test_project.py index c2f4078d..6723dcc7 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -29,6 +29,7 @@ def testProjectNew(nwTempProj,nwRef,nwTemp): assert theProject.newProject() assert theProject.setProjectPath(nwTempProj) assert theProject.saveProject() + assert theProject.closeProject() assert cmpFiles(projFile, refFile, [2]) @pytest.mark.project @@ -41,9 +42,21 @@ def testProjectSave(nwTempProj,nwRef): projFile = path.join(nwTempProj,"nwProject.nwx") refFile = path.join(nwRef,"proj","1_nwProject.nwx") assert theProject.saveProject() + assert theProject.closeProject() assert cmpFiles(projFile, refFile, [2]) assert not theProject.projChanged +@pytest.mark.project +def testProjectOpenTwice(nwTempProj,nwRef): + projFile = path.join(nwTempProj,"nwProject.nwx") + refFile = path.join(nwRef,"proj","1_nwProject.nwx") + assert theProject.openProject(projFile) + assert not theProject.openProject(projFile) + assert theProject.openProject(projFile, overrideLock=True) + assert theProject.saveProject() + assert theProject.closeProject() + assert cmpFiles(projFile, refFile, [2]) + @pytest.mark.project def testProjectNewRoot(nwTempProj,nwRef): projFile = path.join(nwTempProj,"nwProject.nwx") @@ -59,6 +72,7 @@ def testProjectNewRoot(nwTempProj,nwRef): assert isinstance(theProject.newRoot("Custom2", nwItemClass.CUSTOM), str) assert theProject.projChanged assert theProject.saveProject() + assert theProject.closeProject() assert cmpFiles(projFile, refFile, [2]) assert not theProject.projChanged @@ -96,6 +110,8 @@ def testIndexScanThis(nwTempProj): assert str(theBits) == "['@tag', 'this', 'and this']" assert str(thePos) == "[0, 6, 12]" + assert theProject.closeProject() + @pytest.mark.project def testBuildIndex(nwTempProj): projFile = path.join(nwTempProj,"nwProject.nwx") @@ -117,3 +133,4 @@ def testBuildIndex(nwTempProj): assert theIndex.buildNovelList() assert str(theIndex.novelList) == "[[1, 1, 'Novel', 'SCENE'], [3, 2, 'Chapter', 'SCENE'], [5, 3, 'Scene', 'SCENE'], [7, 4, 'Section', 'SCENE']]" assert str(theIndex.novelOrder) == "['31489056e0916:1', '31489056e0916:3', '31489056e0916:5', '31489056e0916:7']" + assert theProject.closeProject()