Merge branch 'main' into i18n-de_DE-created

This commit is contained in:
Veronica Berglyd Olsen
2022-11-13 10:52:13 +01:00
committed by GitHub
214 changed files with 23012 additions and 14496 deletions
+2 -2
View File
@@ -14,7 +14,7 @@ jobs:
testLinux:
strategy:
matrix:
python-version: ["3.7", "3.8", "3.9", "3.10"]
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"]
runs-on: ubuntu-latest
steps:
- name: Python Setup
@@ -25,7 +25,7 @@ jobs:
- name: Install Packages (apt)
run: |
sudo apt update
sudo apt install libenchant-dev qttools5-dev-tools
sudo apt install libenchant-dev qttools5-dev-tools aspell-en
- name: Checkout Source
uses: actions/checkout@v2
- name: Install Dependencies (pip)
+333 -4
View File
@@ -1,7 +1,237 @@
# novelWriter Changelog
## Version 2.0 RC 2 [2022-11-13]
### Release Notes
This is a release candidate of the next release version, and is intended for testing purposes.
Please be careful when using this version on live writing projects, and make sure you take frequent
backups.
Please check the changelog for an overview of changes. The full release notes will be added to the
final release.
### Detailed Changelog
Note: This release introduces a new Project XML format with version number 1.5. When the project is
opened, a request to update the file format will show up.
**Bugfixes**
* The custom folders for user defined themes and syntax were not created properly when the app was
first launched on a new computer. The folders were successfully created on a second launch. The
error was handled, but reported. This was caused by the folder creation process being in the
wrong order. Issue #1180. PR #1184.
* Fixed context menu entries for split and merge having inconsistent labels. Issue #1199. PR #1197.
**User Interface**
* The exported status for document items have been renamed to active/inactive. Their icons have
also been updated. Issues #1196 and #1198. PRs #1200 and #1216.
* The status/importance context menu now shows which label is the current. Issue #1202. PR #1207.
* The status/importance context menu now has a "Manage Labels" action that opens the Project
Settings dialog at the correct place. Issue #1203. PR #1207.
* Both GUI theme and syntax theme can now be updated without restarting the app. Issue #1171.
PR #1212.
* The GUI theme now determines which icon theme is to be loaded. It is no longer a separate
setting. The icon theme can also be reloaded without restart. Issue #1172. PR #1212.
* The block formating features in the Format menu now also works on empty lines. Issue #1178.
PR #1214.
* There is now a Format menu entry and shortcut code for synopsis comments. Issue #1177. PR #1214.
* The split document dialog now has the option to move teh source document to trash. Issue #1179.
PR #1217.
**Other Changes**
* Archived documents are now partially indexed. This mainly means that the item will have the
correct document icon in the project tree corresponding to its main heading. Issue #1176.
PR #1183.
* The option to add notes files in the Project Wizard now automatically switches off if there are
no notes categories enabled. Issue #1192. PR #1201.
* When a project is opened for the first time, the first document in the project is also opened.
Issue #1219. PR #1223.
* When there is no project open, the toolbars on the Project Tree, Novel View and Outline View are
disabled. They are enabled only when a project is loaded. Issue #1220. PR #1230.
**Installation and Packaging**
* The AppImage release now has version information in the package name. Issue #1182. PR #1218.
**Code Improvements**
* The main heading of a document is now stored in the item class instead of the index. PR #1183.
* The common module checker functions no longer allow None values. The only one needing it was the
string checker. A new string checker that allows None has been added for those cases. This makes
type discovery in the code editor easier. Issue #1185. PR #1188.
* Verbose logging has been removed. The lowest severity level is now DEBUG. Issue #1186. PR #1191.
* Added a number of None checks in the code where especially Qt calls could potentially return
None, even if they were unlikely to do so. PR #1197.
* Renamed the status bar attribute in the main GUI class as it conflicts with a Qt method.
Issue #1190. PR #1197.
* The data access methods for the custom config file parser have been improved to better report
correct type information. PR #1197.
* Saving and loading of XML data is now handled by a separate set of reader and writer classes. The
reader class is capable of reading all file formats that have been used thus far. The various
data classes have been improved, and a new XML file formart version 1.5 added. Issue #1189.
PRs #1221 and #1232.
* The index is now automatically rebuilt when the project file format is updated. Issue #1235.
PR #1236.
* The project folder on disk is now wrapped in a storage class that the project accesses files
through. It also handles lock files and archiving used for backup. The change is in preparation
for adding a potential single file format. Issue #1222. PR #1225.
* The Project Wizard now creates the project on disk, and then opens it. This replaces the old
method where the new project was built directly into the current session. This caused a few
inconsistencies from time to time, and was a duplicate way of getting a project into the session.
Issue #1152. PR #1225.
* The Config class has been refactored extensively and now also uses pathlib for all paths. Tests
are also switched to using pathlib. Issue #1224. PRs #1228 and #1229.
* The updating of tree order method of the project tree class has been updated for better
performance. PR #1236.
----
## Version 2.0 RC 1 [2022-10-17]
### Release Notes
This is a release candidate of the next release version, and is intended for testing purposes.
Please be careful when using this version on live writing projects, and make sure you take frequent
backups.
Please check the changelog for an overview of changes. The full release notes will be added to the
final release.
**Note:** As of the 2.0 release, novelWriter requires Qt 5.10 or higher and Python 3.7 or higher.
### Detailed Changelog
**Note:** This will no longer be release 1.7, but 2.0 instead due to the major changes to the User
Interface. PR #1146.
**Features**
* The add new documents feature has been made a little smarter and now tries to choose whether the
added document should be a sibling or a child to the selected document. Issue #1107. PR #1110.
* A folder in the Project Tree can now be converted to a Novel Document or Project Note from the
item's context menu. This makes sense now that documents can have child items. Issue #1071.
PR #1128.
* All child elements of an item in the Project Tree can now be collectively expanded or collapsed
from the context menu. The same action can be triggered on the entire tree from the menu button
at the top. Issue #1122. PR #1129.
* If using the auto-insert feature for spaces next to punctuation designed for for instance French,
any existing spaces are first stripped before the correct space character is added. Some users
find it hard to unlearn the reflex to type the space. Issue #1061. PR #1131.
* The Project Tree now has Quick Links for navigating directly between root folders. This is
convenient for very large projects. Issue #1137. PR #1165.
* It is now possible to filter out entire root folders on the Build Tool. Issue #1138. PR #1168.
**Bugfixes**
* Fix a typo in the Lorem Ipsum tool and set a max width for the views bar. PR #1065.
* The language set in the Build Tool is now properly exported to Open Document files. Previously,
it would be set to English regardless of the user's selection. Issue #1073. PR #1077.
* Since the text editor cursor extends to the right of its position in the window, it would
disappear under the right-hand margin if it reached the edge. A minimum margin of the width of
the cursor has been added to the editor, and the same value subtracted from the margin of the
viewbox. Thus, the cursor no longer disappears. Issue #1112. PR #1113.
* The `Shift+Enter` key combination no longer inserts a Unicode line separator in the text editor.
Issue #1150. PR #1151.
* Fixed an issue where idle time would not be properly reset when a new project was created.
Issue #1149. PR #1153.
* Removed the context menu that would appear on the Views Bar, from which the bar could be hidden.
This is some feature imposed by the Qt library, and it is not wanted. Issue #1147. PR #1153.
**Known Issues**
* An attempt has been made to fix the missing mime icon for novelWriter projects on Linux. The
newer versions of Nautilus don't seem to display the correct icon due to some changes in the way
icons are extracted from the theme. The icon displays fine in other places. PR #1068.
**Internationalisation**
* Norwegian and US English translations have been updated. PR #1170.
**User Interface**
* The New Project Wizard has been updated based on user feedback. The "Working Title" setting is
now called "Project Name". The number of root folders one can create on the options page has been
reduced to the standard ones. An option to add sample notes has been added, and the option to
generate chapter folders has been removed. The "Minimal Project" option also no longer creates
folders. An Archive root folder is automatically added to all new projects, and a Trash folder is
added to custom projects. PR #1067.
* The border around a lot of Widgets on the main GUI have been removed. PR #1069.
* The Views Bar has been updated since the beta re;ease with better icons, tooltips, and an
expanding menu on the Settings button. The Build Tool can now also be accessed from the Views
Bar. PR #1069.
* The editor theme setting in Preferences has been moved back to the General tab where users seem
to expect to find it. PR #1069.
* A toolbar has been added to the Outline View where the user can select which Novel folder to
view. A refresh button for a forced reload of the selected Novel folder has been added, and the
menu to select which columns to show has been added to a new button. This makes it easier to find
for users instead of having to right-click the table header. Issue #1105. PRs #1063, #1094, and
#1111.
* The Rebuild Outline and Auto-Update Outline options have been removed from the Main Menu.
PR #1063.
* The Project Tree has been redesigned. The header is now hidden, and the columns resize
automatically. A toolbar has been added with buttons for moving items up and down in the tree, a
button for adding new files, folders and root folders, and a menu button for further options
affecting the whole tree. These features have mostly bee removed from the Main Menu. PR #1079.
* The context menu of the Project Tree has been rewritten completely. It is now possible to set
importance and status directly from this menu. Part of issue #973. PRs #1079 and #1105.
* The Edit Item dialog has been replaced with a simple Edit Label dialog. Most of the features
handled by the old dialog have been moved to the Project Tree context menu. PR #1082.
* The Novel View has been redesigned to match the new Outline View and Project Tree. The header is
now hidden, and the columns auto-size. The third column can be selected from a set of options, or
be hidden entirely. A button with a menu can be used to select which Novel folder to show.
Issue #1041. PR #1084.
* An arrow icon has been added behind each item in the Novel View. Clicking it, will pop up a
tooltip showing the collected meta data for the heading the item represents. PR #1088.
* The Project Details dialog now supports multiple novel folders. Issue #1078. PRs #1130 and #1167.
* The Split and Merge tools have been rewritten to work with the new feature of allowing documents
to have child documents. The tools have also had more options added to them. The feature is now
accessible through the Project Tree item's context menu, and is no longer available from the Main
Menu. Issues #1072 and #1032. PRs #1148 and #1154.
**Installation and Packaging**
* AppImage distribution has been added by @Ryex. Issue #1091. PR #1092.
**Code Improvements**
* A new test project generator function has been added to replace the dependency on the New Project
tools in the app itself. This ensures that changing the in-app feature doesn't affect the entire
test suite. Over time, this generator function should also replace the minimal sample project
saved in the test suite. PR #1067.
* The index class has been rewritten. The index data is now stored in a hierarchy of objects rather
than a set of nested dictionaries. The `itemIndex` holds all the data collected from the project,
and the `tagsIndex` is a reverse lookup index for linking tags back to where they are used. The
reading/writing of the index to disk between sessions is now handled by pack/unpack functions in
each object. The index will be regenerated when the user first opens a project as it has been
completely restructured. PR #1074.
* The index instance is now an instance of the project class, not the main GUI. PR #1074.
* The Outline View has been restructured into a single parent widget in the same manner as the
Novel View was in the previous pre-release. Related to #1041. PR #1063.
* A lot of main GUI objects have been given new names in the code to better represent what they do.
PR #1081.
* The converter for the old 1.0 project structure has been simplified. Mostly to reduce the amount
of code and number of translation labels needed for it. It now produces a single error if
something went wrong. PR #1083.
* A number of unused icons have been removed from the code base. PR #1164.
* All checks for Qt versions below 5.10 have been removed. PR #1174.
**Not Implemented**
* An idea to make the indexer run in the global thread pool was not implemented. Issue #1076.
* Feature #997 is now obsolete due to the changes made to the Edit Item dialog in #1082.
* Feature #1106 is not implemented. It proposes to hide the option to select Novel folder if there
is only one. We will see if this is really needed based on user feedback.
----
## Version 1.7 Beta 1 [2022-05-17]
**Note:** The 1.7 release cycle was renamed 2.0 on 2022-10-06. See #1144.
### Release Notes
This is a beta release of the next release version, and is intended for testing purposes. Please be
@@ -89,6 +319,105 @@ final release.
----
## Version 1.6.6 [2022-10-25]
### Release Notes
This is a bugfix release that fixes a minor issues with following tags in the editor. It is now
possible to also follow tags that contain spaces.
### Detailed Changelog
**Bugfixes**
* Fix a bug where only the word under the cursor would be looked up when the user tried to follow a
tag in the editor. The lookup function now uses the same parser for the `@`-line as the syntax
highlighter does, so they should behave consistently. Issue #1195. PR #1209.
----
## Version 1.6.5 [2022-10-13]
### Release Notes
This is a bugfix release that fixes a few minor issues. The idle time for new projects would be
artificially inflated as the clock was not reset when the project was first created. This only
affects the first entry in the writing statistics. A scaling issue for the Preferences dialog has
also been fixed. It only affected screens with UI scaling enabled. Lastly, typing `Shift+Enter` in
the text editor now creates a regular line break instead of a special line separator. The line
separator serves no purpose in plain text, and was producing inconsistencies in how text is
processed and displayed.
### Detailed Changelog
**Bugfixes**
* Fixed a bug where the idle time was not properly zeroed when a new project was generated after
the wizard was closed. The idle time would be calculated from the time the previous project
closed, thus inflating the value. Issue #1149. PR #1159.
* Fixes an issue where the window size of the Preferences dialog would have the GUI scaling factor
applied twice when the dialog was closed, resulting in the dialog growing in size each time it is
opened. Issue #989. PR #1159.
**Other Changes**
* The text editor no longer creates a Unicode line separator (U+2028) when the user presses
`Shift+Enter`. The line separator serves no purpose in a plain text editor, and the code in
general treats them as regular line break. This caused the line separator to display differently
before and after saving. The line separator character is now automatically replaced by a
paragraph separator. Issue #1150. PR #1159.
----
## Version 1.6.4 [2022-09-29]
### Release Notes
This is a bugfix release that fixes a critical bug in the insert non-breaking spaces feature. It
basically no longer worked in the 1.6.3 release. This release also fixes a minor issue where the
text cursor sometimes disappears when reaching the right-hand edge of the text editor window.
### Detailed Changelog
**Bugfixes**
* Fixed a bug in the auto-replace feature of the editor that caused a crash when using the insert
non-breaking spaces feature was used. Issue #1118. PR #1120.
* Back ported a bugfix from 1.7 RC1 that resolves an issue with the text cursor sometimes
disappearing at the right-hand edge of the text editor. Issues #1112 and #1119. PR #1120.
----
## Version 1.6.3 [2022-08-18]
### Release Notes
This is a bugfix release that fixes a rare problem causing novelWriter to crash if the spell
checker language setting was configured to an empty value.
A few other minor issues have also been fixed: The project language setting is now properly
exported to ODT documents. Spaces are no longer inserted automatically in front of colons in
certain meta data settings when the feature is enabled (it is primarily used for French). Lastly,
the slider splitting the editor and viewer panels can no longer be dragged until the viewer
disappears. It was not necessarily obvious how the viewer panel could be restored in such cases.
### Detailed Changelog
**Bugfixes**
* Fixed an issue where the project language setting was not exported when building Open Document
files. Issue #1073. PR #1087.
* Fixed an issue where the splitter in the main window could be dragged until it hid the document
viewer panel. This is no longer possible. Issue #1085. PR #1087.
* Fixed an issue where an empty spell check language setting would crash novelWriter. Issue #1096.
PR #1098.
* Added a checker that blocks the automatic insertion of spaces in front of special characters in
the cases where the character is a colon in either a meta tag, or as part of the synopsis
keyword. This feature is used for certain languages like French and Spanish. Issue #1090.
PR #1099.
----
## Version 1.6.2 [2022-03-20]
### Release Notes
@@ -3468,8 +3797,8 @@ helpful feedback and issue reports for the new features added in this, and previ
**User Interface**
* Added a preferences dialog for the program settings. No longer necessary to edit the config file.
PR #30.
* Added a preferences dialog for the program settings. It is no longer necessary to edit the config
file. PR #30.
* The document viewer remembers scroll bar position when pressing `Ctrl+R` on a document already
being viewed. PR #28.
* Removed version number from windows title. PR #28.
@@ -3510,8 +3839,8 @@ helpful feedback and issue reports for the new features added in this, and previ
**Status Bar**
* Redesign of the status bar adding project and session stats as well as a session timer. PR #21.
* Project word count is written to the project file, which is needed for the session word count. PR
#21.
* Project word count is written to the project file, which is needed for the session word count.
PR #21.
* Closing a project now clears the status bar. PR #21.
**Editor**
+1 -1
View File
@@ -33,7 +33,7 @@ You can also follow novelWriter on Mastodon at [fosstodon.org/@novelwriter](http
## Implementation
The application is written with Python 3 (3.7+) using Qt5 and PyQt5 (5.3+). It is developed on
The application is written with Python 3 (3.7+) using Qt5 and PyQt5 (5.10+). It is developed on
Linux, but should in principle work fine on other operating systems as well as long as dependencies
are met. It is regularly tested on Debian and Ubuntu Linux, Windows, and macOS.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 423 KiB

After

Width:  |  Height:  |  Size: 534 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 431 KiB

After

Width:  |  Height:  |  Size: 539 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 286 KiB

After

Width:  |  Height:  |  Size: 379 KiB

+1 -1
View File
@@ -71,5 +71,5 @@ On Windows, file extensions may not be visible by default, so make sure you only
extension, and don't end up with two.
The QSS files are Qt Style Sheet files. See Qt's
`The Style Sheet Syntax <https://doc.qt.io/qt-5/stylesheet-syntax.html>`_` documentation for more
`The Style Sheet Syntax <https://doc.qt.io/qt-5/stylesheet-syntax.html>`_ documentation for more
details.
+1 -1
View File
@@ -34,7 +34,7 @@ The following Python packages are needed to run novelWriter:
* ``lxml`` needed for full XML support.
* ``PyEnchant`` needed for spell checking (optional).
PyQt/Qt should be at least 5.3, but ideally 5.10 or higher for nearly all features to work. For
PyQt/Qt should be at least 5.10, but ideally 5.13 or higher for nearly all features to work. For
instance, searching using regular expressions with full Unicode support requires 5.13. There is no
known minimum version requirement for package ``lxml``, but the code was originally written with
4.2, which is therefore set as the minimum. It may work on lower versions. You have to test it.
+1 -2
View File
@@ -40,8 +40,7 @@ Application Data
novelWriter also stores a bit of data that is generated by the user's actions. This includes the
list of recent projects form the :guilabel:`Open Project` dialog. Custom themes are also saved
here. The system paths are provided by the Qt QStandardPaths_ class and its AppDataLocation value
on Qt 5.4 or greater, or DataLocation for earlier versions.
here. The system paths are provided by the Qt QStandardPaths_ class and its AppDataLocation.
The standard paths are:
+2
View File
@@ -47,6 +47,7 @@ The main shorcuts are as follows:
":kbd:`Ctrl`:kbd:`H`", "Open the search and replace bar and search for the selected word, if any is selected. (On Mac, this is :kbd:`Cmd`:kbd:`=`.)"
":kbd:`Ctrl`:kbd:`I`", "Format selected text, or word under cursor, with emphasis (italic)."
":kbd:`Ctrl`:kbd:`K`", "Activate the insert commands. The commands are listed in :ref:`a_kb_ins`."
":kbd:`Ctrl`:kbd:`L`", "Open the :guilabel:`Quick Links` menu in the Project Tree."
":kbd:`Ctrl`:kbd:`N`", "Create new project item."
":kbd:`Ctrl`:kbd:`O`", "Open selected document."
":kbd:`Ctrl`:kbd:`Q`", "Exit novelWriter."
@@ -136,6 +137,7 @@ a key or key combination for the inserted content.
":kbd:`Ctrl`:kbd:`K`, :kbd:`F`", "Insert a ``@focus`` keyword."
":kbd:`Ctrl`:kbd:`K`, :kbd:`C`", "Insert a ``@char`` keyword."
":kbd:`Ctrl`:kbd:`K`, :kbd:`P`", "Insert a ``@plot`` keyword."
":kbd:`Ctrl`:kbd:`K`, :kbd:`S`", "Insert a synopsis comment."
":kbd:`Ctrl`:kbd:`K`, :kbd:`T`", "Insert a ``@time`` keyword."
":kbd:`Ctrl`:kbd:`K`, :kbd:`L`", "Insert a ``@location`` keyword."
":kbd:`Ctrl`:kbd:`K`, :kbd:`O`", "Insert an ``@object`` keyword."
+1336 -1343
View File
File diff suppressed because it is too large Load Diff
+1338 -1345
View File
File diff suppressed because it is too large Load Diff
+1342 -1349
View File
File diff suppressed because it is too large Load Diff
+8 -46
View File
@@ -27,7 +27,6 @@ import sys
import getopt
import logging
from PyQt5.QtGui import QIcon
from PyQt5.QtWidgets import QApplication, QErrorMessage
from novelwriter.error import exceptionHandler, logException
@@ -60,9 +59,9 @@ __license__ = "GPLv3"
__author__ = "Veronica Berglyd Olsen"
__maintainer__ = "Veronica Berglyd Olsen"
__email__ = "code@vkbo.net"
__version__ = "1.7-beta1"
__hexversion__ = "0x010700b1"
__date__ = "2022-05-17"
__version__ = "2.0-rc2"
__hexversion__ = "0x020000c2"
__date__ = "2022-11-13"
__status__ = "Stable"
__domain__ = "novelwriter.io"
__url__ = "https://novelwriter.io"
@@ -72,32 +71,6 @@ __helpurl__ = "https://github.com/vkbo/novelWriter/discussions"
__releaseurl__ = "https://github.com/vkbo/novelWriter/releases/latest"
__docurl__ = "https://novelwriter.readthedocs.io"
##
# Logging
# =========
# Standard used for logging levels in novelWriter:
# CRITICAL Use for errors that result in termination of the program
# ERROR Use when an action fails, but execution continues
# WARNING When something unexpected, but non-critical happens
# INFO Any useful user information like open, save, exit initiated
# ----------- SPAM Threshold : Output above should be minimal -----------------
# DEBUG Use for descriptions of main program flow
# VERBOSE Use for outputting values and program flow details
##
# Add verbose logging level
VERBOSE = 5
logging.addLevelName(VERBOSE, "VERBOSE")
def logVerbose(self, message, *args, **kws):
if self.isEnabledFor(VERBOSE):
self._log(VERBOSE, message, args, **kws)
logging.Logger.verbose = logVerbose
# Initiating logging
logger = logging.getLogger(__name__)
@@ -122,7 +95,6 @@ def main(sysArgs=None):
"version",
"info",
"debug",
"verbose",
"style=",
"config=",
"data=",
@@ -143,7 +115,6 @@ def main(sysArgs=None):
" -v, --version Print program version and exit.\n"
" --info Print additional runtime information.\n"
" --debug Print debug output. Includes --info.\n"
" --verbose Increase verbosity of debug output. Includes --debug.\n"
" --style= Sets Qt5 style flag. Defaults to 'Fusion'.\n"
" --config= Alternative config file.\n"
" --data= Alternative user data path.\n"
@@ -181,9 +152,6 @@ def main(sysArgs=None):
elif inOpt == "--debug":
logLevel = logging.DEBUG
logFormat = "[{asctime:}] {filename:>17}:{lineno:<4d} {levelname:8} {message:}"
elif inOpt == "--verbose":
logLevel = VERBOSE
logFormat = "[{asctime:}] {filename:>17}:{lineno:<4d} {levelname:8} {message:}"
elif inOpt == "--style":
qtStyle = inArg
elif inOpt == "--config":
@@ -193,9 +161,6 @@ def main(sysArgs=None):
elif inOpt == "--testmode":
testMode = True
# Set Config Options
CONFIG.cmdOpen = cmdOpen
# Set Logging
cHandle = logging.StreamHandler()
cHandle.setFormatter(logging.Formatter(fmt=logFormat, style="{"))
@@ -214,14 +179,14 @@ def main(sysArgs=None):
"At least Python 3.7 is required, found %s" % CONFIG.verPyString
)
errorCode |= 0x04
if CONFIG.verQtValue < 50300:
if CONFIG.verQtValue < 51000:
errorData.append(
"At least Qt5 version 5.3 is required, found %s" % CONFIG.verQtString
"At least Qt5 version 5.10 is required, found %s" % CONFIG.verQtString
)
errorCode |= 0x08
if CONFIG.verPyQtValue < 50300:
if CONFIG.verPyQtValue < 51000:
errorData.append(
"At least PyQt5 version 5.3 is required, found %s" % CONFIG.verPyQtString
"At least PyQt5 version 5.10 is required, found %s" % CONFIG.verPyQtString
)
errorCode |= 0x10
@@ -280,7 +245,6 @@ def main(sysArgs=None):
nwApp = QApplication([CONFIG.appName, (f"-style={qtStyle}")])
nwApp.setApplicationName(CONFIG.appName)
nwApp.setApplicationVersion(__version__)
nwApp.setWindowIcon(QIcon(CONFIG.appIcon))
nwApp.setOrganizationDomain(__domain__)
# Connect the exception handler before making the main GUI
@@ -289,9 +253,7 @@ def main(sysArgs=None):
# Launch main GUI
CONFIG.initLocalisation(nwApp)
nwGUI = GuiMain()
if not nwGUI.hasProject:
nwGUI.showProjectLoadDialog()
nwGUI.releaseNotes()
nwGUI.postLaunchTasks(cmdOpen)
sys.exit(nwApp.exec_())
@@ -18,10 +18,10 @@ licenseurl = https://creativecommons.org/licenses/by-sa/4.0/
[Map]
add = typ_plus.svg
backward = typ_chevron-left.svg
bookmark = typ_bookmark.svg
bullet-off = typ_media-record-outline.svg
bullet-on = typ_media-record.svg
check = typ_tick.svg
clear = typ_backspace.svg
checked = mixed_input-checked.svg
close = typ_times.svg
cls_archive = typ_delete.svg
cls_character = typ_user.svg
@@ -35,32 +35,25 @@ cls_timeline = typ_calendar.svg
cls_trash = typ_trash.svg
cls_world = typ_location.svg
cross = typ_times.svg
delete = typ_delete.svg
doc_h0 = mixed_heading0.svg
doc_h1 = mixed_heading1.svg
doc_h2 = mixed_heading2.svg
doc_h3 = mixed_heading3.svg
doc_h4 = mixed_heading4.svg
done = typ_input-checked.svg
down = typ_chevron-down.svg
edit = typ_pencil.svg
forward = typ_chevron-right.svg
hash = typ_hash.svg
maximise = typ_arrow-maximise.svg
menu = typ_th-menu.svg
minimise = typ_arrow-minimise.svg
noncheckable = mixed_input-none.svg
proj_chapter = mixed_document-chapter.svg
proj_details = typ_th-list-grey.svg
proj_document = typ_document-text.svg
proj_folder = typ_folder.svg
proj_note = mixed_document-note.svg
proj_scene = mixed_document-scene.svg
proj_section = mixed_document-section.svg
proj_stats = typ_chart-bar-grey.svg
proj_title = mixed_document-title.svg
reference = typ_at.svg
refresh = typ_refresh.svg
remove = typ_minus.svg
save = typ_download.svg
search = typ_search.svg
search_cancel = typ_cancel-grey.svg
search_case = nw_search-case.svg
@@ -78,13 +71,16 @@ status_stats = typ_chart-bar-grey.svg
status_time = typ_stopwatch-grey.svg
sticky-off = typ_pin-outline.svg
sticky-on = typ_pin.svg
unchecked = mixed_input-unchecked.svg
up = typ_chevron-up.svg
view_build = typ_export.svg
view_editor = mixed_edit.svg
view_novel = typ_book-grey.svg
view_outline = typ_puzzle-outline.svg
deco_doc_h0 = nw_deco-h0.svg
deco_doc_h1 = nw_deco-h1.svg
deco_doc_h2 = nw_deco-h2.svg
deco_doc_h3 = nw_deco-h3.svg
deco_doc_h4 = nw_deco-h4.svg
deco_doc_more = nw_deco-noveltree-more.svg
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
version="1.2"
width="24"
height="24"
viewBox="0 0 24 24"
id="svg2161">
<metadata
id="metadata2167">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs2165" />
<path
id="rect12986"
style="display:inline;opacity:0.95;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:1.88635;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 5.8886719 3.4453125 C 5.2152275 3.4453125 4.6660156 3.9925712 4.6660156 4.6660156 L 4.6660156 19.333984 C 4.6660156 20.007428 5.2152275 20.554687 5.8886719 20.554688 L 18.111328 20.554688 C 18.784774 20.554688 19.333984 20.007428 19.333984 19.333984 L 19.333984 4.6660156 C 19.333984 3.9925712 18.784774 3.4453125 18.111328 3.4453125 L 5.8886719 3.4453125 z " />
<path
id="rect935"
style="fill:#999999;fill-opacity:1;stroke-width:0;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.2"
d="M 5.8886719 3.4453125 C 5.2152275 3.4453125 4.6660156 3.9925712 4.6660156 4.6660156 L 4.6660156 6 L 4.6660156 7.1894531 L 4.6660156 10.058594 L 19.333984 10.058594 L 19.333984 7.1894531 L 19.333984 6 L 19.333984 4.6660156 C 19.333984 3.9925712 18.784774 3.4453125 18.111328 3.4453125 L 5.8886719 3.4453125 z " />
<path
id="path2157"
style="display:inline;fill:#595959;fill-opacity:1;stroke-width:1.22222"
d="M 7.1113281,13.222656 C 6.7739949,13.222656 6.5,13.49665 6.5,13.833984 c 0,0.337332 0.2739949,0.611328 0.6113281,0.611328 h 9.7773439 c 0.337334,1e-6 0.611328,-0.273996 0.611328,-0.611328 0,-0.337334 -0.273994,-0.611328 -0.611328,-0.611328 z m 0,3.666016 C 6.7739949,16.888672 6.5,17.162666 6.5,17.5 c 0,0.337334 0.2739949,0.611328 0.6113281,0.611328 H 16.888672 C 17.226006,18.111328 17.5,17.837334 17.5,17.5 c 0,-0.337334 -0.273994,-0.611328 -0.611328,-0.611328 z" />
<path
id="path902"
style="display:inline;fill:#848484;fill-opacity:1;stroke-width:1.22222"
d="M 5.8886719,1 C 3.8671165,1 2.2226562,2.6444602 2.2226562,4.6660156 V 19.333984 C 2.2226563,21.355538 3.8671165,23 5.8886719,23 H 18.111328 c 2.021556,0 3.666016,-1.644462 3.666016,-3.666016 V 4.6660156 C 21.777344,2.6444602 20.132884,1 18.111328,1 Z m 0,2.4453125 H 18.111328 c 0.673446,0 1.222656,0.5472587 1.222656,1.2207031 V 19.333984 c 0,0.673444 -0.54921,1.220704 -1.222656,1.220704 H 5.8886719 c -0.6734444,-10e-7 -1.2226563,-0.54726 -1.2226563,-1.220704 V 4.6660156 c 0,-0.6734444 0.5492119,-1.2207031 1.2226563,-1.2207031 z" />
<path
id="path2157-0"
style="display:inline;fill:#ffffff;fill-opacity:1;stroke-width:1.22222"
d="M 7.1113281 5.8886719 C 6.7739949 5.8886719 6.5 6.1626667 6.5 6.5 C 6.5 6.837334 6.7739949 7.1113281 7.1113281 7.1113281 L 16.888672 7.1113281 C 17.226006 7.1113281 17.5 6.837334 17.5 6.5 C 17.5 6.1626667 17.226006 5.8886719 16.888672 5.8886719 L 7.1113281 5.8886719 z " />
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

@@ -1,31 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
id="svg1897"
viewBox="0 0 24 24"
height="24"
width="24"
version="1.2">
<metadata
id="metadata1903">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs1901" />
<path
style="fill:#6699cc;fill-opacity:1;stroke-width:1.42859697"
id="path1895"
d="m 3.1443377,2.8368008 c -1.1157342,1.1157342 -1.1157342,2.9243378 0,4.040072 L 8.2658581,11.999821 3.1443377,17.12277 c -1.1157342,1.115734 -1.1157342,2.924338 0,4.040072 C 3.7014904,21.721423 4.4329321,22 5.1643736,22 5.8958159,22 6.627257,21.721423 7.1844103,21.162842 L 16.348859,11.999821 7.1844103,2.8368008 c -1.1143067,-1.1157343 -2.9257671,-1.1157343 -4.0400726,0 z" />
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

@@ -1,37 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
id="svg1897"
viewBox="0 0 24 24"
height="24"
width="24"
version="1.2">
<metadata
id="metadata1903">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs1901" />
<path
style="fill:#6699cc;fill-opacity:1;stroke-width:1.42859697"
id="path1895"
d="m 3.1443377,2.8368008 c -1.1157342,1.1157342 -1.1157342,2.9243378 0,4.040072 L 8.2658581,11.999821 3.1443377,17.12277 c -1.1157342,1.115734 -1.1157342,2.924338 0,4.040072 C 3.7014904,21.721423 4.4329321,22 5.1643736,22 5.8958159,22 6.627257,21.721423 7.1844103,21.162842 L 16.348859,11.999821 7.1844103,2.8368008 c -1.1143067,-1.1157343 -2.9257671,-1.1157343 -4.0400726,0 z" />
<circle
r="2.2222221"
cy="4.2222223"
cx="19.470242"
id="path2467"
style="fill:#6699cc;fill-opacity:1;stroke:none;stroke-width:1.99999988;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

@@ -1,43 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
id="svg1897"
viewBox="0 0 24 24"
height="24"
width="24"
version="1.2">
<metadata
id="metadata1903">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs1901" />
<path
style="fill:#6699cc;fill-opacity:1;stroke-width:1.42859697"
id="path1895"
d="m 3.1443377,2.8368008 c -1.1157342,1.1157342 -1.1157342,2.9243378 0,4.040072 L 8.2658581,11.999821 3.1443377,17.12277 c -1.1157342,1.115734 -1.1157342,2.924338 0,4.040072 C 3.7014904,21.721423 4.4329321,22 5.1643736,22 5.8958159,22 6.627257,21.721423 7.1844103,21.162842 L 16.348859,11.999821 7.1844103,2.8368008 c -1.1143067,-1.1157343 -2.9257671,-1.1157343 -4.0400726,0 z" />
<circle
r="2.2222221"
cy="4.2222223"
cx="19.470242"
id="path2467"
style="fill:#6699cc;fill-opacity:1;stroke:none;stroke-width:1.99999988;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<circle
style="fill:#6699cc;fill-opacity:1;stroke:none;stroke-width:1.99999988;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="circle2469"
cx="19.470242"
cy="9.4077778"
r="2.2222221" />
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

@@ -1,49 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
id="svg1897"
viewBox="0 0 24 24"
height="24"
width="24"
version="1.2">
<metadata
id="metadata1903">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs1901" />
<path
style="fill:#6699cc;fill-opacity:1;stroke-width:1.42859697"
id="path1895"
d="m 3.1443377,2.8368008 c -1.1157342,1.1157342 -1.1157342,2.9243378 0,4.040072 L 8.2658581,11.999821 3.1443377,17.12277 c -1.1157342,1.115734 -1.1157342,2.924338 0,4.040072 C 3.7014904,21.721423 4.4329321,22 5.1643736,22 5.8958159,22 6.627257,21.721423 7.1844103,21.162842 L 16.348859,11.999821 7.1844103,2.8368008 c -1.1143067,-1.1157343 -2.9257671,-1.1157343 -4.0400726,0 z" />
<circle
r="2.2222221"
cy="4.2222223"
cx="19.470242"
id="path2467"
style="fill:#6699cc;fill-opacity:1;stroke:none;stroke-width:1.99999988;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<circle
style="fill:#6699cc;fill-opacity:1;stroke:none;stroke-width:1.99999988;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="circle2469"
cx="19.470242"
cy="9.4077778"
r="2.2222221" />
<circle
r="2.2222221"
cy="14.592222"
cx="19.470242"
id="circle2471"
style="fill:#6699cc;fill-opacity:1;stroke:none;stroke-width:1.99999988;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

@@ -1,59 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
id="svg1897"
viewBox="0 0 24 24"
height="24"
width="24"
version="1.2">
<metadata
id="metadata1903">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs1901" />
<g
transform="matrix(1.1111111,0,0,1.1111111,-1.3333334,-1.3333332)"
id="g2616">
<path
d="m 4.029904,3.7531206 c -1.0041608,1.0041608 -1.0041608,2.6319041 0,3.6360649 L 8.6392724,11.999839 4.029904,16.610493 c -1.0041608,1.004161 -1.0041608,2.631904 0,3.636065 C 4.5313415,20.749281 5.189639,21 5.8479364,21 c 0.658298,0 1.316595,-0.250719 1.818033,-0.753442 L 15.913973,11.999839 7.6659694,3.7531206 c -1.002876,-1.0041608 -2.6331904,-1.0041608 -3.6360654,0 z"
id="path1895"
style="fill:#6699cc;fill-opacity:1;stroke-width:1.28573728" />
<circle
style="fill:#6699cc;fill-opacity:1;stroke:none;stroke-width:1.79999995;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path2467"
cx="18.723217"
cy="5"
r="2" />
<circle
r="2"
cy="9.6669998"
cx="18.723217"
id="circle2469"
style="fill:#6699cc;fill-opacity:1;stroke:none;stroke-width:1.79999995;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<circle
style="fill:#6699cc;fill-opacity:1;stroke:none;stroke-width:1.79999995;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="circle2471"
cx="18.723217"
cy="14.333"
r="2" />
<circle
r="2"
cy="19"
cx="18.723217"
id="circle2473"
style="fill:#6699cc;fill-opacity:1;stroke:none;stroke-width:1.79999995;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
version="1.2"
viewBox="0 0 24 24"
id="svg2287">
<defs
id="defs2291" />
<metadata
id="metadata2281">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<path
d="m17.714 22h-11.429c-2.3629 0-4.2857-1.9229-4.2857-4.2857v-11.429c0-2.3629 1.9229-4.2857 4.2857-4.2857h7.1429c0.79 0 1.4286 0.64 1.4286 1.4286 0 0.78857-0.63857 1.4286-1.4286 1.4286h-7.1429c-0.78857 0-1.4286 0.64143-1.4286 1.4286v11.429c0 0.78714 0.64 1.4286 1.4286 1.4286h11.429c0.78857 0 1.4286-0.64143 1.4286-1.4286v-4.2857c0-0.78857 0.63857-1.4286 1.4286-1.4286s1.4286 0.64 1.4286 1.4286v4.2857c0 2.3629-1.9229 4.2857-4.2857 4.2857z"
fill="#848484"
stroke-width="1.4286"
id="path2283" />
<path
d="m19.02 3.3503c-0.60933 0.010623-1.2003 0.31545-1.5586 0.86719l-5.0977 7.8477-2.3672-3.6445c-0.57329-0.88278-1.7442-1.1319-2.627-0.55859-0.88278 0.57329-1.1319 1.7461-0.55859 2.6289l3.9199 6.0391c0.38514 0.59307 1.0409 0.88965 1.6973 0.85352 0.03023-2.78e-4 0.05972-0.0041 0.08984-0.0059 0.0556-0.0057 0.11074-0.0088 0.16602-0.01953 0.52104-0.07621 1.0077-0.36147 1.3184-0.83984l6.6445-10.23c0.57329-0.88278 0.32419-2.0556-0.55859-2.6289-0.33104-0.21498-0.70276-0.31497-1.0684-0.30859z"
fill="#9c9"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.111"
id="path2285" />
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
version="1.2"
viewBox="0 0 24 24"
id="svg2931">
<defs
id="defs2935" />
<metadata
id="metadata2925">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<path
d="m2 17.714v-11.429c0-2.3629 1.9229-4.2857 4.2857-4.2857h11.429c2.3629 0 4.2857 1.9229 4.2857 4.2857v7.1429c0 0.79-0.64 1.4286-1.4286 1.4286-0.78857 0-1.4286-0.63857-1.4286-1.4286v-7.1429c0-0.78857-0.64143-1.4286-1.4286-1.4286h-11.429c-0.78714 0-1.4286 0.64-1.4286 1.4286v11.429c0 0.78857 0.64143 1.4286 1.4286 1.4286h4.2857c0.78857 0 1.4286 0.63857 1.4286 1.4286s-0.64 1.4286-1.4286 1.4286h-4.2857c-2.3629 0-4.2857-1.9229-4.2857-4.2857zm15.714 4.2857h-11.429c-2.3629 0-4.2857-1.9229-4.2857-4.2857v-11.429c0-2.3629 1.9229-4.2857 4.2857-4.2857h7.1429c0.79 0 1.4286 0.64 1.4286 1.4286 0 0.78857-0.63857 1.4286-1.4286 1.4286h-7.1429c-0.78857 0-1.4286 0.64143-1.4286 1.4286v11.429c0 0.78714 0.64 1.4286 1.4286 1.4286h11.429c0.78857 0 1.4286-0.64143 1.4286-1.4286v-4.2857c0-0.78857 0.63857-1.4286 1.4286-1.4286s1.4286 0.64 1.4286 1.4286v4.2857c0 2.3629-1.9229 4.2857-4.2857 4.2857z"
fill="#848484"
stroke-width="1.4286"
id="path2927" />
<rect
x="7"
y="10.1"
width="10"
height="3.8"
rx="1.9"
ry="1.9"
fill="#f99157"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.111"
id="rect2929" />
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
version="1.2"
viewBox="0 0 24 24"
id="svg3574">
<defs
id="defs3578" />
<metadata
id="metadata3568">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<path
d="m17.714 22h-11.429c-2.3629 0-4.2857-1.9229-4.2857-4.2857v-11.429c0-2.3629 1.9229-4.2857 4.2857-4.2857h7.1429c0.79 0 1.4286 0.64 1.4286 1.4286 0 0.78857-0.63857 1.4286-1.4286 1.4286h-7.1429c-0.78857 0-1.4286 0.64143-1.4286 1.4286v11.429c0 0.78714 0.64 1.4286 1.4286 1.4286h11.429c0.78857 0 1.4286-0.64143 1.4286-1.4286v-4.2857c0-0.78857 0.63857-1.4286 1.4286-1.4286s1.4286 0.64 1.4286 1.4286v4.2857c0 2.3629-1.9229 4.2857-4.2857 4.2857z"
fill="#848484"
stroke-width="1.4286"
id="path3570" />
<path
d="m17.99 3.3379c-0.48501 0.025418-0.96034 0.23584-1.3125 0.62695l-4.6777 5.1953-1.332-1.4785c-0.70433-0.78223-1.9014-0.84495-2.6836-0.14062-0.78223 0.70433-0.84495 1.9014-0.14062 2.6836l1.5996 1.7754-1.5977 1.7754c-0.70433 0.78223-0.64161 1.9793 0.14062 2.6836 0.78223 0.70433 1.9773 0.64161 2.6816-0.14062l1.332-1.4785 1.332 1.4785c0.70433 0.78224 1.9014 0.84495 2.6836 0.14062 0.78223-0.70433 0.84495-1.9014 0.14062-2.6836l-1.5996-1.7754 4.9453-5.4922c0.70433-0.78223 0.64161-1.9793-0.14062-2.6836-0.39112-0.35216-0.88608-0.51175-1.3711-0.48633z"
fill="#d64848"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.111"
id="path3572" />
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
version="1.2"
width="24"
height="24"
viewBox="0 0 24 24"
id="svg2161"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata
id="metadata2167">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs2165" />
<path
d="M 9.7,18.2 16,12 9.7,5.8 C 9.5,5.6 9.2,5.5 9,5.5 8.8,5.5 8.5,5.6 8.3,5.8 8.1,6 8,6.2 8,6.5 v 11 c 0,0.3 0.1,0.5 0.3,0.7 0.2,0.2 0.5,0.3 0.7,0.3 0.2,0 0.5,-0.1 0.7,-0.3 z"
id="path3096"
style="fill:#848484;fill-opacity:1" />
</svg>

After

Width:  |  Height:  |  Size: 928 B

@@ -1,31 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
version="1.2"
width="24"
height="24"
viewBox="0 0 24 24"
id="svg13156">
<metadata
id="metadata13162">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs13160" />
<path
d="M 19.496214,4.4886395 H 9.4810665 c -1.2679176,0 -2.8382927,0.8082224 -3.5754075,1.8397825 L 3.2917056,9.9869555 2.0908895,11.668499 c -0.1211833,0.175265 -0.1221848,0.492744 0.003,0.665006 l 1.1897995,1.66652 2.622967,3.672554 c 0.7361133,1.030558 2.3054869,1.838782 3.574406,1.838782 H 19.496214 C 20.877302,19.511361 22,18.387661 22,17.007573 V 6.9924263 C 22,5.612339 20.877302,4.4886395 19.496214,4.4886395 Z M 17.19974,14.296473 c 0.391593,0.391592 0.391593,1.02455 0,1.416141 -0.195295,0.195296 -0.451683,0.293445 -0.70807,0.293445 -0.256388,0 -0.512776,-0.09815 -0.708071,-0.293445 l -2.296474,-2.296473 -2.296473,2.296473 c -0.195295,0.195296 -0.451683,0.293445 -0.708071,0.293445 -0.256388,0 -0.512775,-0.09815 -0.7080706,-0.293445 -0.3915923,-0.391591 -0.3915923,-1.024549 0,-1.416141 L 12.070984,12 9.7745104,9.7035265 c -0.3915923,-0.3915921 -0.3915923,-1.0245494 0,-1.4161417 0.3915926,-0.3915922 1.0245496,-0.3915922 1.4161416,0 l 2.296473,2.2964732 2.296474,-2.2964732 c 0.391592,-0.3915922 1.024549,-0.3915922 1.416141,0 0.391593,0.3915923 0.391593,1.0245496 0,1.4161417 L 14.903267,12 Z"
id="path13154"
style="stroke-width:1.00151;fill:#d64848;fill-opacity:1" />
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

@@ -9,9 +9,9 @@
width="24"
height="24"
viewBox="0 0 24 24"
id="svg2608">
id="svg901">
<metadata
id="metadata2614">
id="metadata907">
<rdf:RDF>
<cc:Work
rdf:about="">
@@ -23,9 +23,9 @@
</rdf:RDF>
</metadata>
<defs
id="defs2612" />
id="defs905" />
<path
d="M 16.203226,2 H 7.796774 C 6.05874,2 4.6443545,3.4143855 4.6443545,5.1524195 V 19.86371 c 0,0.540115 0.1092839,0.994063 0.3236484,1.350287 0.5926549,0.982504 1.9072138,1.059213 2.9559187,0.0084 L 11.25708,17.889239 c 0.394052,-0.393002 1.091787,-0.393002 1.485841,0 l 3.333158,3.333158 C 16.592024,21.738349 17.128987,22 17.673304,22 c 0.837492,0 1.682342,-0.660957 1.682342,-2.13629 V 5.1524195 C 19.355646,3.4143855 17.941259,2 16.203226,2 Z M 7.796774,4.101613 h 8.406452 c 0.578994,0 1.050806,0.4718121 1.050806,1.0508065 V 15.560658 l -2.575527,-2.361162 c -1.477434,-1.35449 -3.880628,-1.353439 -5.3580615,0 l -2.574476,2.361162 V 5.1524195 c 0,-0.5789944 0.4718121,-1.0508065 1.0508065,-1.0508065 z m 6.431987,12.301792 C 13.635055,15.809699 12.843798,15.482898 12,15.482898 c -0.843797,0 -1.635054,0.327852 -2.2287605,0.920507 l -3.025272,3.025272 v -2.442075 l 3.2848215,-3.011611 c 1.085483,-0.995114 2.851889,-0.995114 3.937372,0 l 3.285871,3.011611 v 2.442075 z"
id="path2606"
style="stroke-width:1.05081;fill:#6699cc;fill-opacity:1" />
id="path899"
style="fill:#6699cc;fill-opacity:1;stroke-width:1.05081" />
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -1,35 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
version="1.2"
width="24"
height="24"
viewBox="0 0 24 24"
id="svg13801">
<metadata
id="metadata13807">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs13805" />
<path
style="display:inline;fill:#cda66f;fill-opacity:1"
id="path6288"
d="m 20.987,16 c 0,-0.105 -0.004,-0.211 -0.039,-0.316 l -2,-6 C 18.812,9.275 18.431,9 18,9 h -0.219 c -0.094,0.188 -0.21,0.368 -0.367,0.525 L 15.932,11 h 1.348 l 1.667,5 H 5.054 L 6.721,11 H 8.069 L 6.586,9.525 C 6.429,9.368 6.312,9.188 6.219,9 H 6 C 5.569,9 5.188,9.275 5.052,9.684 l -2,6 C 3.017,15.789 3.013,15.895 3.013,16 3,16 3,21 3,21 c 0,0.553 0.447,1 1,1 h 16 c 0.553,0 1,-0.447 1,-1 0,0 0,-5 -0.013,-5 z" />
<path
d="M 16.707,7.404 C 16.518,7.216 16.259,7.121 16,7.121 c -0.259,0 -0.518,0.095 -0.707,0.283 L 13,9.697 V 3 C 13,2.448 12.552,2 12,2 11.448,2 11,2.448 11,3 V 9.697 L 8.707,7.404 C 8.518,7.216 8.267,7.111 8,7.111 c -0.267,0 -0.518,0.105 -0.707,0.293 -0.39,0.39 -0.39,1.024 0,1.414 L 12,13.5 16.709,8.816 C 17.097,8.429 17.097,7.794 16.707,7.404 Z"
id="path13799"
style="display:inline;fill:#99cc99;fill-opacity:1" />
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

@@ -1,31 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
version="1.2"
width="24"
height="24"
viewBox="0 0 24 24"
id="svg3369">
<metadata
id="metadata3375">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs3373" />
<path
d="m 10.828125,20 h -4 c -0.553,0 -1,0.447 -1,1 0,0.553 0.447,1 1,1 h 10 c 0.553,0 1,-0.447 1,-1 0,-0.553 -0.447,-1 -1,-1 h -4 v -1.23 c 1.64,-0.371 3.146,-1.188 4.363,-2.406 1.7,-1.7 2.637,-3.96 2.637,-6.364 0,-2.067 -0.692,-4.029 -1.968,-5.619 l 0.675,-0.673 c 0.391,-0.391 0.391,-1.023 10e-4,-1.415 -0.391,-0.391 -1.024,-0.39 -1.415,-0.001 l -2.052,2.049 0.708,0.708 c 1.322,1.322 2.051,3.081 2.051,4.951 0,1.87 -0.729,3.627 -2.051,4.949 -1.322,1.322 -3.079,2.051 -4.949,2.051 -1.87,0 -3.627,-0.729 -4.949,-2.051 -0.391,-0.391 -1.023,-0.391 -1.414,0 -0.391,0.39 -0.391,1.023 0,1.414 1.699,1.7 3.959,2.637 6.363,2.637 z m 0,-16 c 1.657,0 3.157,0.672 4.243,1.757 1.085,1.086 1.757,2.586 1.757,4.243 0,1.656 -0.672,3.156 -1.757,4.242 -1.086,1.086 -2.586,1.758 -4.243,1.758 -1.658,0 -3.157,-0.672 -4.242,-1.757 -1.085,-1.086 -1.756,-2.586 -1.756,-4.243 0,-1.657 0.671,-3.157 1.756,-4.243 1.085,-1.085 2.584,-1.757 4.242,-1.757 z"
id="path3367"
style="fill:#aeaeae;fill-opacity:1;stroke-width:1" />
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

@@ -1,31 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
version="1.2"
width="24"
height="24"
viewBox="0 0 24 24"
id="svg5204">
<metadata
id="metadata5210">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs5208" />
<path
id="rect928"
d="M 7.75 2 C 6.7805 2 6 2.7805 6 3.75 L 6 5.5 L 4.1621094 5.5 C 3.2410844 5.5 2.5 6.2805 2.5 7.25 C 2.5 8.2195 3.2410844 9 4.1621094 9 L 6 9 L 6 15 L 4.1621094 15 C 3.2410844 15 2.5 15.7805 2.5 16.75 C 2.5 17.7195 3.2410844 18.5 4.1621094 18.5 L 6 18.5 L 6 20.25 C 6 21.2195 6.7805 22 7.75 22 C 8.7195 22 9.5 21.2195 9.5 20.25 L 9.5 18.5 L 14.5 18.5 L 14.5 20.25 C 14.5 21.2195 15.2805 22 16.25 22 C 17.2195 22 18 21.2195 18 20.25 L 18 18.5 L 19.837891 18.5 C 20.758916 18.5 21.5 17.7195 21.5 16.75 C 21.5 15.7805 20.758916 15 19.837891 15 L 18 15 L 18 9 L 19.837891 9 C 20.758916 9 21.5 8.2195 21.5 7.25 C 21.5 6.2805 20.758916 5.5 19.837891 5.5 L 18 5.5 L 18 3.75 C 18 2.7805 17.2195 2 16.25 2 C 15.2805 2 14.5 2.7805 14.5 3.75 L 14.5 5.5 L 9.5 5.5 L 9.5 3.75 C 9.5 2.7805 8.7195 2 7.75 2 z M 9.5 9 L 14.5 9 L 14.5 15 L 9.5 15 L 9.5 9 z "
style="fill:#6699cc;fill-opacity:1;stroke:none;stroke-width:1.7544229;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

@@ -1,35 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
id="svg11367"
viewBox="0 0 24 24"
height="24"
width="24"
version="1.2">
<metadata
id="metadata11373">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs11371" />
<path
style="display:inline;fill:#99cc99;fill-opacity:1;stroke-width:1.28570998"
id="path11365"
d="m 13.499143,15.642429 c -0.45,0 -0.885857,-0.178715 -1.209857,-0.502715 L 8.859,11.710714 c -0.668571,-0.669857 -0.668571,-1.755 0,-2.423571 0.669857,-0.669857 1.755,-0.669857 2.426143,0 L 13.105714,11.109 17.573571,4.0954286 c 0.459,-0.828 1.504286,-1.1275715 2.332286,-0.6672857 0.826714,0.4602857 1.125,1.5042857 0.666,2.331 l -5.572286,9.0000001 c -0.261,0.470571 -0.727714,0.790714 -1.26,0.865286 z" />
<path
d="M 17.142857,21 H 6.8571429 C 4.7305714,21 3,19.269429 3,17.142857 V 6.8571429 C 3,4.7305714 4.7305714,3 6.8571429,3 h 6.4285711 c 0.711,0 1.285715,0.576 1.285715,1.2857143 0,0.7097143 -0.574715,1.2857143 -1.285715,1.2857143 H 6.8571429 c -0.7097143,0 -1.2857143,0.5772857 -1.2857143,1.2857143 V 17.142857 c 0,0.708429 0.576,1.285714 1.2857143,1.285714 H 17.142857 c 0.709714,0 1.285714,-0.577285 1.285714,-1.285714 V 13.285714 C 18.428571,12.576 19.003286,12 19.714286,12 20.425286,12 21,12.576 21,13.285714 v 3.857143 C 21,19.269429 19.269429,21 17.142857,21 Z"
id="path16196"
style="display:inline;fill:#aeaeae;fill-opacity:1;stroke-width:1.28570998" />
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

@@ -1,39 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
version="1.2"
width="24"
height="24"
viewBox="0 0 24 24"
id="svg873">
<metadata
id="metadata879">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs877" />
<path
id="path871"
style="display:inline;fill:#6699cc;fill-opacity:1;stroke-width:1.19007"
d="m 10.330622,1.9999997 c -4.5924663,0 -8.3306193,3.738153 -8.3306193,8.3306193 0,1.391929 0.3467825,2.702587 0.9532665,3.857242 0.084308,-0.108411 0.1590865,-0.228838 0.2534294,-0.323181 L 4.7482003,12.323178 C 4.5237338,11.697056 4.378519,11.032991 4.3785189,10.330619 c 0,-3.2810129 2.6710902,-5.949778 5.9521031,-5.949778 0.702303,0 1.36649,0.1429324 1.992559,0.3673563 L 14.141363,2.9300158 C 12.997884,2.3384803 11.704084,1.9999997 10.330622,1.9999997 Z m 5.635898,3.4852355 -0.60451,0.6045104 2.545919,2.5482442 0.460357,-0.4626829 C 18.332278,8.0413114 18.299136,7.9066594 18.256684,7.7754 Z m 0.292955,4.4245521 c 0.0083,0.1189097 0.01733,0.2373427 0.01861,0.3580557 l 0.169728,-0.169728 z m 2.059986,2.6993717 -5.710299,5.710299 c 0.105352,-0.03032 0.217183,-0.04484 0.320855,-0.07906 l 0.892816,0.89049 1.799581,1.797261 0.07208,0.07208 0.07672,0.06743 C 16.486443,21.66984 17.375591,22 18.275281,22 c 2.054056,0 3.724716,-1.671795 3.724716,-3.72704 0,-0.999657 -0.390465,-1.936781 -1.099745,-2.638921 l -1.129969,-1.174145 -0.639387,-0.639386 -0.890489,-0.892815 c 0.03403,-0.103138 0.04885,-0.213757 0.07906,-0.318531 z m -11.7600542,2.28784 -0.4929085,0.492908 0.5417343,0.541734 0.57196,-0.571959 C 6.960254,15.221393 6.7588057,15.062002 6.5594068,14.896999 Z m 2.7179722,1.285747 -1.209021,1.209021 0.5417345,0.541734 1.6577535,-1.655429 c -0.3372847,-0.0036 -0.668706,-0.03764 -0.990467,-0.09533 z" />
<path
d="m 15.96652,2.4766329 c -0.396657,0 -0.792437,0.1507259 -1.095095,0.4533829 L 3.57173,14.229712 c -0.3026571,0.302657 -0.5759103,0.751428 -0.7742385,1.227621 -0.1983282,0.47826 -0.3208556,0.99063 -0.3208556,1.418274 v 4.647756 h 4.6477558 c 0.427645,0 0.9376894,-0.122526 1.4159495,-0.320855 0.4782601,-0.198328 0.9272894,-0.471583 1.2299463,-0.774239 L 21.069984,9.1285734 c 0.302656,-0.302657 0.453382,-0.7017958 0.453382,-1.0974191 0,-0.3966562 -0.150726,-0.7924369 -0.453382,-1.0950939 L 17.063939,2.9300158 C 16.761281,2.6273588 16.362143,2.4766329 15.96652,2.4766329 Z m 0,2.2785396 3.275981,3.2759818 -1.334572,1.3368983 -3.275982,-3.2759819 z M 13.901884,6.8221334 15.173682,8.0939305 6.6082326,16.661704 5.3364357,15.387582 Z m 2.004185,2.0041848 1.271796,1.2717968 -8.5654475,8.565448 -1.2741221,-1.271796 z M 4.7063497,16.250176 c 0.010333,-0.02065 3.0644031,3.034179 3.0644031,3.034179 C 7.43814,19.422771 7.2039295,19.456408 7.1243917,19.456408 H 5.5759147 L 4.5435968,18.424084 v -1.548477 c 0,-0.07953 0.033633,-0.315548 0.1627529,-0.625436 z"
style="display:inline;fill:#99cc99;fill-opacity:1;stroke-width:1.19007"
id="path15590" />
<path
id="rect19553"
d="m 2.4766359,18.937425 v 2.585938 h 2.5605469 z"
style="display:inline;opacity:0.95;fill:#0a0a0a;fill-opacity:1;stroke:none;stroke-width:1.88249397;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
</svg>

Before

Width:  |  Height:  |  Size: 3.6 KiB

@@ -1,36 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
id="svg7030"
viewBox="0 0 24 24"
height="24"
width="24"
version="1.2">
<metadata
id="metadata7036">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs7034" />
<g
style="fill:#aeaeae;fill-opacity:1"
transform="matrix(0.95238095,0,0,0.95238095,0.5714286,0.09523812)"
id="g7028">
<path
style="fill:#aeaeae;fill-opacity:1"
id="path7026"
d="M 19.414,8.902 C 19.518,8.854 19.62,8.794 19.707,8.707 l 0.5,-0.5 c 0.391,-0.391 0.391,-1.023 0,-1.414 -0.391,-0.391 -1.023,-0.391 -1.414,0 l -0.5,0.5 -0.115,0.173 C 16.791,6.154 14.99,5.276 12.989,5.056 L 13,5 V 4 h 1 C 14.55,4 15,3.55 15,3 15,2.45 14.55,2 14,2 H 10 C 9.45,2 9,2.45 9,3 9,3.55 9.45,4 10,4 h 1 v 1 l 0.012,0.057 C 6.506,5.549 3,9.364 3,14 c 0,4.971 4.029,9 9,9 4.971,0 9,-4.029 9,-9 0,-1.894 -0.588,-3.648 -1.586,-5.098 z M 12,21 C 8.141,21 5,17.86 5,14 5,10.14 8.141,7 12,7 c 3.859,0 7,3.14 7,7 0,3.86 -3.141,7 -7,7 z m 1,-8 v -2 c 0,-0.55 -0.45,-1 -1,-1 -0.55,0 -1,0.45 -1,1 v 3 c 0,0.55 0.45,1 1,1 h 3 c 0.55,0 1,-0.45 1,-1 0,-0.55 -0.45,-1 -1,-1 z M 12,8 c -3.312,0 -6,2.688 -6,6 0,3.312 2.688,6 6,6 3.312,0 6,-2.688 6,-6 0,-3.312 -2.688,-6 -6,-6 z m 0,11 C 9.243,19 7,16.757 7,14 7,11.243 9.243,9 12,9 c 2.757,0 5,2.243 5,5 0,2.757 -2.243,5 -5,5 z" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

@@ -1,31 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
version="1.2"
width="24"
height="24"
viewBox="0 0 24 24"
id="svg896">
<metadata
id="metadata902">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs900" />
<path
d="M 19.576035,3.3487939 C 18.237131,2.6038813 16.550693,3.0884898 15.809935,4.4246248 L 10.66893,13.676495 7.726664,10.734229 c -1.0813694,-1.0813704 -2.8342677,-1.0813704 -3.9156371,0 -1.0813693,1.081369 -1.0813693,2.834267 0,3.915637 l 5.538383,5.538383 c 0.523378,0.524762 1.2295221,0.812758 1.9578191,0.812758 l 0.383533,-0.02769 c 0.859834,-0.12046 1.614439,-0.636914 2.03674,-1.397057 L 20.650482,7.1148966 C 21.39401,5.777375 20.91217,4.0923218 19.576035,3.3487939 Z"
id="path894"
style="stroke-width:1.3846;fill:#99cc99;fill-opacity:1" />
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

@@ -18,10 +18,10 @@ licenseurl = https://creativecommons.org/licenses/by-sa/4.0/
[Map]
add = typ_plus.svg
backward = typ_chevron-left.svg
bookmark = typ_bookmark.svg
bullet-off = typ_media-record-outline.svg
bullet-on = typ_media-record.svg
check = typ_tick.svg
clear = typ_backspace.svg
checked = mixed_input-checked.svg
close = typ_times.svg
cls_archive = typ_delete.svg
cls_character = typ_user.svg
@@ -35,32 +35,25 @@ cls_timeline = typ_calendar.svg
cls_trash = typ_trash.svg
cls_world = typ_location.svg
cross = typ_times.svg
delete = typ_delete.svg
doc_h0 = mixed_heading0.svg
doc_h1 = mixed_heading1.svg
doc_h2 = mixed_heading2.svg
doc_h3 = mixed_heading3.svg
doc_h4 = mixed_heading4.svg
done = typ_input-checked.svg
down = typ_chevron-down.svg
edit = typ_pencil.svg
forward = typ_chevron-right.svg
hash = typ_hash.svg
maximise = typ_arrow-maximise.svg
menu = typ_th-menu.svg
minimise = typ_arrow-minimise.svg
noncheckable = mixed_input-none.svg
proj_chapter = mixed_document-chapter.svg
proj_details = typ_th-list-grey.svg
proj_document = typ_document-text.svg
proj_folder = typ_folder.svg
proj_note = mixed_document-note.svg
proj_scene = mixed_document-scene.svg
proj_section = mixed_document-section.svg
proj_stats = typ_chart-bar-grey.svg
proj_title = mixed_document-title.svg
reference = typ_at.svg
refresh = typ_refresh.svg
remove = typ_minus.svg
save = typ_download.svg
search = typ_search.svg
search_cancel = typ_cancel-grey.svg
search_case = nw_search-case.svg
@@ -78,13 +71,16 @@ status_stats = typ_chart-bar-grey.svg
status_time = typ_stopwatch-grey.svg
sticky-off = typ_pin-outline.svg
sticky-on = typ_pin.svg
unchecked = mixed_input-unchecked.svg
up = typ_chevron-up.svg
view_build = typ_export.svg
view_editor = mixed_edit.svg
view_novel = typ_book-grey.svg
view_outline = typ_puzzle-outline.svg
deco_doc_h0 = nw_deco-h0.svg
deco_doc_h1 = nw_deco-h1.svg
deco_doc_h2 = nw_deco-h2.svg
deco_doc_h3 = nw_deco-h3.svg
deco_doc_h4 = nw_deco-h4.svg
deco_doc_more = nw_deco-noveltree-more.svg
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
version="1.2"
width="24"
height="24"
viewBox="0 0 24 24"
id="svg2161">
<metadata
id="metadata2167">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs2165" />
<path
id="rect12986"
style="display:inline;opacity:0.95;fill:#ededed;fill-opacity:1;stroke:none;stroke-width:1.88635;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 5.8886719 3.4453125 C 5.2152275 3.4453125 4.6660156 3.9925712 4.6660156 4.6660156 L 4.6660156 19.333984 C 4.6660156 20.007428 5.2152275 20.554687 5.8886719 20.554688 L 18.111328 20.554688 C 18.784774 20.554688 19.333984 20.007428 19.333984 19.333984 L 19.333984 4.6660156 C 19.333984 3.9925712 18.784774 3.4453125 18.111328 3.4453125 L 5.8886719 3.4453125 z " />
<path
id="rect935"
style="fill:#787878;fill-opacity:1;stroke-width:0;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.2"
d="M 5.8886719 3.4453125 C 5.2152275 3.4453125 4.6660156 3.9925712 4.6660156 4.6660156 L 4.6660156 6 L 4.6660156 7.1894531 L 4.6660156 10.058594 L 19.333984 10.058594 L 19.333984 7.1894531 L 19.333984 6 L 19.333984 4.6660156 C 19.333984 3.9925712 18.784774 3.4453125 18.111328 3.4453125 L 5.8886719 3.4453125 z " />
<path
id="path2157"
style="display:inline;fill:#333333;fill-opacity:1;stroke-width:1.22222"
d="M 7.1113281,13.222656 C 6.7739949,13.222656 6.5,13.49665 6.5,13.833984 c 0,0.337332 0.2739949,0.611328 0.6113281,0.611328 h 9.7773439 c 0.337334,1e-6 0.611328,-0.273996 0.611328,-0.611328 0,-0.337334 -0.273994,-0.611328 -0.611328,-0.611328 z m 0,3.666016 C 6.7739949,16.888672 6.5,17.162666 6.5,17.5 c 0,0.337334 0.2739949,0.611328 0.6113281,0.611328 H 16.888672 C 17.226006,18.111328 17.5,17.837334 17.5,17.5 c 0,-0.337334 -0.273994,-0.611328 -0.611328,-0.611328 z" />
<path
id="path902"
style="display:inline;fill:#333333;fill-opacity:1;stroke-width:1.22222"
d="M 5.8886719,1 C 3.8671165,1 2.2226562,2.6444602 2.2226562,4.6660156 V 19.333984 C 2.2226563,21.355538 3.8671165,23 5.8886719,23 H 18.111328 c 2.021556,0 3.666016,-1.644462 3.666016,-3.666016 V 4.6660156 C 21.777344,2.6444602 20.132884,1 18.111328,1 Z m 0,2.4453125 H 18.111328 c 0.673446,0 1.222656,0.5472587 1.222656,1.2207031 V 19.333984 c 0,0.673444 -0.54921,1.220704 -1.222656,1.220704 H 5.8886719 c -0.6734444,-10e-7 -1.2226563,-0.54726 -1.2226563,-1.220704 V 4.6660156 c 0,-0.6734444 0.5492119,-1.2207031 1.2226563,-1.2207031 z" />
<path
id="path2157-0"
style="display:inline;fill:#ededed;fill-opacity:1;stroke-width:1.22222"
d="M 7.1113281 5.8886719 C 6.7739949 5.8886719 6.5 6.1626667 6.5 6.5 C 6.5 6.837334 6.7739949 7.1113281 7.1113281 7.1113281 L 16.888672 7.1113281 C 17.226006 7.1113281 17.5 6.837334 17.5 6.5 C 17.5 6.1626667 17.226006 5.8886719 16.888672 5.8886719 L 7.1113281 5.8886719 z " />
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

@@ -1,31 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
id="svg1897"
viewBox="0 0 24 24"
height="24"
width="24"
version="1.2">
<metadata
id="metadata1903">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs1901" />
<path
style="fill:#4271ae;fill-opacity:1;stroke-width:1.42859697"
id="path1895"
d="m 3.1443377,2.8368008 c -1.1157342,1.1157342 -1.1157342,2.9243378 0,4.040072 L 8.2658581,11.999821 3.1443377,17.12277 c -1.1157342,1.115734 -1.1157342,2.924338 0,4.040072 C 3.7014904,21.721423 4.4329321,22 5.1643736,22 5.8958159,22 6.627257,21.721423 7.1844103,21.162842 L 16.348859,11.999821 7.1844103,2.8368008 c -1.1143067,-1.1157343 -2.9257671,-1.1157343 -4.0400726,0 z" />
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

@@ -1,37 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
id="svg1897"
viewBox="0 0 24 24"
height="24"
width="24"
version="1.2">
<metadata
id="metadata1903">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs1901" />
<path
style="fill:#4271ae;fill-opacity:1;stroke-width:1.42859697"
id="path1895"
d="m 3.1443377,2.8368008 c -1.1157342,1.1157342 -1.1157342,2.9243378 0,4.040072 L 8.2658581,11.999821 3.1443377,17.12277 c -1.1157342,1.115734 -1.1157342,2.924338 0,4.040072 C 3.7014904,21.721423 4.4329321,22 5.1643736,22 5.8958159,22 6.627257,21.721423 7.1844103,21.162842 L 16.348859,11.999821 7.1844103,2.8368008 c -1.1143067,-1.1157343 -2.9257671,-1.1157343 -4.0400726,0 z" />
<circle
r="2.2222221"
cy="4.2222223"
cx="19.470242"
id="path2467"
style="fill:#4271ae;fill-opacity:1;stroke:none;stroke-width:1.99999988;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

@@ -1,43 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
id="svg1897"
viewBox="0 0 24 24"
height="24"
width="24"
version="1.2">
<metadata
id="metadata1903">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs1901" />
<path
style="fill:#4271ae;fill-opacity:1;stroke-width:1.42859697"
id="path1895"
d="m 3.1443377,2.8368008 c -1.1157342,1.1157342 -1.1157342,2.9243378 0,4.040072 L 8.2658581,11.999821 3.1443377,17.12277 c -1.1157342,1.115734 -1.1157342,2.924338 0,4.040072 C 3.7014904,21.721423 4.4329321,22 5.1643736,22 5.8958159,22 6.627257,21.721423 7.1844103,21.162842 L 16.348859,11.999821 7.1844103,2.8368008 c -1.1143067,-1.1157343 -2.9257671,-1.1157343 -4.0400726,0 z" />
<circle
r="2.2222221"
cy="4.2222223"
cx="19.470242"
id="path2467"
style="fill:#4271ae;fill-opacity:1;stroke:none;stroke-width:1.99999988;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<circle
style="fill:#4271ae;fill-opacity:1;stroke:none;stroke-width:1.99999988;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="circle2469"
cx="19.470242"
cy="9.4077778"
r="2.2222221" />
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

@@ -1,49 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
id="svg1897"
viewBox="0 0 24 24"
height="24"
width="24"
version="1.2">
<metadata
id="metadata1903">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs1901" />
<path
style="fill:#4271ae;fill-opacity:1;stroke-width:1.42859697"
id="path1895"
d="m 3.1443377,2.8368008 c -1.1157342,1.1157342 -1.1157342,2.9243378 0,4.040072 L 8.2658581,11.999821 3.1443377,17.12277 c -1.1157342,1.115734 -1.1157342,2.924338 0,4.040072 C 3.7014904,21.721423 4.4329321,22 5.1643736,22 5.8958159,22 6.627257,21.721423 7.1844103,21.162842 L 16.348859,11.999821 7.1844103,2.8368008 c -1.1143067,-1.1157343 -2.9257671,-1.1157343 -4.0400726,0 z" />
<circle
r="2.2222221"
cy="4.2222223"
cx="19.470242"
id="path2467"
style="fill:#4271ae;fill-opacity:1;stroke:none;stroke-width:1.99999988;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<circle
style="fill:#4271ae;fill-opacity:1;stroke:none;stroke-width:1.99999988;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="circle2469"
cx="19.470242"
cy="9.4077778"
r="2.2222221" />
<circle
r="2.2222221"
cy="14.592222"
cx="19.470242"
id="circle2471"
style="fill:#4271ae;fill-opacity:1;stroke:none;stroke-width:1.99999988;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

@@ -1,55 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
id="svg1897"
viewBox="0 0 24 24"
height="24"
width="24"
version="1.2">
<metadata
id="metadata1903">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs1901" />
<path
style="fill:#4271ae;fill-opacity:1;stroke-width:1.42859697"
id="path1895"
d="m 3.1443377,2.8368008 c -1.1157342,1.1157342 -1.1157342,2.9243378 0,4.040072 L 8.2658581,11.999821 3.1443377,17.12277 c -1.1157342,1.115734 -1.1157342,2.924338 0,4.040072 C 3.7014904,21.721423 4.4329321,22 5.1643736,22 5.8958159,22 6.627257,21.721423 7.1844103,21.162842 L 16.348859,11.999821 7.1844103,2.8368008 c -1.1143067,-1.1157343 -2.9257671,-1.1157343 -4.0400726,0 z" />
<circle
r="2.2222221"
cy="4.2222223"
cx="19.470242"
id="path2467"
style="fill:#4271ae;fill-opacity:1;stroke:none;stroke-width:1.99999988;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<circle
style="fill:#4271ae;fill-opacity:1;stroke:none;stroke-width:1.99999988;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="circle2469"
cx="19.470242"
cy="9.4077778"
r="2.2222221" />
<circle
r="2.2222221"
cy="14.592222"
cx="19.470242"
id="circle2471"
style="fill:#4271ae;fill-opacity:1;stroke:none;stroke-width:1.99999988;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<circle
style="fill:#4271ae;fill-opacity:1;stroke:none;stroke-width:1.99999988;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="circle2473"
cx="19.470242"
cy="19.777779"
r="2.2222221" />
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
version="1.2"
viewBox="0 0 24 24"
id="svg879">
<defs
id="defs883" />
<metadata
id="metadata873">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<path
d="m17.714 22h-11.429c-2.3629 0-4.2857-1.9229-4.2857-4.2857v-11.429c0-2.3629 1.9229-4.2857 4.2857-4.2857h7.1429c0.79 0 1.4286 0.64 1.4286 1.4286 0 0.78857-0.63857 1.4286-1.4286 1.4286h-7.1429c-0.78857 0-1.4286 0.64143-1.4286 1.4286v11.429c0 0.78714 0.64 1.4286 1.4286 1.4286h11.429c0.78857 0 1.4286-0.64143 1.4286-1.4286v-4.2857c0-0.78857 0.63857-1.4286 1.4286-1.4286s1.4286 0.64 1.4286 1.4286v4.2857c0 2.3629-1.9229 4.2857-4.2857 4.2857z"
fill="#333"
stroke-width="1.4286"
id="path875" />
<path
d="m19.02 3.3503c-0.60933 0.010623-1.2003 0.31545-1.5586 0.86719l-5.0977 7.8477-2.3672-3.6445c-0.57329-0.88278-1.7442-1.1319-2.627-0.55859-0.88278 0.57329-1.1319 1.7461-0.55859 2.6289l3.9199 6.0391c0.38514 0.59307 1.0409 0.88965 1.6973 0.85352 0.03023-2.78e-4 0.05972-0.0041 0.08984-0.0059 0.0556-0.0057 0.11074-0.0088 0.16602-0.01953 0.52104-0.07621 1.0077-0.36147 1.3184-0.83984l6.6445-10.23c0.57329-0.88278 0.32419-2.0556-0.55859-2.6289-0.33104-0.21498-0.70276-0.31497-1.0684-0.30859z"
fill="#718c00"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.111"
id="path877" />
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
version="1.2"
viewBox="0 0 24 24"
id="svg973">
<defs
id="defs977" />
<metadata
id="metadata967">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<path
d="m2 17.714v-11.429c0-2.3629 1.9229-4.2857 4.2857-4.2857h11.429c2.3629 0 4.2857 1.9229 4.2857 4.2857v7.1429c0 0.79-0.64 1.4286-1.4286 1.4286-0.78857 0-1.4286-0.63857-1.4286-1.4286v-7.1429c0-0.78857-0.64143-1.4286-1.4286-1.4286h-11.429c-0.78714 0-1.4286 0.64-1.4286 1.4286v11.429c0 0.78857 0.64143 1.4286 1.4286 1.4286h4.2857c0.78857 0 1.4286 0.63857 1.4286 1.4286s-0.64 1.4286-1.4286 1.4286h-4.2857c-2.3629 0-4.2857-1.9229-4.2857-4.2857zm15.714 4.2857h-11.429c-2.3629 0-4.2857-1.9229-4.2857-4.2857v-11.429c0-2.3629 1.9229-4.2857 4.2857-4.2857h7.1429c0.79 0 1.4286 0.64 1.4286 1.4286 0 0.78857-0.63857 1.4286-1.4286 1.4286h-7.1429c-0.78857 0-1.4286 0.64143-1.4286 1.4286v11.429c0 0.78714 0.64 1.4286 1.4286 1.4286h11.429c0.78857 0 1.4286-0.64143 1.4286-1.4286v-4.2857c0-0.78857 0.63857-1.4286 1.4286-1.4286s1.4286 0.64 1.4286 1.4286v4.2857c0 2.3629-1.9229 4.2857-4.2857 4.2857z"
fill="#333"
stroke-width="1.4286"
id="path969" />
<rect
x="7"
y="10.1"
width="10"
height="3.8"
rx="1.9"
ry="1.9"
fill="#f5871f"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.111"
id="rect971" />
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
version="1.2"
viewBox="0 0 24 24"
id="svg1616">
<defs
id="defs1620" />
<metadata
id="metadata1610">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<path
d="m17.714 22h-11.429c-2.3629 0-4.2857-1.9229-4.2857-4.2857v-11.429c0-2.3629 1.9229-4.2857 4.2857-4.2857h7.1429c0.79 0 1.4286 0.64 1.4286 1.4286 0 0.78857-0.63857 1.4286-1.4286 1.4286h-7.1429c-0.78857 0-1.4286 0.64143-1.4286 1.4286v11.429c0 0.78714 0.64 1.4286 1.4286 1.4286h11.429c0.78857 0 1.4286-0.64143 1.4286-1.4286v-4.2857c0-0.78857 0.63857-1.4286 1.4286-1.4286s1.4286 0.64 1.4286 1.4286v4.2857c0 2.3629-1.9229 4.2857-4.2857 4.2857z"
fill="#333"
stroke-width="1.4286"
id="path1612" />
<path
d="m17.99 3.3379c-0.48501 0.025418-0.96034 0.23584-1.3125 0.62695l-4.6777 5.1953-1.332-1.4785c-0.70433-0.78223-1.9014-0.84495-2.6836-0.14062-0.78223 0.70433-0.84495 1.9014-0.14062 2.6836l1.5996 1.7754-1.5977 1.7754c-0.70433 0.78223-0.64161 1.9793 0.14062 2.6836 0.78223 0.70433 1.9773 0.64161 2.6816-0.14062l1.332-1.4785 1.332 1.4785c0.70433 0.78224 1.9014 0.84495 2.6836 0.14062 0.78223-0.70433 0.84495-1.9014 0.14062-2.6836l-1.5996-1.7754 4.9453-5.4922c0.70433-0.78223 0.64161-1.9793-0.14062-2.6836-0.39112-0.35216-0.88608-0.51175-1.3711-0.48633z"
fill="#c82829"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.111"
id="path1614" />
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
version="1.2"
width="24"
height="24"
viewBox="0 0 24 24"
id="svg2161"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata
id="metadata2167">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs2165" />
<path
d="M 9.7,18.2 16,12 9.7,5.8 C 9.5,5.6 9.2,5.5 9,5.5 8.8,5.5 8.5,5.6 8.3,5.8 8.1,6 8,6.2 8,6.5 v 11 c 0,0.3 0.1,0.5 0.3,0.7 0.2,0.2 0.5,0.3 0.7,0.3 0.2,0 0.5,-0.1 0.7,-0.3 z"
id="path3096"
style="fill:#848484;fill-opacity:1" />
</svg>

After

Width:  |  Height:  |  Size: 928 B

@@ -1,31 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
id="svg13156"
viewBox="0 0 24 24"
height="24"
width="24"
version="1.2">
<metadata
id="metadata13162">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs13160" />
<path
style="stroke-width:1.00151;fill:#c82829;fill-opacity:1"
id="path13154"
d="M 19.496214,4.4886395 H 9.4810665 c -1.2679176,0 -2.8382927,0.8082224 -3.5754075,1.8397825 L 3.2917056,9.9869555 2.0908895,11.668499 c -0.1211833,0.175265 -0.1221848,0.492744 0.003,0.665006 l 1.1897995,1.66652 2.622967,3.672554 c 0.7361133,1.030558 2.3054869,1.838782 3.574406,1.838782 H 19.496214 C 20.877302,19.511361 22,18.387661 22,17.007573 V 6.9924263 C 22,5.612339 20.877302,4.4886395 19.496214,4.4886395 Z M 17.19974,14.296473 c 0.391593,0.391592 0.391593,1.02455 0,1.416141 -0.195295,0.195296 -0.451683,0.293445 -0.70807,0.293445 -0.256388,0 -0.512776,-0.09815 -0.708071,-0.293445 l -2.296474,-2.296473 -2.296473,2.296473 c -0.195295,0.195296 -0.451683,0.293445 -0.708071,0.293445 -0.256388,0 -0.512775,-0.09815 -0.7080706,-0.293445 -0.3915923,-0.391591 -0.3915923,-1.024549 0,-1.416141 L 12.070984,12 9.7745104,9.7035265 c -0.3915923,-0.3915921 -0.3915923,-1.0245494 0,-1.4161417 0.3915926,-0.3915922 1.0245496,-0.3915922 1.4161416,0 l 2.296473,2.2964732 2.296474,-2.2964732 c 0.391592,-0.3915922 1.024549,-0.3915922 1.416141,0 0.391593,0.3915923 0.391593,1.0245496 0,1.4161417 L 14.903267,12 Z" />
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

@@ -5,13 +5,13 @@
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
id="svg2608"
viewBox="0 0 24 24"
height="24"
version="1.2"
width="24"
version="1.2">
height="24"
viewBox="0 0 24 24"
id="svg901">
<metadata
id="metadata2614">
id="metadata907">
<rdf:RDF>
<cc:Work
rdf:about="">
@@ -23,9 +23,9 @@
</rdf:RDF>
</metadata>
<defs
id="defs2612" />
id="defs905" />
<path
style="stroke-width:1.05081;fill:#4271ae;fill-opacity:1"
id="path2606"
d="M 16.203226,2 H 7.796774 C 6.05874,2 4.6443545,3.4143855 4.6443545,5.1524195 V 19.86371 c 0,0.540115 0.1092839,0.994063 0.3236484,1.350287 0.5926549,0.982504 1.9072138,1.059213 2.9559187,0.0084 L 11.25708,17.889239 c 0.394052,-0.393002 1.091787,-0.393002 1.485841,0 l 3.333158,3.333158 C 16.592024,21.738349 17.128987,22 17.673304,22 c 0.837492,0 1.682342,-0.660957 1.682342,-2.13629 V 5.1524195 C 19.355646,3.4143855 17.941259,2 16.203226,2 Z M 7.796774,4.101613 h 8.406452 c 0.578994,0 1.050806,0.4718121 1.050806,1.0508065 V 15.560658 l -2.575527,-2.361162 c -1.477434,-1.35449 -3.880628,-1.353439 -5.3580615,0 l -2.574476,2.361162 V 5.1524195 c 0,-0.5789944 0.4718121,-1.0508065 1.0508065,-1.0508065 z m 6.431987,12.301792 C 13.635055,15.809699 12.843798,15.482898 12,15.482898 c -0.843797,0 -1.635054,0.327852 -2.2287605,0.920507 l -3.025272,3.025272 v -2.442075 l 3.2848215,-3.011611 c 1.085483,-0.995114 2.851889,-0.995114 3.937372,0 l 3.285871,3.011611 v 2.442075 z" />
d="M 16.203226,2 H 7.796774 C 6.05874,2 4.6443545,3.4143855 4.6443545,5.1524195 V 19.86371 c 0,0.540115 0.1092839,0.994063 0.3236484,1.350287 0.5926549,0.982504 1.9072138,1.059213 2.9559187,0.0084 L 11.25708,17.889239 c 0.394052,-0.393002 1.091787,-0.393002 1.485841,0 l 3.333158,3.333158 C 16.592024,21.738349 17.128987,22 17.673304,22 c 0.837492,0 1.682342,-0.660957 1.682342,-2.13629 V 5.1524195 C 19.355646,3.4143855 17.941259,2 16.203226,2 Z M 7.796774,4.101613 h 8.406452 c 0.578994,0 1.050806,0.4718121 1.050806,1.0508065 V 15.560658 l -2.575527,-2.361162 c -1.477434,-1.35449 -3.880628,-1.353439 -5.3580615,0 l -2.574476,2.361162 V 5.1524195 c 0,-0.5789944 0.4718121,-1.0508065 1.0508065,-1.0508065 z m 6.431987,12.301792 C 13.635055,15.809699 12.843798,15.482898 12,15.482898 c -0.843797,0 -1.635054,0.327852 -2.2287605,0.920507 l -3.025272,3.025272 v -2.442075 l 3.2848215,-3.011611 c 1.085483,-0.995114 2.851889,-0.995114 3.937372,0 l 3.285871,3.011611 v 2.442075 z"
id="path899"
style="fill:#4271ae;fill-opacity:1;stroke-width:1.05081" />
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -1,35 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
version="1.2"
width="24"
height="24"
viewBox="0 0 24 24"
id="svg13801">
<metadata
id="metadata13807">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs13805" />
<path
style="display:inline;fill:#a2694f;fill-opacity:1"
id="path6288"
d="m 20.987,16 c 0,-0.105 -0.004,-0.211 -0.039,-0.316 l -2,-6 C 18.812,9.275 18.431,9 18,9 h -0.219 c -0.094,0.188 -0.21,0.368 -0.367,0.525 L 15.932,11 h 1.348 l 1.667,5 H 5.054 L 6.721,11 H 8.069 L 6.586,9.525 C 6.429,9.368 6.312,9.188 6.219,9 H 6 C 5.569,9 5.188,9.275 5.052,9.684 l -2,6 C 3.017,15.789 3.013,15.895 3.013,16 3,16 3,21 3,21 c 0,0.553 0.447,1 1,1 h 16 c 0.553,0 1,-0.447 1,-1 0,0 0,-5 -0.013,-5 z" />
<path
d="M 16.707,7.404 C 16.518,7.216 16.259,7.121 16,7.121 c -0.259,0 -0.518,0.095 -0.707,0.283 L 13,9.697 V 3 C 13,2.448 12.552,2 12,2 11.448,2 11,2.448 11,3 V 9.697 L 8.707,7.404 C 8.518,7.216 8.267,7.111 8,7.111 c -0.267,0 -0.518,0.105 -0.707,0.293 -0.39,0.39 -0.39,1.024 0,1.414 L 12,13.5 16.709,8.816 C 17.097,8.429 17.097,7.794 16.707,7.404 Z"
id="path13799"
style="display:inline;fill:#718c00;fill-opacity:1" />
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

@@ -1,31 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
id="svg3369"
viewBox="0 0 24 24"
height="24"
width="24"
version="1.2">
<metadata
id="metadata3375">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs3373" />
<path
style="fill:#333333;fill-opacity:1;stroke-width:1"
id="path3367"
d="m 10.828125,20 h -4 c -0.553,0 -1,0.447 -1,1 0,0.553 0.447,1 1,1 h 10 c 0.553,0 1,-0.447 1,-1 0,-0.553 -0.447,-1 -1,-1 h -4 v -1.23 c 1.64,-0.371 3.146,-1.188 4.363,-2.406 1.7,-1.7 2.637,-3.96 2.637,-6.364 0,-2.067 -0.692,-4.029 -1.968,-5.619 l 0.675,-0.673 c 0.391,-0.391 0.391,-1.023 10e-4,-1.415 -0.391,-0.391 -1.024,-0.39 -1.415,-0.001 l -2.052,2.049 0.708,0.708 c 1.322,1.322 2.051,3.081 2.051,4.951 0,1.87 -0.729,3.627 -2.051,4.949 -1.322,1.322 -3.079,2.051 -4.949,2.051 -1.87,0 -3.627,-0.729 -4.949,-2.051 -0.391,-0.391 -1.023,-0.391 -1.414,0 -0.391,0.39 -0.391,1.023 0,1.414 1.699,1.7 3.959,2.637 6.363,2.637 z m 0,-16 c 1.657,0 3.157,0.672 4.243,1.757 1.085,1.086 1.757,2.586 1.757,4.243 0,1.656 -0.672,3.156 -1.757,4.242 -1.086,1.086 -2.586,1.758 -4.243,1.758 -1.658,0 -3.157,-0.672 -4.242,-1.757 -1.085,-1.086 -1.756,-2.586 -1.756,-4.243 0,-1.657 0.671,-3.157 1.756,-4.243 1.085,-1.085 2.584,-1.757 4.242,-1.757 z" />
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

@@ -1,31 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
version="1.2"
width="24"
height="24"
viewBox="0 0 24 24"
id="svg5204">
<metadata
id="metadata5210">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs5208" />
<path
id="rect928"
d="M 7.75 2 C 6.7805 2 6 2.7805 6 3.75 L 6 5.5 L 4.1621094 5.5 C 3.2410844 5.5 2.5 6.2805 2.5 7.25 C 2.5 8.2195 3.2410844 9 4.1621094 9 L 6 9 L 6 15 L 4.1621094 15 C 3.2410844 15 2.5 15.7805 2.5 16.75 C 2.5 17.7195 3.2410844 18.5 4.1621094 18.5 L 6 18.5 L 6 20.25 C 6 21.2195 6.7805 22 7.75 22 C 8.7195 22 9.5 21.2195 9.5 20.25 L 9.5 18.5 L 14.5 18.5 L 14.5 20.25 C 14.5 21.2195 15.2805 22 16.25 22 C 17.2195 22 18 21.2195 18 20.25 L 18 18.5 L 19.837891 18.5 C 20.758916 18.5 21.5 17.7195 21.5 16.75 C 21.5 15.7805 20.758916 15 19.837891 15 L 18 15 L 18 9 L 19.837891 9 C 20.758916 9 21.5 8.2195 21.5 7.25 C 21.5 6.2805 20.758916 5.5 19.837891 5.5 L 18 5.5 L 18 3.75 C 18 2.7805 17.2195 2 16.25 2 C 15.2805 2 14.5 2.7805 14.5 3.75 L 14.5 5.5 L 9.5 5.5 L 9.5 3.75 C 9.5 2.7805 8.7195 2 7.75 2 z M 9.5 9 L 14.5 9 L 14.5 15 L 9.5 15 L 9.5 9 z "
style="fill:#4271ae;fill-opacity:1;stroke:none;stroke-width:1.7544229;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

@@ -1,35 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
version="1.2"
width="24"
height="24"
viewBox="0 0 24 24"
id="svg11367">
<metadata
id="metadata11373">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs11371" />
<path
d="m 13.499143,15.642429 c -0.45,0 -0.885857,-0.178715 -1.209857,-0.502715 L 8.859,11.710714 c -0.668571,-0.669857 -0.668571,-1.755 0,-2.423571 0.669857,-0.669857 1.755,-0.669857 2.426143,0 L 13.105714,11.109 17.573571,4.0954286 c 0.459,-0.828 1.504286,-1.1275715 2.332286,-0.6672857 0.826714,0.4602857 1.125,1.5042857 0.666,2.331 l -5.572286,9.0000001 c -0.261,0.470571 -0.727714,0.790714 -1.26,0.865286 z"
id="path11365"
style="display:inline;fill:#718c00;fill-opacity:1;stroke-width:1.28570998" />
<path
style="display:inline;fill:#333333;fill-opacity:1;stroke-width:1.28570998"
id="path16196"
d="M 17.142857,21 H 6.8571429 C 4.7305714,21 3,19.269429 3,17.142857 V 6.8571429 C 3,4.7305714 4.7305714,3 6.8571429,3 h 6.4285711 c 0.711,0 1.285715,0.576 1.285715,1.2857143 0,0.7097143 -0.574715,1.2857143 -1.285715,1.2857143 H 6.8571429 c -0.7097143,0 -1.2857143,0.5772857 -1.2857143,1.2857143 V 17.142857 c 0,0.708429 0.576,1.285714 1.2857143,1.285714 H 17.142857 c 0.709714,0 1.285714,-0.577285 1.285714,-1.285714 V 13.285714 C 18.428571,12.576 19.003286,12 19.714286,12 20.425286,12 21,12.576 21,13.285714 v 3.857143 C 21,19.269429 19.269429,21 17.142857,21 Z" />
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

@@ -1,36 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
version="1.2"
width="24"
height="24"
viewBox="0 0 24 24"
id="svg7030">
<metadata
id="metadata7036">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs7034" />
<g
id="g7028"
transform="matrix(0.95238095,0,0,0.95238095,0.5714286,0.09523812)"
style="fill:#333333;fill-opacity:1">
<path
d="M 19.414,8.902 C 19.518,8.854 19.62,8.794 19.707,8.707 l 0.5,-0.5 c 0.391,-0.391 0.391,-1.023 0,-1.414 -0.391,-0.391 -1.023,-0.391 -1.414,0 l -0.5,0.5 -0.115,0.173 C 16.791,6.154 14.99,5.276 12.989,5.056 L 13,5 V 4 h 1 C 14.55,4 15,3.55 15,3 15,2.45 14.55,2 14,2 H 10 C 9.45,2 9,2.45 9,3 9,3.55 9.45,4 10,4 h 1 v 1 l 0.012,0.057 C 6.506,5.549 3,9.364 3,14 c 0,4.971 4.029,9 9,9 4.971,0 9,-4.029 9,-9 0,-1.894 -0.588,-3.648 -1.586,-5.098 z M 12,21 C 8.141,21 5,17.86 5,14 5,10.14 8.141,7 12,7 c 3.859,0 7,3.14 7,7 0,3.86 -3.141,7 -7,7 z m 1,-8 v -2 c 0,-0.55 -0.45,-1 -1,-1 -0.55,0 -1,0.45 -1,1 v 3 c 0,0.55 0.45,1 1,1 h 3 c 0.55,0 1,-0.45 1,-1 0,-0.55 -0.45,-1 -1,-1 z M 12,8 c -3.312,0 -6,2.688 -6,6 0,3.312 2.688,6 6,6 3.312,0 6,-2.688 6,-6 0,-3.312 -2.688,-6 -6,-6 z m 0,11 C 9.243,19 7,16.757 7,14 7,11.243 9.243,9 12,9 c 2.757,0 5,2.243 5,5 0,2.757 -2.243,5 -5,5 z"
id="path7026"
style="fill:#333333;fill-opacity:1" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

@@ -1,31 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
version="1.2"
width="24"
height="24"
viewBox="0 0 24 24"
id="svg896">
<metadata
id="metadata902">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs900" />
<path
d="M 19.576035,3.3487939 C 18.237131,2.6038813 16.550693,3.0884898 15.809935,4.4246248 L 10.66893,13.676495 7.726664,10.734229 c -1.0813694,-1.0813704 -2.8342677,-1.0813704 -3.9156371,0 -1.0813693,1.081369 -1.0813693,2.834267 0,3.915637 l 5.538383,5.538383 c 0.523378,0.524762 1.2295221,0.812758 1.9578191,0.812758 l 0.383533,-0.02769 c 0.859834,-0.12046 1.614439,-0.636914 2.03674,-1.397057 L 20.650482,7.1148966 C 21.39401,5.777375 20.91217,4.0923218 19.576035,3.3487939 Z"
id="path894"
style="stroke-width:1.3846;fill:#718c00;fill-opacity:1" />
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

+4 -4
View File
@@ -2,11 +2,11 @@
<html>
<body>
<h2>Release Notes for 1.7 Beta 1</h2>
<p><i>Released on 17 May 2022</i></p>
<h2>Release Notes for 2.0 RC 2</h2>
<p><i>Released on 13 November 2022</i></p>
<p>This is a beta release of the next release version, and is intended for testing purposes. Please
be careful when using this version on live writing projects, and make sure you take frequent
<p>This is a release candidate of the next release version, and is intended for testing purposes.
Please be careful when using this version on live writing projects, and make sure you take frequent
backups.</p>
<p>Please check the changelog for an overview of changes. The full release notes will be added to
the final release.</p>
+3 -1
View File
@@ -1,2 +1,4 @@
[Main]
name = Default System Theme
name = Default Theme
description = Qt standard colours
icontheme = typicons_light
+9 -7
View File
@@ -1,16 +1,18 @@
[Main]
name = Default Dark Theme
author = Veronica Berglyd Olsen
credit = Veronica Berglyd Olsen
url = https://github.com/vkbo/novelWriter
license = CC BY-SA 4.0
licenseurl = https://creativecommons.org/licenses/by-sa/4.0/
name = Default Dark Theme
description = The novelWriter standard dark theme
author = Veronica Berglyd Olsen
credit = Veronica Berglyd Olsen
url = https://github.com/vkbo/novelWriter
license = CC BY-SA 4.0
licenseurl = https://creativecommons.org/licenses/by-sa/4.0/
icontheme = typicons_dark
[Palette]
window = 54, 54, 54
windowtext = 174, 174, 174
base = 62, 62, 62
alternatebase = 67, 67, 67
alternatebase = 78, 78, 78
text = 174, 174, 174
tooltipbase = 255, 255, 192
tooltiptext = 21, 21, 13
@@ -1,5 +0,0 @@
/**
* Default Theme: Dark
* This theme doesn't use any custom styles, so the file is only here as
* an example. There doesn't have to be a styles.qss file in the folder.
*/
@@ -5,12 +5,13 @@ credit = Ethan Schoonover
url = https://ethanschoonover.com/solarized/
license = MIT
licenseurl = https://github.com/altercation/solarized/blob/master/LICENSE
icontheme = typicons_dark
[Palette]
window = 0, 43, 54
windowtext = 253, 246, 227
base = 7, 54, 66
alternatebase = 67, 67, 67
alternatebase = 0, 43, 54
text = 253, 246, 227
tooltipbase = 133, 153, 0
tooltiptext = 0, 43, 54
@@ -5,6 +5,7 @@ credit = Ethan Schoonover
url = https://ethanschoonover.com/solarized/
license = MIT
licenseurl = https://github.com/altercation/solarized/blob/master/LICENSE
icontheme = typicons_light
[Palette]
window = 238, 232, 213
+147 -155
View File
@@ -23,11 +23,12 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
import os
import json
import uuid
import hashlib
import logging
from pathlib import Path
from datetime import datetime
from configparser import ConfigParser
@@ -45,52 +46,55 @@ logger = logging.getLogger(__name__)
# Checker Functions
# =============================================================================================== #
def checkString(value, default, allowNone=False):
def checkStringNone(value, default):
"""Check if a variable is a string or a None.
"""
if allowNone and (value is None or value == "None"):
if value is None or value == "None":
return None
if isinstance(value, str):
return str(value)
return default
def checkInt(value, default, allowNone=False):
"""Check if a variable is an integer or a None.
def checkString(value, default):
"""Check if a variable is a string.
"""
if isinstance(value, str):
return str(value)
return default
def checkInt(value, default):
"""Check if a variable is an integer.
"""
if allowNone and (value is None or value == "None"):
return None
try:
return int(value)
except Exception:
return default
def checkFloat(value, default, allowNone=False):
"""Check if a variable is a float or a None.
def checkFloat(value, default):
"""Check if a variable is a float.
"""
if allowNone and (value is None or value == "None"):
return None
try:
return float(value)
except Exception:
return default
def checkBool(value, default, allowNone=False):
"""Check if a variable is a boolean or a None.
def checkBool(value, default):
"""Check if a variable is a boolean.
"""
if allowNone and (value is None or value == "None"):
return None
if isinstance(value, str):
if value == "True":
if isinstance(value, bool):
return value
elif isinstance(value, str):
check = value.lower()
if check in ("true", "yes", "on"):
return True
elif value == "False":
elif check in ("false", "no", "off"):
return False
else:
return default
elif isinstance(value, int):
if value == 1:
return True
@@ -98,7 +102,6 @@ def checkBool(value, default, allowNone=False):
return False
else:
return default
return default
@@ -112,6 +115,26 @@ def checkHandle(value, default, allowNone=False):
return default
def checkUuid(value, default):
"""Try to process a value as an uuid, or return a default.
"""
try:
return str(uuid.UUID(value))
except Exception:
return default
def checkPath(value, default):
"""Check if a value is a valid path. Non-empty strings are accepted.
"""
if isinstance(value, Path):
return value
elif isinstance(value, str):
if value.strip():
return Path(value)
return default
# =============================================================================================== #
# Validator Functions
# =============================================================================================== #
@@ -174,16 +197,6 @@ def hexToInt(value, default=0):
return default
def checkIntRange(value, first, last, default):
"""Check that an int is in a given range. If it isn't, return the
default value.
"""
if isinstance(value, int):
if value >= first and value <= last:
return value
return default
def minmax(value, minVal, maxVal):
"""Make sure an integer is between min and max value (inclusive).
"""
@@ -225,25 +238,25 @@ def formatInt(value):
return str(value)
def formatTimeStamp(theTime, fileSafe=False):
def formatTimeStamp(value, fileSafe=False):
"""Take a number (on the format returned by time.time()) and convert
it to a timestamp string.
"""
if fileSafe:
return datetime.fromtimestamp(theTime).strftime(nwConst.FMT_FSTAMP)
return datetime.fromtimestamp(value).strftime(nwConst.FMT_FSTAMP)
else:
return datetime.fromtimestamp(theTime).strftime(nwConst.FMT_TSTAMP)
return datetime.fromtimestamp(value).strftime(nwConst.FMT_TSTAMP)
def formatTime(tS):
def formatTime(t):
"""Format a time in seconds in HH:MM:SS format or d-HH:MM:SS format
if a full day or longer.
"""
if isinstance(tS, int):
if tS >= 86400:
return f"{tS//86400:d}-{tS%86400//3600:02d}:{tS%3600//60:02d}:{tS%60:02d}"
if isinstance(t, int):
if t >= 86400:
return f"{t//86400:d}-{t%86400//3600:02d}:{t%3600//60:02d}:{t%60:02d}"
else:
return f"{tS//3600:02d}:{tS%3600//60:02d}:{tS%60:02d}"
return f"{t//3600:02d}:{t%3600//60:02d}:{t%60:02d}"
return "ERROR"
@@ -258,12 +271,18 @@ def simplified(string):
return " ".join(str(string).strip().split())
def yesNo(value):
"""Convert a boolean evaluated variable to a yes or no.
"""
return "yes" if value else "no"
def splitVersionNumber(value):
"""Split a version string on the form aa.bb.cc into major, minor
and patch, and computes an integer value aabbcc.
"""
if not isinstance(value, str):
return [0, 0, 0, 0]
return 0, 0, 0, 0
vMajor = 0
vMinor = 0
@@ -282,114 +301,114 @@ def splitVersionNumber(value):
vInt = vMajor*10000 + vMinor*100 + vPatch
return [vMajor, vMinor, vPatch, vInt]
return vMajor, vMinor, vPatch, vInt
def transferCase(theSource, theTarget):
def transferCase(source, target):
"""Transfers the case of the source word to the target word. This
will consider all upper or lower, and first char capitalisation.
"""
theResult = theTarget
theResult = target
if not isinstance(theSource, str) or not isinstance(theTarget, str):
if not isinstance(source, str) or not isinstance(target, str):
return theResult
if len(theTarget) < 1 or len(theSource) < 1:
if len(target) < 1 or len(source) < 1:
return theResult
if theSource.istitle():
theResult = theTarget.title()
if source.istitle():
theResult = target.title()
if theSource.isupper():
theResult = theTarget.upper()
elif theSource.islower():
theResult = theTarget.lower()
if source.isupper():
theResult = target.upper()
elif source.islower():
theResult = target.lower()
return theResult
def fuzzyTime(secDiff):
def fuzzyTime(seconds):
"""Converts a time difference in seconds into a fuzzy time string.
"""
if secDiff < 0:
if seconds < 0:
return QCoreApplication.translate(
"Common", "in the future"
)
elif secDiff < 30:
elif seconds < 30:
return QCoreApplication.translate(
"Common", "just now"
)
elif secDiff < 90:
elif seconds < 90:
return QCoreApplication.translate(
"Common", "a minute ago"
)
elif secDiff < 3300: # 55 minutes
elif seconds < 3300: # 55 minutes
return QCoreApplication.translate(
"Common", "{0} minutes ago"
).format(int(round(secDiff/60)))
elif secDiff < 5400: # 90 minutes
).format(int(round(seconds/60)))
elif seconds < 5400: # 90 minutes
return QCoreApplication.translate(
"Common", "an hour ago"
)
elif secDiff < 84600: # 23.5 hours
elif seconds < 84600: # 23.5 hours
return QCoreApplication.translate(
"Common", "{0} hours ago"
).format(int(round(secDiff/3600)))
elif secDiff < 129600: # 1.5 days
).format(int(round(seconds/3600)))
elif seconds < 129600: # 1.5 days
return QCoreApplication.translate(
"Common", "a day ago"
)
elif secDiff < 561600: # 6.5 days
elif seconds < 561600: # 6.5 days
return QCoreApplication.translate(
"Common", "{0} days ago"
).format(int(round(secDiff/86400)))
elif secDiff < 907200: # 10.5 days
).format(int(round(seconds/86400)))
elif seconds < 907200: # 10.5 days
return QCoreApplication.translate(
"Common", "a week ago"
)
elif secDiff < 2419200: # 28 days
elif seconds < 2419200: # 28 days
return QCoreApplication.translate(
"Common", "{0} weeks ago"
).format(int(round(secDiff/604800)))
elif secDiff < 3888000: # 45 days
).format(int(round(seconds/604800)))
elif seconds < 3888000: # 45 days
return QCoreApplication.translate(
"Common", "a month ago"
)
elif secDiff < 29808000: # 345 days
elif seconds < 29808000: # 345 days
return QCoreApplication.translate(
"Common", "{0} months ago"
).format(int(round(secDiff/2592000)))
elif secDiff < 47336400: # 1.5 years
).format(int(round(seconds/2592000)))
elif seconds < 47336400: # 1.5 years
return QCoreApplication.translate(
"Common", "a year ago"
)
else:
return QCoreApplication.translate(
"Common", "{0} years ago"
).format(int(round(secDiff/31557600)))
).format(int(round(seconds/31557600)))
def numberToRoman(numVal, toLower=False):
def numberToRoman(value, toLower=False):
"""Convert an integer to a Roman number.
"""
if not isinstance(numVal, int):
if not isinstance(value, int):
return "NAN"
if numVal < 1 or numVal > 4999:
if value < 1 or value > 4999:
return "OOR"
theValues = [
lookup = [
(1000, "M"), (900, "CM"), (500, "D"), (400, "CD"), (100, "C"), (90, "XC"),
(50, "L"), (40, "XL"), (10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I"),
]
romNum = ""
for theDiv, theSym in theValues:
n = numVal//theDiv
romNum += n*theSym
numVal -= n*theDiv
if numVal <= 0:
roman = ""
for divisor, symbol in lookup:
n = value//divisor
roman += n*symbol
value -= n*divisor
if value <= 0:
break
return romNum.lower() if toLower else romNum
return roman.lower() if toLower else roman
# =============================================================================================== #
@@ -447,51 +466,47 @@ def jsonEncode(data, n=0, nmax=0):
# File and File System Functions
# =============================================================================================== #
def readTextFile(filePath):
def readTextFile(path):
"""Read the content of a text file in a robust manner.
"""
if not os.path.isfile(filePath):
path = Path(path)
if not path.is_file():
return ""
fileText = ""
try:
with open(filePath, mode="r", encoding="utf-8") as inFile:
fileText = inFile.read()
return path.read_text(encoding="utf-8")
except Exception:
logger.error("Could not read file: %s", filePath)
logger.error("Could not read file: %s", path)
logException()
return ""
return fileText
def makeFileNameSafe(value):
"""Returns a filename safe string of the value.
"""
cleanName = ""
clean = ""
for c in str(value).strip():
if c.isalpha() or c.isdigit() or c == " ":
cleanName += c
return cleanName
clean += c
return clean
def sha256sum(filePath):
def sha256sum(path):
"""Make a shasum of a file using a buffer.
Based on: https://stackoverflow.com/a/44873382/5825851
"""
hDigest = hashlib.sha256()
digest = hashlib.sha256()
bData = bytearray(65536)
mData = memoryview(bData)
try:
with open(filePath, mode="rb", buffering=0) as inFile:
with open(path, mode="rb", buffering=0) as inFile:
for n in iter(lambda: inFile.readinto(mData), 0):
hDigest.update(mData[:n])
digest.update(mData[:n])
except Exception:
logger.error("Could not create sha256sum of: %s", filePath)
logger.error("Could not create sha256sum of: %s", path)
logException()
return None
return hDigest.hexdigest()
return digest.hexdigest()
# =============================================================================================== #
@@ -513,87 +528,64 @@ def getGuiItem(objName):
class NWConfigParser(ConfigParser):
CNF_STR = 0
CNF_INT = 1
CNF_FLOAT = 2
CNF_BOOL = 3
CNF_S_LST = 4
CNF_I_LST = 5
def __init__(self):
super().__init__()
def rdStr(self, section, option, default):
"""Read string value.
"""
return self._parseLine(section, option, default, self.CNF_STR)
return self.get(section, option, fallback=default)
def rdInt(self, section, option, default):
"""Read integer value.
"""
return self._parseLine(section, option, default, self.CNF_INT)
try:
return self.getint(section, option, fallback=default)
except ValueError:
logger.error("Could not read '%s':'%s' from config", section, option)
return default
def rdFlt(self, section, option, default):
"""Read float value.
"""
return self._parseLine(section, option, default, self.CNF_FLOAT)
try:
return self.getfloat(section, option, fallback=default)
except ValueError:
logger.error("Could not read '%s':'%s' from config", section, option)
return default
def rdBool(self, section, option, default):
"""Read boolean value.
"""
return self._parseLine(section, option, default, self.CNF_BOOL)
try:
return self.getboolean(section, option, fallback=default)
except ValueError:
logger.error("Could not read '%s':'%s' from config", section, option)
return default
def rdPath(self, section, option, default):
"""Read a path value.
"""
return checkPath(self.get(section, option, fallback=default), default)
def rdStrList(self, section, option, default):
"""Read string list.
"""
return self._parseLine(section, option, default, self.CNF_S_LST)
result = default.copy() if isinstance(default, list) else []
if self.has_option(section, option):
data = self.get(section, option, fallback="").split(",")
for i in range(min(len(data), len(result))):
result[i] = data[i].strip()
return result
def rdIntList(self, section, option, default):
"""Read integer list.
"""
return self._parseLine(section, option, default, self.CNF_I_LST)
##
# Internal Functions
##
def _unpackList(self, value, default, type):
"""Unpack a comma-separated string of items into a list.
"""
inList = value.split(",")
outList = []
if isinstance(default, list):
outList = default.copy()
for i in range(min(len(inList), len(outList))):
try:
if type == self.CNF_S_LST:
outList[i] = inList[i].strip()
elif type == self.CNF_I_LST:
outList[i] = int(inList[i].strip())
except Exception:
continue
return outList
def _parseLine(self, section, option, default, type):
"""Parse a line and return the correct datatype.
"""
result = default.copy() if isinstance(default, list) else []
if self.has_option(section, option):
try:
if type == self.CNF_STR:
return self.get(section, option)
elif type == self.CNF_INT:
return self.getint(section, option)
elif type == self.CNF_FLOAT:
return self.getfloat(section, option)
elif type == self.CNF_BOOL:
return self.getboolean(section, option)
elif type in (self.CNF_I_LST, self.CNF_S_LST):
return self._unpackList(self.get(section, option), default, type)
except ValueError:
logger.error("Could not read '%s':'%s' from config", str(section), str(option))
logException()
return default
return default
data = self.get(section, option, fallback="").split(",")
for i in range(min(len(data), len(result))):
result[i] = checkInt(data[i].strip(), result[i])
return result
# END Class NWConfigParser
+454 -582
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -68,6 +68,7 @@ class nwHeaders:
class nwFiles:
CONF_FILE = "novelwriter.conf"
PROJ_FILE = "nwProject.nwx"
PROJ_DICT = "wordlist.txt"
PROJ_LOCK = "nwProject.lock"
@@ -157,6 +158,7 @@ class nwLabels:
"doc_h1": QT_TRANSLATE_NOOP("Constant", "Novel Title Page"),
"doc_h2": QT_TRANSLATE_NOOP("Constant", "Novel Chapter"),
"doc_h3": QT_TRANSLATE_NOOP("Constant", "Novel Scene"),
"doc_h4": QT_TRANSLATE_NOOP("Constant", "Novel Section"),
"note": QT_TRANSLATE_NOOP("Constant", "Project Note"),
}
KEY_NAME = {
+4 -2
View File
@@ -19,7 +19,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
from novelwriter.core.document import NWDoc
from novelwriter.core.coretools import DocMerger, DocSplitter, ProjectBuilder
from novelwriter.core.index import countWords
from novelwriter.core.project import NWProject
from novelwriter.core.spellcheck import NWSpellEnchant
@@ -28,8 +28,10 @@ from novelwriter.core.toodt import ToOdt
from novelwriter.core.tomd import ToMarkdown
__all__ = [
"DocMerger",
"DocSplitter",
"ProjectBuilder",
"countWords",
"NWDoc",
"NWProject",
"NWSpellEnchant",
"ToHtml",
+453
View File
@@ -0,0 +1,453 @@
"""
novelWriter Project Document Tools
====================================
A collection of tools to create and manipulate documents
File History:
Created: 2022-10-02 [2.0b1] DocMerger
Created: 2022-10-11 [2.0b1] DocSplitter
This file is a part of novelWriter
Copyright 20182022, Veronica Berglyd Olsen
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
import shutil
import logging
import novelwriter
from time import time
from functools import partial
from PyQt5.QtCore import QCoreApplication
from novelwriter.enum import nwAlert
from novelwriter.common import minmax, simplified
from novelwriter.constants import nwItemClass
from novelwriter.core.project import NWProject
logger = logging.getLogger(__name__)
class DocMerger:
"""Document tool for merging a set of documents into a single new
document. The parameters are defined by the user using the
GuiDocMerge dialog.
"""
def __init__(self, theProject):
self.theProject = theProject
self._error = ""
self._targetDoc = None
self._targetText = []
return
##
# Methods
##
def getError(self):
"""Return any collected errors.
"""
return self._error
def setTargetDoc(self, tHandle):
"""Set the target document for the merging. Calling this
function resets the class.
"""
self._targetDoc = tHandle
self._targetText = []
return
def newTargetDoc(self, srcHandle, docLabel):
"""Create a barnd new target document based on a source handle
and a new doc label. Calling this function resets the class.
"""
srcItem = self.theProject.tree[srcHandle]
if srcItem is None:
return None
newHandle = self.theProject.newFile(docLabel, srcItem.itemParent)
newItem = self.theProject.tree[newHandle]
newItem.setLayout(srcItem.itemLayout)
newItem.setStatus(srcItem.itemStatus)
newItem.setImport(srcItem.itemImport)
self._targetDoc = newHandle
self._targetText = []
return newHandle
def appendText(self, srcHandle, addComment, cmtPrefix):
"""Append text from an existing document to the text buffer.
"""
srcItem = self.theProject.tree[srcHandle]
if srcItem is None:
return False
inDoc = self.theProject.storage.getDocument(srcHandle)
docText = (inDoc.readDocument() or "").rstrip("\n")
if addComment:
docInfo = srcItem.describeMe()
docSt, _ = srcItem.getImportStatus(incIcon=False)
cmtLine = f"% {cmtPrefix} {docInfo}: {srcItem.itemName} [{docSt}]\n\n"
docText = cmtLine + docText
self._targetText.append(docText)
return True
def writeTargetDoc(self):
"""Write the accumulated text into the designated target
document, appending any existing text.
"""
if self._targetDoc is None:
return False
outDoc = self.theProject.storage.getDocument(self._targetDoc)
docText = (outDoc.readDocument() or "").rstrip("\n")
if docText:
self._targetText.insert(0, docText)
status = outDoc.writeDocument("\n\n".join(self._targetText) + "\n\n")
if not status:
self._error = outDoc.getError()
return status
# END Class DocMerger
class DocSplitter:
"""Document tool for splitting a document into a set of new
documents. The parameters are defined by the user using the
GuiDocSplit dialog.
"""
def __init__(self, theProject, sHandle):
self.theProject = theProject
self._error = ""
self._parHandle = None
self._srcHandle = None
self._srcItem = None
self._inFolder = False
self._rawData = []
srcItem = self.theProject.tree[sHandle]
if srcItem is not None and srcItem.isFileType():
self._srcHandle = sHandle
self._srcItem = srcItem
return
##
# Methods
##
def getError(self):
"""Return any collected errors.
"""
return self._error
def setParentItem(self, pHandle):
"""Set the item that will be the top level parent item for the
new documents.
"""
self._parHandle = pHandle
self._inFolder = False
return
def newParentFolder(self, pHandle, folderLabel):
"""Create a new folder that will be the top level parent item
for the new documents.
"""
if self._srcItem is None:
return None
newHandle = self.theProject.newFolder(folderLabel, pHandle)
newItem = self.theProject.tree[newHandle]
newItem.setStatus(self._srcItem.itemStatus)
newItem.setImport(self._srcItem.itemImport)
self._parHandle = newHandle
self._inFolder = True
return newHandle
def splitDocument(self, splitData, splitText):
"""Loop through the split data record and perform the split job.
"""
self._rawData = []
buffer = splitText.copy()
for lineNo, hLevel, hLabel in reversed(splitData):
chunk = buffer[lineNo:]
buffer = buffer[:lineNo]
self._rawData.insert(0, (chunk, hLevel, hLabel))
return True
def writeDocuments(self, docHierarchy):
"""An iterator that will write each document in the buffer, and
return its new handle, parent handle, and sibling handle.
"""
if self._srcHandle is None or self._srcItem is None:
return
pHandle = self._parHandle
nHandle = self._parHandle if self._inFolder else self._srcHandle
hHandle = [self._parHandle, None, None, None, None]
pLevel = 0
for docText, hLevel, docLabel in self._rawData:
hLevel = minmax(hLevel, 1, 4)
if pLevel == 0:
pLevel = hLevel
if docHierarchy:
if hLevel == 1:
pHandle = self._parHandle
elif hLevel == 2:
pHandle = hHandle[1] or hHandle[0]
elif hLevel == 3:
pHandle = hHandle[2] or hHandle[1] or hHandle[0]
elif hLevel == 4:
pHandle = hHandle[3] or hHandle[2] or hHandle[1] or hHandle[0]
if hLevel < pLevel:
nHandle = hHandle[hLevel] or hHandle[0]
elif hLevel > pLevel:
nHandle = pHandle
dHandle = self.theProject.newFile(docLabel, pHandle)
hHandle[hLevel] = dHandle
newItem = self.theProject.tree[dHandle]
newItem.setStatus(self._srcItem.itemStatus)
newItem.setImport(self._srcItem.itemImport)
outDoc = self.theProject.storage.getDocument(dHandle)
status = outDoc.writeDocument("\n".join(docText))
if not status:
self._error = outDoc.getError()
yield status, dHandle, nHandle
hHandle[hLevel] = dHandle
nHandle = dHandle
pLevel = hLevel
return
# END Class DocSplitter
class ProjectBuilder:
"""A class to build a new project from a set of user-defined
parameter provided by the New Projecty Wizard.
"""
def __init__(self, mainGui):
self.mainGui = mainGui
self.mainConf = novelwriter.CONFIG
self.tr = partial(QCoreApplication.translate, "NWProject")
return
##
# Methods
##
def buildProject(self, data):
"""Build a project from a data dictionary of specifications
provided by the wizard.
"""
if not isinstance(data, dict):
logger.error("Invalid call to newProject function")
return False
popMinimal = data.get("popMinimal", True)
popCustom = data.get("popCustom", False)
popSample = data.get("popSample", False)
# Check if we're extracting the sample project. This is handled
# differently as it isn't actually a new project, so we forward
# this to another function and return here.
if popSample:
return self._extractSampleProject(data)
projPath = data.get("projPath", None)
if projPath is None:
logger.error("No project path set for the new project")
return False
project = NWProject(self.mainGui)
if not project.storage.openProjectInPlace(projPath, newProject=True):
return False
lblNewProject = self.tr("New Project")
lblNewChapter = self.tr("New Chapter")
lblNewScene = self.tr("New Scene")
lblTitlePage = self.tr("Title Page")
lblByAuthors = self.tr("By")
# Settings
projName = data.get("projName", lblNewProject)
projTitle = data.get("projTitle", lblNewProject)
projAuthors = data.get("projAuthors", "")
project.data.setUuid(None)
project.data.setName(projName)
project.data.setTitle(projTitle)
project.data.setAuthors(projAuthors)
project.setDefaultStatusImport()
project._projOpened = int(time())
# Add Root Folders
hNovelRoot = project.newRoot(nwItemClass.NOVEL)
hTitlePage = project.newFile(lblTitlePage, hNovelRoot)
novelTitle = project.data.title if project.data.title else project.data.name
titlePage = f"#! {novelTitle}\n\n"
if project.data.authors:
titlePage += f">> {lblByAuthors} {project.getFormattedAuthors()} <<\n\n"
aDoc = project.storage.getDocument(hTitlePage)
aDoc.writeDocument(titlePage)
if popMinimal:
# Creating a minimal project with a few root folders and a
# single chapter with a single scene.
hChapter = project.newFile(lblNewChapter, hNovelRoot)
aDoc = project.storage.getDocument(hChapter)
aDoc.writeDocument(f"## {lblNewChapter}\n\n")
hScene = project.newFile(lblNewScene, hChapter)
aDoc = project.storage.getDocument(hScene)
aDoc.writeDocument(f"### {lblNewScene}\n\n")
project.newRoot(nwItemClass.PLOT)
project.newRoot(nwItemClass.CHARACTER)
project.newRoot(nwItemClass.WORLD)
project.newRoot(nwItemClass.ARCHIVE)
project.saveProject()
project.closeProject()
elif popCustom:
# Create a project structure based on selected root folders
# and a number of chapters and scenes selected in the
# wizard's custom page.
# Create chapters and scenes
numChapters = data.get("numChapters", 0)
numScenes = data.get("numScenes", 0)
chSynop = self.tr("Summary of the chapter.")
scSynop = self.tr("Summary of the scene.")
# Create chapters
if numChapters > 0:
for ch in range(numChapters):
chTitle = self.tr("Chapter {0}").format(f"{ch+1:d}")
cHandle = project.newFile(chTitle, hNovelRoot)
aDoc = project.storage.getDocument(cHandle)
aDoc.writeDocument(f"## {chTitle}\n\n% Synopsis: {chSynop}\n\n")
# Create chapter scenes
if numScenes > 0:
for sc in range(numScenes):
scTitle = self.tr("Scene {0}").format(f"{ch+1:d}.{sc+1:d}")
sHandle = project.newFile(scTitle, cHandle)
aDoc = project.storage.getDocument(sHandle)
aDoc.writeDocument(f"### {scTitle}\n\n% Synopsis: {scSynop}\n\n")
# Create scenes (no chapters)
elif numScenes > 0:
for sc in range(numScenes):
scTitle = self.tr("Scene {0}").format(f"{sc+1:d}")
sHandle = project.newFile(scTitle, hNovelRoot)
aDoc = project.storage.getDocument(sHandle)
aDoc.writeDocument(f"### {scTitle}\n\n% Synopsis: {scSynop}\n\n")
# Create notes folders
noteTitles = {
nwItemClass.PLOT: self.tr("Main Plot"),
nwItemClass.CHARACTER: self.tr("Protagonist"),
nwItemClass.WORLD: self.tr("Main Location"),
}
addNotes = data.get("addNotes", False)
for newRoot in data.get("addRoots", []):
if newRoot in nwItemClass:
rHandle = project.newRoot(newRoot)
if addNotes:
aHandle = project.newFile(noteTitles[newRoot], rHandle)
ntTag = simplified(noteTitles[newRoot]).replace(" ", "")
aDoc = project.storage.getDocument(aHandle)
aDoc.writeDocument(f"# {noteTitles[newRoot]}\n\n@tag: {ntTag}\n\n")
# Also add the archive and trash folders
project.newRoot(nwItemClass.ARCHIVE)
project.trashFolder()
project.saveProject()
project.closeProject()
return True
##
# Internal Functions
##
def _extractSampleProject(self, data):
"""Make a copy of the sample project by extracting the
sample.zip file to the new path.
"""
projPath = data.get("projPath", None)
if projPath is None:
logger.error("No project path set for the example project")
return False
pkgSample = self.mainConf.assetPath("sample.zip")
if pkgSample.is_file():
try:
shutil.unpack_archive(pkgSample, projPath)
except Exception as exc:
self.mainGui.makeAlert(self.tr(
"Failed to create a new example project."
), nwAlert.ERROR, exception=exc)
return False
else:
self.mainGui.makeAlert(self.tr(
"Failed to create a new example project. "
"Could not find the necessary files. "
"They seem to be missing from this installation."
), nwAlert.ERROR)
return False
return True
# END Class ProjectBuilder
+40 -26
View File
@@ -23,9 +23,10 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
import os
import logging
from pathlib import Path
from novelwriter.enum import nwItemLayout, nwItemClass
from novelwriter.error import formatException
from novelwriter.common import isHandle, sha256sum
@@ -33,7 +34,7 @@ from novelwriter.common import isHandle, sha256sum
logger = logging.getLogger(__name__)
class NWDoc():
class NWDocument:
def __init__(self, theProject, theHandle):
@@ -57,7 +58,7 @@ class NWDoc():
return
def __repr__(self):
return f"<NWDoc handle={self._docHandle}>"
return f"<NWDocument handle={self._docHandle}>"
def __bool__(self):
return self._docHandle is not None and bool(self._theItem)
@@ -73,7 +74,7 @@ class NWDoc():
empty string. If something went wrong, return None.
"""
self._docError = ""
if self._docHandle is None:
if not isinstance(self._docHandle, str):
logger.error("No document handle set")
return None
@@ -81,17 +82,22 @@ class NWDoc():
logger.error("Unknown novelWriter document")
return None
contentPath = self.theProject.storage.contentPath
if not isinstance(contentPath, Path):
logger.error("No content path set")
return None
docFile = self._docHandle+".nwd"
logger.debug("Opening document: %s", docFile)
docPath = os.path.join(self.theProject.projContent, docFile)
docPath = contentPath / docFile
self._fileLoc = docPath
theText = ""
self._docMeta = {}
self._prevHash = None
if os.path.isfile(docPath):
if docPath.exists():
self._prevHash = sha256sum(docPath)
try:
with open(docPath, mode="r", encoding="utf-8") as inFile:
@@ -125,17 +131,20 @@ class NWDoc():
if not.
"""
self._docError = ""
if self._docHandle is None:
if not isinstance(self._docHandle, str):
logger.error("No document handle set")
return False
self.theProject.ensureFolderStructure()
contentPath = self.theProject.storage.contentPath
if not isinstance(contentPath, Path):
logger.error("No content path set")
return False
docFile = self._docHandle+".nwd"
logger.debug("Saving document: %s", docFile)
docPath = os.path.join(self.theProject.projContent, docFile)
docTemp = os.path.join(self.theProject.projContent, docFile+"~")
docPath = contentPath / docFile
docTemp = docPath.with_suffix(".tmp")
if self._prevHash is not None and not forceWrite:
self._currHash = sha256sum(docPath)
@@ -164,7 +173,7 @@ class NWDoc():
# If we're here, the file was successfully saved, so we can
# replace the temp file with the actual file
try:
os.replace(docTemp, docPath)
docTemp.replace(docPath)
except OSError as exc:
self._docError = formatException(exc)
return False
@@ -179,23 +188,28 @@ class NWDoc():
from the project data folder.
"""
self._docError = ""
if self._docHandle is None:
if not isinstance(self._docHandle, str):
logger.error("No document handle set")
return False
chkList = [
os.path.join(self.theProject.projContent, f"{self._docHandle}.nwd"),
os.path.join(self.theProject.projContent, f"{self._docHandle}.nwd~"),
]
contentPath = self.theProject.storage.contentPath
if not isinstance(contentPath, Path):
logger.error("No content path set")
return False
for chkFile in chkList:
if os.path.isfile(chkFile):
try:
os.unlink(chkFile)
logger.debug("Deleted: %s", chkFile)
except Exception as exc:
self._docError = formatException(exc)
return False
docPath = contentPath / f"{self._docHandle}.nwd"
docTemp = docPath.with_suffix(".tmp")
try:
# ToDo: When Python 3.7 is dropped, these can be changed to
# path.unlink(missing_ok=True)
if docPath.exists():
docPath.unlink()
if docTemp.exists():
docTemp.unlink()
except Exception as exc:
self._docError = formatException(exc)
return False
return True
@@ -206,7 +220,7 @@ class NWDoc():
def getFileLocation(self):
"""Return the file location of the current document.
"""
return self._fileLoc
return str(self._fileLoc)
def getCurrentItem(self):
"""Return a pointer to the currently open NWItem.
@@ -263,4 +277,4 @@ class NWDoc():
return
# END Class NWDoc
# END Class NWDocument
+110 -100
View File
@@ -26,16 +26,15 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
import os
import json
import logging
from time import time
from pathlib import Path
from novelwriter.enum import nwItemType, nwItemLayout
from novelwriter.error import logException
from novelwriter.constants import nwFiles, nwKeyWords, nwUnicode, nwHeaders
from novelwriter.core.document import NWDoc
from novelwriter.common import (
checkInt, isHandle, isItemClass, isTitleTag, jsonEncode
)
@@ -59,23 +58,23 @@ class NWIndex:
The index data is cached in a JSON file between writing sessions.
"""
def __init__(self, theProject):
def __init__(self, project):
self.theProject = theProject
self._project = project
# Storage and State
self._tagsIndex = TagsIndex()
self._itemIndex = ItemIndex(theProject)
self._itemIndex = ItemIndex(project)
self._indexBroken = False
# TimeStamps
self._indexChange = 0
self._indexChange = 0.0
self._rootChange = {}
return
def __repr__(self):
return f"<NWIndex project='{self.theProject.projName}'>"
return f"<NWIndex project='{self._project.data.name}'>"
##
# Properties
@@ -94,10 +93,22 @@ class NWIndex:
"""
self._tagsIndex.clear()
self._itemIndex.clear()
self._indexChange = 0
self._indexChange = 0.0
self._rootChange = {}
return
def rebuildIndex(self):
"""Rebuild the entire index from scratch.
"""
self.clearIndex()
for nwItem in self._project.tree:
if nwItem is not None and nwItem.isFileType():
tHandle = nwItem.itemHandle
theDoc = self._project.storage.getDocument(tHandle)
self.scanText(tHandle, theDoc.readDocument() or "")
self._indexBroken = False
return
def deleteHandle(self, tHandle):
"""Delete all entries of a given document handle.
"""
@@ -114,11 +125,11 @@ class NWIndex:
moved from the archive or trash folders back into the active
project.
"""
if not self.theProject.tree.checkType(tHandle, nwItemType.FILE):
if not self._project.tree.checkType(tHandle, nwItemType.FILE):
return False
logger.debug("Re-indexing item '%s'", tHandle)
theDoc = NWDoc(self.theProject, tHandle)
theDoc = self._project.storage.getDocument(tHandle)
self.scanText(tHandle, theDoc.readDocument() or "")
return True
@@ -126,13 +137,13 @@ class NWIndex:
def indexChangedSince(self, checkTime):
"""Check if the index has changed since a given time.
"""
return self._indexChange > checkTime
return self._indexChange > float(checkTime)
def rootChangedSince(self, rootHandle, checkTime):
"""Check if the index has changed since a given time for a
given root item.
"""
return self._rootChange.get(rootHandle, self._indexChange) > checkTime
return self._rootChange.get(rootHandle, self._indexChange) > float(checkTime)
##
# Load and Save Index to/from File
@@ -141,12 +152,15 @@ class NWIndex:
def loadIndex(self):
"""Load index from last session from the project meta folder.
"""
indexFile = self._project.storage.getMetaFile(nwFiles.INDEX_FILE)
if not isinstance(indexFile, Path):
return False
theData = {}
indexFile = os.path.join(self.theProject.projMeta, nwFiles.INDEX_FILE)
tStart = time()
self._indexBroken = False
if os.path.isfile(indexFile):
if indexFile.exists():
logger.debug("Loading index file")
try:
with open(indexFile, mode="r", encoding="utf-8") as inFile:
@@ -169,14 +183,14 @@ class NWIndex:
logger.debug("Checking index")
# Check that all files are indexed
for fHandle in self.theProject.projFiles:
for fHandle in self._project.projFiles:
if fHandle not in self._itemIndex:
logger.warning("Item '%s' is not in the index", fHandle)
self.reIndexHandle(fHandle)
self._indexChange = round(time())
self._indexChange = time()
logger.verbose("Index loaded in %.3f ms", (time() - tStart)*1000)
logger.debug("Index loaded in %.3f ms", (time() - tStart)*1000)
return True
@@ -184,8 +198,11 @@ class NWIndex:
"""Save the current index as a json file in the project meta
data folder.
"""
indexFile = self._project.storage.getMetaFile(nwFiles.INDEX_FILE)
if not isinstance(indexFile, Path):
return False
logger.debug("Saving index file")
indexFile = os.path.join(self.theProject.projMeta, nwFiles.INDEX_FILE)
tStart = time()
try:
@@ -202,7 +219,7 @@ class NWIndex:
logException()
return False
logger.verbose("Index saved in %.3f ms", (time() - tStart)*1000)
logger.debug("Index saved in %.3f ms", (time() - tStart)*1000)
return True
@@ -217,11 +234,11 @@ class NWIndex:
files before we save them, in which case we already have the
text.
"""
theItem = self.theProject.tree[tHandle]
theItem = self._project.tree[tHandle]
if theItem is None:
logger.info("Not indexing unknown item '%s'", tHandle)
return False
if theItem.itemType != nwItemType.FILE:
if not theItem.isFileType():
logger.info("Not indexing non-file item '%s'", tHandle)
return False
@@ -243,20 +260,43 @@ class NWIndex:
if theItem.itemParent is None:
logger.info("Not indexing orphaned item '%s'", tHandle)
return False
if theItem.isInactive():
logger.debug("Not indexing inactive item '%s'", tHandle)
return False
logger.debug("Indexing item with handle '%s'", tHandle)
if theItem.isInactive():
self._scanInactive(theItem, theText)
else:
self._scanActive(tHandle, theItem, theText, itemTags)
# Scan the text content
# Update timestamps for index changes
nowTime = time()
self._indexChange = nowTime
self._rootChange[theItem.itemRoot] = nowTime
return True
##
# Internal Indexer Helpers
##
def _scanActive(self, tHandle, theItem, theText, itemTags):
"""Scan an active document for meta data.
"""
nTitle = 0
findHeader = True
theLines = theText.splitlines()
for nLine, aLine in enumerate(theLines, start=1):
if len(aLine.strip()) == 0:
continue
if aLine.startswith("#"):
if findHeader:
hDepth, _ = self._splitHeading(aLine)
if hDepth != "H0":
theItem.setMainHeading(hDepth)
findHeader = False
isTitle = self._indexTitle(tHandle, aLine, nLine)
if isTitle and nLine > 0:
if nTitle > 0:
@@ -289,48 +329,49 @@ class NWIndex:
# Prune no longer used tags
for tTag, isActive in itemTags.items():
if not isActive:
logger.verbose("Deleting removed tag '%s'", tTag)
logger.debug("Deleting removed tag '%s'", tTag)
del self._tagsIndex[tTag]
# Update timestamps for index changes
nowTime = round(time())
self._indexChange = nowTime
self._rootChange[theItem.itemRoot] = nowTime
return
return True
def _scanInactive(self, theItem, theText):
"""Scan an inactive document for meta data.
"""
for aLine in theText.splitlines():
if aLine.startswith("#"):
hDepth, _ = self._splitHeading(aLine)
if hDepth != "H0":
theItem.setMainHeading(hDepth)
break
return
##
# Internal Indexer Helpers
##
def _splitHeading(self, aLine):
"""Split a heading into its header level and text value.
"""
if aLine.startswith("# "):
return "H1", aLine[2:].strip()
elif aLine.startswith("## "):
return "H2", aLine[3:].strip()
elif aLine.startswith("### "):
return "H3", aLine[4:].strip()
elif aLine.startswith("#### "):
return "H4", aLine[5:].strip()
elif aLine.startswith("#! "):
return "H1", aLine[3:].strip()
elif aLine.startswith("##! "):
return "H2", aLine[4:].strip()
return "H0", ""
def _indexTitle(self, tHandle, aLine, nTitle):
"""Save information about the title and its location in the
file to the index.
"""
if aLine.startswith("# "):
hDepth = "H1"
hText = aLine[2:].strip()
elif aLine.startswith("## "):
hDepth = "H2"
hText = aLine[3:].strip()
elif aLine.startswith("### "):
hDepth = "H3"
hText = aLine[4:].strip()
elif aLine.startswith("#### "):
hDepth = "H4"
hText = aLine[5:].strip()
elif aLine.startswith("#! "):
hDepth = "H1"
hText = aLine[3:].strip()
elif aLine.startswith("##! "):
hDepth = "H2"
hText = aLine[4:].strip()
else:
hDepth, hText = self._splitHeading(aLine)
if hDepth == "H0":
return False
sTitle = f"T{nTitle:06d}"
self._itemIndex.addItemHeading(tHandle, sTitle, hDepth, hText)
return True
def _indexWordCounts(self, tHandle, theText, nTitle):
@@ -493,18 +534,15 @@ class NWIndex:
for sTitle, hItem in self._itemIndex.iterItemHeaders(tHandle)
]
def getHandleHeaderLevel(self, tHandle):
"""Get the header level of the first header of a handle.
"""
return self._itemIndex.mainItemHeader(tHandle)
def getTableOfContents(self, maxDepth, skipExcl=True):
def getTableOfContents(self, rootHandle, maxDepth, skipExcl=True):
"""Generate a table of contents up to a maximum depth.
"""
tOrder = []
tData = {}
pKey = None
for tHandle, sTitle, hItem in self._itemIndex.iterNovelStructure(skipExcl=skipExcl):
for tHandle, sTitle, hItem in self._itemIndex.iterNovelStructure(
rootHandle=rootHandle, skipExcl=skipExcl
):
tKey = f"{tHandle}:{sTitle}"
iLevel = nwHeaders.H_LEVEL.get(hItem.level, 0)
if iLevel > maxDepth:
@@ -647,23 +685,17 @@ class TagsIndex:
def tagHandle(self, tagKey):
"""Get the handle of a given tag.
"""
if tagKey in self._tags:
return self._tags.get(tagKey).get("handle")
return None
return self._tags.get(tagKey, {}).get("handle", None)
def tagHeading(self, tagKey):
"""Get the heading of a given tag.
"""
if tagKey in self._tags:
return self._tags.get(tagKey).get("heading")
return nwHeaders.TT_NONE
return self._tags.get(tagKey, {}).get("heading", nwHeaders.TT_NONE)
def tagClass(self, tagKey):
"""Get the class of a given tag.
"""
if tagKey in self._tags:
return self._tags.get(tagKey).get("class")
return None
return self._tags.get(tagKey, {}).get("class", None)
##
# Pack/Unpack
@@ -717,8 +749,8 @@ class ItemIndex:
IndexHeading object for each header of the text.
"""
def __init__(self, theProject):
self.theProject = theProject
def __init__(self, project):
self._project = project
self._items = {}
return
@@ -755,13 +787,6 @@ class ItemIndex:
self._items[tHandle] = IndexItem(tHandle, tItem)
return
def mainItemHeader(self, tHandle):
"""Return the primary item header for an item.
"""
if tHandle in self._items:
return self._items[tHandle].level
return "H0"
def allItemTags(self, tHandle):
"""Get all tags set for headings of an item.
"""
@@ -789,12 +814,12 @@ class ItemIndex:
"""Iterate over all items and headers in the novel structure for
a given root handle, or for all if root handle is None.
"""
for tItem in self.theProject.tree:
for tItem in self._project.tree:
if tItem is None:
continue
if tItem.itemLayout == nwItemLayout.NOTE:
if tItem.isNoteLayout():
continue
if skipExcl and not tItem.isExported:
if skipExcl and not tItem.isActive:
continue
tHandle = tItem.itemHandle
@@ -807,8 +832,6 @@ class ItemIndex:
elif tItem.itemRoot == rootHandle:
for sTitle in self._items[tHandle].headings():
yield tHandle, sTitle, self._items[tHandle][sTitle]
else:
continue
return
@@ -821,7 +844,6 @@ class ItemIndex:
"""
if tHandle in self._items:
tItem = self._items[tHandle]
tItem.updateLevel(hDepth)
tItem.addHeading(IndexHeading(sTitle, hDepth, hText))
return
@@ -875,7 +897,7 @@ class ItemIndex:
if not isHandle(tHandle):
raise ValueError("itemIndex keys must be handles")
nwItem = self.theProject.tree[tHandle]
nwItem = self._project.tree[tHandle]
if nwItem is not None:
tItem = IndexItem(tHandle, nwItem)
tItem.unpackData(tData)
@@ -897,7 +919,6 @@ class IndexItem:
def __init__(self, tHandle, tItem):
self._handle = tHandle
self._item = tItem
self._level = "H0"
self._headings = {}
self._index = 0
@@ -917,21 +938,10 @@ class IndexItem:
def item(self):
return self._item
@property
def level(self):
return self._level
##
# Setters
##
def updateLevel(self, level):
"""Set the level only if it has not already been set.
"""
if self._level == "H0":
self._level = level
return
def addHeading(self, tHeading):
"""Add a heading to the item. Also remove the placeholder entry
if it exists.
@@ -1011,7 +1021,7 @@ class IndexItem:
if hRefs:
refs[sTitle] = hRefs
data = {"level": self._level}
data = {}
data["headings"] = heads
if refs:
data["references"] = refs
@@ -1021,7 +1031,6 @@ class IndexItem:
def unpackData(self, data):
"""Unpack an item entry from the data.
"""
self._level = data.get("level", "H0")
references = data.get("references", {})
for sTitle, hData in data.get("headings", {}).items():
if not isTitleTag(sTitle):
@@ -1030,6 +1039,7 @@ class IndexItem:
tHeading.unpackData(hData)
tHeading.unpackReferences(references.get(sTitle, {}))
self.addHeading(tHeading)
return
# END Class IndexItem
+161 -151
View File
@@ -25,23 +25,27 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
import logging
from lxml import etree
from novelwriter.enum import nwItemType, nwItemClass, nwItemLayout
from novelwriter.common import (
checkInt, isHandle, isItemClass, isItemLayout, isItemType, simplified
checkInt, isHandle, isItemClass, isItemLayout, isItemType, simplified, yesNo
)
from novelwriter.constants import nwLabels, trConst
from novelwriter.constants import nwHeaders, nwLabels, trConst
logger = logging.getLogger(__name__)
class NWItem():
class NWItem:
def __init__(self, theProject):
__slots__ = (
"_project", "_name", "_handle", "_parent", "_root", "_order",
"_type", "_class", "_layout", "_status", "_import", "_active",
"_expanded", "_heading", "_charCount", "_wordCount",
"_paraCount", "_cursorPos", "_initCount",
)
self.theProject = theProject
def __init__(self, project):
self._project = project
self._name = ""
self._handle = None
self._parent = None
@@ -52,15 +56,16 @@ class NWItem():
self._layout = nwItemLayout.NO_LAYOUT
self._status = None
self._import = None
self._active = True
self._expanded = False
self._exported = True
# Document Meta Data
self._charCount = 0 # Current character count
self._wordCount = 0 # Current word count
self._paraCount = 0 # Current paragraph count
self._cursorPos = 0 # Last cursor position
self._initCount = 0 # Initial word count
self._heading = "H0" # The main heading
self._charCount = 0 # Current character count
self._wordCount = 0 # Current word count
self._paraCount = 0 # Current paragraph count
self._cursorPos = 0 # Last cursor position
self._initCount = 0 # Initial word count
return
@@ -114,13 +119,17 @@ class NWItem():
def itemImport(self):
return self._import
@property
def isActive(self):
return self._active
@property
def isExpanded(self):
return self._expanded
@property
def isExported(self):
return self._exported
def mainHeading(self):
return self._heading
@property
def charCount(self):
@@ -143,101 +152,75 @@ class NWItem():
return self._cursorPos
##
# XML Pack/Unpack
# Pack/Unpack Data
##
def packXML(self, xParent):
"""Pack all the data in the class instance into an XML object.
def pack(self):
"""Pack all the data in the class instance into a dictionary.
"""
itemAttrib = {}
itemAttrib["handle"] = str(self._handle)
itemAttrib["parent"] = str(self._parent)
itemAttrib["root"] = str(self._root)
itemAttrib["order"] = str(self._order)
itemAttrib["type"] = str(self._type.name)
itemAttrib["class"] = str(self._class.name)
item = {}
meta = {}
name = {}
item["handle"] = str(self._handle)
item["parent"] = str(self._parent)
item["root"] = str(self._root)
item["order"] = str(self._order)
item["type"] = str(self._type.name)
item["class"] = str(self._class.name)
meta["expanded"] = yesNo(self._expanded)
name["status"] = str(self._status)
name["import"] = str(self._import)
if self._type == nwItemType.FILE:
itemAttrib["layout"] = str(self._layout.name)
item["layout"] = str(self._layout.name)
meta["heading"] = str(self._heading)
meta["charCount"] = str(self._charCount)
meta["wordCount"] = str(self._wordCount)
meta["paraCount"] = str(self._paraCount)
meta["cursorPos"] = str(self._cursorPos)
name["active"] = yesNo(self._active)
metaAttrib = {}
metaAttrib["expanded"] = str(self._expanded)
if self._type == nwItemType.FILE:
metaAttrib["charCount"] = str(self._charCount)
metaAttrib["wordCount"] = str(self._wordCount)
metaAttrib["paraCount"] = str(self._paraCount)
metaAttrib["cursorPos"] = str(self._cursorPos)
data = {
"name": str(self._name),
"itemAttr": item,
"metaAttr": meta,
"nameAttr": name,
}
nameAttrib = {}
nameAttrib["status"] = str(self._status)
nameAttrib["import"] = str(self._import)
if self._type == nwItemType.FILE:
nameAttrib["exported"] = str(self._exported)
return data
xPack = etree.SubElement(xParent, "item", attrib=itemAttrib)
self._subPack(xPack, "meta", attrib=metaAttrib)
self._subPack(xPack, "name", text=str(self._name), attrib=nameAttrib)
return
def unpackXML(self, xItem):
"""Set the values from an XML entry of type 'item'.
def unpack(self, data):
"""Set the values from a data dictionary.
"""
if xItem.tag != "item":
logger.error("XML entry is not an NWItem")
return False
item = data.get("itemAttr", {})
meta = data.get("metaAttr", {})
name = data.get("nameAttr", {})
if "handle" in xItem.attrib:
self.setHandle(xItem.attrib["handle"])
if "handle" in item:
self.setHandle(item["handle"])
else:
logger.error("XML item entry does not have a handle")
logger.error("Item does not have a handle")
return False
self.setParent(xItem.attrib.get("parent", None))
self.setRoot(xItem.attrib.get("root", None))
self.setOrder(xItem.attrib.get("order", 0))
self.setType(xItem.attrib.get("type", nwItemType.NO_TYPE))
self.setClass(xItem.attrib.get("class", nwItemClass.NO_CLASS))
self.setLayout(xItem.attrib.get("layout", nwItemLayout.NO_LAYOUT))
self.setName(data.get("name", ""))
self.setParent(item.get("parent", None))
self.setRoot(item.get("root", None))
self.setOrder(item.get("order", 0))
self.setType(item.get("type", nwItemType.NO_TYPE))
self.setClass(item.get("class", nwItemClass.NO_CLASS))
self.setExpanded(meta.get("expanded", False))
self.setStatus(name.get("status", None))
self.setImport(name.get("import", None))
for xValue in xItem:
if xValue.tag == "meta":
self.setExpanded(xValue.attrib.get("expanded", False))
self.setCharCount(xValue.attrib.get("charCount", 0))
self.setWordCount(xValue.attrib.get("wordCount", 0))
self.setParaCount(xValue.attrib.get("paraCount", 0))
self.setCursorPos(xValue.attrib.get("cursorPos", 0))
elif xValue.tag == "name":
self.setName(xValue.text)
self.setStatus(xValue.attrib.get("status", None))
self.setImport(xValue.attrib.get("import", None))
self.setExported(xValue.attrib.get("exported", True))
# Legacy Format (1.3 and earlier)
elif xValue.tag == "status":
self.setImportStatus(xValue.text)
elif xValue.tag == "type":
self.setType(xValue.text)
elif xValue.tag == "class":
self.setClass(xValue.text)
elif xValue.tag == "layout":
self.setLayout(xValue.text)
elif xValue.tag == "expanded":
self.setExpanded(xValue.text)
elif xValue.tag == "exported":
self.setExported(xValue.text)
elif xValue.tag == "charCount":
self.setCharCount(xValue.text)
elif xValue.tag == "wordCount":
self.setWordCount(xValue.text)
elif xValue.tag == "paraCount":
self.setParaCount(xValue.text)
elif xValue.tag == "cursorPos":
self.setCursorPos(xValue.text)
else:
# Sliently skip as we may otherwise cause orphaned
# items if an otherwise valid file is opened by a
# version of novelWriter that doesn't know the tag
logger.error("Unknown tag '%s'", xValue.tag)
if self._type == nwItemType.FILE:
self.setLayout(item.get("layout", nwItemLayout.NO_LAYOUT))
self.setMainHeading(meta.get("heading", "H0"))
self.setCharCount(meta.get("charCount", 0))
self.setWordCount(meta.get("wordCount", 0))
self.setParaCount(meta.get("paraCount", 0))
self.setCursorPos(meta.get("cursorPos", 0))
self.setActive(name.get("active", True))
# Make some checks to ensure consistency
if self._type == nwItemType.ROOT:
@@ -245,31 +228,22 @@ class NWItem():
self._parent = None # Root items cannot have a parent
if self._type != nwItemType.FILE:
self._charCount = 0 # Only set for files
self._wordCount = 0 # Only set for files
self._paraCount = 0 # Only set for files
self._cursorPos = 0 # Only set for files
# Reset values that should only be set for files
self._layout = nwItemLayout.NO_LAYOUT
self._heading = "H0"
self._active = False
self._charCount = 0
self._wordCount = 0
self._paraCount = 0
self._cursorPos = 0
return True
@staticmethod
def _subPack(xParent, name, attrib=None, text=None, none=True):
"""Pack the values into an XML element.
"""
if not none and (text is None or text == "None"):
return None
xAttr = {} if attrib is None else attrib
xSub = etree.SubElement(xParent, name, attrib=xAttr)
if text is not None:
xSub.text = text
return
##
# Lookup Methods
##
def describeMe(self, hLevel=None):
def describeMe(self):
"""Return a string description of the item.
"""
descKey = "none"
@@ -279,12 +253,14 @@ class NWItem():
descKey = "folder"
elif self._type == nwItemType.FILE:
if self._layout == nwItemLayout.DOCUMENT:
if hLevel == "H1":
if self._heading == "H1":
descKey = "doc_h1"
elif hLevel == "H2":
elif self._heading == "H2":
descKey = "doc_h2"
elif hLevel == "H3":
elif self._heading == "H3":
descKey = "doc_h3"
elif self._heading == "H4":
descKey = "doc_h4"
else:
descKey = "document"
elif self._layout == nwItemLayout.NOTE:
@@ -292,6 +268,22 @@ class NWItem():
return trConst(nwLabels.ITEM_DESCRIPTION.get(descKey, ""))
def getImportStatus(self, incIcon=True):
"""Return the relevant importance or status label and icon for
the current item based on its class.
"""
if self.isNovelLike():
stName = self._project.data.itemStatus.name(self._status)
stIcon = self._project.data.itemStatus.icon(self._status) if incIcon else None
else:
stName = self._project.data.itemImport.name(self._import)
stIcon = self._project.data.itemImport.icon(self._import) if incIcon else None
return stName, stIcon
##
# Checker Methods
##
def isNovelLike(self):
"""Returns true if the item is of a novel-like class.
"""
@@ -307,17 +299,20 @@ class NWItem():
"""
return self._class in (nwItemClass.NO_CLASS, nwItemClass.ARCHIVE, nwItemClass.TRASH)
def getImportStatus(self):
"""Return the relevant importance or status label and icon for
the current item based on its class.
"""
if self.isNovelLike():
stName = self.theProject.statusItems.name(self._status)
stIcon = self.theProject.statusItems.icon(self._status)
else:
stName = self.theProject.importItems.name(self._import)
stIcon = self.theProject.importItems.icon(self._import)
return stName, stIcon
def isRootType(self):
return self._type == nwItemType.ROOT
def isFolderType(self):
return self._type == nwItemType.FOLDER
def isFileType(self):
return self._type == nwItemType.FILE
def isNoteLayout(self):
return self._layout == nwItemLayout.NOTE
def isDocumentLayout(self):
return self._layout == nwItemLayout.DOCUMENT
##
# Special Setters
@@ -419,8 +414,6 @@ class NWItem():
self._type = value
elif isItemType(value):
self._type = nwItemType[value]
elif value == "TRASH":
self._type = nwItemType.ROOT
else:
logger.error("Unrecognised item type '%s'", value)
self._type = nwItemType.NO_TYPE
@@ -447,8 +440,6 @@ class NWItem():
self._layout = value
elif isItemLayout(value):
self._layout = nwItemLayout[value]
elif value in ("TITLE", "PAGE", "BOOK", "PARTITION", "UNNUMBERED", "CHAPTER", "SCENE"):
self._layout = nwItemLayout.DOCUMENT
else:
logger.error("Unrecognised item layout '%s'", value)
self._layout = nwItemLayout.NO_LAYOUT
@@ -458,60 +449,79 @@ class NWItem():
"""Set the item status by looking it up in the valid status
items of the current project.
"""
self._status = self.theProject.statusItems.check(value)
self._status = self._project.data.itemStatus.check(value)
return
def setImport(self, value):
"""Set the item importance by looking it up in the valid import
items of the current project.
"""
self._import = self.theProject.importItems.check(value)
self._import = self._project.data.itemImport.check(value)
return
def setActive(self, state):
"""Set the active flag.
"""
if isinstance(state, bool):
self._active = state
else:
self._active = False
return
def setExpanded(self, state):
"""Set the expanded status of an item in the project tree.
"""
if isinstance(state, str):
self._expanded = (state == str(True))
if isinstance(state, bool):
self._expanded = state
else:
self._expanded = (state is True)
return
def setExported(self, state):
"""Set the export flag.
"""
if isinstance(state, str):
self._exported = (state == str(True))
else:
self._exported = (state is True)
self._expanded = False
return
##
# Set Document Meta Data
##
def setMainHeading(self, value):
"""Set the main heading level.
"""
if value in nwHeaders.H_LEVEL:
self._heading = value
return
def setCharCount(self, count):
"""Set the character count, and ensure that it is an integer.
"""
self._charCount = max(0, checkInt(count, 0))
if isinstance(count, int):
self._charCount = max(0, count)
else:
self._charCount = 0
return
def setWordCount(self, count):
"""Set the word count, and ensure that it is an integer.
"""
self._wordCount = max(0, checkInt(count, 0))
if isinstance(count, int):
self._wordCount = max(0, count)
else:
self._wordCount = 0
return
def setParaCount(self, count):
"""Set the paragraph count, and ensure that it is an integer.
"""
self._paraCount = max(0, checkInt(count, 0))
if isinstance(count, int):
self._paraCount = max(0, count)
else:
self._paraCount = 0
return
def setCursorPos(self, position):
"""Set the cursor position, and ensure that it is an integer.
"""
self._cursorPos = max(0, checkInt(position, 0))
if isinstance(position, int):
self._cursorPos = max(0, position)
else:
self._cursorPos = 0
return
def saveInitialCount(self):
+13 -16
View File
@@ -24,11 +24,11 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
import os
import json
import logging
from enum import Enum
from pathlib import Path
from novelwriter.error import logException
from novelwriter.common import checkBool, checkFloat, checkInt, checkString
@@ -40,30 +40,30 @@ VALID_MAP = {
"GuiWritingStats": {
"winWidth", "winHeight", "widthCol0", "widthCol1", "widthCol2",
"widthCol3", "sortCol", "sortOrder", "incNovel", "incNotes",
"hideZeros", "hideNegative", "groupByDay", "showIdleTime", "histMax"
"hideZeros", "hideNegative", "groupByDay", "showIdleTime", "histMax",
},
"GuiDocSplit": {"spLevel"},
"GuiDocSplit": {"spLevel", "intoFolder", "docHierarchy"},
"GuiBuildNovel": {
"winWidth", "winHeight", "boxWidth", "docWidth", "hideScene",
"hideSection", "addNovel", "addNotes", "ignoreFlag", "justifyText",
"excludeBody", "textFont", "textSize", "lineHeight", "noStyling",
"incSynopsis", "incComments", "incKeywords", "incBodyText",
"replaceTabs", "replaceUCode"
"replaceTabs", "replaceUCode", "rootFilter",
},
"GuiOutline": {"headerOrder", "columnWidth", "columnHidden"},
"GuiProjectSettings": {
"winWidth", "winHeight", "replaceColW", "statusColW", "importColW"
"winWidth", "winHeight", "replaceColW", "statusColW", "importColW",
},
"GuiProjectDetails": {
"winWidth", "winHeight", "widthCol0", "widthCol1", "widthCol2",
"widthCol3", "widthCol4", "wordsPerPage", "countFrom", "clearDouble"
"widthCol3", "widthCol4", "wordsPerPage", "countFrom", "clearDouble",
},
"GuiWordList": {"winWidth", "winHeight"},
"GuiNovelView": {"lastCol"},
}
class OptionState():
class OptionState:
def __init__(self, theProject):
self.theProject = theProject
@@ -77,13 +77,12 @@ class OptionState():
def loadSettings(self):
"""Load the options dictionary from the project settings file.
"""
if self.theProject.projMeta is None:
stateFile = self.theProject.storage.getMetaFile(nwFiles.OPTS_FILE)
if not isinstance(stateFile, Path):
return False
stateFile = os.path.join(self.theProject.projMeta, nwFiles.OPTS_FILE)
theState = {}
if os.path.isfile(stateFile):
if stateFile.exists():
logger.debug("Loading GUI options file")
try:
with open(stateFile, mode="r", encoding="utf-8") as inFile:
@@ -106,12 +105,11 @@ class OptionState():
def saveSettings(self):
"""Save the options dictionary to the project settings file.
"""
if self.theProject.projMeta is None:
stateFile = self.theProject.storage.getMetaFile(nwFiles.OPTS_FILE)
if not isinstance(stateFile, Path):
return False
stateFile = os.path.join(self.theProject.projMeta, nwFiles.OPTS_FILE)
logger.debug("Saving GUI options file")
try:
with open(stateFile, mode="w+", encoding="utf-8") as outFile:
json.dump(self._theState, outFile, indent=2)
@@ -188,8 +186,7 @@ class OptionState():
the default value.
"""
if group in self._theState:
if name in self._theState[group]:
return checkBool(self._theState[group].get(name, default), default)
return checkBool(self._theState[group].get(name, default), default)
return default
def getEnum(self, group, name, lookup, default):
+288 -1075
View File
File diff suppressed because it is too large Load Diff
+354
View File
@@ -0,0 +1,354 @@
"""
novelWriter Project Data Class
================================
Data class for novelWriter projects
File History:
Created: 2022-10-30 [2.0rc1]
This file is a part of novelWriter
Copyright 20182022, Veronica Berglyd Olsen
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
from __future__ import annotations
import uuid
import logging
from novelwriter.common import (
checkBool, checkInt, checkStringNone, checkUuid, isHandle, simplified
)
from novelwriter.core.status import NWStatus
logger = logging.getLogger(__name__)
class NWProjectData:
def __init__(self, theProject):
self.theProject = theProject
# Project Meta
self._uuid = ""
self._name = ""
self._title = ""
self._authors = []
self._saveCount = 0
self._autoCount = 0
self._editTime = 0
# Project Settings
self._doBackup = True
self._language = None
self._spellCheck = False
self._spellLang = None
# Project Dictionaries
self._initCounts = [0, 0]
self._currCounts = [0, 0]
self._lastHandle: dict[str, str | None] = {
"editor": None,
"viewer": None,
"novelTree": None,
"outline": None,
}
self._autoReplace: dict[str, str] = {}
self._titleFormat: dict[str, str] = {
"title": "%title%",
"chapter": "%title%",
"unnumbered": "%title%",
"scene": "* * *",
"section": "",
}
self._status = NWStatus(NWStatus.STATUS)
self._import = NWStatus(NWStatus.IMPORT)
return
##
# Properties
##
@property
def uuid(self):
return self._uuid
@property
def name(self):
return self._name
@property
def title(self):
return self._title
@property
def authors(self):
return self._authors
@property
def saveCount(self):
return self._saveCount
@property
def autoCount(self):
return self._autoCount
@property
def editTime(self):
return self._editTime
@property
def doBackup(self):
return self._doBackup
@property
def language(self):
return self._language
@property
def spellCheck(self):
return self._spellCheck
@property
def spellLang(self):
return self._spellLang
@property
def initCounts(self):
return tuple(self._initCounts)
@property
def currCounts(self):
return tuple(self._currCounts)
@property
def lastHandle(self):
return self._lastHandle
@property
def autoReplace(self):
return self._autoReplace
@property
def titleFormat(self):
return self._titleFormat
@property
def itemStatus(self):
return self._status
@property
def itemImport(self):
return self._import
##
# Methods
##
def addAuthor(self, value):
"""Add an author to the authors list.
"""
self._authors.append(simplified(str(value)))
self.theProject.setProjectChanged(True)
return
def incSaveCount(self):
"""Increment the save count by one.
"""
self._saveCount += 1
self.theProject.setProjectChanged(True)
return
def incAutoCount(self):
"""Increment the auto save count by one.
"""
self._autoCount += 1
self.theProject.setProjectChanged(True)
return
##
# Getters
##
def getLastHandle(self, component):
"""Retrieve the last used handle for a given component.
"""
return self._lastHandle.get(component, None)
def getTitleFormat(self, kind):
"""Retrieve the title format string for a given kind of header.
"""
return self._titleFormat.get(kind, "%title%")
##
# Setters
##
def setUuid(self, value):
"""Set the project id.
"""
value = checkUuid(value, "")
if not value:
self._uuid = str(uuid.uuid4())
elif value != self._uuid:
self._uuid = value
self.theProject.setProjectChanged(True)
return
def setName(self, value):
"""Set a new project name.
"""
if value != self._name:
self._name = simplified(str(value))
self.theProject.setProjectChanged(True)
return
def setTitle(self, value):
"""Set a new novel title.
"""
if value != self._title:
self._title = simplified(str(value))
self.theProject.setProjectChanged(True)
return
def setAuthors(self, value):
"""Set the list of authors from either a string with one author
per line, or a list of authors.
"""
self._authors = []
self.theProject.setProjectChanged(True)
if isinstance(value, str):
for author in value.splitlines():
author = simplified(author)
if author:
self._authors.append(author)
self.theProject.setProjectChanged(True)
elif isinstance(value, list):
self._authors = value
return
def setSaveCount(self, value):
"""Set the save count from last session.
"""
self._saveCount = checkInt(value, 0)
self.theProject.setProjectChanged(True)
return
def setAutoCount(self, value):
"""Set the auto save count from last session.
"""
self._autoCount = checkInt(value, 0)
self.theProject.setProjectChanged(True)
return
def setEditTime(self, value):
"""Set tyje edit time from last session.
"""
self._editTime = checkInt(value, 0)
self.theProject.setProjectChanged(True)
return
def setDoBackup(self, value):
"""Set the do write backup flag.
"""
if value != self._doBackup:
self._doBackup = checkBool(value, False)
self.theProject.setProjectChanged(True)
return
def setLanguage(self, value):
"""Set the project language.
"""
if value != self._language:
self._language = checkStringNone(value, None)
self.theProject.setProjectChanged(True)
return
def setSpellCheck(self, value):
"""Set the spell check flag.
"""
if value != self._spellCheck:
self._spellCheck = checkBool(value, False)
self.theProject.setProjectChanged(True)
return
def setSpellLang(self, value):
"""Set the spell check language.
"""
if value != self._spellLang:
self._spellLang = checkStringNone(value, None)
self.theProject.setProjectChanged(True)
return
def setLastHandle(self, value, component=None):
"""Set a last used handle into the handle registry. If component
is None, the value is assumed to be the whole dictionary of
values.
"""
if isinstance(component, str):
self._lastHandle[component] = checkStringNone(value, None)
self.theProject.setProjectChanged(True)
elif isinstance(value, dict):
for key, entry in value.items():
if key in self._lastHandle:
self._lastHandle[key] = str(entry) if isHandle(entry) else None
self.theProject.setProjectChanged(True)
return
def setInitCounts(self, novel=None, notes=None):
"""Set the worc count totals for novel and note files.
"""
if novel is not None:
self._initCounts[0] = checkInt(novel, 0)
self._currCounts[0] = checkInt(novel, 0)
if notes is not None:
self._initCounts[1] = checkInt(notes, 0)
self._currCounts[1] = checkInt(notes, 0)
return
def setCurrCounts(self, novel=None, notes=None):
"""Set the worc count totals for novel and note files.
"""
if novel is not None:
self._currCounts[0] = checkInt(novel, 0)
if notes is not None:
self._currCounts[1] = checkInt(notes, 0)
return
def setAutoReplace(self, value):
"""Set the auto-replace dictionary.
"""
if isinstance(value, dict):
self._autoReplace = {}
for key, entry in value.items():
if isinstance(entry, str):
self._autoReplace[key] = simplified(entry)
self.theProject.setProjectChanged(True)
return
def setTitleFormat(self, value):
"""Set the title formats.
"""
if isinstance(value, dict):
for key, entry in value.items():
if key in self._titleFormat and isinstance(entry, str):
self._titleFormat[key] = simplified(entry)
self.theProject.setProjectChanged(True)
return
# END Class NWProjectData
+611
View File
@@ -0,0 +1,611 @@
"""
novelWriter Project XML Read/Write
====================================
Classes for reading and writing the project XML file
File History:
Created: 2022-09-28 [2.0rc1] ProjectXMLReader
Created: 2022-09-28 [2.0rc1] XMLReadState
This file is a part of novelWriter
Copyright 20182022, Veronica Berglyd Olsen
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
import logging
import novelwriter
from enum import Enum
from lxml import etree
from time import time
from pathlib import Path
from novelwriter.common import (
checkBool, checkInt, checkString, checkStringNone, formatTimeStamp,
hexToInt, simplified, yesNo
)
from novelwriter.constants import nwFiles
logger = logging.getLogger(__name__)
FILE_VERSION = "1.5" # The current project file format version
HEX_VERSION = 0x0105
NUM_VERSION = {
"1.0": 0x0100, # Up to 0.7
"1.1": 0x0101, # Up to 0.10
"1.2": 0x0102, # Up to 1.5
"1.3": 0x0103, # Up to 2.0 Beta 1
"1.4": 0x0104, # Up to 2.0 RC 2
"1.5": 0x0105, # Current
}
class XMLReadState(Enum):
NO_ACTION = 0
NO_ERROR = 1
PARSED_BACKUP = 2
CANNOT_PARSE = 3
NOT_NWX_FILE = 4
UNKNOWN_VERSION = 5
PARSED_OK = 6
WAS_LEGACY = 7
# END Class XMLReadState
class ProjectXMLReader:
"""The main project XML file reader class. All data is read into a
NWProjectData instance, which must be provided.
File Format Version Change History
==================================
1.0 Original file format.
1.1 Changes the way documents are structured in the project folder
from data_X, where X is the first hex value of the handle, to a
single content folder. Introduced in version 0.7.
1.2 Changes the way autoReplace entries are stored. Introduced in
version 0.10.
1.3 Reduces the number of layouts to only two. One for novel
documents and one for project notes. Introduced in version 1.5.
1.4 Introduces a more compact format for storing items. All settings
aside from name are now attributes. This format also changes the
way satus and importance labels are stored. This format was only
a part of version 2.0 RC 1
1.5 The actual format released for 2.0. It moves last used handles
and title formats into a key/value format similar to auto-
replace, status and imporetance. It adds the heading value to
the content item meta entry. It also moves meta data related to
the project or the content into their respective section nodes
as attributes. The id attribute was also added to the project.
"""
def __init__(self, path):
self._path = Path(path)
self._state = XMLReadState.NO_ACTION
self._root = ""
self._version = 0x0
self._appVersion = ""
self._hexVersion = 0x0
self._timeStamp = ""
return
##
# Properties
##
@property
def state(self):
"""The state of the parsing as an XMLReadState enum value.
"""
return self._state
@property
def xmlRoot(self):
"""The root tag name of the XNL file,
"""
return self._root
@property
def xmlVersion(self):
"""The project XML version number.
"""
return self._version
@property
def appVersion(self):
"""The novelWriter version number who wrote the file.
"""
return self._appVersion
@property
def hexVersion(self):
"""The novelWriter version number who wrote the file as hex.
"""
return self._hexVersion
@property
def timeStamp(self):
"""The date and time when the file was written.
"""
return self._timeStamp
##
# Methods
##
def read(self, projData, projContent):
"""Read and parse the project XML file.
"""
tStart = time()
logger.debug("Reading project XML")
try:
xml = etree.parse(str(self._path))
self._state = XMLReadState.NO_ERROR
except Exception as exc:
# Trying to open backup file instead
logger.error("Failed to parse project XML", exc_info=exc)
self._state = XMLReadState.CANNOT_PARSE
backFile = self._path.with_suffix(".bak")
if backFile.is_file():
try:
xml = etree.parse(str(backFile))
self._state = XMLReadState.PARSED_BACKUP
logger.info("Backup project file parsed")
except Exception as exc:
logger.error("Failed to parse backup project XML", exc_info=exc)
self._state = XMLReadState.CANNOT_PARSE
return False
else:
self._state = XMLReadState.CANNOT_PARSE
return False
xRoot = xml.getroot()
self._root = str(xRoot.tag)
if self._root != "novelWriterXML":
self._state = XMLReadState.NOT_NWX_FILE
return False
fileVersion = str(xRoot.attrib.get("fileVersion", ""))
if fileVersion in NUM_VERSION:
self._version = NUM_VERSION[fileVersion]
else:
self._state = XMLReadState.UNKNOWN_VERSION
return False
logger.debug("XML is '%s' version '%s'", self._root, fileVersion)
self._appVersion = str(xRoot.attrib.get("appVersion", ""))
self._hexVersion = hexToInt(xRoot.attrib.get("hexVersion", ""))
self._timeStamp = str(xRoot.attrib.get("timeStamp", ""))
for xSection in xRoot:
if xSection.tag == "project":
self._parseProjectMeta(xSection, projData)
elif xSection.tag == "settings":
self._parseProjectSettings(xSection, projData)
elif xSection.tag == "content":
if self._version >= 0x0104:
self._parseProjectContent(xSection, projData, projContent)
else:
self._parseProjectContentLegacy(xSection, projData, projContent)
else:
logger.warning("Ignored <root/%s> in XML", xSection.tag)
if self._version == HEX_VERSION:
self._state = XMLReadState.PARSED_OK
else:
self._state = XMLReadState.WAS_LEGACY
logger.debug("Project XML loaded in %.3f ms", (time() - tStart)*1000)
return True
##
# Internal Functions
##
def _parseProjectMeta(self, xSection, projData):
"""Parse the project section of the XML file.
"""
logger.debug("Parsing <project> section")
projData.setUuid(xSection.attrib.get("id", None)) # Added in 1.5
projData.setSaveCount(xSection.attrib.get("saveCount", 0)) # Moved in 1.5
projData.setAutoCount(xSection.attrib.get("autoCount", 0)) # Moved in 1.5
projData.setEditTime(xSection.attrib.get("editTime", 0)) # Moved in 1.5
for xItem in xSection:
if xItem.tag == "name":
projData.setName(xItem.text)
elif xItem.tag == "title":
projData.setTitle(xItem.text)
elif xItem.tag == "author":
projData.addAuthor(xItem.text)
else:
logger.warning("Ignored <root/project/%s> in XML", xItem.tag)
# Deprecated Nodes
if self._version < HEX_VERSION:
for xItem in xSection:
if xItem.tag == "saveCount": # Moved to attribute in 1.5
projData.setSaveCount(xItem.text)
elif xItem.tag == "autoCount": # Moved to attribute in 1.5
projData.setAutoCount(xItem.text)
elif xItem.tag == "editTime": # Moved to attribute in 1.5
projData.setEditTime(xItem.text)
return
def _parseProjectSettings(self, xSection, projData):
"""Parse the settings section of the XML file.
"""
logger.debug("Parsing <settings> section")
for xItem in xSection:
if xItem.tag == "doBackup":
projData.setDoBackup(xItem.text)
elif xItem.tag == "language":
projData.setLanguage(xItem.text)
elif xItem.tag == "spellChecking":
projData.setSpellLang(xItem.text)
projData.setSpellCheck(xItem.attrib.get("auto", False))
elif xItem.tag == "status":
self._parseStatusImport(xItem, projData.itemStatus)
elif xItem.tag == "importance":
self._parseStatusImport(xItem, projData.itemImport)
elif xItem.tag == "lastHandle":
projData.setLastHandle(self._parseDictKeyText(xItem))
elif xItem.tag == "autoReplace":
if self._version >= 0x0102:
projData.setAutoReplace(self._parseDictKeyText(xItem))
else: # Pre 1.2 format
projData.setAutoReplace(self._parseDictTagText(xItem))
elif xItem.tag == "titleFormat":
if self._version >= 0x0105:
projData.setTitleFormat(self._parseDictKeyText(xItem))
else: # Pre 1.4 format
projData.setTitleFormat(self._parseDictTagText(xItem))
else:
logger.warning("Ignored <root/settings/%s> in XML", xItem.tag)
# Deprecated Nodes
if self._version < HEX_VERSION:
for xItem in xSection:
if xItem.tag == "spellCheck": # Changed to spellChecking in 1.5
projData.setSpellCheck(xItem.text)
elif xItem.tag == "spellLang": # Changed to spellChecking in 1.5
projData.setSpellLang(xItem.text)
elif xItem.tag == "novelWordCount": # Moved to content attribute in 1.5
projData.setInitCounts(novel=xItem.text)
elif xItem.tag == "notesWordCount": # Moved to content attribute in 1.5
projData.setInitCounts(notes=xItem.text)
return
def _parseProjectContent(self, xSection, projData, projContent):
"""Parse the content section of the XML file.
"""
logger.debug("Parsing <content> section")
projData.setInitCounts(novel=xSection.attrib.get("novelWords", None)) # Moved in 1.5
projData.setInitCounts(notes=xSection.attrib.get("notesWords", None)) # Moved in 1.5
for xItem in xSection:
if xItem.tag != "item":
logger.warning("Ignored item <root/content/%s> in XML", xItem.tag)
continue
item = {}
meta = {}
name = {}
itemName = ""
item["handle"] = checkStringNone(xItem.attrib.get("handle"), None)
item["parent"] = checkStringNone(xItem.attrib.get("parent"), None)
item["root"] = checkStringNone(xItem.attrib.get("root"), None)
item["order"] = checkInt(xItem.attrib.get("order"), 0)
item["type"] = checkString(xItem.attrib.get("type"), "NO_TYPE")
item["class"] = checkString(xItem.attrib.get("class"), "NO_CLASS")
item["layout"] = checkString(xItem.attrib.get("layout"), "NO_LAYOUT")
for xVal in xItem:
if xVal.tag == "meta":
meta["expanded"] = checkBool(xVal.attrib.get("expanded"), False)
meta["heading"] = checkString(xVal.attrib.get("heading"), "H0")
meta["charCount"] = checkInt(xVal.attrib.get("charCount"), 0)
meta["wordCount"] = checkInt(xVal.attrib.get("wordCount"), 0)
meta["paraCount"] = checkInt(xVal.attrib.get("paraCount"), 0)
meta["cursorPos"] = checkInt(xVal.attrib.get("cursorPos"), 0)
elif xVal.tag == "name":
itemName = simplified(checkString(xVal.text, ""))
name["status"] = checkStringNone(xVal.attrib.get("status"), None)
name["import"] = checkStringNone(xVal.attrib.get("import"), None)
name["active"] = checkBool(xVal.attrib.get("active"), False)
else:
logger.warning("Ignored <root/content/item/%s> in XML", xVal.tag)
# Deprecated Nodes
if self._version < HEX_VERSION:
for xVal in xItem:
if xVal.tag == "name" and "exported" in xVal.attrib:
name["active"] = checkBool(xVal.attrib.get("exported"), False)
projContent.append({
"name": itemName,
"itemAttr": item,
"metaAttr": meta,
"nameAttr": name,
})
return
def _parseProjectContentLegacy(self, xSection, projData, projContent):
"""Parse the content section of the XML file for older versions.
"""
logger.debug("Parsing <content> section (legacy format)")
# Create maps to look up name -> key for status and importance
statusMap = {entry.get("name"): key for key, entry in projData.itemStatus.items()}
importMap = {entry.get("name"): key for key, entry in projData.itemImport.items()}
for xItem in xSection:
if xItem.tag != "item":
logger.warning("Ignored item <root/content/%s> in XML", xItem.tag)
continue
item = {}
meta = {}
name = {}
itemName = ""
item["handle"] = checkStringNone(xItem.attrib.get("handle", None), None)
item["parent"] = checkStringNone(xItem.attrib.get("parent", None), None)
item["root"] = None # Value was added in 1.4
item["order"] = checkInt(xItem.attrib.get("order", 0), 0)
meta["heading"] = "H0" # Value was added in 1.4
tmpStatus = ""
for xVal in xItem:
if xVal.tag == "name":
itemName = simplified(checkString(xVal.text, ""))
elif xVal.tag == "status":
tmpStatus = checkStringNone(xVal.text, None)
elif xVal.tag == "type":
item["type"] = checkString(xVal.text, "")
elif xVal.tag == "class":
item["class"] = checkString(xVal.text, "")
elif xVal.tag == "layout":
item["layout"] = checkString(xVal.text, "")
elif xVal.tag == "expanded":
meta["expanded"] = checkBool(xVal.text, False)
elif xVal.tag == "exported": # Renamed to active in 1.5
name["active"] = checkBool(xVal.text, False)
elif xVal.tag == "charCount":
meta["charCount"] = checkInt(xVal.text, 0)
elif xVal.tag == "wordCount":
meta["wordCount"] = checkInt(xVal.text, 0)
elif xVal.tag == "paraCount":
meta["paraCount"] = checkInt(xVal.text, 0)
elif xVal.tag == "cursorPos":
meta["cursorPos"] = checkInt(xVal.text, 0)
else:
logger.warning("Ignored <root/content/item/%s> in XML", xVal.tag)
# Status was split into separate status/import with a key in 1.4
if item.get("class", "") in ("NOVEL", "ARCHIVE"):
name["status"] = statusMap.get(tmpStatus, None)
else:
name["import"] = importMap.get(tmpStatus, None)
# A number of layouts were removed in 1.3
if item.get("layout", "") in (
"TITLE", "PAGE", "BOOK", "PARTITION", "UNNUMBERED", "CHAPTER", "SCENE"
):
item["layout"] = "DOCUMENT"
# The trash type was removed in 1.4
if item.get("type", "") == "TRASH":
item["type"] = "ROOT"
projContent.append({
"name": itemName,
"itemAttr": item,
"metaAttr": meta,
"nameAttr": name,
})
return
def _parseStatusImport(self, xItem, sObject):
"""Parse a status or importance entry.
"""
for xEntry in xItem:
if xEntry.tag == "entry":
key = xEntry.attrib.get("key", None)
red = checkInt(xEntry.attrib.get("red", 0), 0)
green = checkInt(xEntry.attrib.get("green", 0), 0)
blue = checkInt(xEntry.attrib.get("blue", 0), 0)
count = checkInt(xEntry.attrib.get("count", 0), 0)
sObject.write(key, xEntry.text, (red, green, blue), count)
return
def _parseDictKeyText(self, xItem):
"""Parse a dictionary stored with key as an attribute and the
value as the text porperty.
"""
result = {}
for xEntry in xItem:
if xEntry.tag == "entry" and "key" in xEntry.attrib:
result[xEntry.attrib["key"]] = checkString(xEntry.text, "")
return result
def _parseDictTagText(self, xItem):
"""Parse a dictionary stored with key as the tag and the value
as the text porperty.
"""
return {xNode.tag: checkString(xNode.text, "") for xNode in xItem}
# END Class ProjectXMLReader
class ProjectXMLWriter:
def __init__(self, path):
self._path = Path(path)
self._error = None
return
##
# Properties
##
@property
def error(self):
return self._error
##
# Methods
##
def write(self, projData, projContent, saveTime, editTime):
"""Write the project data and content to the XML files.
"""
tStart = time()
logger.debug("Writing project XML")
xRoot = etree.Element("novelWriterXML", attrib={
"appVersion": str(novelwriter.__version__),
"hexVersion": str(novelwriter.__hexversion__),
"fileVersion": FILE_VERSION,
"timeStamp": formatTimeStamp(saveTime),
})
# Save Project Meta
projAttr = {
"id": projData.uuid,
"saveCount": str(projData.saveCount),
"autoCount": str(projData.autoCount),
"editTime": str(editTime),
}
xProject = etree.SubElement(xRoot, "project", attrib=projAttr)
self._packSingleValue(xProject, "name", projData.name)
self._packSingleValue(xProject, "title", projData.title)
self._packListValue(xProject, "author", projData.authors)
# Save Project Settings
xSettings = etree.SubElement(xRoot, "settings")
self._packSingleValue(xSettings, "doBackup", yesNo(projData.doBackup))
self._packSingleValue(xSettings, "language", projData.language)
self._packSingleValue(xSettings, "spellChecking", projData.spellLang, attrib={
"auto": yesNo(projData.spellCheck)
})
self._packDictKeyValue(xSettings, "lastHandle", projData.lastHandle)
self._packDictKeyValue(xSettings, "autoReplace", projData.autoReplace)
self._packDictKeyValue(xSettings, "titleFormat", projData.titleFormat)
# Save Status/Importance
xStatus = etree.SubElement(xSettings, "status")
for label, attrib in projData.itemStatus.pack():
self._packSingleValue(xStatus, "entry", label, attrib=attrib)
xImport = etree.SubElement(xSettings, "importance")
for label, attrib in projData.itemImport.pack():
self._packSingleValue(xImport, "entry", label, attrib=attrib)
# Save Tree Content
contAttr = {
"items": str(len(projContent)),
"novelWords": str(projData.currCounts[0]),
"notesWords": str(projData.currCounts[1]),
}
xContent = etree.SubElement(xRoot, "content", attrib=contAttr)
for item in projContent:
xItem = etree.SubElement(xContent, "item", attrib=item.get("itemAttr", {}))
etree.SubElement(xItem, "meta", attrib=item.get("metaAttr", {}))
xName = etree.SubElement(xItem, "name", attrib=item.get("nameAttr", {}))
xName.text = item["name"]
# Write the XML tree to file
saveFile = self._path / nwFiles.PROJ_FILE
tempFile = saveFile.with_suffix(".tmp")
backFile = saveFile.with_suffix(".bak")
try:
tempFile.write_bytes(etree.tostring(
xRoot, pretty_print=True, encoding="utf-8", xml_declaration=True
))
except Exception as exc:
self._error = exc
return False
# If we're here, the file was successfully saved,
# so let's sort out the temps and backups
try:
if saveFile.exists():
saveFile.replace(backFile)
tempFile.replace(saveFile)
except Exception as exc:
self._error = exc
return False
logger.debug("Project XML saved in %.3f ms", (time() - tStart)*1000)
return True
##
# Internal Functions
##
def _packSingleValue(self, xParent, name, value, attrib=None):
"""Pack a single value into an XML element.
"""
xItem = etree.SubElement(xParent, name, attrib=attrib)
xItem.text = str(value) or ""
return
def _packListValue(self, xParent, name, data):
"""Pack a list of values into an XML element.
"""
for value in data:
xItem = etree.SubElement(xParent, name)
xItem.text = str(value) or ""
return
def _packDictKeyValue(self, xParent, name, data):
"""Pack the entries of a dictionary into an XML element.
"""
xItem = etree.SubElement(xParent, name)
for key, value in data.items():
if len(key) > 0:
xEntry = etree.SubElement(xItem, "entry", attrib={"key": key})
xEntry.text = str(value) or ""
return
# END Class ProjectXMLWriter
+34 -18
View File
@@ -23,15 +23,17 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
import os
import logging
from collections import namedtuple
from pathlib import Path
from novelwriter.error import logException
logger = logging.getLogger(__name__)
class NWSpellEnchant():
class NWSpellEnchant:
def __init__(self):
@@ -46,36 +48,47 @@ class NWSpellEnchant():
return
##
# Getters and Setters
# Properties
##
@property
def spellLanguage(self):
return self._spellLanguage
##
# Setters
##
def setLanguage(self, theLang, projectDict=None):
"""Load a dictionary for the language specified in the config.
If that fails, we load a mock dictionary so that lookups don't
crash.
crash. Note that enchant will allow loading an empty string as
a tag, but this will fail later on. See issue #1096.
"""
self._theBroker = None
self._theDict = None
self._spellLanguage = None
try:
import enchant
if self._theBroker is not None:
logger.debug("Deleting old pyenchant broker")
del self._theBroker
self._theBroker = enchant.Broker()
self._theDict = self._theBroker.request_dict(theLang)
self._spellLanguage = theLang
logger.debug("Enchant spell checking for language '%s' loaded", theLang)
if theLang and enchant.dict_exists(theLang):
self._theBroker = enchant.Broker()
self._theDict = self._theBroker.request_dict(theLang)
self._spellLanguage = theLang
logger.debug("Enchant spell checking for language '%s' loaded", theLang)
else:
logger.warning("Enchant found no dictionary for language '%s'", theLang)
except Exception:
logger.error("Failed to load enchant spell checking for language '%s'", theLang)
self._theDict = FakeEnchant()
self._spellLanguage = None
self._readProjectDictionary(projectDict)
for pWord in self._projDict:
self._theDict.add_to_session(pWord)
if self._theDict is None:
self._theDict = FakeEnchant()
else:
self._readProjectDictionary(projectDict)
for pWord in self._projDict:
self._theDict.add_to_session(pWord)
return
@@ -160,10 +173,10 @@ class NWSpellEnchant():
self._projDict = set()
self._projectDict = projectDict
if projectDict is None:
if not isinstance(projectDict, Path):
return False
if not os.path.isfile(projectDict):
if not projectDict.exists():
return False
try:
@@ -189,6 +202,9 @@ class FakeEnchant:
"""Fallback for when Enchant is selected, but not installed.
"""
def __init__(self):
self.tag = ""
self.provider = namedtuple("provider", "name")
self.provider.name = ""
return
def check(self, theWord):
+48 -43
View File
@@ -28,16 +28,15 @@ import random
import logging
import novelwriter
from lxml import etree
from PyQt5.QtGui import QIcon, QPainter, QPainterPath, QPixmap, QColor
from PyQt5.QtCore import QRectF, Qt
from PyQt5.QtGui import QIcon, QPixmap, QColor
from novelwriter.common import checkInt, minmax, simplified
from novelwriter.common import minmax, simplified
logger = logging.getLogger(__name__)
class NWStatus():
class NWStatus:
STATUS = 1
IMPORT = 2
@@ -46,13 +45,17 @@ class NWStatus():
self._type = type
self._store = {}
self._reverse = {}
self._default = None
self._iconSize = novelwriter.CONFIG.pxInt(32)
pixmap = QPixmap(self._iconSize, self._iconSize)
pixmap.fill(QColor(100, 100, 100))
self._defaultIcon = QIcon(pixmap)
self._iPX = novelwriter.CONFIG.pxInt(24)
pA = novelwriter.CONFIG.pxInt(2)
pB = novelwriter.CONFIG.pxInt(20)
pR = float(novelwriter.CONFIG.pxInt(4))
self._iconPath = QPainterPath()
self._iconPath.addRoundedRect(QRectF(pA, pA, pB, pB), pR, pR)
self._defaultIcon = self._createIcon(100, 100, 100)
if self._type == self.STATUS:
self._prefix = "s"
@@ -63,31 +66,30 @@ class NWStatus():
return
def write(self, key, name, cols, count=None):
def write(self, key, name, col, count=None):
"""Add or update a status entry. If the key is invalid, a new
key is generated.
"""
if not self._isKey(key):
key = self._newKey()
if not isinstance(cols, tuple):
cols = (100, 100, 100)
if len(cols) != 3:
cols = (100, 100, 100)
pixmap = QPixmap(self._iconSize, self._iconSize)
pixmap.fill(QColor(*cols))
if not isinstance(col, tuple):
col = (100, 100, 100)
if len(col) != 3:
col = (100, 100, 100)
cR = minmax(col[0], 0, 255)
cG = minmax(col[1], 0, 255)
cB = minmax(col[2], 0, 255)
name = simplified(name)
if count is None:
count = self._store[key]["count"] if key in self._store else 0
count = self._store.get(key, {}).get("count", 0)
self._store[key] = {
"name": name,
"icon": QIcon(pixmap),
"cols": cols,
"icon": self._createIcon(cR, cG, cB),
"cols": (cR, cG, cB),
"count": count,
}
self._reverse[name] = key
if self._default is None:
self._default = key
@@ -103,7 +105,6 @@ class NWStatus():
if self._store[key]["count"] > 0:
return False
del self._reverse[self._store[key]["name"]]
del self._store[key]
keys = list(self._store.keys())
@@ -120,8 +121,6 @@ class NWStatus():
"""
if self._isKey(value) and value in self._store:
return value
elif value in self._reverse:
return self._reverse[value]
elif self._default is not None:
return self._default
else:
@@ -203,37 +202,30 @@ class NWStatus():
self._store[key]["count"] += 1
return
def packXML(self, xParent):
"""Pack the status entries into an XML object for saving to the
main project file.
def pack(self):
"""Pack the status entries into a dictionary.
"""
for key, data in self._store.items():
xSub = etree.SubElement(xParent, "entry", attrib={
yield (data["name"], {
"key": key,
"count": str(data["count"]),
"red": str(data["cols"][0]),
"green": str(data["cols"][1]),
"blue": str(data["cols"][2]),
})
xSub.text = data["name"]
return
return True
def unpackXML(self, xParent):
"""Unpack an XML tree and set the class values.
def unpack(self, data):
"""Unpack a data dictionary and set the class values.
"""
self._store = {}
self._reverse = {}
self._default = None
for xChild in xParent:
key = xChild.attrib.get("key", None)
name = xChild.text.strip()
count = max(checkInt(xChild.attrib.get("count", 0), 0), 0)
red = minmax(checkInt(xChild.attrib.get("red", 100), 100), 0, 255)
green = minmax(checkInt(xChild.attrib.get("green", 100), 100), 0, 255)
blue = minmax(checkInt(xChild.attrib.get("blue", 100), 100), 0, 255)
self.write(key, name, (red, green, blue), count)
for key, entry in data.items():
label = entry.get("label", "")
colour = entry.get("colour", (100, 100, 100))
count = entry.get("count", 0)
self.write(key, label, colour, count)
return True
@@ -267,6 +259,19 @@ class NWStatus():
return False
return True
def _createIcon(self, red, green, blue):
"""Generate an icon for a status label.
"""
pixmap = QPixmap(self._iPX, self._iPX)
pixmap.fill(Qt.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.Antialiasing)
painter.fillPath(self._iconPath, QColor(red, green, blue))
painter.end()
return QIcon(pixmap)
##
# Iterator Bits
##
+396
View File
@@ -0,0 +1,396 @@
"""
novelWriter Project Storage Class
===================================
The main class handling the project storage
File History:
Created: 2022-11-01 [2.0rc1] NWStorage
This file is a part of novelWriter
Copyright 20182022, Veronica Berglyd Olsen
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
import logging
import novelwriter
from time import time
from pathlib import Path
from zipfile import ZIP_DEFLATED, ZIP_STORED, ZipFile
from novelwriter.common import minmax
from novelwriter.constants import nwFiles
from novelwriter.core.document import NWDocument
from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter
from novelwriter.error import logException
logger = logging.getLogger(__name__)
class NWStorage:
MODE_INACTIVE = 0
MODE_INPLACE = 1
MODE_ARCHIVE = 2
def __init__(self, theProject):
self.mainConf = novelwriter.CONFIG
self.theProject = theProject
self._storagePath = None
self._runtimePath = None
self._lockFilePath = None
self._openMode = self.MODE_INACTIVE
return
def clear(self):
"""Reset internal variables.
"""
self._storagePath = None
self._runtimePath = None
self._openMode = self.MODE_INACTIVE
return
##
# Properties
##
@property
def storagePath(self):
return self._storagePath
@property
def runtimePath(self):
return self._runtimePath
@property
def contentPath(self):
if self._runtimePath is not None:
return self._runtimePath / "content"
return None
##
# Core Methods
##
def isOpen(self):
"""Check if the storage location is open.
"""
return self._runtimePath is not None
def openProjectInPlace(self, path, newProject=False):
"""Open a novelWriter project in-place. That is, it is opened
directly from a project folder.
"""
inPath = Path(path).resolve()
if inPath.is_file():
# The path should not point to an exisitng file,
# but it can point to a folder containing files
inPath = inPath.parent
self._storagePath = inPath
self._runtimePath = inPath
self._lockFilePath = inPath / nwFiles.PROJ_LOCK
self._openMode = self.MODE_INPLACE
if not self._prepareStorage(checkLegacy=True, newProject=newProject):
self.clear()
return False
return True
def openProjectArchive(self, path): # pragma: no cover
pass
def runPostSaveTasks(self, autoSave=False): # pragma: no cover
"""Run tasks after the project has been saved.
"""
if self._openMode == self.MODE_INPLACE:
# Nothing to do, so we just return
return True
return True
def closeSession(self):
"""Run tasks related to closing the session.
"""
# Clear lockfile
self.clear()
return
##
# Content Access Methods
##
def getXmlReader(self):
"""Return a properly configured ProjectXMLReader instance.
"""
if self._runtimePath is None:
return None
projFile = self._runtimePath / nwFiles.PROJ_FILE
xmlReader = ProjectXMLReader(projFile)
return xmlReader
def getXmlWriter(self):
"""Return a properly configured ProjectXMLWriter instance.
"""
if self._runtimePath is None:
return None
xmlWriter = ProjectXMLWriter(self._runtimePath)
return xmlWriter
def getDocument(self, tHandle):
"""Return a document wrapper object.
"""
if self._runtimePath is not None:
return NWDocument(self.theProject, tHandle)
return NWDocument(self.theProject, None)
def getMetaFile(self, fileName):
"""Return the path to a file in the project meta folder.
"""
if self._runtimePath is not None:
return self._runtimePath / "meta" / fileName
return None
def getCacheFile(self, fileName):
"""Return the path to a file in the project cache folder.
"""
if self._runtimePath is not None:
return self._runtimePath / "cache" / fileName
return None
def readLockFile(self):
"""Read the project lock file.
"""
if self._lockFilePath is None:
return ["ERROR"]
if not self._lockFilePath.exists():
return []
try:
lines = self._lockFilePath.read_text(encoding="utf-8").split(";")
except Exception:
logger.error("Failed to read project lockfile")
logException()
return ["ERROR"]
if len(lines) != 4:
return ["ERROR"]
return lines
def writeLockFile(self):
"""Write the project lock file.
"""
if self._lockFilePath is None:
return False
data = [
self.mainConf.hostName, self.mainConf.osType,
self.mainConf.kernelVer, str(int(time()))
]
try:
self._lockFilePath.write_text(";".join(data), encoding="utf-8")
except Exception:
logger.error("Failed to write project lockfile")
logException()
return False
return True
def clearLockFile(self):
"""Remove the lock file, if it exists.
"""
if self._lockFilePath is None:
return False
if self._lockFilePath.exists():
try:
self._lockFilePath.unlink()
except Exception:
logger.error("Failed to remove project lockfile")
logException()
return False
return True
def zipIt(self, target, compression=None):
"""Zip the content of the project at its runtime location into a
zip file. This process will only grab files that are supposed to
be in the project. All non-project files will be left out.
"""
basePath = self._runtimePath
if not isinstance(basePath, Path):
logger.error("No path set")
return False
baseMeta = basePath / "meta"
baseCont = basePath / "content"
files = [
(basePath / nwFiles.PROJ_FILE, nwFiles.PROJ_FILE),
(baseMeta / nwFiles.OPTS_FILE, f"meta/{nwFiles.OPTS_FILE}"),
(baseMeta / nwFiles.SESS_STATS, f"meta/{nwFiles.SESS_STATS}"),
(baseMeta / nwFiles.INDEX_FILE, f"meta/{nwFiles.INDEX_FILE}"),
(baseMeta / nwFiles.PROJ_DICT, f"meta/{nwFiles.PROJ_DICT}"),
]
for contItem in baseCont.iterdir():
name = contItem.name
if contItem.is_file() and len(name) == 17 and name.endswith(".nwd"):
files.append((contItem, f"content/{name}"))
comp = ZIP_STORED if compression is None else ZIP_DEFLATED
level = minmax(compression, 0, 9) if isinstance(compression, int) else None
try:
with ZipFile(target, mode="w", compression=comp, compresslevel=level) as zipObj:
logger.info("Creating archive: %s", target)
for srcPath, zipPath in files:
if srcPath.is_file():
zipObj.write(srcPath, zipPath)
logger.debug("Added: %s", zipPath)
except Exception:
logger.error("Failed to create acrhive")
logException()
return False
return True
##
# Internal Functions
##
def _prepareStorage(self, checkLegacy=True, newProject=False):
"""Prepare the storage area for the project.
"""
path = self._runtimePath
if not isinstance(path, Path):
logger.error("No path set")
self.clear()
return False
if path == Path.home().absolute():
logger.error("Cannot use the user's home path as the root of a project")
self.clear()
return False
if newProject:
# If it's a new project, we check that there is no existing
# project in the selected path.
if path.exists() and len(list(path.iterdir())) > 0:
logger.error("The new project folder is not empty")
self.clear()
return False
# The folder is not required to exist, as it could be a new
# project, so we make sure it does. Then we add subfolders.
try:
path.mkdir(exist_ok=True)
(path / "content").mkdir(exist_ok=True)
(path / "cache").mkdir(exist_ok=True)
(path / "meta").mkdir(exist_ok=True)
except Exception as exc:
logger.error("Failed to create required project folders", exc_info=exc)
self.clear()
return False
if not checkLegacy:
# The legacy content check is only needed for project folder
# storage, so if it is not expected to be that, there's no
# need for the remaning checks.
return True
# Check for legacy data folders
for child in path.iterdir():
if child.is_dir() and child.name.startswith("data_"):
self._legacyDataFolder(path, child)
# Check for no longer used files, and delete them
self._deleteDeprecatedFiles(path)
return True
##
# Legacy Project Data Handlers
##
def _legacyDataFolder(self, path: Path, child: Path):
"""Handle the content of a legacy data folder from a version 1.0
project.
"""
logger.info("Processing legacy data folder: %s", path)
# Move Documents to Content
first = child.name[-1]
if first not in "0123456789abcdef":
return
for item in child.iterdir():
if not item.is_file():
continue
name = item.name
if len(name) == 21 and name.endswith("_main.nwd"):
newPath = path / "content" / f"{first}{name[:12]}.nwd"
try:
item.rename(newPath)
logger.info("Moved file: %s", newPath)
except Exception as exc:
logger.warning("Failed to move: %s", item, exc_info=exc)
elif len(name) == 21 and name.endswith("_main.bak"):
try:
item.unlink()
logger.info("Deleted file: %s", item)
except Exception as exc:
logger.warning("Failed to delete: %s", item, exc_info=exc)
# Remove Data Folder
try:
child.rmdir()
logger.info("Deleted folder: %s", child)
except Exception as exc:
logger.warning("Failed to delete: %s", child, exc_info=exc)
return
def _deleteDeprecatedFiles(self, path: Path):
"""Delete files that are no longer used by novelWriter.
"""
remove = [
path / "meta" / "mainOptions.json", # Replaced in 0.5
path / "meta" / "exportOptions.json", # Replaced in 0.5
path / "meta" / "outlineOptions.json", # Replaced in 0.5
path / "meta" / "timelineOptions.json", # Replaced in 0.5
path / "meta" / "docMergeOptions.json", # Replaced in 0.5
path / "meta" / "sessionLogOptions.json", # Replaced in 0.5
path / "ToC.json", # Dropped in 1.0 RC 1
]
for item in remove:
if item.is_file():
try:
item.unlink()
logger.info("Deleted: %s", item)
except Exception as exc:
logger.warning("Failed to delete: %s", item, exc_info=exc)
return
# END Class NWStorage
+3 -3
View File
@@ -38,7 +38,7 @@ class ToHtml(Tokenizer):
M_EBOOK = 2 # Tweak output for converting to epub
def __init__(self, theProject):
Tokenizer.__init__(self, theProject)
super().__init__(theProject)
self._genMode = self.M_EXPORT
self._cssStyles = True
@@ -107,7 +107,7 @@ class ToHtml(Tokenizer):
"""Extend the auto-replace to also properly encode some unicode
characters into their respective HTML entities.
"""
Tokenizer.doPreProcessing(self)
super().doPreProcessing()
self._theText = self._theText.translate(self._trMap)
return
@@ -315,7 +315,7 @@ class ToHtml(Tokenizer):
"</body>\n"
"</html>\n"
).format(
projTitle=self.theProject.projName,
projTitle=self.theProject.data.name,
htmlStyle="\n".join(theStyle),
bodyText=bodyText,
)
+7 -13
View File
@@ -36,7 +36,6 @@ from PyQt5.QtCore import QCoreApplication, QRegularExpression
from novelwriter.enum import nwItemLayout, nwItemType
from novelwriter.common import numberToRoman, checkInt
from novelwriter.constants import nwConst, nwRegEx, nwUnicode
from novelwriter.core.document import NWDoc
logger = logging.getLogger(__name__)
@@ -304,16 +303,10 @@ class Tokenizer(ABC):
if self._theItem is None:
return False
self._theText = ""
if theText is not None:
# If the text is set, just use that
self._theText = theText
else:
# Otherwise, load it from file
theDoc = NWDoc(self.theProject, theHandle)
theText = theDoc.readDocument()
if theText:
self._theText = theText
if theText is None:
theText = self.theProject.storage.getDocument(theHandle).readDocument() or ""
self._theText = theText
docSize = len(self._theText)
if docSize > nwConst.MAX_DOCSIZE:
@@ -333,9 +326,10 @@ class Tokenizer(ABC):
"""Run trough the various replace doctionaries.
"""
# Process the user's auto-replace dictionary
if len(self.theProject.autoReplace) > 0:
autoReplace = self.theProject.data.autoReplace
if len(autoReplace) > 0:
repDict = {}
for aKey, aVal in self.theProject.autoReplace.items():
for aKey, aVal in autoReplace.items():
repDict[f"<{aKey}>"] = aVal
xRep = re.compile("|".join([re.escape(k) for k in repDict.keys()]), flags=re.DOTALL)
self._theText = xRep.sub(lambda x: repDict[x.group(0)], self._theText)
+1 -1
View File
@@ -37,7 +37,7 @@ class ToMarkdown(Tokenizer):
M_GH = 1 # GitHub Markdown
def __init__(self, theProject):
Tokenizer.__init__(self, theProject)
super().__init__(theProject)
self._genMode = self.M_STD
self._fullMD = []
+6 -6
View File
@@ -89,7 +89,7 @@ M_DEL = ~X_DEL
class ToOdt(Tokenizer):
def __init__(self, theProject, isFlat):
Tokenizer.__init__(self, theProject)
super().__init__(theProject)
self._isFlat = isFlat # Flat: .fodt, otherwise .odt
@@ -261,8 +261,8 @@ class ToOdt(Tokenizer):
# ===============
if self._headerText == "":
theTitle = self.theProject.bookTitle
theAuth = self.theProject.getAuthors()
theTitle = self.theProject.data.title
theAuth = self.theProject.getFormattedAuthors()
self._headerText = f"{theTitle} / {theAuth} /"
# Create Roots
@@ -994,7 +994,7 @@ class ToOdt(Tokenizer):
# Auto-Style Classes
# =============================================================================================== #
class ODTParagraphStyle():
class ODTParagraphStyle:
"""Wrapper class for the paragraph style setting used by the
exporter. Only the used settings are exposed here to keep the class
minimal and fast.
@@ -1208,7 +1208,7 @@ class ODTParagraphStyle():
# END Class ODTParagraphStyle
class ODTTextStyle():
class ODTTextStyle:
"""Wrapper class for the text style setting used by the exporter.
Only the used settings are exposed here to keep the class minimal
and fast.
@@ -1297,7 +1297,7 @@ X_SPAN_TEXT = 2
X_SPAN_SING = 3
class XMLParagraph():
class XMLParagraph:
"""This is a helper class to manage the text content of a single
XML element using mixed content tags.
+45 -48
View File
@@ -23,13 +23,12 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
import os
import random
import logging
from lxml import etree
from pathlib import Path
from novelwriter.enum import nwItemType, nwItemClass, nwItemLayout
from novelwriter.enum import nwItemClass, nwItemLayout
from novelwriter.error import logException
from novelwriter.common import checkHandle
from novelwriter.constants import nwFiles
@@ -38,7 +37,7 @@ from novelwriter.core.item import NWItem
logger = logging.getLogger(__name__)
class NWTree():
class NWTree:
MAX_DEPTH = 1000 # Cap of tree traversing for loops
@@ -89,20 +88,20 @@ class NWTree():
logger.warning("Duplicate handle '%s' detected, skipping", tHandle)
return False
logger.verbose("Adding item '%s' with parent '%s'", str(tHandle), str(pHandle))
logger.debug("Adding item '%s' with parent '%s'", str(tHandle), str(pHandle))
nwItem.setHandle(tHandle)
nwItem.setParent(pHandle)
if nwItem.itemType == nwItemType.ROOT:
logger.verbose("Item '%s' is a root item", str(tHandle))
if nwItem.isRootType():
logger.debug("Item '%s' is a root item", str(tHandle))
self._treeRoots[tHandle] = nwItem
if nwItem.itemClass == nwItemClass.ARCHIVE:
logger.verbose("Item '%s' is the archive folder", str(tHandle))
logger.debug("Item '%s' is the archive folder", str(tHandle))
self._archRoot = tHandle
elif nwItem.itemClass == nwItemClass.TRASH:
if self._trashRoot is None:
logger.verbose("Item '%s' is the trash folder", str(tHandle))
logger.debug("Item '%s' is the trash folder", str(tHandle))
self._trashRoot = tHandle
else:
logger.error("Only one trash folder allowed")
@@ -114,30 +113,25 @@ class NWTree():
return True
def packXML(self, xParent):
def pack(self):
"""Pack the content of the tree into the provided XML object. In
the order defined by the _treeOrder list.
"""
xContent = etree.SubElement(xParent, "content", attrib={
"count": str(len(self._treeOrder))}
)
tree = []
for tHandle in self._treeOrder:
tItem = self.__getitem__(tHandle)
tItem.packXML(xContent)
return
if tItem:
tree.append(tItem.pack())
return tree
def unpackXML(self, xContent):
"""Iterate through all items of a content XML object and add
them to the project tree.
def unpack(self, data):
"""Iterate through all items of a list and add them to the
project tree.
"""
if xContent.tag != "content":
logger.error("XML entry is not a NWTree")
return False
self.clear()
for xItem in xContent:
for item in data:
nwItem = NWItem(self.theProject)
if nwItem.unpackXML(xItem):
if nwItem.unpack(item):
self.append(nwItem.itemHandle, nwItem.itemParent, nwItem)
nwItem.saveInitialCount()
@@ -147,16 +141,22 @@ class NWTree():
"""Write the convenience table of contents file in the root of
the project directory.
"""
runtimePath = self.theProject.storage.runtimePath
contentPath = self.theProject.storage.contentPath
if not (isinstance(contentPath, Path) and isinstance(runtimePath, Path)):
return False
tocList = []
tocLen = 0
for tHandle in self._treeOrder:
tItem = self.__getitem__(tHandle)
if tItem is None:
continue
tFile = tHandle+".nwd"
if os.path.isfile(os.path.join(self.theProject.projContent, tFile)):
if (contentPath / tFile).is_file():
tocLine = "{0:<25s} {1:<9s} {2:<8s} {3:s}".format(
os.path.join("content", tFile),
str(Path("content") / tFile),
tItem.itemClass.name,
tItem.itemLayout.name,
tItem.itemName,
@@ -166,7 +166,7 @@ class NWTree():
try:
# Dump the text
tocText = os.path.join(self.theProject.projPath, nwFiles.TOC_TXT)
tocText = runtimePath / nwFiles.TOC_TXT
with open(tocText, mode="w", encoding="utf-8") as outFile:
outFile.write("\n")
outFile.write("Table of Contents\n")
@@ -274,11 +274,13 @@ class NWTree():
return rootClasses
def iterRoots(self, itemClass):
"""Iterate over all items of a given class.
"""Iterate over all root items of a given class in order.
"""
for tHandle, nwItem in self._treeRoots.items():
if nwItem.itemClass == itemClass:
yield tHandle, nwItem
for tHandle in self._treeOrder:
nwItem = self.__getitem__(tHandle)
if nwItem is not None and nwItem.isRootType():
if itemClass is None or nwItem.itemClass == itemClass:
yield tHandle, nwItem
return
def isRoot(self, tHandle):
@@ -329,25 +331,20 @@ class NWTree():
def setOrder(self, newOrder):
"""Reorders the tree based on a list of items.
"""
tmpOrder = []
# Add all known elements to a new temp list
for tHandle in newOrder:
if tHandle in self._projTree:
tmpOrder.append(tHandle)
else:
logger.error("Handle '%s' in new tree order is not in project tree", tHandle)
# Do a reverse lookup to check for items that will be lost
# This is mainly for debugging purposes
for tHandle in self._treeOrder:
if tHandle not in tmpOrder:
logger.warning("Handle '%s' in old tree order is not in new tree order", tHandle)
tmpOrder = [tHandle for tHandle in newOrder if tHandle in self._projTree]
if not (len(tmpOrder) == len(newOrder) == len(self._treeOrder)):
# Something is wrong, so let's debug it
for tHandle in newOrder:
if tHandle not in self._projTree:
logger.error("Handle '%s' in new tree order is not in old order", tHandle)
for tHandle in self._treeOrder:
if tHandle not in tmpOrder:
logger.warning("Handle '%s' in old tree order is not in new order", tHandle)
# Save the temp list
self._treeOrder = tmpOrder
self._setTreeChanged(True)
logger.verbose("Project tree order updated")
logger.debug("Project tree order updated")
return
@@ -357,7 +354,7 @@ class NWTree():
tItem = self.__getitem__(tHandle)
if tItem is None:
return False
if tItem.itemType != nwItemType.FILE:
if not tItem.isFileType():
logger.error("Item '%s' is not a file", tHandle)
return False
if not isinstance(itemLayout, nwItemLayout):
@@ -457,7 +454,7 @@ class NWTree():
"""Generate a unique item handle. In the event that the key
already exists, generate a new one.
"""
logger.verbose("Generating new handle")
logger.debug("Generating new handle")
handle = f"{random.getrandbits(52):013x}"
if handle in self._projTree:
logger.warning("Duplicate handle encountered! Retrying ...")
@@ -202,7 +202,7 @@ class QConfigLayout(QGridLayout):
class QHelpLabel(QLabel):
def __init__(self, theText, textCol, fontSize=0.9):
QLabel.__init__(self, theText)
super().__init__(theText)
if isinstance(textCol, QColor):
qCol = textCol
@@ -267,8 +267,8 @@ class QSwitch(QAbstractButton):
return self._offset
@offset.setter
def offset(self, theOffset):
self._offset = theOffset
def offset(self, offset):
self._offset = offset
self.update()
return
@@ -276,11 +276,11 @@ class QSwitch(QAbstractButton):
# Getters and Setters
##
def setChecked(self, isChecked):
def setChecked(self, checked):
"""Overload setChecked to also alter the offset.
"""
super().setChecked(isChecked)
if isChecked:
super().setChecked(checked)
if checked:
self.offset = self._xW - self._xR
else:
self.offset = self._xR
@@ -290,10 +290,10 @@ class QSwitch(QAbstractButton):
# Events
##
def resizeEvent(self, theEvent):
def resizeEvent(self, event):
"""Overload resize to ensure correct offset.
"""
super().resizeEvent(theEvent)
super().resizeEvent(event)
if self.isChecked():
self.offset = self._xW - self._xR
else:
@@ -377,7 +377,7 @@ class QSwitch(QAbstractButton):
class PagedDialog(QDialog):
def __init__(self, parent=None):
QDialog.__init__(self, parent=parent)
super().__init__(parent=parent)
self._tabBar = VerticalTabBar(self)
self._tabBar.setExpanding(False)
@@ -410,31 +410,37 @@ class PagedDialog(QDialog):
return
def addTab(self, widget, label):
"""Forwards the adding of tabs to the QTabWidget.
"""Forward the adding of tabs to the QTabWidget.
"""
self._tabBox.addTab(widget, label)
return
def addControls(self, buttonBar):
"""Adds a button bar to the dialog.
"""Add a button bar to the dialog.
"""
self._buttonBox.addWidget(buttonBar)
return
def setCurrentWidget(self, widget):
"""Forward the changing of tab to the QTabWidget.
"""
self._tabBox.setCurrentWidget(widget)
return
# END Class PagedDialog
class VerticalTabBar(QTabBar):
def __init__(self, parent=None):
QTabBar.__init__(self, parent=parent)
super().__init__(parent=parent)
self._mW = novelwriter.CONFIG.pxInt(150)
return
def tabSizeHint(self, index):
"""Returns a transposed size hint for the rotated bar.
"""Return a transposed size hint for the rotated bar.
"""
tSize = QTabBar.tabSizeHint(self, index)
tSize = super().tabSizeHint(index)
tSize.transpose()
tSize.setWidth(min(tSize.width(), self._mW))
return tSize
+3 -4
View File
@@ -23,7 +23,6 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
import os
import logging
import novelwriter
@@ -44,7 +43,7 @@ logger = logging.getLogger(__name__)
class GuiAbout(QDialog):
def __init__(self, mainGui):
QDialog.__init__(self, mainGui)
super().__init__(parent=mainGui)
logger.debug("Initialising GuiAbout ...")
self.setObjectName("GuiAbout")
@@ -234,7 +233,7 @@ class GuiAbout(QDialog):
def _fillNotesPage(self):
"""Load the content for the Release Notes page.
"""
docPath = os.path.join(self.mainConf.assetPath, "text", "release_notes.htm")
docPath = self.mainConf.assetPath("text") / "release_notes.htm"
docText = readTextFile(docPath)
if docText:
self.pageNotes.setHtml(docText)
@@ -245,7 +244,7 @@ class GuiAbout(QDialog):
def _fillLicensePage(self):
"""Load the content for the Licence page.
"""
docPath = os.path.join(self.mainConf.assetPath, "text", "gplv3_en.htm")
docPath = self.mainConf.assetPath("text") / "gplv3_en.htm"
docText = readTextFile(docPath)
if docText:
self.pageLicense.setHtml(docText)
+88 -110
View File
@@ -1,10 +1,11 @@
"""
novelWriter GUI Doc Merge Tool
================================
GUI class for merging multiple documents to one document
novelWriter GUI Doc Merge Dialog
==================================
Custom dialog class for merging documents.
File History:
Created: 2020-01-23 [0.4.3]
Created: 2020-01-23 [0.4.3]
Rewritten: 2022-10-06 [2.0b1]
This file is a part of novelWriter
Copyright 20182022, Veronica Berglyd Olsen
@@ -26,169 +27,146 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
import logging
import novelwriter
from PyQt5.QtCore import Qt
from PyQt5.QtCore import Qt, QSize
from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QLabel, QListWidget, QAbstractItemView,
QListWidgetItem, QDialogButtonBox
QAbstractItemView, QDialog, QDialogButtonBox, QGridLayout, QLabel,
QListWidget, QListWidgetItem, QVBoxLayout,
)
from novelwriter.core import NWDoc
from novelwriter.enum import nwAlert, nwItemType
from novelwriter.gui.custom import QHelpLabel
from novelwriter.custom import QHelpLabel, QSwitch
logger = logging.getLogger(__name__)
class GuiDocMerge(QDialog):
def __init__(self, mainGui):
QDialog.__init__(self, mainGui)
def __init__(self, mainGui, sHandle, itemList):
super().__init__(parent=mainGui)
logger.debug("Initialising GuiDocMerge ...")
self.setObjectName("GuiDocMerge")
self.mainConf = novelwriter.CONFIG
self.mainGui = mainGui
self.mainTheme = mainGui.mainTheme
self.theProject = mainGui.theProject
self.sourceItem = None
self.outerBox = QVBoxLayout()
self._data = {}
self.setWindowTitle(self.tr("Merge Documents"))
self.headLabel = QLabel("<b>{0}</b>".format(self.tr("Documents to Merge")))
self.helpLabel = QHelpLabel(
self.tr("Drag and drop items to change the order."), self.mainGui.mainTheme.helpText
)
self.helpLabel = QHelpLabel(self.tr(
"Drag and drop items to change the order, or uncheck to exclude."
), self.mainTheme.helpText)
iPx = self.mainTheme.baseIconSize
hSp = self.mainConf.pxInt(12)
vSp = self.mainConf.pxInt(8)
bSp = self.mainConf.pxInt(12)
self.listBox = QListWidget()
self.listBox.setDragDropMode(QAbstractItemView.InternalMove)
self.listBox.setIconSize(QSize(iPx, iPx))
self.listBox.setMinimumWidth(self.mainConf.pxInt(400))
self.listBox.setMinimumHeight(self.mainConf.pxInt(180))
self.listBox.setSelectionBehavior(QAbstractItemView.SelectRows)
self.listBox.setSelectionMode(QAbstractItemView.SingleSelection)
self.listBox.setDragDropMode(QAbstractItemView.InternalMove)
# Merge Options
self.trashLabel = QLabel(self.tr("Move merged items to Trash"))
self.trashSwitch = QSwitch(width=2*iPx, height=iPx)
self.optBox = QGridLayout()
self.optBox.addWidget(self.trashLabel, 0, 0)
self.optBox.addWidget(self.trashSwitch, 0, 1)
self.optBox.setHorizontalSpacing(hSp)
self.optBox.setColumnStretch(2, 1)
# Buttons
self.buttonBox = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
self.buttonBox.accepted.connect(self._doMerge)
self.buttonBox.rejected.connect(self._doClose)
self.buttonBox.accepted.connect(self.accept)
self.buttonBox.rejected.connect(self.reject)
self.resetButton = self.buttonBox.addButton(QDialogButtonBox.Reset)
self.resetButton.clicked.connect(self._resetList)
# Assemble
self.outerBox = QVBoxLayout()
self.outerBox.setSpacing(0)
self.outerBox.addWidget(self.headLabel)
self.outerBox.addWidget(self.helpLabel)
self.outerBox.addSpacing(self.mainConf.pxInt(8))
self.outerBox.addSpacing(vSp)
self.outerBox.addWidget(self.listBox)
self.outerBox.addSpacing(self.mainConf.pxInt(12))
self.outerBox.addSpacing(vSp)
self.outerBox.addLayout(self.optBox)
self.outerBox.addSpacing(bSp)
self.outerBox.addWidget(self.buttonBox)
self.setLayout(self.outerBox)
self.rejected.connect(self._doClose)
self._populateList()
# Load Content
self._loadContent(sHandle, itemList)
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.
def getData(self):
"""Return the user's choices.
"""
logger.verbose("GuiDocMerge merge button clicked")
finalOrder = []
finalItems = []
for i in range(self.listBox.count()):
finalOrder.append(self.listBox.item(i).data(Qt.UserRole))
item = self.listBox.item(i)
if item is not None and item.checkState() == Qt.Checked:
finalItems.append(item.data(Qt.UserRole))
if len(finalOrder) == 0:
self.mainGui.makeAlert(self.tr(
"No source documents found. Nothing to do."
), nwAlert.ERROR)
return False
self._data["moveToTrash"] = self.trashSwitch.isChecked()
self._data["finalItems"] = finalItems
theText = ""
for tHandle in finalOrder:
inDoc = NWDoc(self.theProject, tHandle)
docText = inDoc.readDocument()
docErr = inDoc.getError()
if docText is None and docErr:
self.mainGui.makeAlert([
self.tr("Failed to open document file."), docErr
], nwAlert.ERROR)
if docText:
theText += docText.rstrip("\n")+"\n\n"
return self._data
if self.sourceItem is None:
self.mainGui.makeAlert(self.tr(
"No source folder selected. Nothing to do."
), nwAlert.ERROR)
return False
##
# Slots
##
srcItem = self.theProject.tree[self.sourceItem]
if srcItem is None:
self.mainGui.makeAlert(self.tr("Internal error."), nwAlert.ERROR)
return False
nHandle = self.theProject.newFile(srcItem.itemName, srcItem.itemParent)
newItem = self.theProject.tree[nHandle]
newItem.setStatus(srcItem.itemStatus)
newItem.setImport(srcItem.itemImport)
outDoc = NWDoc(self.theProject, nHandle)
if not outDoc.writeDocument(theText):
self.mainGui.makeAlert([
self.tr("Could not save document."), outDoc.getError()
], nwAlert.ERROR)
return False
self.mainGui.projView.revealNewTreeItem(nHandle)
self.mainGui.openDocument(nHandle, doScroll=True)
self._doClose()
return True
def _doClose(self):
"""Close the dialog window without doing anything.
def _resetList(self):
"""Reset the content of the list box to its original state.
"""
self.close()
logger.debug("Resetting list box content")
sHandle = self._data.get("sHandle", None)
itemList = self._data.get("origItems", [])
self._loadContent(sHandle, itemList)
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.
def _loadContent(self, sHandle, itemList):
"""Load content from a given list of items.
"""
tHandle = self.mainGui.projView.getSelectedHandle()
self.sourceItem = tHandle
if tHandle is None:
return False
self._data = {}
self._data["sHandle"] = sHandle
self._data["origItems"] = itemList
nwItem = self.theProject.tree[tHandle]
if nwItem is None:
return False
if nwItem.itemType is not nwItemType.FOLDER:
self.mainGui.makeAlert(self.tr(
"Element selected in the project tree must be a folder."
), nwAlert.ERROR)
return False
for sHandle in self.mainGui.projView.getTreeFromHandle(tHandle):
newItem = QListWidgetItem()
nwItem = self.theProject.tree[sHandle]
if nwItem.itemType is not nwItemType.FILE:
self.listBox.clear()
for tHandle in itemList:
nwItem = self.theProject.tree[tHandle]
if nwItem is None or not nwItem.isFileType():
continue
itemIcon = self.mainTheme.getItemIcon(
nwItem.itemType, nwItem.itemClass, nwItem.itemLayout, nwItem.mainHeading
)
newItem = QListWidgetItem()
newItem.setIcon(itemIcon)
newItem.setText(nwItem.itemName)
newItem.setData(Qt.UserRole, sHandle)
newItem.setData(Qt.UserRole, tHandle)
newItem.setCheckState(Qt.Checked)
self.listBox.addItem(newItem)
return True
return
# END Class GuiDocMerge
+131 -160
View File
@@ -1,10 +1,11 @@
"""
novelWriter GUI Doc Split Tool
================================
GUI class for splitting a single document into multiple documents
novelWriter GUI Doc Split Dialog
==================================
Custom dialog class for splitting documents.
File History:
Created: 2020-02-01 [0.4.3]
Created: 2020-02-01 [0.4.3]
Rewritten: 2022-10-12 [2.0b1]
This file is a part of novelWriter
Copyright 20182022, Veronica Berglyd Olsen
@@ -29,32 +30,34 @@ import novelwriter
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QComboBox, QListWidget, QAbstractItemView,
QListWidgetItem, QDialogButtonBox, QLabel
QListWidgetItem, QDialogButtonBox, QLabel, QGridLayout
)
from novelwriter.core import NWDoc
from novelwriter.enum import nwAlert, nwItemType
from novelwriter.gui.custom import QHelpLabel
from novelwriter.custom import QHelpLabel, QSwitch
logger = logging.getLogger(__name__)
class GuiDocSplit(QDialog):
def __init__(self, mainGui):
QDialog.__init__(self, mainGui)
LINE_ROLE = Qt.UserRole
LEVEL_ROLE = Qt.UserRole + 1
LABEL_ROLE = Qt.UserRole + 2
def __init__(self, mainGui, sHandle):
super().__init__(parent=mainGui)
logger.debug("Initialising GuiDocSplit ...")
self.setObjectName("GuiDocSplit")
self.mainConf = novelwriter.CONFIG
self.mainGui = mainGui
self.mainTheme = mainGui.mainTheme
self.theProject = mainGui.theProject
self.sourceItem = None
self.sourceText = []
self._data = {}
self._text = []
self.outerBox = QVBoxLayout()
self.setWindowTitle(self.tr("Split Document"))
self.headLabel = QLabel("<b>{0}</b>".format(self.tr("Document Headers")))
@@ -63,6 +66,18 @@ class GuiDocSplit(QDialog):
self.mainGui.mainTheme.helpText
)
# Values
iPx = self.mainTheme.baseIconSize
hSp = self.mainConf.pxInt(12)
vSp = self.mainConf.pxInt(8)
bSp = self.mainConf.pxInt(12)
pOptions = self.theProject.options
spLevel = pOptions.getInt("GuiDocSplit", "spLevel", 3)
intoFolder = pOptions.getBool("GuiDocSplit", "intoFolder", True)
docHierarchy = pOptions.getBool("GuiDocSplit", "docHierarchy", True)
# Header Selection
self.listBox = QListWidget()
self.listBox.setDragDropMode(QAbstractItemView.NoDragDrop)
self.listBox.setMinimumWidth(self.mainConf.pxInt(400))
@@ -73,206 +88,162 @@ class GuiDocSplit(QDialog):
self.splitLevel.addItem(self.tr("Split up to Header Level 2 (Chapter)"), 2)
self.splitLevel.addItem(self.tr("Split up to Header Level 3 (Scene)"), 3)
self.splitLevel.addItem(self.tr("Split up to Header Level 4 (Section)"), 4)
spIndex = self.splitLevel.findData(
self.theProject.options.getInt("GuiDocSplit", "spLevel", 3)
)
spIndex = self.splitLevel.findData(spLevel)
if spIndex != -1:
self.splitLevel.setCurrentIndex(spIndex)
self.splitLevel.currentIndexChanged.connect(self._populateList)
self.splitLevel.currentIndexChanged.connect(self._reloadList)
# Split Options
self.folderLabel = QLabel(self.tr("Split into a new folder"))
self.folderSwitch = QSwitch(width=2*iPx, height=iPx)
self.folderSwitch.setChecked(intoFolder)
self.hierarchyLabel = QLabel(self.tr("Create document hierarchy"))
self.hierarchySwitch = QSwitch(width=2*iPx, height=iPx)
self.hierarchySwitch.setChecked(docHierarchy)
self.trashLabel = QLabel(self.tr("Move split document to Trash"))
self.trashSwitch = QSwitch(width=2*iPx, height=iPx)
self.optBox = QGridLayout()
self.optBox.addWidget(self.folderLabel, 0, 0)
self.optBox.addWidget(self.folderSwitch, 0, 1)
self.optBox.addWidget(self.hierarchyLabel, 1, 0)
self.optBox.addWidget(self.hierarchySwitch, 1, 1)
self.optBox.addWidget(self.trashLabel, 2, 0)
self.optBox.addWidget(self.trashSwitch, 2, 1)
self.optBox.setVerticalSpacing(vSp)
self.optBox.setHorizontalSpacing(hSp)
self.optBox.setColumnStretch(3, 1)
# Buttons
self.buttonBox = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
self.buttonBox.accepted.connect(self._doSplit)
self.buttonBox.rejected.connect(self._doClose)
self.buttonBox.accepted.connect(self.accept)
self.buttonBox.rejected.connect(self.reject)
# Assemble
self.outerBox = QVBoxLayout()
self.outerBox.setSpacing(0)
self.outerBox.addWidget(self.headLabel)
self.outerBox.addWidget(self.helpLabel)
self.outerBox.addSpacing(self.mainConf.pxInt(8))
self.outerBox.addSpacing(vSp)
self.outerBox.addWidget(self.listBox)
self.outerBox.addWidget(self.splitLevel)
self.outerBox.addSpacing(self.mainConf.pxInt(12))
self.outerBox.addSpacing(vSp)
self.outerBox.addLayout(self.optBox)
self.outerBox.addSpacing(bSp)
self.outerBox.addWidget(self.buttonBox)
self.setLayout(self.outerBox)
self.rejected.connect(self._doClose)
self._populateList()
# Load Content
self._loadContent(sHandle)
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 split process, and
must be deleted manually.
def getData(self):
"""Return the user's choices. Also save the users options for
the next time the dialog is used.
"""
logger.verbose("GuiDocSplit split button clicked")
if self.sourceItem is None:
self.mainGui.makeAlert(self.tr(
"No source document selected. Nothing to do."
), nwAlert.ERROR)
return False
srcItem = self.theProject.tree[self.sourceItem]
if srcItem is None:
self.mainGui.makeAlert(self.tr(
"Could not parse source document."
), nwAlert.ERROR)
return False
inDoc = NWDoc(self.theProject, self.sourceItem)
theText = inDoc.readDocument()
docErr = inDoc.getError()
if theText is None and docErr:
self.mainGui.makeAlert([
self.tr("Failed to open document file."), docErr
], nwAlert.ERROR)
if theText is None:
theText = ""
nLines = len(self.sourceText)
logger.debug("Splitting document %s with %d lines", self.sourceItem, nLines)
finalOrder = []
headerList = []
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
item = self.listBox.item(i)
if item is not None:
headerList.append((
item.data(self.LINE_ROLE),
item.data(self.LEVEL_ROLE),
item.data(self.LABEL_ROLE),
))
nFiles = len(finalOrder)
if nFiles == 0:
self.mainGui.makeAlert(self.tr(
"No headers found. Nothing to do."
), nwAlert.ERROR)
return False
spLevel = self.splitLevel.currentData()
intoFolder = self.folderSwitch.isChecked()
docHierarchy = self.hierarchySwitch.isChecked()
moveToTrash = self.trashSwitch.isChecked()
msgYes = self.mainGui.askQuestion(
self.tr("Split Document"),
"{0}<br><br>{1}".format(
self.tr(
"The document will be split into {0} file(s) in a new folder. "
"The original document will remain intact."
).format(nFiles),
self.tr(
"Continue with the splitting process?"
)
)
)
if not msgYes:
return False
self._data["spLevel"] = spLevel
self._data["headerList"] = headerList
self._data["intoFolder"] = intoFolder
self._data["docHierarchy"] = docHierarchy
self._data["moveToTrash"] = moveToTrash
# Create the folder
fHandle = self.theProject.newFolder(srcItem.itemName, srcItem.itemParent)
self.mainGui.projView.revealNewTreeItem(fHandle)
logger.verbose("Creating folder '%s'", fHandle)
pOptions = self.theProject.options
pOptions.setValue("GuiDocSplit", "spLevel", spLevel)
pOptions.setValue("GuiDocSplit", "intoFolder", intoFolder)
pOptions.setValue("GuiDocSplit", "docHierarchy", docHierarchy)
# Loop through, and create the files
for wTitle, iStart, iEnd in finalOrder:
return self._data, self._text
wTitle = wTitle.lstrip("#").strip()
nHandle = self.theProject.newFile(wTitle, fHandle)
newItem = self.theProject.tree[nHandle]
newItem.setStatus(srcItem.itemStatus)
newItem.setImport(srcItem.itemImport)
logger.verbose(
"Creating new document '%s' with text from line %d to %d",
nHandle, iStart+1, iEnd
)
##
# Slots
##
theText = "\n".join(self.sourceText[iStart:iEnd])
theText = theText.rstrip("\n") + "\n\n"
outDoc = NWDoc(self.theProject, nHandle)
if not outDoc.writeDocument(theText):
self.mainGui.makeAlert([
self.tr("Could not save document."), outDoc.getError()
], nwAlert.ERROR)
return False
self.mainGui.projView.revealNewTreeItem(nHandle)
self._doClose()
return True
def _doClose(self):
"""Close the dialog window without doing anything.
def _reloadList(self):
"""Reload the content of the list box.
"""
self.theProject.options.saveSettings()
self.close()
sHandle = self._data.get("sHandle", None)
self._loadContent(sHandle)
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.
def _loadContent(self, sHandle):
"""Load content from a given source item.
"""
self._data = {}
self._data["sHandle"] = sHandle
self.listBox.clear()
if self.sourceItem is None:
self.sourceItem = self.mainGui.projView.getSelectedHandle()
if self.sourceItem is None:
return False
nwItem = self.theProject.tree[self.sourceItem]
if nwItem is None:
return False
if nwItem.itemType is not nwItemType.FILE:
self.mainGui.makeAlert(self.tr(
"Element selected in the project tree must be a file."
), nwAlert.ERROR)
return False
inDoc = NWDoc(self.theProject, self.sourceItem)
theText = inDoc.readDocument()
if theText is None:
theText = ""
return False
nwItem = self.theProject.tree[sHandle]
if nwItem is None or not nwItem.isFileType():
return
spLevel = self.splitLevel.currentData()
self.theProject.options.setValue("GuiDocSplit", "spLevel", spLevel)
logger.debug(
"Scanning document '%s' for headings level <= %d",
self.sourceItem, spLevel
)
if not self._text:
inDoc = self.theProject.storage.getDocument(sHandle)
self._text = (inDoc.readDocument() or "").splitlines()
self.sourceText = theText.splitlines()
for lineNo, aLine in enumerate(self.sourceText):
for lineNo, aLine in enumerate(self._text):
onLine = -1
hLevel = 0
hLabel = aLine.strip()
if aLine.startswith("# ") and spLevel >= 1:
onLine = lineNo
hLevel = 1
hLabel = aLine[2:].strip()
elif aLine.startswith("## ") and spLevel >= 2:
onLine = lineNo
hLevel = 2
hLabel = aLine[3:].strip()
elif aLine.startswith("### ") and spLevel >= 3:
onLine = lineNo
hLevel = 3
hLabel = aLine[4:].strip()
elif aLine.startswith("#### ") and spLevel >= 4:
onLine = lineNo
hLevel = 4
hLabel = aLine[5:].strip()
elif aLine.startswith("#! ") and spLevel >= 1:
onLine = lineNo
hLevel = 1
hLabel = aLine[3:].strip()
elif aLine.startswith("##! ") and spLevel >= 2:
onLine = lineNo
hLevel = 2
hLabel = aLine[4:].strip()
if onLine >= 0:
if onLine >= 0 and hLevel > 0:
newItem = QListWidgetItem()
newItem.setText(aLine.strip())
newItem.setData(Qt.UserRole, onLine)
newItem.setData(self.LINE_ROLE, onLine)
newItem.setData(self.LEVEL_ROLE, hLevel)
newItem.setData(self.LABEL_ROLE, hLabel)
self.listBox.addItem(newItem)
return True
return
# END Class GuiDocSplit
+1 -1
View File
@@ -36,7 +36,7 @@ logger = logging.getLogger(__name__)
class GuiEditLabel(QDialog):
def __init__(self, parent, text=""):
QDialog.__init__(self, parent=parent)
super().__init__(parent=parent)
self.setObjectName("GuiEditLabel")
self.setWindowTitle(self.tr("Item Label"))
+100 -125
View File
@@ -23,7 +23,6 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
import os
import logging
import novelwriter
@@ -34,8 +33,7 @@ from PyQt5.QtWidgets import (
QLineEdit, QFileDialog, QFontDialog, QDoubleSpinBox
)
from novelwriter.enum import nwAlert
from novelwriter.gui.custom import QSwitch, QConfigLayout, PagedDialog
from novelwriter.custom import QSwitch, QConfigLayout, PagedDialog
from novelwriter.dialogs.quotes import GuiQuoteSelect
logger = logging.getLogger(__name__)
@@ -44,7 +42,7 @@ logger = logging.getLogger(__name__)
class GuiPreferences(PagedDialog):
def __init__(self, mainGui):
PagedDialog.__init__(self, mainGui)
super().__init__(parent=mainGui)
logger.debug("Initialising GuiPreferences ...")
self.setObjectName("GuiPreferences")
@@ -55,13 +53,13 @@ class GuiPreferences(PagedDialog):
self.setWindowTitle(self.tr("Preferences"))
self.tabGeneral = GuiPreferencesGeneral(self.mainGui)
self.tabProjects = GuiPreferencesProjects(self.mainGui)
self.tabDocs = GuiPreferencesDocuments(self.mainGui)
self.tabEditor = GuiPreferencesEditor(self.mainGui)
self.tabSyntax = GuiPreferencesSyntax(self.mainGui)
self.tabAuto = GuiPreferencesAutomation(self.mainGui)
self.tabQuote = GuiPreferencesQuotes(self.mainGui)
self.tabGeneral = GuiPreferencesGeneral(self)
self.tabProjects = GuiPreferencesProjects(self)
self.tabDocs = GuiPreferencesDocuments(self)
self.tabEditor = GuiPreferencesEditor(self)
self.tabSyntax = GuiPreferencesSyntax(self)
self.tabAuto = GuiPreferencesAutomation(self)
self.tabQuote = GuiPreferencesQuotes(self)
self.addTab(self.tabGeneral, self.tr("General"))
self.addTab(self.tabProjects, self.tr("Projects"))
@@ -76,12 +74,38 @@ class GuiPreferences(PagedDialog):
self.buttonBox.rejected.connect(self._doClose)
self.addControls(self.buttonBox)
self.resize(*self.mainConf.getPreferencesSize())
self.resize(*self.mainConf.preferencesWinSize)
# Settings
self._updateTheme = False
self._updateSyntax = False
self._needsRestart = False
self._refreshTree = False
logger.debug("GuiPreferences initialisation complete")
return
##
# Properties
##
@property
def updateTheme(self):
return self._updateTheme
@property
def updateSyntax(self):
return self._updateSyntax
@property
def needsRestart(self):
return self._needsRestart
@property
def refreshTree(self):
return self._refreshTree
##
# Slots
##
@@ -92,8 +116,7 @@ class GuiPreferences(PagedDialog):
"""
logger.debug("Saving new preferences")
needsRestart, refreshTree = self.tabGeneral.saveValues()
self.tabGeneral.saveValues()
self.tabProjects.saveValues()
self.tabDocs.saveValues()
self.tabEditor.saveValues()
@@ -101,15 +124,8 @@ class GuiPreferences(PagedDialog):
self.tabAuto.saveValues()
self.tabQuote.saveValues()
if needsRestart:
self.mainGui.makeAlert(self.tr(
"Some changes will not be applied until novelWriter has been restarted."
), nwAlert.INFO)
if refreshTree:
self.mainGui.projView.populateTree()
self._saveWindowSize()
self.mainConf.saveConfig()
self.accept()
return
@@ -128,9 +144,7 @@ class GuiPreferences(PagedDialog):
def _saveWindowSize(self):
"""Save the dialog window size.
"""
winWidth = self.mainConf.rpxInt(self.width())
winHeight = self.mainConf.rpxInt(self.height())
self.mainConf.setPreferencesSize(winWidth, winHeight)
self.mainConf.setPreferencesWinSize(self.width(), self.height())
return
# END Class GuiPreferences
@@ -138,12 +152,13 @@ class GuiPreferences(PagedDialog):
class GuiPreferencesGeneral(QWidget):
def __init__(self, mainGui):
QWidget.__init__(self, mainGui)
def __init__(self, prefsGui):
super().__init__(parent=prefsGui)
self.mainConf = novelwriter.CONFIG
self.mainGui = mainGui
self.mainTheme = mainGui.mainTheme
self.prefsGui = prefsGui
self.mainGui = prefsGui.mainGui
self.mainTheme = prefsGui.mainGui.mainTheme
# The Form
self.mainForm = QConfigLayout()
@@ -156,19 +171,19 @@ class GuiPreferencesGeneral(QWidget):
minWidth = self.mainConf.pxInt(200)
# Select Locale
self.guiLang = QComboBox()
self.guiLang.setMinimumWidth(minWidth)
self.guiLocale = QComboBox()
self.guiLocale.setMinimumWidth(minWidth)
theLangs = self.mainConf.listLanguages(self.mainConf.LANG_NW)
for lang, langName in theLangs:
self.guiLang.addItem(langName, lang)
langIdx = self.guiLang.findData(self.mainConf.guiLang)
self.guiLocale.addItem(langName, lang)
langIdx = self.guiLocale.findData(self.mainConf.guiLocale)
if langIdx != -1:
self.guiLang.setCurrentIndex(langIdx)
self.guiLocale.setCurrentIndex(langIdx)
self.mainForm.addRow(
self.tr("Main GUI language"),
self.guiLang,
self.tr("Requires restart.")
self.guiLocale,
self.tr("Requires restart to take effect.")
)
# Select Theme
@@ -184,23 +199,7 @@ class GuiPreferencesGeneral(QWidget):
self.mainForm.addRow(
self.tr("Main GUI theme"),
self.guiTheme,
self.tr("Requires restart.")
)
# Select Icon Theme
self.guiIcons = QComboBox()
self.guiIcons.setMinimumWidth(minWidth)
self.iconCache = self.mainTheme.iconCache.listThemes()
for iconDir, iconName in self.iconCache:
self.guiIcons.addItem(iconName, iconDir)
iconIdx = self.guiIcons.findData(self.mainConf.guiIcons)
if iconIdx != -1:
self.guiIcons.setCurrentIndex(iconIdx)
self.mainForm.addRow(
self.tr("Main icon theme"),
self.guiIcons,
self.tr("Requires restart.")
self.tr("General colour theme and icons.")
)
# Editor Theme
@@ -230,7 +229,7 @@ class GuiPreferencesGeneral(QWidget):
self.mainForm.addRow(
self.tr("Font family"),
self.guiFont,
self.tr("Requires restart."),
self.tr("Requires restart to take effect."),
theButton=self.fontButton
)
@@ -243,7 +242,7 @@ class GuiPreferencesGeneral(QWidget):
self.mainForm.addRow(
self.tr("Font size"),
self.guiFontSize,
self.tr("Requires restart."),
self.tr("Requires restart to take effect."),
theUnit=self.tr("pt")
)
@@ -288,29 +287,23 @@ class GuiPreferencesGeneral(QWidget):
def saveValues(self):
"""Save the values set for this tab.
"""
guiLang = self.guiLang.currentData()
guiLocale = self.guiLocale.currentData()
guiTheme = self.guiTheme.currentData()
guiIcons = self.guiIcons.currentData()
guiSyntax = self.guiSyntax.currentData()
guiFont = self.guiFont.text()
guiFontSize = self.guiFontSize.value()
emphLabels = self.emphLabels.isChecked()
# Check if restart is needed
needsRestart = False
needsRestart |= self.mainConf.guiLang != guiLang
needsRestart |= self.mainConf.guiTheme != guiTheme
needsRestart |= self.mainConf.guiIcons != guiIcons
needsRestart |= self.mainConf.guiFont != guiFont
needsRestart |= self.mainConf.guiFontSize != guiFontSize
# Update Flags
self.prefsGui._updateTheme |= self.mainConf.guiTheme != guiTheme
self.prefsGui._updateSyntax |= self.mainConf.guiSyntax != guiSyntax
self.prefsGui._needsRestart |= self.mainConf.guiLocale != guiLocale
self.prefsGui._needsRestart |= self.mainConf.guiFont != guiFont
self.prefsGui._needsRestart |= self.mainConf.guiFontSize != guiFontSize
self.prefsGui._refreshTree |= self.mainConf.emphLabels != emphLabels
# Check if refreshing project tree is needed
refreshTree = False
refreshTree |= self.mainConf.emphLabels != emphLabels
self.mainConf.guiLang = guiLang
self.mainConf.guiLocale = guiLocale
self.mainConf.guiTheme = guiTheme
self.mainConf.guiIcons = guiIcons
self.mainConf.guiSyntax = guiSyntax
self.mainConf.guiFont = guiFont
self.mainConf.guiFontSize = guiFontSize
@@ -319,9 +312,7 @@ class GuiPreferencesGeneral(QWidget):
self.mainConf.hideVScroll = self.hideVScroll.isChecked()
self.mainConf.hideHScroll = self.hideHScroll.isChecked()
self.mainConf.confChanged = True
return needsRestart, refreshTree
return
##
# Slots
@@ -344,12 +335,12 @@ class GuiPreferencesGeneral(QWidget):
class GuiPreferencesProjects(QWidget):
def __init__(self, mainGui):
QWidget.__init__(self, mainGui)
def __init__(self, prefsGui):
super().__init__(parent=prefsGui)
self.mainConf = novelwriter.CONFIG
self.mainGui = mainGui
self.mainTheme = mainGui.mainTheme
self.mainGui = prefsGui.mainGui
self.mainTheme = prefsGui.mainGui.mainTheme
# The Form
self.mainForm = QConfigLayout()
@@ -391,7 +382,7 @@ class GuiPreferencesProjects(QWidget):
self.mainForm.addGroupLabel(self.tr("Project Backup"))
# Backup Path
self.backupPath = self.mainConf.backupPath
self.backupPath = self.mainConf.backupPath()
self.backupGetPath = QPushButton(self.tr("Browse"))
self.backupGetPath.clicked.connect(self._backupFolder)
self.backupPathRow = self.mainForm.addRow(
@@ -458,7 +449,7 @@ class GuiPreferencesProjects(QWidget):
self.mainConf.autoSaveProj = self.autoSaveProj.value()
# Project Backup
self.mainConf.backupPath = self.backupPath
self.mainConf.setBackupPath(self.backupPath)
self.mainConf.backupOnClose = self.backupOnClose.isChecked()
self.mainConf.askBeforeBackup = self.askBeforeBackup.isChecked()
@@ -466,8 +457,6 @@ class GuiPreferencesProjects(QWidget):
self.mainConf.stopWhenIdle = self.stopWhenIdle.isChecked()
self.mainConf.userIdleTime = round(self.userIdleTime.value() * 60)
self.mainConf.confChanged = True
return
##
@@ -477,12 +466,9 @@ class GuiPreferencesProjects(QWidget):
def _backupFolder(self):
"""Open a dialog to select the backup folder.
"""
currDir = self.backupPath
if not os.path.isdir(currDir):
currDir = ""
currDir = self.backupPath or ""
newDir = QFileDialog.getExistingDirectory(
self, self.tr("Backup Directory"), currDir, options=QFileDialog.ShowDirsOnly
self, self.tr("Backup Directory"), str(currDir), options=QFileDialog.ShowDirsOnly
)
if newDir:
self.backupPath = newDir
@@ -505,12 +491,12 @@ class GuiPreferencesProjects(QWidget):
class GuiPreferencesDocuments(QWidget):
def __init__(self, mainGui):
QWidget.__init__(self, mainGui)
def __init__(self, prefsGui):
super().__init__(parent=prefsGui)
self.mainConf = novelwriter.CONFIG
self.mainGui = mainGui
self.mainTheme = mainGui.mainTheme
self.mainGui = prefsGui.mainGui
self.mainTheme = prefsGui.mainGui.mainTheme
# The Form
self.mainForm = QConfigLayout()
@@ -640,8 +626,6 @@ class GuiPreferencesDocuments(QWidget):
self.mainConf.textMargin = self.textMargin.value()
self.mainConf.tabWidth = self.tabWidth.value()
self.mainConf.confChanged = True
return
##
@@ -666,12 +650,12 @@ class GuiPreferencesDocuments(QWidget):
class GuiPreferencesEditor(QWidget):
def __init__(self, mainGui):
QWidget.__init__(self, mainGui)
def __init__(self, prefsGui):
super().__init__(parent=prefsGui)
self.mainConf = novelwriter.CONFIG
self.mainGui = mainGui
self.mainTheme = mainGui.mainTheme
self.mainGui = prefsGui.mainGui
self.mainTheme = prefsGui.mainGui.mainTheme
# The Form
self.mainForm = QConfigLayout()
@@ -831,8 +815,6 @@ class GuiPreferencesEditor(QWidget):
self.mainConf.autoScroll = self.autoScroll.isChecked()
self.mainConf.autoScrollPos = self.autoScrollPos.value()
self.mainConf.confChanged = True
return
# END Class GuiPreferencesEditor
@@ -840,12 +822,12 @@ class GuiPreferencesEditor(QWidget):
class GuiPreferencesSyntax(QWidget):
def __init__(self, mainGui):
QWidget.__init__(self, mainGui)
def __init__(self, prefsGui):
super().__init__(parent=prefsGui)
self.mainConf = novelwriter.CONFIG
self.mainGui = mainGui
self.mainTheme = mainGui.mainTheme
self.mainGui = prefsGui.mainGui
self.mainTheme = prefsGui.mainGui.mainTheme
# The Form
self.mainForm = QConfigLayout()
@@ -922,8 +904,6 @@ class GuiPreferencesSyntax(QWidget):
# Text Errors
self.mainConf.showMultiSpaces = self.showMultiSpaces.isChecked()
self.mainConf.confChanged = True
return
##
@@ -943,12 +923,12 @@ class GuiPreferencesSyntax(QWidget):
class GuiPreferencesAutomation(QWidget):
def __init__(self, mainGui):
QWidget.__init__(self, mainGui)
def __init__(self, prefsGui):
super().__init__(parent=prefsGui)
self.mainConf = novelwriter.CONFIG
self.mainGui = mainGui
self.mainTheme = mainGui.mainTheme
self.mainGui = prefsGui.mainGui
self.mainTheme = prefsGui.mainGui.mainTheme
# The Form
self.mainForm = QConfigLayout()
@@ -1076,8 +1056,6 @@ class GuiPreferencesAutomation(QWidget):
self.mainConf.fmtPadAfter = self.fmtPadAfter.text().strip()
self.mainConf.fmtPadThin = self.fmtPadThin.isChecked()
self.mainConf.confChanged = True
return
##
@@ -1100,12 +1078,12 @@ class GuiPreferencesAutomation(QWidget):
class GuiPreferencesQuotes(QWidget):
def __init__(self, mainGui):
QWidget.__init__(self, mainGui)
def __init__(self, prefsGui):
super().__init__(parent=prefsGui)
self.mainConf = novelwriter.CONFIG
self.mainGui = mainGui
self.mainTheme = mainGui.mainTheme
self.mainGui = prefsGui.mainGui
self.mainTheme = prefsGui.mainGui.mainTheme
# The Form
self.mainForm = QConfigLayout()
@@ -1126,7 +1104,7 @@ class GuiPreferencesQuotes(QWidget):
self.quoteSym["SO"].setReadOnly(True)
self.quoteSym["SO"].setFixedWidth(qWidth)
self.quoteSym["SO"].setAlignment(Qt.AlignCenter)
self.quoteSym["SO"].setText(self.mainConf.fmtSingleQuotes[0])
self.quoteSym["SO"].setText(self.mainConf.fmtSQuoteOpen)
self.btnSingleStyleO = QPushButton("...")
self.btnSingleStyleO.setMaximumWidth(bWidth)
self.btnSingleStyleO.clicked.connect(lambda: self._getQuote("SO"))
@@ -1142,7 +1120,7 @@ class GuiPreferencesQuotes(QWidget):
self.quoteSym["SC"].setReadOnly(True)
self.quoteSym["SC"].setFixedWidth(qWidth)
self.quoteSym["SC"].setAlignment(Qt.AlignCenter)
self.quoteSym["SC"].setText(self.mainConf.fmtSingleQuotes[1])
self.quoteSym["SC"].setText(self.mainConf.fmtSQuoteClose)
self.btnSingleStyleC = QPushButton("...")
self.btnSingleStyleC.setMaximumWidth(bWidth)
self.btnSingleStyleC.clicked.connect(lambda: self._getQuote("SC"))
@@ -1159,7 +1137,7 @@ class GuiPreferencesQuotes(QWidget):
self.quoteSym["DO"].setReadOnly(True)
self.quoteSym["DO"].setFixedWidth(qWidth)
self.quoteSym["DO"].setAlignment(Qt.AlignCenter)
self.quoteSym["DO"].setText(self.mainConf.fmtDoubleQuotes[0])
self.quoteSym["DO"].setText(self.mainConf.fmtDQuoteOpen)
self.btnDoubleStyleO = QPushButton("...")
self.btnDoubleStyleO.setMaximumWidth(bWidth)
self.btnDoubleStyleO.clicked.connect(lambda: self._getQuote("DO"))
@@ -1175,7 +1153,7 @@ class GuiPreferencesQuotes(QWidget):
self.quoteSym["DC"].setReadOnly(True)
self.quoteSym["DC"].setFixedWidth(qWidth)
self.quoteSym["DC"].setAlignment(Qt.AlignCenter)
self.quoteSym["DC"].setText(self.mainConf.fmtDoubleQuotes[1])
self.quoteSym["DC"].setText(self.mainConf.fmtDQuoteClose)
self.btnDoubleStyleC = QPushButton("...")
self.btnDoubleStyleC.setMaximumWidth(bWidth)
self.btnDoubleStyleC.clicked.connect(lambda: self._getQuote("DC"))
@@ -1192,13 +1170,10 @@ class GuiPreferencesQuotes(QWidget):
"""Save the values set for this tab.
"""
# Quotation Style
self.mainConf.fmtSingleQuotes[0] = self.quoteSym["SO"].text()
self.mainConf.fmtSingleQuotes[1] = self.quoteSym["SC"].text()
self.mainConf.fmtDoubleQuotes[0] = self.quoteSym["DO"].text()
self.mainConf.fmtDoubleQuotes[1] = self.quoteSym["DC"].text()
self.mainConf.confChanged = True
self.mainConf.fmtSQuoteOpen = self.quoteSym["SO"].text()
self.mainConf.fmtSQuoteClose = self.quoteSym["SC"].text()
self.mainConf.fmtDQuoteOpen = self.quoteSym["DO"].text()
self.mainConf.fmtDQuoteClose = self.quoteSym["DC"].text()
return
##
+67 -20
View File
@@ -27,16 +27,18 @@ import math
import logging
import novelwriter
from PyQt5.QtCore import Qt, QSize
from PyQt5.QtCore import Qt, QSize, pyqtSlot
from PyQt5.QtGui import QFont
from PyQt5.QtWidgets import (
QWidget, QDialogButtonBox, QVBoxLayout, QTreeWidget, QTreeWidgetItem,
QLabel, QSpinBox, QGridLayout, QHBoxLayout, QLineEdit, QAbstractItemView
QAbstractItemView, QComboBox, QDialogButtonBox, QGridLayout, QHBoxLayout,
QLabel, QLineEdit, QSpinBox, QTreeWidget, QTreeWidgetItem, QVBoxLayout,
QWidget
)
from novelwriter.enum import nwItemClass
from novelwriter.common import numberToRoman
from novelwriter.constants import nwUnicode
from novelwriter.gui.custom import PagedDialog, QSwitch
from novelwriter.custom import PagedDialog, QSwitch
from novelwriter.constants import nwLabels, nwUnicode
logger = logging.getLogger(__name__)
@@ -44,7 +46,7 @@ logger = logging.getLogger(__name__)
class GuiProjectDetails(PagedDialog):
def __init__(self, mainGui):
PagedDialog.__init__(self, mainGui)
super().__init__(parent=mainGui)
logger.debug("Initialising GuiProjectDetails ...")
self.setObjectName("GuiProjectDetails")
@@ -140,7 +142,7 @@ class GuiProjectDetails(PagedDialog):
class GuiProjectDetailsMain(QWidget):
def __init__(self, mainGui, theProject):
QWidget.__init__(self, mainGui)
super().__init__(parent=mainGui)
self.mainConf = novelwriter.CONFIG
self.theProject = theProject
@@ -155,7 +157,7 @@ class GuiProjectDetailsMain(QWidget):
# Header
# ======
self.bookTitle = QLabel(self.theProject.bookTitle)
self.bookTitle = QLabel(self.theProject.data.title)
bookFont = self.bookTitle.font()
bookFont.setPointSizeF(2.2*fPt)
bookFont.setWeight(QFont.Bold)
@@ -164,7 +166,7 @@ class GuiProjectDetailsMain(QWidget):
self.bookTitle.setWordWrap(True)
self.projName = QLabel(
self.tr("Working Title: {0}").format(self.theProject.projName)
self.tr("Working Title: {0}").format(self.theProject.data.name)
)
workFont = self.projName.font()
workFont.setPointSizeF(0.8*fPt)
@@ -173,7 +175,9 @@ class GuiProjectDetailsMain(QWidget):
self.projName.setAlignment(Qt.AlignHCenter)
self.projName.setWordWrap(True)
self.bookAuthors = QLabel(self.tr("By {0}").format(self.theProject.getAuthors()))
self.bookAuthors = QLabel(self.tr("By {0}").format(
self.theProject.getFormattedAuthors()
))
authFont = self.bookAuthors.font()
authFont.setPointSizeF(1.2*fPt)
self.bookAuthors.setFont(authFont)
@@ -253,10 +257,10 @@ class GuiProjectDetailsMain(QWidget):
self.wordCountVal.setText(f"{nwCount:n}")
self.chapCountVal.setText(f"{hCounts[2]:n}")
self.sceneCountVal.setText(f"{hCounts[3]:n}")
self.revCountVal.setText(f"{self.theProject.saveCount:n}")
self.revCountVal.setText(f"{self.theProject.data.saveCount:n}")
self.editTimeVal.setText(f"{edTime//3600:02d}:{edTime%3600//60:02d}")
self.projPathVal.setText(self.theProject.projPath)
self.projPathVal.setText(str(self.theProject.storage.storagePath))
return
@@ -272,7 +276,7 @@ class GuiProjectDetailsContents(QWidget):
C_PROG = 4
def __init__(self, mainGui, theProject):
QWidget.__init__(self, mainGui)
super().__init__(parent=mainGui)
self.mainConf = novelwriter.CONFIG
self.theProject = theProject
@@ -281,12 +285,26 @@ class GuiProjectDetailsContents(QWidget):
# Internal
self._theToC = []
self._currentRoot = None
iPx = self.mainTheme.baseIconSize
hPx = self.mainConf.pxInt(12)
vPx = self.mainConf.pxInt(4)
pOptions = self.theProject.options
# Header
# ======
self.tocLabel = QLabel("<b>%s</b>" % self.tr("Table of Contents"))
self.novelValue = QComboBox(self)
self.novelValue.setMinimumWidth(self.mainConf.pxInt(200))
self.novelValue.currentIndexChanged.connect(self._novelValueChanged)
self.headBox = QHBoxLayout()
self.headBox.addWidget(self.tocLabel)
self.headBox.addWidget(self.novelValue)
# Contents Tree
# =============
@@ -389,7 +407,7 @@ class GuiProjectDetailsContents(QWidget):
# ========
self.outerBox = QVBoxLayout()
self.outerBox.addWidget(QLabel("<b>%s</b>" % self.tr("Table of Contents")))
self.outerBox.addLayout(self.headBox)
self.outerBox.addWidget(self.tocTree)
self.outerBox.addLayout(self.optionsBox)
@@ -412,19 +430,35 @@ class GuiProjectDetailsContents(QWidget):
def updateValues(self):
"""Populate the tree.
"""
self._prepareData()
self._currentRoot = None
self._populateNovelList()
rootHandle = self.novelValue.currentData()
self._prepareData(rootHandle)
self._populateTree()
return
##
# Internal Functions
##
def _prepareData(self):
"""Extract the data for the tree.
def _populateNovelList(self):
"""Fill the novel combo box with a list of all novel folders.
"""
self._theToC = []
self._theToC = self.theProject.index.getTableOfContents(2)
self.novelValue.clear()
tIcon = self.mainTheme.getIcon(nwLabels.CLASS_ICON[nwItemClass.NOVEL])
for tHandle, nwItem in self.theProject.tree.iterRoots(nwItemClass.NOVEL):
self.novelValue.addItem(tIcon, nwItem.itemName, tHandle)
return
def _prepareData(self, rootHandle):
"""Extract the information from the project index.
"""
logger.debug("Populating ToC from handle '%s'", rootHandle)
self._theToC = self.theProject.index.getTableOfContents(rootHandle, 2)
self._theToC.append(("", 0, self.tr("END"), 0))
return
@@ -432,6 +466,18 @@ class GuiProjectDetailsContents(QWidget):
# Slots
##
@pyqtSlot()
def _novelValueChanged(self):
"""Refresh the tree with another root item.
"""
rootHandle = self.novelValue.currentData()
if rootHandle != self._currentRoot:
self._prepareData(rootHandle)
self._populateTree()
self._currentRoot = rootHandle
return
@pyqtSlot()
def _populateTree(self):
"""Set the content of the chapter/page tree.
"""
@@ -466,10 +512,11 @@ class GuiProjectDetailsContents(QWidget):
progPage = f"{cPage:n}"
progText = f"{pgProg:.1f}{nwUnicode.U_THSP}%"
hDec = self.mainTheme.getHeaderDecoration(tLevel)
if tTitle.strip() == "":
tTitle = self.tr("Untitled")
newItem.setIcon(self.C_TITLE, self.mainTheme.getIcon("doc_h%d" % tLevel))
newItem.setData(self.C_TITLE, Qt.DecorationRole, hDec)
newItem.setText(self.C_TITLE, tTitle)
newItem.setText(self.C_WORDS, f"{wCount:n}")
newItem.setText(self.C_PAGES, f"{pCount:n}")
+16 -27
View File
@@ -23,10 +23,10 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
import os
import logging
import novelwriter
from pathlib import Path
from datetime import datetime
from PyQt5.QtGui import QKeySequence
@@ -54,7 +54,7 @@ class GuiProjectLoad(QDialog):
C_TIME = 2
def __init__(self, mainGui):
QDialog.__init__(self, mainGui)
super().__init__(parent=mainGui)
logger.debug("Initialising GuiProjectLoad ...")
self.setObjectName("GuiProjectLoad")
@@ -77,7 +77,6 @@ class GuiProjectLoad(QDialog):
self.setWindowTitle(self.tr("Open Project"))
self.setMinimumWidth(self.mainConf.pxInt(650))
self.setMinimumHeight(self.mainConf.pxInt(400))
self.setModal(True)
self.nwIcon = QLabel()
self.nwIcon.setPixmap(self.mainGui.mainTheme.getPixmap("novelwriter", (nPx, nPx)))
@@ -158,7 +157,6 @@ class GuiProjectLoad(QDialog):
def _doOpenRecent(self):
"""Close the dialog window with a recent project selected.
"""
logger.verbose("GuiProjectLoad open button clicked")
self._saveSettings()
self.openPath = None
@@ -183,7 +181,6 @@ class GuiProjectLoad(QDialog):
def _doBrowse(self):
"""Browse for a folder path.
"""
logger.verbose("GuiProjectLoad browse button clicked")
extFilter = [
self.tr("novelWriter Project File ({0})").format(nwFiles.PROJ_FILE),
self.tr("All files ({0})").format("*"),
@@ -192,8 +189,8 @@ class GuiProjectLoad(QDialog):
self, self.tr("Open Project"), "", filter=";;".join(extFilter)
)
if projFile:
thePath = os.path.abspath(os.path.dirname(projFile))
self.selPath.setText(thePath)
thePath = Path(projFile).absolute()
self.selPath.setText(str(thePath))
self.openPath = thePath
self.openState = self.OPEN_STATE
self.accept()
@@ -203,7 +200,6 @@ class GuiProjectLoad(QDialog):
def _doCancel(self):
"""Close the dialog window without doing anything.
"""
logger.verbose("GuiProjectLoad close button clicked")
self.openPath = None
self.openState = self.NONE_STATE
self.close()
@@ -212,7 +208,6 @@ class GuiProjectLoad(QDialog):
def _doNewProject(self):
"""Create a new project.
"""
logger.verbose("GuiProjectLoad new project button clicked")
self._saveSettings()
self.openPath = None
self.openState = self.NEW_STATE
@@ -233,7 +228,7 @@ class GuiProjectLoad(QDialog):
).format(projName)
)
if msgYes:
self.mainConf.removeFromRecentCache(
self.mainConf.recentProjects.remove(
selList[0].data(self.C_NAME, Qt.UserRole)
)
self._populateList()
@@ -262,29 +257,23 @@ class GuiProjectLoad(QDialog):
colWidths[self.C_NAME] = self.listBox.columnWidth(self.C_NAME)
colWidths[self.C_COUNT] = self.listBox.columnWidth(self.C_COUNT)
colWidths[self.C_TIME] = self.listBox.columnWidth(self.C_TIME)
self.mainConf.setProjColWidths(colWidths)
self.mainConf.setProjLoadColWidths(colWidths)
return
def _populateList(self):
"""Populate the list box with recent project data.
"""
dataList = []
for projPath in self.mainConf.recentProj:
theEntry = self.mainConf.recentProj[projPath]
theTitle = theEntry.get("title", "")
theTime = theEntry.get("time", 0)
theWords = theEntry.get("words", 0)
dataList.append([theTitle, theTime, theWords, projPath])
self.listBox.clear()
sortList = sorted(dataList, key=lambda x: x[1], reverse=True)
for theTitle, theTime, theWords, projPath in sortList:
dataList = self.mainConf.recentProjects.listEntries()
sortList = sorted(dataList, key=lambda x: x[3], reverse=True)
nwxIcon = self.mainGui.mainTheme.getIcon("proj_nwx")
for path, title, words, time in sortList:
newItem = QTreeWidgetItem([""]*4)
newItem.setIcon(self.C_NAME, self.mainGui.mainTheme.getIcon("proj_nwx"))
newItem.setText(self.C_NAME, theTitle)
newItem.setData(self.C_NAME, Qt.UserRole, projPath)
newItem.setText(self.C_COUNT, formatInt(theWords))
newItem.setText(self.C_TIME, datetime.fromtimestamp(theTime).strftime("%x %X"))
newItem.setIcon(self.C_NAME, nwxIcon)
newItem.setText(self.C_NAME, title)
newItem.setData(self.C_NAME, Qt.UserRole, path)
newItem.setText(self.C_COUNT, formatInt(words))
newItem.setText(self.C_TIME, datetime.fromtimestamp(time).strftime("%x %X"))
newItem.setTextAlignment(self.C_NAME, Qt.AlignLeft | Qt.AlignVCenter)
newItem.setTextAlignment(self.C_COUNT, Qt.AlignRight | Qt.AlignVCenter)
newItem.setTextAlignment(self.C_TIME, Qt.AlignRight | Qt.AlignVCenter)
@@ -294,7 +283,7 @@ class GuiProjectLoad(QDialog):
if self.listBox.topLevelItemCount() > 0:
self.listBox.topLevelItem(0).setSelected(True)
projColWidth = self.mainConf.getProjColWidths()
projColWidth = self.mainConf.projLoadColWidths
if len(projColWidth) == 3:
self.listBox.setColumnWidth(self.C_NAME, projColWidth[self.C_NAME])
self.listBox.setColumnWidth(self.C_COUNT, projColWidth[self.C_COUNT])
+75 -49
View File
@@ -36,15 +36,20 @@ from PyQt5.QtWidgets import (
from novelwriter.enum import nwAlert
from novelwriter.common import simplified
from novelwriter.gui.custom import QSwitch, PagedDialog, QConfigLayout
from novelwriter.custom import QSwitch, PagedDialog, QConfigLayout
logger = logging.getLogger(__name__)
class GuiProjectSettings(PagedDialog):
def __init__(self, mainGui):
PagedDialog.__init__(self, mainGui)
TAB_MAIN = 0
TAB_STATUS = 1
TAB_IMPORT = 2
TAB_REPLACE = 3
def __init__(self, mainGui, focusTab=TAB_MAIN):
super().__init__(parent=mainGui)
logger.debug("Initialising GuiProjectSettings ...")
self.setObjectName("GuiProjectSettings")
@@ -67,10 +72,10 @@ class GuiProjectSettings(PagedDialog):
self.mainConf.pxInt(pOptions.getInt("GuiProjectSettings", "winHeight", wH))
)
self.tabMain = GuiProjectEditMain(self.mainGui, self.theProject)
self.tabStatus = GuiProjectEditStatus(self.mainGui, self.theProject, True)
self.tabImport = GuiProjectEditStatus(self.mainGui, self.theProject, False)
self.tabReplace = GuiProjectEditReplace(self.mainGui, self.theProject)
self.tabMain = GuiProjectEditMain(self)
self.tabStatus = GuiProjectEditStatus(self, True)
self.tabImport = GuiProjectEditStatus(self, False)
self.tabReplace = GuiProjectEditReplace(self)
self.addTab(self.tabMain, self.tr("Settings"))
self.addTab(self.tabStatus, self.tr("Status"))
@@ -83,12 +88,19 @@ class GuiProjectSettings(PagedDialog):
self.addControls(self.buttonBox)
# Flags
self.spellChanged = False
self._spellChanged = False
# Focus Tab
self._focusTab(focusTab)
logger.debug("GuiProjectSettings initialisation complete")
return
@property
def spellChanged(self):
return self._spellChanged
##
# Slots
##
@@ -96,21 +108,19 @@ class GuiProjectSettings(PagedDialog):
def _doSave(self):
"""Save settings and close dialog.
"""
logger.verbose("GuiProjectSettings save button clicked")
projName = self.tabMain.editName.text()
bookTitle = self.tabMain.editTitle.text()
bookAuthors = self.tabMain.editAuthors.toPlainText()
spellLang = self.tabMain.spellLang.currentData()
doBackup = not self.tabMain.doBackup.isChecked()
self.theProject.setProjectName(projName)
self.theProject.setBookTitle(bookTitle)
self.theProject.setBookAuthors(bookAuthors)
self.theProject.setProjBackup(doBackup)
self.theProject.data.setName(projName)
self.theProject.data.setTitle(bookTitle)
self.theProject.data.setAuthors(bookAuthors)
self.theProject.data.setDoBackup(doBackup)
# Remember this as updating spell dictionary can be expensive
self.spellChanged = self.theProject.setSpellLang(spellLang)
self._spellChanged = self.theProject.data.setSpellLang(spellLang)
if self.tabStatus.colChanged:
newList, delList = self.tabStatus.getNewList()
@@ -125,7 +135,7 @@ class GuiProjectSettings(PagedDialog):
if self.tabReplace.arChanged:
newList = self.tabReplace.getNewList()
self.theProject.setAutoReplace(newList)
self.theProject.data.setAutoReplace(newList)
self._saveGuiSettings()
self.accept()
@@ -143,6 +153,19 @@ class GuiProjectSettings(PagedDialog):
# Internal Functions
##
def _focusTab(self, tab):
"""Change which is the focused tab.
"""
if tab == self.TAB_MAIN:
self.setCurrentWidget(self.tabMain)
elif tab == self.TAB_STATUS:
self.setCurrentWidget(self.tabStatus)
elif tab == self.TAB_IMPORT:
self.setCurrentWidget(self.tabImport)
elif tab == self.TAB_REPLACE:
self.setCurrentWidget(self.tabReplace)
return
def _saveGuiSettings(self):
"""Save GUI settings.
"""
@@ -166,12 +189,12 @@ class GuiProjectSettings(PagedDialog):
class GuiProjectEditMain(QWidget):
def __init__(self, mainGui, theProject):
QWidget.__init__(self, mainGui)
def __init__(self, projGui):
super().__init__(parent=projGui)
self.mainConf = novelwriter.CONFIG
self.mainGui = mainGui
self.theProject = theProject
self.mainGui = projGui.mainGui
self.theProject = projGui.theProject
# The Form
self.mainForm = QConfigLayout()
@@ -186,7 +209,7 @@ class GuiProjectEditMain(QWidget):
self.editName = QLineEdit()
self.editName.setMaxLength(200)
self.editName.setMaximumWidth(xW)
self.editName.setText(self.theProject.projName)
self.editName.setText(self.theProject.data.name)
self.mainForm.addRow(
self.tr("Project name"),
self.editName,
@@ -196,7 +219,7 @@ class GuiProjectEditMain(QWidget):
self.editTitle = QLineEdit()
self.editTitle.setMaxLength(200)
self.editTitle.setMaximumWidth(xW)
self.editTitle.setText(self.theProject.bookTitle)
self.editTitle.setText(self.theProject.data.title)
self.mainForm.addRow(
self.tr("Novel title"),
self.editTitle,
@@ -206,7 +229,7 @@ class GuiProjectEditMain(QWidget):
self.editAuthors = QPlainTextEdit()
self.editAuthors.setMaximumHeight(xH)
self.editAuthors.setMaximumWidth(xW)
self.editAuthors.setPlainText("\n".join(self.theProject.bookAuthors))
self.editAuthors.setPlainText("\n".join(self.theProject.data.authors))
self.mainForm.addRow(
self.tr("Author(s)"),
self.editAuthors,
@@ -229,13 +252,13 @@ class GuiProjectEditMain(QWidget):
)
spellIdx = 0
if self.theProject.projSpell is not None:
spellIdx = self.spellLang.findData(self.theProject.projSpell)
if self.theProject.data.spellLang is not None:
spellIdx = self.spellLang.findData(self.theProject.data.spellLang)
if spellIdx != -1:
self.spellLang.setCurrentIndex(spellIdx)
self.doBackup = QSwitch(self)
self.doBackup.setChecked(not self.theProject.doBackup)
self.doBackup.setChecked(not self.theProject.data.doBackup)
self.mainForm.addRow(
self.tr("No backup on close"),
self.doBackup,
@@ -256,20 +279,20 @@ class GuiProjectEditStatus(QWidget):
COL_ROLE = Qt.UserRole + 1
NUM_ROLE = Qt.UserRole + 2
def __init__(self, mainGui, theProject, isStatus):
QWidget.__init__(self, mainGui)
def __init__(self, projGui, isStatus):
super().__init__(parent=projGui)
self.mainConf = novelwriter.CONFIG
self.mainGui = mainGui
self.theProject = theProject
self.mainTheme = mainGui.mainTheme
self.mainGui = projGui.mainGui
self.theProject = projGui.theProject
self.mainTheme = projGui.mainGui.mainTheme
if isStatus:
self.theStatus = self.theProject.statusItems
self.theStatus = self.theProject.data.itemStatus
pageLabel = self.tr("Novel File Status Levels")
colSetting = "statusColW"
else:
self.theStatus = self.theProject.importItems
self.theStatus = self.theProject.data.itemImport
pageLabel = self.tr("Note File Importance Levels")
colSetting = "importColW"
@@ -367,11 +390,12 @@ class GuiProjectEditStatus(QWidget):
newList = []
for n in range(self.listBox.topLevelItemCount()):
item = self.listBox.topLevelItem(n)
newList.append({
"key": item.data(self.COL_LABEL, self.KEY_ROLE),
"name": item.text(self.COL_LABEL),
"cols": item.data(self.COL_LABEL, self.COL_ROLE),
})
if item is not None:
newList.append({
"key": item.data(self.COL_LABEL, self.KEY_ROLE),
"name": item.text(self.COL_LABEL),
"cols": item.data(self.COL_LABEL, self.COL_ROLE),
})
return newList, self.colDeleted
return [], []
@@ -470,7 +494,8 @@ class GuiProjectEditStatus(QWidget):
self.listBox.insertTopLevelItem(nIndex, cItem)
self.listBox.clearSelection()
cItem.setSelected(True)
if cItem is not None:
cItem.setSelected(True)
self.colChanged = True
return
@@ -527,13 +552,13 @@ class GuiProjectEditReplace(QWidget):
COL_KEY = 0
COL_REPL = 1
def __init__(self, mainGui, theProject):
QWidget.__init__(self, mainGui)
def __init__(self, projGui):
super().__init__(parent=projGui)
self.mainConf = novelwriter.CONFIG
self.mainGui = mainGui
self.mainTheme = mainGui.mainTheme
self.theProject = theProject
self.mainGui = projGui.mainGui
self.mainTheme = projGui.mainGui.mainTheme
self.theProject = projGui.theProject
self.arChanged = False
wCol0 = self.mainConf.pxInt(
@@ -553,7 +578,7 @@ class GuiProjectEditReplace(QWidget):
self.listBox.setColumnWidth(self.COL_KEY, wCol0)
self.listBox.setIndentation(0)
for aKey, aVal in self.theProject.autoReplace.items():
for aKey, aVal in self.theProject.data.autoReplace.items():
newItem = QTreeWidgetItem(["<%s>" % aKey, aVal])
self.listBox.addTopLevelItem(newItem)
@@ -619,10 +644,11 @@ class GuiProjectEditReplace(QWidget):
newList = {}
for n in range(self.listBox.topLevelItemCount()):
tItem = self.listBox.topLevelItem(n)
aKey = self._stripNotAllowed(tItem.text(0))
aVal = tItem.text(1)
if len(aKey) > 0:
newList[aKey] = aVal
if tItem is not None:
aKey = self._stripNotAllowed(tItem.text(0))
aVal = tItem.text(1)
if len(aKey) > 0:
newList[aKey] = aVal
return newList
+1 -1
View File
@@ -43,7 +43,7 @@ class GuiQuoteSelect(QDialog):
selectedQuote = ""
def __init__(self, parent=None, currentQuote='"'):
QDialog.__init__(self, parent=parent)
super().__init__(parent=parent)
self.mainConf = novelwriter.CONFIG
+2 -2
View File
@@ -44,7 +44,7 @@ logger = logging.getLogger(__name__)
class GuiUpdates(QDialog):
def __init__(self, mainGui):
QDialog.__init__(self, mainGui)
super().__init__(parent=mainGui)
logger.debug("Initialising GuiUpdates ...")
self.setObjectName("GuiUpdates")
@@ -135,7 +135,7 @@ class GuiUpdates(QDialog):
logException()
relVersion = rawData.get("tag_name", "Unknown")
relDate = rawData.get("created_at", None)
relDate = rawData.get("created_at", "")
try:
relDate = datetime.strptime(relDate[:10], "%Y-%m-%d").strftime("%x")
+17 -11
View File
@@ -23,10 +23,11 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
import os
import logging
import novelwriter
from pathlib import Path
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (
QDialog, QDialogButtonBox, QVBoxLayout, QHBoxLayout, QListWidget,
@@ -43,7 +44,7 @@ logger = logging.getLogger(__name__)
class GuiWordList(QDialog):
def __init__(self, mainGui):
QDialog.__init__(self, mainGui)
super().__init__(parent=mainGui)
logger.debug("Initialising GuiWordList ...")
self.setObjectName("GuiWordList")
@@ -150,13 +151,19 @@ class GuiWordList(QDialog):
"""
self._saveGuiSettings()
dctFile = os.path.join(self.theProject.projMeta, nwFiles.PROJ_DICT)
tmpFile = dctFile + "~"
dctFile = self.theProject.storage.getMetaFile(nwFiles.PROJ_DICT)
if not isinstance(dctFile, Path):
return False
tmpFile = dctFile.with_suffix(".tmp")
try:
with open(tmpFile, mode="w", encoding="utf-8") as outFile:
for i in range(self.listBox.count()):
outFile.write(self.listBox.item(i).text() + "\n")
item = self.listBox.item(i)
if item is not None:
outFile.write(item.text() + "\n")
tmpFile.replace(dctFile)
except Exception:
logger.error("Could not save new word list")
@@ -164,9 +171,6 @@ class GuiWordList(QDialog):
self.reject()
return False
if os.path.isfile(dctFile):
os.unlink(dctFile)
os.rename(tmpFile, dctFile)
self.accept()
return True
@@ -185,10 +189,12 @@ class GuiWordList(QDialog):
def _loadWordList(self):
"""Load the project's word list, if it exists.
"""
self.listBox.clear()
wordList = self.theProject.storage.getMetaFile(nwFiles.PROJ_DICT)
if not isinstance(wordList, Path):
return False
wordList = os.path.join(self.theProject.projMeta, nwFiles.PROJ_DICT)
if not os.path.isfile(wordList):
self.listBox.clear()
if not wordList.exists():
logger.debug("No project dictionary file found")
return False
+4 -3
View File
@@ -112,9 +112,10 @@ class nwDocInsert(Enum):
QUOTE_RS = 2
QUOTE_LD = 3
QUOTE_RD = 4
NEW_PAGE = 5
VSPACE_S = 6
VSPACE_M = 7
SYNOPSIS = 5
NEW_PAGE = 6
VSPACE_S = 7
VSPACE_M = 8
# END Enum nwDocInsert
+6 -5
View File
@@ -44,7 +44,8 @@ def logException():
"""Log the content of an exception message.
"""
exType, exValue, _ = sys.exc_info()
logger.error("%s: %s", exType.__name__, str(exValue))
if exType is not None:
logger.error("%s: %s", exType.__name__, str(exValue))
def formatException(exc):
@@ -61,7 +62,7 @@ def formatException(exc):
class NWErrorMessage(QDialog):
def __init__(self, parent):
QDialog.__init__(self, parent=parent)
super().__init__(parent=parent)
self.setObjectName("NWErrorMessage")
# Widgets
@@ -131,7 +132,7 @@ class NWErrorMessage(QDialog):
try:
import lxml
lxmlVersion = lxml.__version__
lxmlVersion = lxml.__version__ # type: ignore
except Exception:
lxmlVersion = "Unknown"
@@ -198,8 +199,8 @@ def exceptionHandler(exType, exValue, exTrace):
try:
# Try a controlled shutdown
nwGUI.closeProject(isYes=True)
nwGUI.closeMain()
nwGUI.closeProject(isYes=True) # type: ignore
nwGUI.closeMain() # type: ignore
logger.info("Emergency shutdown successful")
except Exception as exc:
File diff suppressed because it is too large Load Diff
+8 -6
View File
@@ -47,7 +47,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
BLOCK_TITLE = 4
def __init__(self, theDoc, mainGui, spEnchant):
QSyntaxHighlighter.__init__(self, theDoc)
super().__init__(theDoc)
logger.debug("Initialising GuiDocHighlighter ...")
self.mainConf = novelwriter.CONFIG
@@ -151,11 +151,13 @@ class GuiDocHighlighter(QSyntaxHighlighter):
# Quoted Strings
if self.mainConf.highlightQuotes:
fmtDbl = self.mainConf.fmtDoubleQuotes
fmtSng = self.mainConf.fmtSingleQuotes
fmtDblO = self.mainConf.fmtDQuoteOpen
fmtDblC = self.mainConf.fmtDQuoteClose
fmtSngO = self.mainConf.fmtSQuoteOpen
fmtSngC = self.mainConf.fmtSQuoteClose
# Straight Quotes
if fmtDbl != ["\"", "\""]:
if not (fmtDblO == fmtDblC == "\""):
self.hRules.append((
"(\\B\")(.*?)(\"\\B)", {
0: self.hStyles["dialogue1"],
@@ -165,7 +167,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
# Double Quotes
dblEnd = "|$" if self.mainConf.allowOpenDQuote else ""
self.hRules.append((
f"(\\B{fmtDbl[0]})(.*?)({fmtDbl[1]}\\B{dblEnd})", {
f"(\\B{fmtDblO})(.*?)({fmtDblC}\\B{dblEnd})", {
0: self.hStyles["dialogue2"],
}
))
@@ -173,7 +175,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
# Single Quotes
sngEnd = "|$" if self.mainConf.allowOpenSQuote else ""
self.hRules.append((
f"(\\B{fmtSng[0]})(.*?)({fmtSng[1]}\\B{sngEnd})", {
f"(\\B{fmtSngO})(.*?)({fmtSngC}\\B{sngEnd})", {
0: self.hStyles["dialogue3"],
}
))
+103 -73
View File
@@ -54,7 +54,7 @@ class GuiDocViewer(QTextBrowser):
loadDocumentTagRequest = pyqtSignal(str, Enum)
def __init__(self, mainGui):
QTextBrowser.__init__(self, mainGui)
super().__init__(parent=mainGui)
logger.debug("Initialising GuiDocViewer ...")
@@ -102,6 +102,13 @@ class GuiDocViewer(QTextBrowser):
self.docHeader.setTitleFromHandle(self._docHandle)
return True
def updateTheme(self):
"""Update theme elements.
"""
self.docHeader.updateTheme()
self.docFooter.updateTheme()
return
def initViewer(self):
"""Set editor settings from main config.
"""
@@ -150,10 +157,7 @@ class GuiDocViewer(QTextBrowser):
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
# Refresh the tab stops
if self.mainConf.verQtValue >= 51000:
self.setTabStopDistance(self.mainConf.getTabWidth())
else:
self.setTabStopWidth(self.mainConf.getTabWidth())
self.setTabStopDistance(self.mainConf.getTabWidth())
# If we have a document open, we should reload it in case the font changed
if self._docHandle is not None:
@@ -193,10 +197,7 @@ class GuiDocViewer(QTextBrowser):
return False
# Refresh the tab stops
if self.mainConf.verQtValue >= 51000:
self.setTabStopDistance(self.mainConf.getTabWidth())
else:
self.setTabStopWidth(self.mainConf.getTabWidth())
self.setTabStopDistance(self.mainConf.getTabWidth())
# Must be before setHtml
if updateHistory:
@@ -216,7 +217,7 @@ class GuiDocViewer(QTextBrowser):
self.verticalScrollBar().setValue(sPos)
self._docHandle = tHandle
self.theProject.setLastViewed(tHandle)
self.theProject._data.setLastHandle(tHandle, "viewer")
self.docHeader.setTitleFromHandle(self._docHandle)
self.updateDocMargins()
@@ -247,7 +248,7 @@ class GuiDocViewer(QTextBrowser):
"""Wrapper function for various document actions on the current
document.
"""
logger.verbose("Requesting action: '%s'", theAction.name)
logger.debug("Requesting action: '%s'", theAction.name)
if self._docHandle is None:
logger.error("No document open")
return False
@@ -270,7 +271,7 @@ class GuiDocViewer(QTextBrowser):
if not isinstance(tAnchor, str):
return False
if tAnchor.startswith("#"):
logger.verbose("Moving to anchor '%s'", tAnchor)
logger.debug("Moving to anchor '%s'", tAnchor)
self.setSource(QUrl(tAnchor))
return True
@@ -356,7 +357,7 @@ class GuiDocViewer(QTextBrowser):
theBlock = self.document().findBlockByLineNumber(theLine)
if theBlock:
self.setCursorPosition(theBlock.position())
logger.verbose("Cursor moved to line %d", theLine)
logger.debug("Cursor moved to line %d", theLine)
return True
def setScrollPosition(self, thePos):
@@ -402,7 +403,7 @@ class GuiDocViewer(QTextBrowser):
"""Process a clicked link internally in the document.
"""
theLink = theURL.url()
logger.verbose("Clicked link: '%s'", theLink)
logger.debug("Clicked link: '%s'", theLink)
if len(theLink) > 0:
theBits = theLink.split("=")
if len(theBits) == 2:
@@ -461,7 +462,7 @@ class GuiDocViewer(QTextBrowser):
has its margins adjusted according to user preferences.
"""
self.updateDocMargins()
QTextBrowser.resizeEvent(self, theEvent)
super().resizeEvent(theEvent)
return
def mouseReleaseEvent(self, theEvent):
@@ -472,7 +473,7 @@ class GuiDocViewer(QTextBrowser):
elif theEvent.button() == Qt.ForwardButton:
self.navForward()
else:
QTextBrowser.mouseReleaseEvent(self, theEvent)
super().mouseReleaseEvent(theEvent)
return
##
@@ -568,7 +569,7 @@ class GuiDocViewer(QTextBrowser):
# END Class GuiDocViewer
class GuiDocViewHistory():
class GuiDocViewHistory:
def __init__(self, docViewer):
@@ -584,7 +585,7 @@ class GuiDocViewHistory():
def clear(self):
"""Clear the view history.
"""
logger.verbose("View history cleared")
logger.debug("View history cleared")
self._navHistory = []
self._posHistory = []
self._currPos = -1
@@ -598,7 +599,7 @@ class GuiDocViewHistory():
"""
if self._currPos >= 0 and self._currPos < len(self._navHistory):
if tHandle == self._navHistory[self._currPos]:
logger.verbose("Not updating view hsitory")
logger.debug("Not updating view hsitory")
return False
self._truncateHistory(self._currPos)
@@ -613,7 +614,7 @@ class GuiDocViewHistory():
self._dumpHistory()
logger.verbose("Added '%s' to view history", tHandle)
logger.debug("Added '%s' to view history", tHandle)
return True
@@ -622,7 +623,7 @@ class GuiDocViewHistory():
"""
newPos = self._currPos + 1
if newPos < len(self._navHistory):
logger.verbose("Move forward in view history")
logger.debug("Move forward in view history")
self._prevPos = self._currPos
self._updateScrollBar()
@@ -640,7 +641,7 @@ class GuiDocViewHistory():
"""
newPos = self._currPos - 1
if newPos >= 0:
logger.verbose("Move backward in view history")
logger.debug("Move backward in view history")
self._prevPos = self._currPos
self._updateScrollBar()
@@ -686,11 +687,11 @@ class GuiDocViewHistory():
def _dumpHistory(self):
"""Debug function to dump history to the logger. Since it is a
for loop, it is skipped entirely if log level isn't VERBOSE.
for loop, it is skipped entirely if log level isn't DEBUG.
"""
if logger.getEffectiveLevel() < logging.DEBUG:
if logger.getEffectiveLevel() == logging.DEBUG:
for i, (h, p) in enumerate(zip(self._navHistory, self._posHistory)):
logger.verbose(
logger.debug(
"History %02d: %s %13s [x:%d]" % (
i + 1, ">" if i == self._currPos else " ", h, p
)
@@ -708,7 +709,7 @@ class GuiDocViewHistory():
class GuiDocViewHeader(QWidget):
def __init__(self, docViewer):
QWidget.__init__(self, docViewer)
super().__init__(parent=docViewer)
logger.debug("Initialising GuiDocViewHeader ...")
@@ -741,51 +742,38 @@ class GuiDocViewHeader(QWidget):
lblFont.setPointSizeF(0.9*self.mainTheme.fontPointSize)
self.theTitle.setFont(lblFont)
buttonStyle = (
"QToolButton {{border: none; background: transparent;}} "
"QToolButton:hover {{border: none; background: rgba({0},{1},{2},0.2);}}"
).format(*self.mainTheme.colText)
# Buttons
self.backButton = QToolButton(self)
self.backButton.setIcon(self.mainTheme.getIcon("backward"))
self.backButton.setContentsMargins(0, 0, 0, 0)
self.backButton.setIconSize(QSize(fPx, fPx))
self.backButton.setFixedSize(fPx, fPx)
self.backButton.setStyleSheet(buttonStyle)
self.backButton.setToolButtonStyle(Qt.ToolButtonIconOnly)
self.backButton.setVisible(False)
self.backButton.setToolTip(self.tr("Go backward"))
self.backButton.clicked.connect(self.docViewer.navBackward)
self.forwardButton = QToolButton(self)
self.forwardButton.setIcon(self.mainTheme.getIcon("forward"))
self.forwardButton.setContentsMargins(0, 0, 0, 0)
self.forwardButton.setIconSize(QSize(fPx, fPx))
self.forwardButton.setFixedSize(fPx, fPx)
self.forwardButton.setStyleSheet(buttonStyle)
self.forwardButton.setToolButtonStyle(Qt.ToolButtonIconOnly)
self.forwardButton.setVisible(False)
self.forwardButton.setToolTip(self.tr("Go forward"))
self.forwardButton.clicked.connect(self.docViewer.navForward)
self.refreshButton = QToolButton(self)
self.refreshButton.setIcon(self.mainTheme.getIcon("refresh"))
self.refreshButton.setContentsMargins(0, 0, 0, 0)
self.refreshButton.setIconSize(QSize(fPx, fPx))
self.refreshButton.setFixedSize(fPx, fPx)
self.refreshButton.setStyleSheet(buttonStyle)
self.refreshButton.setToolButtonStyle(Qt.ToolButtonIconOnly)
self.refreshButton.setVisible(False)
self.refreshButton.setToolTip(self.tr("Reload the document"))
self.refreshButton.clicked.connect(self._refreshDocument)
self.closeButton = QToolButton(self)
self.closeButton.setIcon(self.mainTheme.getIcon("close"))
self.closeButton.setContentsMargins(0, 0, 0, 0)
self.closeButton.setIconSize(QSize(fPx, fPx))
self.closeButton.setFixedSize(fPx, fPx)
self.closeButton.setStyleSheet(buttonStyle)
self.closeButton.setToolButtonStyle(Qt.ToolButtonIconOnly)
self.closeButton.setVisible(False)
self.closeButton.setToolTip(self.tr("Close the document"))
@@ -809,7 +797,7 @@ class GuiDocViewHeader(QWidget):
self.setMinimumHeight(fPx + 2*cM)
# Fix the Colours
self.matchColours()
self.updateTheme()
logger.debug("GuiDocViewHeader initialisation complete")
@@ -819,6 +807,28 @@ class GuiDocViewHeader(QWidget):
# Methods
##
def updateTheme(self):
"""Update theme elements.
"""
self.backButton.setIcon(self.mainTheme.getIcon("backward"))
self.forwardButton.setIcon(self.mainTheme.getIcon("forward"))
self.refreshButton.setIcon(self.mainTheme.getIcon("refresh"))
self.closeButton.setIcon(self.mainTheme.getIcon("close"))
buttonStyle = (
"QToolButton {{border: none; background: transparent;}} "
"QToolButton:hover {{border: none; background: rgba({0},{1},{2},0.2);}}"
).format(*self.mainTheme.colText)
self.backButton.setStyleSheet(buttonStyle)
self.forwardButton.setStyleSheet(buttonStyle)
self.refreshButton.setStyleSheet(buttonStyle)
self.closeButton.setStyleSheet(buttonStyle)
self.matchColours()
return
def matchColours(self):
"""Update the colours of the widget to match those of the syntax
theme rather than the main GUI.
@@ -879,12 +889,14 @@ class GuiDocViewHeader(QWidget):
# Slots
##
@pyqtSlot()
def _closeDocument(self):
"""Trigger the close editor/viewer on the main window.
"""
self.mainGui.closeDocViewer()
return
@pyqtSlot()
def _refreshDocument(self):
"""Reload the content of the document.
"""
@@ -915,7 +927,7 @@ class GuiDocViewHeader(QWidget):
class GuiDocViewFooter(QWidget):
def __init__(self, docViewer):
QWidget.__init__(self, docViewer)
super().__init__(parent=docViewer)
logger.debug("Initialising GuiDocViewFooter ...")
@@ -932,33 +944,13 @@ class GuiDocViewFooter(QWidget):
bSp = self.mainConf.pxInt(2)
hSp = self.mainConf.pxInt(8)
# Icons
stickyOn = self.mainTheme.getPixmap("sticky-on", (fPx, fPx))
stickyOff = self.mainTheme.getPixmap("sticky-off", (fPx, fPx))
stickyIcon = QIcon()
stickyIcon.addPixmap(stickyOn, QIcon.Normal, QIcon.On)
stickyIcon.addPixmap(stickyOff, QIcon.Normal, QIcon.Off)
bulletOn = self.mainTheme.getPixmap("bullet-on", (fPx, fPx))
bulletOff = self.mainTheme.getPixmap("bullet-off", (fPx, fPx))
bulletIcon = QIcon()
bulletIcon.addPixmap(bulletOn, QIcon.Normal, QIcon.On)
bulletIcon.addPixmap(bulletOff, QIcon.Normal, QIcon.Off)
# Main Widget Settings
self.setContentsMargins(0, 0, 0, 0)
self.setAutoFillBackground(True)
buttonStyle = (
"QToolButton {{border: none; background: transparent;}} "
"QToolButton:hover {{border: none; background: rgba({0},{1},{2},0.2);}}"
).format(*self.mainTheme.colText)
# Show/Hide Details
self.showHide = QToolButton(self)
self.showHide.setToolButtonStyle(Qt.ToolButtonIconOnly)
self.showHide.setStyleSheet(buttonStyle)
self.showHide.setIcon(self.mainTheme.getIcon("reference"))
self.showHide.setIconSize(QSize(fPx, fPx))
self.showHide.setFixedSize(QSize(fPx, fPx))
self.showHide.clicked.connect(self._doShowHide)
@@ -968,8 +960,6 @@ class GuiDocViewFooter(QWidget):
self.stickyRefs = QToolButton(self)
self.stickyRefs.setCheckable(True)
self.stickyRefs.setToolButtonStyle(Qt.ToolButtonIconOnly)
self.stickyRefs.setStyleSheet(buttonStyle)
self.stickyRefs.setIcon(stickyIcon)
self.stickyRefs.setIconSize(QSize(fPx, fPx))
self.stickyRefs.setFixedSize(QSize(fPx, fPx))
self.stickyRefs.toggled.connect(self._doToggleSticky)
@@ -982,8 +972,6 @@ class GuiDocViewFooter(QWidget):
self.showComments.setCheckable(True)
self.showComments.setChecked(self.mainConf.viewComments)
self.showComments.setToolButtonStyle(Qt.ToolButtonIconOnly)
self.showComments.setStyleSheet(buttonStyle)
self.showComments.setIcon(bulletIcon)
self.showComments.setIconSize(QSize(fPx, fPx))
self.showComments.setFixedSize(QSize(fPx, fPx))
self.showComments.toggled.connect(self._doToggleComments)
@@ -994,8 +982,6 @@ class GuiDocViewFooter(QWidget):
self.showSynopsis.setCheckable(True)
self.showSynopsis.setChecked(self.mainConf.viewSynopsis)
self.showSynopsis.setToolButtonStyle(Qt.ToolButtonIconOnly)
self.showSynopsis.setStyleSheet(buttonStyle)
self.showSynopsis.setIcon(bulletIcon)
self.showSynopsis.setIconSize(QSize(fPx, fPx))
self.showSynopsis.setFixedSize(QSize(fPx, fPx))
self.showSynopsis.toggled.connect(self._doToggleSynopsis)
@@ -1069,7 +1055,7 @@ class GuiDocViewFooter(QWidget):
self.setMinimumHeight(fPx + 2*cM)
# Fix the Colours
self.matchColours()
self.updateTheme()
logger.debug("GuiDocViewFooter initialisation complete")
@@ -1079,6 +1065,46 @@ class GuiDocViewFooter(QWidget):
# Methods
##
def updateTheme(self):
"""Update theme elements.
"""
# Icons
fPx = int(0.9*self.mainTheme.fontPixelSize)
stickyOn = self.mainTheme.getPixmap("sticky-on", (fPx, fPx))
stickyOff = self.mainTheme.getPixmap("sticky-off", (fPx, fPx))
stickyIcon = QIcon()
stickyIcon.addPixmap(stickyOn, QIcon.Normal, QIcon.On)
stickyIcon.addPixmap(stickyOff, QIcon.Normal, QIcon.Off)
bulletOn = self.mainTheme.getPixmap("bullet-on", (fPx, fPx))
bulletOff = self.mainTheme.getPixmap("bullet-off", (fPx, fPx))
bulletIcon = QIcon()
bulletIcon.addPixmap(bulletOn, QIcon.Normal, QIcon.On)
bulletIcon.addPixmap(bulletOff, QIcon.Normal, QIcon.Off)
self.showHide.setIcon(self.mainTheme.getIcon("reference"))
self.stickyRefs.setIcon(stickyIcon)
self.showComments.setIcon(bulletIcon)
self.showSynopsis.setIcon(bulletIcon)
# StyleSheets
buttonStyle = (
"QToolButton {{border: none; background: transparent;}} "
"QToolButton:hover {{border: none; background: rgba({0},{1},{2},0.2);}}"
).format(*self.mainTheme.colText)
self.showHide.setStyleSheet(buttonStyle)
self.stickyRefs.setStyleSheet(buttonStyle)
self.showComments.setStyleSheet(buttonStyle)
self.showSynopsis.setStyleSheet(buttonStyle)
self.matchColours()
return
def matchColours(self):
"""Update the colours of the widget to match those of the syntax
theme rather than the main GUI.
@@ -1100,6 +1126,7 @@ class GuiDocViewFooter(QWidget):
# Slots
##
@pyqtSlot()
def _doShowHide(self):
"""Toggle the expand/collapse of the panel.
"""
@@ -1107,26 +1134,29 @@ class GuiDocViewFooter(QWidget):
self.viewMeta.setVisible(not isVisible)
return
@pyqtSlot(bool)
def _doToggleSticky(self, theState):
"""Toggle the sticky flag for the reference panel.
"""
logger.verbose("Reference sticky is %s", str(theState))
logger.debug("Reference sticky is %s", str(theState))
self.docViewer.stickyRef = theState
if not theState and self.docViewer.docHandle() is not None:
self.viewMeta.refreshReferences(self.docViewer.docHandle())
return
@pyqtSlot(bool)
def _doToggleComments(self, theState):
"""Toggle the view comment button and reload the document.
"""
self.mainConf.setViewComments(theState)
self.mainConf.viewComments = theState
self.docViewer.reloadText()
return
@pyqtSlot(bool)
def _doToggleSynopsis(self, theState):
"""Toggle the view synopsis button and reload the document.
"""
self.mainConf.setViewSynopsis(theState)
self.mainConf.viewSynopsis = theState
self.docViewer.reloadText()
return
@@ -1141,7 +1171,7 @@ class GuiDocViewFooter(QWidget):
class GuiDocViewDetails(QScrollArea):
def __init__(self, mainGui):
QScrollArea.__init__(self, mainGui)
super().__init__(parent=mainGui)
logger.debug("Initialising GuiDocViewDetails ...")
self.mainConf = novelwriter.CONFIG
@@ -1205,7 +1235,7 @@ class GuiDocViewDetails(QScrollArea):
"""Capture the link-click and forward it to the document viewer
class for handling.
"""
logger.verbose("Clicked link: '%s'", theLink)
logger.debug("Clicked link: '%s'", theLink)
if len(theLink) == 21:
tHandle = theLink[:13]
tAnchor = theLink[13:]
+17 -15
View File
@@ -30,7 +30,6 @@ from PyQt5.QtCore import Qt, pyqtSlot
from PyQt5.QtGui import QFont, QPixmap
from PyQt5.QtWidgets import QWidget, QGridLayout, QLabel
from novelwriter.enum import nwItemType
from novelwriter.constants import trConst, nwLabels
logger = logging.getLogger(__name__)
@@ -39,7 +38,7 @@ logger = logging.getLogger(__name__)
class GuiItemDetails(QWidget):
def __init__(self, mainGui):
QWidget.__init__(self, mainGui)
super().__init__(parent=mainGui)
logger.debug("Initialising GuiItemDetails ...")
self.mainConf = novelwriter.CONFIG
@@ -54,12 +53,8 @@ class GuiItemDetails(QWidget):
hSp = self.mainConf.pxInt(6)
vSp = self.mainConf.pxInt(1)
mPx = self.mainConf.pxInt(6)
iPx = self.mainTheme.baseIconSize
fPt = self.mainTheme.fontPointSize
self._expCheck = self.mainTheme.getPixmap("check", (iPx, iPx))
self._expCross = self.mainTheme.getPixmap("cross", (iPx, iPx))
fntLabel = QFont()
fntLabel.setBold(True)
fntLabel.setPointSizeF(0.9*fPt)
@@ -180,6 +175,8 @@ class GuiItemDetails(QWidget):
self.setLayout(self.mainBox)
self.updateTheme()
# Make sure the columns for flags and counts don't resize too often
flagWidth = self.mainTheme.getTextWidth("Mm", fntValue)
countWidth = self.mainTheme.getTextWidth("99,999", fntValue)
@@ -220,6 +217,12 @@ class GuiItemDetails(QWidget):
"""
self.updateViewBox(self._itemHandle)
def updateTheme(self):
"""Update theme elements.
"""
self.updateViewBox(self._itemHandle)
return
##
# Public Slots
##
@@ -247,13 +250,13 @@ class GuiItemDetails(QWidget):
if len(theLabel) > 100:
theLabel = theLabel[:96].rstrip()+" ..."
if nwItem.itemType == nwItemType.FILE:
if nwItem.isExported:
self.labelIcon.setPixmap(self._expCheck)
if nwItem.isFileType():
if nwItem.isActive:
self.labelIcon.setPixmap(self.mainTheme.getPixmap("checked", (iPx, iPx)))
else:
self.labelIcon.setPixmap(self._expCross)
self.labelIcon.setPixmap(self.mainTheme.getPixmap("unchecked", (iPx, iPx)))
else:
self.labelIcon.setPixmap(QPixmap(1, 1))
self.labelIcon.setPixmap(self.mainTheme.getPixmap("noncheckable", (iPx, iPx)))
self.labelData.setText(theLabel)
@@ -274,17 +277,16 @@ class GuiItemDetails(QWidget):
# Layout
# ======
hLevel = self.theProject.index.getHandleHeaderLevel(tHandle)
usageIcon = self.mainTheme.getItemIcon(
nwItem.itemType, nwItem.itemClass, nwItem.itemLayout, hLevel
nwItem.itemType, nwItem.itemClass, nwItem.itemLayout, nwItem.mainHeading
)
self.usageIcon.setPixmap(usageIcon.pixmap(iPx, iPx))
self.usageData.setText(nwItem.describeMe(hLevel))
self.usageData.setText(nwItem.describeMe())
# Counts
# ======
if nwItem.itemType == nwItemType.FILE:
if nwItem.isFileType():
self.cCountData.setText(f"{nwItem.charCount:n}")
self.wCountData.setText(f"{nwItem.wordCount:n}")
self.pCountData.setText(f"{nwItem.paraCount:n}")
+25 -28
View File
@@ -26,6 +26,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
import logging
import novelwriter
from pathlib import Path
from urllib.parse import urljoin
from urllib.request import pathname2url
@@ -40,9 +41,13 @@ logger = logging.getLogger(__name__)
class GuiMainMenu(QMenuBar):
"""The GUI main menu. All menu actions are defined here with the
main menu as the owner. Each widget that need them elsewhere need to
add them from this class.
"""
def __init__(self, mainGui):
QMenuBar.__init__(self, mainGui)
super().__init__(parent=mainGui)
logger.debug("Initialising GuiMainMenu ...")
self.mainConf = novelwriter.CONFIG
@@ -79,12 +84,6 @@ class GuiMainMenu(QMenuBar):
self.aSpellCheck.setChecked(theMode)
return
def setFocusMode(self, theMode):
"""Forward focus mode check state to its action.
"""
self.aFocusMode.setChecked(theMode)
return
##
# Slots
##
@@ -106,10 +105,11 @@ class GuiMainMenu(QMenuBar):
def _openUserManualFile(self):
"""Open the documentation in PDF format.
"""
if self.mainConf.pdfDocs is None:
return False
QDesktopServices.openUrl(QUrl(urljoin("file:", pathname2url(self.mainConf.pdfDocs))))
return True
if isinstance(self.mainConf.pdfDocs, Path):
QDesktopServices.openUrl(
QUrl(urljoin("file:", pathname2url(str(self.mainConf.pdfDocs))))
)
return
##
# Menu Builders
@@ -164,14 +164,14 @@ class GuiMainMenu(QMenuBar):
# Project > Edit
self.aEditItem = QAction(self.tr("Rename Item"), self)
self.aEditItem.setShortcuts(["F2"])
self.aEditItem.setShortcut("F2")
self.aEditItem.triggered.connect(lambda: self.mainGui.editItemLabel(None))
self.projMenu.addAction(self.aEditItem)
# Project > Delete
self.aDeleteItem = QAction(self.tr("Delete Item"), self)
self.aDeleteItem.setShortcut("Ctrl+Shift+Del")
self.aDeleteItem.triggered.connect(lambda: self.mainGui.projView.deleteItem(None))
self.aDeleteItem.triggered.connect(lambda: self.mainGui.projView.requestDeleteItem(None))
self.projMenu.addAction(self.aDeleteItem)
# Project > Empty Trash
@@ -244,16 +244,6 @@ class GuiMainMenu(QMenuBar):
self.aImportFile.triggered.connect(lambda: self.mainGui.importDocument())
self.docuMenu.addAction(self.aImportFile)
# Document > Merge Documents
self.aMergeDocs = QAction(self.tr("Merge Folder to Document"), self)
self.aMergeDocs.triggered.connect(lambda: self.mainGui.mergeDocuments())
self.docuMenu.addAction(self.aMergeDocs)
# Document > Split Document
self.aSplitDoc = QAction(self.tr("Split Document to Folder"), self)
self.aSplitDoc.triggered.connect(lambda: self.mainGui.splitDocument())
self.docuMenu.addAction(self.aSplitDoc)
return
def _buildEditMenu(self):
@@ -375,8 +365,6 @@ class GuiMainMenu(QMenuBar):
# View > Focus Mode
self.aFocusMode = QAction(self.tr("Focus Mode"), self)
self.aFocusMode.setShortcut("F8")
self.aFocusMode.setCheckable(True)
self.aFocusMode.setChecked(self.mainGui.isFocusMode)
self.aFocusMode.triggered.connect(lambda: self.mainGui.toggleFocusMode())
self.viewMenu.addAction(self.aFocusMode)
@@ -568,6 +556,15 @@ class GuiMainMenu(QMenuBar):
)
self.mInsKeywords.addAction(self.mInsKWItems[keyWord][0])
# Insert > Special Comments
self.mInsComments = self.insertMenu.addMenu(self.tr("Special Comments"))
# Insert > Synopsis Comment
self.aInsSynopsis = QAction(self.tr("Synopsis Comment"), self)
self.aInsSynopsis.setShortcut("Ctrl+K, S")
self.aInsSynopsis.triggered.connect(lambda: self._docInsert(nwDocInsert.SYNOPSIS))
self.mInsComments.addAction(self.aInsSynopsis)
# Insert > Symbols
self.mInsBreaks = self.insertMenu.addMenu(self.tr("Page Break and Space"))
@@ -799,7 +796,7 @@ class GuiMainMenu(QMenuBar):
# Tools > Check Spelling
self.aSpellCheck = QAction(self.tr("Check Spelling"), self)
self.aSpellCheck.setCheckable(True)
self.aSpellCheck.setChecked(self.theProject.spellCheck)
self.aSpellCheck.setChecked(self.theProject.data.spellCheck)
self.aSpellCheck.triggered.connect(self._toggleSpellCheck) # triggered, not toggled!
self.aSpellCheck.setShortcut("Ctrl+F7")
self.toolsMenu.addAction(self.aSpellCheck)
@@ -829,7 +826,7 @@ class GuiMainMenu(QMenuBar):
# Tools > Backup
self.aBackupProject = QAction(self.tr("Backup Project"), self)
self.aBackupProject.triggered.connect(lambda: self.theProject.zipIt(True))
self.aBackupProject.triggered.connect(lambda: self.theProject.backupProject(True))
self.toolsMenu.addAction(self.aBackupProject)
# Tools > Export Project
@@ -886,7 +883,7 @@ class GuiMainMenu(QMenuBar):
self.helpMenu.addAction(self.aHelpDocs)
# Help > User Manual (PDF)
if self.mainConf.pdfDocs is not None:
if isinstance(self.mainConf.pdfDocs, Path):
self.aPdfDocs = QAction(self.tr("User Manual (PDF)"), self)
self.aPdfDocs.setShortcut("Shift+F1")
self.aPdfDocs.triggered.connect(self._openUserManualFile)
+187 -56
View File
@@ -35,11 +35,11 @@ from PyQt5.QtGui import QPalette
from PyQt5.QtCore import Qt, QSize, pyqtSlot, pyqtSignal
from PyQt5.QtWidgets import (
QAbstractItemView, QActionGroup, QFrame, QHBoxLayout, QHeaderView, QLabel,
QMenu, QSizePolicy, QToolButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout,
QWidget
QMenu, QSizePolicy, QToolButton, QToolTip, QTreeWidget, QTreeWidgetItem,
QVBoxLayout, QWidget
)
from novelwriter.enum import nwDocMode, nwItemClass
from novelwriter.enum import nwDocMode, nwItemClass, nwOutline
from novelwriter.common import checkInt
from novelwriter.constants import nwHeaders, nwKeyWords, nwLabels, trConst
@@ -63,7 +63,7 @@ class GuiNovelView(QWidget):
openDocumentRequest = pyqtSignal(str, Enum, int, str)
def __init__(self, mainGui):
QWidget.__init__(self, mainGui)
super().__init__(parent=mainGui)
self.mainGui = mainGui
self.theProject = mainGui.theProject
@@ -71,6 +71,7 @@ class GuiNovelView(QWidget):
# Build GUI
self.novelTree = GuiNovelTree(self)
self.novelBar = GuiNovelToolBar(self)
self.novelBar.setEnabled(False)
# Assemble
self.outerBox = QVBoxLayout()
@@ -84,6 +85,7 @@ class GuiNovelView(QWidget):
# Function Mappings
self.updateWordCounts = self.novelTree.updateWordCounts
self.getSelectedHandle = self.novelTree.getSelectedHandle
self.setActiveHandle = self.novelTree.setActiveHandle
return
@@ -91,14 +93,24 @@ class GuiNovelView(QWidget):
# Methods
##
def updateTheme(self):
"""Update theme elements.
"""
self.novelBar.updateTheme()
self.novelTree.updateTheme()
self.refreshTree()
return
def initSettings(self):
"""Initialise GUI elements that depend on specific settings.
"""
self.novelTree.initSettings()
return
def refreshTree(self):
"""Refresh the current tree.
"""
self.novelTree.refreshTree(rootHandle=self.theProject.lastNovel)
self.novelTree.refreshTree(rootHandle=self.theProject.data.getLastHandle("novelTree"))
return
def clearProject(self):
@@ -106,12 +118,13 @@ class GuiNovelView(QWidget):
"""
self.novelTree.clearContent()
self.novelBar.clearContent()
self.novelBar.setEnabled(False)
return
def openProjectTasks(self):
"""Run opening project tasks.
"""Run open project tasks.
"""
lastNovel = self.theProject.lastNovel
lastNovel = self.theProject.data.getLastHandle("novelTree")
if lastNovel not in self.theProject.tree:
lastNovel = self.theProject.tree.findRoot(nwItemClass.NOVEL)
@@ -125,6 +138,7 @@ class GuiNovelView(QWidget):
self.novelBar.buildNovelRootMenu()
self.novelBar.setLastColType(lastCol, doRefresh=False)
self.novelBar.setCurrentRoot(lastNovel)
self.novelBar.setEnabled(True)
return
@@ -135,8 +149,8 @@ class GuiNovelView(QWidget):
self.theProject.options.setValue("GuiNovelView", "lastCol", lastColType)
return
def setFocus(self):
"""Forward the set focus call to the tree widget.
def setTreeFocus(self):
"""Set the focus to the tree widget.
"""
self.novelTree.setFocus()
return
@@ -163,7 +177,7 @@ class GuiNovelView(QWidget):
class GuiNovelToolBar(QWidget):
def __init__(self, novelView):
QTreeWidget.__init__(self, novelView)
super().__init__(parent=novelView)
logger.debug("Initialising GuiNovelToolBar ...")
@@ -173,21 +187,11 @@ class GuiNovelToolBar(QWidget):
self.mainTheme = novelView.mainGui.mainTheme
iPx = self.mainTheme.baseIconSize
mPx = self.mainConf.pxInt(3)
mPx = self.mainConf.pxInt(2)
self.setContentsMargins(0, 0, 0, 0)
self.setAutoFillBackground(True)
qPalette = self.palette()
qPalette.setBrush(QPalette.Window, qPalette.base())
self.setPalette(qPalette)
fadeCol = qPalette.text().color()
buttonStyle = (
"QToolButton {{padding: {0}px; border: none; background: transparent;}} "
"QToolButton:hover {{border: none; background: rgba({1},{2},{3},0.2);}}"
).format(mPx, fadeCol.red(), fadeCol.green(), fadeCol.blue())
# Widget Label
self.viewLabel = QLabel("<b>%s</b>" % self.tr("Novel Outline"))
self.viewLabel.setContentsMargins(0, 0, 0, 0)
@@ -196,9 +200,7 @@ class GuiNovelToolBar(QWidget):
# Refresh Button
self.tbRefresh = QToolButton(self)
self.tbRefresh.setToolTip(self.tr("Refresh"))
self.tbRefresh.setIcon(self.mainTheme.getIcon("refresh"))
self.tbRefresh.setIconSize(QSize(iPx, iPx))
self.tbRefresh.setStyleSheet(buttonStyle)
self.tbRefresh.clicked.connect(self._refreshNovelTree)
# Novel Root Menu
@@ -208,9 +210,7 @@ class GuiNovelToolBar(QWidget):
self.tbRoot = QToolButton(self)
self.tbRoot.setToolTip(self.tr("Novel Root"))
self.tbRoot.setIcon(self.mainTheme.getIcon(nwLabels.CLASS_ICON[nwItemClass.NOVEL]))
self.tbRoot.setIconSize(QSize(iPx, iPx))
self.tbRoot.setStyleSheet(buttonStyle)
self.tbRoot.setMenu(self.mRoot)
self.tbRoot.setPopupMode(QToolButton.InstantPopup)
@@ -227,9 +227,7 @@ class GuiNovelToolBar(QWidget):
self.tbMore = QToolButton(self)
self.tbMore.setToolTip(self.tr("More Options"))
self.tbMore.setIcon(self.mainTheme.getIcon("menu"))
self.tbMore.setIconSize(QSize(iPx, iPx))
self.tbMore.setStyleSheet(buttonStyle)
self.tbMore.setMenu(self.mMore)
self.tbMore.setPopupMode(QToolButton.InstantPopup)
@@ -244,6 +242,8 @@ class GuiNovelToolBar(QWidget):
self.setLayout(self.outerBox)
self.updateTheme()
logger.debug("GuiNovelToolBar initialisation complete")
return
@@ -252,6 +252,31 @@ class GuiNovelToolBar(QWidget):
# Methods
##
def updateTheme(self):
"""Update theme elements.
"""
# Icons
self.tbRefresh.setIcon(self.mainTheme.getIcon("refresh"))
self.tbRoot.setIcon(self.mainTheme.getIcon(nwLabels.CLASS_ICON[nwItemClass.NOVEL]))
self.tbMore.setIcon(self.mainTheme.getIcon("menu"))
qPalette = self.palette()
qPalette.setBrush(QPalette.Window, qPalette.base())
self.setPalette(qPalette)
# StyleSheets
fadeCol = qPalette.text().color()
buttonStyle = (
"QToolButton {{padding: {0}px; border: none; background: transparent;}} "
"QToolButton:hover {{border: none; background: rgba({1},{2},{3},0.2);}}"
).format(self.mainConf.pxInt(2), fadeCol.red(), fadeCol.green(), fadeCol.blue())
self.tbRefresh.setStyleSheet(buttonStyle)
self.tbRoot.setStyleSheet(buttonStyle)
self.tbMore.setStyleSheet(buttonStyle)
return
def clearContent(self):
"""Run clearing project tasks.
"""
@@ -297,7 +322,7 @@ class GuiNovelToolBar(QWidget):
def _refreshNovelTree(self):
"""Rebuild the current tree.
"""
rootHandle = self.theProject.lastNovel
rootHandle = self.theProject.data.getLastHandle("novelTree")
self.novelView.novelTree.refreshTree(rootHandle=rootHandle, overRide=True)
return
@@ -322,10 +347,15 @@ class GuiNovelTree(QTreeWidget):
C_TITLE = 0
C_WORDS = 1
C_LAST = 2
C_EXTRA = 2
C_MORE = 3
D_HANDLE = Qt.UserRole
D_TITLE = Qt.UserRole + 1
D_KEY = Qt.UserRole + 2
def __init__(self, novelView):
QTreeWidget.__init__(self, novelView)
super().__init__(parent=novelView)
logger.debug("Initialising GuiNovelTree ...")
@@ -339,6 +369,7 @@ class GuiNovelTree(QTreeWidget):
self._treeMap = {}
self._lastBuild = 0
self._lastCol = NovelTreeColumn.POV
self._actHandle = None
# Cached Strings
self._povLabel = trConst(nwLabels.KEY_NAME[nwKeyWords.POV_KEY])
@@ -353,9 +384,11 @@ class GuiNovelTree(QTreeWidget):
self.setIconSize(QSize(iPx, iPx))
self.setFrameStyle(QFrame.NoFrame)
self.setUniformRowHeights(True)
self.setAllColumnsShowFocus(True)
self.setHeaderHidden(True)
self.setIndentation(0)
self.setColumnCount(3)
self.setColumnCount(4)
self.setSelectionBehavior(QAbstractItemView.SelectRows)
self.setSelectionMode(QAbstractItemView.SingleSelection)
self.setExpandsOnDoubleClick(False)
@@ -367,7 +400,8 @@ class GuiNovelTree(QTreeWidget):
treeHeader.setMinimumSectionSize(iPx + cMg)
treeHeader.setSectionResizeMode(self.C_TITLE, QHeaderView.Stretch)
treeHeader.setSectionResizeMode(self.C_WORDS, QHeaderView.ResizeToContents)
treeHeader.setSectionResizeMode(self.C_LAST, QHeaderView.ResizeToContents)
treeHeader.setSectionResizeMode(self.C_EXTRA, QHeaderView.ResizeToContents)
treeHeader.setSectionResizeMode(self.C_MORE, QHeaderView.ResizeToContents)
# Pre-Generate Tree Formatting
fH1 = self.font()
@@ -378,20 +412,15 @@ class GuiNovelTree(QTreeWidget):
fH2.setBold(True)
self._hFonts = [self.font(), fH1, fH2, self.font(), self.font()]
self._pIndent = [
self.mainTheme.loadDecoration("deco_doc_h0", pxH=iPx),
self.mainTheme.loadDecoration("deco_doc_h1", pxH=iPx),
self.mainTheme.loadDecoration("deco_doc_h2", pxH=iPx),
self.mainTheme.loadDecoration("deco_doc_h3", pxH=iPx),
self.mainTheme.loadDecoration("deco_doc_h4", pxH=iPx),
]
# Connect signals
self.clicked.connect(self._treeItemClicked)
self.itemDoubleClicked.connect(self._treeDoubleClick)
self.itemSelectionChanged.connect(self._treeSelectionChange)
# Set custom settings
self.initSettings()
self.updateTheme()
logger.debug("GuiNovelTree initialisation complete")
@@ -413,6 +442,13 @@ class GuiNovelTree(QTreeWidget):
return
def updateTheme(self):
"""Update theme elements.
"""
iPx = self.mainTheme.baseIconSize
self._pMore = self.mainTheme.loadDecoration("deco_doc_more", pxH=iPx)
return
##
# Properties
##
@@ -436,23 +472,23 @@ class GuiNovelTree(QTreeWidget):
def refreshTree(self, rootHandle=None, overRide=False):
"""Called whenever the Novel tab is activated.
"""
logger.verbose("Requesting refresh of the novel tree")
logger.debug("Requesting refresh of the novel tree")
if rootHandle is None:
rootHandle = self.theProject.tree.findRoot(nwItemClass.NOVEL)
treeChanged = self.mainGui.projView.changedSince(self._lastBuild)
indexChanged = self.theProject.index.rootChangedSince(rootHandle, self._lastBuild)
if not (treeChanged or indexChanged or overRide):
logger.verbose("No changes have been made to the novel index")
logger.debug("No changes have been made to the novel index")
return
selItem = self.selectedItems()
titleKey = None
if selItem:
titleKey = selItem[0].data(self.C_TITLE, Qt.UserRole)[2]
titleKey = selItem[0].data(self.C_TITLE, self.D_KEY)
self._populateTree(rootHandle)
self.theProject.setLastNovelViewed(rootHandle)
self.theProject.data.setLastHandle(rootHandle, "novelTree")
if titleKey is not None and titleKey in self._treeMap:
self._treeMap[titleKey].setSelected(True)
@@ -476,8 +512,9 @@ class GuiNovelTree(QTreeWidget):
tHandle = None
tLine = 0
if selItem:
tHandle = selItem[0].data(self.C_TITLE, Qt.UserRole)[0]
tLine = checkInt(selItem[0].data(self.C_TITLE, Qt.UserRole)[1], 1) - 1
tHandle = selItem[0].data(self.C_TITLE, self.D_HANDLE)
sTitle = selItem[0].data(self.C_TITLE, self.D_TITLE)
tLine = checkInt(sTitle[1:], 1) - 1
return tHandle, tLine
@@ -487,9 +524,34 @@ class GuiNovelTree(QTreeWidget):
if self._lastCol != colType:
logger.debug("Changing last column to %s", colType.name)
self._lastCol = colType
self.setColumnHidden(self.C_LAST, colType == NovelTreeColumn.HIDDEN)
self.setColumnHidden(self.C_EXTRA, colType == NovelTreeColumn.HIDDEN)
if doRefresh:
self.refreshTree(rootHandle=self.theProject.lastNovel, overRide=True)
lastNovel = self.theProject.data.getLastHandle("novelTree")
self.refreshTree(rootHandle=lastNovel, overRide=True)
return
def setActiveHandle(self, tHandle):
"""Highlight the rows associated with a given handle.
"""
tStart = time()
self._actHandle = tHandle
for i in range(self.topLevelItemCount()):
tItem = self.topLevelItem(i)
if tItem is not None:
if tItem.data(self.C_TITLE, self.D_HANDLE) == tHandle:
tItem.setBackground(self.C_TITLE, self.palette().alternateBase())
tItem.setBackground(self.C_WORDS, self.palette().alternateBase())
tItem.setBackground(self.C_EXTRA, self.palette().alternateBase())
tItem.setBackground(self.C_MORE, self.palette().alternateBase())
else:
tItem.setBackground(self.C_TITLE, self.palette().base())
tItem.setBackground(self.C_WORDS, self.palette().base())
tItem.setBackground(self.C_EXTRA, self.palette().base())
tItem.setBackground(self.C_MORE, self.palette().base())
logger.debug("Highlighted Novel Tree in %.3f ms", (time() - tStart)*1000)
return
##
@@ -501,7 +563,7 @@ class GuiNovelTree(QTreeWidget):
mouse in a blank area of the tree view, and to load a document
for viewing if the user middle-clicked.
"""
QTreeWidget.mousePressEvent(self, theEvent)
super().mousePressEvent(theEvent)
if theEvent.button() == Qt.LeftButton:
selItem = self.indexAt(theEvent.pos())
@@ -521,10 +583,28 @@ class GuiNovelTree(QTreeWidget):
return
def focusOutEvent(self, theEvent):
"""Clear the selection when the tree no longer has focus.
"""
super().focusOutEvent(theEvent)
self.clearSelection()
return
##
# Private Slots
##
@pyqtSlot("QModelIndex")
def _treeItemClicked(self, mIndex):
"""The user clicked on an item in the tree.
"""
if mIndex.column() == self.C_MORE:
tHandle = mIndex.siblingAtColumn(self.C_TITLE).data(self.D_HANDLE)
sTitle = mIndex.siblingAtColumn(self.C_TITLE).data(self.D_TITLE)
tipPos = self.mapToGlobal(self.visualRect(mIndex).topRight())
self._popMetaBox(tipPos, tHandle, sTitle)
return
@pyqtSlot()
def _treeSelectionChange(self):
"""Extract the handle and line number of the currently selected
@@ -554,7 +634,7 @@ class GuiNovelTree(QTreeWidget):
"""
self.clearContent()
tStart = time()
logger.verbose("Building novel tree for root item '%s'", rootHandle)
logger.debug("Building novel tree for root item '%s'", rootHandle)
novStruct = self.theProject.index.novelStructure(rootHandle=rootHandle, skipExcl=True)
for tKey, tHandle, sTitle, novIdx in novStruct:
@@ -563,25 +643,31 @@ class GuiNovelTree(QTreeWidget):
if iLevel == 0:
continue
newItem = QTreeWidgetItem()
theData = (tHandle, sTitle[1:].lstrip("0"), tKey)
hDec = self.mainTheme.getHeaderDecoration(iLevel)
newItem.setData(self.C_TITLE, Qt.DecorationRole, self._pIndent[iLevel])
newItem = QTreeWidgetItem()
newItem.setData(self.C_TITLE, Qt.DecorationRole, hDec)
newItem.setText(self.C_TITLE, novIdx.title)
newItem.setData(self.C_TITLE, Qt.UserRole, theData)
newItem.setData(self.C_TITLE, self.D_HANDLE, tHandle)
newItem.setData(self.C_TITLE, self.D_TITLE, sTitle)
newItem.setData(self.C_TITLE, self.D_KEY, tKey)
newItem.setFont(self.C_TITLE, self._hFonts[iLevel])
newItem.setText(self.C_WORDS, f"{novIdx.wordCount:n}")
newItem.setTextAlignment(self.C_WORDS, Qt.AlignRight)
newItem.setData(self.C_MORE, Qt.DecorationRole, self._pMore)
# Custom column
lastText, toolTip = self._getLastColumnText(tHandle, sTitle)
newItem.setText(self.C_LAST, lastText)
newItem.setText(self.C_EXTRA, lastText)
if lastText:
newItem.setToolTip(self.C_LAST, toolTip)
newItem.setToolTip(self.C_EXTRA, toolTip)
self._treeMap[tKey] = newItem
self.addTopLevelItem(newItem)
logger.verbose("Novel Tree built in %.3f ms", (time() - tStart)*1000)
self.setActiveHandle(self._actHandle)
logger.debug("Novel Tree built in %.3f ms", (time() - tStart)*1000)
self._lastBuild = time()
return
@@ -607,4 +693,49 @@ class GuiNovelTree(QTreeWidget):
return "", ""
def _popMetaBox(self, qPos, tHandle, sTitle):
"""Show the novel meta data box.
"""
logger.debug("Generating meta data tooltip for '%s:%s'", tHandle, sTitle)
pIndex = self.theProject.index
novIdx = pIndex.getNovelData(tHandle, sTitle)
refTags = pIndex.getReferences(tHandle, sTitle)
synopText = novIdx.synopsis
if synopText:
synopLabel = trConst(nwLabels.OUTLINE_COLS[nwOutline.SYNOP])
synopText = f"<p><b>{synopLabel}</b>: {synopText}</p>"
refLines = []
refLines = self._appendMetaTag(refTags, nwKeyWords.POV_KEY, refLines)
refLines = self._appendMetaTag(refTags, nwKeyWords.FOCUS_KEY, refLines)
refLines = self._appendMetaTag(refTags, nwKeyWords.CHAR_KEY, refLines)
refLines = self._appendMetaTag(refTags, nwKeyWords.PLOT_KEY, refLines)
refLines = self._appendMetaTag(refTags, nwKeyWords.TIME_KEY, refLines)
refLines = self._appendMetaTag(refTags, nwKeyWords.WORLD_KEY, refLines)
refLines = self._appendMetaTag(refTags, nwKeyWords.OBJECT_KEY, refLines)
refLines = self._appendMetaTag(refTags, nwKeyWords.ENTITY_KEY, refLines)
refLines = self._appendMetaTag(refTags, nwKeyWords.CUSTOM_KEY, refLines)
refText = ""
if refLines:
refList = "<br>".join(refLines)
refText = f"<p>{refList}</p>"
ttText = refText + synopText or self.tr("No meta data")
if ttText:
QToolTip.showText(qPos, ttText)
return
@staticmethod
def _appendMetaTag(refs, key, lines):
"""Generate a reference list for a given reference key.
"""
tags = ", ".join(refs.get(key, []))
if tags:
lines.append(f"<b>{trConst(nwLabels.KEY_NAME[key])}</b>: {tags}")
return lines
# END Class GuiNovelTree
+164 -137
View File
@@ -37,16 +37,16 @@ from PyQt5.QtCore import (
Qt, pyqtSignal, pyqtSlot, QSize, QT_TRANSLATE_NOOP
)
from PyQt5.QtWidgets import (
QAbstractItemView, QAction, QGridLayout, QGroupBox, QHBoxLayout, QLabel,
QMenu, QScrollArea, QSplitter, QTreeWidget, QTreeWidgetItem, QVBoxLayout,
QWidget, QFrame, QToolBar, QSizePolicy, QComboBox, QToolButton
QAbstractItemView, QAction, QComboBox, QFrame, QGridLayout, QGroupBox,
QHBoxLayout, QLabel, QMenu, QScrollArea, QSizePolicy, QSplitter, QToolBar,
QToolButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget
)
from novelwriter.enum import (
nwDocMode, nwItemClass, nwItemLayout, nwItemType, nwOutline
)
from novelwriter.common import checkInt
from novelwriter.constants import trConst, nwKeyWords, nwLabels
from novelwriter.constants import nwHeaders, trConst, nwKeyWords, nwLabels
logger = logging.getLogger(__name__)
@@ -57,20 +57,22 @@ class GuiOutlineView(QWidget):
loadDocumentTagRequest = pyqtSignal(str, Enum)
def __init__(self, mainGui):
QWidget.__init__(self, mainGui)
super().__init__(parent=mainGui)
self.mainConf = novelwriter.CONFIG
self.mainGui = mainGui
self.mainConf = novelwriter.CONFIG
self.mainGui = mainGui
self.theProject = mainGui.theProject
# Build GUI
self.outlineBar = GuiOutlineToolBar(self)
self.outlineTree = GuiOutlineTree(self)
self.outlineData = GuiOutlineDetails(self)
self.outlineBar = GuiOutlineToolBar(self)
self.outlineBar.setEnabled(False)
self.splitOutline = QSplitter(Qt.Vertical)
self.splitOutline.addWidget(self.outlineTree)
self.splitOutline.addWidget(self.outlineData)
self.splitOutline.setSizes(self.mainConf.getOutlinePanePos())
self.splitOutline.setSizes(self.mainConf.outlinePanePos)
# Assemble
self.outerBox = QVBoxLayout()
@@ -96,33 +98,67 @@ class GuiOutlineView(QWidget):
# Methods
##
def splitSizes(self):
return self.splitOutline.sizes()
def updateTheme(self):
"""Update theme elements.
"""
self.outlineBar.updateTheme()
self.refreshTree()
return
def clearOutline(self):
def initSettings(self):
"""Initialise GUI elements that depend on specific settings.
"""
self.outlineTree.initSettings()
self.outlineData.initSettings()
return
def refreshTree(self):
"""Refresh the current tree.
"""
self.outlineTree.refreshTree(rootHandle=self.theProject.data.getLastHandle("outline"))
return
def clearProject(self):
"""Clear project-related GUI content.
"""
self.outlineData.clearDetails()
self.outlineBar.setEnabled(False)
return
def initOutline(self):
self.outlineTree.initOutline()
self.outlineData.initDetails()
def openProjectTasks(self):
"""Run open project tasks.
"""
lastOutline = self.theProject.data.getLastHandle("outline")
if not (lastOutline in self.theProject.tree or lastOutline is None):
lastOutline = self.theProject.tree.findRoot(nwItemClass.NOVEL)
logger.debug("Setting outline tree to root item '%s'", lastOutline)
self.clearProject()
self.outlineBar.populateNovelList()
self.outlineBar.setCurrentRoot(lastOutline)
self.outlineBar.setEnabled(True)
return
def closeOutline(self):
self.outlineTree.closeOutline()
def closeProjectTasks(self):
self.outlineTree.closeProjectTasks()
self.outlineData.updateClasses()
return
def refreshView(self, overRide=False, novelChanged=False):
self.outlineTree.refreshTree(overRide=overRide, novelChanged=novelChanged)
return
def treeHasFocus(self):
return self.outlineTree.hasFocus()
def splitSizes(self):
return self.splitOutline.sizes()
def setTreeFocus(self):
"""Set the focus to the tree widget.
"""
return self.outlineTree.setFocus()
def treeHasFocus(self):
"""Check if the outline tree has focus.
"""
return self.outlineTree.hasFocus()
##
# Public Slots
##
@@ -172,7 +208,7 @@ class GuiOutlineToolBar(QToolBar):
viewColumnToggled = pyqtSignal(bool, Enum)
def __init__(self, theOutline):
QTreeWidget.__init__(self, theOutline)
super().__init__(parent=theOutline)
logger.debug("Initialising GuiOutlineToolBar ...")
@@ -187,7 +223,6 @@ class GuiOutlineToolBar(QToolBar):
self.setMovable(False)
self.setIconSize(QSize(iPx, iPx))
self.setContentsMargins(0, 0, 0, 0)
self.setStyleSheet("QToolBar {border: 0px;}")
stretch = QWidget(self)
stretch.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
@@ -202,7 +237,6 @@ class GuiOutlineToolBar(QToolBar):
# Actions
self.aRefresh = QAction(self.tr("Refresh"), self)
self.aRefresh.setIcon(self.mainTheme.getIcon("refresh"))
self.aRefresh.triggered.connect(self._refreshRequested)
# Column Menu
@@ -212,7 +246,6 @@ class GuiOutlineToolBar(QToolBar):
)
self.tbColumns = QToolButton(self)
self.tbColumns.setIcon(self.mainTheme.getIcon("menu"))
self.tbColumns.setMenu(self.mColumns)
self.tbColumns.setPopupMode(QToolButton.InstantPopup)
@@ -224,6 +257,8 @@ class GuiOutlineToolBar(QToolBar):
self.addWidget(self.tbColumns)
self.addWidget(stretch)
self.updateTheme()
logger.debug("GuiOutlineToolBar initialisation complete")
return
@@ -232,6 +267,16 @@ class GuiOutlineToolBar(QToolBar):
# Methods
##
def updateTheme(self):
"""Update theme elements.
"""
self.setStyleSheet("QToolBar {border: 0px;}")
self.aRefresh.setIcon(self.mainTheme.getIcon("refresh"))
self.tbColumns.setIcon(self.mainTheme.getIcon("menu"))
return
def populateNovelList(self):
"""Fill the novel combo box with a list of all novel folders.
"""
@@ -243,6 +288,17 @@ class GuiOutlineToolBar(QToolBar):
self.novelValue.addItem(tIcon, self.tr("All Novel Folders"), "")
return
def setCurrentRoot(self, rootHandle):
"""Set the current active root handle.
"""
if rootHandle is None:
rootIdx = self.novelValue.count() - 1
else:
rootIdx = self.novelValue.findData(rootHandle)
if rootIdx >= 0:
self.novelValue.setCurrentIndex(rootIdx)
return
def setColumnHiddenState(self, hiddenState):
"""Forward the change of column hidden states to the menu.
"""
@@ -313,11 +369,14 @@ class GuiOutlineTree(QTreeWidget):
nwOutline.SYNOP: False,
}
D_HANDLE = Qt.UserRole
D_TITLE = Qt.UserRole + 1
hiddenStateChanged = pyqtSignal()
activeItemChanged = pyqtSignal(str, str)
def __init__(self, theOutline):
QTreeWidget.__init__(self, theOutline)
super().__init__(parent=theOutline)
logger.debug("Initialising GuiOutlineTree ...")
@@ -326,6 +385,7 @@ class GuiOutlineTree(QTreeWidget):
self.theProject = theOutline.mainGui.theProject
self.mainTheme = theOutline.mainGui.mainTheme
self.setUniformRowHeights(True)
self.setFrameStyle(QFrame.NoFrame)
self.setSelectionBehavior(QAbstractItemView.SelectRows)
self.setSelectionMode(QAbstractItemView.SingleSelection)
@@ -336,11 +396,28 @@ class GuiOutlineTree(QTreeWidget):
iPx = self.mainTheme.baseIconSize
self.setIconSize(QSize(iPx, iPx))
self.setIndentation(iPx)
self.setIndentation(0)
self.treeHead = self.header()
self.treeHead.sectionMoved.connect(self._columnMoved)
# Pre-Generate Tree Formatting
fH1 = self.font()
fH1.setBold(True)
fH1.setUnderline(True)
fH2 = self.font()
fH2.setBold(True)
self._hFonts = [self.font(), fH1, fH2, self.font(), self.font()]
self._dIcon = {
"H0": self.mainTheme.getItemIcon(nwItemType.FILE, None, nwItemLayout.DOCUMENT, "H0"),
"H1": self.mainTheme.getItemIcon(nwItemType.FILE, None, nwItemLayout.DOCUMENT, "H1"),
"H2": self.mainTheme.getItemIcon(nwItemType.FILE, None, nwItemLayout.DOCUMENT, "H2"),
"H3": self.mainTheme.getItemIcon(nwItemType.FILE, None, nwItemLayout.DOCUMENT, "H3"),
"H4": self.mainTheme.getItemIcon(nwItemType.FILE, None, nwItemLayout.DOCUMENT, "H4"),
}
# Internals
self._treeOrder = []
self._colWidth = {}
@@ -350,8 +427,8 @@ class GuiOutlineTree(QTreeWidget):
self._firstView = True
self._lastBuild = 0
self.initOutline()
self.clearOutline()
self.initSettings()
self.clearContent()
self.hiddenStateChanged.emit()
@@ -371,7 +448,7 @@ class GuiOutlineTree(QTreeWidget):
# Methods
##
def initOutline(self):
def initSettings(self):
"""Set or update outline settings.
"""
# Scroll bars
@@ -387,7 +464,7 @@ class GuiOutlineTree(QTreeWidget):
return
def clearOutline(self):
def clearContent(self):
"""Clear the tree and header and set the default values for the
columns arrays.
"""
@@ -425,18 +502,20 @@ class GuiOutlineTree(QTreeWidget):
# If the novel index or novel tree has changed since the tree
# was last built, we rebuild the tree from the updated index.
indexChanged = self.theProject.index.rootChangedSince(rootHandle, self._lastBuild)
doBuild = (novelChanged or indexChanged) and self.theProject.autoOutline
if doBuild or overRide:
logger.debug("Rebuilding Project Outline")
self._populateTree(rootHandle)
if not (novelChanged or indexChanged or overRide):
logger.debug("No changes have been made to the novel index")
return
self._populateTree(rootHandle)
self.theProject.data.setLastHandle(rootHandle or None, "outline")
return
def closeOutline(self):
def closeProjectTasks(self):
"""Called before a project is closed.
"""
self._saveHeaderState()
self.clearOutline()
self.clearContent()
self._firstView = True
return
@@ -448,7 +527,7 @@ class GuiOutlineTree(QTreeWidget):
tHandle = None
tLine = 0
if selItem:
tHandle = selItem[0].data(self._colIdx[nwOutline.TITLE], Qt.UserRole)
tHandle = selItem[0].data(self._colIdx[nwOutline.TITLE], self.D_HANDLE)
tLine = checkInt(selItem[0].text(self._colIdx[nwOutline.LINE]), 1) - 1
return tHandle, tLine
@@ -474,8 +553,8 @@ class GuiOutlineTree(QTreeWidget):
"""
selItems = self.selectedItems()
if selItems:
tHandle = selItems[0].data(self._colIdx[nwOutline.TITLE], Qt.UserRole)
sTitle = selItems[0].data(self._colIdx[nwOutline.LINE], Qt.UserRole)
tHandle = selItems[0].data(self._colIdx[nwOutline.TITLE], self.D_HANDLE)
sTitle = selItems[0].data(self._colIdx[nwOutline.TITLE], self.D_TITLE)
self.activeItemChanged.emit(tHandle, sTitle)
return
@@ -494,11 +573,9 @@ class GuiOutlineTree(QTreeWidget):
"""Receive the changes to column visibility forwarded by the
column selection menu.
"""
logger.verbose("User toggled Outline column '%s'", theItem.name)
if theItem in self._colIdx:
self.setColumnHidden(self._colIdx[theItem], not isChecked)
self._saveHeaderState()
return
##
@@ -613,110 +690,60 @@ class GuiOutlineTree(QTreeWidget):
self.setColumnWidth(self._colIdx[hItem], self._colWidth[hItem])
self.setColumnHidden(self._colIdx[hItem], self._colHidden[hItem])
# Make sure title column is always visible,
# and handle column always hidden
# Make sure title column is always visible
self.setColumnHidden(self._colIdx[nwOutline.TITLE], False)
headItem = self.headerItem()
headItem.setTextAlignment(self._colIdx[nwOutline.CCOUNT], Qt.AlignRight)
headItem.setTextAlignment(self._colIdx[nwOutline.WCOUNT], Qt.AlignRight)
headItem.setTextAlignment(self._colIdx[nwOutline.PCOUNT], Qt.AlignRight)
currTitle = None
currChapter = None
currScene = None
if headItem is not None:
headItem.setTextAlignment(self._colIdx[nwOutline.CCOUNT], Qt.AlignRight)
headItem.setTextAlignment(self._colIdx[nwOutline.WCOUNT], Qt.AlignRight)
headItem.setTextAlignment(self._colIdx[nwOutline.PCOUNT], Qt.AlignRight)
novStruct = self.theProject.index.novelStructure(rootHandle=rootHandle, skipExcl=True)
for _, tHandle, sTitle, novIdx in novStruct:
tItem = self._createTreeItem(tHandle, sTitle, novIdx)
iLevel = nwHeaders.H_LEVEL.get(novIdx.level, 0)
if iLevel == 0:
continue
tLevel = novIdx.level
if tLevel == "H1":
self.addTopLevelItem(tItem)
currTitle = tItem
currChapter = None
currScene = None
trItem = QTreeWidgetItem()
nwItem = self.theProject.tree[tHandle]
hDec = self.mainTheme.getHeaderDecoration(iLevel)
elif tLevel == "H2":
if currTitle is None:
self.addTopLevelItem(tItem)
else:
currTitle.addChild(tItem)
currChapter = tItem
currScene = None
trItem.setData(self._colIdx[nwOutline.TITLE], Qt.DecorationRole, hDec)
trItem.setText(self._colIdx[nwOutline.TITLE], novIdx.title)
trItem.setData(self._colIdx[nwOutline.TITLE], self.D_HANDLE, tHandle)
trItem.setData(self._colIdx[nwOutline.TITLE], self.D_TITLE, sTitle)
trItem.setFont(self._colIdx[nwOutline.TITLE], self._hFonts[iLevel])
trItem.setText(self._colIdx[nwOutline.LEVEL], novIdx.level)
trItem.setIcon(self._colIdx[nwOutline.LABEL], self._dIcon[nwItem.mainHeading])
trItem.setText(self._colIdx[nwOutline.LABEL], nwItem.itemName)
trItem.setText(self._colIdx[nwOutline.LINE], sTitle[1:].lstrip("0"))
trItem.setText(self._colIdx[nwOutline.SYNOP], novIdx.synopsis)
trItem.setText(self._colIdx[nwOutline.CCOUNT], f"{novIdx.charCount:n}")
trItem.setText(self._colIdx[nwOutline.WCOUNT], f"{novIdx.wordCount:n}")
trItem.setText(self._colIdx[nwOutline.PCOUNT], f"{novIdx.paraCount:n}")
trItem.setTextAlignment(self._colIdx[nwOutline.CCOUNT], Qt.AlignRight)
trItem.setTextAlignment(self._colIdx[nwOutline.WCOUNT], Qt.AlignRight)
trItem.setTextAlignment(self._colIdx[nwOutline.PCOUNT], Qt.AlignRight)
elif tLevel == "H3":
if currChapter is None:
if currTitle is None:
self.addTopLevelItem(tItem)
else:
currTitle.addChild(tItem)
else:
currChapter.addChild(tItem)
currScene = tItem
refs = self.theProject.index.getReferences(tHandle, sTitle)
trItem.setText(self._colIdx[nwOutline.POV], ", ".join(refs[nwKeyWords.POV_KEY]))
trItem.setText(self._colIdx[nwOutline.FOCUS], ", ".join(refs[nwKeyWords.FOCUS_KEY]))
trItem.setText(self._colIdx[nwOutline.CHAR], ", ".join(refs[nwKeyWords.CHAR_KEY]))
trItem.setText(self._colIdx[nwOutline.PLOT], ", ".join(refs[nwKeyWords.PLOT_KEY]))
trItem.setText(self._colIdx[nwOutline.TIME], ", ".join(refs[nwKeyWords.TIME_KEY]))
trItem.setText(self._colIdx[nwOutline.WORLD], ", ".join(refs[nwKeyWords.WORLD_KEY]))
trItem.setText(self._colIdx[nwOutline.OBJECT], ", ".join(refs[nwKeyWords.OBJECT_KEY]))
trItem.setText(self._colIdx[nwOutline.ENTITY], ", ".join(refs[nwKeyWords.ENTITY_KEY]))
trItem.setText(self._colIdx[nwOutline.CUSTOM], ", ".join(refs[nwKeyWords.CUSTOM_KEY]))
elif tLevel == "H4":
if currScene is None:
if currChapter is None:
if currTitle is None:
self.addTopLevelItem(tItem)
else:
currTitle.addChild(tItem)
else:
currChapter.addChild(tItem)
else:
currScene.addChild(tItem)
tItem.setExpanded(True)
self.addTopLevelItem(trItem)
self._lastBuild = time()
return
def _createTreeItem(self, tHandle, sTitle, novIdx):
"""Populate a tree item with all the column values.
"""
nwItem = self.theProject.tree[tHandle]
newItem = QTreeWidgetItem()
hIcon = "doc_%s" % novIdx.level.lower()
hLevel = self.theProject.index.getHandleHeaderLevel(tHandle)
dIcon = self.mainTheme.getItemIcon(nwItemType.FILE, None, nwItemLayout.DOCUMENT, hLevel)
cC = int(novIdx.charCount)
wC = int(novIdx.wordCount)
pC = int(novIdx.paraCount)
newItem.setText(self._colIdx[nwOutline.TITLE], novIdx.title)
newItem.setData(self._colIdx[nwOutline.TITLE], Qt.UserRole, tHandle)
newItem.setIcon(self._colIdx[nwOutline.TITLE], self.mainTheme.getIcon(hIcon))
newItem.setText(self._colIdx[nwOutline.LEVEL], novIdx.level)
newItem.setText(self._colIdx[nwOutline.LABEL], nwItem.itemName)
newItem.setIcon(self._colIdx[nwOutline.LABEL], dIcon)
newItem.setText(self._colIdx[nwOutline.LINE], sTitle[1:].lstrip("0"))
newItem.setData(self._colIdx[nwOutline.LINE], Qt.UserRole, sTitle)
newItem.setText(self._colIdx[nwOutline.SYNOP], novIdx.synopsis)
newItem.setText(self._colIdx[nwOutline.CCOUNT], f"{cC:n}")
newItem.setText(self._colIdx[nwOutline.WCOUNT], f"{wC:n}")
newItem.setText(self._colIdx[nwOutline.PCOUNT], f"{pC:n}")
newItem.setTextAlignment(self._colIdx[nwOutline.CCOUNT], Qt.AlignRight)
newItem.setTextAlignment(self._colIdx[nwOutline.WCOUNT], Qt.AlignRight)
newItem.setTextAlignment(self._colIdx[nwOutline.PCOUNT], Qt.AlignRight)
theRefs = self.theProject.index.getReferences(tHandle, sTitle)
newItem.setText(self._colIdx[nwOutline.POV], ", ".join(theRefs[nwKeyWords.POV_KEY]))
newItem.setText(self._colIdx[nwOutline.FOCUS], ", ".join(theRefs[nwKeyWords.FOCUS_KEY]))
newItem.setText(self._colIdx[nwOutline.CHAR], ", ".join(theRefs[nwKeyWords.CHAR_KEY]))
newItem.setText(self._colIdx[nwOutline.PLOT], ", ".join(theRefs[nwKeyWords.PLOT_KEY]))
newItem.setText(self._colIdx[nwOutline.TIME], ", ".join(theRefs[nwKeyWords.TIME_KEY]))
newItem.setText(self._colIdx[nwOutline.WORLD], ", ".join(theRefs[nwKeyWords.WORLD_KEY]))
newItem.setText(self._colIdx[nwOutline.OBJECT], ", ".join(theRefs[nwKeyWords.OBJECT_KEY]))
newItem.setText(self._colIdx[nwOutline.ENTITY], ", ".join(theRefs[nwKeyWords.ENTITY_KEY]))
newItem.setText(self._colIdx[nwOutline.CUSTOM], ", ".join(theRefs[nwKeyWords.CUSTOM_KEY]))
return newItem
# END Class GuiOutlineTree
@@ -725,7 +752,7 @@ class GuiOutlineHeaderMenu(QMenu):
columnToggled = pyqtSignal(bool, Enum)
def __init__(self, theOutline):
QMenu.__init__(self, theOutline)
super().__init__(parent=theOutline)
self.acceptToggle = True
@@ -776,7 +803,7 @@ class GuiOutlineDetails(QScrollArea):
itemTagClicked = pyqtSignal(str)
def __init__(self, theOutline):
QScrollArea.__init__(self, theOutline)
super().__init__(parent=theOutline)
logger.debug("Initialising GuiOutlineDetails ...")
@@ -963,13 +990,13 @@ class GuiOutlineDetails(QScrollArea):
self.setWidgetResizable(True)
self.setFrameStyle(QFrame.NoFrame)
self.initDetails()
self.initSettings()
logger.debug("GuiOutlineDetails initialisation complete")
return
def initDetails(self):
def initSettings(self):
"""Set or update outline settings.
"""
# Scroll bars
@@ -1032,7 +1059,7 @@ class GuiOutlineDetails(QScrollArea):
self.titleLabel.setText("<b>%s</b>" % self.tr("Title"))
self.titleValue.setText(novIdx.title)
itemStatus, _ = nwItem.getImportStatus()
itemStatus, _ = nwItem.getImportStatus(incIcon=False)
self.fileValue.setText(nwItem.itemName)
self.itemValue.setText(itemStatus)

Some files were not shown because too many files have changed in this diff Show More