diff --git a/.travis.yml b/.travis.yml
index d40b1c68..de4a683a 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,10 +1,9 @@
os: linux
-dist: xenial
+dist: bionic
services:
- xvfb
language: python
cache: bundler
-sudo: required
addons:
apt:
packages:
@@ -12,9 +11,11 @@ addons:
- python3-pyqt5
- python3-pyqt5.qtsvg
python:
- - "3.5"
+ - "3.6"
- "3.7"
+ - "3.8"
install:
+ - pip install --upgrade pip
- pip install -r requirements.txt
# - pip install pytest-faulthandler
- pip install pytest-xvfb
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 236d217a..4ae258ab 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,46 @@
# novelWriter ChangeLog
+## Version 0.4.5 [2020-02-17]
+
+**Features**
+
+* A project can now be opened from the command line by providing the project path to the launching script. PRs #164 and #166.
+
+**User Interface**
+
+* Added functionality to split a document into a folder of multiple documents, and also to merge a folder of documents into a single document. PRs #159 and #163.
+* It is now possible to permanently delete files from the Trash folder. This can be done file-by-file or by using the Empty Trash option in the menu. PRs #159 and #163.
+* When running the spell checker, a wait cursor is displayed. This will alert the user that novelWriter is working on something when, for instance, a very large document is opened and initial spell checking is running. PR #158.
+
+**Bug Fixes**
+
+* Fixed a few keyboard shortcuts that were not working in distraction free mode. PR #157.
+* Added a check to ensure the user does not drag and drop an item into the Orphaned Items folder. Since this folder is not an actual project item, novelWriter would crash when trying to change the dropped item's parent item to the Orphaned Items folder. Now, instead, the drop event is cancelled if the target folder is Orphaned Items. PR #163.
+
+**Code Improvements**
+
+* The way project files are saved has been altered slightly. When a project file or document file is saved, the data is first streamed to a temp file. Then the old storage file is renamed to .bak, and and the temp file is renamed to the correct storage file name. This ensures that the storage file is only replaced after a complete and successful write. PR #165.
+* The cache folder has been removed. It was used to store the 10 most recent versions of the project file. Instead, the previous project file is renamed to .bak, and can be restored if opening from the latest project file fails. Any additional restore capabilities should be ensured by backup solutions, either the internal simple zip backup, or other third party tools. PR #165.
+* The dependency on the Python package `appdirs` has been dropped. It was used only for extracting the path to the user's config folder, a feature which is also provided by Qt. PR #169.
+
+## Version 0.4.4 [2020-02-17]
+
+* Botched release. Replaced with 0.4.5
+
+## Version 0.4.3 [2019-11-24]
+
+**User Interface**
+
+* Added keyboard shortcuts and menu entries for formatting headers, comments, and removing block formats. PR #155.
+* Disable re-highlighting of open file when resizing window. This is potentially a slow process if the spell checker is on and the file is large. There is no need to do this just for reflowing text, so it is now disabled on resize events. PR #153 fixing issue #150.
+* Improved the speed of the syntax highlighter by about 40% by not using regular expressions for highlighting block formats and by skipping empty lines entirely. PR #154.
+
+**Bug Fixes**
+
+* Fixed an issue when closing the import file dialog without selecting a file, the import would procede, but fail on file not found. The import is now cancelled when there is no file selected. PR #149.
+* Fixed an issue with markdown export did not take into account hard line breaks. PR #152 fixing issue #151.
+* Fixed a crash when running file status check when the project contains orphaned files. PR #152.
+
## Version 0.4.2 [2019-11-17]
**User Interface**
diff --git a/README.md b/README.md
index 0325cb84..d0e772cf 100644
--- a/README.md
+++ b/README.md
@@ -70,14 +70,13 @@ For the apt package manager on Debian systems, the following Python3 packages ar
* `python3-pyqt5` for the GUI
* `python3-pyqt5.qtsvg` may need to be installed separately
-* `python3-appdirs` for locating the system's config folder
* `python3-lxml` for writing project files
These are optional, but recommended:
* `python3-enchant` for better spell checking
* `python3-latexcodec` for escaping unicode characters in LaTeX export
-* `python3-pandoc` for additional exports to Word, Open Office, eBooks, etc.
+* `python3-pypandoc` for additional exports to Word, Open Office, eBooks, etc.
Alternatively, the packages can be installed with `pip` by running
```
@@ -88,7 +87,6 @@ in the application folder.
You can also do them one at a time, skipping the ones you don't need:
```
python3 -m pip install pyqt5
-python3 -m pip install appdirs
python3 -m pip install lxml
python3 -m pip install pyenchant
python3 -m pip install latexcodec
diff --git a/docs/source/conf.py b/docs/source/conf.py
index 8cdb4fad..51540235 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -20,13 +20,13 @@
# -- Project information -----------------------------------------------------
project = "novelWriter"
-copyright = "2018-2019, Veronica Berglyd Olsen"
+copyright = "2018-2020, Veronica Berglyd Olsen"
author = "Veronica Berglyd Olsen"
# The short X.Y version
-version = "0.4.2"
+version = "0.4.5"
# The full version, including alpha/beta/rc tags
-release = "0.4.2"
+release = "0.4.5"
# -- General configuration ---------------------------------------------------
diff --git a/docs/source/interface.txt b/docs/source/interface.txt
index 3703bf22..994f5571 100644
--- a/docs/source/interface.txt
+++ b/docs/source/interface.txt
@@ -37,9 +37,9 @@ The editor also has a minimal set of keywords used for setting tags and referenc
"``## Title``", "Heading level two. The space after the # is mandatory."
"``### Title``", "Heading level three. The space after the # is mandatory."
"``#### Title``", "Heading level four. The space after the # is mandatory."
- "``**text**``", "The text is renderred as bold text."
- "``_text_``", "The text is renderred as italics text."
- "``__text__``", "The text is renderred as underlined text."
+ "``**text**``", "The text is rendered as bold text."
+ "``_text_``", "The text is rendered as italicized text."
+ "``__text__``", "The text is rendered as underlined text."
"``% text...``", "A comment. The text is not exported, seen in viewer, or counted towards word counts."
"``@keyword: value``", "A keyword argument followed by a value, or a comma separated list of values."
diff --git a/docs/source/projects.txt b/docs/source/projects.txt
index be2a75ae..2ece4f4b 100644
--- a/docs/source/projects.txt
+++ b/docs/source/projects.txt
@@ -22,7 +22,7 @@ These other root folder types are intended for your notes on the various element
Using these is of course entirely optional.
A new project will not have all of the root folders present, but you can add the ones you want from :menuselection:`Project --> Create Root Folder`.
-The root folders are intended for the follwing use, but aside from the Novel folder, not restrictions apply.
+The root folders are intended for the following use, but aside from the Novel folder, no restrictions apply.
* **Novel:** The root folder of all text that goes into the final novel.
This class of files have other rules and features than other files in the project.
@@ -44,7 +44,7 @@ Orphaned Documents
------------------
In the event the editor crashes or otherwise exits without saving the project state, files that have been added to the project tree and are saved to disk will appear in a special "Orphaned Items" root folder next time the application is started.
-These orphaned files will not have any meta data associated with them, so the label and other information has to be set again, and the files moved back to the correct location in the project.
+These orphaned files will not have any meta data associated with them, so the label and other information will have to be set again, and the files moved back to the correct location in the project.
Using Folders in the Project Tree
---------------------------------
diff --git a/docs/source/started.txt b/docs/source/started.txt
index 747a8c27..93e75940 100644
--- a/docs/source/started.txt
+++ b/docs/source/started.txt
@@ -6,8 +6,8 @@ You can download novelWriter from https://github.com/vkbo/novelWriter/releases
Latest version is |version|:
-* ZIP file: https://github.com/vkbo/novelWriter/archive/v0.3.2.zip
-* TAR file: https://github.com/vkbo/novelWriter/archive/v0.3.2.tar.gz
+* ZIP file: https://github.com/vkbo/novelWriter/archive/v0.4.5.zip
+* TAR file: https://github.com/vkbo/novelWriter/archive/v0.4.5.tar.gz
Extract the archive to a location of your choice.
@@ -26,7 +26,6 @@ On some operating systems you need to use ``python3`` instead of ``python``.
The following Python packages are required to run novelWriter:
* ``pyqt5`` for the GUI
-* ``appdirs`` for locating the system's config folder
* ``lxml`` for writing project files
.. note::
diff --git a/docs/source/technical.txt b/docs/source/technical.txt
index 9c81d7c9..afee5551 100644
--- a/docs/source/technical.txt
+++ b/docs/source/technical.txt
@@ -28,7 +28,7 @@ The project XML file is suitable for diff tools and version control, although a
Project Documents
-----------------
-The project documents are saved in folders staring with ``data_``.
+The project documents are saved in folders starting with ``data_``.
Each document has a file handle taken from the first 13 characters of a SHA256 hash of the system time when the file was first created.
The documents are saved with a folder and filename derived from this hash.
If you wish to find the physical location of a file in the project, you can either look it up in the project XML file, or select :menuselection:`Document --> Show File Details` in the menu when having the document open.
diff --git a/install.py b/install.py
index 14356f23..2d05835d 100755
--- a/install.py
+++ b/install.py
@@ -63,8 +63,8 @@ else:
instOpt = [
"--name=novelWriter",
"--onefile",
- "--add-data=%s%s%s" % (os.path.join("nw", "themes"), dotDot,"themes"),
- "--add-data=%s%s%s" % (os.path.join("nw", "graphics"),dotDot,"graphics"),
+ "--add-data=%s%s%s" % (os.path.join("nw", "assets", "themes"), dotDot,"themes"),
+ "--add-data=%s%s%s" % (os.path.join("nw", "assets", "graphics"), dotDot,"graphics"),
"--icon=%s" % os.path.join("nw", "assets", "icons", "novelWriter.ico"),
]
if buildWindowed:
diff --git a/novelWriter.py b/novelWriter.py
index 0723076f..3aa88038 100755
--- a/novelWriter.py
+++ b/novelWriter.py
@@ -3,8 +3,8 @@
import sys
-if sys.hexversion < 0x030500F0:
- print("ERROR: At least Python 3.5 is required")
+if sys.hexversion < 0x030600F0:
+ print("ERROR: At least Python 3.6 is required")
sys.exit(1)
try:
@@ -27,12 +27,6 @@ except:
print("ERROR: Failed to load dependency python3-lxml")
sys.exit(1)
-try:
- import appdirs
-except:
- print("ERROR: Failed to load dependency python3-appdirs")
- sys.exit(1)
-
if __name__ == "__main__":
import nw
nw.main(sys.argv[1:])
diff --git a/nw/__init__.py b/nw/__init__.py
index 7f1e2e6d..f3766e6c 100644
--- a/nw/__init__.py
+++ b/nw/__init__.py
@@ -23,10 +23,10 @@ from nw.config import Config
__package__ = "novelWriter"
__author__ = "Veronica Berglyd Olsen"
-__copyright__ = "Copyright 2018–2019, Veronica Berglyd Olsen"
+__copyright__ = "Copyright 2018–2020, Veronica Berglyd Olsen"
__license__ = "GPLv3"
-__version__ = "0.4.2"
-__date__ = "2019-11-17"
+__version__ = "0.4.5"
+__date__ = "2020-02-17"
__maintainer__ = "Veronica Berglyd Olsen"
__email__ = "code@vkbo.net"
__status__ = "Development"
@@ -77,17 +77,17 @@ def main(sysArgs=None):
sysArgs = sys.argv[1:]
# Valid Input Options
- shortOpt = "hdiqtl:v"
+ shortOpt = "hdiql:v"
longOpt = [
"help",
"debug",
"info",
"verbose",
"quiet",
- "time",
"logfile=",
"version",
"config=",
+ "data=",
"testmode",
"style=",
]
@@ -103,10 +103,10 @@ def main(sysArgs=None):
" -d, --debug Print debug output.\n"
" --verbose Increase verbosity of debug output.\n"
" -q, --quiet Disable output to command line. Does not affect log file.\n"
- " -t, --time Shows time stamp in logging output.\n"
" -l, --logfile= Specify log file.\n"
" --style= Set Qt5 style flag. Defaults to 'Fusion'.\n"
" --config= Alternative config file.\n"
+ " --data= Alternative user data path.\n"
" --headless Do not display GUI. Useful for testing scripts.\n"
).format(
appname = __package__,
@@ -118,22 +118,25 @@ def main(sysArgs=None):
# Defaults
debugLevel = logging.WARN
debugStr = "{levelname:8} {message:}"
- timeStr = "[{asctime:}] "
logFile = ""
toFile = False
toStd = True
- showTime = False
confPath = None
+ dataPath = None
testMode = False
qtStyle = "Fusion"
+ cmdOpen = None
# Parse Options
try:
- inOpts, inArgs = getopt.getopt(sysArgs,shortOpt,longOpt)
+ inOpts, inRemain = getopt.getopt(sysArgs,shortOpt,longOpt)
except getopt.GetoptError:
print(helpMsg)
sys.exit(2)
+ if len(inRemain) > 0:
+ cmdOpen = inRemain[0]
+
for inOpt, inArg in inOpts:
if inOpt in ("-h","--help"):
print(helpMsg)
@@ -145,7 +148,7 @@ def main(sysArgs=None):
debugLevel = logging.INFO
elif inOpt in ("-d", "--debug"):
debugLevel = logging.DEBUG
- debugStr = "{name:>30}:{lineno:<4d} {levelname:8} {message:}"
+ debugStr = "[{asctime:}] {name:>30}:{lineno:<4d} {levelname:8} {message:}"
elif inOpt in ("-l","--logfile"):
logFile = inArg
toFile = True
@@ -153,21 +156,21 @@ def main(sysArgs=None):
toStd = False
elif inOpt in ("--verbose"):
debugLevel = VERBOSE
- elif inOpt in ("-t","--time"):
- showTime = True
elif inOpt in ("--style"):
qtStyle = inArg
elif inOpt in ("--config"):
confPath = inArg
+ elif inOpt in ("--data"):
+ dataPath = inArg
elif inOpt in ("--testmode"):
testMode = True
# Set Config Options
CONFIG.showGUI = not testMode
CONFIG.debugInfo = debugLevel < logging.INFO
+ CONFIG.cmdOpen = cmdOpen
# Set Logging
- if showTime: debugStr = timeStr+debugStr
logFmt = logging.Formatter(fmt=debugStr,datefmt="%Y-%m-%d %H:%M:%S",style="{")
if not logFile == "" and toFile:
@@ -189,13 +192,15 @@ def main(sysArgs=None):
logger.setLevel(debugLevel)
- CONFIG.initConfig(confPath)
+ CONFIG.initConfig(confPath, dataPath)
if testMode:
nwGUI = GuiMain()
return nwGUI
else:
nwApp = QApplication([__package__,("-style=%s" % qtStyle)])
+ nwApp.setApplicationName(__package__)
+ nwApp.setApplicationVersion(__version__)
nwGUI = GuiMain()
sys.exit(nwApp.exec_())
diff --git a/nw/assets/graphics/export.txt b/nw/assets/graphics/license.txt
similarity index 75%
rename from nw/assets/graphics/export.txt
rename to nw/assets/graphics/license.txt
index 1a65e8da..8a822fd2 100644
--- a/nw/assets/graphics/export.txt
+++ b/nw/assets/graphics/license.txt
@@ -1,3 +1,8 @@
FROM ICON SET: Typicons
LICENSE: Creative Commons (Attribution-Share Alike 3.0 Unported)
https://creativecommons.org/licenses/by-sa/3.0/
+
+Apllies to:
+export.svg
+merge.svg
+split.svg
diff --git a/nw/assets/graphics/merge.svg b/nw/assets/graphics/merge.svg
new file mode 100644
index 00000000..1b4c6ae9
--- /dev/null
+++ b/nw/assets/graphics/merge.svg
@@ -0,0 +1,31 @@
+
+
diff --git a/nw/assets/graphics/split.svg b/nw/assets/graphics/split.svg
new file mode 100644
index 00000000..c427eea6
--- /dev/null
+++ b/nw/assets/graphics/split.svg
@@ -0,0 +1,31 @@
+
+
diff --git a/nw/common.py b/nw/common.py
index 8aa0a0f0..6764d244 100644
--- a/nw/common.py
+++ b/nw/common.py
@@ -88,6 +88,25 @@ def colRange(rgbStart, rgbEnd, nStep):
return retCol
+def formatInt(theInt):
+ """Formats an integer with k, M, G etc.
+ """
+ postFix = ["k","M","G","T","P","E"]
+ theVal = float(theInt)
+
+ if theVal > 1000.0:
+ for pF in postFix:
+ theVal /= 1000.0
+ if theVal < 1000.0:
+ if theVal < 10.0:
+ return "%4.2f%s" % (theVal,pF)
+ elif theVal < 100.0:
+ return "%4.1f%s" % (theVal,pF)
+ else:
+ return "%3.0f%s" % (theVal,pF)
+
+ return "%d" % theInt
+
def splitVersionNumber(vString):
""" Splits a version string on the form aa.bb.cc into major, minor
and patch, and computes an integer value aabbcc.
diff --git a/nw/config.py b/nw/config.py
index 281fe863..94c30f71 100644
--- a/nw/config.py
+++ b/nw/config.py
@@ -12,15 +12,15 @@
import logging
import configparser
+import json
import sys
import nw
-from os import path, mkdir, makedirs
-from appdirs import user_config_dir
+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
+from PyQt5.QtCore import QT_VERSION_STR, QStandardPaths, QSysInfo
from nw.constants import nwFiles, nwUnicode
from nw.common import splitVersionNumber
@@ -41,10 +41,16 @@ class Config:
self.appHandle = nw.__package__.lower()
self.showGUI = True
self.debugInfo = False
+ self.cmdOpen = None
+
+ # Config Error Handling
+ self.hasError = False
+ self.errData = []
# Set Paths
self.confPath = None
self.confFile = None
+ self.dataPath = None
self.homePath = None
self.lastPath = None
self.appPath = None
@@ -57,7 +63,7 @@ class Config:
self.iconPath = None
# Set default values
- self.confChanged = False
+ self.confChanged = False
## General
self.guiTheme = "default"
@@ -111,9 +117,6 @@ class Config:
self.showRefPanel = True
self.viewComments = True
- ## Path
- self.recentList = [""]*10
-
# Check Qt5 Versions
verQt = splitVersionNumber(QT_VERSION_STR)
self.verQtString = QT_VERSION_STR
@@ -153,24 +156,48 @@ class Config:
else:
self.osUnknown = True
+ # Other System Info
+ self.hostName = QSysInfo.machineHostName()
+ self.kernelVer = QSysInfo.kernelVersion()
+
# Packages
self.hasEnchant = False
self.hasSymSpell = False
+ # Recent Cache
+ self.recentProj = {}
+
return
##
# Actions
##
- def initConfig(self, confPath=None):
+ def initConfig(self, confPath=None, dataPath=None):
+ """Initialise the config class. The manual setting of confPath
+ and dataPath is mainly intended for the test suite.
+ """
if confPath is None:
- self.confPath = user_config_dir(self.appHandle)
+ confRoot = QStandardPaths.writableLocation(QStandardPaths.ConfigLocation)
+ self.confPath = path.join(path.abspath(confRoot), self.appHandle)
else:
logger.info("Setting config from alternative path: %s" % confPath)
self.confPath = confPath
+ if dataPath is None:
+ if self.verQtValue >= 50400:
+ dataRoot = QStandardPaths.writableLocation(QStandardPaths.AppDataLocation)
+ else:
+ dataRoot = QStandardPaths.writableLocation(QStandardPaths.DataLocation)
+ self.dataPath = path.join(path.abspath(dataRoot), self.appHandle)
+ else:
+ logger.info("Setting data path from alternative path: %s" % dataPath)
+ self.dataPath = dataPath
+
+ logger.verbose("Config path: %s" % self.confPath)
+ logger.verbose("Data path: %s" % self.dataPath)
+
self.confFile = self.appHandle+".conf"
self.homePath = path.expanduser("~")
self.lastPath = self.homePath
@@ -185,20 +212,42 @@ class Config:
# If config folder does not exist, make it.
# This assumes that the os config folder itself exists.
- if self.osWindows:
- if not path.isdir(self.confPath):
- makedirs(self.confPath)
- else:
- if not path.isdir(self.confPath):
+ if not path.isdir(self.confPath):
+ try:
mkdir(self.confPath)
+ except Exception as e:
+ logger.error("Could not create folder: %s" % self.confPath)
+ logger.error(str(e))
+ self.hasError = True
+ self.errData.append("Could not create folder: %s" % self.confPath)
+ self.errData.append(str(e))
+ self.confPath = None
# Check if config file exists
- if path.isfile(path.join(self.confPath,self.confFile)):
- # If it exists, load it
- self.loadConfig()
- else:
- # If it does not exist, save a copy of the default values
- self.saveConfig()
+ if self.confPath is not None:
+ if path.isfile(path.join(self.confPath,self.confFile)):
+ # If it exists, load it
+ self.loadConfig()
+ else:
+ # If it does not exist, save a copy of the default values
+ self.saveConfig()
+
+ # If data folder does not exist, make it.
+ # This assumes that the os data folder itself exists.
+ if self.dataPath is not None:
+ if not path.isdir(self.dataPath):
+ try:
+ mkdir(self.dataPath)
+ except Exception as e:
+ logger.error("Could not create folder: %s" % self.dataPath)
+ logger.error(str(e))
+ self.hasError = True
+ self.errData.append("Could not create folder: %s" % self.dataPath)
+ self.errData.append(str(e))
+ self.dataPath = None
+
+ # Load recent projects cache
+ self.loadRecentCache()
# Check the availability of optional packages
self._checkOptionalPackages()
@@ -220,6 +269,10 @@ class Config:
)
except Exception as e:
logger.error("Could not load config file")
+ logger.error(str(e))
+ self.hasError = True
+ self.errData.append("Could not load config file")
+ self.errData.append(str(e))
return False
## Main
@@ -353,10 +406,6 @@ class Config:
self.lastPath = self._parseLine(
cnfParse, cnfSec, "lastpath", self.CNF_STR, self.lastPath
)
- for i in range(10):
- self.recentList[i] = self._parseLine(
- cnfParse, cnfSec, "recent%d" % i,self.CNF_STR, self.recentList[i]
- )
# Check Certain Values for None
self.spellLanguage = self._checkNone(self.spellLanguage)
@@ -435,8 +484,6 @@ class Config:
cnfSec = "Path"
cnfParse.add_section(cnfSec)
cnfParse.set(cnfSec,"lastpath", str(self.lastPath))
- for i in range(10):
- cnfParse.set(cnfSec,"recent%d" % i, str(self.recentList[i]))
# Write config file
try:
@@ -444,24 +491,93 @@ class Config:
self.confChanged = False
except Exception as e:
logger.error("Could not save config file")
+ logger.error(str(e))
+ self.hasError = True
+ self.errData.append("Could not save config file")
+ self.errData.append(str(e))
return False
return True
- ##
- # Setters
- ##
+ def loadRecentCache(self):
+ """Load the cache file for recent projects.
+ """
- def setRecent(self, recentPath):
- if recentPath == "": return
- if recentPath in self.recentList[0:10]:
- self.recentList.remove(recentPath)
- self.recentList.insert(0,recentPath)
- return
+ if self.dataPath is None:
+ return False
- def clearRecent(self):
- self.recentList = [""]*10
- return
+ cacheFile = path.join(self.dataPath, nwFiles.RECENT_FILE)
+ self.recentProj = {}
+
+ if path.isfile(cacheFile):
+ try:
+ with open(cacheFile, mode="r", encoding="utf8") as inFile:
+ theJson = inFile.read()
+ theData = json.loads(theJson)
+
+ for projPath in theData.keys():
+ theEntry = theData[projPath]
+ theTitle = ""
+ lastTime = 0
+ wordCount = 0
+ if "title" in theEntry.keys():
+ theTitle = theEntry["title"]
+ if "time" in theEntry.keys():
+ lastTime = int(theEntry["time"])
+ if "words" in theEntry.keys():
+ wordCount = int(theEntry["words"])
+ self.recentProj[projPath] = {
+ "title" : theTitle,
+ "time" : lastTime,
+ "words" : wordCount,
+ }
+
+ except Exception as e:
+ self.hasError = True
+ self.errData.append("Could not load recent project cache")
+ self.errData.append(str(e))
+ return False
+
+ return True
+
+ def saveRecentCache(self):
+ """Save the cache dictionary of recent projects.
+ """
+
+ if self.dataPath is None:
+ return False
+
+ cacheFile = path.join(self.dataPath, nwFiles.RECENT_FILE)
+ cacheTemp = path.join(self.dataPath, nwFiles.RECENT_FILE+"~")
+
+ try:
+ with open(cacheTemp, mode="w+", encoding="utf8") as outFile:
+ outFile.write(json.dumps(self.recentProj, indent=2))
+ except Exception as e:
+ self.hasError = True
+ self.errData.append("Could not save recent project cache")
+ self.errData.append(str(e))
+ return False
+
+ if path.isfile(cacheFile):
+ unlink(cacheFile)
+ rename(cacheTemp, cacheFile)
+
+ return True
+
+ def updateRecentCache(self, projPath, projTitle, wordCount, saveTime):
+ """Add or update recent cache information o9n a given project.
+ """
+ self.recentProj[path.abspath(projPath)] = {
+ "title" : projTitle,
+ "time" : int(saveTime),
+ "words" : int(wordCount),
+ }
+ return True
+
+ ##
+ # Setters and Getters
+ ##
def setConfPath(self, newPath):
if newPath is None:
@@ -473,6 +589,15 @@ class Config:
self.confFile = path.basename(newPath)
return True
+ def setDataPath(self, newPath):
+ if newPath is None:
+ return True
+ if not path.isdir(newPath):
+ logger.error("Config: Path not found. Using default data path instead.")
+ return False
+ self.dataPath = path.abspath(newPath)
+ return True
+
def setLastPath(self, lastPath):
if lastPath is None or lastPath == "":
self.lastPath = ""
@@ -507,12 +632,18 @@ class Config:
def setShowRefPanel(self, checkState):
self.showRefPanel = checkState
self.confChanged = True
- return
+ return self.showRefPanel
def setViewComments(self, checkState):
self.viewComments = checkState
self.confChanged = True
- return
+ return self.viewComments
+
+ def getErrData(self):
+ errMessage = " ".join(self.errData)
+ self.hasError = False
+ self.errData = []
+ return errMessage
##
# Internal Functions
@@ -550,7 +681,7 @@ class Config:
if checkVal is None:
return None
if isinstance(checkVal, str):
- if checkVal.lower == "none":
+ if checkVal.lower() == "none":
return None
return checkVal
diff --git a/nw/constants/constants.py b/nw/constants/constants.py
index acc63977..b39830bc 100644
--- a/nw/constants/constants.py
+++ b/nw/constants/constants.py
@@ -22,13 +22,12 @@ class nwFiles():
APP_ICON = "novelWriter.svg"
PROJ_FILE = "nwProject.nwx"
- PROJ_COUNT = "projCount.txt"
PROJ_DICT = "wordlist.txt"
+ PROJ_LOCK = "nwProject.lock"
SESS_INFO = "sessionInfo.log"
INDEX_FILE = "tagsIndex.json"
- EXPORT_OPT = "exportOptions.json"
- OUTLINE_OPT = "outlineOptions.json"
- SLOG_OPT = "sessionLogOptions.json"
+ OPTS_FILE = "guiOptions.json"
+ RECENT_FILE = "recentProjects.json"
# END Class nwFiles
@@ -116,10 +115,6 @@ class nwDependencies():
"site" : "",
"docs" : "",
},
- "appdirs" : {
- "site" : "",
- "docs" : "",
- },
"lxml" : {
"site" : "",
"docs" : "",
diff --git a/nw/gui/__init__.py b/nw/gui/__init__.py
index 680c5010..87dec78c 100644
--- a/nw/gui/__init__.py
+++ b/nw/gui/__init__.py
@@ -8,9 +8,12 @@ from nw.gui.theme import GuiTheme
# Dialogs
from nw.gui.dialogs.configeditor import GuiConfigEditor
+from nw.gui.dialogs.docmerge import GuiDocMerge
+from nw.gui.dialogs.docsplit import GuiDocSplit
from nw.gui.dialogs.export import GuiExport
from nw.gui.dialogs.itemeditor import GuiItemEditor
from nw.gui.dialogs.projecteditor import GuiProjectEditor
+from nw.gui.dialogs.projectload import GuiProjectLoad
from nw.gui.dialogs.sessionlog import GuiSessionLogView
# GUI Elements
@@ -33,9 +36,12 @@ __all__ = [
"GuiMainStatus",
"GuiTheme",
"GuiConfigEditor",
+ "GuiDocMerge",
+ "GuiDocSplit",
"GuiExport",
"GuiItemEditor",
"GuiProjectEditor",
+ "GuiProjectLoad",
"GuiSessionLogView",
"GuiDocDetails",
"GuiDocEditor",
diff --git a/nw/gui/dialogs/__init__.py b/nw/gui/dialogs/__init__.py
index 9374ff13..2dbc503e 100644
--- a/nw/gui/dialogs/__init__.py
+++ b/nw/gui/dialogs/__init__.py
@@ -1,15 +1,21 @@
# -*- coding: utf-8 -*-
from nw.gui.dialogs.configeditor import GuiConfigEditor
+from nw.gui.dialogs.docmerge import GuiDocMerge
+from nw.gui.dialogs.docsplit import GuiDocSplit
from nw.gui.dialogs.export import GuiExport
from nw.gui.dialogs.itemeditor import GuiItemEditor
from nw.gui.dialogs.projecteditor import GuiProjectEditor
+from nw.gui.dialogs.projectload import GuiProjectLoad
from nw.gui.dialogs.sessionlog import GuiSessionLogView
__all__ = [
"GuiConfigEditor",
+ "GuiDocMerge",
+ "GuiDocSplit",
"GuiExport",
"GuiItemEditor",
"GuiProjectEditor",
+ "GuiProjectLoad",
"GuiSessionLogView",
]
diff --git a/nw/gui/dialogs/docmerge.py b/nw/gui/dialogs/docmerge.py
new file mode 100644
index 00000000..ba3f8d61
--- /dev/null
+++ b/nw/gui/dialogs/docmerge.py
@@ -0,0 +1,157 @@
+# -*- coding: utf-8 -*-
+"""novelWriter GUI Doc Merge
+
+ novelWriter – GUI Doc Merge
+=============================
+ Tool for merging multiple documents to one
+
+ File History:
+ Created: 2020-01-23 [0.4.3]
+
+"""
+
+import logging
+import nw
+
+from PyQt5.QtCore import Qt
+from PyQt5.QtWidgets import (
+ QDialog, QHBoxLayout, QVBoxLayout, QGridLayout, QPushButton,
+ QListWidget, QAbstractItemView, QListWidgetItem
+)
+from nw.constants import nwAlert, nwItemType
+from nw.project import NWDoc
+
+logger = logging.getLogger(__name__)
+
+class GuiDocMerge(QDialog):
+
+ def __init__(self, theParent, theProject):
+ QDialog.__init__(self, theParent)
+
+ logger.debug("Initialising GuiDocMerge ...")
+
+ self.mainConf = nw.CONFIG
+ self.theParent = theParent
+ self.theProject = theProject
+ self.sourceItem = None
+
+ self.outerBox = QHBoxLayout()
+ self.innerBox = QVBoxLayout()
+ self.setWindowTitle("Merge Documents")
+ self.setLayout(self.outerBox)
+
+ self.guiDeco = self.theParent.theTheme.loadDecoration("merge",(64,64))
+
+ self.outerBox.addWidget(self.guiDeco, 0, Qt.AlignTop)
+ self.outerBox.addLayout(self.innerBox)
+
+ self.doMergeForm = QGridLayout()
+ self.doMergeForm.setContentsMargins(0,0,0,0)
+
+ self.listBox = QListWidget()
+ self.listBox.setDragDropMode(QAbstractItemView.InternalMove)
+
+ self.mergeButton = QPushButton("Merge")
+ self.mergeButton.clicked.connect(self._doMerge)
+
+ self.closeButton = QPushButton("Close")
+ self.closeButton.clicked.connect(self._doClose)
+
+ self.doMergeForm.addWidget(self.listBox, 0, 0, 1, 3)
+ self.doMergeForm.addWidget(self.mergeButton, 1, 1)
+ self.doMergeForm.addWidget(self.closeButton, 1, 2)
+
+ self.innerBox.addLayout(self.doMergeForm)
+
+ self.rejected.connect(self._doClose)
+ self.show()
+
+ self._populateList()
+
+ logger.debug("GuiDocMerge initialisation complete")
+
+ return
+
+ ##
+ # Buttons
+ ##
+
+ def _doMerge(self):
+ """Perform the merge of the files in the selected folder, and
+ create a new file in the same parent folder. The old files are
+ not removed in the merge process, and must be deleted manually.
+ """
+
+ logger.verbose("GuiDocMerge merge button clicked")
+
+ finalOrder = []
+ for i in range(self.listBox.count()):
+ finalOrder.append(self.listBox.item(i).data(Qt.UserRole))
+
+ theDoc = NWDoc(self.theProject, self.theParent)
+ theText = ""
+ for tHandle in finalOrder:
+ theText += theDoc.openDocument(tHandle, False).rstrip()
+ theText += "\n\n"
+
+ if self.sourceItem is None:
+ self.theParent.makeAlert((
+ "Cannot parse source item."
+ ), nwAlert.ERROR)
+ return
+
+ srcItem = self.theProject.getItem(self.sourceItem)
+ nHandle = self.theProject.newFile(srcItem.itemName, srcItem.itemClass, srcItem.parHandle)
+ self.theParent.treeView.revealTreeItem(nHandle)
+ theDoc.openDocument(nHandle, False)
+ theDoc.saveDocument(theText)
+ self.theParent.openDocument(nHandle)
+
+ self.close()
+
+ return
+
+ def _doClose(self):
+ """Close the dialog window without doing anything.
+ """
+ logger.verbose("GuiDocMerge close button clicked")
+ self.close()
+ return
+
+ ##
+ # Internal Functions
+ ##
+
+ def _populateList(self):
+ """Get the item selected in the tree, check that it is a folder,
+ and try to find all files associated with it. The valid files
+ are then added to the list view in order. The list itself can be
+ reordered by the user.
+ """
+
+ tHandle = self.theParent.treeView.getSelectedHandle()
+ self.sourceItem = tHandle
+ if tHandle is None:
+ return
+
+ nwItem = self.theProject.getItem(tHandle)
+ if nwItem is None:
+ return
+ if nwItem.itemType is not nwItemType.FOLDER:
+ self.theParent.makeAlert((
+ "Element selected in the project tree must be a folder."
+ ), nwAlert.ERROR)
+ return
+
+ for sHandle in self.theParent.treeView.getTreeFromHandle(tHandle):
+ newItem = QListWidgetItem()
+ nwItem = self.theProject.getItem(sHandle)
+ if nwItem.itemType is not nwItemType.FILE:
+ continue
+ newItem.setText(nwItem.itemName)
+ newItem.setData(Qt.UserRole, sHandle)
+ self.listBox.addItem(newItem)
+
+ return
+
+# END Class GuiDocMerge
diff --git a/nw/gui/dialogs/docsplit.py b/nw/gui/dialogs/docsplit.py
new file mode 100644
index 00000000..4296717c
--- /dev/null
+++ b/nw/gui/dialogs/docsplit.py
@@ -0,0 +1,241 @@
+# -*- coding: utf-8 -*-
+"""novelWriter GUI Doc Split
+
+ novelWriter – GUI Doc Split
+=============================
+ Tool for splitting a single document into multiple documents
+
+ File History:
+ Created: 2020-02-01 [0.4.3]
+
+"""
+
+import logging
+import nw
+
+from PyQt5.QtCore import Qt
+from PyQt5.QtWidgets import (
+ QDialog, QHBoxLayout, QVBoxLayout, QGridLayout, QPushButton, QComboBox,
+ QListWidget, QAbstractItemView, QListWidgetItem
+)
+from nw.constants import nwAlert, nwItemType, nwItemClass, nwItemLayout
+from nw.project import NWDoc
+
+logger = logging.getLogger(__name__)
+
+class GuiDocSplit(QDialog):
+
+ def __init__(self, theParent, theProject):
+ QDialog.__init__(self, theParent)
+
+ logger.debug("Initialising GuiDocSplit ...")
+
+ self.mainConf = nw.CONFIG
+ self.theParent = theParent
+ self.theProject = theProject
+ self.optState = self.theProject.optState
+ self.sourceItem = None
+
+ self.outerBox = QHBoxLayout()
+ self.innerBox = QVBoxLayout()
+ self.setWindowTitle("Split Document")
+ self.setLayout(self.outerBox)
+
+ self.guiDeco = self.theParent.theTheme.loadDecoration("split",(64,64))
+
+ self.outerBox.addWidget(self.guiDeco, 0, Qt.AlignTop)
+ self.outerBox.addLayout(self.innerBox)
+
+ self.doMergeForm = QGridLayout()
+ self.doMergeForm.setContentsMargins(0,0,0,0)
+
+ self.listBox = QListWidget()
+ self.listBox.setDragDropMode(QAbstractItemView.NoDragDrop)
+
+ self.splitLevel = QComboBox(self)
+ self.splitLevel.addItem("Split on Header Level 1 (Title)", 1)
+ self.splitLevel.addItem("Split up to Header Level 2 (Chapter)", 2)
+ self.splitLevel.addItem("Split up to Header Level 3 (Scene)", 3)
+ self.splitLevel.addItem("Split up to Header Level 4 (Section)", 4)
+ spIndex = self.splitLevel.findData(
+ self.optState.getInt("GuiDocSplit", "spLevel", 3)
+ )
+ if spIndex != -1:
+ self.splitLevel.setCurrentIndex(spIndex)
+ self.splitLevel.currentIndexChanged.connect(self._populateList)
+
+ self.splitButton = QPushButton("Split")
+ self.splitButton.clicked.connect(self._doSplit)
+
+ self.closeButton = QPushButton("Close")
+ self.closeButton.clicked.connect(self._doClose)
+
+ self.doMergeForm.addWidget(self.listBox, 0, 0, 1, 3)
+ self.doMergeForm.addWidget(self.splitLevel, 1, 0, 1, 3)
+ self.doMergeForm.addWidget(self.splitButton, 2, 1)
+ self.doMergeForm.addWidget(self.closeButton, 2, 2)
+
+ self.innerBox.addLayout(self.doMergeForm)
+
+ self.rejected.connect(self._doClose)
+ self.show()
+
+ self._populateList()
+
+ logger.debug("GuiDocSplit initialisation complete")
+
+ return
+
+ ##
+ # Buttons
+ ##
+
+ def _doSplit(self):
+ """Perform the split of the file, create a new folder in the
+ same parent folder, and multiple files depending on split level
+ settings. The old file is not removed in the merge process, and
+ must be deleted manually.
+ """
+
+ logger.verbose("GuiDocSplit split button clicked")
+
+ if self.sourceItem is None:
+ self.theParent.makeAlert((
+ "No source document selected. Nothing to do."
+ ), nwAlert.ERROR)
+ return
+
+ srcItem = self.theProject.getItem(self.sourceItem)
+ if srcItem is None:
+ self.theParent.makeAlert((
+ "Could not parse source document."
+ ), nwAlert.ERROR)
+ return
+
+ theDoc = NWDoc(self.theProject, self.theParent)
+ theText = theDoc.openDocument(self.sourceItem, False)
+ theLines = theText.splitlines()
+ nLines = len(theLines)
+ theLines.insert(0, "%Split Doc")
+ logger.debug(
+ "Splitting document %s with %d lines" % (self.sourceItem,nLines)
+ )
+
+ finalOrder = []
+ for i in range(self.listBox.count()):
+ listItem = self.listBox.item(i)
+ wTitle = listItem.text()
+ lineNo = listItem.data(Qt.UserRole)
+ finalOrder.append([wTitle, lineNo, nLines])
+ if i > 0:
+ finalOrder[i-1][2] = lineNo
+
+ if len(finalOrder) == 0:
+ self.theParent.makeAlert((
+ "No headers found. Nothing to do."
+ ), nwAlert.ERROR)
+ return
+
+ fHandle = self.theProject.newFolder(srcItem.itemName, srcItem.itemClass, srcItem.parHandle)
+ self.theParent.treeView.revealTreeItem(fHandle)
+ logger.verbose("Creating folder %s" % fHandle)
+
+ for wTitle, iStart, iEnd in finalOrder:
+
+ itemLayout = nwItemLayout.NOTE
+ if srcItem.itemClass == nwItemClass.NOVEL:
+ if wTitle.startswith("# "):
+ itemLayout = nwItemLayout.PARTITION
+ elif wTitle.startswith("## "):
+ itemLayout = nwItemLayout.CHAPTER
+ elif wTitle.startswith("### "):
+ itemLayout = nwItemLayout.SCENE
+ elif wTitle.startswith("#### "):
+ itemLayout = nwItemLayout.PAGE
+
+ wTitle = wTitle.lstrip("#")
+ wTitle = wTitle.strip()
+
+ nHandle = self.theProject.newFile(wTitle, srcItem.itemClass, fHandle)
+ newItem = self.theProject.getItem(nHandle)
+ newItem.setLayout(itemLayout)
+ logger.verbose(
+ "Creating new document %s with text from line %d to %d" % (nHandle, iStart, iEnd-1)
+ )
+
+ theText = "\n".join(theLines[iStart:iEnd])
+ theDoc.openDocument(nHandle, False)
+ theDoc.saveDocument(theText)
+ theDoc.clearDocument()
+ self.theParent.treeView.revealTreeItem(nHandle)
+
+ self.close()
+
+ return
+
+ def _doClose(self):
+ """Close the dialog window without doing anything.
+ """
+ logger.verbose("GuiDocSplit close button clicked")
+ self.optState.saveSettings()
+ self.close()
+ return
+
+ ##
+ # Internal Functions
+ ##
+
+ def _populateList(self):
+ """Get the item selected in the tree, check that it is a folder,
+ and try to find all files associated with it. The valid files
+ are then added to the list view in order. The list itself can be
+ reordered by the user.
+ """
+
+ if self.sourceItem is None:
+ self.sourceItem = self.theParent.treeView.getSelectedHandle()
+
+ if self.sourceItem is None:
+ return
+
+ nwItem = self.theProject.getItem(self.sourceItem)
+ if nwItem is None:
+ return
+ if nwItem.itemType is not nwItemType.FILE:
+ self.theParent.makeAlert((
+ "Element selected in the project tree must be a file."
+ ), nwAlert.ERROR)
+ return
+
+ self.listBox.clear()
+ theDoc = NWDoc(self.theProject, self.theParent)
+ theText = theDoc.openDocument(self.sourceItem, False)
+
+ spLevel = self.splitLevel.currentData()
+ self.optState.setValue("GuiDocSplit", "spLevel", spLevel)
+ logger.debug("Scanning document %s for headings level <= %d" % (self.sourceItem, spLevel))
+
+ lineNo = 0
+ for aLine in theText.splitlines():
+
+ lineNo += 1
+ onLine = 0
+
+ if aLine.startswith("# ") and spLevel >= 1:
+ onLine = lineNo
+ elif aLine.startswith("## ") and spLevel >= 2:
+ onLine = lineNo
+ elif aLine.startswith("### ") and spLevel >= 3:
+ onLine = lineNo
+ elif aLine.startswith("#### ") and spLevel >= 4:
+ onLine = lineNo
+
+ if onLine > 0:
+ newItem = QListWidgetItem()
+ newItem.setText(aLine.strip())
+ newItem.setData(Qt.UserRole, onLine)
+ self.listBox.addItem(newItem)
+
+ return
+
+# END Class GuiDocSplit
diff --git a/nw/gui/dialogs/export.py b/nw/gui/dialogs/export.py
index 16f72484..a626ade6 100644
--- a/nw/gui/dialogs/export.py
+++ b/nw/gui/dialogs/export.py
@@ -24,7 +24,6 @@ from PyQt5.QtWidgets import (
)
from nw.convert import TextFile, HtmlFile, MarkdownFile, LaTeXFile, ConcatFile
-from nw.tools import OptLastState
from nw.common import packageRefURL
from nw.constants import nwFiles, nwItemType, nwAlert
@@ -40,8 +39,7 @@ class GuiExport(QDialog):
self.mainConf = nw.CONFIG
self.theParent = theParent
self.theProject = theProject
- self.optState = ExportLastState(self.theProject,nwFiles.EXPORT_OPT)
- self.optState.loadSettings()
+ self.optState = self.theProject.optState
self.outerBox = QHBoxLayout()
self.innerBox = QVBoxLayout()
@@ -50,8 +48,8 @@ class GuiExport(QDialog):
self.guiDeco = self.theParent.theTheme.loadDecoration("export",(64,64))
- self.tabMain = GuiExportMain(self.theParent, self.theProject, self.optState)
- self.tabPandoc = GuiExportPandoc(self.theParent, self.theProject, self.optState)
+ self.tabMain = GuiExportMain(self.theParent, self.theProject)
+ self.tabPandoc = GuiExportPandoc(self.theParent, self.theProject)
self.tabWidget = QTabWidget()
self.tabWidget.addTab(self.tabMain, "Settings")
@@ -290,24 +288,24 @@ class GuiExport(QDialog):
if saveTo.startswith("~"):
saveTo = path.expanduser(saveTo)
- self.optState.setSetting("wNovel", wNovel)
- self.optState.setSetting("wNotes", wNotes)
- self.optState.setSetting("eFormat", eFormat)
- self.optState.setSetting("fixWidth", fixWidth)
- self.optState.setSetting("wComments",wComments)
- self.optState.setSetting("wKeywords",wKeywords)
- self.optState.setSetting("chFormat", chFormat)
- self.optState.setSetting("unFormat", unFormat)
- self.optState.setSetting("scFormat", scFormat)
- self.optState.setSetting("seFormat", seFormat)
- self.optState.setSetting("saveTo", saveTo)
- self.optState.setSetting("hScene", hScene)
- self.optState.setSetting("hSection", hSection)
+ self.optState.setValue("GuiExport", "wNovel", wNovel)
+ self.optState.setValue("GuiExport", "wNotes", wNotes)
+ self.optState.setValue("GuiExport", "eFormat", eFormat)
+ self.optState.setValue("GuiExport", "fixWidth", fixWidth)
+ self.optState.setValue("GuiExport", "wComments", wComments)
+ self.optState.setValue("GuiExport", "wKeywords", wKeywords)
+ self.optState.setValue("GuiExport", "chFormat", chFormat)
+ self.optState.setValue("GuiExport", "unFormat", unFormat)
+ self.optState.setValue("GuiExport", "scFormat", scFormat)
+ self.optState.setValue("GuiExport", "seFormat", seFormat)
+ self.optState.setValue("GuiExport", "saveTo", saveTo)
+ self.optState.setValue("GuiExport", "hScene", hScene)
+ self.optState.setValue("GuiExport", "hSection", hSection)
# Pandoc Settings
pFormat = self.tabPandoc.outputFormat.currentData()
- self.optState.setSetting("pFormat", pFormat)
+ self.optState.setValue("GuiExport", "pFormat", pFormat)
self.optState.saveSettings()
self.close()
@@ -362,14 +360,14 @@ class GuiExportMain(QWidget):
),
}
- def __init__(self, theParent, theProject, optState):
+ def __init__(self, theParent, theProject):
QWidget.__init__(self, theParent)
self.theParent = theParent
self.theProject = theProject
self.theTheme = theParent.theTheme
self.outerBox = QGridLayout()
- self.optState = optState
+ self.optState = self.theProject.optState
self.currFormat = self.FMT_TXT
# Select Files
@@ -378,19 +376,27 @@ class GuiExportMain(QWidget):
self.guiFiles.setLayout(self.guiFilesForm)
self.expNovel = QCheckBox("Novel files",self)
- self.expNovel.setChecked(self.optState.getSetting("wNovel"))
+ self.expNovel.setChecked(
+ self.optState.getBool("GuiExport", "wNovel", True)
+ )
self.expNovel.setToolTip("Include all novel files in the exported document")
self.expNotes = QCheckBox("Note files",self)
- self.expNotes.setChecked(self.optState.getSetting("wNotes"))
+ self.expNotes.setChecked(
+ self.optState.getBool("GuiExport", "wNotes", False)
+ )
self.expNotes.setToolTip("Include all note files in the exported document")
self.expComments = QCheckBox("Comments",self)
- self.expComments.setChecked(self.optState.getSetting("wComments"))
+ self.expComments.setChecked(
+ self.optState.getBool("GuiExport", "wComments", False)
+ )
self.expComments.setToolTip("Export comments from all files")
self.expKeywords = QCheckBox("Keywords",self)
- self.expKeywords.setChecked(self.optState.getSetting("wKeywords"))
+ self.expKeywords.setChecked(
+ self.optState.getBool("GuiExport", "wKeywords", False)
+ )
self.expKeywords.setToolTip("Export @keywords from all files")
self.guiFilesForm.addWidget(self.expNovel, 0, 1)
@@ -406,13 +412,17 @@ class GuiExportMain(QWidget):
self.chapterFormat = QLineEdit()
self.chapterFormat.setMaxLength(200)
- self.chapterFormat.setText(self.optState.getSetting("chFormat"))
+ self.chapterFormat.setText(
+ self.optState.getString("GuiExport", "chFormat", "Chapter %numword%")
+ )
self.chapterFormat.setToolTip("Available formats: %num%, %numword%, %title%")
self.chapterFormat.setMinimumWidth(250)
self.unnumFormat = QLineEdit()
self.unnumFormat.setMaxLength(200)
- self.unnumFormat.setText(self.optState.getSetting("unFormat"))
+ self.unnumFormat.setText(
+ self.optState.getString("GuiExport", "unFormat", "%title%")
+ )
self.unnumFormat.setToolTip("Available formats: %title%")
self.unnumFormat.setMinimumWidth(250)
@@ -428,22 +438,30 @@ class GuiExportMain(QWidget):
self.sceneFormat = QLineEdit()
self.sceneFormat.setMaxLength(200)
- self.sceneFormat.setText(self.optState.getSetting("scFormat"))
+ self.sceneFormat.setText(
+ self.optState.getString("GuiExport", "scFormat", "* * *")
+ )
self.sceneFormat.setToolTip("Available formats: %title%")
self.sceneFormat.setMinimumWidth(100)
self.sectionFormat = QLineEdit()
self.sectionFormat.setMaxLength(200)
- self.sectionFormat.setText(self.optState.getSetting("seFormat"))
+ self.sectionFormat.setText(
+ self.optState.getString("GuiExport", "seFormat", "")
+ )
self.sectionFormat.setToolTip("Available formats: %title%")
self.sectionFormat.setMinimumWidth(100)
self.hideScene = QCheckBox("Skip",self)
- self.hideScene.setChecked(self.optState.getSetting("hScene"))
+ self.hideScene.setChecked(
+ self.optState.getBool("GuiExport", "hScene", False)
+ )
self.hideScene.setToolTip("Skip scene titles in export")
self.hideSection = QCheckBox("Skip",self)
- self.hideSection.setChecked(self.optState.getSetting("hSection"))
+ self.hideSection.setChecked(
+ self.optState.getBool("GuiExport", "hSection", False)
+ )
self.hideSection.setToolTip("Skip section titles in export")
self.guiScenesForm.addWidget(QLabel("Scenes"), 0, 0)
@@ -458,8 +476,9 @@ class GuiExportMain(QWidget):
self.exportToForm = QGridLayout(self)
self.exportTo.setLayout(self.exportToForm)
- self.exportPath = QLineEdit(self.optState.getSetting("saveTo"))
-
+ self.exportPath = QLineEdit(
+ self.optState.getString("GuiExport", "saveTo", "")
+ )
self.exportGetPath = QPushButton(self.theTheme.getIcon("folder"),"")
self.exportGetPath.clicked.connect(self._exportFolder)
@@ -486,7 +505,9 @@ class GuiExportMain(QWidget):
self.outputFormat.addItem("Pandoc via Markdown or HTML", self.FMT_PDOC)
self.outputFormat.currentIndexChanged.connect(self._updateFormat)
- optIdx = self.outputFormat.findData(self.optState.getSetting("eFormat"))
+ optIdx = self.outputFormat.findData(
+ self.optState.getInt("GuiExport", "eFormat", 1)
+ )
if optIdx == -1:
self.outputFormat.setCurrentIndex(1)
self._updateFormat(1)
@@ -508,7 +529,9 @@ class GuiExportMain(QWidget):
self.fixedWidth.setMinimum(0)
self.fixedWidth.setMaximum(999)
self.fixedWidth.setSingleStep(1)
- self.fixedWidth.setValue(self.optState.getSetting("fixWidth"))
+ self.fixedWidth.setValue(
+ self.optState.getInt("GuiExport", "fixWidth", 80)
+ )
self.fixedWidth.setToolTip(
"Applies to .txt and .md files. A value of '0' disables the feature."
)
@@ -609,13 +632,13 @@ class GuiExportPandoc(QWidget):
FMT_ZIM : "markdown",
}
- def __init__(self, theParent, theProject, optState):
+ def __init__(self, theParent, theProject):
QWidget.__init__(self, theParent)
self.theParent = theParent
self.theProject = theProject
self.outerBox = QGridLayout()
- self.optState = optState
+ self.optState = self.theProject.optState
try:
import pypandoc
@@ -659,7 +682,9 @@ class GuiExportPandoc(QWidget):
self.outputFormat.addItem("ePUB eBook v3 (.epub3)", self.FMT_EPUB3)
self.outputFormat.addItem("Zim Wiki (.txt)", self.FMT_ZIM)
- optIdx = self.outputFormat.findData(self.optState.getSetting("pFormat"))
+ optIdx = self.outputFormat.findData(
+ self.optState.getInt("GuiExport", "pFormat", 1)
+ )
if optIdx == -1:
self.outputFormat.setCurrentIndex(1)
else:
@@ -678,30 +703,3 @@ class GuiExportPandoc(QWidget):
return
# END Class GuiExportPandoc
-
-class ExportLastState(OptLastState):
-
- def __init__(self, theProject, theFile):
- OptLastState.__init__(self, theProject, theFile)
- self.theState = {
- "wNovel" : True,
- "wNotes" : False,
- "eFormat" : 1,
- "pFormat" : 1,
- "fixWidth" : 80,
- "wComments" : False,
- "wKeywords" : False,
- "chFormat" : "Chapter %numword%",
- "unFormat" : "%title%",
- "scFormat" : "* * *",
- "seFormat" : "",
- "saveTo" : "",
- "hScene" : False,
- "hSection" : False,
- }
- self.stringOpt = ("chFormat","unFormat","scFormat","seFormat","saveTo")
- self.boolOpt = ("wNovel","wNotes","wComments","wKeywords","hScene","hSection")
- self.intOpt = ("eFormat","pFormat","fixWidth")
- return
-
-# END Class ExportLastState
diff --git a/nw/gui/dialogs/itemeditor.py b/nw/gui/dialogs/itemeditor.py
index 0dc219ff..5383aefa 100644
--- a/nw/gui/dialogs/itemeditor.py
+++ b/nw/gui/dialogs/itemeditor.py
@@ -13,8 +13,6 @@
import logging
import nw
-from os import path
-
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (
QDialog, QHBoxLayout, QVBoxLayout, QGroupBox, QFormLayout, QLineEdit,
diff --git a/nw/gui/dialogs/projecteditor.py b/nw/gui/dialogs/projecteditor.py
index 6a09fe7d..3b8c0114 100644
--- a/nw/gui/dialogs/projecteditor.py
+++ b/nw/gui/dialogs/projecteditor.py
@@ -13,8 +13,6 @@
import logging
import nw
-from os import path
-
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QIcon, QPixmap, QColor, QBrush
from PyQt5.QtWidgets import (
diff --git a/nw/gui/dialogs/projectload.py b/nw/gui/dialogs/projectload.py
new file mode 100644
index 00000000..3e773a9d
--- /dev/null
+++ b/nw/gui/dialogs/projectload.py
@@ -0,0 +1,169 @@
+# -*- coding: utf-8 -*-
+"""novelWriter GUI Open Project
+
+ novelWriter – GUI Open Project
+================================
+ New and open project dialog
+
+ File History:
+ Created: 2020-02-26 [0.4.5]
+
+"""
+
+import logging
+import nw
+
+from datetime import datetime
+
+from PyQt5.QtCore import Qt
+from PyQt5.QtWidgets import (
+ QDialog, QHBoxLayout, QVBoxLayout, QGridLayout, QPushButton, QTreeWidget,
+ QAbstractItemView, QTreeWidgetItem
+)
+
+from nw.common import formatInt
+
+logger = logging.getLogger(__name__)
+
+class GuiProjectLoad(QDialog):
+
+ def __init__(self, theParent):
+ QDialog.__init__(self, theParent)
+
+ logger.debug("Initialising GuiProjectLoad ...")
+
+ self.mainConf = nw.CONFIG
+ self.theParent = theParent
+ self.sourceItem = None
+ self.openPath = None
+
+ self.outerBox = QHBoxLayout()
+ self.innerBox = QVBoxLayout()
+ self.setWindowTitle("Open Project")
+ self.setLayout(self.outerBox)
+
+ self.guiDeco = self.theParent.theTheme.loadDecoration("nwicon", (128, 128))
+
+ self.outerBox.addWidget(self.guiDeco, 0, Qt.AlignTop)
+ self.outerBox.addLayout(self.innerBox)
+
+ self.projectForm = QGridLayout()
+ self.projectForm.setContentsMargins(0, 0, 0, 0)
+
+ self.listBox = QTreeWidget()
+ self.listBox.setSelectionMode(QAbstractItemView.SingleSelection)
+ self.listBox.setDragDropMode(QAbstractItemView.NoDragDrop)
+ self.listBox.setColumnCount(4)
+ self.listBox.setHeaderLabels(["Working Title","Words","Accessed","Path"])
+ self.listBox.setRootIsDecorated(False)
+
+ treeHead = self.listBox.headerItem()
+ treeHead.setTextAlignment(1, Qt.AlignRight)
+
+ self.recentButton = QPushButton("Open")
+ self.recentButton.clicked.connect(self._doOpenRecent)
+ self.browseButton = QPushButton("Browse")
+ self.browseButton.clicked.connect(self._doBrowse)
+ self.closeButton = QPushButton("Close")
+ self.closeButton.clicked.connect(self._doClose)
+
+ self.projectForm.addWidget(self.listBox, 0, 0, 1, 4)
+ self.projectForm.addWidget(self.recentButton, 1, 1)
+ self.projectForm.addWidget(self.browseButton, 1, 2)
+ self.projectForm.addWidget(self.closeButton, 1, 3)
+ self.projectForm.setColumnStretch(0, 1)
+
+ self.innerBox.addLayout(self.projectForm)
+
+ self.rejected.connect(self._doClose)
+ self.setModal(True)
+ self.setMinimumWidth(750)
+ self.setMinimumHeight(450)
+ self.show()
+
+ self._populateList()
+
+ logger.debug("GuiProjectLoad initialisation complete")
+
+ return
+
+ ##
+ # Buttons
+ ##
+
+ def _doOpenRecent(self):
+ """Close the dialog window with a recent project selected.
+ """
+ logger.verbose("GuiProjectLoad open button clicked")
+
+ selItems = self.listBox.selectedItems()
+ if selItems:
+ self.openPath = selItems[0].text(3)
+ self.accept()
+ else:
+ self.openPath = None
+
+ return
+
+ def _doBrowse(self):
+ """Close the dialog window with no selected path, triggering the
+ project browser dialog.
+ """
+ logger.verbose("GuiProjectLoad browse button clicked")
+ self.openPath = None
+ self.accept()
+ return
+
+ def _doClose(self):
+ """Close the dialog window without doing anything.
+ """
+ logger.verbose("GuiProjectLoad close button clicked")
+ self.close()
+ return
+
+ ##
+ # Internal Functions
+ ##
+
+ def _populateList(self):
+ """Populate the list box with recent project data.
+ """
+
+ listOrder = []
+ listData = {}
+ for projPath in self.mainConf.recentProj.keys():
+ theEntry = self.mainConf.recentProj[projPath]
+ theTitle = ""
+ theTime = 0
+ theWords = 0
+ if "title" in theEntry.keys():
+ theTitle = theEntry["title"]
+ if "time" in theEntry.keys():
+ theTime = theEntry["time"]
+ if "words" in theEntry.keys():
+ theWords = theEntry["words"]
+ if theTime > 0:
+ listOrder.append(theTime)
+ listData[theTime] = [theTitle, theWords, projPath]
+
+ self.listBox.clear()
+ hasSelection = False
+ for timeStamp in sorted(listOrder, reverse=True):
+ newItem = QTreeWidgetItem([""]*4)
+ newItem.setText(0, listData[timeStamp][0])
+ newItem.setText(1, formatInt(listData[timeStamp][1]))
+ newItem.setText(2, datetime.fromtimestamp(timeStamp).strftime("%x %X"))
+ newItem.setText(3, listData[timeStamp][2])
+ newItem.setTextAlignment(1, Qt.AlignRight)
+ self.listBox.addTopLevelItem(newItem)
+ if not hasSelection:
+ newItem.setSelected(True)
+ hasSelection = True
+
+ self.listBox.resizeColumnToContents(0)
+ self.listBox.resizeColumnToContents(1)
+ self.listBox.resizeColumnToContents(2)
+
+ return
+
+# END Class GuiProjectLoad
diff --git a/nw/gui/dialogs/sessionlog.py b/nw/gui/dialogs/sessionlog.py
index ccea5b1d..ccae4bb3 100644
--- a/nw/gui/dialogs/sessionlog.py
+++ b/nw/gui/dialogs/sessionlog.py
@@ -24,7 +24,6 @@ from PyQt5.QtWidgets import (
)
from nw.constants import nwConst, nwFiles, nwAlert
-from nw.tools import OptLastState
logger = logging.getLogger(__name__)
@@ -38,8 +37,7 @@ class GuiSessionLogView(QDialog):
self.mainConf = nw.CONFIG
self.theProject = theProject
self.theParent = theParent
- self.optState = SessionLogLastState(self.theProject,nwFiles.SLOG_OPT)
- self.optState.loadSettings()
+ self.optState = self.theProject.optState
self.timeFilter = 0.0
self.timeTotal = 0.0
@@ -52,13 +50,13 @@ class GuiSessionLogView(QDialog):
self.setMinimumHeight(400)
widthCol0 = self.optState.validIntRange(
- self.optState.getSetting("widthCol0"), 30, 999, 180
+ self.optState.getInt("GuiSession", "widthCol0", 180), 30, 999, 180
)
widthCol1 = self.optState.validIntRange(
- self.optState.getSetting("widthCol1"), 30, 999, 80
+ self.optState.getInt("GuiSession", "widthCol1", 80), 30, 999, 80
)
widthCol2 = self.optState.validIntRange(
- self.optState.getSetting("widthCol2"), 30, 999, 80
+ self.optState.getInt("GuiSession", "widthCol2", 80), 30, 999, 80
)
self.listBox = QTreeWidget()
@@ -77,10 +75,11 @@ class GuiSessionLogView(QDialog):
sortValid = (Qt.AscendingOrder, Qt.DescendingOrder)
sortCol = self.optState.validIntRange(
- self.optState.getSetting("sortCol"), 0, 2, 0
+ self.optState.getInt("GuiSession", "sortCol", 0), 0, 2, 0
)
sortOrder = self.optState.validIntTuple(
- self.optState.getSetting("sortOrder"), sortValid, Qt.DescendingOrder
+ self.optState.getInt("GuiSession", "sortOrder", Qt.DescendingOrder),
+ sortValid, Qt.DescendingOrder
)
self.listBox.sortByColumn(sortCol, sortOrder)
@@ -110,11 +109,15 @@ class GuiSessionLogView(QDialog):
self.filterBox.setLayout(self.filterBoxForm)
self.hideZeros = QCheckBox("Hide zero word count", self)
- self.hideZeros.setChecked(self.optState.getSetting("hideZeros"))
+ self.hideZeros.setChecked(
+ self.optState.getBool("GuiSession", "hideZeros", True)
+ )
self.hideZeros.stateChanged.connect(self._doHideZeros)
self.hideNegative = QCheckBox("Hide negative word count", self)
- self.hideNegative.setChecked(self.optState.getSetting("hideNegative"))
+ self.hideNegative.setChecked(
+ self.optState.getBool("GuiSession", "hideNegative", False)
+ )
self.hideNegative.stateChanged.connect(self._doHideNegative)
self.filterBoxForm.addWidget(self.hideZeros, 0, 0)
@@ -208,13 +211,13 @@ class GuiSessionLogView(QDialog):
hideZeros = self.hideZeros.isChecked()
hideNegative = self.hideNegative.isChecked()
- self.optState.setSetting("widthCol0", widthCol0)
- self.optState.setSetting("widthCol1", widthCol1)
- self.optState.setSetting("widthCol2", widthCol2)
- self.optState.setSetting("sortCol", sortCol)
- self.optState.setSetting("sortOrder", sortOrder)
- self.optState.setSetting("hideZeros", hideZeros)
- self.optState.setSetting("hideNegative",hideNegative)
+ self.optState.setValue("GuiSession", "widthCol0", widthCol0)
+ self.optState.setValue("GuiSession", "widthCol1", widthCol1)
+ self.optState.setValue("GuiSession", "widthCol2", widthCol2)
+ self.optState.setValue("GuiSession", "sortCol", sortCol)
+ self.optState.setValue("GuiSession", "sortOrder", sortOrder)
+ self.optState.setValue("GuiSession", "hideZeros", hideZeros)
+ self.optState.setValue("GuiSession", "hideNegative", hideNegative)
self.optState.saveSettings()
self.close()
@@ -237,23 +240,3 @@ class GuiSessionLogView(QDialog):
return "%02d:%02d:%02d" % (tH,tM,tS)
# END Class GuiSessionLogView
-
-class SessionLogLastState(OptLastState):
-
- def __init__(self, theProject, theFile):
- OptLastState.__init__(self, theProject, theFile)
- self.theState = {
- "widthCol0" : 180,
- "widthCol1" : 80,
- "widthCol2" : 80,
- "sortCol" : 0,
- "sortOrder" : Qt.DescendingOrder,
- "hideZeros" : True,
- "hideNegative" : False,
- }
- self.stringOpt = ()
- self.boolOpt = ("hideZeros","hideNegative")
- self.intOpt = ("widthCol0","widthCol1","widthCol2","sortCol","sortOrder")
- return
-
-# END Class SessionLogLastState
diff --git a/nw/gui/elements/docdetails.py b/nw/gui/elements/docdetails.py
index 78fbf386..00b3aa89 100644
--- a/nw/gui/elements/docdetails.py
+++ b/nw/gui/elements/docdetails.py
@@ -23,11 +23,6 @@ logger = logging.getLogger(__name__)
class GuiDocDetails(QFrame):
- C_NAME = 0
- C_COUNT = 1
- C_FLAGS = 2
- C_HANDLE = 3
-
def __init__(self, theParent, theProject):
QFrame.__init__(self, theParent)
diff --git a/nw/gui/elements/doceditor.py b/nw/gui/elements/doceditor.py
index 681f1082..d277ac75 100644
--- a/nw/gui/elements/doceditor.py
+++ b/nw/gui/elements/doceditor.py
@@ -21,13 +21,13 @@ from PyQt5.QtWidgets import (
)
from PyQt5.QtGui import (
QTextCursor, QTextOption, QKeySequence, QFont, QColor, QPalette,
- QTextDocument
+ QTextDocument, QCursor
)
from nw.project import NWDoc
from nw.gui.tools import GuiDocHighlighter, WordCounter
-from nw.tools import NWSpellCheck, NWSpellSimple
-from nw.constants import nwFiles, nwUnicode, nwDocAction, nwAlert
+from nw.tools import NWSpellSimple
+from nw.constants import nwUnicode, nwDocAction
logger = logging.getLogger(__name__)
@@ -209,7 +209,7 @@ class GuiDocEditor(QTextEdit):
risk overwriting the file if it exists. This can for instance
happen of the file contains binary elements or an encoding that
novelWriter does not support. If load is successful, or the
- document is new (empty string) we set up the editor for editing
+ document is new (empty string), we set up the editor for editing
the file.
"""
@@ -219,6 +219,7 @@ class GuiDocEditor(QTextEdit):
self.clearEditor()
return False
+ qApp.setOverrideCursor(QCursor(Qt.WaitCursor))
self.hLight.setHandle(tHandle)
# Check that the document is not too big for full, initial spell
@@ -246,6 +247,7 @@ class GuiDocEditor(QTextEdit):
self.theParent.noticeBar.showNote("This document is read only.")
self.hLight.spellCheck = spTemp
+ qApp.restoreOverrideCursor()
return True
@@ -277,8 +279,9 @@ class GuiDocEditor(QTextEdit):
return True
def updateDocMargins(self):
- """Automatically adjust the margins so the text is centred, but
- only if Config.textFixedW is enabled or we're in Zen mode.
+ """Automatically adjust the margins so the text is centred if
+ Config.textFixedW is enabled or we're in Zen mode. Otherwise,
+ just ensure the margins are set correctly.
"""
if self.mainConf.textFixedW or self.theParent.isZenMode:
@@ -335,7 +338,7 @@ class GuiDocEditor(QTextEdit):
return theText
def setCursorPosition(self, thePosition):
- if thePosition > 0:
+ if thePosition >= 0:
theCursor = self.textCursor()
theCursor.setPosition(thePosition)
self.setTextCursor(theCursor)
@@ -361,8 +364,8 @@ class GuiDocEditor(QTextEdit):
def setSpellCheck(self, theMode):
"""This is the master spell check setting function, and this one
should call all other setSpellCheck functions in other classes.
- If the spell check mode is not defined, then toggle the current
- status saved in the class.
+ If the spell check mode (theMode) is not defined (None), then
+ toggle the current status saved in this class.
"""
if theMode is None:
@@ -375,23 +378,29 @@ class GuiDocEditor(QTextEdit):
self.theParent.mainMenu.setSpellCheck(theMode)
self.theProject.setSpellCheck(theMode)
self.hLight.setSpellCheck(theMode)
- self.reHighlightDocument()
+ if not self.bigDoc:
+ self.spellCheckDocument()
logger.verbose("Spell check is set to %s" % str(theMode))
return True
- def reHighlightDocument(self):
+ def spellCheckDocument(self):
"""Rerun the highlighter to update spell checking status of the
currently loaded text. The fastest way to do this, at least as
of Qt 5.13, is to clear the text and put it back.
"""
+ logger.verbose("Running spell checker")
if self.spellCheck:
- theText = self.getText()
- self.clear()
bfTime = time()
- self.setPlainText(theText)
+ qApp.setOverrideCursor(QCursor(Qt.WaitCursor))
+ if self.bigDoc:
+ theText = self.getText()
+ self.setPlainText(theText)
+ else:
+ self.hLight.rehighlight()
+ qApp.restoreOverrideCursor()
afTime = time()
logger.debug("Document re-highlighted in %.3f milliseconds" % (1000*(afTime-bfTime)))
diff --git a/nw/gui/elements/doctree.py b/nw/gui/elements/doctree.py
index d5b9fb4f..ae7aef17 100644
--- a/nw/gui/elements/doctree.py
+++ b/nw/gui/elements/doctree.py
@@ -16,10 +16,10 @@ import nw
from PyQt5.QtCore import Qt, QSize
from PyQt5.QtGui import QFont, QColor
from PyQt5.QtWidgets import (
- QTreeWidget, QTreeWidgetItem, QAbstractItemView, QApplication
+ QTreeWidget, QTreeWidgetItem, QAbstractItemView, QApplication, QMessageBox
)
-from nw.project import NWItem
+from nw.project import NWItem, NWDoc
from nw.constants import (
nwLabels, nwItemType, nwItemClass, nwItemLayout, nwAlert
)
@@ -171,14 +171,21 @@ class GuiDocTree(QTreeWidget):
return False
# Add the new item to the tree
- nwItem = self.theProject.getItem(tHandle)
- trItem = self._addTreeItem(nwItem)
+ self.revealTreeItem(tHandle)
+ self.theParent.editItem()
+
+ return True
+
+ def revealTreeItem(self, tHandle):
+ """Reveal a newly added project item in the project tree.
+ """
+ nwItem = self.theProject.getItem(tHandle)
+ trItem = self._addTreeItem(nwItem)
+ pHandle = nwItem.parHandle
if pHandle is not None and pHandle in self.theMap.keys():
self.theMap[pHandle].setExpanded(True)
self.clearSelection()
trItem.setSelected(True)
- self.theParent.editItem()
-
return True
def moveTreeItem(self, nStep):
@@ -221,6 +228,16 @@ class GuiDocTree(QTreeWidget):
self.theProject.setTreeOrder(theList)
return True
+ def getTreeFromHandle(self, tHandle):
+ """Recursively return all the children items starting from a
+ given item handle.
+ """
+ theList = []
+ theItem = self._getTreeItem(tHandle)
+ if theItem is not None:
+ theList = self._scanChildren(theList, theItem, 0)
+ return theList
+
def getColumnSizes(self):
retVals = [
self.columnWidth(0),
@@ -229,7 +246,44 @@ class GuiDocTree(QTreeWidget):
]
return retVals
- def deleteItem(self, tHandle=None):
+ def emptyTrash(self):
+ """Permanently delete all documents in the Trash folder. This
+ function only asks for confirmation once, and calls the regular
+ deleteItem function for each document in the Trash folder.
+ """
+
+ logger.debug("Emptying Trash folder")
+ if self.theProject.trashRoot is None:
+ self.makeAlert("There is no Trash folder.", nwAlert.INFO)
+ return False
+
+ theTrash = self.getTreeFromHandle(self.theProject.trashRoot)
+ if self.theProject.trashRoot in theTrash:
+ theTrash.remove(self.theProject.trashRoot)
+
+ nTrash = len(theTrash)
+ if nTrash == 0:
+ self.makeAlert("The Trash folder is empty.", nwAlert.INFO)
+ return False
+
+ msgBox = QMessageBox()
+ msgRes = msgBox.question(
+ self, "Empty Trash", "Permanently delete %d file%s from Trash?" % (
+ nTrash, "s"*int(nTrash > 1)
+ )
+ )
+ if msgRes != QMessageBox.Yes:
+ return False
+
+ logger.verbose("Deleting %d files from Trash" % nTrash)
+ for tHandle in self.getTreeFromHandle(self.theProject.trashRoot):
+ if tHandle == self.theProject.trashRoot:
+ continue
+ self.deleteItem(tHandle, True)
+
+ return True
+
+ def deleteItem(self, tHandle=None, alreadyAsked=False):
"""Delete items from the tree. Note that this does not delete
the item from the item tree in the project object. However,
since this is only meta data, there isn't really a need to do
@@ -246,21 +300,61 @@ class GuiDocTree(QTreeWidget):
trItemS = self._getTreeItem(tHandle)
nwItemS = self.theProject.getItem(tHandle)
+ if nwItemS is None:
+ return False
+
if nwItemS.itemType == nwItemType.FILE:
logger.debug("User requested file %s moved to trash" % tHandle)
trItemP = trItemS.parent()
trItemT = self._addTrashRoot()
if trItemP is None or trItemT is None:
- logger.error("Could not move item to trash")
+ logger.error("Could not delete item")
return False
- tIndex = trItemP.indexOfChild(trItemS)
- trItemC = trItemP.takeChild(tIndex)
- trItemT.addChild(trItemC)
- nwItemS.setParent(self.theProject.trashRoot)
- self.clearSelection()
- trItemP.setSelected(True)
- self.theProject.setProjectChanged(True)
- self.theParent.theIndex.deleteHandle(tHandle)
+
+ pHandle = nwItemS.parHandle
+ if pHandle is not None and pHandle == self.theProject.trashRoot:
+ # If the file is in the trash folder already, as the
+ # user if they want to permanently delete the file.
+
+ doPermanent = False
+ if self.mainConf.showGUI and not alreadyAsked:
+ msgBox = QMessageBox()
+ msgRes = msgBox.question(
+ self, "Delete File", "Permanently delete file '%s'?" % nwItemS.itemName
+ )
+ if msgRes == QMessageBox.Yes:
+ doPermanent = True
+ else:
+ doPermanent = True
+
+ if doPermanent:
+ logger.debug("Permanently deleting file with handle %s" % tHandle)
+
+ tIndex = trItemP.indexOfChild(trItemS)
+ trItemC = trItemP.takeChild(tIndex)
+
+ if self.theParent.docEditor.theHandle == tHandle:
+ self.theParent.closeDocument()
+
+ theDoc = NWDoc(self.theProject, self.theParent)
+ theDoc.deleteDocument(tHandle)
+ self.theProject.deleteItem(tHandle)
+ self.theParent.theIndex.deleteHandle(tHandle)
+
+ else:
+ # The file is not already in the trash folder, so we
+ # move it there.
+
+ if pHandle is None:
+ logger.warning("File has no parent item")
+
+ tIndex = trItemP.indexOfChild(trItemS)
+ trItemC = trItemP.takeChild(tIndex)
+ trItemT.addChild(trItemC)
+ nwItemS.setParent(self.theProject.trashRoot)
+
+ self.theProject.setProjectChanged(True)
+ self.theParent.theIndex.deleteHandle(tHandle)
elif nwItemS.itemType == nwItemType.FOLDER:
logger.debug("User requested folder %s deleted" % tHandle)
@@ -271,8 +365,6 @@ class GuiDocTree(QTreeWidget):
tIndex = trItemP.indexOfChild(trItemS)
if trItemS.childCount() == 0:
trItemP.takeChild(tIndex)
- self.clearSelection()
- trItemP.setSelected(True)
self.theProject.deleteItem(tHandle)
else:
self.makeAlert(["Cannot delete folder.","It is not empty."], nwAlert.ERROR)
@@ -428,6 +520,9 @@ class GuiDocTree(QTreeWidget):
return newItem
def _addTrashRoot(self):
+ """Adds the trash root folder if it doesn't already exist in the
+ project tree.
+ """
if self.theProject.trashRoot is None:
self.theProject.addTrash()
trItem = self._addTreeItem(
@@ -518,16 +613,22 @@ class GuiDocTree(QTreeWidget):
"""
sHandle = self.getSelectedHandle()
if sHandle is None:
+ logger.error("No handle selected")
return
- dIndex = self.indexAt(theEvent.pos())
+ dIndex = self.indexAt(theEvent.pos())
if not dIndex.isValid():
+ logger.error("Invalid drop index")
return
dItem = self.itemFromIndex(dIndex)
dHandle = dItem.text(self.C_HANDLE)
snItem = self.theProject.getItem(sHandle)
dnItem = self.theProject.getItem(dHandle)
+ if dnItem is None:
+ self.makeAlert("The item cannot be moved to that location.", nwAlert.ERROR)
+ return
+
isSame = snItem.itemClass == dnItem.itemClass
isNone = snItem.itemClass == nwItemClass.NO_CLASS
isNote = snItem.itemLayout == nwItemLayout.NOTE
diff --git a/nw/gui/elements/outline.py b/nw/gui/elements/outline.py
index 403c20a0..0be50914 100644
--- a/nw/gui/elements/outline.py
+++ b/nw/gui/elements/outline.py
@@ -21,7 +21,6 @@ from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QTreeWidget, QTreeWidgetItem
)
-from nw.tools import OptLastState
from nw.constants import nwItemLayout, nwKeyWords, nwLabels, nwFiles
logger = logging.getLogger(__name__)
@@ -75,7 +74,7 @@ class GuiProjectOutline(QWidget):
self.theParent = theParent
self.theProject = theProject
self.theIndex = self.theParent.theIndex
- self.optState = OutlineLastState(self.theProject,nwFiles.OUTLINE_OPT)
+ self.optState = self.theProject.optState
self.showWords = True
self.showSynopsis = True
@@ -111,13 +110,13 @@ class GuiProjectOutline(QWidget):
colW.append(self.mainTree.columnWidth(iCol))
self.treeCols["width"] = colW
- self.optState.setSetting("headState", self.treeCols)
+ self.optState.setValue("GuiProjectOutline", "headState", self.treeCols)
self.optState.saveSettings()
return
def loadHeaderState(self):
- self.optState.loadSettings()
- treeCols = self.optState.getSetting("headState")
+
+ treeCols = self.optState.getValue("GuiProjectOutline", "headState", {})
if "order" not in treeCols.keys(): return
if not isinstance(treeCols["order"], list): return
@@ -253,15 +252,3 @@ class GuiProjectOutline(QWidget):
return
# END Class GuiProjectOutline
-
-class OutlineLastState(OptLastState):
-
- def __init__(self, theProject, theFile):
- OptLastState.__init__(self, theProject, theFile)
- self.theState = {
- "headState" : {},
- }
- self.dictOpt = ("headState")
- return
-
-# END Class OutlineLastState
diff --git a/nw/gui/elements/searchbar.py b/nw/gui/elements/searchbar.py
index cb40878e..21be1cfb 100644
--- a/nw/gui/elements/searchbar.py
+++ b/nw/gui/elements/searchbar.py
@@ -86,13 +86,13 @@ class GuiSearchBar(QFrame):
if not self.isVisible():
self.setVisible(True)
self.searchBox.setText(theText)
- self.searchBox.setFocus(True)
+ self.searchBox.setFocus()
logger.verbose("Setting search text to '%s'" % theText)
return True
def setReplaceText(self, theText):
self._replaceVisible(True)
- self.replaceBox.setFocus(True)
+ self.replaceBox.setFocus()
self.replaceBox.setText(theText)
return True
diff --git a/nw/gui/elements/viewdetails.py b/nw/gui/elements/viewdetails.py
index b92edd01..80cd8241 100644
--- a/nw/gui/elements/viewdetails.py
+++ b/nw/gui/elements/viewdetails.py
@@ -18,8 +18,6 @@ from PyQt5.QtWidgets import (
QWidget, QLabel, QScrollArea, QFrame, QToolButton, QCheckBox, QGridLayout
)
-from nw.constants import nwLabels
-
logger = logging.getLogger(__name__)
class GuiDocViewDetails(QWidget):
diff --git a/nw/gui/icons.py b/nw/gui/icons.py
index 88149b6e..a6a68db0 100644
--- a/nw/gui/icons.py
+++ b/nw/gui/icons.py
@@ -45,8 +45,11 @@ class GuiIcons:
}
DECO_MAP = {
- "export" : "export.svg",
- "settings" : "gear.svg",
+ "nwicon" : ["icons", "novelWriter.svg"],
+ "export" : ["graphics", "export.svg"],
+ "merge" : ["graphics", "merge.svg"],
+ "settings" : ["graphics", "gear.svg"],
+ "split" : ["graphics", "split.svg"],
}
def __init__(self, theParent):
@@ -65,6 +68,9 @@ class GuiIcons:
return
def initIcons(self, priPath):
+ """Load all icons listed in the icon map. Can be overridden by
+ the selected theme.
+ """
self.priPath = priPath
self.secPath = self.mainConf.iconPath
@@ -76,12 +82,19 @@ class GuiIcons:
return
def loadDecoration(self, decoKey, decoSize=None):
+ """Load graphical decoration element based on the decoration
+ map. This function always returns a QSwgWidget.
+ """
if decoKey not in self.DECO_MAP:
logger.error("Decoration with name '%s' does not exist" % decoKey)
return QSvgWidget()
- svgPath = path.join(self.mainConf.graphPath, self.DECO_MAP[decoKey])
+ svgPath = path.join(
+ self.mainConf.assetPath,
+ self.DECO_MAP[decoKey][0],
+ self.DECO_MAP[decoKey][1]
+ )
if not path.isfile(svgPath):
logger.error("Decoration file '%s' not in assets folder" % self.DECO_MAP[decoKey])
return QSvgWidget()
@@ -93,11 +106,17 @@ class GuiIcons:
return svgDeco
def getIcon(self, iconKey, iconSize=None):
+ """Return an icon from the icon buffer. If it doesn't exist,
+ return an empty icon.
+ """
if iconKey in self.qIcons:
return self.qIcons[iconKey]
return QIcon()
def getPixmap(self, iconKey, iconSize):
+ """Return an icon from the icon buffer as a QPixmap. If it
+ doesn't exist, return an empty QPixmap.
+ """
if iconKey in self.qIcons:
return self.qIcons[iconKey].pixmap(iconSize[0], iconSize[1], QIcon.Normal)
return QPixmap()
@@ -107,6 +126,11 @@ class GuiIcons:
##
def _loadIcon(self, iconKey):
+ """Load an icon from the assets or theme folder, with a
+ preference for dark/light icons depending on theme type, if such
+ an icon exists. Prefer svg files over png files. Always returns
+ a QIcon.
+ """
if iconKey not in self.ICON_MAP:
logger.error("Icon with name '%s' does not exist" % iconKey)
diff --git a/nw/gui/mainmenu.py b/nw/gui/mainmenu.py
index 358223eb..7d866564 100644
--- a/nw/gui/mainmenu.py
+++ b/nw/gui/mainmenu.py
@@ -48,11 +48,6 @@ class GuiMainMenu(QMenuBar):
return
- def openRecentProject(self, menuItem, recentItem):
- logger.verbose("User requested opening recent project #%d" % recentItem)
- self.theParent.openProject(self.mainConf.recentList[recentItem])
- return True
-
def setAvailableRoot(self):
for itemClass in nwItemClass:
if itemClass == nwItemClass.NO_CLASS: continue
@@ -66,30 +61,6 @@ class GuiMainMenu(QMenuBar):
# Update Menu on Settings Changed
##
- def updateMenu(self):
- self.updateRecentProjects()
- return
-
- def updateRecentProjects(self):
-
- self.recentMenu.clear()
- for n in range(len(self.mainConf.recentList)):
- recentProject = self.mainConf.recentList[n]
- if recentProject == "": continue
- menuItem = QAction("%s" % recentProject, self.projMenu)
- menuItem.triggered.connect(
- lambda menuItem, n=n : self.openRecentProject(menuItem, n)
- )
- self.recentMenu.addAction(menuItem)
-
- self.recentMenu.addSeparator()
- menuItem = QAction("Clear Recent Projects", self)
- menuItem.setStatusTip("Clear the list of recent projects")
- menuItem.triggered.connect(self._clearRecentProjects)
- self.recentMenu.addAction(menuItem)
-
- return
-
def setSpellCheck(self, theMode):
"""Set the spell check check box to theMode. This is controlled
by the document editor class, which holds the master spell check
@@ -166,11 +137,6 @@ class GuiMainMenu(QMenuBar):
QDesktopServices.openUrl(QUrl(nw.__docurl__))
return True
- def _clearRecentProjects(self):
- self.mainConf.clearRecent()
- self.updateRecentProjects()
- return True
-
def _showDocumentLocation(self):
self.theParent.docEditor.revealLocation()
return True
@@ -194,7 +160,7 @@ class GuiMainMenu(QMenuBar):
self.aOpenProject = QAction("Open Project", self)
self.aOpenProject.setStatusTip("Open project")
self.aOpenProject.setShortcut("Ctrl+Shift+O")
- self.aOpenProject.triggered.connect(lambda : self.theParent.openProject(None))
+ self.aOpenProject.triggered.connect(self.theParent.manageProjects)
self.projMenu.addAction(self.aOpenProject)
# Project > Save Project
@@ -211,10 +177,6 @@ class GuiMainMenu(QMenuBar):
self.aCloseProject.triggered.connect(lambda : self.theParent.closeProject(False))
self.projMenu.addAction(self.aCloseProject)
- # Project > Recent Projects
- self.recentMenu = self.projMenu.addMenu("Recent Projects")
- self.updateRecentProjects()
-
# Project > Project Settings
self.aProjectSettings = QAction("Project Settings", self)
self.aProjectSettings.setStatusTip("Project settings")
@@ -255,7 +217,7 @@ class GuiMainMenu(QMenuBar):
self.rootItems[itemClass].triggered.connect(
lambda nCount, itemClass=itemClass : self._newTreeItem(nwItemType.ROOT, itemClass)
)
- self.rootMenu.addActions(self.rootItems.values())
+ self.rootMenu.addAction(self.rootItems[itemClass])
# Project > New Folder
self.aCreateFolder = QAction("Create Folder", self)
@@ -281,6 +243,12 @@ class GuiMainMenu(QMenuBar):
self.aDeleteItem.triggered.connect(lambda : self.theParent.treeView.deleteItem(None))
self.projMenu.addAction(self.aDeleteItem)
+ # Project > Empty Trash
+ self.aEmptyTrash = QAction("Empty Trash", self)
+ self.aEmptyTrash.setStatusTip("Permanently delete all files in the Trash folder")
+ self.aEmptyTrash.triggered.connect(self.theParent.treeView.emptyTrash)
+ self.projMenu.addAction(self.aEmptyTrash)
+
# Project > Separator
self.projMenu.addSeparator()
@@ -369,6 +337,18 @@ class GuiMainMenu(QMenuBar):
self.aImportFile.triggered.connect(self.theParent.importDocument)
self.docuMenu.addAction(self.aImportFile)
+ # Document > Merge Documents
+ self.aMergeDocs = QAction("Merge Folder to Document", self)
+ self.aMergeDocs.setStatusTip("Merge a folder of documents to a single document")
+ self.aMergeDocs.triggered.connect(self.theParent.mergeDocuments)
+ self.docuMenu.addAction(self.aMergeDocs)
+
+ # Document > Split Document
+ self.aSplitDoc = QAction("Split Document to Folder", self)
+ self.aSplitDoc.setStatusTip("Split a document into a folder of multiple documents")
+ self.aSplitDoc.triggered.connect(self.theParent.splitDocument)
+ self.docuMenu.addAction(self.aSplitDoc)
+
return
def _buildViewMenu(self):
@@ -653,7 +633,7 @@ class GuiMainMenu(QMenuBar):
self.aReRunSpell = QAction("Re-Run Spell Check", self)
self.aReRunSpell.setStatusTip("Run the spell checker on current document")
self.aReRunSpell.setShortcut("F7")
- self.aReRunSpell.triggered.connect(self.theParent.docEditor.reHighlightDocument)
+ self.aReRunSpell.triggered.connect(self.theParent.docEditor.spellCheckDocument)
self.toolsMenu.addAction(self.aReRunSpell)
# Tools > Separator
diff --git a/nw/guimain.py b/nw/guimain.py
index 96e74be9..33c883b8 100644
--- a/nw/guimain.py
+++ b/nw/guimain.py
@@ -15,19 +15,20 @@ 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
+from PyQt5.QtGui import QIcon, QPixmap, QColor, QKeySequence
from PyQt5.QtWidgets import (
qApp, QMainWindow, QVBoxLayout, QFrame, QSplitter, QFileDialog, QShortcut,
QMessageBox, QProgressDialog, QDialog, QTabWidget
)
from nw.gui import (
- GuiMainMenu, GuiMainStatus, GuiTheme, GuiDocTree, GuiDocEditor,
- GuiDocViewer, GuiDocDetails, GuiSearchBar, GuiNoticeBar,
- GuiDocViewDetails, GuiConfigEditor, GuiProjectEditor, GuiExport,
- GuiItemEditor, GuiSessionLogView, GuiProjectOutline
+ GuiMainMenu, GuiMainStatus, GuiTheme, GuiDocTree, GuiDocEditor, GuiExport,
+ GuiDocViewer, GuiDocDetails, GuiSearchBar, GuiNoticeBar, GuiDocViewDetails,
+ GuiConfigEditor, GuiProjectEditor, GuiItemEditor, GuiProjectOutline,
+ GuiSessionLogView, GuiDocMerge, GuiDocSplit, GuiProjectLoad
)
from nw.project import NWProject, NWDoc, NWItem, NWIndex, NWBackup
from nw.tools import countWords
@@ -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)
)
@@ -64,7 +65,7 @@ class GuiMain(QMainWindow):
self.resize(*self.mainConf.winGeometry)
self._setWindowTitle()
- self.setWindowIcon(QIcon(path.join(self.mainConf.appIcon)))
+ self.setWindowIcon(QIcon(self.mainConf.appIcon))
# Main GUI Elements
self.statusBar = GuiMainStatus(self)
@@ -84,7 +85,7 @@ class GuiMain(QMainWindow):
# Assemble Main Window
self.treePane = QFrame()
- self.treeBox = QVBoxLayout()
+ self.treeBox = QVBoxLayout()
self.treeBox.setContentsMargins(0,0,0,0)
self.treeBox.addWidget(self.treeView)
self.treeBox.addWidget(self.treeMeta)
@@ -163,17 +164,12 @@ class GuiMain(QMainWindow):
# Shortcuts and Actions
self._connectMenuActions()
- QShortcut(
- Qt.Key_Return,
- self.treeView,
- context=Qt.WidgetShortcut,
- activated=self._treeKeyPressReturn
- )
- QShortcut(
- Qt.Key_Escape,
- self,
- activated=self._keyPressEscape
- )
+ keyReturn = QShortcut(self.treeView)
+ keyReturn.setKey(QKeySequence(Qt.Key_Return))
+ keyReturn.activated.connect(self._treeKeyPressReturn)
+ keyEscape = QShortcut(self)
+ keyEscape.setKey(QKeySequence(Qt.Key_Escape))
+ keyEscape.activated.connect(self._keyPressEscape)
# Forward Functions
self.setStatus = self.statusBar.setStatus
@@ -182,6 +178,9 @@ class GuiMain(QMainWindow):
if self.mainConf.showGUI:
self.show()
+ # Check that config loaded fine
+ self.reportConfErr()
+
self.initMain()
self.asProjTimer.start()
self.asDocTimer.start()
@@ -193,6 +192,12 @@ class GuiMain(QMainWindow):
logger.debug("GUI initialisation complete")
+ if self.mainConf.cmdOpen is not None:
+ logger.debug("Opening project from additional command line option")
+ self.openProject(self.mainConf.cmdOpen)
+ else:
+ self.manageProjects()
+
return
def clearGUI(self):
@@ -213,13 +218,30 @@ class GuiMain(QMainWindow):
# Project Actions
##
+ def manageProjects(self):
+ """Opens the projects dialog for selecting either existing
+ projects from a cache of recently opened projects, or provide a
+ browse button for projects not yet cached.
+ """
+ if not self.mainConf.showGUI:
+ return False
+
+ dlgProj = GuiProjectLoad(self)
+ dlgProj.exec_()
+ if dlgProj.result() == QDialog.Accepted:
+ self.openProject(dlgProj.openPath)
+
+ return True
+
def newProject(self, projPath=None, forceNew=False):
+ """Create new project with a few default files and folders.
+ """
if self.hasProject:
msgBox = QMessageBox()
msgRes = msgBox.warning(
self, "New Project",
- "Please close the current project before making a new one."
+ "Please close the current project before making a new one."
)
return False
@@ -232,7 +254,7 @@ class GuiMain(QMainWindow):
msgBox = QMessageBox()
msgRes = msgBox.critical(
self, "New Project",
- "A project already exists in that location. Please choose another folder."
+ "A project already exists in that location. Please choose another folder."
)
return False
@@ -247,9 +269,9 @@ class GuiMain(QMainWindow):
return True
def closeProject(self, isYes=False):
- """Closes the project if one is open.
- isYes is passed on from the close application event so the user
- doesn't get prompted twice.
+ """Closes the project if one is open. isYes is passed on from
+ the close application event so the user doesn't get prompted
+ twice.
"""
if not self.hasProject:
# There is no project loaded, everything OK
@@ -309,7 +331,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
@@ -323,7 +380,6 @@ class GuiMain(QMainWindow):
self.docEditor.setDictionaries()
self.docEditor.setSpellCheck(self.theProject.spellCheck)
self.statusBar.setRefTime(self.theProject.projOpened)
- self.mainMenu.updateMenu()
# Restore previously open documents, if any
if self.theProject.lastEdited is not None:
@@ -337,7 +393,7 @@ class GuiMain(QMainWindow):
return True
- def saveProject(self, isAuto=False):
+ def saveProject(self):
"""Save the current project.
"""
if not self.hasProject:
@@ -351,9 +407,8 @@ class GuiMain(QMainWindow):
return False
self.treeView.saveTreeOrder()
- self.theProject.saveProject(isAuto)
+ self.theProject.saveProject()
self.theIndex.saveIndex()
- self.mainMenu.updateRecentProjects()
return True
@@ -469,6 +524,22 @@ class GuiMain(QMainWindow):
return True
+ def mergeDocuments(self):
+ """Merge multiple documents to one single new document.
+ """
+ if self.mainConf.showGUI:
+ dlgMerge = GuiDocMerge(self, self.theProject)
+ dlgMerge.exec_()
+ return True
+
+ def splitDocument(self):
+ """Split a single document into multiple documents.
+ """
+ if self.mainConf.showGUI:
+ dlgSplit = GuiDocSplit(self, self.theProject)
+ dlgSplit.exec_()
+ return True
+
def passDocumentAction(self, theAction):
"""Pass on document action theAction to whatever document has
the focus. If no document has focus, the action is discarded.
@@ -676,6 +747,16 @@ class GuiMain(QMainWindow):
return
+ def reportConfErr(self):
+ """Checks if the Config module has any errors to report, and let
+ the user know if this is the case. The Config module caches
+ errors since it is initialised before the GUI itself.
+ """
+ if self.mainConf.hasError:
+ self.makeAlert(self.mainConf.getErrData(), nwAlert.ERROR)
+ return True
+ return False
+
##
# Main Window Actions
##
@@ -691,9 +772,9 @@ class GuiMain(QMainWindow):
return False
logger.info("Exiting %s" % nw.__package__)
- self.projView.saveHeaderState()
-
- self.closeProject(True)
+ if self.hasProject:
+ self.projView.saveHeaderState()
+ self.closeProject(True)
self.mainConf.setTreeColWidths(self.treeView.getColumnSizes())
if not self.mainConf.isFullScreen:
@@ -702,6 +783,7 @@ class GuiMain(QMainWindow):
self.mainConf.setMainPanePos(self.splitMain.sizes())
self.mainConf.setDocPanePos(self.splitView.sizes())
self.mainConf.saveConfig()
+ self.reportConfErr()
qApp.quit()
@@ -803,6 +885,12 @@ class GuiMain(QMainWindow):
self.addAction(self.mainMenu.aFmtULine)
self.addAction(self.mainMenu.aFmtDQuote)
self.addAction(self.mainMenu.aFmtSQuote)
+ self.addAction(self.mainMenu.aFmtHead1)
+ self.addAction(self.mainMenu.aFmtHead2)
+ self.addAction(self.mainMenu.aFmtHead3)
+ self.addAction(self.mainMenu.aFmtHead4)
+ self.addAction(self.mainMenu.aFmtComment)
+ self.addAction(self.mainMenu.aFmtNoFormat)
self.addAction(self.mainMenu.aSpellCheck)
self.addAction(self.mainMenu.aReRunSpell)
self.addAction(self.mainMenu.aPreferences)
@@ -820,7 +908,7 @@ class GuiMain(QMainWindow):
if (self.hasProject and self.theProject.projChanged and
self.theProject.projPath is not None):
logger.debug("Autosaving project")
- self.saveProject(isAuto=True)
+ self.saveProject()
return
def _autoSaveDocument(self):
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/document.py b/nw/project/document.py
index 0cc0fa66..9339a4cd 100644
--- a/nw/project/document.py
+++ b/nw/project/document.py
@@ -59,7 +59,7 @@ class NWDoc():
if self.theItem.parHandle == self.theProject.trashRoot:
self.docEditable = False
- docDir, docFile = self._assemblePath(self.FILE_MN)
+ docDir, docFile = self.assemblePath(self.docHandle, self.FILE_MN)
self.fileLoc = path.join(docDir,docFile)
logger.debug("Opening document %s" % self.fileLoc)
dataDir = path.join(self.theProject.projPath, docDir)
@@ -92,7 +92,7 @@ class NWDoc():
if self.docHandle is None or not self.docEditable:
return False
- docDir, docFile = self._assemblePath(self.FILE_MN)
+ docDir, docFile = self.assemblePath(self.docHandle, self.FILE_MN)
logger.debug("Saving document %s" % path.join(docDir,docFile))
dataPath = path.join(self.theProject.projPath, docDir)
docPath = path.join(dataPath, docFile)
@@ -100,39 +100,54 @@ class NWDoc():
mkdir(dataPath)
logger.debug("Created folder %s" % dataPath)
- docTemp = path.join(dataPath,docFile[:-3]+"tmp")
- docBack = path.join(dataPath,docFile[:-3]+"bak")
-
- if path.isfile(docTemp):
- unlink(docTemp)
- if path.isfile(docBack):
- rename(docBack,docTemp)
- if path.isfile(docPath):
- rename(docPath,docBack)
+ docTemp = path.join(dataPath, docFile+"~")
+ docBack = path.join(dataPath, docFile[:-3]+"bak")
try:
- with open(docPath,mode="w",encoding="utf8") as outFile:
+ with open(docTemp,mode="w",encoding="utf8") as outFile:
outFile.write(docText)
except Exception as e:
self.makeAlert(["Could not save document.",str(e)], nwAlert.ERROR)
return False
- if path.isfile(docTemp):
- unlink(docTemp)
+ # If we're here, the file was successfully saved,
+ # so let's sort out the temps and backups
+ if path.isfile(docBack):
+ unlink(docBack)
+ if path.isfile(docPath):
+ rename(docPath, docBack)
+ rename(docTemp, docPath)
self.theParent.statusBar.setStatus("Saved Document: %s" % self.theItem.itemName)
return True
- ##
- # Internal Functions
- ##
+ def deleteDocument(self, tHandle):
+ """Permanently delete a document source file and its backups
+ from the project data folder.
+ """
+ docDir, docFile = self.assemblePath(tHandle, self.FILE_MN)
+ dataPath = path.join(self.theProject.projPath, docDir)
+ chkList = []
+ chkList.append(path.join(dataPath, docFile))
+ chkList.append(path.join(dataPath, docFile+"~"))
+ chkList.append(path.join(dataPath, docFile[:-3]+"bak"))
+ for chkFile in chkList:
+ if path.isfile(chkFile):
+ try:
+ unlink(chkFile)
+ logger.debug("Deleted: %s" % chkFile)
+ except Exception as e:
+ self.makeAlert(["Could not delete document file.",str(e)], nwAlert.ERROR)
+ return False
+ return True
- def _assemblePath(self, docExt):
- if self.docHandle is None:
- return None
- docDir = "data_"+self.docHandle[0]
- docFile = self.docHandle[1:13]+"_"+docExt
+ @staticmethod
+ def assemblePath(tHandle, docExt):
+ if tHandle is None:
+ return None, None
+ docDir = "data_"+tHandle[0]
+ docFile = tHandle[1:13]+"_"+docExt
return docDir, docFile
# END Class NWDoc
diff --git a/nw/project/item.py b/nw/project/item.py
index 5eb98f4b..7ed0e6a3 100644
--- a/nw/project/item.py
+++ b/nw/project/item.py
@@ -14,7 +14,6 @@ import logging
import nw
from lxml import etree
-from datetime import datetime
from nw.common import checkInt
from nw.constants import nwItemType, nwItemClass, nwItemLayout
@@ -23,8 +22,6 @@ logger = logging.getLogger(__name__)
class NWItem():
- MAX_DEPTH = 8
-
def __init__(self, theProject):
self.theProject = theProject
diff --git a/nw/project/project.py b/nw/project/project.py
index 041029a1..afde1e02 100644
--- a/nw/project/project.py
+++ b/nw/project/project.py
@@ -13,8 +13,7 @@
import logging
import nw
-from os import path, mkdir, listdir
-from shutil import copyfile
+from os import path, mkdir, listdir, unlink, rename
from lxml import etree
from hashlib import sha256
from datetime import datetime
@@ -22,6 +21,7 @@ from time import time
from nw.project.status import NWStatus
from nw.project.item import NWItem
+from nw.tools import projectMaintenance, OptionState
from nw.common import checkString, checkBool, checkInt
from nw.constants import (
nwFiles, nwConst, nwItemType, nwItemClass, nwItemLayout, nwAlert
@@ -36,9 +36,11 @@ class NWProject():
# Internal
self.theParent = theParent
self.mainConf = self.theParent.mainConf
+ self.optState = OptionState(self)
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
@@ -50,7 +52,6 @@ class NWProject():
self.trashRoot = None # The handle of the trash root folder
self.projPath = None # The full path to where the currently open project is saved
self.projMeta = None # The full path to the project's meta data folder
- self.projCache = None # The full path to the project's cache folder
self.projDict = None # The spell check dictionary
self.projFile = None # The file name of the project main xml file
@@ -154,7 +155,6 @@ class NWProject():
self.trashRoot = None
self.projPath = None
self.projMeta = None
- self.projCache = None
self.projDict = None
self.projFile = nwFiles.PROJ_FILE
self.projName = ""
@@ -179,7 +179,12 @@ 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
+ build the tree of project items.
+ """
if not path.isfile(fileName):
fileName = path.join(fileName, nwFiles.PROJ_FILE)
@@ -188,24 +193,53 @@ class NWProject():
return False
self.clearProject()
- self.projPath = path.dirname(fileName)
+ self.projPath = path.abspath(path.dirname(fileName))
logger.debug("Opening project: %s" % self.projPath)
- self.projMeta = path.join(self.projPath,"meta")
- self.projCache = path.join(self.projPath,"cache")
- self.projDict = path.join(self.projMeta, nwFiles.PROJ_DICT)
+ self.projMeta = path.join(self.projPath,"meta")
+ self.projDict = path.join(self.projMeta, nwFiles.PROJ_DICT)
if not self._checkFolder(self.projMeta):
return
- if not self._checkFolder(self.projCache):
- 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:
+ logger.error(str(E))
try:
nwXML = etree.parse(fileName)
except Exception as e:
self.makeAlert(["Failed to parse project xml.",str(e)], nwAlert.ERROR)
- self.clearProject()
- return False
+
+ # Trying to open backup file instead
+ backFile = fileName[:-3]+"bak"
+ if path.isfile(backFile):
+ self.makeAlert("Attempting to open backup project file instead.", nwAlert.INFO)
+ try:
+ nwXML = etree.parse(backFile)
+ except Exception as e:
+ self.makeAlert(["Failed to parse project xml.",str(e)], nwAlert.ERROR)
+ self.clearProject()
+ return False
+ else:
+ self.clearProject()
+ return False
xRoot = nwXML.getroot()
nwxRoot = xRoot.tag
@@ -278,41 +312,47 @@ class NWProject():
nwItem.setFromTag(xValue.tag,xValue.text)
self._appendItem(tHandle,pHandle,nwItem)
- self.mainConf.setRecent(self.projPath)
+ self.optState.loadSettings()
+
+ # Update recent projects
+ self.mainConf.updateRecentCache(self.projPath, self.projName, self.lastWCount, time())
+ self.mainConf.saveRecentCache()
+
self.theParent.setStatus("Opened Project: %s" % self.projName)
self._scanProjectFolder()
self.setProjectChanged(False)
self.projOpened = time()
self.projAltered = False
+ self._writeLockFile()
return True
- def saveProject(self, isAuto=False):
+ def saveProject(self):
+ """Save the project main XML file. The saving command itself
+ uses a temporary filename, and the file is renamed afterwards to
+ make sure if the save fails, we're not left with a truncated
+ file.
+ """
if self.projPath is None:
self.makeAlert("Project path not set, cannot save.", nwAlert.ERROR)
return False
- self.projMeta = path.join(self.projPath,"meta")
- self.projCache = path.join(self.projPath,"cache")
+ self.projMeta = path.join(self.projPath,"meta")
+ saveTime = time()
- if not self._checkFolder(self.projPath): return
- if not self._checkFolder(self.projMeta): return
- if not self._checkFolder(self.projCache): return
+ if not self._checkFolder(self.projPath): return
+ if not self._checkFolder(self.projMeta): return
logger.debug("Saving project: %s" % self.projPath)
- # Save a copy of the current file, just in case
- if not isAuto:
- self._maintainPrevious()
-
# Root element and project details
logger.debug("Writing project meta")
nwXML = etree.Element("novelWriterXML",attrib={
"appVersion" : str(nw.__version__),
"fileVersion" : "1.0",
- "timeStamp" : datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
+ "timeStamp" : datetime.fromtimestamp(saveTime).strftime("%Y-%m-%d %H:%M:%S"),
})
# Save Project Meta
@@ -345,9 +385,11 @@ class NWProject():
self.projTree[tHandle].packXML(xContent)
# Write the xml tree to file
- saveFile = path.join(self.projPath,self.projFile)
+ tempFile = path.join(self.projPath, self.projFile+"~")
+ saveFile = path.join(self.projPath, self.projFile)
+ backFile = path.join(self.projPath, self.projFile[:-3]+"bak")
try:
- with open(saveFile,mode="wb") as outFile:
+ with open(tempFile, mode="wb") as outFile:
outFile.write(etree.tostring(
nwXML,
pretty_print = True,
@@ -358,15 +400,34 @@ class NWProject():
self.makeAlert(["Failed to save project.",str(e)], nwAlert.ERROR)
return False
- self.mainConf.setRecent(self.projPath)
+ # If we're here, the file was successfully saved,
+ # so let's sort out the temps and backups
+ if path.isfile(backFile):
+ unlink(backFile)
+ if path.isfile(saveFile):
+ rename(saveFile, backFile)
+ rename(tempFile, saveFile)
+
+ # Save project GUI options
+ self.optState.saveSettings()
+
+ # Update recent projects
+ 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
##
@@ -452,9 +513,6 @@ class NWProject():
self.setProjectChanged(True)
return True
- def getSessionWordCount(self):
- return self.currWCount - self.lastWCount
-
def setStatusColours(self, newCols):
replaceMap = self.statusItems.setNewEntries(newCols)
if self.projTree is not None:
@@ -497,6 +555,9 @@ class NWProject():
logger.error("No tree item with handle %s" % str(tHandle))
return None
+ def getSessionWordCount(self):
+ return self.currWCount - self.lastWCount
+
def getRootItem(self, tHandle):
"""Iterate upwards in the tree until we find the item with
parent None, the root item. We do this with a for loop with a
@@ -505,10 +566,13 @@ class NWProject():
tItem = self.getItem(tHandle)
if tItem is not None:
for i in range(200):
- tHandle = tItem.parHandle
- tItem = self.getItem(tHandle)
- if tItem is None:
+ if tItem.parHandle is None:
return tHandle
+ else:
+ tHandle = tItem.parHandle
+ tItem = self.getItem(tHandle)
+ if tItem is None:
+ return tHandle
return None
def getProjectItems(self):
@@ -560,6 +624,11 @@ class NWProject():
"""This only removes the item from the order list, but not from
the project tree.
"""
+ if tHandle not in self.treeOrder:
+ logger.warning(
+ "Could not remove item %s from treeOrder as it does not exist" % tHandle
+ )
+ return False
self.treeOrder.remove(tHandle)
self.setProjectChanged(True)
return True
@@ -598,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:
@@ -732,42 +868,4 @@ class NWProject():
itemHandle = self._makeHandle(addSeed+"!")
return itemHandle
- def _maintainPrevious(self):
- """This function will take the current project file and copy it
- into the project cache folder with an incremental file extension
- added. These serve as a backup in case the xml file gets
- corrupted.
- """
-
- countFile = path.join(self.projCache, nwFiles.PROJ_COUNT)
- projCount = 0
-
- if path.isfile(countFile):
- try:
- with open(countFile, mode="r") as inFile:
- projCount = int(inFile.read())+1
- except:
- projCount = 0
-
- if projCount > 9:
- projCount = 0
-
- projBackup = "%s.%d" % (nwFiles.PROJ_FILE, projCount)
-
- try:
- copyfile(
- path.join(self.projPath, self.projFile),
- path.join(self.projCache, projBackup)
- )
- except:
- logger.error("Failed to write to file %s" % projBackup)
-
- try:
- with open(countFile, mode="w") as outFile:
- outFile.write(str(projCount))
- except:
- logger.error("Failed to write to file %s" % countFile)
-
- return
-
# END Class NWProject
diff --git a/nw/tools/__init__.py b/nw/tools/__init__.py
index 582d18fb..d43736f9 100644
--- a/nw/tools/__init__.py
+++ b/nw/tools/__init__.py
@@ -1,7 +1,8 @@
# -*- coding: utf-8 -*-
from nw.tools.analyse import TextAnalysis
-from nw.tools.optlaststate import OptLastState
+from nw.tools.legacy import projectMaintenance
+from nw.tools.optionstate import OptionState
from nw.tools.spellcheck import NWSpellCheck
from nw.tools.spellenchant import NWSpellEnchant
from nw.tools.spellsimple import NWSpellSimple
@@ -10,7 +11,8 @@ from nw.tools.wordcount import countWords
__all__ = [
"TextAnalysis",
- "OptLastState",
+ "projectMaintenance",
+ "OptionState",
"NWSpellCheck",
"NWSpellEnchant",
"NWSpellSimple",
diff --git a/nw/tools/legacy.py b/nw/tools/legacy.py
new file mode 100644
index 00000000..0bc1e10a
--- /dev/null
+++ b/nw/tools/legacy.py
@@ -0,0 +1,63 @@
+# -*- coding: utf-8 -*-
+"""novelWriter Legacy Tools
+
+ novelWriter – Legacy Tools
+============================
+ Various functions to handle old projects
+
+ File History:
+ Created: 2020-02-13 [0.4.3]
+
+"""
+
+import logging
+import nw
+
+from os import path, unlink, rmdir
+
+logger = logging.getLogger(__name__)
+
+def projectMaintenance(theProject):
+ """Wrapper class for handling various tasks related to managing old
+ projects with content from older versions of novelWriter.
+ """
+
+ # Remove no longer used project cache folder
+ if path.isdir(theProject.projPath):
+ cacheDir = path.join(theProject.projPath, "cache")
+ if path.isdir(cacheDir):
+ logger.info("Deprecated cache folder found")
+ rmList = []
+ for i in range(10):
+ rmList.append(path.join(cacheDir, "nwProject.nwx.%d" % i))
+ rmList.append(path.join(cacheDir, "projCount.txt"))
+ for rmFile in rmList:
+ if path.isfile(rmFile):
+ logger.info("Deleting: %s" % rmFile)
+ try:
+ unlink(rmFile)
+ except Exception as e:
+ logger.error(str(e))
+ logger.info("Deleting: %s" % cacheDir)
+ try:
+ rmdir(cacheDir)
+ except Exception as e:
+ logger.error(str(e))
+
+ # Remove no longer used meta files
+ rmList = []
+ rmList.append(path.join(theProject.projMeta, "mainOptions.json"))
+ rmList.append(path.join(theProject.projMeta, "exportOptions.json"))
+ rmList.append(path.join(theProject.projMeta, "outlineOptions.json"))
+ rmList.append(path.join(theProject.projMeta, "timelineOptions.json"))
+ rmList.append(path.join(theProject.projMeta, "docMergeOptions.json"))
+ rmList.append(path.join(theProject.projMeta, "sessionLogOptions.json"))
+ for rmFile in rmList:
+ if path.isfile(rmFile):
+ logger.info("Deleting: %s" % rmFile)
+ try:
+ unlink(rmFile)
+ except Exception as e:
+ logger.error(str(e))
+
+ return
diff --git a/nw/tools/optionstate.py b/nw/tools/optionstate.py
new file mode 100644
index 00000000..881ff4e5
--- /dev/null
+++ b/nw/tools/optionstate.py
@@ -0,0 +1,162 @@
+# -*- coding: utf-8 -*-
+"""novelWriter Options State
+
+ novelWriter – Options State
+=============================
+ Class holding the last state of GUI options
+
+ File History:
+ Created: 2019-10-21 [0.3.1] - Original version meant to be sub classed
+ Created: 2020-02-19 [0.4.5] - Rewritten from superclass to single file tool
+
+"""
+
+import logging
+import json
+import nw
+
+from os import path
+
+from nw.common import checkString, checkBool, checkInt
+from nw.constants import nwFiles
+
+logger = logging.getLogger(__name__)
+
+class OptionState():
+
+ def __init__(self, theProject):
+
+ self.theProject = theProject
+ self.theState = {}
+ self.stringOpt = ()
+ self.boolOpt = ()
+ self.intOpt = ()
+
+ return
+
+ def loadSettings(self):
+ """Load the options dictionary from the project settings file.
+ """
+
+ stateFile = path.join(self.theProject.projMeta, nwFiles.OPTS_FILE)
+ theState = {}
+
+ if path.isfile(stateFile):
+ logger.debug("Loading GUI options file")
+ try:
+ with open(stateFile,mode="r",encoding="utf8") as inFile:
+ theJson = inFile.read()
+ theState = json.loads(theJson)
+ except Exception as e:
+ logger.error("Failed to load GUI options file")
+ logger.error(str(e))
+ return False
+ for anOpt in theState:
+ self.theState[anOpt] = theState[anOpt]
+
+ return True
+
+ def saveSettings(self):
+ """Save the options dictionary to the project settings file.
+ """
+
+ stateFile = path.join(self.theProject.projMeta, nwFiles.OPTS_FILE)
+ logger.debug("Saving GUI options file")
+
+ try:
+ with open(stateFile,mode="w+",encoding="utf8") as outFile:
+ outFile.write(json.dumps(self.theState, indent=2))
+ except Exception as e:
+ logger.error("Failed to save GUI options file")
+ logger.error(str(e))
+ return False
+
+ return True
+
+ def setValue(self, setGroup, setName, setValue):
+ """Saves a value, with a given group and name.
+ """
+ if not setGroup in self.theState:
+ self.theState[setGroup] = {}
+ self.theState[setGroup][setName] = setValue
+ return True
+
+ def getValue(self, getGroup, getName, defaultValue):
+ """Return an arbitrary type value, if it exists. Otherwise,
+ return the default value.
+ """
+ if getGroup in self.theState:
+ if getName in self.theState[getGroup]:
+ try:
+ return self.theState[getGroup][getName]
+ except:
+ return defaultValue
+ return defaultValue
+
+ def getString(self, getGroup, getName, defaultValue):
+ """Return the value as a string, if it exists. Otherwise, return
+ the default value.
+ """
+ if getGroup in self.theState:
+ if getName in self.theState[getGroup]:
+ try:
+ return str(self.theState[getGroup][getName])
+ except:
+ return defaultValue
+ return defaultValue
+
+ def getInt(self, getGroup, getName, defaultValue):
+ """Return the value as an int, if it exists. Otherwise, return
+ the default value.
+ """
+ if getGroup in self.theState:
+ if getName in self.theState[getGroup]:
+ try:
+ return int(self.theState[getGroup][getName])
+ except:
+ return defaultValue
+ return defaultValue
+
+ def getFloat(self, getGroup, getName, defaultValue):
+ """Return the value as a float, if it exists. Otherwise, return
+ the default value.
+ """
+ if getGroup in self.theState:
+ if getName in self.theState[getGroup]:
+ try:
+ return float(self.theState[getGroup][getName])
+ except:
+ return defaultValue
+ return defaultValue
+
+ def getBool(self, getGroup, getName, defaultValue):
+ """Return the value as a bool, if it exists. Otherwise, return
+ the default value.
+ """
+ if getGroup in self.theState:
+ if getName in self.theState[getGroup]:
+ try:
+ return bool(self.theState[getGroup][getName])
+ except:
+ return defaultValue
+ return defaultValue
+
+ def validIntRange(self, theValue, intA, intB, intDefault):
+ """Check that an int is in a given range. If it isn't, return
+ the default value.
+ """
+ if isinstance(theValue, int):
+ if theValue >= intA and theValue <= intB:
+ return theValue
+ return intDefault
+
+ def validIntTuple(self, theValue, theTuple, intDefault):
+ """Check that an int is an element of a tuple. If it isn't,
+ return the default value.
+ """
+ if isinstance(theValue, int):
+ if theValue in theTuple:
+ return theValue
+ return intDefault
+
+# END Class OptionState
diff --git a/nw/tools/optlaststate.py b/nw/tools/optlaststate.py
deleted file mode 100644
index bce89f00..00000000
--- a/nw/tools/optlaststate.py
+++ /dev/null
@@ -1,115 +0,0 @@
-# -*- coding: utf-8 -*-
-"""novelWriter Options Last State
-
- novelWriter – Options Last State
-==================================
- Class holding the last state of GUI options
-
- File History:
- Created: 2019-10-21 [0.3.1]
-
-"""
-
-import logging
-import json
-import nw
-
-from os import path
-
-from nw.common import checkString, checkBool, checkInt
-
-logger = logging.getLogger(__name__)
-
-class OptLastState():
-
- def __init__(self, theProject, theFile):
- self.theProject = theProject
- self.theFile = theFile
- self.theState = {}
- self.stringOpt = ()
- self.boolOpt = ()
- self.dictOpt = ()
- self.intOpt = ()
- return
-
- def loadSettings(self):
-
- if self.theProject.projMeta is None or self.theFile is None:
- logger.error("Cannot load file '%s' to path '%s'" % (
- str(self.theFile), str(self.theProject.projMeta)
- ))
- return False
-
- stateFile = path.join(self.theProject.projMeta,self.theFile)
- theState = {}
-
- if path.isfile(stateFile):
- logger.debug("Loading options file")
- try:
- with open(stateFile,mode="r",encoding="utf8") as inFile:
- theJson = inFile.read()
- theState = json.loads(theJson)
- except Exception as e:
- logger.error("Failed to load options file")
- logger.error(str(e))
- return False
-
- for anOpt in theState:
- self.theState[anOpt] = theState[anOpt]
-
- return True
-
- def saveSettings(self):
-
- if self.theProject.projMeta is None or self.theFile is None:
- logger.error("Cannot save file '%s' to path '%s'" % (
- str(self.theFile), str(self.theProject.projMeta)
- ))
- return False
-
- stateFile = path.join(self.theProject.projMeta,self.theFile)
- logger.debug("Saving options file")
- try:
- with open(stateFile,mode="w+",encoding="utf8") as outFile:
- outFile.write(json.dumps(self.theState, indent=2))
- except Exception as e:
- logger.error("Failed to save options file")
- logger.error(str(e))
- return False
-
- return True
-
- def setSetting(self, setName, setValue):
- if setName in self.theState:
- self.theState[setName] = setValue
- else:
- return False
- return True
-
- def getSetting(self, setName):
- if setName in self.stringOpt:
- return checkString(self.theState[setName],self.theState[setName],False)
- elif setName in self.boolOpt:
- return checkBool(self.theState[setName],self.theState[setName],False)
- elif setName in self.dictOpt:
- if isinstance(self.theState[setName], dict):
- return self.theState[setName]
- else:
- return {}
- elif setName in self.intOpt:
- return checkInt(self.theState[setName],self.theState[setName],False)
- return None
-
- def validIntRange(self, theValue, intA, intB, intDefault):
- if isinstance(theValue, int):
- if theValue >= intA and theValue <= intB:
- return theValue
- return intDefault
-
- def validIntTuple(self, theValue, theTuple, intDefault):
- if isinstance(theValue, int):
- if theValue in theTuple:
- return theValue
- return intDefault
-
-# END Class OptLastState
diff --git a/pytest.ini b/pytest.ini
index 17f2ac93..f266d081 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -4,3 +4,4 @@ markers =
core: Core functionality tests
gui: Qt5 GUI tests
serial
+qt_api = pyqt5
diff --git a/requirements.txt b/requirements.txt
index b1d28992..648be38e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,5 +1,4 @@
pyqt5
-appdirs
lxml
pyenchant
latexcodec
diff --git a/sample/sampleNovel/nwProject.nwx b/sample/sampleNovel/nwProject.nwx
index 2da17d79..448a7375 100644
--- a/sample/sampleNovel/nwProject.nwx
+++ b/sample/sampleNovel/nwProject.nwx
@@ -1,5 +1,5 @@
-
+Sample ProjectSample Project
@@ -33,7 +33,7 @@
Main
-
+ NovelROOT
@@ -82,7 +82,7 @@
11992167
- 949
+ 19Another Scene
diff --git a/setup.py b/setup.py
index 38e3f3c8..91393346 100755
--- a/setup.py
+++ b/setup.py
@@ -6,7 +6,7 @@ with open("README.md", "r") as inFile:
setuptools.setup(
name = "novelWriter",
- version = "0.4.2",
+ version = "0.4.5",
author = "Veronica Berglyd Olsen",
author_email = "code@vkbo.net",
description = "A markdown-like document editor for writing novels",
@@ -35,10 +35,9 @@ setuptools.setup(
"Natural Language :: English",
"Topic :: Text Editors",
],
- python_requires = ">=3.5",
+ python_requires = ">=3.6",
install_requires = [
"pyqt5",
- "appdirs",
"lxml",
"pyenchant",
"latexcodec",
diff --git a/tests/reference/novelwriter.conf b/tests/reference/novelwriter.conf
index 79fb70a7..bac82e44 100644
--- a/tests/reference/novelwriter.conf
+++ b/tests/reference/novelwriter.conf
@@ -1,5 +1,5 @@
[Main]
-timestamp = 2019-11-19 21:49:29
+timestamp = 2020-02-26 18:10:35
theme = default
syntax = default_light
guidark = False
@@ -49,14 +49,4 @@ viewcomments = True
[Path]
lastpath =
-recent0 =
-recent1 =
-recent2 =
-recent3 =
-recent4 =
-recent5 =
-recent6 =
-recent7 =
-recent8 =
-recent9 =
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 @@
00
-
+ TimelineROOTTIMELINENewFalse
-
+ ObjectROOTOBJECTNewFalse
-
+ Custom1ROOTCUSTOMNewFalse
-
+ Custom2ROOTCUSTOM
diff --git a/tests/test_config.py b/tests/test_config.py
index 4c4c2523..a8562976 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -13,7 +13,7 @@ theConf = Config()
def testConfigInit(nwTemp,nwRef):
tmpConf = path.join(nwTemp,"novelwriter.conf")
refConf = path.join(nwRef, "novelwriter.conf")
- assert theConf.initConfig(nwTemp)
+ assert theConf.initConfig(nwTemp, nwTemp)
assert theConf.setLastPath("")
assert theConf.saveConfig()
assert cmpFiles(tmpConf, refConf, [2])
@@ -37,6 +37,14 @@ def testConfigSetConfPath(nwTemp):
assert theConf.confFile == "novelwriter.conf"
assert not theConf.confChanged
+@pytest.mark.core
+def testConfigSetDataPath(nwTemp):
+ assert theConf.setDataPath(None)
+ assert not theConf.setDataPath(path.join("somewhere","over","the","rainbow"))
+ assert theConf.setDataPath(nwTemp)
+ assert theConf.dataPath == nwTemp
+ assert not theConf.confChanged
+
@pytest.mark.core
def testConfigLoad():
assert theConf.loadConfig()
@@ -67,7 +75,7 @@ def testConfigSetTreeColWidths(nwTemp,nwRef):
assert not theConf.confChanged
@pytest.mark.core
-def testConfigSetMainPanePos(nwTemp,nwRef):
+def testConfigSetPanePos(nwTemp,nwRef):
tmpConf = path.join(nwTemp,"novelwriter.conf")
refConf = path.join(nwRef, "novelwriter.conf")
assert theConf.setMainPanePos([0, 0])
@@ -77,3 +85,31 @@ def testConfigSetMainPanePos(nwTemp,nwRef):
assert theConf.saveConfig()
assert cmpFiles(tmpConf, refConf, [2])
assert not theConf.confChanged
+
+@pytest.mark.core
+def testConfigFlags(nwTemp,nwRef):
+ tmpConf = path.join(nwTemp,"novelwriter.conf")
+ refConf = path.join(nwRef, "novelwriter.conf")
+ assert not theConf.setShowRefPanel(False)
+ assert theConf.setShowRefPanel(True)
+ assert not theConf.setViewComments(False)
+ assert theConf.setViewComments(True)
+ assert theConf.confChanged
+ assert theConf.saveConfig()
+ assert cmpFiles(tmpConf, refConf, [2])
+ assert not theConf.confChanged
+
+@pytest.mark.core
+def testConfigErrors(nwTemp):
+ nonPath = path.join("somewhere","over","the","rainbow")
+ assert theConf.initConfig(nonPath, nonPath)
+ assert theConf.hasError
+ assert not theConf.loadConfig()
+ assert not theConf.saveConfig()
+ assert not theConf.loadRecentCache()
+ assert len(theConf.getErrData()) > 0
+
+@pytest.mark.core
+def testConfigInternals():
+ assert theConf._checkNone(None) is None
+ assert theConf._checkNone("None") is None
diff --git a/tests/test_gui.py b/tests/test_gui.py
index d5633b0e..c8f8d013 100644
--- a/tests/test_gui.py
+++ b/tests/test_gui.py
@@ -17,8 +17,8 @@ keyDelay = 10
stepDelay = 50
@pytest.mark.gui
-def testMainWindows(qtbot, nwTempGUI, nwRef):
- nwGUI = nw.main(["--testmode","--config=%s" % nwTempGUI])
+def testMainWindows(qtbot, nwTempGUI, nwRef, nwTemp):
+ nwGUI = nw.main(["--testmode","--config=%s" % nwTempGUI, "--data=%s" % nwTemp])
qtbot.addWidget(nwGUI)
nwGUI.show()
qtbot.waitForWindowShown(nwGUI)
@@ -36,7 +36,6 @@ def testMainWindows(qtbot, nwTempGUI, nwRef):
assert nwGUI.theProject.trashRoot is None
assert nwGUI.theProject.projPath is None
assert nwGUI.theProject.projMeta is None
- assert nwGUI.theProject.projCache is None
assert nwGUI.theProject.projFile == "nwProject.nwx"
assert nwGUI.theProject.projName == ""
assert nwGUI.theProject.bookTitle == ""
@@ -61,7 +60,6 @@ def testMainWindows(qtbot, nwTempGUI, nwRef):
assert nwGUI.theProject.trashRoot is None
assert nwGUI.theProject.projPath == nwTempGUI
assert nwGUI.theProject.projMeta == path.join(nwTempGUI,"meta")
- assert nwGUI.theProject.projCache == path.join(nwTempGUI,"cache")
assert nwGUI.theProject.projFile == "nwProject.nwx"
assert nwGUI.theProject.projName == ""
assert nwGUI.theProject.bookTitle == ""
@@ -252,8 +250,8 @@ def testMainWindows(qtbot, nwTempGUI, nwRef):
# qtbot.stopForInteraction()
@pytest.mark.gui
-def testProjectEditor(qtbot, nwTempGUI, nwRef):
- nwGUI = nw.main(["--testmode","--config=%s" % nwTempGUI])
+def testProjectEditor(qtbot, nwTempGUI, nwRef, nwTemp):
+ nwGUI = nw.main(["--testmode","--config=%s" % nwTempGUI, "--data=%s" % nwTemp])
qtbot.addWidget(nwGUI)
nwGUI.show()
qtbot.waitForWindowShown(nwGUI)
@@ -339,8 +337,8 @@ def testProjectEditor(qtbot, nwTempGUI, nwRef):
# qtbot.stopForInteraction()
@pytest.mark.gui
-def testItemEditor(qtbot, nwTempGUI, nwRef):
- nwGUI = nw.main(["--testmode","--config=%s" % nwTempGUI])
+def testItemEditor(qtbot, nwTempGUI, nwRef, nwTemp):
+ nwGUI = nw.main(["--testmode","--config=%s" % nwTempGUI, "--data=%s" % nwTemp])
qtbot.addWidget(nwGUI)
nwGUI.show()
qtbot.waitForWindowShown(nwGUI)
diff --git a/tests/test_project.py b/tests/test_project.py
index 41b5246a..a38d6d58 100644
--- a/tests/test_project.py
+++ b/tests/test_project.py
@@ -22,13 +22,14 @@ theProject = NWProject(theMain)
theProject.handleSeed = 42
@pytest.mark.project
-def testProjectNew(nwTempProj,nwRef):
+def testProjectNew(nwTempProj,nwRef,nwTemp):
projFile = path.join(nwTempProj,"nwProject.nwx")
refFile = path.join(nwRef,"proj","1_nwProject.nwx")
- assert theConf.initConfig(nwRef)
+ assert theConf.initConfig(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
@@ -95,3 +109,5 @@ def testIndexScanThis(nwTempProj):
assert isValid
assert str(theBits) == "['@tag', 'this', 'and this']"
assert str(thePos) == "[0, 6, 12]"
+
+ assert theProject.closeProject()