diff --git a/nw/config.py b/nw/config.py index 50941257..fa993523 100644 --- a/nw/config.py +++ b/nw/config.py @@ -89,6 +89,11 @@ class Config: self.spellLanguage = "en_GB" + # Backup + self.backupPath = "" + self.backupOnClose = False + self.askBeforeBackup = True + # Path self.recentList = [""]*10 @@ -167,22 +172,28 @@ class Config: ## Editor cnfSec = "Editor" - self.textFont = self._parseLine(cnfParse, cnfSec, "textfont", self.CNF_STR, self.textFont) - self.textSize = self._parseLine(cnfParse, cnfSec, "textsize", self.CNF_INT, self.textSize) - self.textFixedW = self._parseLine(cnfParse, cnfSec, "fixedwidth", self.CNF_BOOL, self.textFixedW) - self.textWidth = self._parseLine(cnfParse, cnfSec, "width", self.CNF_INT, self.textWidth) - self.textMargin = self._parseLine(cnfParse, cnfSec, "margin", self.CNF_INT, self.textMargin) - self.tabWidth = self._parseLine(cnfParse, cnfSec, "tabwidth", self.CNF_INT, self.tabWidth) - self.doJustify = self._parseLine(cnfParse, cnfSec, "justify", self.CNF_BOOL, self.doJustify) - self.autoSelect = self._parseLine(cnfParse, cnfSec, "autoselect", self.CNF_BOOL, self.autoSelect) - self.doReplace = self._parseLine(cnfParse, cnfSec, "autoreplace", self.CNF_BOOL, self.doReplace) - self.doReplaceSQuote = self._parseLine(cnfParse, cnfSec, "repsquotes", self.CNF_BOOL, self.doReplaceSQuote) - self.doReplaceDQuote = self._parseLine(cnfParse, cnfSec, "repdquotes", self.CNF_BOOL, self.doReplaceDQuote) - self.doReplaceDash = self._parseLine(cnfParse, cnfSec, "repdash", self.CNF_BOOL, self.doReplaceDash) - self.doReplaceDots = self._parseLine(cnfParse, cnfSec, "repdots", self.CNF_BOOL, self.doReplaceDots) - self.fmtSingleQuotes = self._parseLine(cnfParse, cnfSec, "fmtsinglequote", self.CNF_LIST, self.fmtSingleQuotes) - self.fmtDoubleQuotes = self._parseLine(cnfParse, cnfSec, "fmtdoublequote", self.CNF_LIST, self.fmtDoubleQuotes) - self.spellLanguage = self._parseLine(cnfParse, cnfSec, "spellcheck", self.CNF_STR, self.spellLanguage) + self.textFont = self._parseLine(cnfParse, cnfSec, "textfont", self.CNF_STR, self.textFont) + self.textSize = self._parseLine(cnfParse, cnfSec, "textsize", self.CNF_INT, self.textSize) + self.textFixedW = self._parseLine(cnfParse, cnfSec, "fixedwidth", self.CNF_BOOL, self.textFixedW) + self.textWidth = self._parseLine(cnfParse, cnfSec, "width", self.CNF_INT, self.textWidth) + self.textMargin = self._parseLine(cnfParse, cnfSec, "margin", self.CNF_INT, self.textMargin) + self.tabWidth = self._parseLine(cnfParse, cnfSec, "tabwidth", self.CNF_INT, self.tabWidth) + self.doJustify = self._parseLine(cnfParse, cnfSec, "justify", self.CNF_BOOL, self.doJustify) + self.autoSelect = self._parseLine(cnfParse, cnfSec, "autoselect", self.CNF_BOOL, self.autoSelect) + self.doReplace = self._parseLine(cnfParse, cnfSec, "autoreplace", self.CNF_BOOL, self.doReplace) + self.doReplaceSQuote = self._parseLine(cnfParse, cnfSec, "repsquotes", self.CNF_BOOL, self.doReplaceSQuote) + self.doReplaceDQuote = self._parseLine(cnfParse, cnfSec, "repdquotes", self.CNF_BOOL, self.doReplaceDQuote) + self.doReplaceDash = self._parseLine(cnfParse, cnfSec, "repdash", self.CNF_BOOL, self.doReplaceDash) + self.doReplaceDots = self._parseLine(cnfParse, cnfSec, "repdots", self.CNF_BOOL, self.doReplaceDots) + self.fmtSingleQuotes = self._parseLine(cnfParse, cnfSec, "fmtsinglequote", self.CNF_LIST, self.fmtSingleQuotes) + self.fmtDoubleQuotes = self._parseLine(cnfParse, cnfSec, "fmtdoublequote", self.CNF_LIST, self.fmtDoubleQuotes) + self.spellLanguage = self._parseLine(cnfParse, cnfSec, "spellcheck", self.CNF_STR, self.spellLanguage) + + ## Backup + cnfSec = "Backup" + self.backupPath = self._parseLine(cnfParse, cnfSec, "backuppath", self.CNF_STR, self.backupPath) + self.backupOnClose = self._parseLine(cnfParse, cnfSec, "backuponclose", self.CNF_BOOL, self.backupOnClose) + self.askBeforeBackup = self._parseLine(cnfParse, cnfSec, "askbeforebackup", self.CNF_BOOL, self.askBeforeBackup) ## Path cnfSec = "Path" @@ -240,6 +251,13 @@ class Config: cnfParse.set(cnfSec,"fmtdoublequote",self._packList(self.fmtDoubleQuotes)) cnfParse.set(cnfSec,"spellcheck", str(self.spellLanguage)) + ## Backup + cnfSec = "Backup" + cnfParse.add_section(cnfSec) + cnfParse.set(cnfSec,"backuppath", str(self.backupPath)) + cnfParse.set(cnfSec,"backuponclose", str(self.backupOnClose)) + cnfParse.set(cnfSec,"askbeforebackup",str(self.askBeforeBackup)) + ## Path cnfSec = "Path" cnfParse.add_section(cnfSec) diff --git a/nw/gui/configeditor.py b/nw/gui/configeditor.py index d90424f8..e6851b22 100644 --- a/nw/gui/configeditor.py +++ b/nw/gui/configeditor.py @@ -21,7 +21,7 @@ from PyQt5.QtSvg import QSvgWidget from PyQt5.QtWidgets import ( QDialog, QHBoxLayout, QVBoxLayout, QFormLayout, QLineEdit, QPlainTextEdit, QLabel, QWidget, QTabWidget, QDialogButtonBox, QSpinBox, QGroupBox, QComboBox, QMessageBox, - QCheckBox, QGridLayout, QFontComboBox + QCheckBox, QGridLayout, QFontComboBox, QPushButton, QFileDialog ) from nw.enum import nwAlert from nw.constants import nwQuotes @@ -183,12 +183,47 @@ class GuiConfigEditGeneral(QWidget): self.autoSaveForm.addWidget(self.autoSaveProj, 1, 1) self.autoSaveForm.addWidget(QLabel("seconds"), 1, 2) + # Backup + self.projBackup = QGroupBox("Backup", self) + self.projBackupForm = QGridLayout(self) + self.projBackup.setLayout(self.projBackupForm) + + self.projBackupPath = QLineEdit() + if path.isdir(self.mainConf.backupPath): + self.projBackupPath.setText(self.mainConf.backupPath) + + self.projBackupGetPath = QPushButton(QIcon.fromTheme("folder"),"") + self.projBackupGetPath.clicked.connect(self._backupFolder) + + self.projBackupClose = QCheckBox(self) + self.projBackupClose.setToolTip("Backup automatically on project close.") + if self.mainConf.backupOnClose: + self.projBackupClose.setCheckState(Qt.Checked) + else: + self.projBackupClose.setCheckState(Qt.Unchecked) + + self.projBackupAsk = QCheckBox(self) + self.projBackupAsk.setToolTip("Ask before backup.") + if self.mainConf.askBeforeBackup: + self.projBackupAsk.setCheckState(Qt.Checked) + else: + self.projBackupAsk.setCheckState(Qt.Unchecked) + + self.projBackupForm.addWidget(QLabel("Backup Folder"), 0, 0) + self.projBackupForm.addWidget(self.projBackupPath, 0, 1, 1, 3) + self.projBackupForm.addWidget(self.projBackupGetPath, 0, 4) + self.projBackupForm.addWidget(QLabel("Run on Close"), 1, 0) + self.projBackupForm.addWidget(self.projBackupClose, 1, 1) + self.projBackupForm.addWidget(QLabel("Ask Before Backuo"), 1, 2) + self.projBackupForm.addWidget(self.projBackupAsk, 1, 3) + # Assemble - self.outerBox.addWidget(self.guiLook, 0, 0) - self.outerBox.addWidget(self.spellLang, 1, 0) - self.outerBox.addWidget(self.autoSave, 2, 0) + self.outerBox.addWidget(self.guiLook, 0, 0) + self.outerBox.addWidget(self.spellLang, 1, 0) + self.outerBox.addWidget(self.autoSave, 2, 0) + self.outerBox.addWidget(self.projBackup, 3, 0) self.outerBox.setColumnStretch(2, 1) - self.outerBox.setRowStretch(3, 1) + self.outerBox.setRowStretch(4, 1) self.setLayout(self.outerBox) return @@ -198,25 +233,52 @@ class GuiConfigEditGeneral(QWidget): validEntries = True needsRestart = False - guiTheme = self.guiLookTheme.currentData() - guiSyntax = self.guiLookSyntax.currentData() - spellLanguage = self.spellLangList.currentData() - autoSaveDoc = self.autoSaveDoc.value() - autoSaveProj = self.autoSaveProj.value() + guiTheme = self.guiLookTheme.currentData() + guiSyntax = self.guiLookSyntax.currentData() + spellLanguage = self.spellLangList.currentData() + autoSaveDoc = self.autoSaveDoc.value() + autoSaveProj = self.autoSaveProj.value() + backupPath = self.projBackupPath.text() + backupOnClose = self.projBackupClose.isChecked() + askBeforeBackup = self.projBackupAsk.isChecked() # Check if restart is needed needsRestart |= self.mainConf.guiTheme != guiTheme - self.mainConf.guiTheme = guiTheme - self.mainConf.guiSyntax = guiSyntax - self.mainConf.spellLanguage = spellLanguage - self.mainConf.autoSaveDoc = autoSaveDoc - self.mainConf.autoSaveProj = autoSaveProj + self.mainConf.guiTheme = guiTheme + self.mainConf.guiSyntax = guiSyntax + self.mainConf.spellLanguage = spellLanguage + self.mainConf.autoSaveDoc = autoSaveDoc + self.mainConf.autoSaveProj = autoSaveProj + self.mainConf.backupPath = backupPath + self.mainConf.backupOnClose = backupOnClose + self.mainConf.askBeforeBackup = askBeforeBackup self.mainConf.confChanged = True return validEntries, needsRestart + ## + # Internal Functions + ## + + def _backupFolder(self): + + currDir = self.projBackupPath.text() + if not path.isdir(currDir): + currDir = "" + + dlgOpt = QFileDialog.Options() + dlgOpt |= QFileDialog.ShowDirsOnly + dlgOpt |= QFileDialog.DontUseNativeDialog + newDir = QFileDialog.getExistingDirectory( + self,"Backup Directory",currDir,options=dlgOpt + ) + if newDir: + self.projBackupPath.setText(newDir) + return True + return False + # END Class GuiConfigEditGeneral class GuiConfigEditEditor(QWidget): diff --git a/nw/gui/mainmenu.py b/nw/gui/mainmenu.py index 917af5f8..6fba5cfd 100644 --- a/nw/gui/mainmenu.py +++ b/nw/gui/mainmenu.py @@ -507,6 +507,12 @@ class GuiMainMenu(QMenuBar): menuItem.triggered.connect(self.theParent.rebuildIndex) self.toolsMenu.addAction(menuItem) + # Tools > Backup + menuItem = QAction(QIcon.fromTheme("drive-harddisk"), "Backup Project", self) + menuItem.setStatusTip("Backup Project") + menuItem.triggered.connect(self.theParent.backupProject) + self.toolsMenu.addAction(menuItem) + # Tools > Settings menuItem = QAction(QIcon.fromTheme("preferences-system"), "Preferences", self) menuItem.setStatusTip("Preferences") diff --git a/nw/gui/projecteditor.py b/nw/gui/projecteditor.py index ab15c148..88da6ca6 100644 --- a/nw/gui/projecteditor.py +++ b/nw/gui/projecteditor.py @@ -21,7 +21,7 @@ from PyQt5.QtSvg import QSvgWidget from PyQt5.QtWidgets import ( QDialog, QHBoxLayout, QVBoxLayout, QFormLayout, QLineEdit, QPlainTextEdit, QLabel, QWidget, QTabWidget, QDialogButtonBox, QListWidget, QListWidgetItem, QPushButton, - QColorDialog, QAbstractItemView, QTreeWidget, QTreeWidgetItem + QColorDialog, QAbstractItemView, QTreeWidget, QTreeWidgetItem, QCheckBox ) from nw.enum import nwAlert @@ -81,9 +81,11 @@ class GuiProjectEditor(QDialog): projName = self.tabMain.editName.text() bookTitle = self.tabMain.editTitle.text() bookAuthors = self.tabMain.editAuthors.toPlainText() + doBackup = self.tabMain.doBackup.isChecked() self.theProject.setProjectName(projName) self.theProject.setBookTitle(bookTitle) self.theProject.setBookAuthors(bookAuthors) + self.theProject.setProjBackup(doBackup) if self.tabStatus.colChanged: statusCol = self.tabStatus.getNewList() @@ -116,13 +118,19 @@ class GuiProjectEditMain(QWidget): self.theParent = theParent self.theProject = theProject self.mainForm = QFormLayout() + self.backupBox = QHBoxLayout() self.editName = QLineEdit() self.editTitle = QLineEdit() self.editAuthors = QPlainTextEdit() + self.doBackup = QCheckBox(self) - self.mainForm.addRow("Working Title", self.editName) - self.mainForm.addRow("Book Title", self.editTitle) - self.mainForm.addRow("Book Authors", self.editAuthors) + self.mainForm.addRow("Working Title", self.editName) + self.mainForm.addRow("Book Title", self.editTitle) + self.mainForm.addRow("Book Authors", self.editAuthors) + self.mainForm.addRow(self.backupBox) + self.backupBox.addStretch(1) + self.backupBox.addWidget(QLabel("Backup on Close")) + self.backupBox.addWidget(self.doBackup) self.editName.setText(self.theProject.projName) self.editTitle.setText(self.theProject.bookTitle) @@ -130,8 +138,13 @@ class GuiProjectEditMain(QWidget): for bookAuthor in self.theProject.bookAuthors: bookAuthors += bookAuthor+"\n" self.editAuthors.setPlainText(bookAuthors) + if self.theProject.doBackup: + self.doBackup.setCheckState(Qt.Checked) + else: + self.doBackup.setCheckState(Qt.Unchecked) self.setLayout(self.mainForm) + self.editAuthors.setMaximumHeight(120) return diff --git a/nw/gui/winmain.py b/nw/gui/winmain.py index 374668a6..7b9bb03e 100644 --- a/nw/gui/winmain.py +++ b/nw/gui/winmain.py @@ -36,6 +36,7 @@ from nw.project.project import NWProject from nw.project.document import NWDoc from nw.project.item import NWItem from nw.project.index import NWIndex +from nw.project.backup import NWBackup from nw.tools.wordcount import countWords from nw.theme import Theme from nw.enum import nwItemType, nwAlert @@ -112,7 +113,7 @@ class GuiMain(QMainWindow): # Set Main Window Elements self.setMenuBar(self.mainMenu) self.setStatusBar(self.statusBar) - self.statusBar.showMessage("Ready") + self.statusBar.setStatus("Ready") # Set Up Autosaving Project Timer self.asProjTimer = QTimer() @@ -206,18 +207,33 @@ class GuiMain(QMainWindow): if msgRes != QMessageBox.Yes: return False - self.closeDocument() + if self.docEditor.docChanged: + self.saveDocument() + if self.theProject.projChanged: - saveOK = self.saveProject() + saveOK = self.saveProject() + doBackup = False + if self.theProject.doBackup and self.mainConf.backupOnClose: + doBackup = True + if self.mainConf.showGUI and self.mainConf.askBeforeBackup: + msgBox = QMessageBox() + msgRes = msgBox.question( + self, "Backup Project", "Backup current project?" + ) + if msgRes != QMessageBox.Yes: + doBackup = False + if doBackup: + self.backupProject() else: saveOK = True if saveOK: + self.closeDocument() self.theProject.closeProject() self.theIndex.clearIndex() self.clearGUI() self.hasProject = False - + return saveOK def openProject(self, projFile=None): @@ -279,6 +295,11 @@ class GuiMain(QMainWindow): return True + def backupProject(self): + theBackup = NWBackup(self, self.theProject) + theBackup.zipIt() + return True + ## # Document Actions ## diff --git a/nw/project/backup.py b/nw/project/backup.py new file mode 100644 index 00000000..75235b85 --- /dev/null +++ b/nw/project/backup.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +"""novelWriter Project Backup + + novelWriter – Project Backup +============================== + Class handling project backups + + File History: + Created: 2019-06-16 [0.1.5] + +""" + +import logging +import nw + +from os import path, mkdir, listdir +from shutil import make_archive +from datetime import datetime + +from nw.enum import nwAlert + +logger = logging.getLogger(__name__) + +class NWBackup(): + + def __init__(self, theParent, theProject): + + self.mainConf = nw.CONFIG + self.theParent = theParent + self.theProject = theProject + + return + + def zipIt(self): + + if self.mainConf.backupPath is None: + self.theParent.makeAlert("Cannot backup project because no backup path is set.",nwAlert.WARN) + return False + + if self.theProject.projName is None: + self.theParent.makeAlert("Cannot backup project because no project name is set.",nwAlert.WARN) + return False + + logger.info("Backing up project") + self.theParent.statusBar.setStatus("Backing up project ...") + + archName = "" + for c in self.theProject.projName: + if c.isalnum(): + archName += c + + archName = archName+"_"+datetime.now().strftime("%Y%m%d-%H%M%S") + baseName = path.join(self.mainConf.backupPath, archName) + + try: + make_archive(baseName, "zip", self.theProject.projPath, ".") + except Exception as e: + self.theParent.makeAlert(["Could not write backup archive.",str(e)],nwAlert.ERROR) + return False + + self.theParent.statusBar.setStatus("Project backup complete") + + return True + +# END Class NWBackup diff --git a/nw/project/project.py b/nw/project/project.py index 8cc6bf9b..bdc1b8b3 100644 --- a/nw/project/project.py +++ b/nw/project/project.py @@ -67,6 +67,7 @@ class NWProject(): self.lastViewed = None self.lastWCount = 0 self.currWCount = 0 + self.doBackup = True # Set Defaults self.clearProject() @@ -221,6 +222,8 @@ class NWProject(): elif xItem.tag == "author": logger.verbose("Author: '%s'" % xItem.text) self.bookAuthors.append(xItem.text) + elif xItem.tag == "backup": + self.doBackup = checkBool(xItem.text,False) elif xChild.tag == "settings": logger.debug("Found project settings") for xItem in xChild: @@ -295,6 +298,7 @@ class NWProject(): self._saveProjectValue(xProject,"name", self.projName, True) self._saveProjectValue(xProject,"title", self.bookTitle, True) self._saveProjectValue(xProject,"author",self.bookAuthors) + self._saveProjectValue(xProject,"backup",self.doBackup) # Save Project Settings xSettings = etree.SubElement(nwXML,"settings") @@ -375,6 +379,24 @@ class NWProject(): self.setProjectChanged(True) return True + def setProjBackup(self, doBackup): + self.doBackup = False + if doBackup: + if not path.isdir(self.mainConf.backupPath): + self.theParent.makeAlert( + "You must set a valid backup path in preferences
to use the automatic project backup feature.", + nwAlert.ERROR + ) + return False + if self.projName == "": + self.theParent.makeAlert( + "You must set a valid project name in project settings
to use the automatic project backup feature.", + nwAlert.ERROR + ) + return False + self.doBackup = True + return True + def setSpellCheck(self, theMode): if self.spellCheck != theMode: self.spellCheck = theMode diff --git a/sample/sampleNovel/nwProject.nwx b/sample/sampleNovel/nwProject.nwx index 0081d96a..d7d364cd 100644 --- a/sample/sampleNovel/nwProject.nwx +++ b/sample/sampleNovel/nwProject.nwx @@ -1,10 +1,11 @@ - + Sample Project Sample Project Jane Smith Jay Doh + False True @@ -80,7 +81,7 @@ 736 137 6 - 69 + 82 New File diff --git a/tests/reference/gui/0_nwProject.nwx b/tests/reference/gui/0_nwProject.nwx index 19443161..c3337adb 100644 --- a/tests/reference/gui/0_nwProject.nwx +++ b/tests/reference/gui/0_nwProject.nwx @@ -1,8 +1,9 @@ - + + True False diff --git a/tests/reference/gui/1_nwProject.nwx b/tests/reference/gui/1_nwProject.nwx index 67cd383d..193087e6 100644 --- a/tests/reference/gui/1_nwProject.nwx +++ b/tests/reference/gui/1_nwProject.nwx @@ -1,8 +1,9 @@ - + + True True diff --git a/tests/reference/gui/2_nwProject.nwx b/tests/reference/gui/2_nwProject.nwx index dd2b6333..02c51167 100644 --- a/tests/reference/gui/2_nwProject.nwx +++ b/tests/reference/gui/2_nwProject.nwx @@ -5,6 +5,7 @@ Project Title Jane Doe John Doh + True False diff --git a/tests/reference/gui/3_nwProject.nwx b/tests/reference/gui/3_nwProject.nwx index 7ae54e07..503c011c 100644 --- a/tests/reference/gui/3_nwProject.nwx +++ b/tests/reference/gui/3_nwProject.nwx @@ -3,6 +3,7 @@ + True False diff --git a/tests/reference/novelwriter.conf b/tests/reference/novelwriter.conf index c0eb784e..8b6bff87 100644 --- a/tests/reference/novelwriter.conf +++ b/tests/reference/novelwriter.conf @@ -1,5 +1,5 @@ [Main] -timestamp = 2019-06-15 23:10:43 +timestamp = 2019-06-27 20:26:43 theme = default syntax = default_light @@ -32,6 +32,11 @@ fmtsinglequote = ‘, ’ fmtdoublequote = “, ” spellcheck = en_GB +[Backup] +backuppath = +backuponclose = False +askbeforebackup = True + [Path] recent0 = recent1 = diff --git a/tests/reference/proj/1_nwProject.nwx b/tests/reference/proj/1_nwProject.nwx index a9c82564..4960024b 100644 --- a/tests/reference/proj/1_nwProject.nwx +++ b/tests/reference/proj/1_nwProject.nwx @@ -3,6 +3,7 @@ + True False diff --git a/tests/reference/proj/2_nwProject.nwx b/tests/reference/proj/2_nwProject.nwx index f7bd904d..b78c14a4 100644 --- a/tests/reference/proj/2_nwProject.nwx +++ b/tests/reference/proj/2_nwProject.nwx @@ -3,6 +3,7 @@ + True False diff --git a/tests/test_gui.py b/tests/test_gui.py index 5954ebbe..f7840805 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -287,6 +287,7 @@ def testProjectEditor(qtbot, nwTempGUI, nwRef): # Create new, save, open project nwGUI.theProject.handleSeed = 42 assert nwGUI.newProject(nwTempGUI, True) + nwGUI.mainConf.backupPath = nwTempGUI projEdit = GuiProjectEditor(nwGUI, nwGUI.theProject) qtbot.addWidget(projEdit)