diff --git a/.github/workflows/test_linux.yml b/.github/workflows/test_linux.yml index ad331896..6dcf160f 100644 --- a/.github/workflows/test_linux.yml +++ b/.github/workflows/test_linux.yml @@ -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) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1b08033..84be59b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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** diff --git a/README.md b/README.md index 7b0a9965..d0e13b6f 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/source/images/screenshot_dark.png b/docs/source/images/screenshot_dark.png index 4555806f..8fb3d728 100644 Binary files a/docs/source/images/screenshot_dark.png and b/docs/source/images/screenshot_dark.png differ diff --git a/docs/source/images/screenshot_default.png b/docs/source/images/screenshot_default.png index d6bd2769..f3ce1e59 100644 Binary files a/docs/source/images/screenshot_default.png and b/docs/source/images/screenshot_default.png differ diff --git a/docs/source/images/screenshot_multi.png b/docs/source/images/screenshot_multi.png index a06922fd..cbbcae21 100644 Binary files a/docs/source/images/screenshot_multi.png and b/docs/source/images/screenshot_multi.png differ diff --git a/docs/source/int_customise.rst b/docs/source/int_customise.rst index c5c26e3b..f77cc9cc 100644 --- a/docs/source/int_customise.rst +++ b/docs/source/int_customise.rst @@ -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 `_` documentation for more +`The Style Sheet Syntax `_ documentation for more details. diff --git a/docs/source/int_source.rst b/docs/source/int_source.rst index 86fb3b1e..879700a5 100644 --- a/docs/source/int_source.rst +++ b/docs/source/int_source.rst @@ -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. diff --git a/docs/source/tech_locations.rst b/docs/source/tech_locations.rst index 8f1d4db6..f3003e37 100644 --- a/docs/source/tech_locations.rst +++ b/docs/source/tech_locations.rst @@ -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: diff --git a/docs/source/usage_shortcuts.rst b/docs/source/usage_shortcuts.rst index e9e7bb36..8254dfe0 100644 --- a/docs/source/usage_shortcuts.rst +++ b/docs/source/usage_shortcuts.rst @@ -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." diff --git a/i18n/nw_base.ts b/i18n/nw_base.ts index 8d7e2940..d4241a2b 100644 --- a/i18n/nw_base.ts +++ b/i18n/nw_base.ts @@ -4,72 +4,72 @@ Common - + in the future - + just now - + a minute ago - + {0} minutes ago - + an hour ago - + {0} hours ago - + a day ago - + {0} days ago - + a week ago - + {0} weeks ago - + a month ago - + {0} months ago - + a year ago - + {0} years ago @@ -77,259 +77,264 @@ Constant - - - + + + None - + Novel - - + + Plot - - + + Characters - - + + Locations - - + + Timeline - - + + Objects - - + + Entities - - + + Custom - + Archive - + Trash - - + + Novel Document + - Project Note - + Root Folder - + Folder - + Novel Title Page - + Novel Chapter - + Novel Scene - + + Novel Section + + + + Tag - + Point of View - - + + Focus - + Title - + Level - + Document - + Line - + Chars - + Words - + Pars - + POV - + Synopsis - + Straight single quotation mark - + Straight double quotation mark - + Left single quotation mark - + Right single quotation mark - + Single low-9 quotation mark - + Single high-reversed-9 quotation mark - + Left double quotation mark - + Right double quotation mark - + Double low-9 quotation mark - + Double high-reversed-9 quotation mark - + Double low-reversed-9 quotation mark - + Single left-pointing angle quotation mark - + Single right-pointing angle quotation mark - + Double left-pointing angle quotation mark - + Double right-pointing angle quotation mark - + Left corner bracket - + Right corner bracket - + Left white corner bracket - + Right white corner bracket @@ -337,105 +342,105 @@ GuiAbout - - + + About novelWriter - + About - + Release - - - - - - Licence - - - - - Website: {0} - - - - - Credits - - - - - Developer - - - - - Concept - - - - - i18n - - - - - novelWriter is a markdown-like text editor designed for organising and writing novels. It is written in Python 3 with a Qt5 GUI, using PyQt5. - - - - - novelWriter 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. - - - - - novelWriter 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 Licence tab for the full licence text, or visit the GNU website at {0} for more details. - - - - - Translations - - - - - Theme: {0} - - - - - - - Author - - + + Licence + + + + + Website: {0} + + + + + Credits + + + + + Developer + + + + + Concept + + + + + i18n + + + + + novelWriter is a markdown-like text editor designed for organising and writing novels. It is written in Python 3 with a Qt5 GUI, using PyQt5. + + + + + novelWriter 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. + + + + + novelWriter 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 Licence tab for the full licence text, or visit the GNU website at {0} for more details. + + + + + Translations + + + + + Theme: {0} + + + + + + + Author + + + + + + Credit - + Icons: {0} - + Syntax: {0} @@ -443,7 +448,7 @@ GuiBuildNovel - + Build Novel Project @@ -604,176 +609,181 @@ + Root Filter Options + + + + File Filter Options - + Include novel files - + Include note files - - - Ignore export flag - - - - - Export Options - - - - - Replace tabs with spaces - - - Replace Unicode in HTML + Include inactive files - Build Preview - - - - - Print + Export Options - Print Preview + Replace tabs with spaces - - Print to PDF + + Replace Unicode in HTML - - Save As + + Build Preview - - Open Document (.odt) - - - - - Flat Open Document (.fodt) - - - - - novelWriter HTML (.htm) - - - - - novelWriter Markdown (.nwd) + + Print - Standard Markdown (.md) + Print Preview + Print to PDF + + + + + Save As + + + + + Open Document (.odt) + + + + + Flat Open Document (.fodt) + + + + + novelWriter HTML (.htm) + + + + + novelWriter Markdown (.nwd) + + + + + Standard Markdown (.md) + + + + GitHub Markdown (.md) - + JSON + novelWriter HTML (.json) - + JSON + novelWriter Markdown (.json) - + Close - + Failed to generate preview. The result is too big. - + There were problems when building the project: - + Open Document - + Flat Open Document - + Plain HTML - + novelWriter Markdown - + Standard Markdown - + GitHub Markdown - + JSON + novelWriter HTML - + JSON + novelWriter Markdown - + PDF - + Save Document As - + {0} file successfully written to: - + Failed to write {0} file. {1} @@ -781,17 +791,17 @@ GuiBuildNovelDocView - + This area will show the content of the document to be exported or printed. Press the "Build Preview" button to generate content. - + Unknown - + Build Time: @@ -799,32 +809,32 @@ GuiDocEditFooter - + Status - + Line: {0} ({1}) - + Words: {0} ({1}) - + Document size is {0} bytes - + Words: {0} selected - + Character count: {0} @@ -832,22 +842,22 @@ GuiDocEditHeader - - Edit document meta + + Edit document label - + Search document - + Toggle Focus Mode - + Close the document @@ -855,58 +865,58 @@ GuiDocEditSearch - - + + Search - + Replace - + Case Sensitive - + Whole Words Only - + RegEx Mode - + Loop Search - + Search Next File - + Preserve Case - + Close Search - + Find in current document - + Find and replace in current document @@ -914,117 +924,117 @@ GuiDocEditor - + The document you are trying to open is too big. The document size is {0} MB. The maximum size allowed is {1} MB. - + Opened Document: {0} - + The text you are trying to add is too big. The text size is {0} MB. The maximum size allowed is {1} MB. - + File Changed on Disk - + This document has been changed outside of novelWriter while it was open. Overwrite the file on disk? - + Could not save document. - + Saved Document: {0} - + Spell checking requires the package PyEnchant. It does not appear to be installed. - + Spell check complete - + File Location - + The currently open file is saved in: - + The document has grown too big and you cannot add more text to it. The maximum size of a single novelWriter document is {0} MB. - + Follow Tag - + Cut - + Copy - + Paste - + Select All - + Select Word - + Select Paragraph - + Spelling Suggestion(s) - + No Suggestions - + Add Word to Dictionary - + Please select some text before calling replace quotes. @@ -1042,159 +1052,108 @@ - - Drag and drop items to change the order. + + Drag and drop items to change the order, or uncheck to exclude. - - No source documents found. Nothing to do. - - - - - Failed to open document file. - - - - - No source folder selected. Nothing to do. - - - - - Internal error. - - - - - Could not save document. - - - - - Element selected in the project tree must be a folder. + + Move merged items to Trash GuiDocSplit - - + Split Document - + Document Headers - + Select the maximum level to split into files. - + Split on Header Level 1 (Title) - + Split up to Header Level 2 (Chapter) - + Split up to Header Level 3 (Scene) - + Split up to Header Level 4 (Section) - - No source document selected. Nothing to do. + + Split into a new folder - - Could not parse source document. + + Create document hierarchy - - Failed to open document file. - - - - - No headers found. Nothing to do. - - - - - The document will be split into {0} file(s) in a new folder. The original document will remain intact. - - - - - Continue with the splitting process? - - - - - Could not save document. - - - - - Element selected in the project tree must be a file. + + Move split document to Trash GuiDocViewFooter - + Show/hide the references panel - + Activate to freeze the content of the references panel when changing document - + Show comments - + Show synopsis comments - + References - + Sticky - + Comments - + Synopsis @@ -1202,22 +1161,22 @@ GuiDocViewHeader - + Go backward - + Go forward - + Reload the document - + Close the document @@ -1225,126 +1184,106 @@ GuiDocViewer - + An error occurred while generating the preview. - - Could not find the reference for tag '{0}'. It either doesn't exist, or the index is out of date. The index can be updated from the Tools menu, or by pressing {1}. - - - - + Copy - + Select All - + Select Word - + Select Paragraph + + GuiEditLabel + + + Item Label + + + + + Label + + + GuiItemDetails - + Label - + Status - + Class - + Usage - + Characters - + Words - + Paragraphs - - GuiItemEditor - - - Item Settings - - - - - Include when building project - - - - - Label - - - - - Status - - - - - Layout - - - GuiLipsum - + Insert Placeholder Text - + Insert Lorem Ipsum Text - + Number of paragraphs - + Randomise order - + Insert @@ -1352,862 +1291,771 @@ GuiMain - + You are running an untested development version of novelWriter. Please be careful when working on a live project and make sure you take regular backups. - + novelWriter is ready ... - + Cannot create a new project when another project is open. - + A project already exists in that location. Please choose another folder. - - New project created ... - - - - + Close Project - + Close the current project? - - + + Changes are saved automatically. - + Backup Project - + Backup the current project? - + The project was locked by the computer '{0}' ({1} {2}), last active on {3}. - + Project Locked - + The project is already open by another instance of novelWriter, and is therefore locked. Override lock and continue anyway? - + Note: If the program or the computer previously crashed, the lock can safely be overridden. However, overriding it is not recommended if the project is open in another instance of novelWriter. Doing so may corrupt the project. - + The project index is outdated or broken. Rebuilding index. - + Text files ({0}) - + Markdown files ({0}) - + novelWriter files ({0}) - + All files ({0}) - + Import File - + Could not read file. The file must be an existing text file. - + Please open a document to import the text file into. - + Import Document - + Importing the file will overwrite the current content of the document. Do you want to proceed? - - - Indexing: '{0}' - - - - - Unknown item - - - - + Indexing completed in {0} ms - + The project index has been successfully rebuilt. - + + Some changes will not be applied until novelWriter has been restarted. + + + + Information - + Warning - + Error - + This is a bug! - + Internal Error - + Exit - + Do you want to exit novelWriter? + + + Could not find the reference for tag '{0}'. It either doesn't exist, or the index is out of date. The index can be updated from the Tools menu, or by pressing {1}. + + GuiMainMenu - + &Project - + New Project - + Open Project - + Save Project - + Close Project - + Project Settings - + Project Details - - Create Root Folder + + Rename Item - - Novel Root - - - - - Plot Root - - - - - Character Root - - - - - Location Root - - - - - Timeline Root - - - - - Object Root - - - - - Entity Root - - - - - Custom Root - - - - - Archive Root - - - - - Create Folder - - - - - Edit Item - - - - + Delete Item - - Move Item Up - - - - - Move Item Down - - - - - Undo Last Move - - - - + Empty Trash - + Exit - + &Document - - New Document - - - - + Open Document - + Save Document - + Close Document - + View Document - + Close Document View - + Show File Details - + Import Text from File - - Merge Folder to Document - - - - - Split Document to Folder - - - - + &Edit - + Undo - + Redo - + Cut - + Copy - + Paste - + Select All - + Select Paragraph - + &View - + Go to Project Tree - + Go to Document Editor - + Go to Document Viewer - + Go to Outline - + Navigate Backward - + Navigate Forward - + Focus Mode - + Full Screen Mode - + &Insert - + Dashes - + Short Dash - + Long Dash - + Horizontal Bar - + Figure Dash - + Quote Marks - + Left Single Quote - + Right Single Quote - + Left Double Quote - + Right Double Quote - + Alternative Apostrophe - + General Punctuation - + Ellipsis - + Prime - + Double Prime - + White Spaces - + Non-Breaking Space - + Thin Space - + Thin Non-Breaking Space - + Other Symbols - + List Bullet - + Hyphen Bullet - + Flower Mark - + Per Mille - + Degree Symbol - + Minus Sign - + Times Sign - + Division Sign - + Tags and References - + + Special Comments + + + + + Synopsis Comment + + + + Page Break and Space - + Page Break - + Vertical Space (Single) - + Vertical Space (Multi) - + Placeholder Text - + &Format - + Emphasis - + Strong Emphasis - + Strikethrough - + Wrap Double Quotes - + Wrap Single Quotes - + Header 1 (Partition) - + Header 2 (Chapter) - + Header 3 (Scene) - + Header 4 (Section) - + Novel Title - + Unnumbered Chapter - + Align Left - + Align Centre - + Align Right - + Indent Left - + Indent Right - + Toggle Comment - + Remove Block Format - + Convert Single Quotes - + Convert Double Quotes - + Remove In-Paragraph Breaks - + &Search - + Find - + Replace - + Find Next - + Find Previous - + Replace Next - + &Tools - + Check Spelling - + Re-Run Spell Check - + Project Word List - + Rebuild Index - - Rebuild Outline - - - - - Auto-Update Outline - - - - + Backup Project - + Build Novel Project - + Writing Statistics - + Preferences - + &Help - + About novelWriter - + About Qt5 - + User Manual (Online) - + User Manual (PDF) - + Report an Issue (GitHub) - + Ask a Question (GitHub) - + The novelWriter Website - + Check for New Release @@ -2215,137 +2063,160 @@ GuiMainStatus - + None - + Editor - + Project - + Session Time - + Words: {0} ({1}) - + Project word count (session change) - + Novel word count (session change) + + GuiNovelToolBar + + + Novel Outline + + + + + Refresh + + + + + Novel Root + + + + + Last Column + + + + + Hidden + + + + + Point of View Character + + + + + Focus Character + + + + + Novel Plot + + + + + More Options + + + GuiNovelTree - - Novel Outline - - - - - Words - - - - - POV - - - - - Section title - - - - - Word count - - - - - Point-of-view character + + No meta data GuiOutlineDetails - - - - + + + + Title - + Chapter - + Scene - + Section - + Document - + Status - + Characters - + Words - + Paragraphs - + Synopsis - + Title Details - + Reference Tags @@ -2353,159 +2224,172 @@ GuiOutlineHeaderMenu - + Select Columns + + GuiOutlineToolBar + + + Outline of + + + + + Refresh + + + + + All Novel Folders + + + GuiPreferences - + Preferences - + General - + Projects - + Documents - + Editor - + Highlighting - + Automation - + Quotes - - - Some changes will not be applied until novelWriter has been restarted. - - GuiPreferencesAutomation - + Automatic Features - + Auto-select word under cursor - + Apply formatting to word under cursor if no selection is made. - + Auto-replace text as you type - + Allow the editor to replace symbols as you type. - + Replace as You Type - + Auto-replace single quotes - - + + Try to guess which is an opening or a closing quote. - + Auto-replace double quotes - + Auto-replace dashes - + Double and triple hyphens become short and long dashes. - + Auto-replace dots - + Three consecutive dots become ellipsis. - + Automatic Padding - + Insert non-breaking space before - + Automatically add space before any of these symbols. - + Insert non-breaking space after - + Automatically add space after any of these symbols. - + Use thin space instead - + Inserts a thin space instead of a regular space. @@ -2513,93 +2397,93 @@ GuiPreferencesDocuments - + Text Style - + Font family - - - - + + + + Applies to both document editor and viewer. - + Font size - + pt - + Text Flow - + Maximum text width in "Normal Mode" - + Set to 0 to disable this feature. - - - - + + + + px - + Maximum text width in "Focus Mode" - + The maximum width cannot be disabled. - + Hide document footer in "Focus Mode" - + Hide the information bar in the document editor. - + Justify the text margins - + Minimum text margin - + Tab width - + The width of a tab key press in the editor and viewer. @@ -2607,117 +2491,117 @@ GuiPreferencesEditor - + Spell Checking - + None - + Not installed - + Spell check language - + Available languages are determined by your system. - + Big document limit - + Full spell checking is disabled above this limit. - + kB - + Word Count - + Word count interval - + seconds - + Include project notes in status bar word count - + Writing Guides - + Show tabs and spaces - + Show line endings - + Scroll Behaviour - + Scroll past end of the document - + Set to 0 to disable this feature. - + lines - + Typewriter style scrolling when you type - + Keeps the cursor at a fixed vertical position. - + Minimum position for Typewriter scrolling - + Percentage of the editor height from the top. @@ -2725,87 +2609,95 @@ GuiPreferencesGeneral - + Look and Feel - + Main GUI language - - - - - - Requires restart. + + + + Requires restart to take effect. - + Main GUI theme - - Main icon theme + + General colour theme and icons. - + + Editor theme + + + + + Colour theme for the editor and viewer. + + + + Font family - + Font size - + pt - + GUI Settings - + Emphasise partition and chapter labels - + Makes them stand out in the project tree. - + Show full path in document header - + Add the parent folder names to the header. - + Hide vertical scroll bars in main windows - - + + Scrolling available with mouse wheel and keys only. - + Hide horizontal scroll bars in main windows @@ -2813,109 +2705,109 @@ GuiPreferencesProjects - + Automatic Save - + Save document interval - + How often the document is automatically saved. - - + + seconds - + Save project interval - + How often the project is automatically saved. - + Project Backup - + Browse - + Backup storage location - - + + Path: {0} - + Run backup when the project is closed - + Can be overridden for individual projects in Project Settings. - + Ask before running backup - + If off, backups will run in the background. - + Session Timer - + Pause the session timer when not writing - + Also pauses when the application window does not have focus. - + Editor inactive time before pausing timer - + User activity includes typing and changing the content. - + minutes - + Backup Directory @@ -2923,47 +2815,47 @@ GuiPreferencesQuotes - + Quotation Style - + Single quote open style - + The symbol to use for a leading single quote. - + Single quote close style - + The symbol to use for a trailing single quote. - + Double quote open style - + The symbol to use for a leading double quote. - + Double quote close style - + The symbol to use for a trailing double quote. @@ -2972,73 +2864,58 @@ GuiPreferencesSyntax - Highlighting Theme - - - - - Highlighting theme - - - - - Colour theme for the editor and viewer. - - - - Quotes & Dialogue - + Highlight text wrapped in quotes - - - + + + Applies to the document editor only. - + Allow open-ended single quotes - + Highlight single-quoted line with no closing quote. - + Allow open-ended double quotes - + Highlight double-quoted line with no closing quote. - + Text Emphasis - + Add highlight colour to emphasised text - + Text Errors - + Highlight multiple or trailing spaces @@ -3046,17 +2923,17 @@ GuiProjectDetails - + Project Details - + Overview - + Contents @@ -3064,72 +2941,72 @@ GuiProjectDetailsContents - - Title - - - - - Words - - - - - Pages - - - - - Page - - - - - Progress - - - - - Typical word count for a 5 by 8 inch book page with 11 pt font is 350. - - - - - Start counting page numbers from this page. - - - - - Assume a new chapter or partition always start on an odd numbered page. - - - - - Words per page - - - - - Count pages from - - - - - Clear double pages - - - - + Table of Contents - + + Title + + + + + Words + + + + + Pages + + + + + Page + + + + + Progress + + + + + Typical word count for a 5 by 8 inch book page with 11 pt font is 350. + + + + + Start counting page numbers from this page. + + + + + Assume a new chapter or partition always start on an odd numbered page. + + + + + Words per page + + + + + Count pages from + + + + + Clear double pages + + + + END - + Untitled @@ -3137,42 +3014,42 @@ GuiProjectDetailsMain - + Working Title: {0} - + By {0} - + Words - + Chapters - + Scenes - + Revisions - + Editing Time - + Path @@ -3180,58 +3057,58 @@ GuiProjectEditMain - + Project Settings - - Working title + + Project name - + Should be set only once. - + Novel title - + Change whenever you want! - + Author(s) - + One name per line. - + Default - + Spell check language - - + + Overrides main preferences. - + No backup on close @@ -3239,27 +3116,27 @@ GuiProjectEditReplace - + Text Replace List for Preview and Export - + Keyword - + Replace With - + Select item to edit - + Save @@ -3267,67 +3144,67 @@ GuiProjectEditStatus - + Novel File Status Levels - + Note File Importance Levels - + Label - + Usage - + Select item to edit - + Colour - + Save - + Select Colour - + New Item - + Cannot delete a status item that is in use. - + Not in use - + Used once - + Used by {0} items @@ -3335,63 +3212,63 @@ GuiProjectLoad - + Open Project - + Working Title - + Words - + Last Opened - + Recently Opened Projects - + Path - + New - + Remove - + novelWriter Project File ({0}) - + All files ({0}) - + Remove Entry - + Remove '{0}' from the recent projects list? The project files will not be deleted. @@ -3399,190 +3276,283 @@ GuiProjectSettings - + Project Settings - + Settings - + Status - + Importance - + Auto-Replace + + GuiProjectToolBar + + + Project Content + + + + + Quick Links + + + + + Move Up + + + + + Move Down + + + + + Add Item + + + + + Expand All + + + + + Collapse All + + + + + Undo Move + + + + + Empty Trash + + + + + More Options + + + GuiProjectTree - - Project Tree + + Active - - Words + + Inactive - - Item label - - - - - Word count - - - - - Include in build - - - - - Item status - - - - + Did not find anywhere to add the file or folder! - + Cannot add new files or folders to the Trash folder. - - New Document - - - - + New Note - + + New Chapter + + + + + New Scene + + + + + New Document + + + + New Folder - + There is currently no Trash folder in this project. - + The Trash folder is already empty. - + + Empty Trash - + Permanently delete {0} file(s) from Trash? - - Cannot delete root folder. It is not empty. Recursive deletion is not supported. Please delete the content first. - - - - - + + Delete - - Permanently delete '{0}'? - - - - + Move '{0}' to Trash? - - Could not delete document file. + + Root folders can only be deleted when they are empty. - - There is nowhere to add item with name '{0}'. - - - - - GuiProjectTreeMenu - - - Edit Project Item + + Permanently delete '{0}'? - + Open Document - + View Document - - Toggle Included Flag + + Change Label - - New File + + Toggle Active - - New Folder + + Set Status to ... - - Delete Item + + Set Importance to ... - - Empty Trash + + Transform - - Move Item Up + + + Convert to {0} - - Move Item Down + + Merge Child Items into Self + + + + + Merge Child Items into New + + + + + Merge Documents in Folder + + + + + Split Document by Headers + + + + + Expand All + + + + + Collapse All + + + + + Delete Permanently + + + + + Move to Trash + + + + + Convert Folder + + + + + Do you want to convert the folder to a {0}? This action cannot be reversed. + + + + + No documents selected for merging. + + + + + Merged + + + + + + Could not write document content. + + + + + There is nowhere to add item with name '{0}'. @@ -3623,32 +3593,67 @@ GuiViewsBar - + Project - - Novel + + Project Tree View + Novel + + + + + Novel Tree View + + + + Outline - - Details + + Novel Outline View - - Stats + + Build + Build Novel Project + + + + + Details + + + + + Project Details + + + + + Stats + + + + + Writing Statistics + + + + Settings @@ -3656,18 +3661,18 @@ GuiWordList - + Project Word List - + Cannot add a blank word. - + The word '{0}' is already in the word list. @@ -3675,152 +3680,152 @@ GuiWritingStats - + Writing Statistics - + Session Start - + Length - + Idle - + Words - + Histogram - + Sum Totals - + Total Time: - + Idle Time: - + Filtered Time: - + Novel Word Count: - + Notes Word Count: - + Total Word Count: - + Filters - + Count novel files - + Count note files - + Hide zero word count - + Hide negative word count - + Group entries by day - + Show idle time - + Word count cap for the histogram - + Save As - + JSON Data File (.json) - + CSV Data File (.csv) - + JSON Data File - + CSV Data File - + Save Data As - + {0} file successfully written to: - + Failed to write {0} file. - + Failed to read session log file. @@ -3828,385 +3833,299 @@ NWProject - - - New + + Could not delete document file. - - Note - - - - - Draft - - - - - Finished - - - - - Minor - - - - - Major - - - - - Main - - - - - New Project - - - - - By - - - - - - Novel - - - - - Plot - - - - - Characters - - - - - World - - - - - - Title Page - - - - - - - New Chapter - - - - - - New Scene - - - - - Chapter {0} - - - - - - Scene {0} - - - - - File not found: {0} - - - - - - Failed to parse project xml. - - - - - Attempting to open backup project file instead. - - - - - + Unknown - + Project file does not appear to be a novelWriterXML file. - + Unknown or unsupported novelWriter project file format. The project cannot be opened by this version of novelWriter. The file was saved with novelWriter version {0}. - + + Failed to parse project xml. + + + + File Version - + The file format of your project is about to be updated. If you proceed, older versions of novelWriter will no longer be able to open this project. Continue? - + Version Conflict - + This project was saved by a newer version of novelWriter, version {0}. This is version {1}. If you continue to open the project, some attributes and settings may not be preserved, but the overall project should be fine. Continue opening the project? - + Opened Project: {0} - - Project path not set, cannot save project. + + There is no project open. - - + Failed to save project. - + Saved Project: {0} - + Backing up project ... - + Cannot backup project because no valid backup path is set. Please set a valid backup location in Preferences. - - Cannot backup project because no project name is set. Please set a Working Title in Project Settings. + + Cannot backup project because no project name is set. Please set a Project Name in Project Settings. - + Could not create backup folder. - - Cannot backup project because the backup path is within the project folder to be backed up. Please choose a different backup path in Preferences. - - - - + Backup from {0} - + Backup archive file written to: {0} - + Could not write backup archive. - + Project backed up to '{0}' - - - Failed to create a new example project. + + + New - - Failed to create a new example project. Could not find the necessary files. They seem to be missing from this installation. + + Note - - Could not create new project folder. + + Draft - - New project folder is not empty. Each project requires a dedicated project folder. + + Finished - - You must set a valid backup path in Preferences to use the automatic project backup feature. + + Minor - - You must set a valid project name in Project Settings to use the automatic project backup feature. + + Major - + + Main + + + + and - - Could not create folder. - - - - + Found {0} orphaned file(s) in project folder. - + Recovered - + [{0}] {1} - + Recovered File {0} - + One or more orphaned files could not be added back into the project. Make sure at least a Novel root folder exists. - - - Not a folder: {0} - - - - - Could not move: {0} - - - - - - Could not delete: {0} - - - - - Could not make folder: {0} - - - - - Could not move item {0} to {1}. - - ProjWizardCustomPage - + Custom Project Options - - Select which additional root folders to make, and how to populate the Novel folder. If you don't want to add chapters or scenes, set the values to 0. You can add scenes without chapters. + + Select which additional elements to populate the project with. You can skip making chapters and add only scenes by setting the number of chapters to 0. - - Additional Root Folders + + Add a folder for plot notes - - - - - - - {0} folder + + Add a folder for character notes - - Populate Novel Folder + + Add a folder for location notes - - Add chapters + + Add example notes to the above - - Scenes (per chapter) + + Add chapters to the novel folder - - Add chapter folders + + Add scenes to each chapter ProjWizardFinalPage + + + Summary + + + + + Project Name: {0} + + + + + Project Path: {0} + + - Finished + Fill the project with a minimal set of items - - All done. + + Fill the project with example files - + + Add a folder for plot notes + + + + + Add a folder for character notes + + + + + Add a folder for location notes + + + + + Add example notes to the above + + + + + Add {0} chapters to the novel folder + + + + + Add {0} scenes to each chapter + + + + + Add {0} scenes + + + + + You have selected the following: + + + + Press '{0}' to create the new project. - + Done - + Finish @@ -4214,33 +4133,33 @@ ProjWizardFolderPage - - + + Select Project Folder - + Select a location to store the project. A new project folder will be created in the selected location. - + Required - + Project Path - + Error: A project folder cannot be created using this path. - + Error: The selected path already exists. @@ -4248,47 +4167,47 @@ ProjWizardIntroPage - + Create New Project - - Provide at least a working title. The working title should not be change beyond this point as it is used by the application for generating file names for for instance backups. The other fields are optional and can be changed at any time in Project Settings. + + Provide at least a project name. The project name should not be changed beyond this point as it is used for generating file names for for instance backups. The other fields are optional and can be changed at any time in Project Settings. - + Side image by {0}, {1} - + Required - + Optional - + Optional. One name per line. - - Working Title + + Project Name - + Novel Title - + Author(s) @@ -4296,31 +4215,105 @@ ProjWizardPopulatePage - + Populate Project - + Choose how to pre-fill the project. Either with a minimal set of starter items, an example project explaining and showing many of the features, or show further custom options on the next page. - + Fill the project with a minimal set of items - + Fill the project with example files - + Show detailed options for filling the project + + ProjectBuilder + + + New Project + + + + + New Chapter + + + + + New Scene + + + + + Title Page + + + + + By + + + + + Summary of the chapter. + + + + + Summary of the scene. + + + + + Chapter {0} + + + + + + Scene {0} + + + + + Main Plot + + + + + Protagonist + + + + + Main Location + + + + + Failed to create a new example project. + + + + + Failed to create a new example project. Could not find the necessary files. They seem to be missing from this installation. + + + QDialogButtonBox @@ -4512,17 +4505,17 @@ Tokenizer - + Synopsis - + Document '{0}' is too big ({1} MB). Skipping. - + ERROR diff --git a/i18n/nw_en_US.ts b/i18n/nw_en_US.ts index 75aee812..0bb773d2 100644 --- a/i18n/nw_en_US.ts +++ b/i18n/nw_en_US.ts @@ -4,72 +4,72 @@ Common - + in the future in the future - + just now just now - + a minute ago a minute ago - + {0} minutes ago {0} minutes ago - + an hour ago an hour ago - + {0} hours ago {0} hours ago - + a day ago a day ago - + {0} days ago {0} days ago - + a week ago a week ago - + {0} weeks ago {0} weeks ago - + a month ago a month ago - + {0} months ago {0} months ago - + a year ago a year ago - + {0} years ago {0} years ago @@ -77,259 +77,264 @@ Constant - - - + + + None None - + Novel Novel - - + + Plot Plot - - + + Characters Characters - - + + Locations Locations - - + + Timeline Timeline - - + + Objects Objects - - + + Entities Entities - - + + Custom Custom - + Archive Archive - + Trash Trash - - + + Novel Document Novel Document + - Project Note Project Note - + Root Folder Root Folder - + Folder Folder - + Novel Title Page Novel Title Page - + Novel Chapter Novel Chapter - + Novel Scene Novel Scene - + + Novel Section + Novel Section + + + Tag Tag - + Point of View Point of View - - + + Focus Focus - + Title Title - + Level Level - + Document Document - + Line Line - + Chars Chars - + Words Words - + Pars Pars - + POV POV - + Synopsis Synopsis - + Straight single quotation mark Straight single quotation mark - + Straight double quotation mark Straight double quotation mark - + Left single quotation mark Left single quotation mark - + Right single quotation mark Right single quotation mark - + Single low-9 quotation mark Single low-9 quotation mark - + Single high-reversed-9 quotation mark Single high-reversed-9 quotation mark - + Left double quotation mark Left double quotation mark - + Right double quotation mark Right double quotation mark - + Double low-9 quotation mark Double low-9 quotation mark - + Double high-reversed-9 quotation mark Double high-reversed-9 quotation mark - + Double low-reversed-9 quotation mark Double low-reversed-9 quotation mark - + Single left-pointing angle quotation mark Single left-pointing angle quotation mark - + Single right-pointing angle quotation mark Single right-pointing angle quotation mark - + Double left-pointing angle quotation mark Double left-pointing angle quotation mark - + Double right-pointing angle quotation mark Double right-pointing angle quotation mark - + Left corner bracket Left corner bracket - + Right corner bracket Right corner bracket - + Left white corner bracket Left white corner bracket - + Right white corner bracket Right white corner bracket @@ -337,105 +342,105 @@ GuiAbout - - + + About novelWriter About novelWriter - + About About - + Release Release - - - - - - Licence - License - - - - Website: {0} - Website: {0} - - - - Credits - Credits - - - - Developer - Developer - - - - Concept - Concept - - - - i18n - i18n - - - - novelWriter is a markdown-like text editor designed for organising and writing novels. It is written in Python 3 with a Qt5 GUI, using PyQt5. - novelWriter is a markdown-like text editor designed for organising and writing novels. It is written in Python 3 with a Qt5 GUI, using PyQt5. - - - - novelWriter 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. - novelWriter 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. - - - - novelWriter 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. - novelWriter 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 Licence tab for the full licence text, or visit the GNU website at {0} for more details. - See the License tab for the full license text, or visit the GNU website at {0} for more details. - - - - Translations - Translations - - - - Theme: {0} - Theme: {0} - - - - - - Author - Author - + + Licence + License + + + + Website: {0} + Website: {0} + + + + Credits + Credits + + + + Developer + Developer + + + + Concept + Concept + + + + i18n + i18n + + + + novelWriter is a markdown-like text editor designed for organising and writing novels. It is written in Python 3 with a Qt5 GUI, using PyQt5. + novelWriter is a markdown-like text editor designed for organising and writing novels. It is written in Python 3 with a Qt5 GUI, using PyQt5. + + + + novelWriter 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. + novelWriter 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. + + + + novelWriter 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. + novelWriter 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 Licence tab for the full licence text, or visit the GNU website at {0} for more details. + See the License tab for the full license text, or visit the GNU website at {0} for more details. + + + + Translations + Translations + + + + Theme: {0} + Theme: {0} + + + + + + Author + Author + + + + + Credit Credit - + Icons: {0} Icons: {0} - + Syntax: {0} Syntax: {0} @@ -443,7 +448,7 @@ GuiBuildNovel - + Build Novel Project Build Novel Project @@ -604,176 +609,181 @@ + Root Filter Options + Root Filter Options + + + File Filter Options File Filter Options - + Include novel files Include novel files - + Include note files Include note files - - Ignore export flag - Ignore export flag + + Include inactive files + Include inactive files - + Export Options Export Options - + Replace tabs with spaces Replace tabs with spaces - + Replace Unicode in HTML Replace Unicode in HTML - + Build Preview Build Preview - + Print Print - + Print Preview Print Preview - + Print to PDF Print to PDF - + Save As Save As - + Open Document (.odt) Open Document (.odt) - + Flat Open Document (.fodt) Flat Open Document (.fodt) - + novelWriter HTML (.htm) novelWriter HTML (.htm) - + novelWriter Markdown (.nwd) novelWriter Markdown (.nwd) - + Standard Markdown (.md) Standard Markdown (.md) - + GitHub Markdown (.md) GitHub Markdown (.md) - + JSON + novelWriter HTML (.json) JSON + novelWriter HTML (.json) - + JSON + novelWriter Markdown (.json) JSON + novelWriter Markdown (.json) - + Close Close - + Failed to generate preview. The result is too big. Failed to generate preview. The result is too big. - + There were problems when building the project: There were problems when building the project: - + Open Document Open Document - + Flat Open Document Flat Open Document - + Plain HTML Plain HTML - + novelWriter Markdown novelWriter Markdown - + Standard Markdown Standard Markdown - + GitHub Markdown GitHub Markdown - + JSON + novelWriter HTML JSON + novelWriter HTML - + JSON + novelWriter Markdown JSON + novelWriter Markdown - + PDF PDF - + Save Document As Save Document As - + {0} file successfully written to: {0} file successfully written to: - + Failed to write {0} file. {1} Failed to write {0} file. {1} @@ -781,17 +791,17 @@ GuiBuildNovelDocView - + This area will show the content of the document to be exported or printed. Press the "Build Preview" button to generate content. This area will show the content of the document to be exported or printed. Press the "Build Preview" button to generate content. - + Unknown Unknown - + Build Time: Build Time: @@ -799,32 +809,32 @@ GuiDocEditFooter - + Status Status - + Line: {0} ({1}) Line: {0} ({1}) - + Words: {0} ({1}) Words: {0} ({1}) - + Document size is {0} bytes Document size is {0} bytes - + Words: {0} selected Words: {0} selected - + Character count: {0} Character count: {0} @@ -832,22 +842,22 @@ GuiDocEditHeader - - Edit document meta - Edit document meta + + Edit document label + Edit document label - + Search document Search document - + Toggle Focus Mode Toggle Focus Mode - + Close the document Close the document @@ -855,58 +865,58 @@ GuiDocEditSearch - - + + Search Search - + Replace Replace - + Case Sensitive Case Sensitive - + Whole Words Only Whole Words Only - + RegEx Mode RegEx Mode - + Loop Search Loop Search - + Search Next File Search Next File - + Preserve Case Preserve Case - + Close Search Close Search - + Find in current document Find in current document - + Find and replace in current document Find and replace in current document @@ -914,117 +924,117 @@ GuiDocEditor - + The document you are trying to open is too big. The document size is {0} MB. The maximum size allowed is {1} MB. The document you are trying to open is too big. The document size is {0} MB. The maximum size allowed is {1} MB. - + Opened Document: {0} Opened Document: {0} - + The text you are trying to add is too big. The text size is {0} MB. The maximum size allowed is {1} MB. The text you are trying to add is too big. The text size is {0} MB. The maximum size allowed is {1} MB. - + File Changed on Disk File Changed on Disk - + This document has been changed outside of novelWriter while it was open. Overwrite the file on disk? This document has been changed outside of novelWriter while it was open. Overwrite the file on disk? - + Could not save document. Could not save document. - + Saved Document: {0} Saved Document: {0} - + Spell checking requires the package PyEnchant. It does not appear to be installed. Spell checking requires the package PyEnchant. It does not appear to be installed. - + Spell check complete Spell check complete - + File Location File Location - + The currently open file is saved in: The currently open file is saved in: - + The document has grown too big and you cannot add more text to it. The maximum size of a single novelWriter document is {0} MB. The document has grown too big and you cannot add more text to it. The maximum size of a single novelWriter document is {0} MB. - + Follow Tag Follow Tag - + Cut Cut - + Copy Copy - + Paste Paste - + Select All Select All - + Select Word Select Word - + Select Paragraph Select Paragraph - + Spelling Suggestion(s) Spelling Suggestion(s) - + No Suggestions No Suggestions - + Add Word to Dictionary Add Word to Dictionary - + Please select some text before calling replace quotes. Please select some text before calling replace quotes. @@ -1042,159 +1052,108 @@ Documents to Merge - - Drag and drop items to change the order. - Drag and drop items to change the order. + + Drag and drop items to change the order, or uncheck to exclude. + Drag and drop items to change the order, or uncheck to exclude. - - No source documents found. Nothing to do. - No source documents found. Nothing to do. - - - - Failed to open document file. - Failed to open document file. - - - - No source folder selected. Nothing to do. - No source folder selected. Nothing to do. - - - - Internal error. - Internal error. - - - - Could not save document. - Could not save document. - - - - Element selected in the project tree must be a folder. - Element selected in the project tree must be a folder. + + Move merged items to Trash + Move merged items to Trash GuiDocSplit - - + Split Document Split Document - + Document Headers Document Headers - + Select the maximum level to split into files. Select the maximum level to split into files. - + Split on Header Level 1 (Title) Split on Header Level 1 (Title) - + Split up to Header Level 2 (Chapter) Split up to Header Level 2 (Chapter) - + Split up to Header Level 3 (Scene) Split up to Header Level 3 (Scene) - + Split up to Header Level 4 (Section) Split up to Header Level 4 (Section) - - No source document selected. Nothing to do. - No source document selected. Nothing to do. + + Split into a new folder + Split into a new folder - - Could not parse source document. - Could not parse source document. + + Create document hierarchy + Create document hierarchy - - Failed to open document file. - Failed to open document file. - - - - No headers found. Nothing to do. - No headers found. Nothing to do. - - - - The document will be split into {0} file(s) in a new folder. The original document will remain intact. - The document will be split into {0} file(s) in a new folder. The original document will remain intact. - - - - Continue with the splitting process? - Continue with the splitting process? - - - - Could not save document. - Could not save document. - - - - Element selected in the project tree must be a file. - Element selected in the project tree must be a file. + + Move split document to Trash + Move split document to Trash GuiDocViewFooter - + Show/hide the references panel Show/hide the references panel - + Activate to freeze the content of the references panel when changing document Activate to freeze the content of the references panel when changing document - + Show comments Show comments - + Show synopsis comments Show synopsis comments - + References References - + Sticky Sticky - + Comments Comments - + Synopsis Synopsis @@ -1202,22 +1161,22 @@ GuiDocViewHeader - + Go backward Go backward - + Go forward Go forward - + Reload the document Reload the document - + Close the document Close the document @@ -1225,126 +1184,106 @@ GuiDocViewer - + An error occurred while generating the preview. An error occurred while generating the preview. - - Could not find the reference for tag '{0}'. It either doesn't exist, or the index is out of date. The index can be updated from the Tools menu, or by pressing {1}. - Could not find the reference for tag '{0}'. It either doesn't exist, or the index is out of date. The index can be updated from the Tools menu, or by pressing {1}. - - - + Copy Copy - + Select All Select All - + Select Word Select Word - + Select Paragraph Select Paragraph + + GuiEditLabel + + + Item Label + Item Label + + + + Label + Label + + GuiItemDetails - + Label Label - + Status Status - + Class Class - + Usage Usage - + Characters Characters - + Words Words - + Paragraphs Paragraphs - - GuiItemEditor - - - Item Settings - Item Settings - - - - Include when building project - Include when building project - - - - Label - Label - - - - Status - Status - - - - Layout - Layout - - GuiLipsum - + Insert Placeholder Text Insert Placeholder Text - + Insert Lorem Ipsum Text Insert Lorem Ipsum Text - + Number of paragraphs Number of paragraphs - + Randomise order Randomize order - + Insert Insert @@ -1352,862 +1291,771 @@ GuiMain - + You are running an untested development version of novelWriter. Please be careful when working on a live project and make sure you take regular backups. You are running an untested development version of novelWriter. Please be careful when working on a live project and make sure you take regular backups. - + novelWriter is ready ... novelWriter is ready ... - + Cannot create a new project when another project is open. Cannot create a new project when another project is open. - + A project already exists in that location. Please choose another folder. A project already exists in that location. Please choose another folder. - - New project created ... - New project created ... - - - + Close Project Close Project - + Close the current project? Close the current project? - - + + Changes are saved automatically. Changes are saved automatically. - + Backup Project Backup Project - + Backup the current project? Backup the current project? - + The project was locked by the computer '{0}' ({1} {2}), last active on {3}. The project was locked by the computer '{0}' ({1} {2}), last active on {3}. - + Project Locked Project Locked - + The project is already open by another instance of novelWriter, and is therefore locked. Override lock and continue anyway? The project is already open by another instance of novelWriter, and is therefore locked. Override lock and continue anyway? - + Note: If the program or the computer previously crashed, the lock can safely be overridden. However, overriding it is not recommended if the project is open in another instance of novelWriter. Doing so may corrupt the project. Note: If the program or the computer previously crashed, the lock can safely be overridden. However, overriding it is not recommended if the project is open in another instance of novelWriter. Doing so may corrupt the project. - + The project index is outdated or broken. Rebuilding index. The project index is outdated or broken. Rebuilding index. - + Text files ({0}) Text files ({0}) - + Markdown files ({0}) Markdown files ({0}) - + novelWriter files ({0}) novelWriter files ({0}) - + All files ({0}) All files ({0}) - + Import File Import File - + Could not read file. The file must be an existing text file. Could not read file. The file must be an existing text file. - + Please open a document to import the text file into. Please open a document to import the text file into. - + Import Document Import Document - + Importing the file will overwrite the current content of the document. Do you want to proceed? Importing the file will overwrite the current content of the document. Do you want to proceed? - - - Indexing: '{0}' - Indexing: '{0}' - - - - Unknown item - Unknown item - - - + Indexing completed in {0} ms Indexing completed in {0} ms - + The project index has been successfully rebuilt. The project index has been successfully rebuilt. - + + Some changes will not be applied until novelWriter has been restarted. + Some changes will not be applied until novelWriter has been restarted. + + + Information Information - + Warning Warning - + Error Error - + This is a bug! This is a bug! - + Internal Error Internal Error - + Exit Exit - + Do you want to exit novelWriter? Do you want to exit novelWriter? + + + Could not find the reference for tag '{0}'. It either doesn't exist, or the index is out of date. The index can be updated from the Tools menu, or by pressing {1}. + Could not find the reference for tag '{0}'. It either doesn't exist, or the index is out of date. The index can be updated from the Tools menu, or by pressing {1}. + GuiMainMenu - + &Project &Project - + New Project New Project - + Open Project Open Project - + Save Project Save Project - + Close Project Close Project - + Project Settings Project Settings - + Project Details Project Details - - Create Root Folder - Create Root Folder + + Rename Item + Rename Item - - Novel Root - Novel Root - - - - Plot Root - Plot Root - - - - Character Root - Character Root - - - - Location Root - Location Root - - - - Timeline Root - Timeline Root - - - - Object Root - Object Root - - - - Entity Root - Entity Root - - - - Custom Root - Custom Root - - - - Archive Root - Archive Root - - - - Create Folder - Create Folder - - - - Edit Item - Edit Item - - - + Delete Item Delete Item - - Move Item Up - Move Item Up - - - - Move Item Down - Move Item Down - - - - Undo Last Move - Undo Last Move - - - + Empty Trash Empty Trash - + Exit Exit - + &Document &Document - - New Document - New Document - - - + Open Document Open Document - + Save Document Save Document - + Close Document Close Document - + View Document View Document - + Close Document View Close Document View - + Show File Details Show File Details - + Import Text from File Import Text from File - - Merge Folder to Document - Merge Folder to Document - - - - Split Document to Folder - Split Document to Folder - - - + &Edit &Edit - + Undo Undo - + Redo Redo - + Cut Cut - + Copy Copy - + Paste Paste - + Select All Select All - + Select Paragraph Select Paragraph - + &View &View - + Go to Project Tree Go to Project Tree - + Go to Document Editor Go to Document Editor - + Go to Document Viewer Go to Document Viewer - + Go to Outline Go to Outline - + Navigate Backward Navigate Backward - + Navigate Forward Navigate Forward - + Focus Mode Focus Mode - + Full Screen Mode Full Screen Mode - + &Insert &Insert - + Dashes Dashes - + Short Dash Short Dash - + Long Dash Long Dash - + Horizontal Bar Horizontal Bar - + Figure Dash Figure Dash - + Quote Marks Quote Marks - + Left Single Quote Left Single Quote - + Right Single Quote Right Single Quote - + Left Double Quote Left Double Quote - + Right Double Quote Right Double Quote - + Alternative Apostrophe Alternative Apostrophe - + General Punctuation General Punctuation - + Ellipsis Ellipsis - + Prime Prime - + Double Prime Double Prime - + White Spaces White Spaces - + Non-Breaking Space Non-Breaking Space - + Thin Space Thin Space - + Thin Non-Breaking Space Thin Non-Breaking Space - + Other Symbols Other Symbols - + List Bullet List Bullet - + Hyphen Bullet Hyphen Bullet - + Flower Mark Flower Mark - + Per Mille Per Mille - + Degree Symbol Degree Symbol - + Minus Sign Minus Sign - + Times Sign Times Sign - + Division Sign Division Sign - + Tags and References Tags and References - + + Special Comments + Special Comments + + + + Synopsis Comment + Synopsis Comment + + + Page Break and Space Page Break and Space - + Page Break Page Break - + Vertical Space (Single) Vertical Space (Single) - + Vertical Space (Multi) Vertical Space (Multi) - + Placeholder Text Placeholder Text - + &Format &Format - + Emphasis Emphasis - + Strong Emphasis Strong Emphasis - + Strikethrough Strikethrough - + Wrap Double Quotes Wrap Double Quotes - + Wrap Single Quotes Wrap Single Quotes - + Header 1 (Partition) Header 1 (Partition) - + Header 2 (Chapter) Header 2 (Chapter) - + Header 3 (Scene) Header 3 (Scene) - + Header 4 (Section) Header 4 (Section) - + Novel Title Novel Title - + Unnumbered Chapter Unnumbered Chapter - + Align Left Align Left - + Align Centre Align Center - + Align Right Align Right - + Indent Left Indent Left - + Indent Right Indent Right - + Toggle Comment Toggle Comment - + Remove Block Format Remove Block Format - + Convert Single Quotes Convert Single Quotes - + Convert Double Quotes Convert Double Quotes - + Remove In-Paragraph Breaks Remove In-Paragraph Breaks - + &Search &Search - + Find Find - + Replace Replace - + Find Next Find Next - + Find Previous Find Previous - + Replace Next Replace Next - + &Tools &Tools - + Check Spelling Check Spelling - + Re-Run Spell Check Re-Run Spell Check - + Project Word List Project Word List - + Rebuild Index Rebuild Index - - Rebuild Outline - Rebuild Outline - - - - Auto-Update Outline - Auto-Update Outline - - - + Backup Project Backup Project - + Build Novel Project Build Novel Project - + Writing Statistics Writing Statistics - + Preferences Preferences - + &Help &Help - + About novelWriter About novelWriter - + About Qt5 About Qt5 - + User Manual (Online) User Manual (Online) - + User Manual (PDF) User Manual (PDF) - + Report an Issue (GitHub) Report an Issue (GitHub) - + Ask a Question (GitHub) Ask a Question (GitHub) - + The novelWriter Website The novelWriter Website - + Check for New Release Check for New Release @@ -2215,137 +2063,160 @@ GuiMainStatus - + None None - + Editor Editor - + Project Project - + Session Time Session Time - + Words: {0} ({1}) Words: {0} ({1}) - + Project word count (session change) Project word count (session change) - + Novel word count (session change) Novel word count (session change) - GuiNovelTree + GuiNovelToolBar - + Novel Outline Novel Outline - - Words - Words + + Refresh + Refresh - - POV - POV + + Novel Root + Novel Root - - Section title - Section title + + Last Column + Last Column - - Word count - Word count + + Hidden + Hidden - - Point-of-view character - Point-of-view character + + Point of View Character + Point of View Character + + + + Focus Character + Focus Character + + + + Novel Plot + Novel Plot + + + + More Options + More Options + + + + GuiNovelTree + + + No meta data + No meta data GuiOutlineDetails - - - - + + + + Title Title - + Chapter Chapter - + Scene Scene - + Section Section - + Document Document - + Status Status - + Characters Characters - + Words Words - + Paragraphs Paragraphs - + Synopsis Synopsis - + Title Details Title Details - + Reference Tags Reference Tags @@ -2353,159 +2224,172 @@ GuiOutlineHeaderMenu - + Select Columns Select Columns + + GuiOutlineToolBar + + + Outline of + Outline of + + + + Refresh + Refresh + + + + All Novel Folders + All Novel Folders + + GuiPreferences - + Preferences Preferences - + General General - + Projects Projects - + Documents Documents - + Editor Editor - + Highlighting Highlighting - + Automation Automation - + Quotes Quotes - - - Some changes will not be applied until novelWriter has been restarted. - Some changes will not be applied until novelWriter has been restarted. - GuiPreferencesAutomation - + Automatic Features Automatic Features - + Auto-select word under cursor Auto-select word under cursor - + Apply formatting to word under cursor if no selection is made. Apply formatting to word under cursor if no selection is made. - + Auto-replace text as you type Auto-replace text as you type - + Allow the editor to replace symbols as you type. Allow the editor to replace symbols as you type. - + Replace as You Type Replace as You Type - + Auto-replace single quotes Auto-replace single quotes - - + + Try to guess which is an opening or a closing quote. Try to guess which is an opening or a closing quote. - + Auto-replace double quotes Auto-replace double quotes - + Auto-replace dashes Auto-replace dashes - + Double and triple hyphens become short and long dashes. Double and triple hyphens become short and long dashes. - + Auto-replace dots Auto-replace dots - + Three consecutive dots become ellipsis. Three consecutive dots become ellipsis. - + Automatic Padding Automatic Padding - + Insert non-breaking space before Insert non-breaking space before - + Automatically add space before any of these symbols. Automatically add space before any of these symbols. - + Insert non-breaking space after Insert non-breaking space after - + Automatically add space after any of these symbols. Automatically add space after any of these symbols. - + Use thin space instead Use thin space instead - + Inserts a thin space instead of a regular space. Inserts a thin space instead of a regular space. @@ -2513,93 +2397,93 @@ GuiPreferencesDocuments - + Text Style Text Style - + Font family Font family - - - - + + + + Applies to both document editor and viewer. Applies to both document editor and viewer. - + Font size Font size - + pt pt - + Text Flow Text Flow - + Maximum text width in "Normal Mode" Maximum text width in "Normal Mode" - + Set to 0 to disable this feature. Set to 0 to disable this feature. - - - - + + + + px px - + Maximum text width in "Focus Mode" Maximum text width in "Focus Mode" - + The maximum width cannot be disabled. The maximum width cannot be disabled. - + Hide document footer in "Focus Mode" Hide document footer in "Focus Mode" - + Hide the information bar in the document editor. Hide the information bar in the document editor. - + Justify the text margins Justify the text margins - + Minimum text margin Minimum text margin - + Tab width Tab width - + The width of a tab key press in the editor and viewer. The width of a tab key press in the editor and viewer. @@ -2607,117 +2491,117 @@ GuiPreferencesEditor - + Spell Checking Spell Checking - + None None - + Not installed Not installed - + Spell check language Spell check language - + Available languages are determined by your system. Available languages are determined by your system. - + Big document limit Big document limit - + Full spell checking is disabled above this limit. Full spell checking is disabled above this limit. - + kB kB - + Word Count Word Count - + Word count interval Word count interval - + seconds seconds - + Include project notes in status bar word count Include project notes in status bar word count - + Writing Guides Writing Guides - + Show tabs and spaces Show tabs and spaces - + Show line endings Show line endings - + Scroll Behaviour Scroll Behavior - + Scroll past end of the document Scroll past end of the document - + Set to 0 to disable this feature. Set to 0 to disable this feature. - + lines lines - + Typewriter style scrolling when you type Typewriter style scrolling when you type - + Keeps the cursor at a fixed vertical position. Keeps the cursor at a fixed vertical position. - + Minimum position for Typewriter scrolling Minimum position for Typewriter scrolling - + Percentage of the editor height from the top. Percentage of the editor height from the top. @@ -2725,87 +2609,95 @@ GuiPreferencesGeneral - + Look and Feel Look and Feel - + Main GUI language Main GUI language - - - - - - Requires restart. - Requires restart. + + + + Requires restart to take effect. + Requires restart to take effect. - + Main GUI theme Main GUI theme - - Main icon theme - Main icon theme + + General colour theme and icons. + General color theme and icons. - + + Editor theme + Editor theme + + + + Colour theme for the editor and viewer. + Color theme for the editor and viewer. + + + Font family Font family - + Font size Font size - + pt pt - + GUI Settings GUI Settings - + Emphasise partition and chapter labels Emphasise partition and chapter labels - + Makes them stand out in the project tree. Makes them stand out in the project tree. - + Show full path in document header Show full path in document header - + Add the parent folder names to the header. Add the parent folder names to the header. - + Hide vertical scroll bars in main windows Hide vertical scroll bars in main windows - - + + Scrolling available with mouse wheel and keys only. Scrolling available with mouse wheel and keys only. - + Hide horizontal scroll bars in main windows Hide horizontal scroll bars in main windows @@ -2813,109 +2705,109 @@ GuiPreferencesProjects - + Automatic Save Automatic Save - + Save document interval Save document interval - + How often the document is automatically saved. How often the document is automatically saved. - - + + seconds seconds - + Save project interval Save project interval - + How often the project is automatically saved. How often the project is automatically saved. - + Project Backup Project Backup - + Browse Browse - + Backup storage location Backup storage location - - + + Path: {0} Path: {0} - + Run backup when the project is closed Run backup when the project is closed - + Can be overridden for individual projects in Project Settings. Can be overridden for individual projects in Project Settings. - + Ask before running backup Ask before running backup - + If off, backups will run in the background. If off, backups will run in the background. - + Session Timer Session Timer - + Pause the session timer when not writing Pause the session timer when not writing - + Also pauses when the application window does not have focus. Also pauses when the application window does not have focus. - + Editor inactive time before pausing timer Editor inactive time before pausing timer - + User activity includes typing and changing the content. User activity includes typing and changing the content. - + minutes minutes - + Backup Directory Backup Directory @@ -2923,47 +2815,47 @@ GuiPreferencesQuotes - + Quotation Style Quotation Style - + Single quote open style Single quote open style - + The symbol to use for a leading single quote. The symbol to use for a leading single quote. - + Single quote close style Single quote close style - + The symbol to use for a trailing single quote. The symbol to use for a trailing single quote. - + Double quote open style Double quote open style - + The symbol to use for a leading double quote. The symbol to use for a leading double quote. - + Double quote close style Double quote close style - + The symbol to use for a trailing double quote. The symbol to use for a trailing double quote. @@ -2972,73 +2864,58 @@ GuiPreferencesSyntax - Highlighting Theme - Highlighting Theme - - - - Highlighting theme - Highlighting theme - - - - Colour theme for the editor and viewer. - Color theme for the editor and viewer. - - - Quotes & Dialogue Quotes & Dialogue - + Highlight text wrapped in quotes Highlight text wrapped in quotes - - - + + + Applies to the document editor only. Applies to the document editor only. - + Allow open-ended single quotes Allow open-ended single quotes - + Highlight single-quoted line with no closing quote. Highlight single-quoted line with no closing quote. - + Allow open-ended double quotes Allow open-ended double quotes - + Highlight double-quoted line with no closing quote. Highlight double-quoted line with no closing quote. - + Text Emphasis Text Emphasis - + Add highlight colour to emphasised text Add highlight color to emphasised text - + Text Errors Text Errors - + Highlight multiple or trailing spaces Highlight multiple or trailing spaces @@ -3046,17 +2923,17 @@ GuiProjectDetails - + Project Details Project Details - + Overview Overview - + Contents Contents @@ -3064,72 +2941,72 @@ GuiProjectDetailsContents - - Title - Title - - - - Words - Words - - - - Pages - Pages - - - - Page - Page - - - - Progress - Progress - - - - Typical word count for a 5 by 8 inch book page with 11 pt font is 350. - Typical word count for a 5 by 8 inch book page with 11 pt font is 350. - - - - Start counting page numbers from this page. - Start counting page numbers from this page. - - - - Assume a new chapter or partition always start on an odd numbered page. - Assume a new chapter or partition always start on an odd numbered page. - - - - Words per page - Words per page - - - - Count pages from - Count pages from - - - - Clear double pages - Clear double pages - - - + Table of Contents Table of Contents - + + Title + Title + + + + Words + Words + + + + Pages + Pages + + + + Page + Page + + + + Progress + Progress + + + + Typical word count for a 5 by 8 inch book page with 11 pt font is 350. + Typical word count for a 5 by 8 inch book page with 11 pt font is 350. + + + + Start counting page numbers from this page. + Start counting page numbers from this page. + + + + Assume a new chapter or partition always start on an odd numbered page. + Assume a new chapter or partition always start on an odd numbered page. + + + + Words per page + Words per page + + + + Count pages from + Count pages from + + + + Clear double pages + Clear double pages + + + END END - + Untitled Untitled @@ -3137,42 +3014,42 @@ GuiProjectDetailsMain - + Working Title: {0} Working Title: {0} - + By {0} By {0} - + Words Words - + Chapters Chapters - + Scenes Scenes - + Revisions Revisions - + Editing Time Editing Time - + Path Path @@ -3180,58 +3057,58 @@ GuiProjectEditMain - + Project Settings Project Settings - - Working title - Working title + + Project name + Project name - + Should be set only once. Should be set only once. - + Novel title Novel title - + Change whenever you want! Change whenever you want! - + Author(s) Author(s) - + One name per line. One name per line. - + Default Default - + Spell check language Spell check language - - + + Overrides main preferences. Overrides main preferences. - + No backup on close No backup on close @@ -3239,27 +3116,27 @@ GuiProjectEditReplace - + Text Replace List for Preview and Export Text Replace List for Preview and Export - + Keyword Keyword - + Replace With Replace With - + Select item to edit Select item to edit - + Save Save @@ -3267,67 +3144,67 @@ GuiProjectEditStatus - + Novel File Status Levels Novel File Status Levels - + Note File Importance Levels Note File Importance Levels - + Label Label - + Usage Usage - + Select item to edit Select item to edit - + Colour Color - + Save Save - + Select Colour Select Color - + New Item New Item - + Cannot delete a status item that is in use. Cannot delete a status item that is in use. - + Not in use Not in use - + Used once Used once - + Used by {0} items Used by {0} items @@ -3335,63 +3212,63 @@ GuiProjectLoad - + Open Project Open Project - + Working Title Working Title - + Words Words - + Last Opened Last Opened - + Recently Opened Projects Recently Opened Projects - + Path Path - + New New - + Remove Remove - + novelWriter Project File ({0}) novelWriter Project File ({0}) - + All files ({0}) All files ({0}) - + Remove Entry Remove Entry - + Remove '{0}' from the recent projects list? The project files will not be deleted. Remove '{0}' from the recent projects list? The project files will not be deleted. @@ -3399,191 +3276,284 @@ GuiProjectSettings - + Project Settings Project Settings - + Settings Settings - + Status Status - + Importance Importance - + Auto-Replace Auto-Replace + + GuiProjectToolBar + + + Project Content + Project Content + + + + Quick Links + Quick Links + + + + Move Up + Move Up + + + + Move Down + Move Down + + + + Add Item + Add Item + + + + Expand All + Expand All + + + + Collapse All + Collapse All + + + + Undo Move + Undo Move + + + + Empty Trash + Empty Trash + + + + More Options + More Options + + GuiProjectTree - - Project Tree - Project Tree + + Active + Active - - Words - Words + + Inactive + Inactive - - Item label - Item label - - - - Word count - Word count - - - - Include in build - Include in build - - - - Item status - Item status - - - + Did not find anywhere to add the file or folder! Did not find anywhere to add the file or folder! - + Cannot add new files or folders to the Trash folder. Cannot add new files or folders to the Trash folder. - - New Document - New Document - - - + New Note New Note - + + New Chapter + New Chapter + + + + New Scene + New Scene + + + + New Document + New Document + + + New Folder New Folder - + There is currently no Trash folder in this project. There is currently no Trash folder in this project. - + The Trash folder is already empty. The Trash folder is already empty. - + + Empty Trash Empty Trash - + Permanently delete {0} file(s) from Trash? Permanently delete {0} file(s) from Trash? - - Cannot delete root folder. It is not empty. Recursive deletion is not supported. Please delete the content first. - Cannot delete root folder. It is not empty. Recursive deletion is not supported. Please delete the content first. - - - - + + Delete Delete - - Permanently delete '{0}'? - Permanently delete '{0}'? - - - + Move '{0}' to Trash? Move '{0}' to Trash? - - Could not delete document file. - Could not delete document file. + + Root folders can only be deleted when they are empty. + Root folders can only be deleted when they are empty. - - There is nowhere to add item with name '{0}'. - There is nowhere to add item with name '{0}'. - - - - GuiProjectTreeMenu - - - Edit Project Item - Edit Project Item + + Permanently delete '{0}'? + Permanently delete '{0}'? - + Open Document Open Document - + View Document View Document - - Toggle Included Flag - Toggle Included Flag + + Change Label + Change Label - - New File - New File + + Toggle Active + Toggle Active - - New Folder - New Folder + + Set Status to ... + Set Status to ... - - Delete Item - Delete Item + + Set Importance to ... + Set Importance to ... - - Empty Trash - Empty Trash + + Transform + Transform - - Move Item Up - Move Item Up + + + Convert to {0} + Convert to {0} - - Move Item Down - Move Item Down + + Merge Child Items into Self + Merge Child Items into Self + + + + Merge Child Items into New + Merge Child Items into New + + + + Merge Documents in Folder + Merge Documents in Folder + + + + Split Document by Headers + Split Document by Headers + + + + Expand All + Expand All + + + + Collapse All + Collapse All + + + + Delete Permanently + Delete Permanently + + + + Move to Trash + Move to Trash + + + + Convert Folder + Convert Folder + + + + Do you want to convert the folder to a {0}? This action cannot be reversed. + Do you want to convert the folder to a {0}? This action cannot be reversed. + + + + No documents selected for merging. + No documents selected for merging. + + + + Merged + Merged + + + + + Could not write document content. + Could not write document content. + + + + There is nowhere to add item with name '{0}'. + There is nowhere to add item with name '{0}'. @@ -3623,32 +3593,67 @@ GuiViewsBar - + Project Project - + + Project Tree View + Project Tree View + + + Novel Novel - + + Novel Tree View + Novel Tree View + + + Outline Outline - + + Novel Outline View + Novel Outline View + + + + Build + Build + + + + Build Novel Project + Build Novel Project + + + Details Details - + + Project Details + Project Details + + + Stats Stats - + + Writing Statistics + Writing Statistics + + + Settings Settings @@ -3656,18 +3661,18 @@ GuiWordList - + Project Word List Project Word List - + Cannot add a blank word. Cannot add a blank word. - + The word '{0}' is already in the word list. The word '{0}' is already in the word list. @@ -3675,152 +3680,152 @@ GuiWritingStats - + Writing Statistics Writing Statistics - + Session Start Session Start - + Length Length - + Idle Idle - + Words Words - + Histogram Histogram - + Sum Totals Sum Totals - + Total Time: Total Time: - + Idle Time: Idle Time: - + Filtered Time: Filtered Time: - + Novel Word Count: Novel Word Count: - + Notes Word Count: Notes Word Count: - + Total Word Count: Total Word Count: - + Filters Filters - + Count novel files Count novel files - + Count note files Count note files - + Hide zero word count Hide zero word count - + Hide negative word count Hide negative word count - + Group entries by day Group entries by day - + Show idle time Show idle time - + Word count cap for the histogram Word count cap for the histogram - + Save As Save As - + JSON Data File (.json) JSON Data File (.json) - + CSV Data File (.csv) CSV Data File (.csv) - + JSON Data File JSON Data File - + CSV Data File CSV Data File - + Save Data As Save Data As - + {0} file successfully written to: {0} file successfully written to: - + Failed to write {0} file. Failed to write {0} file. - + Failed to read session log file. Failed to read session log file. @@ -3828,385 +3833,299 @@ NWProject - - - New - New + + Could not delete document file. + Could not delete document file. - - Note - Note - - - - Draft - Draft - - - - Finished - Finished - - - - Minor - Minor - - - - Major - Major - - - - Main - Main - - - - New Project - New Project - - - - By - By - - - - - Novel - Novel - - - - Plot - Plot - - - - Characters - Characters - - - - World - World - - - - - Title Page - Title Page - - - - - - New Chapter - New Chapter - - - - - New Scene - New Scene - - - - Chapter {0} - Chapter {0} - - - - - Scene {0} - Scene {0} - - - - File not found: {0} - File not found: {0} - - - - - Failed to parse project xml. - Failed to parse project xml. - - - - Attempting to open backup project file instead. - Attempting to open backup project file instead. - - - - + Unknown Unknown - + Project file does not appear to be a novelWriterXML file. Project file does not appear to be a novelWriterXML file. - + Unknown or unsupported novelWriter project file format. The project cannot be opened by this version of novelWriter. The file was saved with novelWriter version {0}. Unknown or unsupported novelWriter project file format. The project cannot be opened by this version of novelWriter. The file was saved with novelWriter version {0}. - + + Failed to parse project xml. + Failed to parse project xml. + + + File Version File Version - + The file format of your project is about to be updated. If you proceed, older versions of novelWriter will no longer be able to open this project. Continue? The file format of your project is about to be updated. If you proceed, older versions of novelWriter will no longer be able to open this project. Continue? - + Version Conflict Version Conflict - + This project was saved by a newer version of novelWriter, version {0}. This is version {1}. If you continue to open the project, some attributes and settings may not be preserved, but the overall project should be fine. Continue opening the project? This project was saved by a newer version of novelWriter, version {0}. This is version {1}. If you continue to open the project, some attributes and settings may not be preserved, but the overall project should be fine. Continue opening the project? - + Opened Project: {0} Opened Project: {0} - - Project path not set, cannot save project. - Project path not set, cannot save project. + + There is no project open. + There is no project open. - - + Failed to save project. Failed to save project. - + Saved Project: {0} Saved Project: {0} - + Backing up project ... Backing up project ... - + Cannot backup project because no valid backup path is set. Please set a valid backup location in Preferences. Cannot backup project because no valid backup path is set. Please set a valid backup location in Preferences. - - Cannot backup project because no project name is set. Please set a Working Title in Project Settings. - Cannot backup project because no project name is set. Please set a Working Title in Project Settings. + + Cannot backup project because no project name is set. Please set a Project Name in Project Settings. + Cannot backup project because no project name is set. Please set a Project Name in Project Settings. - + Could not create backup folder. Could not create backup folder. - - Cannot backup project because the backup path is within the project folder to be backed up. Please choose a different backup path in Preferences. - Cannot backup project because the backup path is within the project folder to be backed up. Please choose a different backup path in Preferences. - - - + Backup from {0} Backup from {0} - + Backup archive file written to: {0} Backup archive file written to: {0} - + Could not write backup archive. Could not write backup archive. - + Project backed up to '{0}' Project backed up to '{0}' - - - Failed to create a new example project. - Failed to create a new example project. + + + New + New - - Failed to create a new example project. Could not find the necessary files. They seem to be missing from this installation. - Failed to create a new example project. Could not find the necessary files. They seem to be missing from this installation. + + Note + Note - - Could not create new project folder. - Could not create new project folder. + + Draft + Draft - - New project folder is not empty. Each project requires a dedicated project folder. - New project folder is not empty. Each project requires a dedicated project folder. + + Finished + Finished - - You must set a valid backup path in Preferences to use the automatic project backup feature. - You must set a valid backup path in Preferences to use the automatic project backup feature. + + Minor + Minor - - You must set a valid project name in Project Settings to use the automatic project backup feature. - You must set a valid project name in Project Settings to use the automatic project backup feature. + + Major + Major - + + Main + Main + + + and and - - Could not create folder. - Could not create folder. - - - + Found {0} orphaned file(s) in project folder. Found {0} orphaned file(s) in project folder. - + Recovered Recovered - + [{0}] {1} [{0}] {1} - + Recovered File {0} Recovered File {0} - + One or more orphaned files could not be added back into the project. Make sure at least a Novel root folder exists. One or more orphaned files could not be added back into the project. Make sure at least a Novel root folder exists. - - - Not a folder: {0} - Not a folder: {0} - - - - Could not move: {0} - Could not move: {0} - - - - - Could not delete: {0} - Could not delete: {0} - - - - Could not make folder: {0} - Could not make folder: {0} - - - - Could not move item {0} to {1}. - Could not move item {0} to {1}. - ProjWizardCustomPage - + Custom Project Options Custom Project Options - - Select which additional root folders to make, and how to populate the Novel folder. If you don't want to add chapters or scenes, set the values to 0. You can add scenes without chapters. - Select which additional root folders to make, and how to populate the Novel folder. If you don't want to add chapters or scenes, set the values to 0. You can add scenes without chapters. + + Select which additional elements to populate the project with. You can skip making chapters and add only scenes by setting the number of chapters to 0. + Select which additional elements to populate the project with. You can skip making chapters and add only scenes by setting the number of chapters to 0. - - Additional Root Folders - Additional Root Folders + + Add a folder for plot notes + Add a folder for plot notes - - - - - - - {0} folder - {0} folder + + Add a folder for character notes + Add a folder for character notes - - Populate Novel Folder - Populate Novel Folder + + Add a folder for location notes + Add a folder for location notes - - Add chapters - Add chapters + + Add example notes to the above + Add example notes to the above - - Scenes (per chapter) - Scenes (per chapter) + + Add chapters to the novel folder + Add chapters to the novel folder - - Add chapter folders - Add chapter folders + + Add scenes to each chapter + Add scenes to each chapter ProjWizardFinalPage + + + Summary + Summary + + + + Project Name: {0} + Project Name: {0} + + + + Project Path: {0} + Project Path: {0} + - Finished - Finished + Fill the project with a minimal set of items + Fill the project with a minimal set of items - - All done. - All done. + + Fill the project with example files + Fill the project with example files - + + Add a folder for plot notes + Add a folder for plot notes + + + + Add a folder for character notes + Add a folder for character notes + + + + Add a folder for location notes + Add a folder for location notes + + + + Add example notes to the above + Add example notes to the above + + + + Add {0} chapters to the novel folder + Add {0} chapters to the novel folder + + + + Add {0} scenes to each chapter + Add {0} scenes to each chapter + + + + Add {0} scenes + Add {0} scenes + + + + You have selected the following: + You have selected the following: + + + Press '{0}' to create the new project. Press '{0}' to create the new project. - + Done Done - + Finish Finish @@ -4214,33 +4133,33 @@ ProjWizardFolderPage - - + + Select Project Folder Select Project Folder - + Select a location to store the project. A new project folder will be created in the selected location. Select a location to store the project. A new project folder will be created in the selected location. - + Required Required - + Project Path Project Path - + Error: A project folder cannot be created using this path. Error: A project folder cannot be created using this path. - + Error: The selected path already exists. Error: The selected path already exists. @@ -4248,47 +4167,47 @@ ProjWizardIntroPage - + Create New Project Create New Project - - Provide at least a working title. The working title should not be change beyond this point as it is used by the application for generating file names for for instance backups. The other fields are optional and can be changed at any time in Project Settings. - Provide at least a working title. The working title should not be change beyond this point as it is used by the application for generating file names for for instance backups. The other fields are optional and can be changed at any time in Project Settings. + + Provide at least a project name. The project name should not be changed beyond this point as it is used for generating file names for for instance backups. The other fields are optional and can be changed at any time in Project Settings. + Provide at least a project name. The project name should not be changed beyond this point as it is used for generating file names for for instance backups. The other fields are optional and can be changed at any time in Project Settings. - + Side image by {0}, {1} Side image by {0}, {1} - + Required Required - + Optional Optional - + Optional. One name per line. Optional. One name per line. - - Working Title - Working Title + + Project Name + Project Name - + Novel Title Novel Title - + Author(s) Author(s) @@ -4296,31 +4215,105 @@ ProjWizardPopulatePage - + Populate Project Populate Project - + Choose how to pre-fill the project. Either with a minimal set of starter items, an example project explaining and showing many of the features, or show further custom options on the next page. Choose how to pre-fill the project. Either with a minimal set of starter items, an example project explaining and showing many of the features, or show further custom options on the next page. - + Fill the project with a minimal set of items Fill the project with a minimal set of items - + Fill the project with example files Fill the project with example files - + Show detailed options for filling the project Show detailed options for filling the project + + ProjectBuilder + + + New Project + New Project + + + + New Chapter + New Chapter + + + + New Scene + New Scene + + + + Title Page + Title Page + + + + By + By + + + + Summary of the chapter. + Summary of the chapter. + + + + Summary of the scene. + Summary of the scene. + + + + Chapter {0} + Chapter {0} + + + + + Scene {0} + Scene {0} + + + + Main Plot + Main Plot + + + + Protagonist + Protagonist + + + + Main Location + Main Location + + + + Failed to create a new example project. + Failed to create a new example project. + + + + Failed to create a new example project. Could not find the necessary files. They seem to be missing from this installation. + Failed to create a new example project. Could not find the necessary files. They seem to be missing from this installation. + + QDialogButtonBox @@ -4512,17 +4505,17 @@ Tokenizer - + Synopsis Synopsis - + Document '{0}' is too big ({1} MB). Skipping. Document '{0}' is too big ({1} MB). Skipping. - + ERROR ERROR diff --git a/i18n/nw_nb_NO.ts b/i18n/nw_nb_NO.ts index 17cb5ddb..38e16437 100644 --- a/i18n/nw_nb_NO.ts +++ b/i18n/nw_nb_NO.ts @@ -4,72 +4,72 @@ Common - + in the future i fremtiden - + just now nå nettopp - + a minute ago for et minutt siden - + {0} minutes ago for {0} minutter siden - + an hour ago for en time siden - + {0} hours ago for {0} timer siden - + a day ago for en dag siden - + {0} days ago for {0} dager siden - + a week ago for en uke siden - + {0} weeks ago for {0} uker siden - + a month ago for en måned siden - + {0} months ago for {0} måneder siden - + a year ago for et år siden - + {0} years ago for {0} år siden @@ -77,259 +77,264 @@ Constant - - - + + + None Ingen - + Novel Roman - - + + Plot Plott - - + + Characters Karakterer - - + + Locations Lokasjoner - - + + Timeline Tidslinje - - + + Objects Objekter - - + + Entities Enheter - - + + Custom Annet - + Archive Arkiv - + Trash Søppel - - + + Novel Document Romandokument + - Project Note Prosjektnotat - + Root Folder Hovedmappe - + Folder Mappe - + Novel Title Page Tittelside - + Novel Chapter Kapittel - + Novel Scene Scene - + + Novel Section + Seksjon + + + Tag Knagg - + Point of View Perspektiv - - + + Focus Fokus - + Title Tittel - + Level Nivå - + Document Dokument - + Line Linje - + Chars Tegn - + Words Ord - + Pars Avsnitt - + POV Persp. - + Synopsis Sammendrag - + Straight single quotation mark Rett, enkelt sitattegn - + Straight double quotation mark Rett, dobbelt sitattegn - + Left single quotation mark Venstre, enkelt sitattegn - + Right single quotation mark Høyre, enkelt sitattegn - + Single low-9 quotation mark Enkelt, lavt-9 sitattegn - + Single high-reversed-9 quotation mark Enkelt, høyt, reversert-9 sitattegn - + Left double quotation mark Venstre, dobbelt sitattegn - + Right double quotation mark Høyre, dobbelt sitattegn - + Double low-9 quotation mark Dobbelt, lavt-9 sitattegn - + Double high-reversed-9 quotation mark Dobbelt, høyt, reversert-9 sitattegn - + Double low-reversed-9 quotation mark Dobbelt, lavt, reversert-9 sitattegn - + Single left-pointing angle quotation mark Enkelt, venstre, angulært sitattegn - + Single right-pointing angle quotation mark Enkelt, høyre, angulært sitattegn - + Double left-pointing angle quotation mark Dobbelt, venstre, angulært sitattegn - + Double right-pointing angle quotation mark Dobbelt, høyre, angulært sitattegn - + Left corner bracket Venstre hjørnevinkel - + Right corner bracket Høyre hjørnevinkel - + Left white corner bracket Venstre, hvit hjørnevinkel - + Right white corner bracket Høyre, hvit hjørnevinkel @@ -337,105 +342,105 @@ GuiAbout - - + + About novelWriter Om novelWriter - + About Om - + Release Utgivelse - - - - - - Licence - Lisens - - - - Website: {0} - Nettside: {0} - - - - Credits - Krediteringer - - - - Developer - Utvikler - - - - Concept - Konsept - - - - i18n - i18n - - - - novelWriter is a markdown-like text editor designed for organising and writing novels. It is written in Python 3 with a Qt5 GUI, using PyQt5. - novelWriter er en markdown-liknende teksteditor laget for å kunne organisere og skrive romaner og noveller. Programmet er skrevet i Python 3 med et brukergrensesnitt i Qt 5 via PyQt5. - - - - novelWriter 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. - novelWriter er gratis programvare: du kan videredistribuere det og/eller modifisere det under vilkårene i GNU General Public License som utgitt av Free Software Foundation, enten versjon 3 av Lisensen, eller (etter eget valg) enhver senere versjon. - - - - novelWriter 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. - novelWriter er distribuert i håp om at det vil være nyttig, men UTEN NOEN GARANTI; uten selv en underforstått garanti vedrørende SALGBARHET eller EGNETHET TIL ET BESTEMT FORMÅL. Se GNU General Public Licence for flere detaljer. - - - - See the Licence tab for the full licence text, or visit the GNU website at {0} for more details. - Se lisens-fanen for fulltekst-versjonen av lisensen (på engelsk), eller besøk GNU sin nettside på {0} for mer informasjon. - - - - Translations - Oversettelser - - - - Theme: {0} - Tema: {0} - - - - - - Author - Ansvarlig - + + Licence + Lisens + + + + Website: {0} + Nettside: {0} + + + + Credits + Krediteringer + + + + Developer + Utvikler + + + + Concept + Konsept + + + + i18n + i18n + + + + novelWriter is a markdown-like text editor designed for organising and writing novels. It is written in Python 3 with a Qt5 GUI, using PyQt5. + novelWriter er en markdown-liknende teksteditor laget for å kunne organisere og skrive romaner og noveller. Programmet er skrevet i Python 3 med et brukergrensesnitt i Qt 5 via PyQt5. + + + + novelWriter 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. + novelWriter er gratis programvare: du kan videredistribuere det og/eller modifisere det under vilkårene i GNU General Public License som utgitt av Free Software Foundation, enten versjon 3 av Lisensen, eller (etter eget valg) enhver senere versjon. + + + + novelWriter 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. + novelWriter er distribuert i håp om at det vil være nyttig, men UTEN NOEN GARANTI; uten selv en underforstått garanti vedrørende SALGBARHET eller EGNETHET TIL ET BESTEMT FORMÅL. Se GNU General Public Licence for flere detaljer. + + + + See the Licence tab for the full licence text, or visit the GNU website at {0} for more details. + Se lisens-fanen for fulltekst-versjonen av lisensen (på engelsk), eller besøk GNU sin nettside på {0} for mer informasjon. + + + + Translations + Oversettelser + + + + Theme: {0} + Tema: {0} + + + + + + Author + Ansvarlig + + + + + Credit Kreditert - + Icons: {0} Ikoner: {0} - + Syntax: {0} Syntaks: {0} @@ -443,7 +448,7 @@ GuiBuildNovel - + Build Novel Project Bygg prosjektet @@ -604,176 +609,181 @@ + Root Filter Options + Filtrer rotmapper + + + File Filter Options Fil-filtre - + Include novel files Inkluder romanfiler - + Include note files Inkluder notatfiler - - Ignore export flag - Ignorer 'ta med'-flagg + + Include inactive files + Inkluder ikke-aktive filer - + Export Options Eksportvalg - + Replace tabs with spaces Erstatt tab med mellomrom - + Replace Unicode in HTML Erstatt Unicode med HTML - + Build Preview Lag forhåndsvisning - + Print Skriv ut - + Print Preview Forhåndsvisning av utskrift - + Print to PDF Skriv ut til PDF - + Save As Lagre som - + Open Document (.odt) Open Document (.odt) - + Flat Open Document (.fodt) Flat Open Document (.fodt) - + novelWriter HTML (.htm) novelWriter HTML (.htm) - + novelWriter Markdown (.nwd) novelWriter Markdown (.nwd) - + Standard Markdown (.md) Standard Markdown (.md) - + GitHub Markdown (.md) GitHub Markdown (.md) - + JSON + novelWriter HTML (.json) JSON + novelWriter HTML (.json) - + JSON + novelWriter Markdown (.json) JSON + novelWriter Markdown (.json) - + Close Lukk - + Failed to generate preview. The result is too big. Kunne ikke generere forhåndsvisning. Resultatet er for stort til å vise. - + There were problems when building the project: Det har oppstått problemer under bygging av prosjektet: - + Open Document Open Document - + Flat Open Document Flat Open Document - + Plain HTML Enkel HTML - + novelWriter Markdown novelWriter Markdown - + Standard Markdown Standard Markdown - + GitHub Markdown GitHub Markdown - + JSON + novelWriter HTML JSON + novelWriter HTML - + JSON + novelWriter Markdown JSON + novelWriter Markdown - + PDF PDF - + Save Document As Lagre dokumentet som - + {0} file successfully written to: Lagring av {0} var vellykket, og filen ble skrevet til: - + Failed to write {0} file. {1} Misslykkes i å skrive {0} til. {1} @@ -781,17 +791,17 @@ GuiBuildNovelDocView - + This area will show the content of the document to be exported or printed. Press the "Build Preview" button to generate content. Dette området vil vise innholdet av dokumentet som skal eksporteres. Trykk på knappen merket med "Lag forhåndsvisning" for å oppdatere innholdet. - + Unknown Ukjent - + Build Time: Bygget: @@ -799,32 +809,32 @@ GuiDocEditFooter - + Status Status - + Line: {0} ({1}) Linje: {0} ({1}) - + Words: {0} ({1}) Ord: {0} ({1}) - + Document size is {0} bytes Dokumentet er {0} byte - + Words: {0} selected Ord: {0} valgt - + Character count: {0} Antall tegn: {0} @@ -832,22 +842,22 @@ GuiDocEditHeader - - Edit document meta - Rediger dokumentinstillinger + + Edit document label + Endre dokumentnavn - + Search document Søk i dokumentet - + Toggle Focus Mode Slå av/på "Fokus-modus" - + Close the document Lukk dokumentet @@ -855,58 +865,58 @@ GuiDocEditSearch - - + + Search Søk - + Replace Erstatt - + Case Sensitive Skill store/små bokstaver - + Whole Words Only Kun hele ord - + RegEx Mode RegEx-modus - + Loop Search Søk rundt - + Search Next File Søk i neste file - + Preserve Case Behold store/små bokstaver - + Close Search Lukk søk - + Find in current document Søk i det åpne dokumentet - + Find and replace in current document Søk og erstatt i det åpne dokumentet @@ -914,117 +924,117 @@ GuiDocEditor - + The document you are trying to open is too big. The document size is {0} MB. The maximum size allowed is {1} MB. Dokumentet du prøver å åpne er for stort. Dokumenter er på {0} MB. Den maksimale størrelsen tillat er {1} MB. - + Opened Document: {0} Åpnet dokument: {0} - + The text you are trying to add is too big. The text size is {0} MB. The maximum size allowed is {1} MB. Teksten du forsøker å legge til er for stor. Teksten er {0} MB. Den maksimale tillatte størrelsen er {1} MB. - + File Changed on Disk Filen er endret på disk - + This document has been changed outside of novelWriter while it was open. Overwrite the file on disk? Dette dokumentet er endret utenfor novelWriter mens det var åpent. Overskrive filen på disken? - + Could not save document. Kunne ikke lagre dokumentet. - + Saved Document: {0} Lagret dokument: {0} - + Spell checking requires the package PyEnchant. It does not appear to be installed. Stavekontroll krever at pakken PyEnchant er installert. Det ser det ikke ut til at den er. - + Spell check complete Stavekontrollen er ferdig - + File Location Filens plassering - + The currently open file is saved in: Det åpne dokumentet er lagret på følgende sted: - + The document has grown too big and you cannot add more text to it. The maximum size of a single novelWriter document is {0} MB. Dokumentet har blitt for stort og du kan ikke legge til mer tekst. Den maksimale tillatte størrelsen for et novelWriter-dokument er {0} MB. - + Follow Tag Følg knagg - + Cut Klipp - + Copy Kopier - + Paste Lim inn - + Select All Velg hele teksten - + Select Word Velg hele ordet - + Select Paragraph Velg hele avsnittet - + Spelling Suggestion(s) Forslag fra stavekontrollen - + No Suggestions Ingen forslag - + Add Word to Dictionary Legg til ord i ordbok - + Please select some text before calling replace quotes. Venligst velg en del av teksten før du velger å erstatte sitattegn. @@ -1042,159 +1052,108 @@ Dokumenter som skal slås sammen - - Drag and drop items to change the order. - Dra og slipp dokumenter for å endre rekkefølge. + + Drag and drop items to change the order, or uncheck to exclude. + Dra og slipp elementer for å endre rekkefølgen, eller fjern merking for å ekskludere. - - No source documents found. Nothing to do. - Ingen kilde-dokument funnet. Det er ingenting å gjøre. - - - - Failed to open document file. - Kunne ikke åpne dokumentets fil. - - - - No source folder selected. Nothing to do. - Ingen kildemappe valgt. Det er ingenting å gjøre. - - - - Internal error. - Intern feil - - - - Could not save document. - Kunne ikke lagre dokumentet. - - - - Element selected in the project tree must be a folder. - Elementet som er valgt i prosjekttreet må være en mappe. + + Move merged items to Trash + Flytt sammenslåtte elementer til papirkurven GuiDocSplit - - + Split Document Del opp dokument - + Document Headers Dokumentets overskrifter - + Select the maximum level to split into files. Velg hvilket nivå av overskrifter å dele opp til. - + Split on Header Level 1 (Title) Del på overskrifter på nivå 1 (titler) - + Split up to Header Level 2 (Chapter) Del på overskrifter opp til nivå 2 (kapitler) - + Split up to Header Level 3 (Scene) Del på overskrifter opp til nivå 3 (scener) - + Split up to Header Level 4 (Section) Del på overskrifter opp til nivå 4 (seksjoner) - - No source document selected. Nothing to do. - Ingen kilde-dokument er valgt. Det er ingenting å gjøre. + + Split into a new folder + Del inn i en ny mappe - - Could not parse source document. - Klarte ikke å lese kilde-dokumentet. + + Create document hierarchy + Opprett dokumenthierarki - - Failed to open document file. - Kunne ikke åpne dokumentets fil. - - - - No headers found. Nothing to do. - Ingen overskrifter ble funnet i dokumentet. Det er ikke noe å gjøre. - - - - The document will be split into {0} file(s) in a new folder. The original document will remain intact. - Dokumentet vil nå bli delt opp i {0} nye filer i en ny mappe. Det originale dokumentet vil ikke bli endret eller fjernet. - - - - Continue with the splitting process? - Fortsette med oppdelingen? - - - - Could not save document. - Kunne ikke lagre dokumentet. - - - - Element selected in the project tree must be a file. - Elementet som er valgt i prosjekttreet må være et dokument. + + Move split document to Trash + Flytt splittet element til papirkurven GuiDocViewFooter - + Show/hide the references panel Skjul eller vis referanse-panelet - + Activate to freeze the content of the references panel when changing document Aktiver for å fryse innholdet i referanse-panelet ved bytte av vist dokument - + Show comments Vis kommentarer - + Show synopsis comments Vis sammendrag - + References Referanser - + Sticky Hold igjen - + Comments Kommentarer - + Synopsis Sammendrag @@ -1202,22 +1161,22 @@ GuiDocViewHeader - + Go backward Gå bakover - + Go forward Gå fremover - + Reload the document Last dokumentet på nytt - + Close the document Lukk dokumentet @@ -1225,126 +1184,106 @@ GuiDocViewer - + An error occurred while generating the preview. Det har oppstått en feil under genereringen av visningen. - - Could not find the reference for tag '{0}'. It either doesn't exist, or the index is out of date. The index can be updated from the Tools menu, or by pressing {1}. - Kunne ikke finne referansen til knagg {0}. Enten finnes den ikke, eller så er prosjektets indeks ikke oppdatert. Indeksen kan oppdateres fra Verktøy-menyen eller ved å trykke på {1}. - - - + Copy Kopier - + Select All Velg hele teksten - + Select Word Velg hele ordet - + Select Paragraph Velg hele avsnittet + + GuiEditLabel + + + Item Label + Enhetens navn + + + + Label + Navn + + GuiItemDetails - + Label Navn - + Status Status - + Class Klasse - + Usage Formål - + Characters Tegn - + Words Ord - + Paragraphs Avsnitt - - GuiItemEditor - - - Item Settings - Enhetsinstillinger - - - - Include when building project - Ta med ved eksport - - - - Label - Navn - - - - Status - Status - - - - Layout - Format - - GuiLipsum - + Insert Placeholder Text Sett inn midlertidig tekst - + Insert Lorem Ipsum Text Sett inn Lorem Ipsum-tekst - + Number of paragraphs Antall avsnitt - + Randomise order Tilfeldig rekkefølge - + Insert Sett inn @@ -1352,862 +1291,771 @@ GuiMain - + You are running an untested development version of novelWriter. Please be careful when working on a live project and make sure you take regular backups. Du kjører nå en utestet versjon av novelWriter. Vær forsiktig om du jobber med et av dine faktiske prosjekter. Husk å ta backup! - + novelWriter is ready ... novelWriter er klar ... - + Cannot create a new project when another project is open. Kan ikke lage et nytt prosjekt mens et annet prosjekt er åpent. - + A project already exists in that location. Please choose another folder. Et prosjekt finnes allerede i den mappen. Vennligst velg et annet sted å lagre prosjektet. - - New project created ... - Et nytt prosjekt har blitt opprettet ... - - - + Close Project Lukk prosjektet - + Close the current project? Ønsker du å lukke dette prosjektet? - - + + Changes are saved automatically. Endringer lagres automatisk. - + Backup Project Sikkerhetskopiering - + Backup the current project? Ønsker du å ta sikkerhetskopi av dette prosjektet? - + The project was locked by the computer '{0}' ({1} {2}), last active on {3}. Prosjektet er låst av datamaskinen {0} ({1} {2}), siste registrerte aktivitet var {3}. - + Project Locked Prosjektlås - + The project is already open by another instance of novelWriter, and is therefore locked. Override lock and continue anyway? Prosjektet er allerede åpent av en annen instans av novelWriter, og er derfor låst. Vil du overstyre denne låsen og fortsette likevel? - + Note: If the program or the computer previously crashed, the lock can safely be overridden. However, overriding it is not recommended if the project is open in another instance of novelWriter. Doing so may corrupt the project. Merk: Hvis programmet eller datamaskinen tidligere krasjet, kan fil-låsen trygt overstyres. Det anbefales imidlertid ikke å overstyre den hvis prosjektet er åpent i en annen instans av novelWriter. Å gjøre det kan skape konflikter i prosjektets filer. - + The project index is outdated or broken. Rebuilding index. Prosjektets indeks er utdatert eller skadet. Bygger indeksen på nytt. - + Text files ({0}) Tekstfiler ({0}) - + Markdown files ({0}) Markdown-filer ({0}) - + novelWriter files ({0}) novelWriter-filer ({0}) - + All files ({0}) Alle filer ({0}) - + Import File Importer fil - + Could not read file. The file must be an existing text file. Kunne ikke lese filen. Filen må eksistere fra før av. - + Please open a document to import the text file into. Vennligst åpne et dokument hvor teksten i filen kan importeres. - + Import Document Importer dokument - + Importing the file will overwrite the current content of the document. Do you want to proceed? Å importere filen vil overskrive all eksisterende tekst i dokumentet. Ønsker du å fortsette? - - - Indexing: '{0}' - Indekserer: '{0}' - - - - Unknown item - Ukjent enhet - - - + Indexing completed in {0} ms Indekseringen tok {0} ms - + The project index has been successfully rebuilt. Prosjektets indeks har blitt bygget på nytt. - + + Some changes will not be applied until novelWriter has been restarted. + Noen endringer vil ikke tas i bruk før neste gang novelWriter startes. + + + Information Informasjon - + Warning Advarsel - + Error Feil - + This is a bug! Dette er en systemfeil! - + Internal Error Intern feil - + Exit Avslutt - + Do you want to exit novelWriter? Ønsker du å avslutte novelWriter? + + + Could not find the reference for tag '{0}'. It either doesn't exist, or the index is out of date. The index can be updated from the Tools menu, or by pressing {1}. + Kunne ikke finne referansen til knagg {0}. Enten finnes den ikke, eller så er prosjektets indeks ikke oppdatert. Indeksen kan oppdateres fra Verktøy-menyen eller ved å trykke på {1}. + GuiMainMenu - + &Project &Prosjekt - + New Project Nytt prosjekt - + Open Project Åpne prosjekt - + Save Project Lagre prosjektet - + Close Project Lukk prosjektet - + Project Settings Prosjektinnstillinger - + Project Details Prosjektdetaljer - - Create Root Folder - Lag ny hovedmappe + + Rename Item + Endre navn - - Novel Root - Mappe for "Roman" - - - - Plot Root - Mappe for "Plott" - - - - Character Root - Mappe for "Karakterer" - - - - Location Root - Mappe for "Lokasjoner" - - - - Timeline Root - Mappe for "Tidslinjer" - - - - Object Root - Mappe for "Objekter" - - - - Entity Root - Mappe for "Enheter" - - - - Custom Root - Mappe for "Annet" - - - - Archive Root - Mappe for Arkiv - - - - Create Folder - Lag ny mappe - - - - Edit Item - Endre enhet - - - + Delete Item Slett enhet - - Move Item Up - Flytt enhet opp - - - - Move Item Down - Flytt enhet ned - - - - Undo Last Move - Angre siste flytting - - - + Empty Trash Tøm søppel - + Exit Avslutt - + &Document &Dokument - - New Document - Nytt dokument - - - + Open Document Åpne dokument - + Save Document Lagre dokumentet - + Close Document Lukk dokumentet - + View Document Vis dokument - + Close Document View Lukk dokumentvisning - + Show File Details Vis filinformasjon - + Import Text from File Importer tekst fra fil - - Merge Folder to Document - Slå sammen mappe - - - - Split Document to Folder - Del opp dokument - - - + &Edit &Rediger - + Undo Angre - + Redo Gjenopprett - + Cut Klipp - + Copy Kopier - + Paste Lim inn - + Select All Velg hele teksten - + Select Paragraph Velg hele avsnittet - + &View &Vis - + Go to Project Tree Gå til prosjekt-tre - + Go to Document Editor Gå til dokument-editor - + Go to Document Viewer Gå til visningsvindu - + Go to Outline Gå til disposisjon - + Navigate Backward Navigere bakover - + Navigate Forward Navigere fremover - + Focus Mode Focus-modus - + Full Screen Mode Fullskjerm-modus - + &Insert Sett &inn - + Dashes Bindestreker - + Short Dash Kort bindestrek - + Long Dash Lang bindestrek - + Horizontal Bar Horisontal strek - + Figure Dash Tallstrek - + Quote Marks Sitattegn - + Left Single Quote Venstre, enkelt sitattegn - + Right Single Quote Høyre, enkelt sitattegn - + Left Double Quote Venstre, dobbelt sitattegn - + Right Double Quote Høyre, dobbelt sitattegn - + Alternative Apostrophe Alternativ apostrof - + General Punctuation Generell tegnsetting - + Ellipsis Ellipsis - + Prime Primtegn - + Double Prime Dobbelt primtegn - + White Spaces Mellomrom - + Non-Breaking Space Hardt mellomrom - + Thin Space Kort mellomrom - + Thin Non-Breaking Space Hardt, kort mellomrom - + Other Symbols Andre symboler - + List Bullet Kulepunkt - + Hyphen Bullet Bindestrekpunkt - + Flower Mark Blomsterpunkt - + Per Mille Promille - + Degree Symbol Gradertegn - + Minus Sign Minustegn - + Times Sign Gangetegn - + Division Sign Deletegn - + Tags and References Knagger og referanser - + + Special Comments + Andre kommentartyper + + + + Synopsis Comment + Kommentar med sammendrag + + + Page Break and Space Sideskift og avstand - + Page Break Sideskift - + Vertical Space (Single) Vertikal avstand (enkel) - + Vertical Space (Multi) Vertikal avstand (flere) - + Placeholder Text Midlertidig tekst - + &Format &Formattering - + Emphasis Kursiv - + Strong Emphasis Uthev - + Strikethrough Gjennomstrek - + Wrap Double Quotes Sett i doble sitattegn - + Wrap Single Quotes Sett i enkle sitattegn - + Header 1 (Partition) Overskrift 1 (inndeling) - + Header 2 (Chapter) Overskrift 2 (kapittel) - + Header 3 (Scene) Overskrift 3 (scene) - + Header 4 (Section) Overskrift 4 (seksjon) - + Novel Title Boktittel - + Unnumbered Chapter Unumrert kapittel - + Align Left Venstrejuster - + Align Centre Sentrer - + Align Right Høyrejuster - + Indent Left Innrykk fra venstre - + Indent Right Innrykk fra høyre - + Toggle Comment Veksle kommentar - + Remove Block Format Fjern formattering - + Convert Single Quotes Konverter enkle sitattegn - + Convert Double Quotes Konverter doble sitattegn - + Remove In-Paragraph Breaks Fjern linjeskift i avsnittet - + &Search &Søk - + Find Søk - + Replace Erstatt - + Find Next Finn neste - + Find Previous Finn forrige - + Replace Next Erstatt neste - + &Tools &Verktøy - + Check Spelling Stavekontroll - + Re-Run Spell Check Kjør stavekontroll - + Project Word List Prosjektets ordliste - + Rebuild Index Bygg indeks - - Rebuild Outline - Bygg disposisjon - - - - Auto-Update Outline - Auto-oppdater disposisjon - - - + Backup Project Lag sikkerhetskopi av prosjektets mappe - + Build Novel Project Bygg prosjektet - + Writing Statistics Statistikk - + Preferences Innstillinger - + &Help &Hjelp - + About novelWriter Om novelWriter - + About Qt5 Om Qt5 - + User Manual (Online) Brukermanual (på nett) - + User Manual (PDF) Brukermanual (PDF) - + Report an Issue (GitHub) Rapporter en feil (GitHub) - + Ask a Question (GitHub) Still et spørsmål (GitHub) - + The novelWriter Website novelWriters nettside - + Check for New Release Sjekk etter oppdateringer @@ -2215,137 +2063,160 @@ GuiMainStatus - + None Ingen - + Editor Editor - + Project Prosjekt - + Session Time Tid brukt i gjeldende sesjon - + Words: {0} ({1}) Ord: {0} ({1}) - + Project word count (session change) Antall ord i prosjektet (endring i denne sesjonen) - + Novel word count (session change) Antall ord i roman-teksten (endring i denne sesjonen) - GuiNovelTree + GuiNovelToolBar - + Novel Outline Disposisjon - - Words - Ord + + Refresh + Oppdatér - - POV - Persp. + + Novel Root + Roman-mappe - - Section title - Seksjonens tittel + + Last Column + Siste kolonne - - Word count - Antall ord + + Hidden + Skjult - - Point-of-view character + + Point of View Character Synsvinkel-karakter + + + Focus Character + Fokus-karakter + + + + Novel Plot + Roman-plott + + + + More Options + Flere valg + + + + GuiNovelTree + + + No meta data + Ingen meta-data + GuiOutlineDetails - - - - + + + + Title Tittel - + Chapter Kapittel - + Scene Scene - + Section Seksjon - + Document Dokument - + Status Status - + Characters Tegn - + Words Ord - + Paragraphs Avsnitt - + Synopsis Sammendrag - + Title Details Oversikt - + Reference Tags Referanser @@ -2353,159 +2224,172 @@ GuiOutlineHeaderMenu - + Select Columns Velg kolonner + + GuiOutlineToolBar + + + Outline of + Disposisjon for + + + + Refresh + Oppdatér + + + + All Novel Folders + Alle roman-mapper + + GuiPreferences - + Preferences Innstillinger - + General Generelt - + Projects Prosjekt - + Documents Dokument - + Editor Editor - + Highlighting Fremheving - + Automation Automasjon - + Quotes Sitattegn - - - Some changes will not be applied until novelWriter has been restarted. - Noen endringer vil ikke tas i bruk før neste gang novelWriter startes. - GuiPreferencesAutomation - + Automatic Features Automatiske funksjoner - + Auto-select word under cursor Auto-velg ord under markør - + Apply formatting to word under cursor if no selection is made. Hvis ingen tekst er valgt, formatter ordet hvor markøren står. - + Auto-replace text as you type Erstatt mens du skriver - + Allow the editor to replace symbols as you type. La editoren erstatte symboler mens du skriver. - + Replace as You Type Erstatt mens du skriver - + Auto-replace single quotes Erstatt enkle sitattegn - - + + Try to guess which is an opening or a closing quote. Prøv å gjette om det er et åpne- eller lukketegn. - + Auto-replace double quotes Erstatt doble sitattegn - + Auto-replace dashes Erstatt bindestreker - + Double and triple hyphens become short and long dashes. To og tre bindestreker erstattes med kort og lang bindestrek. - + Auto-replace dots Erstatt tre punktum - + Three consecutive dots become ellipsis. Tre punktum på rad erstattes med ellipsis. - + Automatic Padding Automatisk mellomrom - + Insert non-breaking space before Sett inn hardt mellomrom foran - + Automatically add space before any of these symbols. Legg til mellomrom automatisk foran disse tegnene. - + Insert non-breaking space after Sett inn hardt mellomrom etter - + Automatically add space after any of these symbols. Legg til mellomrom automatisk etter disse tegnene. - + Use thin space instead Bruk tynt mellomrom istedet - + Inserts a thin space instead of a regular space. Sett inn et tynt mellomrom istedenfor et vanlig et. @@ -2513,93 +2397,93 @@ GuiPreferencesDocuments - + Text Style Tekststil - + Font family Skriftfamilie - - - - + + + + Applies to both document editor and viewer. Gjelder både redigerings- og visningsvindu. - + Font size Skriftstørrelse - + pt pt - + Text Flow Tekstflyt - + Maximum text width in "Normal Mode" Maks tekstbredde i "Normal-modus" - + Set to 0 to disable this feature. Sett til 0 for å deaktivere denne funksjonen. - - - - + + + + px px - + Maximum text width in "Focus Mode" Maks tekstbredde i "Fokus-modus" - + The maximum width cannot be disabled. Denne maks-bredden kan ikke deaktiveres. - + Hide document footer in "Focus Mode" Gjem dokumentets bunnlinje i "Fokus-modus" - + Hide the information bar in the document editor. Skjul informasjonslinjen i dokumenteditoren. - + Justify the text margins Juster tekstmarginer - + Minimum text margin Minimum tekstmargin - + Tab width Tabulatorens bredde - + The width of a tab key press in the editor and viewer. Hvor langt tabulatoren hopper i editor og visning. @@ -2607,117 +2491,117 @@ GuiPreferencesEditor - + Spell Checking Stavekontroll - + None Ingen - + Not installed Ikke installert - + Spell check language Språk for stavekontroll - + Available languages are determined by your system. Tilgjengelige språk hentes fra operativystemet ditt. - + Big document limit Grense for store dokumenter - + Full spell checking is disabled above this limit. Automatisk stavekontroll slås av over grensen. - + kB kB - + Word Count Telling av ord - + Word count interval Telle-intervall - + seconds sekunder - + Include project notes in status bar word count Inkluder prosjektnotater i antallet ord i statuslinjen - + Writing Guides Hjelpesymboler - + Show tabs and spaces Synlige tabulatorer og mellomrom - + Show line endings Synlige linjeender - + Scroll Behaviour Rullefelt - + Scroll past end of the document Tillat å rulle forbi slutten av dokumentet - + Set to 0 to disable this feature. Sett til 0 for å deaktivere denne funksjonen. - + lines linjer - + Typewriter style scrolling when you type Skrivemaskin-liknende rulling mens du skriver - + Keeps the cursor at a fixed vertical position. Holder markøren på samme sted vertikalt. - + Minimum position for Typewriter scrolling Minste avstand for skrivemaskin-rulling - + Percentage of the editor height from the top. I prosent fra toppen av editor-vinduet. @@ -2725,87 +2609,95 @@ GuiPreferencesGeneral - + Look and Feel Utseende - + Main GUI language Programspråk - - - - - - Requires restart. - Krever omstart. + + + + Requires restart to take effect. + Krever omstart for å tre i kraft. - + Main GUI theme Fargetema - - Main icon theme - Ikon-tema + + General colour theme and icons. + Generelt fargetema og ikoner. - + + Editor theme + Syntaksfremheving + + + + Colour theme for the editor and viewer. + Fargetema for editor og visning. + + + Font family Skriftfamilie - + Font size Skriftstørrelse - + pt pt - + GUI Settings Brukergrensesnitt - + Emphasise partition and chapter labels Fremhev filnavn for inndeling og kapitler - + Makes them stand out in the project tree. Får dem til å skille seg ut i prosjekttreet. - + Show full path in document header Vis full prosjektbane i dokumenthoder - + Add the parent folder names to the header. Legger til mappene foran dokumentets navn. - + Hide vertical scroll bars in main windows Skjul vertikale rullefelt i hovedvinduer - - + + Scrolling available with mouse wheel and keys only. Rulling kan bare gjøres med mus og tastatur. - + Hide horizontal scroll bars in main windows Skjul horisontale rullefelt i hovedvinduer @@ -2813,109 +2705,109 @@ GuiPreferencesProjects - + Automatic Save Automatisk lagring - + Save document interval Interval for lagring av dokument - + How often the document is automatically saved. Hvor ofte dokumentet lagres automatisk. - - + + seconds sekunder - + Save project interval Interval for lagring av prosjekt - + How often the project is automatically saved. Hvor ofte prosjektet lagres automatisk. - + Project Backup Sikkerhetskopi - + Browse Bla - + Backup storage location Filbane for sikkerhetskopi - - + + Path: {0} Filbane: {0} - + Run backup when the project is closed Lag sikkerhetskopi når prosjektet lukkes - + Can be overridden for individual projects in Project Settings. Kan overstyres fra individuelle prosjektinnstillinger. - + Ask before running backup Spør før sikkerhetskopi tas - + If off, backups will run in the background. Hvis avslått, tas sikkerhetskopi automatisk. - + Session Timer Sesjons-klokke - + Pause the session timer when not writing Sett klokka på pause når du er inaktiv - + Also pauses when the application window does not have focus. Pauses også når du ikke jobber i applikasjonens vindu. - + Editor inactive time before pausing timer Tid uten skriving før klokka settes på pause - + User activity includes typing and changing the content. Dette måler kun endringer i teksteditoren. - + minutes minutter - + Backup Directory Mappe for sikkerhetskopi @@ -2923,47 +2815,47 @@ GuiPreferencesQuotes - + Quotation Style Sitattegn - + Single quote open style Enkelt sitat, venstre side - + The symbol to use for a leading single quote. Symbol for enkelt sitattegn før et sitat. - + Single quote close style Enkelt sitat, høyre side - + The symbol to use for a trailing single quote. Symbol for enkelt sitattegn etter et sitat. - + Double quote open style Dobbelt sitat, venstre side - + The symbol to use for a leading double quote. Symbol for dobbelt sitattegn før et sitat. - + Double quote close style Dobbelt sitat, høyre side - + The symbol to use for a trailing double quote. Symbol for dobbelt sitattegn etter et sitat. @@ -2972,73 +2864,58 @@ GuiPreferencesSyntax - Highlighting Theme - Syntaksfremheving - - - - Highlighting theme - Fremhevingstema - - - - Colour theme for the editor and viewer. - Fargetema for editor og visning. - - - Quotes & Dialogue Sitattegn & dialog - + Highlight text wrapped in quotes Fremhev tekst mellom sitattegn - - - + + + Applies to the document editor only. Gjelder bare for redigeringsvindu. - + Allow open-ended single quotes Tillat enkle sitattegn som ikke lukkes - + Highlight single-quoted line with no closing quote. Fremhev sitater som ikke er lukket i samme avsnitt. - + Allow open-ended double quotes Tillat doble sitattegn som ikke lukkes - + Highlight double-quoted line with no closing quote. Fremhev sitater som ikke er lukket i samme avsnitt. - + Text Emphasis Fremheving av tekst - + Add highlight colour to emphasised text Fremhev formattert tekst - + Text Errors Feil i tekst - + Highlight multiple or trailing spaces Fremhev flere eller etterfølgende mellomrom @@ -3046,17 +2923,17 @@ GuiProjectDetails - + Project Details Prosjektdetaljer - + Overview Oversikt - + Contents Innhold @@ -3064,72 +2941,72 @@ GuiProjectDetailsContents - - Title - Tittel - - - - Words - Ord - - - - Pages - Sider - - - - Page - Side - - - - Progress - Fremdrift - - - - Typical word count for a 5 by 8 inch book page with 11 pt font is 350. - Typisk antall ord for en 5 x 8 tommers bokside med 11 pt tekst er 350. - - - - Start counting page numbers from this page. - Begynn sidetall fra denne siden. - - - - Assume a new chapter or partition always start on an odd numbered page. - Beregn at nye kapitler og partisjoner alltid starter på en høyreside. - - - - Words per page - Ord per side - - - - Count pages from - Tell sider fra - - - - Clear double pages - Tøm doble sider - - - + Table of Contents Innholdsfortegnelse - + + Title + Tittel + + + + Words + Ord + + + + Pages + Sider + + + + Page + Side + + + + Progress + Fremdrift + + + + Typical word count for a 5 by 8 inch book page with 11 pt font is 350. + Typisk antall ord for en 5 x 8 tommers bokside med 11 pt tekst er 350. + + + + Start counting page numbers from this page. + Begynn sidetall fra denne siden. + + + + Assume a new chapter or partition always start on an odd numbered page. + Beregn at nye kapitler og partisjoner alltid starter på en høyreside. + + + + Words per page + Ord per side + + + + Count pages from + Tell sider fra + + + + Clear double pages + Tøm doble sider + + + END SLUTT - + Untitled Uten tittel @@ -3137,42 +3014,42 @@ GuiProjectDetailsMain - + Working Title: {0} Arbeidstittel: {0} - + By {0} Av {0} - + Words Ord - + Chapters Kapitler - + Scenes Scener - + Revisions Revisjoner - + Editing Time Redigeringstid - + Path Filbane @@ -3180,58 +3057,58 @@ GuiProjectEditMain - + Project Settings Prosjektinnstillinger - - Working title - Arbeidstittel + + Project name + Prosjektnavn - + Should be set only once. Bør bare settes én gang. - + Novel title Bokens tittel - + Change whenever you want! Kan endres når som helst! - + Author(s) Forfatter(e) - + One name per line. Ett navn per linje. - + Default Ingen valg - + Spell check language Språk for stavekontroll - - + + Overrides main preferences. Overstyrer valg i innstillinger. - + No backup on close Slå av sikkerhetskopi @@ -3239,27 +3116,27 @@ GuiProjectEditReplace - + Text Replace List for Preview and Export Erstatningsliste for forhåndsvisning og eksport - + Keyword Kodeord - + Replace With Erstatt med - + Select item to edit Velg enhet å redigere - + Save Lagre @@ -3267,67 +3144,67 @@ GuiProjectEditStatus - + Novel File Status Levels Statusnivåer i roman-filer - + Note File Importance Levels Viktighetsnivåer i notatfiler - + Label Navn - + Usage Bruk - + Select item to edit Velg enhet å redigere - + Colour Farge - + Save Lagre - + Select Colour Velg farge - + New Item Legg til - + Cannot delete a status item that is in use. Kan ikke slette status som er i bruk. - + Not in use Ikke i bruk - + Used once Brukt ett sted - + Used by {0} items Brukt {0} steder @@ -3335,63 +3212,63 @@ GuiProjectLoad - + Open Project Åpne prosjekt - + Working Title Arbeidstittel - + Words Ord - + Last Opened Sist åpnet - + Recently Opened Projects Tidligere åpnede prosjekter - + Path Filbane - + New Ny - + Remove Fjern - + novelWriter Project File ({0}) novelWriter-prosjektfil ({0}) - + All files ({0}) Alle filer ({0}) - + Remove Entry Fjern linje - + Remove '{0}' from the recent projects list? The project files will not be deleted. Vil du fjerne {0} fra listen over tidligere åpnede prosjekter? Selve prosjektfilene blir ikke slettet. @@ -3399,191 +3276,284 @@ GuiProjectSettings - + Project Settings Prosjektinnstillinger - + Settings Innstillinger - + Status Status - + Importance Viktighet - + Auto-Replace Autoerstatt + + GuiProjectToolBar + + + Project Content + Prosjektets innhold + + + + Quick Links + Hurtiglenker + + + + Move Up + Flytt opp + + + + Move Down + Flytt ned + + + + Add Item + Legg til element + + + + Expand All + Utvid alle + + + + Collapse All + Lukk alle + + + + Undo Move + Angre flytting + + + + Empty Trash + Tøm papirkurven + + + + More Options + Flere valg + + GuiProjectTree - - Project Tree - Prosjektoversikt + + Active + Aktiv - - Words - Ord + + Inactive + Inaktiv - - Item label - Enhetens navn - - - - Word count - Antall ord - - - - Include in build - Ta med ved eksport - - - - Item status - Filen eller dokumentets status - - - + Did not find anywhere to add the file or folder! Fant ikke noe sted å legge til filen eller mappen! - + Cannot add new files or folders to the Trash folder. - Kan ikke legge til nye filer eller mapper til søppel-mappen. + Kan ikke legge til nye filer eller mapper i papirkurvmappen. - - New Document - Nytt dokument - - - + New Note Nytt notat - + + New Chapter + Nytt kapittel + + + + New Scene + Ny scene + + + + New Document + Nytt dokument + + + New Folder Ny mappe - + There is currently no Trash folder in this project. - Det er for øyeblikket ingen søppel-mappe i dette prosjektet. + Det er for øyeblikket ingen papirkurv i dette prosjektet. - + The Trash folder is already empty. - Søppel-mappen er allerede tom. + Papirkurven er allerede tom. - + + Empty Trash - Tøm søppel + Tøm papirkurven - + Permanently delete {0} file(s) from Trash? - Vil du slette {0} filer i søppel-mappen for godt? + Vil du slette {0} filer i papirkurven for godt? - - Cannot delete root folder. It is not empty. Recursive deletion is not supported. Please delete the content first. - Kan ikke slette hovedmappen da den ikke er tom. Rekursiv sletting er ikke støttet. Du må slette innholdet først. - - - - + + Delete Slett - - Permanently delete '{0}'? - Slette filen "{0}" for godt? - - - + Move '{0}' to Trash? Vil du flytte filen "{0}" til søpla? - - Could not delete document file. - Kunne ikke slette dokumentets fil. + + Root folders can only be deleted when they are empty. + Rotmapper kan bare slettes når de er tomme. - - There is nowhere to add item with name '{0}'. - Fant ikke noe sted å legge til enheten med navn {0}'. - - - - GuiProjectTreeMenu - - - Edit Project Item - Endre enhet + + Permanently delete '{0}'? + Slette filen "{0}" for godt? - + Open Document Åpne dokument - + View Document Vis dokument - - Toggle Included Flag - Slå av/på inkludering + + Change Label + Endre navn - - New File - Ny fil + + Toggle Active + Aktiver/deaktiver - - New Folder - Ny mappe + + Set Status to ... + Sett status til ... - - Delete Item - Slett enhet + + Set Importance to ... + Sett viktighetsnivå til ... - - Empty Trash - Tøm søppel + + Transform + Transformer - - Move Item Up - Flytt enhet opp + + + Convert to {0} + Konverter til {0} - - Move Item Down - Flytt enhet ned + + Merge Child Items into Self + Lim inn underelementer i dette dokumentet + + + + Merge Child Items into New + Lim inn underelementer i nytt dokument + + + + Merge Documents in Folder + Slå sammen dokumenter i mappen + + + + Split Document by Headers + Del dokumentet etter overskrift + + + + Expand All + Utvid alle + + + + Collapse All + Lukk alle + + + + Delete Permanently + Slett permanent + + + + Move to Trash + Flytt til papirkurv + + + + Convert Folder + Konverter mappe + + + + Do you want to convert the folder to a {0}? This action cannot be reversed. + Vil du konvertere mappen til et {0}? Denne handlingen kan ikke angres. + + + + No documents selected for merging. + Ingen dokumenter er valgt for sammenslåing. + + + + Merged + Sammenslått + + + + + Could not write document content. + Kan ikke skrive til dokumentet. + + + + There is nowhere to add item with name '{0}'. + Fant ikke noe sted å legge til enheten med navn {0}'. @@ -3623,32 +3593,67 @@ GuiViewsBar - + Project Prosjekt - + + Project Tree View + Prosjektoversikt + + + Novel Roman - + + Novel Tree View + Romanoversikt + + + Outline Oversikt - + + Novel Outline View + Disposisjon + + + + Build + Bygge + + + + Build Novel Project + Bygg prosjektet + + + Details Detaljer - + + Project Details + Prosjektdetaljer + + + Stats Statistikk - + + Writing Statistics + Statistikk + + + Settings Oppsett @@ -3656,18 +3661,18 @@ GuiWordList - + Project Word List Prosjektets ordliste - + Cannot add a blank word. Kan ikke legge til et tomt ord. - + The word '{0}' is already in the word list. Ordet {0} ligger allerede i ordlisten. @@ -3675,152 +3680,152 @@ GuiWritingStats - + Writing Statistics Statistikk - + Session Start Starttid - + Length Lengde - + Idle Inaktiv - + Words Ord - + Histogram Histogram - + Sum Totals Totalsummer - + Total Time: Totaltid: - + Idle Time: Inaktiv tid: - + Filtered Time: Filtrert tid: - + Novel Word Count: Ord i roman: - + Notes Word Count: Ord i notater: - + Total Word Count: Ord totalt: - + Filters Filtre - + Count novel files Tell i romanfiler - + Count note files Tell i notatfiler - + Hide zero word count Skjul null-verdier - + Hide negative word count Skjul negative verdier - + Group entries by day Samle rader per dag - + Show idle time Vis inaktiv som tid - + Word count cap for the histogram Maks antall ord for histogram - + Save As Lagre som - + JSON Data File (.json) JSON-format (.json) - + CSV Data File (.csv) CSV-format (.csv) - + JSON Data File JSON-format - + CSV Data File CSV-format - + Save Data As Lagre data som - + {0} file successfully written to: {0}-filen ble skrevet til: - + Failed to write {0} file. Kunne ikke skrive {0}-filen. - + Failed to read session log file. Kunne ikke lese loggfil med skrive-statistikk. @@ -3828,385 +3833,299 @@ NWProject - - - New - Ny + + Could not delete document file. + Kunne ikke slette dokumentets fil. - - Note - Notat - - - - Draft - Utkast - - - - Finished - Ferdig - - - - Minor - Mindre - - - - Major - Større - - - - Main - Hoved - - - - New Project - Nytt prosjekt - - - - By - Av - - - - - Novel - Roman - - - - Plot - Plott - - - - Characters - Karakterer - - - - World - Verden - - - - - Title Page - Tittelside - - - - - - New Chapter - Nytt kapittel - - - - - New Scene - Ny scene - - - - Chapter {0} - Kapittel {0} - - - - - Scene {0} - Scene {0} - - - - File not found: {0} - Fant ikke filen: {0} - - - - - Failed to parse project xml. - Kunne ikke lese prosjektets xml-data. - - - - Attempting to open backup project file instead. - Forsøker å åpne prosjektets sekundære prosjektfil istedet. - - - - + Unknown Ukjent - + Project file does not appear to be a novelWriterXML file. Prosjektfilen later ikke til å være en novelWriterXML-fil. - + Unknown or unsupported novelWriter project file format. The project cannot be opened by this version of novelWriter. The file was saved with novelWriter version {0}. Prosjektfilen har et ukjent eller ikke støttet format, og kan ikke åpnes med denne versjonen av novelWriter. Prosjektet ble lagret av novelWriter versjon {0}. - + + Failed to parse project xml. + Kunne ikke lese prosjektets xml-data. + + + File Version Filversjon - + The file format of your project is about to be updated. If you proceed, older versions of novelWriter will no longer be able to open this project. Continue? Filformatet til prosjektet ditt er i ferd med å bli oppdatert. Hvis du fortsetter, vil ikke eldre versjoner av novelWriter lenger kunne åpne dette prosjektet. Fortsette? - + Version Conflict Versjonskonflikt - + This project was saved by a newer version of novelWriter, version {0}. This is version {1}. If you continue to open the project, some attributes and settings may not be preserved, but the overall project should be fine. Continue opening the project? Dette prosjektet ble lagret av en nyere versjon av novelWriter, versjon {0}. Dette er versjon {1}. Hvis du ønsker å fortsette med å åpne prosjektet, kan noen av innstillingene bli borte, men selve prosjektet vil være i orden. Vil du fortsatt åpne prosjektet? - + Opened Project: {0} Åpnet prosjekt: {0} - - Project path not set, cannot save project. - Prosjektet mangler filbane, og kan ikke lagres. + + There is no project open. + Det er ikke noe prosjekter åpent. - - + Failed to save project. Kunne ikke lagre prosjektet. - + Saved Project: {0} Lagret prosjekt: {0} - + Backing up project ... Lager sikkerhetskopi ... - + Cannot backup project because no valid backup path is set. Please set a valid backup location in Preferences. Kan ikke ta sikkerhetskopi av prosjektet da ingen filbane er satt. Du må først sette en gyldig filbane i Innstillinger. - - Cannot backup project because no project name is set. Please set a Working Title in Project Settings. - Kan ikke ta sikkerhetskopi av prosjektet da ingen arbeidstittel er satt. Du må først sette en arbeidstittel i Prosjektinnstillinger. + + Cannot backup project because no project name is set. Please set a Project Name in Project Settings. + Kan ikke ta sikkerhetskopi av prosjektet da prosjektnavn ikke er satt. Du må først sette et prosjektnavn i Prosjektinnstillinger. - + Could not create backup folder. Kunne ikke lage mappe til sikkerhetskopi. - - Cannot backup project because the backup path is within the project folder to be backed up. Please choose a different backup path in Preferences. - Kan ikke ta sikkerhetskopi av prosjektet da filbanen er inne i prosjektmappen. Du må sette en ny filbane i Innstillinger. - - - + Backup from {0} Sikkerhetskopi fra {0} - + Backup archive file written to: {0} Sikkerhetskopi skrevet til: {0} - + Could not write backup archive. Kunne ikke lage sikkerhetskopi. - + Project backed up to '{0}' Sikkerhetskopi skrevet til '{0}' - - - Failed to create a new example project. - Kunne ikke lage nytt eksempel-prosjekt. + + + New + Ny - - Failed to create a new example project. Could not find the necessary files. They seem to be missing from this installation. - Kunne ikke lage nytt eksempel-prosjekt. Kunne ikke finne de nødvendige filene. De ser ut til å mangle i denne installasjonen. + + Note + Notat - - Could not create new project folder. - Kunne ikke lage ny prosjekt-mappe. + + Draft + Utkast - - New project folder is not empty. Each project requires a dedicated project folder. - Ny prosjektmappe er ikke tom. Hvert prosjekt trenger sin egen mappe. + + Finished + Ferdig - - You must set a valid backup path in Preferences to use the automatic project backup feature. - Du må sette en gyldig filbane i innstillingene for å kunne bruke automatisk sikkerhetskopi. + + Minor + Mindre - - You must set a valid project name in Project Settings to use the automatic project backup feature. - Du må sette en gyldig arbeidstittel i prosjektinnstillingene for å kunne bruke automatisk sikkerhetskopi. + + Major + Større - + + Main + Hoved + + + and og - - Could not create folder. - Kunne ikke opprette mappe. - - - + Found {0} orphaned file(s) in project folder. Fant {0} tapte filer i prosjektmappen. - + Recovered Gjennopprettet - + [{0}] {1} [{0}] {1} - + Recovered File {0} Gjennopprettet fil {0} - + One or more orphaned files could not be added back into the project. Make sure at least a Novel root folder exists. Én eller flere gjennopprettede filer kunne ikke bli lagt til i posjektet. Pass på at "Roman"-mappen i det minste eksisterer. - - - Not a folder: {0} - Ikke en mappe: {0} - - - - Could not move: {0} - Kunne ikke flytte: {0} - - - - - Could not delete: {0} - Kunne ikke slette: {0} - - - - Could not make folder: {0} - Kunne ikke lage mappe: {0} - - - - Could not move item {0} to {1}. - Kunne ikke flytte {0} til {1}. - ProjWizardCustomPage - + Custom Project Options Flere alternativer - - Select which additional root folders to make, and how to populate the Novel folder. If you don't want to add chapters or scenes, set the values to 0. You can add scenes without chapters. - Velg hvilke mapper du ønsker i prosjektet, og hvordan du ønsker å fylle hovedmappen for boken. Hvis du ikke ønsker å legge til kapitler og scener, sett verdiene til 0. Du kan også legge til scener uten å legge til kapitler. + + Select which additional elements to populate the project with. You can skip making chapters and add only scenes by setting the number of chapters to 0. + Velg hvilke ekstra elementer å fylle prosjektet med. Du kan hoppe over å lage kapitler og bare legge til scener ved å sette antallet kapitler til 0. - - Additional Root Folders - Hovedmapper + + Add a folder for plot notes + Legg til en mappe for plott-notater - - - - - - - {0} folder - {0} + + Add a folder for character notes + Legg til en mappe for karakterer - - Populate Novel Folder - Fyll roman-mappen + + Add a folder for location notes + Legg til en mappe for lokasjoner - - Add chapters - Legg til kapitler + + Add example notes to the above + Lag eksempelfiler til ovennevnte - - Scenes (per chapter) - Scener (per kapittel) + + Add chapters to the novel folder + Legg til kapitler i romanmappen - - Add chapter folders - Lag kapittel-mapper + + Add scenes to each chapter + Legg til scener i hvert kapittel ProjWizardFinalPage + + + Summary + Sammendrag + + + + Project Name: {0} + Prosjektnavn: {0} + + + + Project Path: {0} + Filbane: {0} + - Finished - Ferdig + Fill the project with a minimal set of items + Fyll prosjektet med et minimalt innhold - - All done. - Alt er klart. + + Fill the project with example files + Fyll prosjektet med eksempelfiler - + + Add a folder for plot notes + Legg til en mappe for plott-notater + + + + Add a folder for character notes + Legg til en mappe for karakterer + + + + Add a folder for location notes + Legg til en mappe for lokasjoner + + + + Add example notes to the above + Lag eksempelfiler til ovennevnte + + + + Add {0} chapters to the novel folder + Legg til {0} kapitler i romanmappen + + + + Add {0} scenes to each chapter + Legg til {0} scener i hvert kapittel + + + + Add {0} scenes + Legg til {0} scener + + + + You have selected the following: + Du har valgt følgende: + + + Press '{0}' to create the new project. Trykk '{0}' for å opprette det nye prosjektet. - + Done Ferdig - + Finish Fullfør @@ -4214,33 +4133,33 @@ ProjWizardFolderPage - - + + Select Project Folder Velg prosjektmappe - + Select a location to store the project. A new project folder will be created in the selected location. Velg et sted hvor prosjektet skal lagres. En mappe for hele prosjektet vil bli opprettet her. - + Required Påkrevd - + Project Path Filbane - + Error: A project folder cannot be created using this path. Feil: En prosjektmappe kan ikke opprettes ved hjelp av denne banen. - + Error: The selected path already exists. Feil: Den valgte banen finnes allerede. @@ -4248,47 +4167,47 @@ ProjWizardIntroPage - + Create New Project Opprett nytt prosjekt - - Provide at least a working title. The working title should not be change beyond this point as it is used by the application for generating file names for for instance backups. The other fields are optional and can be changed at any time in Project Settings. - Du må fylle inn minst en arbeidstittel. Du bør ikke endre arbeidstittelen etter at prosjektet er opprettet da denne brukes til blant annet filnavn for sikkerhetskopi. De andre feltene er valgfrie, og kan endres når som helst i Prosjektinstillinger. + + Provide at least a project name. The project name should not be changed beyond this point as it is used for generating file names for for instance backups. The other fields are optional and can be changed at any time in Project Settings. + Du må fylle inn minst et prosjektnavn. Du bør ikke endre prosjektnavnet etter at prosjektet er opprettet da dette brukes til blant annet filnavn for sikkerhetskopi. De andre feltene er valgfrie, og kan endres når som helst i Prosjektinstillinger. - + Side image by {0}, {1} Illustrasjon av {0}, {1} - + Required Påkrevd - + Optional Valgfritt - + Optional. One name per line. Valgfritt. Ett navn per linje. - - Working Title - Arbeidstittel + + Project Name + Prosjektnavn - + Novel Title Bokens tittel - + Author(s) Forfatter(e) @@ -4296,31 +4215,105 @@ ProjWizardPopulatePage - + Populate Project Fyll prosjektet - + Choose how to pre-fill the project. Either with a minimal set of starter items, an example project explaining and showing many of the features, or show further custom options on the next page. Velg hvordan du vil forhåndsfylle prosjektet. Du kan velge mellom et minimalt sett med mapper og filer, et eksempel-prosjekt som forklarer og viser hvordan du bruker programmet, eller se flere valg på neste side. - + Fill the project with a minimal set of items Fyll prosjektet med et minimalt innhold - + Fill the project with example files Fyll prosjektet med eksempelfiler - + Show detailed options for filling the project Vis detaljerte valg for å fylle prosjektet + + ProjectBuilder + + + New Project + Nytt prosjekt + + + + New Chapter + Nytt kapittel + + + + New Scene + Ny scene + + + + Title Page + Tittelside + + + + By + Av + + + + Summary of the chapter. + Sammendrag av kapittelet. + + + + Summary of the scene. + Sammendrag av scenen. + + + + Chapter {0} + Kapittel {0} + + + + + Scene {0} + Scene {0} + + + + Main Plot + Hovedplott + + + + Protagonist + Protagonist + + + + Main Location + Hovedlokasjon + + + + Failed to create a new example project. + Kunne ikke lage nytt eksempel-prosjekt. + + + + Failed to create a new example project. Could not find the necessary files. They seem to be missing from this installation. + Kunne ikke lage nytt eksempel-prosjekt. Kunne ikke finne de nødvendige filene. De ser ut til å mangle i denne installasjonen. + + QDialogButtonBox @@ -4512,17 +4505,17 @@ Tokenizer - + Synopsis Sammendrag - + Document '{0}' is too big ({1} MB). Skipping. Dokumentet '{0}' er for stort ({1} MB). Hopper over. - + ERROR FEIL diff --git a/novelwriter/__init__.py b/novelwriter/__init__.py index 0ae10566..e2e15757 100644 --- a/novelwriter/__init__.py +++ b/novelwriter/__init__.py @@ -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_()) diff --git a/novelwriter/assets/icons/typicons_dark/icons.conf b/novelwriter/assets/icons/typicons_dark/icons.conf index d4106803..fa8b8619 100644 --- a/novelwriter/assets/icons/typicons_dark/icons.conf +++ b/novelwriter/assets/icons/typicons_dark/icons.conf @@ -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 diff --git a/novelwriter/assets/icons/typicons_dark/mixed_document-section.svg b/novelwriter/assets/icons/typicons_dark/mixed_document-section.svg new file mode 100644 index 00000000..69542bd9 --- /dev/null +++ b/novelwriter/assets/icons/typicons_dark/mixed_document-section.svg @@ -0,0 +1,47 @@ + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/novelwriter/assets/icons/typicons_dark/mixed_heading0.svg b/novelwriter/assets/icons/typicons_dark/mixed_heading0.svg deleted file mode 100644 index 65ca642c..00000000 --- a/novelwriter/assets/icons/typicons_dark/mixed_heading0.svg +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - diff --git a/novelwriter/assets/icons/typicons_dark/mixed_heading1.svg b/novelwriter/assets/icons/typicons_dark/mixed_heading1.svg deleted file mode 100644 index faad4e82..00000000 --- a/novelwriter/assets/icons/typicons_dark/mixed_heading1.svg +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - - diff --git a/novelwriter/assets/icons/typicons_dark/mixed_heading2.svg b/novelwriter/assets/icons/typicons_dark/mixed_heading2.svg deleted file mode 100644 index 08c8ad09..00000000 --- a/novelwriter/assets/icons/typicons_dark/mixed_heading2.svg +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - - - diff --git a/novelwriter/assets/icons/typicons_dark/mixed_heading3.svg b/novelwriter/assets/icons/typicons_dark/mixed_heading3.svg deleted file mode 100644 index b5a35871..00000000 --- a/novelwriter/assets/icons/typicons_dark/mixed_heading3.svg +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - - - - diff --git a/novelwriter/assets/icons/typicons_dark/mixed_heading4.svg b/novelwriter/assets/icons/typicons_dark/mixed_heading4.svg deleted file mode 100644 index 718c0746..00000000 --- a/novelwriter/assets/icons/typicons_dark/mixed_heading4.svg +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - - - - - - - diff --git a/novelwriter/assets/icons/typicons_dark/mixed_input-checked.svg b/novelwriter/assets/icons/typicons_dark/mixed_input-checked.svg new file mode 100644 index 00000000..a044fbaa --- /dev/null +++ b/novelwriter/assets/icons/typicons_dark/mixed_input-checked.svg @@ -0,0 +1,39 @@ + + + + + + + image/svg+xml + + + + + + + + diff --git a/novelwriter/assets/icons/typicons_dark/mixed_input-none.svg b/novelwriter/assets/icons/typicons_dark/mixed_input-none.svg new file mode 100644 index 00000000..105e1d8f --- /dev/null +++ b/novelwriter/assets/icons/typicons_dark/mixed_input-none.svg @@ -0,0 +1,44 @@ + + + + + + + image/svg+xml + + + + + + + + diff --git a/novelwriter/assets/icons/typicons_dark/mixed_input-unchecked.svg b/novelwriter/assets/icons/typicons_dark/mixed_input-unchecked.svg new file mode 100644 index 00000000..b5ee8b37 --- /dev/null +++ b/novelwriter/assets/icons/typicons_dark/mixed_input-unchecked.svg @@ -0,0 +1,39 @@ + + + + + + + image/svg+xml + + + + + + + + diff --git a/novelwriter/assets/icons/typicons_dark/nw_deco-noveltree-more.svg b/novelwriter/assets/icons/typicons_dark/nw_deco-noveltree-more.svg new file mode 100644 index 00000000..f42d3306 --- /dev/null +++ b/novelwriter/assets/icons/typicons_dark/nw_deco-noveltree-more.svg @@ -0,0 +1,30 @@ + + + + + + image/svg+xml + + + + + + + diff --git a/novelwriter/assets/icons/typicons_dark/typ_backspace.svg b/novelwriter/assets/icons/typicons_dark/typ_backspace.svg deleted file mode 100644 index f0f8ac94..00000000 --- a/novelwriter/assets/icons/typicons_dark/typ_backspace.svg +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - diff --git a/novelwriter/assets/icons/typicons_dark/typ_bookmark.svg b/novelwriter/assets/icons/typicons_dark/typ_bookmark.svg index e56ebbee..bea4bafa 100644 --- a/novelwriter/assets/icons/typicons_dark/typ_bookmark.svg +++ b/novelwriter/assets/icons/typicons_dark/typ_bookmark.svg @@ -9,9 +9,9 @@ width="24" height="24" viewBox="0 0 24 24" - id="svg2608"> + id="svg901"> + id="metadata907"> @@ -23,9 +23,9 @@ + id="defs905" /> + id="path899" + style="fill:#6699cc;fill-opacity:1;stroke-width:1.05081" /> diff --git a/novelwriter/assets/icons/typicons_dark/typ_download.svg b/novelwriter/assets/icons/typicons_dark/typ_download.svg deleted file mode 100644 index e53e6fca..00000000 --- a/novelwriter/assets/icons/typicons_dark/typ_download.svg +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - - diff --git a/novelwriter/assets/icons/typicons_dark/typ_globe.svg b/novelwriter/assets/icons/typicons_dark/typ_globe.svg deleted file mode 100644 index aef68b3b..00000000 --- a/novelwriter/assets/icons/typicons_dark/typ_globe.svg +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - diff --git a/novelwriter/assets/icons/typicons_dark/typ_hash.svg b/novelwriter/assets/icons/typicons_dark/typ_hash.svg deleted file mode 100644 index 2b7419ec..00000000 --- a/novelwriter/assets/icons/typicons_dark/typ_hash.svg +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - diff --git a/novelwriter/assets/icons/typicons_dark/typ_input-checked.svg b/novelwriter/assets/icons/typicons_dark/typ_input-checked.svg deleted file mode 100644 index 0ef2facc..00000000 --- a/novelwriter/assets/icons/typicons_dark/typ_input-checked.svg +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - - diff --git a/novelwriter/assets/icons/typicons_dark/typ_mod_search-replace.svg b/novelwriter/assets/icons/typicons_dark/typ_mod_search-replace.svg deleted file mode 100644 index 588174a5..00000000 --- a/novelwriter/assets/icons/typicons_dark/typ_mod_search-replace.svg +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - - - diff --git a/novelwriter/assets/icons/typicons_dark/typ_stopwatch.svg b/novelwriter/assets/icons/typicons_dark/typ_stopwatch.svg deleted file mode 100644 index 345a74d0..00000000 --- a/novelwriter/assets/icons/typicons_dark/typ_stopwatch.svg +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - - - diff --git a/novelwriter/assets/icons/typicons_dark/typ_tick.svg b/novelwriter/assets/icons/typicons_dark/typ_tick.svg deleted file mode 100644 index 84383114..00000000 --- a/novelwriter/assets/icons/typicons_dark/typ_tick.svg +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - diff --git a/novelwriter/assets/icons/typicons_light/icons.conf b/novelwriter/assets/icons/typicons_light/icons.conf index 8d267a47..5212b685 100644 --- a/novelwriter/assets/icons/typicons_light/icons.conf +++ b/novelwriter/assets/icons/typicons_light/icons.conf @@ -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 diff --git a/novelwriter/assets/icons/typicons_light/mixed_document-section.svg b/novelwriter/assets/icons/typicons_light/mixed_document-section.svg new file mode 100644 index 00000000..28dc015a --- /dev/null +++ b/novelwriter/assets/icons/typicons_light/mixed_document-section.svg @@ -0,0 +1,47 @@ + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/novelwriter/assets/icons/typicons_light/mixed_heading0.svg b/novelwriter/assets/icons/typicons_light/mixed_heading0.svg deleted file mode 100644 index 87155b79..00000000 --- a/novelwriter/assets/icons/typicons_light/mixed_heading0.svg +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - diff --git a/novelwriter/assets/icons/typicons_light/mixed_heading1.svg b/novelwriter/assets/icons/typicons_light/mixed_heading1.svg deleted file mode 100644 index 89276c97..00000000 --- a/novelwriter/assets/icons/typicons_light/mixed_heading1.svg +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - - diff --git a/novelwriter/assets/icons/typicons_light/mixed_heading2.svg b/novelwriter/assets/icons/typicons_light/mixed_heading2.svg deleted file mode 100644 index 5f69658e..00000000 --- a/novelwriter/assets/icons/typicons_light/mixed_heading2.svg +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - - - diff --git a/novelwriter/assets/icons/typicons_light/mixed_heading3.svg b/novelwriter/assets/icons/typicons_light/mixed_heading3.svg deleted file mode 100644 index b7a33025..00000000 --- a/novelwriter/assets/icons/typicons_light/mixed_heading3.svg +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - - - - diff --git a/novelwriter/assets/icons/typicons_light/mixed_heading4.svg b/novelwriter/assets/icons/typicons_light/mixed_heading4.svg deleted file mode 100644 index 50b0b351..00000000 --- a/novelwriter/assets/icons/typicons_light/mixed_heading4.svg +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - - - - - diff --git a/novelwriter/assets/icons/typicons_light/mixed_input-checked.svg b/novelwriter/assets/icons/typicons_light/mixed_input-checked.svg new file mode 100644 index 00000000..8e42a8b6 --- /dev/null +++ b/novelwriter/assets/icons/typicons_light/mixed_input-checked.svg @@ -0,0 +1,39 @@ + + + + + + + image/svg+xml + + + + + + + + diff --git a/novelwriter/assets/icons/typicons_light/mixed_input-none.svg b/novelwriter/assets/icons/typicons_light/mixed_input-none.svg new file mode 100644 index 00000000..f80dd7ff --- /dev/null +++ b/novelwriter/assets/icons/typicons_light/mixed_input-none.svg @@ -0,0 +1,44 @@ + + + + + + + image/svg+xml + + + + + + + + diff --git a/novelwriter/assets/icons/typicons_light/mixed_input-unchecked.svg b/novelwriter/assets/icons/typicons_light/mixed_input-unchecked.svg new file mode 100644 index 00000000..0bd5f6dc --- /dev/null +++ b/novelwriter/assets/icons/typicons_light/mixed_input-unchecked.svg @@ -0,0 +1,39 @@ + + + + + + + image/svg+xml + + + + + + + + diff --git a/novelwriter/assets/icons/typicons_light/nw_deco-noveltree-more.svg b/novelwriter/assets/icons/typicons_light/nw_deco-noveltree-more.svg new file mode 100644 index 00000000..f42d3306 --- /dev/null +++ b/novelwriter/assets/icons/typicons_light/nw_deco-noveltree-more.svg @@ -0,0 +1,30 @@ + + + + + + image/svg+xml + + + + + + + diff --git a/novelwriter/assets/icons/typicons_light/typ_backspace.svg b/novelwriter/assets/icons/typicons_light/typ_backspace.svg deleted file mode 100644 index 167dba0f..00000000 --- a/novelwriter/assets/icons/typicons_light/typ_backspace.svg +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - diff --git a/novelwriter/assets/icons/typicons_light/typ_bookmark.svg b/novelwriter/assets/icons/typicons_light/typ_bookmark.svg index 3936ba30..e7fa1eee 100644 --- a/novelwriter/assets/icons/typicons_light/typ_bookmark.svg +++ b/novelwriter/assets/icons/typicons_light/typ_bookmark.svg @@ -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"> + id="metadata907"> @@ -23,9 +23,9 @@ + id="defs905" /> + 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" /> diff --git a/novelwriter/assets/icons/typicons_light/typ_download.svg b/novelwriter/assets/icons/typicons_light/typ_download.svg deleted file mode 100644 index eb84cf04..00000000 --- a/novelwriter/assets/icons/typicons_light/typ_download.svg +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - - diff --git a/novelwriter/assets/icons/typicons_light/typ_globe.svg b/novelwriter/assets/icons/typicons_light/typ_globe.svg deleted file mode 100644 index c6f46eb4..00000000 --- a/novelwriter/assets/icons/typicons_light/typ_globe.svg +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - diff --git a/novelwriter/assets/icons/typicons_light/typ_hash.svg b/novelwriter/assets/icons/typicons_light/typ_hash.svg deleted file mode 100644 index ced3c240..00000000 --- a/novelwriter/assets/icons/typicons_light/typ_hash.svg +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - diff --git a/novelwriter/assets/icons/typicons_light/typ_input-checked.svg b/novelwriter/assets/icons/typicons_light/typ_input-checked.svg deleted file mode 100644 index 6a2124fe..00000000 --- a/novelwriter/assets/icons/typicons_light/typ_input-checked.svg +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - - diff --git a/novelwriter/assets/icons/typicons_light/typ_stopwatch.svg b/novelwriter/assets/icons/typicons_light/typ_stopwatch.svg deleted file mode 100644 index 669d84e9..00000000 --- a/novelwriter/assets/icons/typicons_light/typ_stopwatch.svg +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - - - diff --git a/novelwriter/assets/icons/typicons_light/typ_tick.svg b/novelwriter/assets/icons/typicons_light/typ_tick.svg deleted file mode 100644 index 1c24677a..00000000 --- a/novelwriter/assets/icons/typicons_light/typ_tick.svg +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - diff --git a/novelwriter/assets/text/release_notes.htm b/novelwriter/assets/text/release_notes.htm index aeb5fbc7..5a529b14 100644 --- a/novelwriter/assets/text/release_notes.htm +++ b/novelwriter/assets/text/release_notes.htm @@ -2,11 +2,11 @@ -

Release Notes for 1.7 Beta 1

-

Released on 17 May 2022

+

Release Notes for 2.0 RC 2

+

Released on 13 November 2022

-

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 +

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.

diff --git a/novelwriter/assets/themes/default.conf b/novelwriter/assets/themes/default.conf index 004b811d..aa3691e1 100644 --- a/novelwriter/assets/themes/default.conf +++ b/novelwriter/assets/themes/default.conf @@ -1,2 +1,4 @@ [Main] -name = Default System Theme +name = Default Theme +description = Qt standard colours +icontheme = typicons_light \ No newline at end of file diff --git a/novelwriter/assets/themes/default_dark.conf b/novelwriter/assets/themes/default_dark.conf index 14d0ad51..05a9b4ce 100644 --- a/novelwriter/assets/themes/default_dark.conf +++ b/novelwriter/assets/themes/default_dark.conf @@ -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 diff --git a/novelwriter/assets/themes/default_dark.qss b/novelwriter/assets/themes/default_dark.qss deleted file mode 100644 index 44f94ec6..00000000 --- a/novelwriter/assets/themes/default_dark.qss +++ /dev/null @@ -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. - */ diff --git a/novelwriter/assets/themes/solarized_dark.conf b/novelwriter/assets/themes/solarized_dark.conf index 81a85a16..cf46d519 100644 --- a/novelwriter/assets/themes/solarized_dark.conf +++ b/novelwriter/assets/themes/solarized_dark.conf @@ -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 diff --git a/novelwriter/assets/themes/solarized_light.conf b/novelwriter/assets/themes/solarized_light.conf index 855e4e76..c9fb50c0 100644 --- a/novelwriter/assets/themes/solarized_light.conf +++ b/novelwriter/assets/themes/solarized_light.conf @@ -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 diff --git a/novelwriter/common.py b/novelwriter/common.py index 69f6bc05..c712015a 100644 --- a/novelwriter/common.py +++ b/novelwriter/common.py @@ -23,11 +23,12 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -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 diff --git a/novelwriter/config.py b/novelwriter/config.py index 7fb334c2..b6708add 100644 --- a/novelwriter/config.py +++ b/novelwriter/config.py @@ -23,12 +23,12 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import os import sys import json import logging from time import time +from pathlib import Path from PyQt5.Qt import PYQT_VERSION_STR from PyQt5.QtCore import ( @@ -37,7 +37,7 @@ from PyQt5.QtCore import ( ) from novelwriter.error import logException, formatException -from novelwriter.common import splitVersionNumber, formatTimeStamp, NWConfigParser +from novelwriter.common import checkPath, splitVersionNumber, formatTimeStamp, NWConfigParser from novelwriter.constants import nwFiles, nwUnicode logger = logging.getLogger(__name__) @@ -50,74 +50,80 @@ class Config: def __init__(self): + # Initialisation + # ============== + # Set Application Variables self.appName = "novelWriter" - self.appHandle = self.appName.lower() - - # Config Error Handling - self.hasError = False # True if the config class encountered an error - self.errData = [] # List of error messages + self.appHandle = "novelwriter" # Set Paths - self.cmdOpen = None # Path from command line for project to be opened on launch - self.confPath = None # Folder where the config is saved - self.confFile = None # The config file name - self.dataPath = None # Folder where app data is stored - self.lastPath = None # The last user-selected folder (browse dialogs) - self.appPath = None # The full path to the novelwriter package folder - self.appRoot = None # The full path to the novelwriter root folder - self.appIcon = None # The full path to the novelwriter icon file - self.assetPath = None # The full path to the novelwriter/assets folder - self.pdfDocs = None # The location of the PDF manual, if it exists + confRoot = Path(QStandardPaths.writableLocation(QStandardPaths.ConfigLocation)) + dataRoot = Path(QStandardPaths.writableLocation(QStandardPaths.AppDataLocation)) + + self._confPath = confRoot.absolute() / self.appHandle # The user config location + self._dataPath = dataRoot.absolute() / self.appHandle # The user data location + self._homePath = Path.home().absolute() # The user's home directory + + self._appPath = Path(__file__).parent.absolute() + self._appRoot = self._appPath.parent + if self._appRoot.is_file(): + # novelWriter is packaged as a single file + self._appRoot = self._appRoot.parent + self._appPath = self._appRoot # Runtime Settings and Variables - self.confChanged = False # True whenever the config has chenged, false after save - - # General - self.guiTheme = "" # GUI theme - self.guiSyntax = "" # Syntax theme - self.guiIcons = "" # Icon theme - self.guiFont = "" # Defaults to system default font - self.guiFontSize = 11 # Is overridden if system default is loaded - self.guiScale = 1.0 # Set automatically by Theme class - self.lastNotes = "0x0" # The latest release notes that have been shown - - self.setDefaultGuiTheme() - self.setDefaultSyntaxTheme() - self.setDefaultIconTheme() + self._hasError = False # True if the config class encountered an error + self._errData = [] # List of error messages # Localisation - self.qLocal = QLocale.system() - self.guiLang = self.qLocal.name() - self.qtLangPath = QLibraryInfo.location(QLibraryInfo.TranslationsPath) - self.nwLangPath = None - self.qtTrans = {} + # Note that these paths must be strings + self._qLocale = QLocale.system() + self._qtTrans = {} + self._qtLangPath = QLibraryInfo.location(QLibraryInfo.TranslationsPath) + self._nwLangPath = str(self._appPath / "assets" / "i18n") - # Sizes - self.winGeometry = [1200, 650] - self.prefGeometry = [700, 615] - self.treeColWidth = [200, 50, 30] - self.novelColWidth = [200, 50] - self.projColWidth = [200, 60, 140] - self.mainPanePos = [300, 800] - self.docPanePos = [400, 400] - self.viewPanePos = [500, 150] - self.outlnPanePos = [500, 150] - self.isFullScreen = False + # PDF Manual + pdfDocs = self._appPath / "assets" / "manual.pdf" + self.pdfDocs = pdfDocs if pdfDocs.is_file() else None - # Features - self.hideVScroll = False # Hide vertical scroll bars on main widgets - self.hideHScroll = False # Hide horizontal scroll bars on main widgets - self.emphLabels = True # Add emphasis to H1 and H2 item labels + # User Settings + # ============= - # Project - self.autoSaveProj = 60 # Interval for auto-saving project in seconds - self.autoSaveDoc = 30 # Interval for auto-saving document in seconds + self._recentProj = RecentProjects(self) - # Text Editor + # General GUI Settings + self.guiLocale = self._qLocale.name() + self.guiTheme = "default" # GUI theme + self.guiSyntax = "default_light" # Syntax theme + self.guiFont = "" # Defaults to system default font in theme class + self.guiFontSize = 11 # Is overridden if system default is loaded + self.guiScale = 1.0 # Set automatically by Theme class + self.hideVScroll = False # Hide vertical scroll bars on main widgets + self.hideHScroll = False # Hide horizontal scroll bars on main widgets + self.lastNotes = "0x0" # The latest release notes that have been shown + self._lastPath = self._homePath # The user's last used path + + # Size Settings + self._mainWinSize = [1200, 650] # Last size of the main GUI window + self._prefsWinSize = [700, 615] # Last size of the Preferences dialog + self._projLoadCols = [280, 60, 160] # Last columns withs of the Project Load dialog + self._mainPanePos = [300, 800] # Last position of the main window splitter + self._viewPanePos = [500, 150] # Last position of the document viewer splitter + self._outlnPanePos = [500, 150] # Last position of the outline panel splitter + + # Project Settings + self.autoSaveProj = 60 # Interval for auto-saving project, in seconds + self.autoSaveDoc = 30 # Interval for auto-saving document, in seconds + self.emphLabels = True # Add emphasis to H1 and H2 item labels + self._backupPath = None # Backup path to use, can be none + self.backupOnClose = False # Flag for running automatic backups + self.askBeforeBackup = True # Flag for asking before running automatic backup + + # Text Editor Settings self.textFont = None # Editor font self.textSize = 12 # Editor font size - self.textWidth = 600 # Editor text width + self.textWidth = 700 # Editor text width self.textMargin = 40 # Editor/viewer text margin self.tabWidth = 40 # Editor tabulator width @@ -153,16 +159,24 @@ class Config: self.stopWhenIdle = True # Stop the status bar clock when the user is idle self.userIdleTime = 300 # Time of inactivity to consider user idle - # User-Selected Symbols + # User-Selected Symbol Settings self.fmtApostrophe = nwUnicode.U_RSQUO - self.fmtSingleQuotes = [nwUnicode.U_LSQUO, nwUnicode.U_RSQUO] - self.fmtDoubleQuotes = [nwUnicode.U_LDQUO, nwUnicode.U_RDQUO] + self.fmtSQuoteOpen = nwUnicode.U_LSQUO + self.fmtSQuoteClose = nwUnicode.U_RSQUO + self.fmtDQuoteOpen = nwUnicode.U_LDQUO + self.fmtDQuoteClose = nwUnicode.U_RDQUO self.fmtPadBefore = "" self.fmtPadAfter = "" self.fmtPadThin = False - # Spell Checking - self.spellLanguage = None + # Spell Checking Settings + self.spellLanguage = "en" + + # State + self.isFullScreen = False # Last fullscreen state + self.showRefPanel = True # The reference panel for the viewer is visible + self.viewComments = True # Comments are shown in the viewer + self.viewSynopsis = True # Synopsis is shown in the viewer # Search Bar Switches self.searchCase = False @@ -172,15 +186,8 @@ class Config: self.searchNextFile = False self.searchMatchCap = False - # Backup - self.backupPath = "" - self.backupOnClose = False - self.askBeforeBackup = True - - # State - self.showRefPanel = True # The reference panel for the viewer is visible - self.viewComments = True # Comments are shown in the viewer - self.viewSynopsis = True # Synopsis is shown in the viewer + # System and App Information + # ========================== # Check Qt5 Versions verQt = splitVersionNumber(QT_VERSION_STR) @@ -222,15 +229,129 @@ class Config: self.osUnknown = True # Other System Info - self.hostName = "Unknown" - self.kernelVer = "Unknown" + self.hostName = QSysInfo.machineHostName() + self.kernelVer = QSysInfo.kernelVersion() # Packages self.hasEnchant = False # The pyenchant package - # Recent Cache - self.recentProj = {} + return + ## + # Properties + ## + + @property + def hasError(self): + return self._hasError + + @property + def recentProjects(self): + return self._recentProj + + @property + def mainWinSize(self): + return [int(x*self.guiScale) for x in self._mainWinSize] + + @property + def preferencesWinSize(self): + return [int(x*self.guiScale) for x in self._prefsWinSize] + + @property + def projLoadColWidths(self): + return [int(x*self.guiScale) for x in self._projLoadCols] + + @property + def mainPanePos(self): + return [int(x*self.guiScale) for x in self._mainPanePos] + + @property + def viewPanePos(self): + return [int(x*self.guiScale) for x in self._viewPanePos] + + @property + def outlinePanePos(self): + return [int(x*self.guiScale) for x in self._outlnPanePos] + + ## + # Getters + ## + + def getTextWidth(self, focusMode=False): + """Get the text with for the correct editor mode.""" + if focusMode: + return self.pxInt(max(self.focusWidth, 200)) + else: + return self.pxInt(max(self.textWidth, 200)) + + def getTextMargin(self): + """Get the scaled text margin.""" + return self.pxInt(max(self.textMargin, 0)) + + def getTabWidth(self): + """Get the scaled tab width.""" + return self.pxInt(max(self.tabWidth, 0)) + + ## + # Setters + ## + + def setMainWinSize(self, newWidth, newHeight): + """Set the size of the main window, but only if the change is + larger than 5 pixels. The OS window manager will sometimes + adjust it a bit, and we don't want the main window to shrink or + grow each time the app is opened. + """ + newWidth = int(newWidth/self.guiScale) + newHeight = int(newHeight/self.guiScale) + if abs(self._mainWinSize[0] - newWidth) > 5: + self._mainWinSize[0] = newWidth + if abs(self._mainWinSize[1] - newHeight) > 5: + self._mainWinSize[1] = newHeight + return + + def setPreferencesWinSize(self, newWidth, newHeight): + """Set the size of the Preferences dialog window.""" + self._prefsWinSize[0] = int(newWidth/self.guiScale) + self._prefsWinSize[1] = int(newHeight/self.guiScale) + return + + def setProjLoadColWidths(self, colWidths): + """Set the column widths of the Load Project dialog.""" + self._projLoadCols = [int(x/self.guiScale) for x in colWidths] + return + + def setMainPanePos(self, panePos): + """Set the position of the main GUI splitter.""" + self._mainPanePos = [int(x/self.guiScale) for x in panePos] + return + + def setViewPanePos(self, panePos): + """Set the position of the viewer meta data splitter.""" + self._viewPanePos = [int(x/self.guiScale) for x in panePos] + return + + def setOutlinePanePos(self, panePos): + """Set the position of the outline details splitter.""" + self._outlnPanePos = [int(x/self.guiScale) for x in panePos] + return + + def setLastPath(self, lastPath): + """Set the last used path. Only the folder is saved, so if the + path is not a folder, the parent of the path is used instead. + """ + if isinstance(lastPath, (str, Path)): + lastPath = checkPath(lastPath, self._homePath) + if not lastPath.is_dir(): + lastPath = lastPath.parent + if lastPath.is_dir(): + self._lastPath = lastPath + logger.debug("Last path updated: %s" % self._lastPath) + return + + def setBackupPath(self, backupPath): + """Set the current backup path.""" + self._backupPath = checkPath(backupPath, None) return ## @@ -249,157 +370,45 @@ class Config: """ return int(theSize/self.guiScale) - ## - # Config Actions - ## + def dataPath(self, target=None): + """Return a path in the data folder.""" + if isinstance(target, str): + return self._dataPath / target + return self._dataPath - def initConfig(self, confPath=None, dataPath=None): - """Initialise the config class. The manual setting of confPath - and dataPath is mainly intended for the test suite. + def assetPath(self, target=None): + """Return a path in the assets folder.""" + if isinstance(target, str): + return self._appPath / "assets" / target + return self._appPath / "assets" + + def lastPath(self): + """Return the last path used by the user, but ensure it exists. """ - logger.debug("Initialising Config ...") - if confPath is None: - confRoot = QStandardPaths.writableLocation(QStandardPaths.ConfigLocation) - self.confPath = os.path.join(os.path.abspath(confRoot), self.appHandle) - else: - logger.info("Setting config from alternative path: %s", confPath) - self.confPath = confPath + if isinstance(self._lastPath, Path): + if self._lastPath.is_dir(): + return self._lastPath + return self._homePath - if dataPath is None: - if self.verQtValue >= 50400: - dataRoot = QStandardPaths.writableLocation(QStandardPaths.AppDataLocation) - else: - dataRoot = QStandardPaths.writableLocation(QStandardPaths.DataLocation) - self.dataPath = os.path.join(os.path.abspath(dataRoot), self.appHandle) - else: - logger.info("Setting data path from alternative path: %s", dataPath) - self.dataPath = dataPath + def backupPath(self): + """Return the backup path.""" + if isinstance(self._backupPath, Path): + if self._backupPath.is_dir(): + return self._backupPath + return None - logger.verbose("Config path: %s", self.confPath) - logger.verbose("Data path: %s", self.dataPath) - - # Check Data Path Subdirs - dataDirs = ["syntax", "themes"] - for dataDir in dataDirs: - dirPath = os.path.join(self.dataPath, dataDir) - if not os.path.isdir(dirPath): - try: - os.mkdir(dirPath) - logger.info("Created folder: %s", dirPath) - except Exception: - logger.error("Could not create folder: %s", dirPath) - logException() - - self.confFile = self.appHandle+".conf" - self.lastPath = os.path.expanduser("~") - self.appPath = getattr(sys, "_MEIPASS", os.path.abspath(os.path.dirname(__file__))) - self.appRoot = os.path.abspath(os.path.join(self.appPath, os.path.pardir)) - - if os.path.isfile(self.appRoot): - # novelWriter is packaged as a single file, so the app and - # root paths are the same, and equal to the folder that - # contains the single executable. - self.appRoot = os.path.dirname(self.appRoot) - self.appPath = self.appRoot - - # Assets - self.assetPath = os.path.join(self.appPath, "assets") - self.appIcon = os.path.join(self.assetPath, "icons", "novelwriter.svg") - - # Internationalisation - self.nwLangPath = os.path.join(self.assetPath, "i18n") - - logger.debug("Assets: %s", self.assetPath) - logger.verbose("App path: %s", self.appPath) - logger.verbose("Last path: %s", self.lastPath) - - # If the config folder does not exist, create it. - # This assumes that the os config folder itself exists. - if not os.path.isdir(self.confPath): - try: - os.mkdir(self.confPath) - except Exception as exc: - logger.error("Could not create folder: %s", self.confPath) - logException() - self.hasError = True - self.errData.append("Could not create folder: %s" % self.confPath) - self.errData.append(formatException(exc)) - self.confPath = None - - # Check if config file exists - if self.confPath is not None: - if os.path.isfile(os.path.join(self.confPath, self.confFile)): - # If it exists, load it - self.loadConfig() - else: - # If it does not exist, save a copy of the default values - self.saveConfig() - - # If the data folder does not exist, create it. - # This assumes that the os data folder itself exists. - if self.dataPath is not None: - if not os.path.isdir(self.dataPath): - try: - os.mkdir(self.dataPath) - except Exception as exc: - logger.error("Could not create folder: %s", self.dataPath) - logException() - self.hasError = True - self.errData.append("Could not create folder: %s" % self.dataPath) - self.errData.append(formatException(exc)) - self.dataPath = None - - # Host and Kernel - if self.verQtValue >= 50600: - self.hostName = QSysInfo.machineHostName() - self.kernelVer = QSysInfo.kernelVersion() - - # Load recent projects cache - self.loadRecentCache() - - # Check the availability of optional packages - self._checkOptionalPackages() - - if self.spellLanguage is None: - self.spellLanguage = "en" - - # Look for a PDF version of the manual - pdfDocs = os.path.join(self.assetPath, "manual.pdf") - if os.path.isfile(pdfDocs): - logger.debug("Found manual: %s", pdfDocs) - self.pdfDocs = pdfDocs - - logger.debug("Config initialisation complete") - - return True - - def initLocalisation(self, nwApp): - """Initialise the localisation of the GUI. + def errorText(self): + """Compile and return error messages from the initialisation of + the Config class, and clear the error buffer. """ - self.qLocal = QLocale(self.guiLang) - QLocale.setDefault(self.qLocal) - self.qtTrans = {} - - langList = [ - (self.qtLangPath, "qtbase"), # Qt 5.x - (self.nwLangPath, "qtbase"), # Alternative Qt 5.x - (self.nwLangPath, "nw"), # novelWriter - ] - for lngPath, lngBase in langList: - for lngCode in self.qLocal.uiLanguages(): - qTrans = QTranslator() - lngFile = "%s_%s" % (lngBase, lngCode.replace("-", "_")) - if lngFile not in self.qtTrans: - if qTrans.load(lngFile, lngPath): - logger.debug("Loaded: %s", os.path.join(lngPath, lngFile)) - nwApp.installTranslator(qTrans) - self.qtTrans[lngFile] = qTrans - - return + errMessage = "
".join(self._errData) + self._hasError = False + self._errData = [] + return errMessage def listLanguages(self, lngSet): """List localisation files in the i18n folder. The default GUI - language 'en_GB' is British English. + language is British English (en_GB). """ if lngSet == self.LANG_NW: fPre = "nw_" @@ -412,68 +421,134 @@ class Config: else: return [] - for qmFile in os.listdir(self.nwLangPath): - if not os.path.isfile(os.path.join(self.nwLangPath, qmFile)): + for qmFile in Path(self._nwLangPath).iterdir(): + qmName = qmFile.name + if not (qmFile.is_file() and qmName.startswith(fPre) and qmName.endswith(fExt)): continue - if not qmFile.startswith(fPre) or not qmFile.endswith(fExt): - continue - qmLang = qmFile[len(fPre):-len(fExt)] + + qmLang = qmName[len(fPre):-len(fExt)] qmName = QLocale(qmLang).nativeLanguageName().title() if qmLang and qmName and qmLang != "en_GB": langList[qmLang] = qmName return sorted(langList.items(), key=lambda x: x[0]) + ## + # Config Actions + ## + + def initConfig(self, confPath=None, dataPath=None): + """Initialise the config class. The manual setting of confPath + and dataPath is mainly intended for the test suite. + """ + logger.debug("Initialising Config ...") + if isinstance(confPath, (str, Path)): + logger.info("Setting config from alternative path: %s", confPath) + self._confPath = Path(confPath) + if isinstance(dataPath, (str, Path)): + logger.info("Setting data path from alternative path: %s", dataPath) + self._dataPath = Path(dataPath) + + logger.debug("Config Path: %s", self._confPath) + logger.debug("Data Path: %s", self._dataPath) + logger.debug("App Root: %s", self._appRoot) + logger.debug("App Path: %s", self._appPath) + logger.debug("Last Path: %s", self._lastPath) + logger.debug("PDF Manual: %s", self.pdfDocs) + + # If the config and data folders don't exist, create them + # This assumes that the os config and data folders exist + self._confPath.mkdir(exist_ok=True) + self._dataPath.mkdir(exist_ok=True) + + # Also create the syntax and themes folders if possible + if self._dataPath.is_dir(): + (self._dataPath / "syntax").mkdir(exist_ok=True) + (self._dataPath / "themes").mkdir(exist_ok=True) + + # Check if config file exists, and load it. If not, we save defaults + if (self._confPath / nwFiles.CONF_FILE).is_file(): + self.loadConfig() + else: + self.saveConfig() + + self._recentProj.loadCache() + self._checkOptionalPackages() + + logger.debug("Config initialisation complete") + + return + + def initLocalisation(self, nwApp): + """Initialise the localisation of the GUI. + """ + self._qLocale = QLocale(self.guiLocale) + QLocale.setDefault(self._qLocale) + self._qtTrans = {} + + langList = [ + (self._qtLangPath, "qtbase"), # Qt 5.x + (self._nwLangPath, "nw"), # novelWriter + ] + for lngPath, lngBase in langList: + for lngCode in self._qLocale.uiLanguages(): + qTrans = QTranslator() + lngFile = "%s_%s" % (lngBase, lngCode.replace("-", "_")) + if lngFile not in self._qtTrans: + if qTrans.load(lngFile, lngPath): + logger.debug("Loaded: %s.qm", lngFile) + nwApp.installTranslator(qTrans) + self._qtTrans[lngFile] = qTrans + + return + def loadConfig(self): """Load preferences from file and replace default settings. """ logger.debug("Loading config file") - if self.confPath is None: - return False theConf = NWConfigParser() - cnfPath = os.path.join(self.confPath, self.confFile) + cnfPath = self._confPath / nwFiles.CONF_FILE try: with open(cnfPath, mode="r", encoding="utf-8") as inFile: theConf.read_file(inFile) except Exception as exc: logger.error("Could not load config file") logException() - self.hasError = True - self.errData.append("Could not load config file") - self.errData.append(formatException(exc)) + self._hasError = True + self._errData.append("Could not load config file") + self._errData.append(formatException(exc)) return False # Main cnfSec = "Main" self.guiTheme = theConf.rdStr(cnfSec, "theme", self.guiTheme) self.guiSyntax = theConf.rdStr(cnfSec, "syntax", self.guiSyntax) - self.guiIcons = theConf.rdStr(cnfSec, "icons", self.guiIcons) - self.guiFont = theConf.rdStr(cnfSec, "guifont", self.guiFont) - self.guiFontSize = theConf.rdInt(cnfSec, "guifontsize", self.guiFontSize) - self.lastNotes = theConf.rdStr(cnfSec, "lastnotes", self.lastNotes) - self.guiLang = theConf.rdStr(cnfSec, "guilang", self.guiLang) + self.guiFont = theConf.rdStr(cnfSec, "font", self.guiFont) + self.guiFontSize = theConf.rdInt(cnfSec, "fontsize", self.guiFontSize) + self.guiLocale = theConf.rdStr(cnfSec, "localisation", self.guiLocale) self.hideVScroll = theConf.rdBool(cnfSec, "hidevscroll", self.hideVScroll) self.hideHScroll = theConf.rdBool(cnfSec, "hidehscroll", self.hideHScroll) + self.lastNotes = theConf.rdStr(cnfSec, "lastnotes", self.lastNotes) + self._lastPath = theConf.rdPath(cnfSec, "lastpath", self._lastPath) # Sizes cnfSec = "Sizes" - self.winGeometry = theConf.rdIntList(cnfSec, "geometry", self.winGeometry) - self.prefGeometry = theConf.rdIntList(cnfSec, "preferences", self.prefGeometry) - self.treeColWidth = theConf.rdIntList(cnfSec, "treecols", self.treeColWidth) - self.novelColWidth = theConf.rdIntList(cnfSec, "novelcols", self.novelColWidth) - self.projColWidth = theConf.rdIntList(cnfSec, "projcols", self.projColWidth) - self.mainPanePos = theConf.rdIntList(cnfSec, "mainpane", self.mainPanePos) - self.docPanePos = theConf.rdIntList(cnfSec, "docpane", self.docPanePos) - self.viewPanePos = theConf.rdIntList(cnfSec, "viewpane", self.viewPanePos) - self.outlnPanePos = theConf.rdIntList(cnfSec, "outlinepane", self.outlnPanePos) - self.isFullScreen = theConf.rdBool(cnfSec, "fullscreen", self.isFullScreen) + self._mainWinSize = theConf.rdIntList(cnfSec, "mainwindow", self._mainWinSize) + self._prefsWinSize = theConf.rdIntList(cnfSec, "preferences", self._prefsWinSize) + self._projLoadCols = theConf.rdIntList(cnfSec, "projloadcols", self._projLoadCols) + self._mainPanePos = theConf.rdIntList(cnfSec, "mainpane", self._mainPanePos) + self._viewPanePos = theConf.rdIntList(cnfSec, "viewpane", self._viewPanePos) + self._outlnPanePos = theConf.rdIntList(cnfSec, "outlinepane", self._outlnPanePos) # Project cnfSec = "Project" - self.autoSaveProj = theConf.rdInt(cnfSec, "autosaveproject", self.autoSaveProj) - self.autoSaveDoc = theConf.rdInt(cnfSec, "autosavedoc", self.autoSaveDoc) - self.emphLabels = theConf.rdBool(cnfSec, "emphlabels", self.emphLabels) + self.autoSaveProj = theConf.rdInt(cnfSec, "autosaveproject", self.autoSaveProj) + self.autoSaveDoc = theConf.rdInt(cnfSec, "autosavedoc", self.autoSaveDoc) + self.emphLabels = theConf.rdBool(cnfSec, "emphlabels", self.emphLabels) + self._backupPath = theConf.rdPath(cnfSec, "backuppath", self._backupPath) + self.backupOnClose = theConf.rdBool(cnfSec, "backuponclose", self.backupOnClose) + self.askBeforeBackup = theConf.rdBool(cnfSec, "askbeforebackup", self.askBeforeBackup) # Editor cnfSec = "Editor" @@ -494,8 +569,10 @@ class Config: self.scrollPastEnd = theConf.rdInt(cnfSec, "scrollpastend", self.scrollPastEnd) self.autoScroll = theConf.rdBool(cnfSec, "autoscroll", self.autoScroll) self.autoScrollPos = theConf.rdInt(cnfSec, "autoscrollpos", self.autoScrollPos) - self.fmtSingleQuotes = theConf.rdStrList(cnfSec, "fmtsinglequote", self.fmtSingleQuotes) - self.fmtDoubleQuotes = theConf.rdStrList(cnfSec, "fmtdoublequote", self.fmtDoubleQuotes) + self.fmtSQuoteOpen = theConf.rdStr(cnfSec, "fmtsquoteopen", self.fmtSQuoteOpen) + self.fmtSQuoteClose = theConf.rdStr(cnfSec, "fmtsquoteclose", self.fmtSQuoteClose) + self.fmtDQuoteOpen = theConf.rdStr(cnfSec, "fmtdquoteopen", self.fmtDQuoteOpen) + self.fmtDQuoteClose = theConf.rdStr(cnfSec, "fmtdquoteclose", self.fmtDQuoteClose) self.fmtPadBefore = theConf.rdStr(cnfSec, "fmtpadbefore", self.fmtPadBefore) self.fmtPadAfter = theConf.rdStr(cnfSec, "fmtpadafter", self.fmtPadAfter) self.fmtPadThin = theConf.rdBool(cnfSec, "fmtpadthin", self.fmtPadThin) @@ -514,14 +591,9 @@ class Config: self.stopWhenIdle = theConf.rdBool(cnfSec, "stopwhenidle", self.stopWhenIdle) self.userIdleTime = theConf.rdInt(cnfSec, "useridletime", self.userIdleTime) - # Backup - cnfSec = "Backup" - self.backupPath = theConf.rdStr(cnfSec, "backuppath", self.backupPath) - self.backupOnClose = theConf.rdBool(cnfSec, "backuponclose", self.backupOnClose) - self.askBeforeBackup = theConf.rdBool(cnfSec, "askbeforebackup", self.askBeforeBackup) - # State cnfSec = "State" + self.isFullScreen = theConf.rdBool(cnfSec, "fullscreen", self.isFullScreen) self.showRefPanel = theConf.rdBool(cnfSec, "showrefpanel", self.showRefPanel) self.viewComments = theConf.rdBool(cnfSec, "viewcomments", self.viewComments) self.viewSynopsis = theConf.rdBool(cnfSec, "viewsynopsis", self.viewSynopsis) @@ -532,69 +604,80 @@ class Config: self.searchNextFile = theConf.rdBool(cnfSec, "searchnextfile", self.searchNextFile) self.searchMatchCap = theConf.rdBool(cnfSec, "searchmatchcap", self.searchMatchCap) - # Path - cnfSec = "Path" - self.lastPath = theConf.rdStr(cnfSec, "lastpath", self.lastPath) + # Deprecated Settings or Locations as of 2.0 + # ToDo: These will be loaded for a few minor releases until the users have converted them + self.guiFont = theConf.rdStr("Main", "guifont", self.guiFont) + self.guiFontSize = theConf.rdInt("Main", "guifontsize", self.guiFontSize) + self.guiLocale = theConf.rdStr("Main", "guilang", self.guiLocale) + self._backupPath = theConf.rdPath("Backup", "backuppath", self._backupPath) + self.backupOnClose = theConf.rdBool("Backup", "backuponclose", self.backupOnClose) + self.askBeforeBackup = theConf.rdBool("Backup", "askbeforebackup", self.askBeforeBackup) + fmtSingleQuotes = theConf.rdStrList(cnfSec, "fmtsinglequote", []) + fmtDoubleQuotes = theConf.rdStrList(cnfSec, "fmtdoublequote", []) + + if isinstance(fmtSingleQuotes, list) and len(fmtSingleQuotes) == 2: + self.fmtSQuoteOpen = fmtSingleQuotes[0] + self.fmtSQuoteClose = fmtSingleQuotes[1] + if isinstance(fmtDoubleQuotes, list) and len(fmtDoubleQuotes) == 2: + self.fmtDQuoteOpen = fmtDoubleQuotes[0] + self.fmtDQuoteClose = fmtDoubleQuotes[1] + + # Check Values + # ============ # Check Certain Values for None self.spellLanguage = self._checkNone(self.spellLanguage) # If we're using straight quotes, disable auto-replace - if self.fmtSingleQuotes == ["'", "'"] and self.doReplaceSQuote: + if self.fmtSQuoteOpen == self.fmtSQuoteClose == "'" and self.doReplaceSQuote: logger.info("Using straight single quotes, so disabling auto-replace") self.doReplaceSQuote = False - if self.fmtDoubleQuotes == ['"', '"'] and self.doReplaceDQuote: + if self.fmtDQuoteOpen == self.fmtDQuoteClose == '"' and self.doReplaceDQuote: logger.info("Using straight double quotes, so disabling auto-replace") self.doReplaceDQuote = False - # Check deprecated settings - if self.guiIcons in ("typicons_colour_dark", "typicons_grey_dark"): - self.guiIcons = "typicons_dark" - elif self.guiIcons in ("typicons_colour_light", "typicons_grey_light"): - self.guiIcons = "typicons_light" - return True def saveConfig(self): """Save the current preferences to file. """ logger.debug("Saving config file") - if self.confPath is None: - return False theConf = NWConfigParser() + theConf["Meta"] = { + "timestamp": formatTimeStamp(time()), + } + theConf["Main"] = { - "timestamp": formatTimeStamp(time()), - "theme": str(self.guiTheme), - "syntax": str(self.guiSyntax), - "icons": str(self.guiIcons), - "guifont": str(self.guiFont), - "guifontsize": str(self.guiFontSize), - "lastnotes": str(self.lastNotes), - "guilang": str(self.guiLang), - "hidevscroll": str(self.hideVScroll), - "hidehscroll": str(self.hideHScroll), + "theme": str(self.guiTheme), + "syntax": str(self.guiSyntax), + "font": str(self.guiFont), + "fontsize": str(self.guiFontSize), + "localisation": str(self.guiLocale), + "hidevscroll": str(self.hideVScroll), + "hidehscroll": str(self.hideHScroll), + "lastnotes": str(self.lastNotes), + "lastpath": str(self._lastPath), } theConf["Sizes"] = { - "geometry": self._packList(self.winGeometry), - "preferences": self._packList(self.prefGeometry), - "treecols": self._packList(self.treeColWidth), - "novelcols": self._packList(self.novelColWidth), - "projcols": self._packList(self.projColWidth), - "mainpane": self._packList(self.mainPanePos), - "docpane": self._packList(self.docPanePos), - "viewpane": self._packList(self.viewPanePos), - "outlinepane": self._packList(self.outlnPanePos), - "fullscreen": str(self.isFullScreen), + "mainwindow": self._packList(self._mainWinSize), + "preferences": self._packList(self._prefsWinSize), + "projloadcols": self._packList(self._projLoadCols), + "mainpane": self._packList(self._mainPanePos), + "viewpane": self._packList(self._viewPanePos), + "outlinepane": self._packList(self._outlnPanePos), } theConf["Project"] = { "autosaveproject": str(self.autoSaveProj), "autosavedoc": str(self.autoSaveDoc), "emphlabels": str(self.emphLabels), + "backuppath": str(self._backupPath or ""), + "backuponclose": str(self.backupOnClose), + "askbeforebackup": str(self.askBeforeBackup), } theConf["Editor"] = { @@ -615,8 +698,10 @@ class Config: "scrollpastend": str(self.scrollPastEnd), "autoscroll": str(self.autoScroll), "autoscrollpos": str(self.autoScrollPos), - "fmtsinglequote": self._packList(self.fmtSingleQuotes), - "fmtdoublequote": self._packList(self.fmtDoubleQuotes), + "fmtsquoteopen": str(self.fmtSQuoteOpen), + "fmtsquoteclose": str(self.fmtSQuoteClose), + "fmtdquoteopen": str(self.fmtDQuoteOpen), + "fmtdquoteclose": str(self.fmtDQuoteClose), "fmtpadbefore": str(self.fmtPadBefore), "fmtpadafter": str(self.fmtPadAfter), "fmtpadthin": str(self.fmtPadThin), @@ -636,13 +721,8 @@ class Config: "useridletime": str(self.userIdleTime), } - theConf["Backup"] = { - "backuppath": str(self.backupPath), - "backuponclose": str(self.backupOnClose), - "askbeforebackup": str(self.askBeforeBackup), - } - theConf["State"] = { + "fullscreen": str(self.isFullScreen), "showrefpanel": str(self.showRefPanel), "viewcomments": str(self.viewComments), "viewsynopsis": str(self.viewSynopsis), @@ -654,304 +734,21 @@ class Config: "searchmatchcap": str(self.searchMatchCap), } - theConf["Path"] = { - "lastpath": str(self.lastPath), - } - # Write config file - cnfPath = os.path.join(self.confPath, self.confFile) + cnfPath = self._confPath / nwFiles.CONF_FILE try: with open(cnfPath, mode="w", encoding="utf-8") as outFile: theConf.write(outFile) - self.confChanged = False except Exception as exc: logger.error("Could not save config file") logException() - self.hasError = True - self.errData.append("Could not save config file") - self.errData.append(formatException(exc)) + self._hasError = True + self._errData.append("Could not save config file") + self._errData.append(formatException(exc)) return False return True - def loadRecentCache(self): - """Load the cache file for recent projects. - """ - if self.dataPath is None: - return False - - self.recentProj = {} - - cacheFile = os.path.join(self.dataPath, nwFiles.RECENT_FILE) - if not os.path.isfile(cacheFile): - return True - - try: - with open(cacheFile, mode="r", encoding="utf-8") as inFile: - theData = json.load(inFile) - - for projPath, theEntry in theData.items(): - self.recentProj[projPath] = { - "title": theEntry.get("title", ""), - "time": theEntry.get("time", 0), - "words": theEntry.get("words", 0), - } - - except Exception as exc: - self.hasError = True - self.errData.append("Could not load recent project cache") - self.errData.append(formatException(exc)) - return False - - return True - - def saveRecentCache(self): - """Save the cache dictionary of recent projects. - """ - if self.dataPath is None: - return False - - cacheFile = os.path.join(self.dataPath, nwFiles.RECENT_FILE) - cacheTemp = os.path.join(self.dataPath, nwFiles.RECENT_FILE+"~") - - try: - with open(cacheTemp, mode="w+", encoding="utf-8") as outFile: - json.dump(self.recentProj, outFile, indent=2) - except Exception as exc: - self.hasError = True - self.errData.append("Could not save recent project cache") - self.errData.append(formatException(exc)) - return False - - if os.path.isfile(cacheFile): - os.unlink(cacheFile) - os.rename(cacheTemp, cacheFile) - - return True - - def updateRecentCache(self, projPath, projTitle, wordCount, saveTime): - """Add or update recent cache information on a given project. - """ - self.recentProj[os.path.abspath(projPath)] = { - "title": projTitle, - "time": int(saveTime), - "words": int(wordCount), - } - return True - - def removeFromRecentCache(self, thePath): - """Trying to remove a path from the recent projects cache. - """ - if thePath in self.recentProj: - del self.recentProj[thePath] - logger.verbose("Removed recent: %s", thePath) - self.saveRecentCache() - else: - logger.error("Unknown recent: %s", thePath) - return False - return True - - ## - # Setters - ## - - def setConfPath(self, newPath): - """Set the path and filename to the config file. - """ - if newPath is None: - return True - if not os.path.isfile(newPath): - logger.error("File not found, using default config path instead") - return False - self.confPath = os.path.dirname(newPath) - self.confFile = os.path.basename(newPath) - return True - - def setDataPath(self, newPath): - """Set the data path. - """ - if newPath is None: - return True - if not os.path.isdir(newPath): - logger.error("Path not found, using default data path instead") - return False - self.dataPath = os.path.abspath(newPath) - return True - - def setLastPath(self, lastPath): - """Set the last used path (by the user). - """ - if lastPath is None or lastPath == "": - self.lastPath = "" - else: - self.lastPath = os.path.dirname(lastPath) - return True - - def setWinSize(self, newWidth, newHeight): - """Set the size of the main window, but only if the change is - larger than 5 pixels. The OS window manager will sometimes - adjust it a bit, and we don't want the main window to shrink or - grow each time the app is opened. - """ - newWidth = int(newWidth/self.guiScale) - newHeight = int(newHeight/self.guiScale) - if abs(self.winGeometry[0] - newWidth) > 5: - self.winGeometry[0] = newWidth - self.confChanged = True - if abs(self.winGeometry[1] - newHeight) > 5: - self.winGeometry[1] = newHeight - self.confChanged = True - return True - - def setPreferencesSize(self, newWidth, newHeight): - """Sat the size of the Preferences dialog window. - """ - self.prefGeometry[0] = int(newWidth/self.guiScale) - self.prefGeometry[1] = int(newHeight/self.guiScale) - self.confChanged = True - return True - - def setTreeColWidths(self, colWidths): - """Set the column widths of the main project tree. - """ - self.treeColWidth = [int(x/self.guiScale) for x in colWidths] - self.confChanged = True - return True - - def setNovelColWidths(self, colWidths): - """Set the column widths of the novel tree. - """ - self.novelColWidth = [int(x/self.guiScale) for x in colWidths] - self.confChanged = True - return True - - def setProjColWidths(self, colWidths): - """Set the column widths of the Load Project dialog. - """ - self.projColWidth = [int(x/self.guiScale) for x in colWidths] - self.confChanged = True - return True - - def setMainPanePos(self, panePos): - """Set the position of the main GUI splitter. - """ - self.mainPanePos = [int(x/self.guiScale) for x in panePos] - self.confChanged = True - return True - - def setDocPanePos(self, panePos): - """Set the position of the main editor/viewer splitter. - """ - self.docPanePos = [int(x/self.guiScale) for x in panePos] - self.confChanged = True - return True - - def setViewPanePos(self, panePos): - """Set the position of the viewer meta data splitter. - """ - self.viewPanePos = [int(x/self.guiScale) for x in panePos] - self.confChanged = True - return True - - def setOutlinePanePos(self, panePos): - """Set the position of the outline details splitter. - """ - self.outlnPanePos = [int(x/self.guiScale) for x in panePos] - self.confChanged = True - return True - - def setShowRefPanel(self, checkState): - """Set the visibility state of the reference panel. - """ - self.showRefPanel = checkState - self.confChanged = True - return self.showRefPanel - - def setViewComments(self, viewState): - """Set the visibility state of comments in the viewer. - """ - self.viewComments = viewState - self.confChanged = True - return self.viewComments - - def setViewSynopsis(self, viewState): - """Set the visibility state of synopsis comments in the viewer. - """ - self.viewSynopsis = viewState - self.confChanged = True - return self.viewSynopsis - - ## - # Default Setters - ## - - def setDefaultGuiTheme(self): - """Reset the GUI theme to default value. - """ - self.guiTheme = "default" - - def setDefaultSyntaxTheme(self): - """Reset the syntax theme to default value. - """ - self.guiSyntax = "default_light" - - def setDefaultIconTheme(self): - """Reset the icon theme to default value. - """ - self.guiIcons = "typicons_light" - - ## - # Getters - ## - - def getWinSize(self): - return [int(x*self.guiScale) for x in self.winGeometry] - - def getPreferencesSize(self): - return [int(x*self.guiScale) for x in self.prefGeometry] - - def getTreeColWidths(self): - return [int(x*self.guiScale) for x in self.treeColWidth] - - def getNovelColWidths(self): - return [int(x*self.guiScale) for x in self.novelColWidth] - - def getProjColWidths(self): - return [int(x*self.guiScale) for x in self.projColWidth] - - def getMainPanePos(self): - return [int(x*self.guiScale) for x in self.mainPanePos] - - def getDocPanePos(self): - return [int(x*self.guiScale) for x in self.docPanePos] - - def getViewPanePos(self): - return [int(x*self.guiScale) for x in self.viewPanePos] - - def getOutlinePanePos(self): - return [int(x*self.guiScale) for x in self.outlnPanePos] - - def getTextWidth(self, focusMode=False): - if focusMode: - return self.pxInt(max(self.focusWidth, 200)) - else: - return self.pxInt(max(self.textWidth, 200)) - - def getTextMargin(self): - return self.pxInt(max(self.textMargin, 0)) - - def getTabWidth(self): - return self.pxInt(max(self.tabWidth, 0)) - - def getErrData(self): - """Compile and return error messages from the initialisation of - the Config class, and clear the error buffer. - """ - errMessage = "
".join(self.errData) - self.hasError = False - self.errData = [] - return errMessage - ## # Internal Functions ## @@ -987,3 +784,78 @@ class Config: return # END Class Config + + +class RecentProjects: + + def __init__(self, mainConf): + self.mainConf = mainConf + self._data = {} + return + + def loadCache(self): + """Load the cache file for recent projects. + """ + self._data = {} + + cacheFile = self.mainConf.dataPath(nwFiles.RECENT_FILE) + if not cacheFile.is_file(): + return True + + try: + with open(cacheFile, mode="r", encoding="utf-8") as inFile: + theData = json.load(inFile) + for projPath, theEntry in theData.items(): + self._data[projPath] = { + "title": theEntry.get("title", ""), + "words": theEntry.get("words", 0), + "time": theEntry.get("time", 0), + } + except Exception: + logger.error("Could not load recent project cache") + logException() + return False + + return True + + def saveCache(self): + """Save the cache dictionary of recent projects. + """ + cacheFile = self.mainConf.dataPath(nwFiles.RECENT_FILE) + cacheTemp = cacheFile.with_suffix(".tmp") + try: + with open(cacheTemp, mode="w+", encoding="utf-8") as outFile: + json.dump(self._data, outFile, indent=2) + cacheTemp.replace(cacheFile) + except Exception: + logger.error("Could not save recent project cache") + logException() + return False + + return True + + def listEntries(self): + """List all items in the cache. + """ + return [(k, e["title"], e["words"], e["time"]) for k, e in self._data.items()] + + def update(self, projPath, projTitle, wordCount, saveTime): + """Add or update recent cache information on a given project. + """ + self._data[str(projPath)] = { + "title": projTitle, + "words": int(wordCount), + "time": int(saveTime), + } + self.saveCache() + return + + def remove(self, projPath): + """Try to remove a path from the recent projects cache. + """ + if self._data.pop(str(projPath), None) is not None: + logger.debug("Removed recent: %s", projPath) + self.saveCache() + return + +# END Class RecentProjects diff --git a/novelwriter/constants.py b/novelwriter/constants.py index 3d1b5c13..87293a52 100644 --- a/novelwriter/constants.py +++ b/novelwriter/constants.py @@ -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 = { diff --git a/novelwriter/core/__init__.py b/novelwriter/core/__init__.py index c91ca941..07d6cc94 100644 --- a/novelwriter/core/__init__.py +++ b/novelwriter/core/__init__.py @@ -19,7 +19,7 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -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", diff --git a/novelwriter/core/coretools.py b/novelwriter/core/coretools.py new file mode 100644 index 00000000..ff002935 --- /dev/null +++ b/novelwriter/core/coretools.py @@ -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 2018–2022, Veronica Berglyd Olsen + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, but +WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +import 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 diff --git a/novelwriter/core/document.py b/novelwriter/core/document.py index 5420d44e..ab9b8ac9 100644 --- a/novelwriter/core/document.py +++ b/novelwriter/core/document.py @@ -23,9 +23,10 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -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"" + return f"" 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 diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py index 0b3afdc5..bdd49925 100644 --- a/novelwriter/core/index.py +++ b/novelwriter/core/index.py @@ -26,16 +26,15 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -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"" + return f"" ## # 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 diff --git a/novelwriter/core/item.py b/novelwriter/core/item.py index 88650475..155e31b0 100644 --- a/novelwriter/core/item.py +++ b/novelwriter/core/item.py @@ -25,23 +25,27 @@ along with this program. If not, see . 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): diff --git a/novelwriter/core/options.py b/novelwriter/core/options.py index d64970ae..cbf3fe14 100644 --- a/novelwriter/core/options.py +++ b/novelwriter/core/options.py @@ -24,11 +24,11 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -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): diff --git a/novelwriter/core/project.py b/novelwriter/core/project.py index fb81f033..b60cbbc6 100644 --- a/novelwriter/core/project.py +++ b/novelwriter/core/project.py @@ -1,7 +1,7 @@ """ novelWriter – Project Wrapper ============================= -Data class for novelWriter projects +The parent class for a novelWriter project File History: Created: 2018-09-29 [0.0.1] @@ -23,94 +23,59 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import os import json -import shutil import logging import novelwriter from time import time -from lxml import etree +from pathlib import Path from functools import partial -from PyQt5.QtCore import QCoreApplication +from PyQt5.QtCore import QCoreApplication, QObject, pyqtSignal +from novelwriter.enum import nwItemType, nwItemClass, nwItemLayout, nwAlert +from novelwriter.error import logException +from novelwriter.constants import trConst, nwFiles, nwLabels from novelwriter.core.tree import NWTree from novelwriter.core.item import NWItem from novelwriter.core.index import NWIndex -from novelwriter.core.status import NWStatus from novelwriter.core.options import OptionState -from novelwriter.core.document import NWDoc -from novelwriter.enum import nwItemType, nwItemClass, nwItemLayout, nwAlert -from novelwriter.error import logException +from novelwriter.core.storage import NWStorage +from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter, XMLReadState +from novelwriter.core.projectdata import NWProjectData from novelwriter.common import ( - checkString, checkBool, checkInt, isHandle, formatTimeStamp, - makeFileNameSafe, hexToInt, minmax, simplified + checkStringNone, formatTimeStamp, hexToInt, isHandle, makeFileNameSafe, minmax ) -from novelwriter.constants import trConst, nwFiles, nwLabels logger = logging.getLogger(__name__) -class NWProject(): +class NWProject(QObject): - FILE_VERSION = "1.4" # The current project file format version + projectStatusChanged = pyqtSignal(bool) def __init__(self, mainGui): + super().__init__(parent=mainGui) # Internal self.mainConf = novelwriter.CONFIG self.mainGui = mainGui # Core Elements - self._optState = OptionState(self) # Project-specific GUI options - self._projTree = NWTree(self) # The project tree - self._projIndex = NWIndex(self) # The projecty index - self._langData = {} # Localisation data + self._options = OptionState(self) # Project-specific GUI options + self._storage = NWStorage(self) # The project storage handler + self._data = NWProjectData(self) # The project settings + self._tree = NWTree(self) # The project tree + self._index = NWIndex(self) # The projecty index + + # Data Cache + self._langData = {} # Localisation data # Project Status - self.projOpened = 0 # The time stamp of when the project file was opened - self.projChanged = False # The project has unsaved changes - self.projAltered = False # The project has been altered this session - self.lockedBy = None # Data on which computer has the project open - self.saveCount = 0 # Meta data: number of saves - self.autoCount = 0 # Meta data: number of automatic saves - self.editTime = 0 # The accumulated edit time read from the project file - - # Class Settings - self.projPath = None # The full path to where the currently open project is saved - self.projMeta = None # The full path to the project's meta data folder - self.projCache = None # The full path to the project's cache folder - self.projContent = None # The full path to the project's content folder - self.projDict = None # The spell check dictionary - self.projSpell = None # The spell check language, if different than default - self.projLang = None # The project language, used for builds - self.projFile = None # The file name of the project main XML file - self.projFiles = [] # A list of all files in the content folder on load - - # Project Meta - self.projName = "" # Project name - self.bookTitle = "" # The final title; should only be used for exports - self.bookAuthors = [] # A list of book authors - - # Project Settings - self.autoReplace = {} # Text to auto-replace on exports - self.titleFormat = {} # The formatting of titles for exports - self.spellCheck = False # Controls the spellcheck-as-you-type feature - self.autoOutline = True # If true, the Project Outline is updated automatically - self.statusItems = None # Novel file progress status values - self.importItems = None # Note file importance values - self.lastEdited = None # The handle of the last file to be edited - self.lastViewed = None # The handle of the last file to be viewed - self.lastNovel = None # The handle of the last novel root viewed - self.lastOutline = None # The handle of the last outline root viewed - self.lastWCount = 0 # The project word count from last session - self.lastNovelWC = 0 # The novel files word count from last session - self.lastNotesWC = 0 # The note files word count from last session - self.currWCount = 0 # The project word count in current session - self.currNovelWC = 0 # The novel files word count in cutrent session - self.currNotesWC = 0 # The note files word count in cutrent session - self.doBackup = True # Run project backup on exit + self._projOpened = 0 # The time stamp of when the project file was opened + self._projChanged = False # The project has unsaved changes + self._lockedBy = None # Data on which computer has the project open + self._projFiles = [] # A list of all files in the content folder on load # Internal Mapping self.tr = partial(QCoreApplication.translate, "NWProject") @@ -125,16 +90,36 @@ class NWProject(): ## @property - def index(self): - return self._projIndex + def options(self): + return self._options + + @property + def storage(self): + return self._storage + + @property + def data(self): + return self._data @property def tree(self): - return self._projTree + return self._tree @property - def options(self): - return self._optState + def index(self): + return self._index + + @property + def projOpened(self): + return self._projOpened + + @property + def projChanged(self): + return self._projChanged + + @property + def projFiles(self): + return self._projFiles ## # Item Methods @@ -149,71 +134,88 @@ class NWProject(): newItem.setName(label) newItem.setType(nwItemType.ROOT) newItem.setClass(itemClass) - self._projTree.append(None, None, newItem) - self._projTree.updateItemData(newItem.itemHandle) + self._tree.append(None, None, newItem) + self._tree.updateItemData(newItem.itemHandle) return newItem.itemHandle def newFolder(self, label, pHandle): """Add a new folder with a given label and parent item. """ - if pHandle not in self._projTree: + if pHandle not in self._tree: return None newItem = NWItem(self) newItem.setName(label) newItem.setType(nwItemType.FOLDER) - self._projTree.append(None, pHandle, newItem) - self._projTree.updateItemData(newItem.itemHandle) + self._tree.append(None, pHandle, newItem) + self._tree.updateItemData(newItem.itemHandle) return newItem.itemHandle def newFile(self, label, pHandle): """Add a new file with a given label and parent item. """ - if pHandle not in self._projTree: + if pHandle not in self._tree: return None newItem = NWItem(self) newItem.setName(label) newItem.setType(nwItemType.FILE) - self._projTree.append(None, pHandle, newItem) - self._projTree.updateItemData(newItem.itemHandle) + self._tree.append(None, pHandle, newItem) + self._tree.updateItemData(newItem.itemHandle) return newItem.itemHandle - def writeNewFile(self, tHandle, hLevel, isDocument): + def writeNewFile(self, tHandle, hLevel, isDocument, addText=""): """Write content to a new document after it is created. This will not run if the file exists and is not empty. """ - tItem = self._projTree[tHandle] + tItem = self._tree[tHandle] if tItem is None: return False - if tItem.itemType != nwItemType.FILE: + if not tItem.isFileType(): return False - newDoc = NWDoc(self, tHandle) - if newDoc.readDocument().strip(): + newDoc = self._storage.getDocument(tHandle) + if (newDoc.readDocument() or "").strip(): return False hshText = "#"*minmax(hLevel, 1, 4) - newText = f"{hshText} {tItem.itemName}\n\n" + newText = f"{hshText} {tItem.itemName}\n\n{addText}" if tItem.isNovelLike() and isDocument: tItem.setLayout(nwItemLayout.DOCUMENT) else: tItem.setLayout(nwItemLayout.NOTE) newDoc.writeDocument(newText) - self._projIndex.scanText(tHandle, newText) + self._index.scanText(tHandle, newText) + + return True + + def removeItem(self, tHandle): + """Remove an item from the project. This will delete both the + project entry and a document file if it exists. + """ + if self._tree.checkType(tHandle, nwItemType.FILE): + delDoc = self._storage.getDocument(tHandle) + if not delDoc.deleteDocument(): + self.mainGui.makeAlert([ + self.tr("Could not delete document file."), delDoc.getError() + ], nwAlert.ERROR) + return False + + self._index.deleteHandle(tHandle) + del self._tree[tHandle] return True def trashFolder(self): """Add the special trash root folder to the project. """ - trashHandle = self._projTree.trashRoot() + trashHandle = self._tree.trashRoot() if trashHandle is None: newItem = NWItem(self) newItem.setName(trConst(nwLabels.CLASS_NAME[nwItemClass.TRASH])) newItem.setType(nwItemType.ROOT) newItem.setClass(nwItemClass.TRASH) - self._projTree.append(None, None, newItem) - self._projTree.updateItemData(newItem.itemHandle) + self._tree.append(None, None, newItem) + self._tree.updateItemData(newItem.itemHandle) return newItem.itemHandle return trashHandle @@ -227,317 +229,87 @@ class NWProject(): default values. """ # Project Status - self.projOpened = 0 - self.projChanged = False - self.projAltered = False - self.saveCount = 0 - self.autoCount = 0 + self._projOpened = 0 + self._projChanged = False # Project Tree - self._projTree.clear() + self._storage.clear() + self._tree.clear() + self._index.clearIndex() + self._data = NWProjectData(self) # Project Settings - self.projPath = None - self.projMeta = None - self.projCache = None - self.projContent = None - self.projDict = None - self.projSpell = None - self.projLang = None - self.projFile = nwFiles.PROJ_FILE - self.projFiles = [] - self.projName = "" - self.bookTitle = "" - self.bookAuthors = [] - self.autoReplace = {} - self.titleFormat = { - "title": "%title%", - "chapter": "%title%", - "unnumbered": "%title%", - "scene": "* * *", - "section": "", - } - self.spellCheck = False - self.autoOutline = True - self.statusItems = NWStatus(NWStatus.STATUS) - self.statusItems.write(None, self.tr("New"), (100, 100, 100)) - self.statusItems.write(None, self.tr("Note"), (200, 50, 0)) - self.statusItems.write(None, self.tr("Draft"), (200, 150, 0)) - self.statusItems.write(None, self.tr("Finished"), (50, 200, 0)) - self.importItems = NWStatus(NWStatus.IMPORT) - self.importItems.write(None, self.tr("New"), (100, 100, 100)) - self.importItems.write(None, self.tr("Minor"), (200, 50, 0)) - self.importItems.write(None, self.tr("Major"), (200, 150, 0)) - self.importItems.write(None, self.tr("Main"), (50, 200, 0)) - self.lastEdited = None - self.lastViewed = None - self.lastWCount = 0 - self.lastNovelWC = 0 - self.lastNotesWC = 0 - self.currWCount = 0 - self.currNovelWC = 0 - self.currNotesWC = 0 + self._projFiles = [] return - def newProject(self, projData): - """Create a new project by populating the project tree with a - few starter items. - """ - if not isinstance(projData, dict): - logger.error("Invalid call to newProject function") - return False - - popMinimal = projData.get("popMinimal", True) - popCustom = projData.get("popCustom", False) - popSample = projData.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(projData) - - # Project Settings - projPath = projData.get("projPath", None) - projName = projData.get("projName", self.tr("New Project")) - projTitle = projData.get("projTitle", "") - projAuthors = projData.get("projAuthors", "") - - if projPath is None: - logger.error("No project path set for the new project") - return False - - self.clearProject() - if not self.setProjectPath(projPath, newProject=True): - return False - - self.setProjectName(projName) - self.setBookTitle(projTitle) - self.setBookAuthors(projAuthors) - - hNovelRoot = self.newRoot(nwItemClass.NOVEL) - hTitlePage = self.newFile(self.tr("Title Page"), hNovelRoot) - - titlePage = "#! %s\n\n" % (self.bookTitle if self.bookTitle else self.projName) - if self.bookAuthors: - titlePage = "%s>> %s %s <<\n" % (titlePage, self.tr("By"), self.getAuthors()) - - aDoc = NWDoc(self, hTitlePage) - aDoc.writeDocument(titlePage) - - if popMinimal: - # Creating a minimal project with a few root folders and a - # single chapter with a single scene. - hChapter = self.newFile(self.tr("New Chapter"), hNovelRoot) - aDoc = NWDoc(self, hChapter) - aDoc.writeDocument("## %s\n\n" % self.tr("New Chapter")) - - hScene = self.newFile(self.tr("New Scene"), hChapter) - aDoc = NWDoc(self, hScene) - aDoc.writeDocument("### %s\n\n" % self.tr("New Scene")) - - self.newRoot(nwItemClass.PLOT) - self.newRoot(nwItemClass.CHARACTER) - self.newRoot(nwItemClass.WORLD) - self.newRoot(nwItemClass.ARCHIVE) - - 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 = projData.get("numChapters", 0) - numScenes = projData.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 = self.newFile(chTitle, hNovelRoot) - aDoc = NWDoc(self, 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 = self.newFile(scTitle, cHandle) - aDoc = NWDoc(self, 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 = self.newFile(scTitle, hNovelRoot) - aDoc = NWDoc(self, 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 = projData.get("addNotes", False) - for newRoot in projData.get("addRoots", []): - if newRoot in nwItemClass: - rHandle = self.newRoot(newRoot) - if addNotes: - aHandle = self.newFile(noteTitles[newRoot], rHandle) - ntTag = simplified(noteTitles[newRoot]).replace(" ", "") - aDoc = NWDoc(self, aHandle) - aDoc.writeDocument(f"# {noteTitles[newRoot]}\n\n@tag: {ntTag}\n\n") - - # Also add the archive and trash folders - self.newRoot(nwItemClass.ARCHIVE) - self.trashFolder() - - # Finalise - if popCustom or popMinimal: - self.projOpened = time() - self.setProjectChanged(True) - self.saveProject(autoSave=True) - - return True - - def openProject(self, fileName, overrideLock=False): + def openProject(self, projPath, overrideLock=False): """Open the project file provided. If it doesn't exist, assume it is a folder and look for the file within it. If successful, parse the XML of the file and populate the project variables and build the tree of project items. """ - if not os.path.isfile(fileName): - fileName = os.path.join(fileName, nwFiles.PROJ_FILE) - if not os.path.isfile(fileName): - self.mainGui.makeAlert(self.tr( - "File not found: {0}" - ).format(fileName), nwAlert.ERROR) - return False - self.clearProject() - self.projPath = os.path.abspath(os.path.dirname(fileName)) - logger.info("Opening project: %s", self.projPath) - - # Standard Folders and Files - # ========================== - - if not self.ensureFolderStructure(): - self.clearProject() + if not self._storage.openProjectInPlace(projPath): return False - self.projDict = os.path.join(self.projMeta, nwFiles.PROJ_DICT) - - # Check for Old Legacy Data - # ========================= - - legacyList = [] # Cleanup is done later - for projItem in os.listdir(self.projPath): - logger.verbose("Project contains: %s", projItem) - if projItem.startswith("data_") and len(projItem) == 6: - legacyList.append(projItem) + logger.info("Opening project: %s", projPath) # Project Lock # ============ if overrideLock: - self._clearLockFile() + self._storage.clearLockFile() - lockStatus = self._readLockFile() + lockStatus = self._storage.readLockFile() if len(lockStatus) > 0: if lockStatus[0] == "ERROR": logger.warning("Failed to check lock file") else: logger.error("Project is locked, so not opening") - self.lockedBy = lockStatus + self._lockedBy = lockStatus self.clearProject() return False else: - logger.verbose("Project is not locked") + logger.debug("Project is not locked") # Open The Project XML File # ========================= - try: - nwXML = etree.parse(fileName) - except Exception as exc: - self.mainGui.makeAlert(self.tr( - "Failed to parse project xml." - ), nwAlert.ERROR, exception=exc) + xmlReader = self._storage.getXmlReader() + if not isinstance(xmlReader, ProjectXMLReader): + self.clearProject() + return False - # Trying to open backup file instead - backFile = fileName[:-3]+"bak" - if os.path.isfile(backFile): + self._data = NWProjectData(self) + projContent = [] + xmlParsed = xmlReader.read(self._data, projContent) + + appVersion = xmlReader.appVersion or self.tr("Unknown") + + if not xmlParsed: + if xmlReader.state == XMLReadState.NOT_NWX_FILE: self.mainGui.makeAlert(self.tr( - "Attempting to open backup project file instead." - ), nwAlert.INFO) - try: - nwXML = etree.parse(backFile) - except Exception as exc: - self.mainGui.makeAlert(self.tr( - "Failed to parse project xml." - ), nwAlert.ERROR, exception=exc) - self.clearProject() - return False + "Project file does not appear to be a novelWriterXML file." + ), nwAlert.ERROR) + elif xmlReader.state == XMLReadState.UNKNOWN_VERSION: + self.mainGui.makeAlert(self.tr( + "Unknown or unsupported novelWriter project file format. " + "The project cannot be opened by this version of novelWriter. " + "The file was saved with novelWriter version {0}." + ).format(appVersion), nwAlert.ERROR) else: - self.clearProject() - return False + self.mainGui.makeAlert(self.tr( + "Failed to parse project xml." + ), nwAlert.ERROR) - xRoot = nwXML.getroot() - nwxRoot = xRoot.tag - - appVersion = xRoot.attrib.get("appVersion", self.tr("Unknown")) - hexVersion = xRoot.attrib.get("hexVersion", "0x0") - fileVersion = xRoot.attrib.get("fileVersion", self.tr("Unknown")) - - logger.verbose("XML root is '%s'", nwxRoot) - logger.verbose("File version is '%s'", fileVersion) - - # Check File Type - # =============== - - if nwxRoot != "novelWriterXML": - self.mainGui.makeAlert(self.tr( - "Project file does not appear to be a novelWriterXML file." - ), nwAlert.ERROR) self.clearProject() return False - # Check Project Storage Version - # ============================= + # Check Legacy Upgrade + # ==================== - # Changes: - # 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. - # 1.2 : Changes the way autoReplace entries are stored. The 1.1 - # parser will lose the autoReplace settings if allowed to - # read the file. 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 and handled. Introduced in version 1.7. - - if fileVersion not in ("1.0", "1.1", "1.2", "1.3", "1.4"): - self.mainGui.makeAlert(self.tr( - "Unknown or unsupported novelWriter project file format. " - "The project cannot be opened by this version of novelWriter. " - "The file was saved with novelWriter version {0}." - ).format(appVersion), nwAlert.ERROR) - self.clearProject() - return False - - if fileVersion != self.FILE_VERSION: + if xmlReader.state == XMLReadState.WAS_LEGACY: msgYes = self.mainGui.askQuestion( self.tr("File Version"), self.tr( @@ -553,7 +325,7 @@ class NWProject(): # Check novelWriter Version # ========================= - if hexToInt(hexVersion) > hexToInt(novelwriter.__hexversion__): + if xmlReader.hexVersion > hexToInt(novelwriter.__hexversion__): msgYes = self.mainGui.askQuestion( self.tr("Version Conflict"), self.tr( @@ -568,120 +340,39 @@ class NWProject(): self.clearProject() return False - # Start Parsing the XML - # ===================== + # Extract Data + # ============ - for xChild in xRoot: - if xChild.tag == "project": - logger.debug("Found project meta") - for xItem in xChild: - if xItem.text is None: - continue - if xItem.tag == "name": - self.projName = checkString(simplified(xItem.text), "") - logger.verbose("Working Title: '%s'", self.projName) - elif xItem.tag == "title": - self.bookTitle = checkString(simplified(xItem.text), "") - logger.verbose("Title is '%s'", self.bookTitle) - elif xItem.tag == "author": - author = checkString(simplified(xItem.text), "") - if author: - self.bookAuthors.append(author) - logger.verbose("Author: '%s'", author) - elif xItem.tag == "saveCount": - self.saveCount = checkInt(xItem.text, 0) - elif xItem.tag == "autoCount": - self.autoCount = checkInt(xItem.text, 0) - elif xItem.tag == "editTime": - self.editTime = checkInt(xItem.text, 0) - - elif xChild.tag == "settings": - logger.debug("Found project settings") - for xItem in xChild: - if xItem.text is None: - continue - if xItem.tag == "doBackup": - self.doBackup = checkBool(xItem.text, False) - elif xItem.tag == "language": - self.projLang = checkString(xItem.text, None, True) - elif xItem.tag == "spellCheck": - self.spellCheck = checkBool(xItem.text, False) - elif xItem.tag == "spellLang": - self.projSpell = checkString(xItem.text, None, True) - elif xItem.tag == "autoOutline": - self.autoOutline = checkBool(xItem.text, True) - elif xItem.tag == "lastEdited": - self.lastEdited = checkString(xItem.text, None, True) - elif xItem.tag == "lastViewed": - self.lastViewed = checkString(xItem.text, None, True) - elif xItem.tag == "lastNovel": - self.lastNovel = checkString(xItem.text, None, True) - elif xItem.tag == "lastOutline": - self.lastOutline = checkString(xItem.text, None, True) - elif xItem.tag == "lastWordCount": - self.lastWCount = checkInt(xItem.text, 0, False) - elif xItem.tag == "novelWordCount": - self.lastNovelWC = checkInt(xItem.text, 0, False) - elif xItem.tag == "notesWordCount": - self.lastNotesWC = checkInt(xItem.text, 0, False) - elif xItem.tag == "status": - self.statusItems.unpackXML(xItem) - elif xItem.tag == "importance": - self.importItems.unpackXML(xItem) - elif xItem.tag == "autoReplace": - for xEntry in xItem: - if xEntry.tag == "entry" and "key" in xEntry.attrib: - self.autoReplace[xEntry.attrib["key"]] = checkString( - xEntry.text, None, False - ) - elif xItem.tag == "titleFormat": - titleFormat = self.titleFormat.copy() - for xEntry in xItem: - titleFormat[xEntry.tag] = checkString(xEntry.text, "", False) - self.setTitleFormat(titleFormat) - - elif xChild.tag == "content": - logger.debug("Found project content") - self._projTree.unpackXML(xChild) - - self._optState.loadSettings() - - # Sort out old file locations - if legacyList: - try: - for projItem in legacyList: - self._legacyDataFolder(projItem) - except Exception: - self.mainGui.makeAlert(self.tr( - "There was an error updating the project. " - "Some data may not have been preserved." - ), nwAlert.ERROR) - - # Clean up no longer used files - self._deprecatedFiles() + self._tree.unpack(projContent) + self._options.loadSettings() + self._loadProjectLocalisation() # Update recent projects - self.mainConf.updateRecentCache(self.projPath, self.projName, self.lastWCount, time()) - self.mainConf.saveRecentCache() + self.mainConf.recentProjects.update( + self._storage.storagePath, self._data.name, sum(self._data.initCounts), time() + ) # Check the project tree consistency - for tItem in self._projTree: - tHandle = tItem.itemHandle - logger.verbose("Checking item '%s'", tHandle) - if not self._projTree.updateItemData(tHandle): - logger.error("There was a problem item '%s', and it has been removed", tHandle) - del self._projTree[tHandle] # The file will be re-added as orphaned + for tItem in self._tree: + if tItem: + tHandle = tItem.itemHandle + logger.debug("Checking item '%s'", tHandle) + if not self._tree.updateItemData(tHandle): + logger.error("There was a problem the item, and it has been removed") + del self._tree[tHandle] # The file will be re-added as orphaned self._scanProjectFolder() - self._loadProjectLocalisation() + self._index.loadIndex() + if xmlReader.state == XMLReadState.WAS_LEGACY: + # Often, the index needs to be rebuilt when updating format + self._index.rebuildIndex() + self.updateWordCounts() + self._projOpened = time() - self.projOpened = time() - self.projAltered = False - - self._writeLockFile() + self._storage.writeLockFile() self.setProjectChanged(False) - self.mainGui.setStatus(self.tr("Opened Project: {0}").format(self.projName)) + self.mainGui.setStatus(self.tr("Opened Project: {0}").format(self._data.name)) return True @@ -691,518 +382,210 @@ class NWProject(): to make sure if the save fails, we're not left with a truncated file. """ - if self.projPath is None: + if not self._storage.isOpen(): self.mainGui.makeAlert(self.tr( - "Project path not set, cannot save project." + "There is no project open." ), nwAlert.ERROR) return False saveTime = time() - if not self.ensureFolderStructure(): - return False - logger.info("Saving project: %s", self.projPath) + logger.info("Saving project: %s", self._storage.storagePath) if autoSave: - self.autoCount += 1 + self._data.incAutoCount() else: - self.saveCount += 1 - - # Root element and project details - logger.debug("Writing project meta") - nwXML = etree.Element("novelWriterXML", attrib={ - "appVersion": str(novelwriter.__version__), - "hexVersion": str(novelwriter.__hexversion__), - "fileVersion": self.FILE_VERSION, - "timeStamp": formatTimeStamp(saveTime), - }) + self._data.incSaveCount() self.updateWordCounts() - editTime = int(self.editTime + saveTime - self.projOpened) - - # Save Project Meta - xProject = etree.SubElement(nwXML, "project") - self._packProjectValue(xProject, "name", self.projName) - self._packProjectValue(xProject, "title", self.bookTitle) - self._packProjectValue(xProject, "author", self.bookAuthors) - self._packProjectValue(xProject, "saveCount", str(self.saveCount)) - self._packProjectValue(xProject, "autoCount", str(self.autoCount)) - self._packProjectValue(xProject, "editTime", str(editTime)) - - # Save Project Settings - xSettings = etree.SubElement(nwXML, "settings") - self._packProjectValue(xSettings, "doBackup", self.doBackup) - self._packProjectValue(xSettings, "language", self.projLang) - self._packProjectValue(xSettings, "spellCheck", self.spellCheck) - self._packProjectValue(xSettings, "spellLang", self.projSpell) - self._packProjectValue(xSettings, "autoOutline", self.autoOutline) - self._packProjectValue(xSettings, "lastEdited", self.lastEdited) - self._packProjectValue(xSettings, "lastViewed", self.lastViewed) - self._packProjectValue(xSettings, "lastNovel", self.lastNovel) - self._packProjectValue(xSettings, "lastOutline", self.lastOutline) - self._packProjectValue(xSettings, "lastWordCount", self.currWCount) - self._packProjectValue(xSettings, "novelWordCount", self.currNovelWC) - self._packProjectValue(xSettings, "notesWordCount", self.currNotesWC) - self._packProjectKeyValue(xSettings, "autoReplace", self.autoReplace) - - xTitleFmt = etree.SubElement(xSettings, "titleFormat") - for aKey, aValue in self.titleFormat.items(): - if len(aKey) > 0: - self._packProjectValue(xTitleFmt, aKey, aValue) - - # Save Status/Importance self.countStatus() - xStatus = etree.SubElement(xSettings, "status") - self.statusItems.packXML(xStatus) - xStatus = etree.SubElement(xSettings, "importance") - self.importItems.packXML(xStatus) - # Save Tree Content - logger.debug("Writing project content") - self._projTree.packXML(nwXML) - - # Write the xml tree to file - tempFile = os.path.join(self.projPath, self.projFile+"~") - saveFile = os.path.join(self.projPath, self.projFile) - backFile = os.path.join(self.projPath, self.projFile[:-3]+"bak") - try: - with open(tempFile, mode="wb") as outFile: - outFile.write(etree.tostring( - nwXML, - pretty_print=True, - encoding="utf-8", - xml_declaration=True - )) - except Exception as exc: - self.mainGui.makeAlert(self.tr( - "Failed to save project." - ), nwAlert.ERROR, exception=exc) + xmlWriter = self._storage.getXmlWriter() + if not isinstance(xmlWriter, ProjectXMLWriter): return False - # If we're here, the file was successfully saved, - # so let's sort out the temps and backups - try: - if os.path.isfile(saveFile): - os.replace(saveFile, backFile) - os.replace(tempFile, saveFile) - except OSError as exc: + saveTime = time() + editTime = int(self._data.editTime + saveTime - self._projOpened) + content = self._tree.pack() + if not xmlWriter.write(self._data, content, saveTime, editTime): self.mainGui.makeAlert(self.tr( "Failed to save project." - ), nwAlert.ERROR, exception=exc) + ), nwAlert.ERROR, exception=xmlWriter.error) return False - # Save project GUI options - self._optState.saveSettings() + # Save other project data + self._options.saveSettings() + self._index.saveIndex() + self._storage.runPostSaveTasks(autoSave=autoSave) # Update recent projects - self.mainConf.updateRecentCache(self.projPath, self.projName, self.currWCount, saveTime) - self.mainConf.saveRecentCache() + self.mainConf.recentProjects.update( + self._storage.storagePath, self._data.name, sum(self._data.currCounts), saveTime + ) - self._writeLockFile() - self.mainGui.setStatus(self.tr("Saved Project: {0}").format(self.projName)) + self._storage.writeLockFile() + self.mainGui.setStatus(self.tr("Saved Project: {0}").format(self._data.name)) self.setProjectChanged(False) return True - def closeProject(self, idleTime=0): + def closeProject(self, idleTime=0.0): """Close the current project and clear all meta data. """ - logger.info("Closing project: %s", self.projPath) - self._optState.saveSettings() - self._projTree.writeToCFile() + logger.info("Closing project") + self._options.saveSettings() + self._tree.writeToCFile() self._appendSessionStats(idleTime) - self._clearLockFile() + self._storage.clearLockFile() + self._storage.closeSession() self.clearProject() - self.lockedBy = None + self._lockedBy = None return True - def ensureFolderStructure(self): - """Ensure that all necessary folders exist in the project - folder. - """ - if self.projPath is None or self.projPath == "": - return False - - self.projMeta = os.path.join(self.projPath, "meta") - self.projCache = os.path.join(self.projPath, "cache") - self.projContent = os.path.join(self.projPath, "content") - - if self.projPath == os.path.expanduser("~"): - # Don't make a mess in the user's home folder - return False - - if not self._checkFolder(self.projMeta): - return False - if not self._checkFolder(self.projCache): - return False - if not self._checkFolder(self.projContent): - return False - - return True - - ## - # Zip/Unzip Project - ## - - def zipIt(self, doNotify): + def backupProject(self, doNotify): """Create a zip file of the entire project. """ - if not self.mainGui.hasProject: + if not self._storage.isOpen(): logger.error("No project open") return False logger.info("Backing up project") self.mainGui.setStatus(self.tr("Backing up project ...")) - if not (self.mainConf.backupPath and os.path.isdir(self.mainConf.backupPath)): + backupPath = self.mainConf.backupPath() + if not isinstance(backupPath, Path): self.mainGui.makeAlert(self.tr( "Cannot backup project because no valid backup path is set. " "Please set a valid backup location in Preferences." ), nwAlert.ERROR) return False - if not self.projName: + if not self._data.name: self.mainGui.makeAlert(self.tr( "Cannot backup project because no project name is set. " - "Please set a Working Title in Project Settings." + "Please set a Project Name in Project Settings." ), nwAlert.ERROR) return False - cleanName = makeFileNameSafe(self.projName) - baseDir = os.path.abspath(os.path.join(self.mainConf.backupPath, cleanName)) - if not os.path.isdir(baseDir): - try: - os.mkdir(baseDir) - logger.debug("Created folder: %s", baseDir) - except Exception as exc: - self.mainGui.makeAlert(self.tr( - "Could not create backup folder." - ), nwAlert.ERROR, exception=exc) - return False - - if baseDir and baseDir.startswith(self.projPath): - self.mainGui.makeAlert(self.tr( - "Cannot backup project because the backup path is within the " - "project folder to be backed up. Please choose a different " - "backup path in Preferences." - ), nwAlert.ERROR) - return False - - archName = self.tr("Backup from {0}").format(formatTimeStamp(time(), fileSafe=True)) - baseName = os.path.join(baseDir, archName) - + cleanName = makeFileNameSafe(self._data.name) + baseDir = backupPath / cleanName try: - self._clearLockFile() - shutil.make_archive(baseName, "zip", self.projPath, ".") - self._writeLockFile() - logger.info("Backup written to: %s", archName) + baseDir.mkdir(exist_ok=True) + except Exception as exc: + self.mainGui.makeAlert(self.tr( + "Could not create backup folder." + ), nwAlert.ERROR, exception=exc) + return False + + archName = baseDir / self.tr( + "Backup from {0}" + ).format(formatTimeStamp(time(), fileSafe=True) + ".zip") + if self._storage.zipIt(archName, compression=2): if doNotify: self.mainGui.makeAlert(self.tr( "Backup archive file written to: {0}" - ).format(f"{os.path.join(cleanName, archName)}.zip"), nwAlert.INFO) - - except Exception as exc: + ).format(str(archName), nwAlert.INFO)) + else: self.mainGui.makeAlert(self.tr( "Could not write backup archive." - ), nwAlert.ERROR, exception=exc) + ), nwAlert.ERROR) return False self.mainGui.setStatus(self.tr( "Project backed up to '{0}'" - ).format(f"{baseName}.zip")) + ).format(str(archName))) return True - def extractSampleProject(self, projData): - """Make a copy of the sample project. - First, look for the sample.zip file in the assets folder and - unpack it. If it doesn't exist, try to copy the content of the - sample folder to the new project path. If neither exits, error. - """ - projPath = projData.get("projPath", None) - if projPath is None: - logger.error("No project path set for the example project") - return False - - srcSample = os.path.abspath(os.path.join(self.mainConf.appRoot, "sample")) - pkgSample = os.path.join(self.mainConf.assetPath, "sample.zip") - - isSuccess = False - if os.path.isfile(pkgSample): - - self.setProjectPath(projPath, newProject=True) - try: - shutil.unpack_archive(pkgSample, projPath) - isSuccess = True - except Exception as exc: - self.mainGui.makeAlert(self.tr( - "Failed to create a new example project." - ), nwAlert.ERROR, exception=exc) - - elif os.path.isdir(srcSample): - - self.setProjectPath(projPath, newProject=True) - try: - srcProj = os.path.join(srcSample, nwFiles.PROJ_FILE) - dstProj = os.path.join(projPath, nwFiles.PROJ_FILE) - shutil.copyfile(srcProj, dstProj) - - srcContent = os.path.join(srcSample, "content") - dstContent = os.path.join(projPath, "content") - for srcFile in os.listdir(srcContent): - srcDoc = os.path.join(srcContent, srcFile) - dstDoc = os.path.join(dstContent, srcFile) - shutil.copyfile(srcDoc, dstDoc) - - isSuccess = True - - except Exception as exc: - self.mainGui.makeAlert(self.tr( - "Failed to create a new example project." - ), nwAlert.ERROR, exception=exc) - - 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) - - if isSuccess: - self.clearProject() - self.mainGui.openProject(projPath) - self.mainGui.rebuildIndex() - - return isSuccess - ## # Setters ## - def setProjectPath(self, projPath, newProject=False): - """Set the project storage path, and also expand ~ to the user - directory using the path library. + def setDefaultStatusImport(self): + """Set the default status and importance values. """ - if projPath is None or projPath == "": - self.projPath = None - else: - if projPath.startswith("~"): - projPath = os.path.expanduser(projPath) - self.projPath = os.path.abspath(projPath) - - if newProject: - if not os.path.isdir(projPath): - try: - os.mkdir(projPath) - logger.debug("Created folder: %s", projPath) - except Exception as exc: - self.mainGui.makeAlert(self.tr( - "Could not create new project folder." - ), nwAlert.ERROR, exception=exc) - return False - - if os.path.isdir(projPath): - if os.listdir(self.projPath): - self.mainGui.makeAlert(self.tr( - "New project folder is not empty. " - "Each project requires a dedicated project folder." - ), nwAlert.ERROR) - return False - - self.ensureFolderStructure() - self.setProjectChanged(True) - - return True - - def setProjectName(self, projName): - """Set the project name, This is the the name used for backup - files etc. - """ - self.projName = simplified(projName) - self.setProjectChanged(True) - return True - - def setBookTitle(self, bookTitle): - """Set the book title, that is, the title to include in exports. - """ - self.bookTitle = simplified(bookTitle) - self.setProjectChanged(True) - return True - - def setBookAuthors(self, bookAuthors): - """A line-separated list of authors, parsed into an array. - """ - if not isinstance(bookAuthors, str): - return False - - self.bookAuthors = [] - for bookAuthor in bookAuthors.splitlines(): - bookAuthor = simplified(bookAuthor) - if bookAuthor == "": - continue - self.bookAuthors.append(bookAuthor) - - self.setProjectChanged(True) - - return True - - def setProjBackup(self, doBackup): - """Set whether projects should be backed up or not. The user - will be notified in case required settings are missing. - """ - self.doBackup = doBackup - if doBackup: - if not os.path.isdir(self.mainConf.backupPath): - self.mainGui.makeAlert(self.tr( - "You must set a valid backup path in Preferences to use " - "the automatic project backup feature." - ), nwAlert.WARN) - return False - - if self.projName == "": - self.mainGui.makeAlert(self.tr( - "You must set a valid project name in Project Settings to " - "use the automatic project backup feature." - ), nwAlert.WARN) - return False - - return True - - def setSpellCheck(self, theMode): - """Enable/disable spell checking. - """ - if self.spellCheck != theMode: - self.spellCheck = theMode - self.setProjectChanged(True) - return self.spellCheck - - def setSpellLang(self, theLang): - """Set the project-specific spell check language. - """ - theLang = checkString(theLang, None, True) - if self.projSpell != theLang: - self.projSpell = theLang - self.setProjectChanged(True) - return True - return False + self._data.itemStatus.write(None, self.tr("New"), (100, 100, 100)) + self._data.itemStatus.write(None, self.tr("Note"), (200, 50, 0)) + self._data.itemStatus.write(None, self.tr("Draft"), (200, 150, 0)) + self._data.itemStatus.write(None, self.tr("Finished"), (50, 200, 0)) + self._data.itemImport.write(None, self.tr("New"), (100, 100, 100)) + self._data.itemImport.write(None, self.tr("Minor"), (200, 50, 0)) + self._data.itemImport.write(None, self.tr("Major"), (200, 150, 0)) + self._data.itemImport.write(None, self.tr("Main"), (50, 200, 0)) + return def setProjectLang(self, theLang): """Set the project-specific language. """ - theLang = checkString(theLang, None, True) - if self.projLang != theLang: - self.projLang = theLang + theLang = checkStringNone(theLang, None) + if self._data.language != theLang: + self._data.setLanguage(theLang) self._loadProjectLocalisation() self.setProjectChanged(True) return True - def setAutoOutline(self, theMode): - """Enable/disable automatic update of project outline. - """ - if self.autoOutline != theMode: - self.autoOutline = theMode - self.setProjectChanged(True) - return self.autoOutline - def setTreeOrder(self, newOrder): """A list representing the linear/flattened order of project items in the GUI project tree. The user can rearrange the order by drag-and-drop. Forwarded to the NWTree class. """ - if len(self._projTree) != len(newOrder): + if len(self._tree) != len(newOrder): logger.warning("Sizes of new and old tree order do not match") - self._projTree.setOrder(newOrder) + self._tree.setOrder(newOrder) self.setProjectChanged(True) return True - def setLastEdited(self, tHandle): - """Set last edited project item. - """ - if self.lastEdited != tHandle: - self.lastEdited = tHandle - self.setProjectChanged(True) - return True - - def setLastViewed(self, tHandle): - """Set last viewed project item. - """ - if self.lastViewed != tHandle: - self.lastViewed = tHandle - self.setProjectChanged(True) - return True - - def setLastNovelViewed(self, tHandle): - """Set last viewed novel root in the novel tree. - """ - if self.lastNovel != tHandle: - self.lastNovel = tHandle - self.setProjectChanged(True) - return True - def setStatusColours(self, newCols, delCols): """Update the list of novel file status flags. """ - return self._setStatusImport(newCols, delCols, self.statusItems) + return self._setStatusImport(newCols, delCols, self._data.itemStatus) def setImportColours(self, newCols, delCols): """Update the list of note file importance flags. """ - return self._setStatusImport(newCols, delCols, self.importItems) + return self._setStatusImport(newCols, delCols, self._data.itemImport) - def setAutoReplace(self, autoReplace): - """Update the auto-replace dictionary. - """ - self.autoReplace = {} - for key, entry in autoReplace.items(): - self.autoReplace[key] = simplified(entry) - self.setProjectChanged(True) - return True - - def setTitleFormat(self, titleFormat): - """Set the formatting of titles in the project. - """ - for valKey, valEntry in titleFormat.items(): - if valKey in self.titleFormat: - self.titleFormat[valKey] = checkString( - simplified(valEntry), self.titleFormat[valKey] - ) - return True - - def setProjectChanged(self, bValue): + def setProjectChanged(self, value): """Toggle the project changed flag, and propagate the information to the GUI statusbar. """ - self.projChanged = bValue - self.mainGui.statusBar.doUpdateProjectStatus(bValue) - if bValue: - # If we've changed the project at all, this should be True - self.projAltered = True - return self.projChanged + if isinstance(value, bool): + self._projChanged = value + self.projectStatusChanged.emit(self._projChanged) + return self._projChanged ## # Getters ## - def getAuthors(self): + def getLockStatus(self): + """Return the project lock information for the project. + """ + if isinstance(self._lockedBy, list) and len(self._lockedBy) == 4: + return self._lockedBy + return None + + def getFormattedAuthors(self): """Return a formatted string of authors. """ - nAuth = len(self.bookAuthors) - authString = "" + authors = self._data.authors + nAuth = len(authors) + result = "" if nAuth == 1: - authString = self.bookAuthors[0] + result = authors[0] elif nAuth > 1: - authString = "%s %s %s" % ( - ", ".join(self.bookAuthors[0:-1]), self.tr("and"), self.bookAuthors[-1] + result = "%s %s %s" % ( + ", ".join(authors[0:-1]), self.tr("and"), authors[-1] ) - return authString + return result def getCurrentEditTime(self): """Get the total project edit time, including the time spent in the current session. """ - return round(self.editTime + time() - self.projOpened) + return round(self._data.editTime + time() - self._projOpened) def getProjectItems(self): """This function ensures that the item tree loaded is sent to @@ -1213,12 +596,12 @@ class NWProject(): capable of handling it. """ sentItems = [] - iterItems = self._projTree.handles() + iterItems = self._tree.handles() n = 0 nMax = min(len(iterItems), 10000) while n < nMax: tHandle = iterItems[n] - tItem = self._projTree[tHandle] + tItem = self._tree[tHandle] n += 1 if tItem is None: # Technically a bug since treeOrder is built from the @@ -1253,13 +636,8 @@ class NWProject(): def updateWordCounts(self): """Update the total word count values. """ - wcNovel, wcNotes = self._projTree.sumWords() - wcTotal = wcNovel + wcNotes - if wcTotal != self.currWCount: - self.currNovelWC = wcNovel - self.currNotesWC = wcNotes - self.currWCount = wcTotal - self.setProjectChanged(True) + novel, notes = self._tree.sumWords() + self._data.setCurrCounts(novel=novel, notes=notes) return def countStatus(self): @@ -1267,13 +645,13 @@ class NWProject(): project tree. The counts themselves are kept in the NWStatus objects. This is essentially a refresh. """ - self.statusItems.resetCounts() - self.importItems.resetCounts() - for nwItem in self._projTree: + self._data.itemStatus.resetCounts() + self._data.itemImport.resetCounts() + for nwItem in self._tree: if nwItem.isNovelLike(): - self.statusItems.increment(nwItem.itemStatus) + self._data.itemStatus.increment(nwItem.itemStatus) else: - self.importItems.increment(nwItem.itemImport) + self._data.itemImport.increment(nwItem.itemImport) return def localLookup(self, theWord): @@ -1312,18 +690,18 @@ class NWProject(): def _loadProjectLocalisation(self): """Load the language data for the current project language. """ - if self.projLang is None: + if self._data.language is None or self.mainConf._nwLangPath is None: self._langData = {} return False - langFile = os.path.join(self.mainConf.nwLangPath, "project_%s.json" % self.projLang) - if not os.path.isfile(langFile): - langFile = os.path.join(self.mainConf.nwLangPath, "project_en_GB.json") + langFile = Path(self.mainConf._nwLangPath) / f"project_{self._data.language}.json" + if not langFile.is_file(): + langFile = Path(self.mainConf._nwLangPath) / "project_en_GB.json" try: with open(langFile, mode="r", encoding="utf-8") as inFile: self._langData = json.load(inFile) - logger.debug("Loaded project language file: %s", os.path.basename(langFile)) + logger.debug("Loaded project language file: %s", langFile.name) except Exception: logger.error("Failed to project language file") @@ -1332,136 +710,40 @@ class NWProject(): return True - def _readLockFile(self): - """Reads the lock file in the project folder. - """ - if self.projPath is None: - return ["ERROR"] - - lockFile = os.path.join(self.projPath, nwFiles.PROJ_LOCK) - if not os.path.isfile(lockFile): - return [] - - theLines = [] - try: - with open(lockFile, mode="r", encoding="utf-8") as inFile: - theData = inFile.read() - theLines = theData.splitlines() - if len(theLines) != 4: - return ["ERROR"] - - except Exception: - logger.error("Failed to read project lockfile") - logException() - return ["ERROR"] - - return theLines - - def _writeLockFile(self): - """Writes a lock file to the project folder. - """ - if self.projPath is None: - return False - - lockFile = os.path.join(self.projPath, nwFiles.PROJ_LOCK) - try: - with open(lockFile, mode="w+", encoding="utf-8") as outFile: - outFile.write("%s\n" % self.mainConf.hostName) - outFile.write("%s\n" % self.mainConf.osType) - outFile.write("%s\n" % self.mainConf.kernelVer) - outFile.write("%d\n" % time()) - - except Exception: - logger.error("Failed to write project lockfile") - logException() - return False - - return True - - def _clearLockFile(self): - """Remove the lock file, if it exists. - """ - if self.projPath is None: - return False - - lockFile = os.path.join(self.projPath, nwFiles.PROJ_LOCK) - if os.path.isfile(lockFile): - try: - os.unlink(lockFile) - except Exception: - logger.error("Failed to remove project lockfile") - logException() - return False - - return True - - def _checkFolder(self, thePath): - """Check if a folder exists, and if it doesn't, create it. - """ - if not os.path.isdir(thePath): - try: - os.mkdir(thePath) - logger.debug("Created folder: %s", thePath) - except Exception as exc: - self.mainGui.makeAlert(self.tr( - "Could not create folder." - ), nwAlert.ERROR, exception=exc) - return False - return True - - def _packProjectValue(self, xParent, theName, theValue, allowNone=True): - """Pack a list of values into an xml element. - """ - if not isinstance(theValue, list): - theValue = [theValue] - for aValue in theValue: - if (aValue == "" or aValue is None) and not allowNone: - continue - xItem = etree.SubElement(xParent, theName) - xItem.text = str(aValue) - return - - def _packProjectKeyValue(self, xParent, theName, theDict): - """Pack the entries of a dictionary into an xml element. - """ - xAutoRep = etree.SubElement(xParent, theName) - for aKey, aValue in theDict.items(): - if len(aKey) > 0: - xEntry = etree.SubElement(xAutoRep, "entry", attrib={"key": aKey}) - xEntry.text = aValue - return - def _scanProjectFolder(self): """Scan the project folder and check that the files in it are also in the project XML file. If they aren't, import them as orphaned files so the user can either delete them, or put them back into the project tree. """ - if self.projPath is None: + contentPath = self._storage.contentPath + if not isinstance(contentPath, Path): return False # Then check the files in the data folder logger.debug("Checking files in project content folder") orphanFiles = [] - self.projFiles = [] - for fileItem in os.listdir(self.projContent): - if not fileItem.endswith(".nwd"): - logger.warning("Skipping file: %s", fileItem) + self._projFiles = [] + + for item in contentPath.iterdir(): + itemName = item.name + if not itemName.endswith(".nwd"): + logger.warning("Skipping file: %s", itemName) continue - if len(fileItem) != 17: - logger.warning("Skipping file: %s", fileItem) + if len(itemName) != 17: + logger.warning("Skipping file: %s", itemName) continue - fHandle = fileItem[:13] + fHandle = itemName[:13] if not isHandle(fHandle): - logger.warning("Skipping file: %s", fileItem) + logger.warning("Skipping file: %s", itemName) continue - if fHandle in self._projTree: - self.projFiles.append(fHandle) - logger.debug("Checking file %s, handle '%s': OK", fileItem, fHandle) + if fHandle in self._tree: + self._projFiles.append(fHandle) + logger.debug("Checking file %s, handle '%s': OK", itemName, fHandle) else: - logger.warning("Checking file %s, handle '%s': Orphaned", fileItem, fHandle) + logger.warning("Checking file %s, handle '%s': Orphaned", itemName, fHandle) orphanFiles.append(fHandle) # Report status @@ -1485,7 +767,7 @@ class NWProject(): oClass = None oLayout = None - aDoc = NWDoc(self, oHandle) + aDoc = self._storage.getDocument(oHandle) if aDoc.readDocument(isOrphan=True) is not None: oName, oParent, oClass, oLayout = aDoc.getMeta() @@ -1504,10 +786,10 @@ class NWProject(): if oLayout is None: oLayout = nwItemLayout.NOTE - if oParent is None or oParent not in self._projTree: - oParent = self._projTree.findRoot(oClass) + if oParent is None or oParent not in self._tree: + oParent = self._tree.findRoot(oClass) if oParent is None: - oParent = self._projTree.findRoot(nwItemClass.NOVEL) + oParent = self._tree.findRoot(nwItemClass.NOVEL) # If the file still has no parent item, skip it if oParent is None: @@ -1519,8 +801,8 @@ class NWProject(): orphItem.setType(nwItemType.FILE) orphItem.setClass(oClass) orphItem.setLayout(oLayout) - self._projTree.append(oHandle, oParent, orphItem) - self._projTree.updateItemData(orphItem.itemHandle) + self._tree.append(oHandle, oParent, orphItem) + self._tree.updateItemData(orphItem.itemHandle) if noWhere: self.mainGui.makeAlert(self.tr( @@ -1533,15 +815,16 @@ class NWProject(): def _appendSessionStats(self, idleTime): """Append session statistics to the sessions log file. """ - if not self.ensureFolderStructure(): + sessionFile = self._storage.getMetaFile(nwFiles.SESS_STATS) + if not isinstance(sessionFile, Path): return False - sessionFile = os.path.join(self.projMeta, nwFiles.SESS_STATS) - isFile = os.path.isfile(sessionFile) - nowTime = time() - sessDiff = self.currWCount - self.lastWCount - sessTime = nowTime - self.projOpened + iNovel, iNotes = self._data.initCounts + cNovel, cNotes = self._data.currCounts + iTotal = iNovel + iNotes + sessDiff = cNovel + cNotes - iTotal + sessTime = nowTime - self._projOpened logger.info("The session lasted %d sec and added %d words", int(sessTime), sessDiff) if sessTime < 300 and sessDiff == 0: @@ -1549,20 +832,21 @@ class NWProject(): return False try: + isFile = sessionFile.exists() # We must save the state before we open with open(sessionFile, mode="a+", encoding="utf-8") as outFile: if not isFile: # It's a new file, so add a header - if self.lastWCount > 0: - outFile.write("# Offset %d\n" % self.lastWCount) + if iTotal > 0: + outFile.write("# Offset %d\n" % iTotal) outFile.write("# %-17s %-19s %8s %8s %8s\n" % ( "Start Time", "End Time", "Novel", "Notes", "Idle" )) outFile.write("%-19s %-19s %8d %8d %8d\n" % ( - formatTimeStamp(self.projOpened), + formatTimeStamp(self._projOpened), formatTimeStamp(nowTime), - self.currNovelWC, - self.currNotesWC, + cNovel, + cNotes, int(idleTime), )) @@ -1573,75 +857,4 @@ class NWProject(): return True - ## - # Legacy Data Structure Handlers - ## - - def _legacyDataFolder(self, dataDir): - """Clean up legacy data folders. - """ - dataPath = os.path.join(self.projPath, dataDir) - if not os.path.isdir(dataPath): - return False - - logger.info("Old data folder found: %s", dataDir) - - # Move Documents to Content - for dataItem in os.listdir(dataPath): - dataFile = os.path.join(dataPath, dataItem) - if not os.path.isfile(dataFile): - continue - - if len(dataItem) == 21 and dataItem.endswith("_main.nwd"): - tHandle = dataDir[-1] + dataItem[:12] - newPath = os.path.join(self.projContent, f"{tHandle}.nwd") - os.rename(dataFile, newPath) - logger.info("Moved file: %s", dataFile) - - elif len(dataItem) == 21 and dataItem.endswith("_main.bak"): - os.unlink(dataFile) - logger.info("Deleted file: %s", dataFile) - - # Remove Data Folder - if not os.listdir(dataPath): - os.rmdir(dataPath) - logger.info("Deleted folder: %s", dataDir) - - return True - - def _deprecatedFiles(self): - """Delete files that are no longer used by novelWriter. - """ - rmList = [ - os.path.join(self.projCache, "nwProject.nwx.0"), - os.path.join(self.projCache, "nwProject.nwx.1"), - os.path.join(self.projCache, "nwProject.nwx.2"), - os.path.join(self.projCache, "nwProject.nwx.3"), - os.path.join(self.projCache, "nwProject.nwx.4"), - os.path.join(self.projCache, "nwProject.nwx.5"), - os.path.join(self.projCache, "nwProject.nwx.6"), - os.path.join(self.projCache, "nwProject.nwx.7"), - os.path.join(self.projCache, "nwProject.nwx.8"), - os.path.join(self.projCache, "nwProject.nwx.9"), - os.path.join(self.projMeta, "mainOptions.json"), - os.path.join(self.projMeta, "exportOptions.json"), - os.path.join(self.projMeta, "outlineOptions.json"), - os.path.join(self.projMeta, "timelineOptions.json"), - os.path.join(self.projMeta, "docMergeOptions.json"), - os.path.join(self.projMeta, "sessionLogOptions.json"), - os.path.join(self.projPath, "ToC.json"), - ] - - for rmFile in rmList: - if os.path.isfile(rmFile): - logger.info("Deleting: %s", rmFile) - try: - os.unlink(rmFile) - except Exception: - logger.error("Could not delete: %s", rmFile) - logException() - return False - - return True - # END Class NWProject diff --git a/novelwriter/core/projectdata.py b/novelwriter/core/projectdata.py new file mode 100644 index 00000000..40bce1a7 --- /dev/null +++ b/novelwriter/core/projectdata.py @@ -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 2018–2022, 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 . +""" + +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 diff --git a/novelwriter/core/projectxml.py b/novelwriter/core/projectxml.py new file mode 100644 index 00000000..5b1b72f4 --- /dev/null +++ b/novelwriter/core/projectxml.py @@ -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 2018–2022, Veronica Berglyd Olsen + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, but +WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +import 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 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 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 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 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 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 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 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 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 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 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 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 diff --git a/novelwriter/core/spellcheck.py b/novelwriter/core/spellcheck.py index 8f2fcea9..30396d0d 100644 --- a/novelwriter/core/spellcheck.py +++ b/novelwriter/core/spellcheck.py @@ -23,15 +23,17 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -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): diff --git a/novelwriter/core/status.py b/novelwriter/core/status.py index 4bade5e7..fea6397d 100644 --- a/novelwriter/core/status.py +++ b/novelwriter/core/status.py @@ -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 ## diff --git a/novelwriter/core/storage.py b/novelwriter/core/storage.py new file mode 100644 index 00000000..9b28f31a --- /dev/null +++ b/novelwriter/core/storage.py @@ -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 2018–2022, Veronica Berglyd Olsen + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, but +WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +import 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 diff --git a/novelwriter/core/tohtml.py b/novelwriter/core/tohtml.py index 86a21786..88647d63 100644 --- a/novelwriter/core/tohtml.py +++ b/novelwriter/core/tohtml.py @@ -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): "\n" "\n" ).format( - projTitle=self.theProject.projName, + projTitle=self.theProject.data.name, htmlStyle="\n".join(theStyle), bodyText=bodyText, ) diff --git a/novelwriter/core/tokenizer.py b/novelwriter/core/tokenizer.py index 3a3b52f1..0b2ab376 100644 --- a/novelwriter/core/tokenizer.py +++ b/novelwriter/core/tokenizer.py @@ -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) diff --git a/novelwriter/core/tomd.py b/novelwriter/core/tomd.py index 48d23a35..89ae9e4f 100644 --- a/novelwriter/core/tomd.py +++ b/novelwriter/core/tomd.py @@ -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 = [] diff --git a/novelwriter/core/toodt.py b/novelwriter/core/toodt.py index 59eaf30f..594074ff 100644 --- a/novelwriter/core/toodt.py +++ b/novelwriter/core/toodt.py @@ -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. diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py index 10a7a78f..1f9960de 100644 --- a/novelwriter/core/tree.py +++ b/novelwriter/core/tree.py @@ -23,13 +23,12 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -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 ...") diff --git a/novelwriter/gui/custom.py b/novelwriter/custom.py similarity index 94% rename from novelwriter/gui/custom.py rename to novelwriter/custom.py index e571a5a1..5d9d8122 100644 --- a/novelwriter/gui/custom.py +++ b/novelwriter/custom.py @@ -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 diff --git a/novelwriter/dialogs/about.py b/novelwriter/dialogs/about.py index c1a9ec22..a9a45e84 100644 --- a/novelwriter/dialogs/about.py +++ b/novelwriter/dialogs/about.py @@ -23,7 +23,6 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -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) diff --git a/novelwriter/dialogs/docmerge.py b/novelwriter/dialogs/docmerge.py index 30eb602f..b6fd8381 100644 --- a/novelwriter/dialogs/docmerge.py +++ b/novelwriter/dialogs/docmerge.py @@ -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 2018–2022, Veronica Berglyd Olsen @@ -26,169 +27,146 @@ along with this program. If not, see . 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("{0}".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 diff --git a/novelwriter/dialogs/docsplit.py b/novelwriter/dialogs/docsplit.py index b507479e..314ef8d8 100644 --- a/novelwriter/dialogs/docsplit.py +++ b/novelwriter/dialogs/docsplit.py @@ -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 2018–2022, 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("{0}".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}

{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 diff --git a/novelwriter/dialogs/editlabel.py b/novelwriter/dialogs/editlabel.py index 03bf9d08..a1982ede 100644 --- a/novelwriter/dialogs/editlabel.py +++ b/novelwriter/dialogs/editlabel.py @@ -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")) diff --git a/novelwriter/dialogs/preferences.py b/novelwriter/dialogs/preferences.py index f807fde1..7804c0a6 100644 --- a/novelwriter/dialogs/preferences.py +++ b/novelwriter/dialogs/preferences.py @@ -23,7 +23,6 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -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 ## diff --git a/novelwriter/dialogs/projdetails.py b/novelwriter/dialogs/projdetails.py index e83fc0a4..9f3999a4 100644 --- a/novelwriter/dialogs/projdetails.py +++ b/novelwriter/dialogs/projdetails.py @@ -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("%s" % 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("%s" % 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}") diff --git a/novelwriter/dialogs/projload.py b/novelwriter/dialogs/projload.py index c7229f44..326e8bce 100644 --- a/novelwriter/dialogs/projload.py +++ b/novelwriter/dialogs/projload.py @@ -23,10 +23,10 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -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]) diff --git a/novelwriter/dialogs/projsettings.py b/novelwriter/dialogs/projsettings.py index 11a640b1..77da0860 100644 --- a/novelwriter/dialogs/projsettings.py +++ b/novelwriter/dialogs/projsettings.py @@ -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 diff --git a/novelwriter/dialogs/quotes.py b/novelwriter/dialogs/quotes.py index 299386d8..685459d4 100644 --- a/novelwriter/dialogs/quotes.py +++ b/novelwriter/dialogs/quotes.py @@ -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 diff --git a/novelwriter/dialogs/updates.py b/novelwriter/dialogs/updates.py index c4782e6d..1e79dc26 100644 --- a/novelwriter/dialogs/updates.py +++ b/novelwriter/dialogs/updates.py @@ -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") diff --git a/novelwriter/dialogs/wordlist.py b/novelwriter/dialogs/wordlist.py index 9a17eed8..9281df0a 100644 --- a/novelwriter/dialogs/wordlist.py +++ b/novelwriter/dialogs/wordlist.py @@ -23,10 +23,11 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -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 diff --git a/novelwriter/enum.py b/novelwriter/enum.py index 48b894ba..ecb4ea4a 100644 --- a/novelwriter/enum.py +++ b/novelwriter/enum.py @@ -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 diff --git a/novelwriter/error.py b/novelwriter/error.py index 2ceae1e3..855f3723 100644 --- a/novelwriter/error.py +++ b/novelwriter/error.py @@ -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: diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index 0d32689e..1e2704f2 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -50,10 +50,10 @@ from PyQt5.QtWidgets import ( QFrame ) -from novelwriter.core import NWDoc, NWSpellEnchant, countWords +from novelwriter.core import NWSpellEnchant, countWords from novelwriter.enum import nwAlert, nwDocAction, nwDocInsert, nwDocMode from novelwriter.common import transferCase -from novelwriter.constants import nwConst, nwKeyWords, nwUnicode +from novelwriter.constants import nwConst, nwFiles, nwKeyWords, nwUnicode from novelwriter.gui.dochighlight import GuiDocHighlighter logger = logging.getLogger(__name__) @@ -73,7 +73,7 @@ class GuiDocEditor(QTextEdit): loadDocumentTagRequest = pyqtSignal(str, Enum) def __init__(self, mainGui): - QTextEdit.__init__(self, mainGui) + super().__init__(parent=mainGui) logger.debug("Initialising GuiDocEditor ...") @@ -92,6 +92,7 @@ class GuiDocEditor(QTextEdit): self._spellCheck = False # Flag for spell checking enabled self._nonWord = "\"'" # Characters to not include in spell checking + self._vpMargin = 0 # The editor viewport margin, set during init # Document Variables self._charCount = 0 # Character count @@ -104,12 +105,18 @@ class GuiDocEditor(QTextEdit): self._doReplace = False # Switch to temporarily disable auto-replace self._queuePos = None # Used for delayed change of cursor position - # Typography - self._typDQOpen = '"' - self._typDQClose = '"' - self._typSQOpen = "'" - self._typSQClose = "'" + # Typography Cache self._typPadChar = " " + self._typDQuoteO = '"' + self._typDQuoteC = '"' + self._typSQuoteO = "'" + self._typSQuoteC = "'" + self._typRepDQuote = False + self._typRepSQuote = False + self._typRepDash = False + self._typRepDots = False + self._typPadBefore = "" + self._typPadAfter = "" # Core Elements and Signals qDoc = self.document() @@ -137,24 +144,20 @@ class GuiDocEditor(QTextEdit): self.setFrameStyle(QFrame.NoFrame) # Custom Shortcuts - QShortcut( - QKeySequence("Ctrl+."), - self, - context=Qt.WidgetShortcut, - activated=self._openSpellContext - ) - QShortcut( - Qt.Key_Return | Qt.ControlModifier, - self, - context=Qt.WidgetShortcut, - activated=self._followTag - ) - QShortcut( - Qt.Key_Enter | Qt.ControlModifier, - self, - context=Qt.WidgetShortcut, - activated=self._followTag - ) + self.keyContext = QShortcut(self) + self.keyContext.setKey("Ctrl+.") + self.keyContext.setContext(Qt.WidgetShortcut) + self.keyContext.activated.connect(self._openSpellContext) + + self.followTag1 = QShortcut(self) + self.followTag1.setKey(Qt.Key_Return | Qt.ControlModifier) + self.followTag1.setContext(Qt.WidgetShortcut) + self.followTag1.activated.connect(self._followTag) + + self.followTag2 = QShortcut(self) + self.followTag2.setKey(Qt.Key_Enter | Qt.ControlModifier) + self.followTag2.setContext(Qt.WidgetShortcut) + self.followTag2.activated.connect(self._followTag) # Set Up Document Word Counter self.wcTimerDoc = QTimer() @@ -176,6 +179,7 @@ class GuiDocEditor(QTextEdit): self.wCounterSel.signals.countsReady.connect(self._updateSelCounts) # Finalise + self.updateSyntaxColours() self.initEditor() logger.debug("GuiDocEditor initialisation complete") @@ -209,15 +213,46 @@ class GuiDocEditor(QTextEdit): return True + def updateTheme(self): + """Update theme elements + """ + self.docSearch.updateTheme() + self.docHeader.updateTheme() + self.docFooter.updateTheme() + return + + def updateSyntaxColours(self): + """Update the syntax highlighting theme. + """ + mainPalette = self.palette() + mainPalette.setColor(QPalette.Window, QColor(*self.mainTheme.colBack)) + mainPalette.setColor(QPalette.Base, QColor(*self.mainTheme.colBack)) + mainPalette.setColor(QPalette.Text, QColor(*self.mainTheme.colText)) + self.setPalette(mainPalette) + + docPalette = self.viewport().palette() + docPalette.setColor(QPalette.Base, QColor(*self.mainTheme.colBack)) + docPalette.setColor(QPalette.Text, QColor(*self.mainTheme.colText)) + self.viewport().setPalette(docPalette) + + self.docHeader.matchColours() + self.docFooter.matchColours() + + self.highLight.initHighlighter() + + return + def initEditor(self): """Initialise or re-initialise the editor with the user's settings. This function is both called when the editor is created, and when the user changes the main editor preferences. """ # Some Constants - self._nonWord = "\"'" - self._nonWord += "".join(self.mainConf.fmtDoubleQuotes) - self._nonWord += "".join(self.mainConf.fmtSingleQuotes) + self._nonWord = ( + "\"'" + f"{self.mainConf.fmtSQuoteOpen}{self.mainConf.fmtSQuoteClose}" + f"{self.mainConf.fmtDQuoteOpen}{self.mainConf.fmtDQuoteClose}" + ) # Typography if self.mainConf.fmtPadThin: @@ -225,10 +260,16 @@ class GuiDocEditor(QTextEdit): else: self._typPadChar = nwUnicode.U_NBSP - self._typDQOpen = self.mainConf.fmtDoubleQuotes[0] - self._typDQClose = self.mainConf.fmtDoubleQuotes[1] - self._typSQOpen = self.mainConf.fmtSingleQuotes[0] - self._typSQClose = self.mainConf.fmtSingleQuotes[1] + self._typSQuoteO = self.mainConf.fmtSQuoteOpen + self._typSQuoteC = self.mainConf.fmtSQuoteClose + self._typDQuoteO = self.mainConf.fmtDQuoteOpen + self._typDQuoteC = self.mainConf.fmtDQuoteClose + self._typRepDQuote = self.mainConf.doReplaceDQuote + self._typRepSQuote = self.mainConf.doReplaceSQuote + self._typRepDash = self.mainConf.doReplaceDash + self._typRepDots = self.mainConf.doReplaceDots + self._typPadBefore = self.mainConf.fmtPadBefore + self._typPadAfter = self.mainConf.fmtPadAfter # Reload spell check and dictionaries self.setDictionaries() @@ -255,25 +296,13 @@ class GuiDocEditor(QTextEdit): theFont.setPointSize(self.mainConf.textSize) self.setFont(theFont) - # Set the widget colours to match syntax theme - mainPalette = self.palette() - mainPalette.setColor(QPalette.Window, QColor(*self.mainTheme.colBack)) - mainPalette.setColor(QPalette.Base, QColor(*self.mainTheme.colBack)) - mainPalette.setColor(QPalette.Text, QColor(*self.mainTheme.colText)) - self.setPalette(mainPalette) - - docPalette = self.viewport().palette() - docPalette.setColor(QPalette.Base, QColor(*self.mainTheme.colBack)) - docPalette.setColor(QPalette.Text, QColor(*self.mainTheme.colText)) - self.viewport().setPalette(docPalette) - - self.docHeader.matchColours() - self.docFooter.matchColours() - # Set default text margins - cM = self.mainConf.getTextMargin() - qDoc.setDocumentMargin(0) - self.setViewportMargins(cM, cM, cM, cM) + # Due to cursor visibility, a part of the margin must be + # allocated to the document itself. See issue #1112. + cW = self.cursorWidth() + qDoc.setDocumentMargin(cW) + self._vpMargin = max(self.mainConf.getTextMargin() - cW, 0) + self.setViewportMargins(self._vpMargin, self._vpMargin, self._vpMargin, self._vpMargin) # Also set the document text options for the document text flow theOpt = QTextOption() @@ -299,13 +328,7 @@ class GuiDocEditor(QTextEdit): self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) # Refresh the tab stops - if self.mainConf.verQtValue >= 51000: - self.setTabStopDistance(self.mainConf.getTabWidth()) - else: # pragma: no cover - self.setTabStopWidth(self.mainConf.getTabWidth()) - - # Initialise the syntax highlighter - self.highLight.initHighlighter() + self.setTabStopDistance(self.mainConf.getTabWidth()) # Configure word count timer self.wcInterval = self.mainConf.wordCountTimer @@ -330,7 +353,7 @@ class GuiDocEditor(QTextEdit): document is new (empty string), we set up the editor for editing the file. """ - self._nwDocument = NWDoc(self.theProject, tHandle) + self._nwDocument = self.theProject.storage.getDocument(tHandle) self._nwItem = self._nwDocument.getCurrentItem() theDoc = self._nwDocument.readDocument() @@ -461,8 +484,8 @@ class GuiDocEditor(QTextEdit): return True def saveText(self): - """Save the text currently in the editor to the NWDoc object, - and update the NWItem meta data. + """Save the text currently in the editor to the NWDocument + object, and update the NWItem meta data. """ if self._nwItem is None or self._nwDocument is None: logger.error("Cannot save text as no document is open") @@ -507,12 +530,12 @@ class GuiDocEditor(QTextEdit): self.setDocumentChanged(False) - oldHeader = self.theProject.index.getHandleHeaderLevel(tHandle) + oldHeader = self._nwItem.mainHeading self.theProject.index.scanText(tHandle, docText) - newHeader = self.theProject.index.getHandleHeaderLevel(tHandle) + newHeader = self._nwItem.mainHeading # ToDo: This should be a signal - if self._updateHeaders(checkLevel=True): + if self._updateHeaders(): self.mainGui.requestNovelTreeRefresh() else: self.mainGui.novelView.updateWordCounts(tHandle) @@ -537,7 +560,6 @@ class GuiDocEditor(QTextEdit): """ wW = self.width() wH = self.height() - cM = self.mainConf.getTextMargin() vBar = self.verticalScrollBar() sW = vBar.width() if vBar.isVisible() else 0 @@ -545,10 +567,10 @@ class GuiDocEditor(QTextEdit): hBar = self.horizontalScrollBar() sH = hBar.height() if hBar.isVisible() else 0 - tM = cM + tM = self._vpMargin if self.mainConf.textWidth > 0 or self.mainGui.isFocusMode: tW = self.mainConf.getTextWidth(self.mainGui.isFocusMode) - tM = max((wW - sW - tW)//2, cM) + tM = max((wW - sW - tW)//2, self._vpMargin) tB = self.frameWidth() tW = wW - 2*tB - sW @@ -565,8 +587,8 @@ class GuiDocEditor(QTextEdit): rL = wW - sW - rW - 2*tB self.docSearch.move(rL, 2*tB) - uM = max(cM, tH, rH) - lM = max(cM, fH) + uM = max(self._vpMargin, tH, rH) + lM = max(self._vpMargin, fH) self.setViewportMargins(tM, uM, tM, lM) return @@ -601,19 +623,15 @@ class GuiDocEditor(QTextEdit): ## def getText(self): - """Get the text content of the current document. This method - uses QTextEdit->toPlainText for Qt versions lower than 5.9, and - the QTextDocument->toRawText for higher version. The latter - preserves non-breaking spaces, which the former does not. - We still want to get rid of page and line separators though. + """Get the text content of the current document. This method uses + QTextDocument->toRawText instead of toPlainText(). The former preserves + non-breaking spaces, the latter does not. We still want to get rid of + page and line separators though. See: https://doc.qt.io/qt-5/qtextdocument.html#toPlainText """ - if self.mainConf.verQtValue >= 50900: - theText = self.document().toRawText() - theText = theText.replace(nwUnicode.U_LSEP, "\n") # Line separators - theText = theText.replace(nwUnicode.U_PSEP, "\n") # Paragraph separators - else: - theText = self.toPlainText() + theText = self.document().toRawText() + theText = theText.replace(nwUnicode.U_LSEP, "\n") # Line separators + theText = theText.replace(nwUnicode.U_PSEP, "\n") # Paragraph separators return theText def getCursorPosition(self): @@ -668,7 +686,7 @@ class GuiDocEditor(QTextEdit): if theBlock: self.setCursorPosition(theBlock.position()) self.docFooter.updateLineCount() - logger.verbose("Cursor moved to line %d", theLine) + logger.debug("Cursor moved to line %d", theLine) return True @@ -680,12 +698,13 @@ class GuiDocEditor(QTextEdit): """Set the spell checker dictionary language, and emit the dictionary changed signal. """ - if self.theProject.projSpell is None: + if self.theProject.data.spellLang is None: theLang = self.mainConf.spellLanguage else: - theLang = self.theProject.projSpell + theLang = self.theProject.data.spellLang - self.spEnchant.setLanguage(theLang, self.theProject.projDict) + projDict = self.theProject.storage.getMetaFile(nwFiles.PROJ_DICT) + self.spEnchant.setLanguage(theLang, projDict) _, theProvider = self.spEnchant.describeDict() self.spellDictionaryChanged.emit(str(theLang), str(theProvider)) @@ -712,17 +731,17 @@ class GuiDocEditor(QTextEdit): ), nwAlert.INFO) theMode = False - if self.spEnchant.spellLanguage() is None: + if self.spEnchant.spellLanguage is None: theMode = False self._spellCheck = theMode self.mainGui.mainMenu.setSpellCheck(theMode) - self.theProject.setSpellCheck(theMode) + self.theProject.data.setSpellCheck(theMode) self.highLight.setSpellCheck(theMode) if not self._bigDoc: self.spellCheckDocument() - logger.verbose("Spell check is set to '%s'", str(theMode)) + logger.debug("Spell check is set to '%s'", str(theMode)) return True @@ -732,7 +751,7 @@ class GuiDocEditor(QTextEdit): of Qt 5.13, is to clear the text and put it back. This clears the undo stack, so we only do it for big documents. """ - logger.verbose("Running spell checker") + logger.debug("Running spell checker") if self._spellCheck: bfTime = time() qApp.setOverrideCursor(QCursor(Qt.WaitCursor)) @@ -744,7 +763,7 @@ class GuiDocEditor(QTextEdit): qApp.restoreOverrideCursor() afTime = time() logger.debug("Document highlighted in %.3f ms", 1000*(afTime-bfTime)) - self.mainGui.statusBar.setStatus(self.tr("Spell check complete")) + self.mainGui.mainStatus.setStatus(self.tr("Spell check complete")) return True @@ -767,7 +786,7 @@ class GuiDocEditor(QTextEdit): logger.error("Not a document action") return False - logger.verbose("Requesting action: %s", theAction.name) + logger.debug("Requesting action: %s", theAction.name) self._allowAutoReplace(False) if theAction == nwDocAction.UNDO: @@ -787,9 +806,9 @@ class GuiDocEditor(QTextEdit): elif theAction == nwDocAction.STRIKE: self._toggleFormat(2, "~") elif theAction == nwDocAction.S_QUOTE: - self._wrapSelection(self._typSQOpen, self._typSQClose) + self._wrapSelection(self._typSQuoteO, self._typSQuoteC) elif theAction == nwDocAction.D_QUOTE: - self._wrapSelection(self._typDQOpen, self._typDQClose) + self._wrapSelection(self._typDQuoteO, self._typDQuoteC) elif theAction == nwDocAction.SEL_ALL: self._makeSelection(QTextCursor.Document) elif theAction == nwDocAction.SEL_PARA: @@ -811,9 +830,9 @@ class GuiDocEditor(QTextEdit): elif theAction == nwDocAction.BLOCK_UNN: self._formatBlock(nwDocAction.BLOCK_UNN) elif theAction == nwDocAction.REPL_SNG: - self._replaceQuotes("'", self._typSQOpen, self._typSQClose) + self._replaceQuotes("'", self._typSQuoteO, self._typSQuoteC) elif theAction == nwDocAction.REPL_DBL: - self._replaceQuotes("\"", self._typDQOpen, self._typDQClose) + self._replaceQuotes("\"", self._typDQuoteO, self._typDQuoteC) elif theAction == nwDocAction.RM_BREAKS: self._removeInParLineBreaks() elif theAction == nwDocAction.ALIGN_L: @@ -873,34 +892,42 @@ class GuiDocEditor(QTextEdit): return False newBlock = False + goAfter = False if isinstance(theInsert, str): theText = theInsert elif isinstance(theInsert, nwDocInsert): if theInsert == nwDocInsert.QUOTE_LS: - theText = self._typSQOpen + theText = self._typSQuoteO elif theInsert == nwDocInsert.QUOTE_RS: - theText = self._typSQClose + theText = self._typSQuoteC elif theInsert == nwDocInsert.QUOTE_LD: - theText = self._typDQOpen + theText = self._typDQuoteO elif theInsert == nwDocInsert.QUOTE_RD: - theText = self._typDQClose + theText = self._typDQuoteC + elif theInsert == nwDocInsert.SYNOPSIS: + theText = "% Synopsis: " + newBlock = True + goAfter = True elif theInsert == nwDocInsert.NEW_PAGE: theText = "[NEW PAGE]" newBlock = True + goAfter = False elif theInsert == nwDocInsert.VSPACE_S: theText = "[VSPACE]" newBlock = True + goAfter = False elif theInsert == nwDocInsert.VSPACE_M: theText = "[VSPACE:2]" newBlock = True + goAfter = False else: return False else: return False if newBlock: - self.insertNewBlock(theText, defaultAfter=False) + self.insertNewBlock(theText, defaultAfter=goAfter) else: theCursor = self.textCursor() theCursor.beginEditBlock() @@ -948,7 +975,7 @@ class GuiDocEditor(QTextEdit): logger.error("Invalid keyword '%s'", keyWord) return False - logger.verbose("Inserting keyword '%s'", keyWord) + logger.debug("Inserting keyword '%s'", keyWord) theState = self.insertNewBlock("%s: " % keyWord) return theState @@ -1000,7 +1027,7 @@ class GuiDocEditor(QTextEdit): if self.mainConf.autoScroll: cOld = self.cursorRect().center().y() - QTextEdit.keyPressEvent(self, keyEvent) + super().keyPressEvent(keyEvent) kMod = keyEvent.modifiers() okMod = kMod == Qt.NoModifier or kMod == Qt.ShiftModifier @@ -1019,7 +1046,7 @@ class GuiDocEditor(QTextEdit): doAnim.start() else: - QTextEdit.keyPressEvent(self, keyEvent) + super().keyPressEvent(keyEvent) self.docFooter.updateLineCount() @@ -1046,7 +1073,7 @@ class GuiDocEditor(QTextEdit): theCursor = self.cursorForPosition(theEvent.pos()) self._followTag(theCursor) - QTextEdit.mouseReleaseEvent(self, theEvent) + super().mouseReleaseEvent(theEvent) self.docFooter.updateLineCount() return @@ -1056,7 +1083,7 @@ class GuiDocEditor(QTextEdit): has its margins adjusted according to user preferences. """ self.updateDocMargins() - QTextEdit.resizeEvent(self, theEvent) + super().resizeEvent(theEvent) return ## @@ -1166,6 +1193,7 @@ class GuiDocEditor(QTextEdit): posCursor = self.cursorForPosition(thePos) spellCheck = self._spellCheck + theWord = "" if posCursor.block().text().startswith("@"): spellCheck = False @@ -1176,7 +1204,7 @@ class GuiDocEditor(QTextEdit): spellCheck &= theWord != "" if spellCheck: - logger.verbose("Looking up '%s' in the dictionary", theWord) + logger.debug("Looking up '%s' in the dictionary", theWord) spellCheck &= not self.spEnchant.checkWord(theWord) if spellCheck: @@ -1242,11 +1270,11 @@ class GuiDocEditor(QTextEdit): return if self.wCounterDoc.isRunning(): - logger.verbose("Word counter is busy") + logger.debug("Word counter is busy") return if time() - self._lastEdit < 5 * self.wcInterval: - logger.verbose("Running word counter") + logger.debug("Running word counter") self.mainGui.threadPool.start(self.wCounterDoc) return @@ -1258,7 +1286,7 @@ class GuiDocEditor(QTextEdit): if self._docHandle is None or self._nwItem is None: return - logger.verbose("Updating word count") + logger.debug("Updating word count") self._charCount = cCount self._wordCount = wCount @@ -1301,7 +1329,7 @@ class GuiDocEditor(QTextEdit): return if self.wCounterSel.isRunning(): - logger.verbose("Selection word counter is busy") + logger.debug("Selection word counter is busy") return self.mainGui.threadPool.start(self.wCounterSel) @@ -1315,7 +1343,7 @@ class GuiDocEditor(QTextEdit): if self._docHandle is None or self._nwItem is None: return - logger.verbose("User selectee %d words", wCount) + logger.debug("User selectee %d words", wCount) self.docFooter.updateCounts(wCount=wCount, cCount=cCount) self.wcTimerSel.stop() @@ -1333,11 +1361,11 @@ class GuiDocEditor(QTextEdit): QPointF(theSize.width(), theSize.height()), Qt.FuzzyHit ) if self._queuePos <= thePos: - logger.verbose("Allowed cursor move to %d <= %d", self._queuePos, thePos) + logger.debug("Allowed cursor move to %d <= %d", self._queuePos, thePos) self.setCursorPosition(self._queuePos) self._queuePos = None else: - logger.verbose("Denied cursor move to %d > %d", self._queuePos, thePos) + logger.debug("Denied cursor move to %d > %d", self._queuePos, thePos) return @@ -1434,6 +1462,7 @@ class GuiDocEditor(QTextEdit): origB = theCursor.selectionEnd() else: origA = theCursor.position() + origB = theCursor.position() findOpt = QTextDocument.FindFlag(0) if self.docSearch.isCaseSense: @@ -1524,7 +1553,7 @@ class GuiDocEditor(QTextEdit): theCursor.endEditBlock() theCursor.setPosition(theCursor.selectionEnd()) self.setTextCursor(theCursor) - logger.verbose( + logger.debug( "Replaced occurrence of '%s' with '%s' on line %d", searchFor, replWith, theCursor.blockNumber() ) @@ -1703,12 +1732,8 @@ class GuiDocEditor(QTextEdit): logger.debug("Invalid block selected for action '%s'", str(docAction)) return False - theText = theBlock.text() - if len(theText.strip()) == 0: - logger.debug("Empty block selected for action '%s'", str(docAction)) - return False - # Remove existing format first, if any + theText = theBlock.text() if theText.startswith("@"): logger.error("Cannot apply block format to keyword/value line") return False @@ -1878,10 +1903,10 @@ class GuiDocEditor(QTextEdit): def _followTag(self, theCursor=None, loadTag=True): """Activated by Ctrl+Enter. Checks that we're in a block - starting with '@'. We then find the word under the cursor and - check that it is after the ':'. If all this is fine, we have a - tag and can tell the document viewer to try and find and load - the file where the tag is defined. + starting with '@'. We then find the tag under the cursor and + check that it is not the tag itself. If all this is fine, we + have a tag and can tell the document viewer to try and find and + load the file where the tag is defined. """ if theCursor is None: theCursor = self.textCursor() @@ -1894,18 +1919,29 @@ class GuiDocEditor(QTextEdit): if theText.startswith("@"): - theCursor.select(QTextCursor.WordUnderCursor) - theWord = theCursor.selectedText() - cPos = theText.find(":") - wPos = theCursor.selectionStart() - theBlock.position() - if wPos <= cPos: + isGood, tBits, tPos = self.theProject.index.scanThis(theText) + if not isGood: + return False + + theTag = "" + cPos = theCursor.selectionStart() - theBlock.position() + for sTag, sPos in zip(reversed(tBits), reversed(tPos)): + if cPos >= sPos: + # The cursor is between the start of two tags + if cPos <= sPos + len(sTag): + # The cursor is inside or at the edge of the tag + theTag = sTag + break + + if not theTag or theTag.startswith("@"): + # The keyword cannot be looked up, so we ignore that return False if loadTag: - logger.verbose("Attempting to follow tag '%s'", theWord) - self.loadDocumentTagRequest.emit(theWord, nwDocMode.VIEW) + logger.debug("Attempting to follow tag '%s'", theTag) + self.loadDocumentTagRequest.emit(theTag, nwDocMode.VIEW) else: - logger.verbose("Potential tag '%s'", theWord) + logger.debug("Potential tag '%s'", theTag) return True @@ -1943,60 +1979,71 @@ class GuiDocEditor(QTextEdit): nDelete = 0 tInsert = theOne - if self.mainConf.doReplaceDQuote and theTwo[:1].isspace() and theTwo.endswith('"'): + if self._typRepDQuote and theTwo[:1].isspace() and theTwo.endswith('"'): nDelete = 1 - tInsert = self._typDQOpen + tInsert = self._typDQuoteO - elif self.mainConf.doReplaceDQuote and theOne == '"': + elif self._typRepDQuote and theOne == '"': nDelete = 1 if thePos == 1: - tInsert = self._typDQOpen + tInsert = self._typDQuoteO elif thePos == 2 and theTwo == '>"': - tInsert = self._typDQOpen + tInsert = self._typDQuoteO elif thePos == 3 and theThree == '>>"': - tInsert = self._typDQOpen + tInsert = self._typDQuoteO else: - tInsert = self._typDQClose + tInsert = self._typDQuoteC - elif self.mainConf.doReplaceSQuote and theTwo[:1].isspace() and theTwo.endswith("'"): + elif self._typRepSQuote and theTwo[:1].isspace() and theTwo.endswith("'"): nDelete = 1 - tInsert = self._typSQOpen + tInsert = self._typSQuoteO - elif self.mainConf.doReplaceSQuote and theOne == "'": + elif self._typRepSQuote and theOne == "'": nDelete = 1 if thePos == 1: - tInsert = self._typSQOpen + tInsert = self._typSQuoteO elif thePos == 2 and theTwo == ">'": - tInsert = self._typSQOpen + tInsert = self._typSQuoteO elif thePos == 3 and theThree == ">>'": - tInsert = self._typSQOpen + tInsert = self._typSQuoteO else: - tInsert = self._typSQClose + tInsert = self._typSQuoteC - elif self.mainConf.doReplaceDash and theThree == "---": + elif self._typRepDash and theThree == "---": nDelete = 3 tInsert = nwUnicode.U_EMDASH - elif self.mainConf.doReplaceDash and theTwo == "--": + elif self._typRepDash and theTwo == "--": nDelete = 2 tInsert = nwUnicode.U_ENDASH - elif self.mainConf.doReplaceDash and theTwo == nwUnicode.U_ENDASH + "-": + elif self._typRepDash and theTwo == nwUnicode.U_ENDASH + "-": nDelete = 2 tInsert = nwUnicode.U_EMDASH - elif self.mainConf.doReplaceDots and theThree == "...": + elif self._typRepDots and theThree == "...": nDelete = 3 tInsert = nwUnicode.U_HELLIP - tCheck = tInsert - if tCheck in self.mainConf.fmtPadBefore: - nDelete = max(nDelete, 1) - tInsert = self._typPadChar + tInsert + elif theOne == nwUnicode.U_LSEP: + # This resolves issue #1150 + nDelete = 1 + tInsert = nwUnicode.U_PSEP - if tCheck in self.mainConf.fmtPadAfter: - nDelete = max(nDelete, 1) - tInsert = tInsert + self._typPadChar + tCheck = tInsert + if self._typPadBefore and tCheck in self._typPadBefore: + if self._allowSpaceBeforeColon(theText, tCheck): + nDelete = max(nDelete, 1) + chkPos = thePos - nDelete - 1 + if chkPos >= 0 and theText[chkPos].isspace(): + # Strip existing space before inserting a new (#1061) + nDelete += 1 + tInsert = self._typPadChar + tInsert + + if self._typPadAfter and tCheck in self._typPadAfter: + if self._allowSpaceBeforeColon(theText, tCheck): + nDelete = max(nDelete, 1) + tInsert = tInsert + self._typPadChar if nDelete > 0: theCursor.movePosition(QTextCursor.Left, QTextCursor.KeepAnchor, nDelete) @@ -2004,7 +2051,21 @@ class GuiDocEditor(QTextEdit): return - def _updateHeaders(self, checkPos=False, checkLevel=False): + @staticmethod + def _allowSpaceBeforeColon(text, char): + """Special checker function only used by the insert space + feature for French, Spanish, etc, so it doesn't insert a + space before colons in meta data lines. See issue #1090. + """ + if char == ":" and len(text) > 1: + if text[0] == "@": + return False + if text[0] == "%": + if text[1:].lstrip()[:9].lower() == "synopsis:": + return False + return True + + def _updateHeaders(self): """Update the headers record and return True if anything changed, if a check flag was provided. """ @@ -2012,21 +2073,12 @@ class GuiDocEditor(QTextEdit): return False newHeaders = self.theProject.index.getHandleHeaders(self._docHandle) - if checkPos: - newPos = [x[0] for x in newHeaders] - oldPos = [x[0] for x in self._docHeaders] - if checkLevel: - newLev = [x[1] for x in newHeaders] - oldLev = [x[1] for x in self._docHeaders] + newLev = [x[1] for x in newHeaders] + oldLev = [x[1] for x in self._docHeaders] self._docHeaders = newHeaders - if checkPos: - return newPos != oldPos - if checkLevel: - return newLev != oldLev - - return False + return newLev != oldLev def _checkDocSize(self, theSize): """Check if document size crosses the big document limit set in @@ -2133,7 +2185,7 @@ class GuiDocEditor(QTextEdit): class BackgroundWordCounter(QRunnable): def __init__(self, docEditor, forSelection=False): - QRunnable.__init__(self) + super().__init__() self._docEditor = docEditor self._forSelection = forSelection @@ -2183,7 +2235,7 @@ class BackgroundWordCounterSignals(QObject): class GuiDocEditSearch(QFrame): def __init__(self, docEditor): - QFrame.__init__(self, docEditor) + super().__init__(parent=docEditor) logger.debug("Initialising GuiDocEditSearch ...") @@ -2230,7 +2282,6 @@ class GuiDocEditSearch(QFrame): self.searchOpt.setToolButtonStyle(Qt.ToolButtonIconOnly) self.searchOpt.setIconSize(QSize(tPx, tPx)) self.searchOpt.setContentsMargins(0, 0, 0, 0) - self.searchOpt.setStyleSheet("QToolBar {padding: 0;}") self.searchLabel = QLabel(self.tr("Search")) self.searchLabel.setFont(self.boxFont) @@ -2241,35 +2292,30 @@ class GuiDocEditSearch(QFrame): self.resultLabel.setMinimumWidth(self.mainTheme.getTextWidth("?/?", self.boxFont)) self.toggleCase = QAction(self.tr("Case Sensitive"), self) - self.toggleCase.setIcon(self.mainTheme.getIcon("search_case")) self.toggleCase.setCheckable(True) self.toggleCase.setChecked(self.isCaseSense) self.toggleCase.toggled.connect(self._doToggleCase) self.searchOpt.addAction(self.toggleCase) self.toggleWord = QAction(self.tr("Whole Words Only"), self) - self.toggleWord.setIcon(self.mainTheme.getIcon("search_word")) self.toggleWord.setCheckable(True) self.toggleWord.setChecked(self.isWholeWord) self.toggleWord.toggled.connect(self._doToggleWord) self.searchOpt.addAction(self.toggleWord) self.toggleRegEx = QAction(self.tr("RegEx Mode"), self) - self.toggleRegEx.setIcon(self.mainTheme.getIcon("search_regex")) self.toggleRegEx.setCheckable(True) self.toggleRegEx.setChecked(self.isRegEx) self.toggleRegEx.toggled.connect(self._doToggleRegEx) self.searchOpt.addAction(self.toggleRegEx) self.toggleLoop = QAction(self.tr("Loop Search"), self) - self.toggleLoop.setIcon(self.mainTheme.getIcon("search_loop")) self.toggleLoop.setCheckable(True) self.toggleLoop.setChecked(self.doLoop) self.toggleLoop.toggled.connect(self._doToggleLoop) self.searchOpt.addAction(self.toggleLoop) self.toggleProject = QAction(self.tr("Search Next File"), self) - self.toggleProject.setIcon(self.mainTheme.getIcon("search_project")) self.toggleProject.setCheckable(True) self.toggleProject.setChecked(self.doNextFile) self.toggleProject.toggled.connect(self._doToggleProject) @@ -2278,7 +2324,6 @@ class GuiDocEditSearch(QFrame): self.searchOpt.addSeparator() self.toggleMatchCap = QAction(self.tr("Preserve Case"), self) - self.toggleMatchCap.setIcon(self.mainTheme.getIcon("search_preserve")) self.toggleMatchCap.setCheckable(True) self.toggleMatchCap.setChecked(self.doMatchCap) self.toggleMatchCap.toggled.connect(self._doToggleMatchCap) @@ -2287,7 +2332,6 @@ class GuiDocEditSearch(QFrame): self.searchOpt.addSeparator() self.cancelSearch = QAction(self.tr("Close Search"), self) - self.cancelSearch.setIcon(self.mainTheme.getIcon("search_cancel")) self.cancelSearch.triggered.connect(self._doClose) self.searchOpt.addAction(self.cancelSearch) @@ -2299,15 +2343,14 @@ class GuiDocEditSearch(QFrame): self.showReplace = QToolButton(self) self.showReplace.setArrowType(Qt.RightArrow) self.showReplace.setCheckable(True) - self.showReplace.setStyleSheet("QToolButton {border: none; background: transparent;}") self.showReplace.toggled.connect(self._doToggleReplace) - self.searchButton = QPushButton(self.mainTheme.getIcon("search"), "") + self.searchButton = QPushButton("") self.searchButton.setFixedSize(QSize(bPx, bPx)) self.searchButton.setToolTip(self.tr("Find in current document")) self.searchButton.clicked.connect(self._doSearch) - self.replaceButton = QPushButton(self.mainTheme.getIcon("search_replace"), "") + self.replaceButton = QPushButton("") self.replaceButton.setFixedSize(QSize(bPx, bPx)) self.replaceButton.setToolTip(self.tr("Find and replace in current document")) self.replaceButton.clicked.connect(self._doReplace) @@ -2337,6 +2380,35 @@ class GuiDocEditSearch(QFrame): self.replaceButton.setVisible(False) self.adjustSize() + self.updateTheme() + + logger.debug("GuiDocEditSearch initialisation complete") + + return + + def updateTheme(self): + """Update theme elements. + """ + qPalette = qApp.palette() + self.setPalette(qPalette) + self.searchBox.setPalette(qPalette) + self.replaceBox.setPalette(qPalette) + + # Set icons + self.toggleCase.setIcon(self.mainTheme.getIcon("search_case")) + self.toggleWord.setIcon(self.mainTheme.getIcon("search_word")) + self.toggleRegEx.setIcon(self.mainTheme.getIcon("search_regex")) + self.toggleLoop.setIcon(self.mainTheme.getIcon("search_loop")) + self.toggleProject.setIcon(self.mainTheme.getIcon("search_project")) + self.toggleMatchCap.setIcon(self.mainTheme.getIcon("search_preserve")) + self.cancelSearch.setIcon(self.mainTheme.getIcon("search_cancel")) + self.searchButton.setIcon(self.mainTheme.getIcon("search")) + self.replaceButton.setIcon(self.mainTheme.getIcon("search_replace")) + + # Set stylesheets + self.searchOpt.setStyleSheet("QToolBar {padding: 0;}") + self.showReplace.setStyleSheet("QToolButton {border: none; background: transparent;}") + # Construct Box Colours qPalette = self.searchBox.palette() baseCol = qPalette.base().color() @@ -2355,8 +2427,6 @@ class GuiDocEditSearch(QFrame): False: errCol } - logger.debug("GuiDocEditSearch initialisation complete") - return def closeSearch(self): @@ -2382,10 +2452,10 @@ class GuiDocEditSearch(QFrame): """ if self.replaceBox.isVisible(): if self.searchBox.hasFocus(): - self.replaceBox.setFocus(True) + self.replaceBox.setFocus() return True elif self.replaceBox.hasFocus(): - self.searchBox.setFocus(True) + self.searchBox.setFocus() return True return False @@ -2410,7 +2480,6 @@ class GuiDocEditSearch(QFrame): self.searchBox.selectAll() if self.isRegEx: self._alertSearchValid(True) - logger.verbose("Setting search text to '%s'", theText) return True def setReplaceText(self, theText): @@ -2450,7 +2519,8 @@ class GuiDocEditSearch(QFrame): self._alertSearchValid(theRegEx.isValid()) return theRegEx - else: # >= 50300 to < 51300 + else: # pragma: no cover + # >= 50300 to < 51300 if self.isCaseSense: rxOpt = Qt.CaseSensitive else: @@ -2475,12 +2545,14 @@ class GuiDocEditSearch(QFrame): # Slots ## + @pyqtSlot() def _doClose(self): """Hide the search/replace bar. """ self.closeSearch() return + @pyqtSlot() def _doSearch(self): """Call the search action function for the document editor. """ @@ -2491,12 +2563,14 @@ class GuiDocEditSearch(QFrame): self.docEditor.findNext() return + @pyqtSlot() def _doReplace(self): """Call the replace action function for the document editor. """ self.docEditor.replaceNext() return + @pyqtSlot(bool) def _doToggleReplace(self, theState): """Toggle the show/hide of the replace box. """ @@ -2511,36 +2585,42 @@ class GuiDocEditSearch(QFrame): self.docEditor.updateDocMargins() return + @pyqtSlot(bool) def _doToggleCase(self, theState): """Enable/disable case sensitive mode. """ self.isCaseSense = theState return + @pyqtSlot(bool) def _doToggleWord(self, theState): """Enable/disable whole word search mode. """ self.isWholeWord = theState return + @pyqtSlot(bool) def _doToggleRegEx(self, theState): """Enable/disable regular expression search mode. """ self.isRegEx = theState return + @pyqtSlot(bool) def _doToggleLoop(self, theState): """Enable/disable looping the search. """ self.doLoop = theState return + @pyqtSlot(bool) def _doToggleProject(self, theState): """Enable/disable continuing search in next project file. """ self.doNextFile = theState return + @pyqtSlot(bool) def _doToggleMatchCap(self, theState): """Enable/disable preserving capitalisation when replacing. """ @@ -2571,7 +2651,7 @@ class GuiDocEditSearch(QFrame): class GuiDocEditHeader(QWidget): def __init__(self, docEditor): - QWidget.__init__(self, docEditor) + super().__init__(parent=docEditor) logger.debug("Initialising GuiDocEditHeader ...") @@ -2603,51 +2683,38 @@ class GuiDocEditHeader(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.editButton = QToolButton(self) - self.editButton.setIcon(self.mainTheme.getIcon("edit")) self.editButton.setContentsMargins(0, 0, 0, 0) self.editButton.setIconSize(QSize(fPx, fPx)) self.editButton.setFixedSize(fPx, fPx) - self.editButton.setStyleSheet(buttonStyle) self.editButton.setToolButtonStyle(Qt.ToolButtonIconOnly) self.editButton.setVisible(False) - self.editButton.setToolTip(self.tr("Edit document meta")) + self.editButton.setToolTip(self.tr("Edit document label")) self.editButton.clicked.connect(self._editDocument) self.searchButton = QToolButton(self) - self.searchButton.setIcon(self.mainTheme.getIcon("search")) self.searchButton.setContentsMargins(0, 0, 0, 0) self.searchButton.setIconSize(QSize(fPx, fPx)) self.searchButton.setFixedSize(fPx, fPx) - self.searchButton.setStyleSheet(buttonStyle) self.searchButton.setToolButtonStyle(Qt.ToolButtonIconOnly) self.searchButton.setVisible(False) self.searchButton.setToolTip(self.tr("Search document")) self.searchButton.clicked.connect(self._searchDocument) self.minmaxButton = QToolButton(self) - self.minmaxButton.setIcon(self.mainTheme.getIcon("maximise")) self.minmaxButton.setContentsMargins(0, 0, 0, 0) self.minmaxButton.setIconSize(QSize(fPx, fPx)) self.minmaxButton.setFixedSize(fPx, fPx) - self.minmaxButton.setStyleSheet(buttonStyle) self.minmaxButton.setToolButtonStyle(Qt.ToolButtonIconOnly) self.minmaxButton.setVisible(False) self.minmaxButton.setToolTip(self.tr("Toggle Focus Mode")) self.minmaxButton.clicked.connect(self._minmaxDocument) 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")) @@ -2670,8 +2737,7 @@ class GuiDocEditHeader(QWidget): self.outerBox.setContentsMargins(cM, cM, cM, cM) self.setMinimumHeight(fPx + 2*cM) - # Fix the Colours - self.matchColours() + self.updateTheme() logger.debug("GuiDocEditHeader initialisation complete") @@ -2681,6 +2747,28 @@ class GuiDocEditHeader(QWidget): # Methods ## + def updateTheme(self): + """Update theme elements. + """ + self.editButton.setIcon(self.mainTheme.getIcon("edit")) + self.searchButton.setIcon(self.mainTheme.getIcon("search")) + self.minmaxButton.setIcon(self.mainTheme.getIcon("maximise")) + 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.editButton.setStyleSheet(buttonStyle) + self.searchButton.setStyleSheet(buttonStyle) + self.minmaxButton.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. @@ -2745,18 +2833,21 @@ class GuiDocEditHeader(QWidget): # Slots ## + @pyqtSlot() def _editDocument(self): """Open the edit item dialog from the main GUI. """ self.mainGui.editItemLabel(self._docHandle) return + @pyqtSlot() def _searchDocument(self): """Toggle the visibility of the search box. """ self.docEditor.toggleSearch() return + @pyqtSlot() def _closeDocument(self): """Trigger the close editor on the main window. """ @@ -2767,6 +2858,7 @@ class GuiDocEditHeader(QWidget): self.minmaxButton.setVisible(False) return + @pyqtSlot() def _minmaxDocument(self): """Switch on or off Focus Mode. """ @@ -2795,7 +2887,7 @@ class GuiDocEditHeader(QWidget): class GuiDocEditFooter(QWidget): def __init__(self, docEditor): - QWidget.__init__(self, docEditor) + super().__init__(parent=docEditor) logger.debug("Initialising GuiDocEditFooter ...") @@ -2839,7 +2931,6 @@ class GuiDocEditFooter(QWidget): # Lines self.linesIcon = QLabel("") - self.linesIcon.setPixmap(self.mainTheme.getPixmap("status_lines", (self.sPx, self.sPx))) self.linesIcon.setContentsMargins(0, 0, 0, 0) self.linesIcon.setFixedHeight(self.sPx) self.linesIcon.setAlignment(Qt.AlignLeft | Qt.AlignTop) @@ -2855,7 +2946,6 @@ class GuiDocEditFooter(QWidget): # Words self.wordsIcon = QLabel("") - self.wordsIcon.setPixmap(self.mainTheme.getPixmap("status_stats", (self.sPx, self.sPx))) self.wordsIcon.setContentsMargins(0, 0, 0, 0) self.wordsIcon.setFixedHeight(self.sPx) self.wordsIcon.setAlignment(Qt.AlignLeft | Qt.AlignTop) @@ -2890,7 +2980,7 @@ class GuiDocEditFooter(QWidget): self.setMinimumHeight(fPx + 2*cM) # Fix the Colours - self.matchColours() + self.updateTheme() self.updateLineCount() self.updateCounts() @@ -2902,6 +2992,16 @@ class GuiDocEditFooter(QWidget): # Methods ## + def updateTheme(self): + """Update theme elements. + """ + self.linesIcon.setPixmap(self.mainTheme.getPixmap("status_lines", (self.sPx, self.sPx))) + self.wordsIcon.setPixmap(self.mainTheme.getPixmap("status_stats", (self.sPx, self.sPx))) + + self.matchColours() + + return + def matchColours(self): """Update the colours of the widget to match those of the syntax theme rather than the main GUI. @@ -2923,7 +3023,7 @@ class GuiDocEditFooter(QWidget): """ self._docHandle = tHandle if self._docHandle is None: - logger.verbose("No handle set, so clearing the editor footer") + logger.debug("No handle set, so clearing the editor footer") self._theItem = None else: self._theItem = self.theProject.tree[self._docHandle] @@ -2950,8 +3050,7 @@ class GuiDocEditFooter(QWidget): else: theStatus, theIcon = self._theItem.getImportStatus() sIcon = theIcon.pixmap(self.sPx, self.sPx) - hLevel = self.theProject.index.getHandleHeaderLevel(self._docHandle) - sText = f"{theStatus} / {self._theItem.describeMe(hLevel)}" + sText = f"{theStatus} / {self._theItem.describeMe()}" self.statusIcon.setPixmap(sIcon) self.statusText.setText(sText) diff --git a/novelwriter/gui/dochighlight.py b/novelwriter/gui/dochighlight.py index 8b7ec1a5..baea34bb 100644 --- a/novelwriter/gui/dochighlight.py +++ b/novelwriter/gui/dochighlight.py @@ -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"], } )) diff --git a/novelwriter/gui/docviewer.py b/novelwriter/gui/docviewer.py index 6ce275a3..e498a100 100644 --- a/novelwriter/gui/docviewer.py +++ b/novelwriter/gui/docviewer.py @@ -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:] diff --git a/novelwriter/gui/itemdetails.py b/novelwriter/gui/itemdetails.py index 859e58f2..9183ebfe 100644 --- a/novelwriter/gui/itemdetails.py +++ b/novelwriter/gui/itemdetails.py @@ -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}") diff --git a/novelwriter/gui/mainmenu.py b/novelwriter/gui/mainmenu.py index 59af7691..77eff2b1 100644 --- a/novelwriter/gui/mainmenu.py +++ b/novelwriter/gui/mainmenu.py @@ -26,6 +26,7 @@ along with this program. If not, see . 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) diff --git a/novelwriter/gui/noveltree.py b/novelwriter/gui/noveltree.py index 8711dc8d..02a7f567 100644 --- a/novelwriter/gui/noveltree.py +++ b/novelwriter/gui/noveltree.py @@ -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("%s" % 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"

{synopLabel}: {synopText}

" + + 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 = "
".join(refLines) + refText = f"

{refList}

" + + 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"{trConst(nwLabels.KEY_NAME[key])}: {tags}") + return lines + # END Class GuiNovelTree diff --git a/novelwriter/gui/outline.py b/novelwriter/gui/outline.py index 4b2077f6..3ccba658 100644 --- a/novelwriter/gui/outline.py +++ b/novelwriter/gui/outline.py @@ -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("%s" % 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) diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index 08b24232..5ddeacba 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -34,15 +34,15 @@ from time import time from PyQt5.QtGui import QPalette from PyQt5.QtCore import Qt, QSize, pyqtSignal, pyqtSlot from PyQt5.QtWidgets import ( - QAbstractItemView, QFrame, QHBoxLayout, QHeaderView, QLabel, + QAbstractItemView, QDialog, QFrame, QHBoxLayout, QHeaderView, QLabel, QMenu, QShortcut, QSizePolicy, QToolButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget ) -from novelwriter.core import NWDoc +from novelwriter.core import DocMerger, DocSplitter from novelwriter.enum import nwDocMode, nwItemType, nwItemClass, nwItemLayout, nwAlert -from novelwriter.constants import trConst, nwLabels -from novelwriter.dialogs.editlabel import GuiEditLabel +from novelwriter.dialogs import GuiDocMerge, GuiDocSplit, GuiEditLabel, GuiProjectSettings +from novelwriter.constants import nwHeaders, nwUnicode, trConst, nwLabels logger = logging.getLogger(__name__) @@ -55,7 +55,6 @@ class GuiProjectView(QWidget): # Signals triggered when the meta data values of items change treeItemChanged = pyqtSignal(str) - novelItemChanged = pyqtSignal(str) rootFolderChanged = pyqtSignal(str) wordCountsChanged = pyqtSignal() @@ -63,14 +62,18 @@ class GuiProjectView(QWidget): selectedItemChanged = pyqtSignal(str) openDocumentRequest = pyqtSignal(str, Enum, int, str) + # Requests for the main GUI + projectSettingsRequest = pyqtSignal(int) + def __init__(self, mainGui): - QWidget.__init__(self, mainGui) + super().__init__(parent=mainGui) self.mainGui = mainGui # Build GUI self.projTree = GuiProjectTree(self) self.projBar = GuiProjectToolBar(self) + self.projBar.setEnabled(False) # Assemble self.outerBox = QVBoxLayout() @@ -103,11 +106,8 @@ class GuiProjectView(QWidget): self.keyContext.activated.connect(lambda: self.projTree.openContextOnSelected()) # Function Mappings - self.revealNewTreeItem = self.projTree.revealNewTreeItem - self.renameTreeItem = self.projTree.renameTreeItem - self.getTreeFromHandle = self.projTree.getTreeFromHandle self.emptyTrash = self.projTree.emptyTrash - self.deleteItem = self.projTree.deleteItem + self.requestDeleteItem = self.projTree.requestDeleteItem self.setTreeItemValues = self.projTree.setTreeItemValues self.propagateCount = self.projTree.propagateCount self.getSelectedHandle = self.projTree.getSelectedHandle @@ -120,19 +120,43 @@ class GuiProjectView(QWidget): # Methods ## + def updateTheme(self): + """Update theme elements. + """ + self.projBar.updateTheme() + self.populateTree() + return + def initSettings(self): + """Initialise GUI elements that depend on specific settings. + """ self.projTree.initSettings() return def clearProject(self): + """Clear project-related GUI content. + """ + self.projBar.clearContent() + self.projBar.setEnabled(False) self.projTree.clearTree() return - def saveProjectTree(self): + def openProjectTasks(self): + """Run open project tasks. + """ + self.projBar.buildQuickLinkMenu() + self.projBar.setEnabled(True) + return + + def saveProjectTasks(self): + """Run save project tasks. + """ self.projTree.saveTreeOrder() return def populateTree(self): + """Build the tree structure from project data. + """ self.projTree.buildTree() return @@ -147,6 +171,16 @@ class GuiProjectView(QWidget): """ return self.projTree.hasFocus() + def renameTreeItem(self, tHandle=None): + """External request to rename an item or the currently selected + item. This is triggered by the global menu or keyboard shortcut. + """ + if tHandle is None: + tHandle = self.projTree.getSelectedHandle() + if tHandle: + return self.projTree.renameTreeItem(tHandle) + return + ## # Public Slots ## @@ -159,13 +193,20 @@ class GuiProjectView(QWidget): self.wordCountsChanged.emit() return + @pyqtSlot(str) + def updateRootItem(self, tHandle): + """If any root item changes, rebuild the quick link root menu. + """ + self.projBar.buildQuickLinkMenu() + return + # END Class GuiProjectView class GuiProjectToolBar(QWidget): def __init__(self, projView): - QTreeWidget.__init__(self, projView) + super().__init__(parent=projView) logger.debug("Initialising GuiProjectToolBar ...") @@ -177,98 +218,84 @@ class GuiProjectToolBar(QWidget): self.mainTheme = projView.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("%s" % self.tr("Project Content")) self.viewLabel.setContentsMargins(0, 0, 0, 0) self.viewLabel.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + # Quick Links + self.mQuick = QMenu() + + self.tbQuick = QToolButton(self) + self.tbQuick.setToolTip("%s [Ctrl+L]" % self.tr("Quick Links")) + self.tbQuick.setShortcut("Ctrl+L") + self.tbQuick.setIconSize(QSize(iPx, iPx)) + self.tbQuick.setMenu(self.mQuick) + self.tbQuick.setPopupMode(QToolButton.InstantPopup) + # Move Buttons self.tbMoveU = QToolButton(self) self.tbMoveU.setToolTip("%s [Ctrl+Up]" % self.tr("Move Up")) - self.tbMoveU.setIcon(self.mainTheme.getIcon("up")) self.tbMoveU.setIconSize(QSize(iPx, iPx)) - self.tbMoveU.setStyleSheet(buttonStyle) self.tbMoveU.clicked.connect(lambda: self.projTree.moveTreeItem(-1)) self.tbMoveD = QToolButton(self) self.tbMoveD.setToolTip("%s [Ctrl+Down]" % self.tr("Move Down")) - self.tbMoveD.setIcon(self.mainTheme.getIcon("down")) self.tbMoveD.setIconSize(QSize(iPx, iPx)) - self.tbMoveD.setStyleSheet(buttonStyle) self.tbMoveD.clicked.connect(lambda: self.projTree.moveTreeItem(1)) # Add Item Menu self.mAdd = QMenu() self.aAddEmpty = self.mAdd.addAction(trConst(nwLabels.ITEM_DESCRIPTION["document"])) - self.aAddEmpty.setIcon(self.mainTheme.getIcon("proj_document")) self.aAddEmpty.triggered.connect( lambda: self.projTree.newTreeItem(nwItemType.FILE, hLevel=0, isNote=False) ) self.aAddChap = self.mAdd.addAction(trConst(nwLabels.ITEM_DESCRIPTION["doc_h2"])) - self.aAddChap.setIcon(self.mainTheme.getIcon("proj_chapter")) self.aAddChap.triggered.connect( lambda: self.projTree.newTreeItem(nwItemType.FILE, hLevel=2, isNote=False) ) self.aAddScene = self.mAdd.addAction(trConst(nwLabels.ITEM_DESCRIPTION["doc_h3"])) - self.aAddScene.setIcon(self.mainTheme.getIcon("proj_scene")) self.aAddScene.triggered.connect( lambda: self.projTree.newTreeItem(nwItemType.FILE, hLevel=3, isNote=False) ) self.aAddNote = self.mAdd.addAction(trConst(nwLabels.ITEM_DESCRIPTION["note"])) - self.aAddNote.setIcon(self.mainTheme.getIcon("proj_note")) self.aAddNote.triggered.connect( lambda: self.projTree.newTreeItem(nwItemType.FILE, hLevel=1, isNote=True) ) self.aAddFolder = self.mAdd.addAction(trConst(nwLabels.ITEM_DESCRIPTION["folder"])) - self.aAddFolder.setIcon(self.mainTheme.getIcon("proj_folder")) self.aAddFolder.triggered.connect( lambda: self.projTree.newTreeItem(nwItemType.FOLDER) ) self.mAddRoot = self.mAdd.addMenu(trConst(nwLabels.ITEM_DESCRIPTION["root"])) - self._addRootFolderEntry(nwItemClass.NOVEL) - self._addRootFolderEntry(nwItemClass.ARCHIVE) - self.mAddRoot.addSeparator() - self._addRootFolderEntry(nwItemClass.PLOT) - self._addRootFolderEntry(nwItemClass.CHARACTER) - self._addRootFolderEntry(nwItemClass.WORLD) - self._addRootFolderEntry(nwItemClass.TIMELINE) - self._addRootFolderEntry(nwItemClass.OBJECT) - self._addRootFolderEntry(nwItemClass.ENTITY) - self._addRootFolderEntry(nwItemClass.CUSTOM) + self._buildRootMenu() self.tbAdd = QToolButton(self) self.tbAdd.setToolTip("%s [Ctrl+N]" % self.tr("Add Item")) self.tbAdd.setShortcut("Ctrl+N") - self.tbAdd.setIcon(self.mainTheme.getIcon("add")) self.tbAdd.setIconSize(QSize(iPx, iPx)) - self.tbAdd.setStyleSheet(buttonStyle) self.tbAdd.setMenu(self.mAdd) self.tbAdd.setPopupMode(QToolButton.InstantPopup) # More Options Menu self.mMore = QMenu() + self.aExpand = self.mMore.addAction(self.tr("Expand All")) + self.aExpand.triggered.connect(lambda: self.projTree.setExpandedFromHandle(None, True)) + + self.aCollapse = self.mMore.addAction(self.tr("Collapse All")) + self.aCollapse.triggered.connect(lambda: self.projTree.setExpandedFromHandle(None, False)) + self.aMoreUndo = self.mMore.addAction(self.tr("Undo Move")) self.aMoreUndo.triggered.connect(lambda: self.projTree.undoLastMove()) @@ -277,15 +304,14 @@ class GuiProjectToolBar(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) # Assemble self.outerBox = QHBoxLayout() self.outerBox.addWidget(self.viewLabel) + self.outerBox.addWidget(self.tbQuick) self.outerBox.addWidget(self.tbMoveU) self.outerBox.addWidget(self.tbMoveD) self.outerBox.addWidget(self.tbAdd) @@ -294,42 +320,124 @@ class GuiProjectToolBar(QWidget): self.outerBox.setSpacing(0) self.setLayout(self.outerBox) + self.updateTheme() logger.debug("GuiProjectToolBar initialisation complete") return + ## + # Methods + ## + + def updateTheme(self): + """Update theme elements. + """ + 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(self.mainConf.pxInt(2), fadeCol.red(), fadeCol.green(), fadeCol.blue()) + + self.tbQuick.setStyleSheet(buttonStyle) + self.tbMoveU.setStyleSheet(buttonStyle) + self.tbMoveD.setStyleSheet(buttonStyle) + self.tbAdd.setStyleSheet(buttonStyle) + self.tbMore.setStyleSheet(buttonStyle) + + self.tbQuick.setIcon(self.mainTheme.getIcon("bookmark")) + self.tbMoveU.setIcon(self.mainTheme.getIcon("up")) + self.tbMoveD.setIcon(self.mainTheme.getIcon("down")) + self.aAddEmpty.setIcon(self.mainTheme.getIcon("proj_document")) + self.aAddChap.setIcon(self.mainTheme.getIcon("proj_chapter")) + self.aAddScene.setIcon(self.mainTheme.getIcon("proj_scene")) + self.aAddNote.setIcon(self.mainTheme.getIcon("proj_note")) + self.aAddFolder.setIcon(self.mainTheme.getIcon("proj_folder")) + self.tbAdd.setIcon(self.mainTheme.getIcon("add")) + self.tbMore.setIcon(self.mainTheme.getIcon("menu")) + + self.buildQuickLinkMenu() + self._buildRootMenu() + + return + + def clearContent(self): + """Clear dynamic content on the tool bar. + """ + self.mQuick.clear() + return + + def buildQuickLinkMenu(self): + """Build the quick link menu. + """ + logger.debug("Rebuilding quick links menu") + + self.mQuick.clear() + for n, (tHandle, nwItem) in enumerate(self.theProject.tree.iterRoots(None)): + aRoot = self.mQuick.addAction(nwItem.itemName) + aRoot.setData(tHandle) + aRoot.setIcon(self.mainTheme.getIcon(nwLabels.CLASS_ICON[nwItem.itemClass])) + aRoot.triggered.connect( + lambda n, tHandle=tHandle: self.projView.setSelectedHandle(tHandle, doScroll=True) + ) + + return + ## # Internal Functions ## - def _addRootFolderEntry(self, itemClass): - """Add a menu entry for a root folder of a given class. + def _buildRootMenu(self): + """Build the rood folder menu. """ - aNew = self.mAddRoot.addAction(trConst(nwLabels.CLASS_NAME[itemClass])) - aNew.setIcon(self.mainTheme.getIcon(nwLabels.CLASS_ICON[itemClass])) - aNew.triggered.connect(lambda: self.projTree.newTreeItem(nwItemType.ROOT, itemClass)) - self.mAddRoot.addAction(aNew) + def addClass(itemClass): + aNew = self.mAddRoot.addAction(trConst(nwLabels.CLASS_NAME[itemClass])) + aNew.setIcon(self.mainTheme.getIcon(nwLabels.CLASS_ICON[itemClass])) + aNew.triggered.connect(lambda: self.projTree.newTreeItem(nwItemType.ROOT, itemClass)) + self.mAddRoot.addAction(aNew) + return + + self.mAddRoot.clear() + addClass(nwItemClass.NOVEL) + addClass(nwItemClass.ARCHIVE) + self.mAddRoot.addSeparator() + addClass(nwItemClass.PLOT) + addClass(nwItemClass.CHARACTER) + addClass(nwItemClass.WORLD) + addClass(nwItemClass.TIMELINE) + addClass(nwItemClass.OBJECT) + addClass(nwItemClass.ENTITY) + addClass(nwItemClass.CUSTOM) + + return # END Class GuiProjectToolBar class GuiProjectTree(QTreeWidget): + C_DATA = 0 C_NAME = 0 C_COUNT = 1 - C_EXPORT = 2 + C_ACTIVE = 2 C_STATUS = 3 + D_HANDLE = Qt.UserRole + D_WORDS = Qt.UserRole + 1 + def __init__(self, projView): - QTreeWidget.__init__(self, projView) + super().__init__(parent=projView) logger.debug("Initialising GuiProjectTree ...") self.mainConf = novelwriter.CONFIG self.projView = projView self.mainGui = projView.mainGui - self.mainTheme = projView.mainGui.mainTheme + self.mainTheme = projView.mainGui.mainTheme self.theProject = projView.mainGui.theProject # Internal Variables @@ -350,7 +458,10 @@ class GuiProjectTree(QTreeWidget): self.setIconSize(QSize(iPx, iPx)) self.setFrameStyle(QFrame.NoFrame) + self.setUniformRowHeights(True) + self.setAllColumnsShowFocus(True) self.setExpandsOnDoubleClick(False) + self.setAutoExpandDelay(1000) self.setHeaderHidden(True) self.setIndentation(iPx) self.setColumnCount(4) @@ -361,9 +472,9 @@ class GuiProjectTree(QTreeWidget): treeHeader.setMinimumSectionSize(iPx + cMg) treeHeader.setSectionResizeMode(self.C_NAME, QHeaderView.Stretch) treeHeader.setSectionResizeMode(self.C_COUNT, QHeaderView.ResizeToContents) - treeHeader.setSectionResizeMode(self.C_EXPORT, QHeaderView.Fixed) + treeHeader.setSectionResizeMode(self.C_ACTIVE, QHeaderView.Fixed) treeHeader.setSectionResizeMode(self.C_STATUS, QHeaderView.Fixed) - treeHeader.resizeSection(self.C_EXPORT, iPx + cMg) + treeHeader.resizeSection(self.C_ACTIVE, iPx + cMg) treeHeader.resizeSection(self.C_STATUS, iPx + cMg) # Allow Move by Drag & Drop @@ -374,9 +485,12 @@ class GuiProjectTree(QTreeWidget): trRoot = self.invisibleRootItem() trRoot.setFlags(trRoot.flags() ^ Qt.ItemIsDropEnabled) - # Set Multiple Selection by CTRL - # Disabled for now, until the merge files option has been added - # self.setSelectionMode(QAbstractItemView.ExtendedSelection) + # Cached values + self._lblActive = self.tr("Active") + self._lblInactive = self.tr("Inactive") + + # Set selection options + self.setSelectionMode(QAbstractItemView.SingleSelection) self.setSelectionBehavior(QAbstractItemView.SelectRows) # Connect signals @@ -445,16 +559,11 @@ class GuiProjectTree(QTreeWidget): ), nwAlert.ERROR) return False - # If the selected item is a file, the new item will be a - # sibling if the file has no children, otherwise a child + # Collect some information about the selected item that pItem = self.theProject.tree[sHandle] qItem = self._getTreeItem(sHandle) - if pItem.itemType == nwItemType.FILE and qItem.childCount() == 0: - nHandle = sHandle - sHandle = pItem.itemParent - if sHandle is None: - logger.error("Internal error") # Bug - return False + sLevel = nwHeaders.H_LEVEL.get(pItem.mainHeading, 0) + sIsParent = False if qItem is None else qItem.childCount() > 0 if self.theProject.tree.isTrash(sHandle): self.mainGui.makeAlert(self.tr( @@ -462,19 +571,36 @@ class GuiProjectTree(QTreeWidget): ), nwAlert.ERROR) return False - # Ask for label + # Set default label and determine if new item is to be added + # as child or sibling to the selected item if itemType == nwItemType.FILE: if isNote: newLabel = self.tr("New Note") + asChild = sIsParent elif hLevel == 2: newLabel = self.tr("New Chapter") + asChild = sIsParent and pItem.isDocumentLayout() and sLevel < 2 elif hLevel == 3: newLabel = self.tr("New Scene") + asChild = sIsParent and pItem.isDocumentLayout() and sLevel < 3 else: newLabel = self.tr("New Document") + asChild = sIsParent and pItem.isDocumentLayout() else: newLabel = self.tr("New Folder") + asChild = False + if not (asChild or pItem.isFolderType() or pItem.isRootType()): + # Move to the parent item so that the new item is added + # as a sibling instead + nHandle = sHandle + sHandle = pItem.itemParent + if sHandle is None: + # Bug: We have a condition that is unhandled + logger.error("Internal error") + return False + + # Ask for label newLabel, dlgOk = GuiEditLabel.getLabel(self, text=newLabel) if not dlgOk: logger.info("New item creation cancelled by user") @@ -497,18 +623,14 @@ class GuiProjectTree(QTreeWidget): # Handle new file creation if itemType == nwItemType.FILE and hLevel > 0: - if self.theProject.writeNewFile(tHandle, hLevel, not isNote): - # If successful, update word count - wC = self.theProject.index.getCounts(tHandle)[1] - self.propagateCount(tHandle, wC) - self.projView.wordCountsChanged.emit() + self.theProject.writeNewFile(tHandle, hLevel, not isNote) # Add the new item to the project tree - self.revealNewTreeItem(tHandle, nHandle) + self.revealNewTreeItem(tHandle, nHandle=nHandle, wordCount=True) return True - def revealNewTreeItem(self, tHandle, nHandle=None): + def revealNewTreeItem(self, tHandle, nHandle=None, wordCount=False): """Reveal a newly added project item in the project tree. """ nwItem = self.theProject.tree[tHandle] @@ -519,13 +641,17 @@ class GuiProjectTree(QTreeWidget): if trItem is None: return False + if nwItem.isFileType() and wordCount: + wC = self.theProject.index.getCounts(tHandle)[1] + self.propagateCount(tHandle, wC) + self.projView.wordCountsChanged.emit() + pHandle = nwItem.itemParent if pHandle is not None and pHandle in self._treeMap: self._treeMap[pHandle].setExpanded(True) - self._alertTreeChange(tHandle=tHandle, flush=True) - self.clearSelection() - trItem.setSelected(True) + self._alertTreeChange(tHandle, flush=True) + self.setCurrentItem(trItem) return True @@ -535,7 +661,7 @@ class GuiProjectTree(QTreeWidget): tHandle = self.getSelectedHandle() trItem = self._getTreeItem(tHandle) if trItem is None: - logger.verbose("No item selected") + logger.debug("No item selected") return False pItem = trItem.parent() @@ -563,9 +689,8 @@ class GuiProjectTree(QTreeWidget): pItem.insertChild(nIndex, cItem) self._recordLastMove(cItem, pItem, tIndex) - self._alertTreeChange(tHandle=tHandle, flush=True) - self.clearSelection() - trItem.setSelected(True) + self._alertTreeChange(tHandle, flush=True) + self.setCurrentItem(trItem) trItem.setExpanded(isExp) return True @@ -581,9 +706,9 @@ class GuiProjectTree(QTreeWidget): if dlgOk: tItem.setName(newLabel) self.setTreeItemValues(tHandle) - self._alertTreeChange(tHandle=tHandle, flush=False) + self._alertTreeChange(tHandle, flush=False) - return + return True def saveTreeOrder(self): """Build a list of the items in the project tree and send them @@ -608,6 +733,42 @@ class GuiProjectTree(QTreeWidget): theList = self._scanChildren(theList, theItem, 0) return theList + def requestDeleteItem(self, tHandle=None): + """Request an item deleted from the project tree. This function + can be called on any item, and will check whether to attempt a + permanent deletion or moving the item to Trash. + """ + if not self.mainGui.hasProject: + logger.error("No project open") + return False + + if not self.hasFocus(): + logger.info("Delete action blocked due to no widget focus") + return False + + if tHandle is None: + tHandle = self.getSelectedHandle() + + if tHandle is None: + logger.error("There is no item to delete") + return False + + trashHandle = self.theProject.tree.trashRoot() + if tHandle == trashHandle: + logger.error("Cannot delete the Trash folder") + return False + + nwItem = self.theProject.tree[tHandle] + if nwItem is None: + return False + + if self.theProject.tree.isTrash(tHandle) or nwItem.isRootType(): + status = self.permanentlyDeleteItem(tHandle) + else: + status = self.moveItemToTrash(tHandle) + + return status + def emptyTrash(self): """Permanently delete all documents in the Trash folder. This function only asks for confirmation once, and calls the regular @@ -642,41 +803,24 @@ class GuiProjectTree(QTreeWidget): self.tr("Permanently delete {0} file(s) from Trash?").format(nTrash) ) if not msgYes: + logger.info("Action cancelled by user") return False - logger.verbose("Deleting %d file(s) from Trash", nTrash) + logger.debug("Deleting %d file(s) from Trash", nTrash) for tHandle in reversed(self.getTreeFromHandle(trashHandle)): if tHandle == trashHandle: continue - self.deleteItem(tHandle, alreadyAsked=True, bulkAction=True) + self.permanentlyDeleteItem(tHandle, askFirst=False, flush=False) if nTrash > 0: - self._alertTreeChange(tHandle=trashHandle, flush=True) + self._alertTreeChange(trashHandle, flush=True) return True - def deleteItem(self, tHandle=None, alreadyAsked=False, bulkAction=False): - """Delete an item from the project tree. As a first step, files are - moved to the Trash folder. Permanent deletion is a second step. This - second step also deletes the item from the project object as well as - delete the files on disk. Root folders are deleted if they're empty - only, and the deletion is always permanent. + def moveItemToTrash(self, tHandle, askFirst=True, flush=True): + """Move an item to Trash. Root folders cannot be moved to Trash, + so such a request is cancelled. """ - if not self.mainGui.hasProject: - logger.error("No project open") - return False - - if not self.hasFocus() and not bulkAction: - logger.info("Delete action blocked due to no widget focus") - return False - - if tHandle is None: - tHandle = self.getSelectedHandle() - - if tHandle is None: - logger.error("There is no item to delete") - return False - trItemS = self._getTreeItem(tHandle) nwItemS = self.theProject.tree[tHandle] @@ -684,87 +828,107 @@ class GuiProjectTree(QTreeWidget): logger.error("Could not find tree item for deletion") return False + if self.theProject.tree.isTrash(tHandle): + logger.error("Item is already in the Trash folder") + return False + + if nwItemS.isRootType(): + logger.error("Root folders cannot be moved to Trash") + return False + + logger.debug("User requested file or folder '%s' move to Trash", tHandle) + + trItemP = trItemS.parent() + trItemT = self._addTrashRoot() + if trItemP is None or trItemT is None: + logger.error("Could not delete item") + return False + + if askFirst: + msgYes = self.mainGui.askQuestion( + self.tr("Delete"), + self.tr("Move '{0}' to Trash?").format(nwItemS.itemName), + ) + if not msgYes: + logger.info("Action cancelled by user") + return False + wCount = self._getItemWordCount(tHandle) - autoFlush = not bulkAction - if nwItemS.itemType == nwItemType.ROOT: + self.propagateCount(tHandle, 0) + + tIndex = trItemP.indexOfChild(trItemS) + trItemC = trItemP.takeChild(tIndex) + trItemT.addChild(trItemC) + + self._postItemMove(tHandle, wCount) + self._recordLastMove(trItemS, trItemP, tIndex) + self._alertTreeChange(tHandle, flush=flush) + + logger.debug("Moved item '%s' to Trash", tHandle) + + return True + + def permanentlyDeleteItem(self, tHandle, askFirst=True, flush=True): + """Permanently delete a tree item from the project and the map. + Root items are handled a little different than other items. + """ + trItemS = self._getTreeItem(tHandle) + nwItemS = self.theProject.tree[tHandle] + if trItemS is None or nwItemS is None: + logger.error("Could not find tree item for deletion") + return False + + if nwItemS.isRootType(): # Only an empty ROOT folder can be deleted - logger.debug("User requested a root folder '%s' deleted", tHandle) - tIndex = self.indexOfTopLevelItem(trItemS) - if trItemS.childCount() == 0: - self.takeTopLevelItem(tIndex) - self._deleteTreeItem(tHandle) - self._alertTreeChange(tHandle=tHandle, flush=True) - else: + if trItemS.childCount() > 0: self.mainGui.makeAlert(self.tr( - "Cannot delete root folder. It is not empty. " - "Recursive deletion is not supported. " - "Please delete the content first." + "Root folders can only be deleted when they are empty." ), nwAlert.ERROR) return False - elif nwItemS.itemType == nwItemType.FOLDER and trItemS.childCount() == 0: - # An empty FOLDER is just deleted without any further checks - logger.debug("User requested an empty folder '%s' deleted", tHandle) + logger.debug("Permanently deleting root folder '%s'", tHandle) + + tIndex = self.indexOfTopLevelItem(trItemS) + self.takeTopLevelItem(tIndex) + self.theProject.removeItem(tHandle) + self._treeMap.pop(tHandle, None) + self._alertTreeChange(tHandle, flush=True) + + # These are not emitted by the alert function because the + # item has already been deleted + self.projView.rootFolderChanged.emit(tHandle) + self.projView.treeItemChanged.emit(tHandle) + + else: + if askFirst: + msgYes = self.mainGui.askQuestion( + self.tr("Delete"), + self.tr("Permanently delete '{0}'?").format(nwItemS.itemName) + ) + if not msgYes: + logger.info("Action cancelled by user") + return False + + logger.debug("Permanently deleting item '%s'", tHandle) + + self.propagateCount(tHandle, 0) + trItemP = trItemS.parent() tIndex = trItemP.indexOfChild(trItemS) trItemP.takeChild(tIndex) - self._deleteTreeItem(tHandle) - self._alertTreeChange(tHandle=tHandle, flush=autoFlush) - else: - # A populated FOLDER or a FILE requires confirmtation - logger.debug("User requested a file or folder '%s' deleted", tHandle) - trItemP = trItemS.parent() - trItemT = self._addTrashRoot() - if trItemP is None or trItemT is None: - logger.error("Could not delete item") - return False + for dHandle in reversed(self.getTreeFromHandle(tHandle)): + if self.mainGui.docEditor.docHandle() == dHandle: + self.mainGui.closeDocument() + self.theProject.removeItem(dHandle) + self._treeMap.pop(dHandle, None) - if self.theProject.tree.isTrash(tHandle): - # If the file is in the trash folder already, as the - # user if they want to permanently delete the file. - doPermanent = False - if not alreadyAsked: - msgYes = self.mainGui.askQuestion( - self.tr("Delete"), - self.tr("Permanently delete '{0}'?").format(nwItemS.itemName) - ) - if msgYes: - doPermanent = True - else: - doPermanent = True + self._alertTreeChange(tHandle, flush=flush) + self.projView.wordCountsChanged.emit() - if doPermanent: - logger.debug("Permanently deleting item with handle '%s'", tHandle) - - self.propagateCount(tHandle, 0) - tIndex = trItemP.indexOfChild(trItemS) - trItemC = trItemP.takeChild(tIndex) - for dHandle in reversed(self.getTreeFromHandle(tHandle)): - if self.mainGui.docEditor.docHandle() == dHandle: - self.mainGui.closeDocument() - self._deleteTreeItem(dHandle) - - self._alertTreeChange(tHandle=tHandle, flush=autoFlush) - self.projView.wordCountsChanged.emit() - - else: - # The item is not already in the trash folder, so we - # move it there. - msgYes = self.mainGui.askQuestion( - self.tr("Delete"), - self.tr("Move '{0}' to Trash?").format(nwItemS.itemName), - ) - if msgYes: - logger.debug("Moving item '%s' to trash", tHandle) - - self.propagateCount(tHandle, 0) - tIndex = trItemP.indexOfChild(trItemS) - trItemC = trItemP.takeChild(tIndex) - trItemT.addChild(trItemC) - self._postItemMove(tHandle, wCount) - self._recordLastMove(trItemS, trItemP, tIndex) - self._alertTreeChange(tHandle=tHandle, flush=autoFlush) + # This is not emitted by the alert function because the item + # has already been deleted + self.projView.treeItemChanged.emit(tHandle) return True @@ -779,7 +943,7 @@ class GuiProjectTree(QTreeWidget): return itemStatus, statusIcon = nwItem.getImportStatus() - hLevel = self.theProject.index.getHandleHeaderLevel(tHandle) + hLevel = nwItem.mainHeading itemIcon = self.mainTheme.getItemIcon( nwItem.itemType, nwItem.itemClass, nwItem.itemLayout, hLevel ) @@ -789,12 +953,16 @@ class GuiProjectTree(QTreeWidget): trItem.setIcon(self.C_STATUS, statusIcon) trItem.setToolTip(self.C_STATUS, itemStatus) - if nwItem.itemType == nwItemType.FILE: - trItem.setIcon( - self.C_EXPORT, self.mainTheme.getIcon("check" if nwItem.isExported else "cross") - ) + if nwItem.isFileType(): + iconName = "checked" if nwItem.isActive else "unchecked" + toolTip = self._lblActive if nwItem.isActive else self._lblInactive + trItem.setToolTip(self.C_ACTIVE, toolTip) + else: + iconName = "noncheckable" - if self.mainConf.emphLabels and nwItem.itemLayout == nwItemLayout.DOCUMENT: + trItem.setIcon(self.C_ACTIVE, self.mainTheme.getIcon(iconName)) + + if self.mainConf.emphLabels and nwItem.isDocumentLayout(): trFont = trItem.font(self.C_NAME) trFont.setBold(hLevel == "H1" or hLevel == "H2") trFont.setUnderline(hLevel == "H1") @@ -816,10 +984,10 @@ class GuiProjectTree(QTreeWidget): if countChildren: for i in range(tItem.childCount()): - newCount += int(tItem.child(i).data(self.C_COUNT, Qt.UserRole)) + newCount += int(tItem.child(i).data(self.C_DATA, self.D_WORDS)) tItem.setText(self.C_COUNT, f"{newCount:n}") - tItem.setData(self.C_COUNT, Qt.UserRole, int(newCount)) + tItem.setData(self.C_DATA, self.D_WORDS, int(newCount)) pItem = tItem.parent() if pItem is None: @@ -828,8 +996,8 @@ class GuiProjectTree(QTreeWidget): pCount = 0 pHandle = None for i in range(pItem.childCount()): - pCount += int(pItem.child(i).data(self.C_COUNT, Qt.UserRole)) - pHandle = pItem.data(self.C_NAME, Qt.UserRole) + pCount += int(pItem.child(i).data(self.C_DATA, self.D_WORDS)) + pHandle = pItem.data(self.C_DATA, self.D_HANDLE) if pHandle: if self.theProject.tree.checkType(pHandle, nwItemType.FILE): @@ -865,8 +1033,10 @@ class GuiProjectTree(QTreeWidget): dstItem = self._lastMove.get("parent", None) dstIndex = self._lastMove.get("index", None) - if srcItem is None or dstItem is None or dstIndex is None: - logger.verbose("No tree move to undo") + srcOK = isinstance(srcItem, QTreeWidgetItem) + dstOk = isinstance(dstItem, QTreeWidgetItem) + if not srcOK or not dstOk or dstIndex is None: + logger.debug("No tree move to undo") return False if srcItem not in self._treeMap.values(): @@ -878,8 +1048,8 @@ class GuiProjectTree(QTreeWidget): return False dstIndex = min(max(0, dstIndex), dstItem.childCount()) - sHandle = srcItem.data(self.C_NAME, Qt.UserRole) - dHandle = dstItem.data(self.C_NAME, Qt.UserRole) + sHandle = srcItem.data(self.C_DATA, self.D_HANDLE) + dHandle = dstItem.data(self.C_DATA, self.D_HANDLE) logger.debug("Moving item '%s' back to '%s', index %d", sHandle, dHandle, dstIndex) wCount = self._getItemWordCount(sHandle) @@ -890,10 +1060,9 @@ class GuiProjectTree(QTreeWidget): dstItem.insertChild(dstIndex, movItem) self._postItemMove(sHandle, wCount) - self._alertTreeChange(tHandle=sHandle, flush=True) + self._alertTreeChange(sHandle, flush=True) - self.clearSelection() - movItem.setSelected(True) + self.setCurrentItem(movItem) self._lastMove = {} return True @@ -904,7 +1073,7 @@ class GuiProjectTree(QTreeWidget): """ selItem = self.selectedItems() if selItem: - return selItem[0].data(self.C_NAME, Qt.UserRole) + return selItem[0].data(self.C_DATA, self.D_HANDLE) return None @@ -915,15 +1084,25 @@ class GuiProjectTree(QTreeWidget): if tItem is None: return False - self.clearSelection() - self._treeMap[tHandle].setSelected(True) + self.setFocus() + if tHandle in self._treeMap: + self.setCurrentItem(self._treeMap[tHandle]) - selItems = self.selectedIndexes() - if selItems and doScroll: - self.scrollTo(selItems[0], QAbstractItemView.PositionAtCenter) + selIndex = self.selectedIndexes() + if selIndex and doScroll: + self.scrollTo(selIndex[0], QAbstractItemView.PositionAtCenter) return True + def setExpandedFromHandle(self, tHandle, isExpanded): + """Iterate through items below tHandle and change expanded + status for all child items. If tHandle is None, it affects the + entire tree. + """ + trItem = self._getTreeItem(tHandle) or self.invisibleRootItem() + self._recursiveSetExpanded(trItem, isExpanded) + return + def openContextOnSelected(self): """Open the context menu on the current selected item. """ @@ -952,7 +1131,7 @@ class GuiProjectTree(QTreeWidget): return @pyqtSlot("QTreeWidgetItem*", int) - def _treeDoubleClick(self, tItem, colNo): + def _treeDoubleClick(self, trItem, colNo): """Capture a double-click event and either request the document for editing if it is a file, or expand/close the node it is not. """ @@ -964,12 +1143,10 @@ class GuiProjectTree(QTreeWidget): if tItem is None: return - if tItem.itemType == nwItemType.FILE: + if tItem.isFileType(): self.projView.openDocumentRequest.emit(tHandle, nwDocMode.EDIT, -1, "") else: - trItem = self._getTreeItem(tHandle) - if trItem is not None: - trItem.setExpanded(not trItem.isExpanded()) + trItem.setExpanded(not trItem.isExpanded()) return @@ -979,10 +1156,12 @@ class GuiProjectTree(QTreeWidget): open a context menu in-place. """ tItem = None + hasChild = False selItem = self.itemAt(clickPos) if isinstance(selItem, QTreeWidgetItem): - tHandle = selItem.data(self.C_NAME, Qt.UserRole) + tHandle = selItem.data(self.C_DATA, self.D_HANDLE) tItem = self.theProject.tree[tHandle] + hasChild = selItem.childCount() > 0 if tItem is None: logger.debug("No item found") @@ -996,23 +1175,25 @@ class GuiProjectTree(QTreeWidget): trashHandle = self.theProject.tree.trashRoot() if tItem.itemHandle == trashHandle and trashHandle is not None: # The trash folder only has one option - ctxMenu.addAction( - self.tr("Empty Trash"), lambda: self.emptyTrash() - ) + aEmptyTrash = ctxMenu.addAction(self.tr("Empty Trash")) + aEmptyTrash.triggered.connect(lambda: self.emptyTrash()) ctxMenu.exec_(self.viewport().mapToGlobal(clickPos)) return True # Document Actions # ================ - isFile = tItem.itemType == nwItemType.FILE + isRoot = tItem.isRootType() + isFolder = tItem.isFolderType() + isFile = tItem.isFileType() + if isFile: - ctxMenu.addAction( - self.tr("Open Document"), + aOpenDoc = ctxMenu.addAction(self.tr("Open Document")) + aOpenDoc.triggered.connect( lambda: self.projView.openDocumentRequest.emit(tHandle, nwDocMode.EDIT, -1, "") ) - ctxMenu.addAction( - self.tr("View Document"), + aViewDoc = ctxMenu.addAction(self.tr("View Document")) + aViewDoc.triggered.connect( lambda: self.projView.openDocumentRequest.emit(tHandle, nwDocMode.VIEW, -1, "") ) ctxMenu.addSeparator() @@ -1020,60 +1201,98 @@ class GuiProjectTree(QTreeWidget): # Edit Item Settings # ================== - ctxMenu.addAction( - self.tr("Change Label"), lambda: self.renameTreeItem(tHandle) - ) + aLabel = ctxMenu.addAction(self.tr("Change Label")) + aLabel.triggered.connect(lambda: self.renameTreeItem(tHandle)) if isFile: - ctxMenu.addAction( - self.tr("Toggle Exported"), lambda: self._toggleItemExported(tHandle) - ) + aActive = ctxMenu.addAction(self.tr("Toggle Active")) + aActive.triggered.connect(lambda: self._toggleItemActive(tHandle)) + checkMark = f" ({nwUnicode.U_CHECK})" if tItem.isNovelLike(): - mStatus = ctxMenu.addMenu(self.tr("Change Status")) - for n, (key, entry) in enumerate(self.theProject.statusItems.items()): - aStatus = mStatus.addAction(entry["icon"], entry["name"]) + mStatus = ctxMenu.addMenu(self.tr("Set Status to ...")) + for n, (key, entry) in enumerate(self.theProject.data.itemStatus.items()): + entryName = entry["name"] + (checkMark if tItem.itemStatus == key else "") + aStatus = mStatus.addAction(entry["icon"], entryName) aStatus.triggered.connect( lambda n, key=key: self._changeItemStatus(tHandle, key) ) + mStatus.addSeparator() + aManage1 = mStatus.addAction("Manage Labels ...") + aManage1.triggered.connect( + lambda: self.projView.projectSettingsRequest.emit(GuiProjectSettings.TAB_STATUS) + ) else: - mImport = ctxMenu.addMenu(self.tr("Change Importance")) - for n, (key, entry) in enumerate(self.theProject.importItems.items()): - aImport = mImport.addAction(entry["icon"], entry["name"]) + mImport = ctxMenu.addMenu(self.tr("Set Importance to ...")) + for n, (key, entry) in enumerate(self.theProject.data.itemImport.items()): + entryName = entry["name"] + (checkMark if tItem.itemImport == key else "") + aImport = mImport.addAction(entry["icon"], entryName) aImport.triggered.connect( lambda n, key=key: self._changeItemImport(tHandle, key) ) + mImport.addSeparator() + aManage2 = mImport.addAction("Manage Labels ...") + aManage2.triggered.connect( + lambda: self.projView.projectSettingsRequest.emit(GuiProjectSettings.TAB_IMPORT) + ) - if isFile and tItem.documentAllowed(): - if tItem.itemLayout == nwItemLayout.NOTE: - ctxMenu.addAction( - self.tr("Change to {0}").format( - trConst(nwLabels.LAYOUT_NAME[nwItemLayout.DOCUMENT]) - ), + # Transform Item + # ============== + + if not isRoot: + mTrans = ctxMenu.addMenu(self.tr("Transform")) + + trDoc = trConst(nwLabels.LAYOUT_NAME[nwItemLayout.DOCUMENT]) + trNote = trConst(nwLabels.LAYOUT_NAME[nwItemLayout.NOTE]) + + isDocFile = isFile and tItem.isDocumentLayout() + isNoteFile = isFile and tItem.isNoteLayout() + + if (isNoteFile or isFolder) and tItem.documentAllowed(): + aConvert1 = mTrans.addAction(self.tr("Convert to {0}").format(trDoc)) + aConvert1.triggered.connect( lambda: self._changeItemLayout(tHandle, nwItemLayout.DOCUMENT) ) - else: - ctxMenu.addAction( - self.tr("Change to {0}").format( - trConst(nwLabels.LAYOUT_NAME[nwItemLayout.NOTE]) - ), + + if isDocFile or isFolder: + aConvert2 = mTrans.addAction(self.tr("Convert to {0}").format(trNote)) + aConvert2.triggered.connect( lambda: self._changeItemLayout(tHandle, nwItemLayout.NOTE) ) + if hasChild and isFile: + aMerge1 = mTrans.addAction(self.tr("Merge Child Items into Self")) + aMerge1.triggered.connect(lambda: self._mergeDocuments(tHandle, False)) + aMerge2 = mTrans.addAction(self.tr("Merge Child Items into New")) + aMerge2.triggered.connect(lambda: self._mergeDocuments(tHandle, True)) + + if hasChild and isFolder: + aMerge3 = mTrans.addAction(self.tr("Merge Documents in Folder")) + aMerge3.triggered.connect(lambda: self._mergeDocuments(tHandle, True)) + + if isFile: + aSplit1 = mTrans.addAction(self.tr("Split Document by Headers")) + aSplit1.triggered.connect(lambda: self._splitDocument(tHandle)) + + # Expand/Collapse/Delete + # ====================== + ctxMenu.addSeparator() - # Delete Item - # =========== + if hasChild: + aExpand = ctxMenu.addAction(self.tr("Expand All")) + aExpand.triggered.connect(lambda: self.setExpandedFromHandle(tHandle, True)) + aCollapse = ctxMenu.addAction(self.tr("Collapse All")) + aCollapse.triggered.connect(lambda: self.setExpandedFromHandle(tHandle, False)) - if tItem.itemClass == nwItemClass.TRASH or tItem.itemType == nwItemType.ROOT: - ctxMenu.addAction( - self.tr("Delete Permanently"), lambda: self.deleteItem(tHandle) - ) + if tItem.itemClass == nwItemClass.TRASH or isRoot or (isFolder and not hasChild): + aDelete = ctxMenu.addAction(self.tr("Delete Permanently")) + aDelete.triggered.connect(lambda: self.permanentlyDeleteItem(tHandle)) else: - ctxMenu.addAction( - self.tr("Move to Trash"), lambda: self.deleteItem(tHandle) - ) + aMoveTrash = ctxMenu.addAction(self.tr("Move to Trash")) + aMoveTrash.triggered.connect(lambda: self.moveItemToTrash(tHandle)) + # Show Context Menu ctxMenu.exec_(self.viewport().mapToGlobal(clickPos)) return True @@ -1087,7 +1306,7 @@ class GuiProjectTree(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()) @@ -1099,12 +1318,12 @@ class GuiProjectTree(QTreeWidget): if not isinstance(selItem, QTreeWidgetItem): return - tHandle = selItem.data(self.C_NAME, Qt.UserRole) + tHandle = selItem.data(self.C_DATA, self.D_HANDLE) tItem = self.theProject.tree[tHandle] if tItem is None: return - if tItem.itemType == nwItemType.FILE: + if tItem.isFileType(): self.projView.openDocumentRequest.emit(tHandle, nwDocMode.VIEW, -1, "") return @@ -1133,11 +1352,12 @@ class GuiProjectTree(QTreeWidget): wCount = self._getItemWordCount(sHandle) self.propagateCount(sHandle, 0) - QTreeWidget.dropEvent(self, theEvent) + super().dropEvent(theEvent) self._postItemMove(sHandle, wCount) self._recordLastMove(sItem, pItem, pIndex) - self._alertTreeChange(tHandle=sHandle, flush=True) - sItem.setExpanded(isExpanded) + self._alertTreeChange(sHandle, flush=True) + if sItem is not None: + sItem.setExpanded(isExpanded) return @@ -1157,7 +1377,7 @@ class GuiProjectTree(QTreeWidget): # Update item parent handle in the project, make sure meta data # is updated accordingly, and update word count - pHandle = trItemP.data(self.C_NAME, Qt.UserRole) + pHandle = trItemP.data(self.C_DATA, self.D_HANDLE) nwItemS.setParent(pHandle) trItemP.setExpanded(True) logger.debug("The parent of item '%s' has been changed to '%s'", tHandle, pHandle) @@ -1187,37 +1407,33 @@ class GuiProjectTree(QTreeWidget): tItem = self._getTreeItem(tHandle) if tItem is None: return 0 - return int(tItem.data(self.C_COUNT, Qt.UserRole)) + return int(tItem.data(self.C_DATA, self.D_WORDS)) def _getTreeItem(self, tHandle): """Return the QTreeWidgetItem of a given item handle. """ return self._treeMap.get(tHandle, None) - def _deleteTreeItem(self, tHandle): - """Permanently delete a tree item from the project and the map. - """ - if self.theProject.tree.checkType(tHandle, nwItemType.FILE): - delDoc = NWDoc(self.theProject, tHandle) - if not delDoc.deleteDocument(): - self.mainGui.makeAlert([ - self.tr("Could not delete document file."), delDoc.getError() - ], nwAlert.ERROR) - return False - - self.theProject.index.deleteHandle(tHandle) - del self.theProject.tree[tHandle] - self._treeMap.pop(tHandle, None) - - return True - - def _toggleItemExported(self, tHandle): - """Toggle the exported status of an item. + def _toggleItemActive(self, tHandle): + """Toggle the active status of an item. """ tItem = self.theProject.tree[tHandle] if tItem is not None: - tItem.setExported(not tItem.isExported) + tItem.setActive(not tItem.isActive) self.setTreeItemValues(tItem.itemHandle) + self._alertTreeChange(tHandle, flush=False) + return + + def _recursiveSetExpanded(self, trItem, isExpanded): + """Recursive function to set expanded status starting from (and + not including) a given item. + """ + if isinstance(trItem, QTreeWidgetItem): + chCount = trItem.childCount() + for i in range(chCount): + chItem = trItem.child(i) + chItem.setExpanded(isExpanded) + self._recursiveSetExpanded(chItem, isExpanded) return def _changeItemStatus(self, tHandle, tStatus): @@ -1227,6 +1443,7 @@ class GuiProjectTree(QTreeWidget): if tItem is not None: tItem.setStatus(tStatus) self.setTreeItemValues(tItem.itemHandle) + self._alertTreeChange(tHandle, flush=False) return def _changeItemImport(self, tHandle, tImport): @@ -1236,6 +1453,7 @@ class GuiProjectTree(QTreeWidget): if tItem is not None: tItem.setImport(tImport) self.setTreeItemValues(tItem.itemHandle) + self._alertTreeChange(tHandle, flush=False) return def _changeItemLayout(self, tHandle, itemLayout): @@ -1245,23 +1463,181 @@ class GuiProjectTree(QTreeWidget): if tItem is not None: if itemLayout == nwItemLayout.DOCUMENT and tItem.documentAllowed(): tItem.setLayout(nwItemLayout.DOCUMENT) - self.setTreeItemValues(tItem.itemHandle) + self.setTreeItemValues(tHandle) + self._alertTreeChange(tHandle, flush=False) elif itemLayout == nwItemLayout.NOTE: tItem.setLayout(nwItemLayout.NOTE) - self.setTreeItemValues(tItem.itemHandle) + self.setTreeItemValues(tHandle) + self._alertTreeChange(tHandle, flush=False) return + def _covertFolderToFile(self, tHandle, itemLayout): + """Convert a folder to a note or document. + """ + tItem = self.theProject.tree[tHandle] + if tItem is not None and tItem.isFolderType(): + msgYes = self.mainGui.askQuestion( + self.tr("Convert Folder"), + self.tr( + "Do you want to convert the folder to a {0}? " + "This action cannot be reversed." + ).format(trConst(nwLabels.LAYOUT_NAME[itemLayout])) + ) + if msgYes and itemLayout == nwItemLayout.DOCUMENT and tItem.documentAllowed(): + tItem.setType(nwItemType.FILE) + tItem.setLayout(nwItemLayout.DOCUMENT) + self.setTreeItemValues(tHandle) + self._alertTreeChange(tHandle, flush=False) + elif msgYes and itemLayout == nwItemLayout.NOTE: + tItem.setType(nwItemType.FILE) + tItem.setLayout(nwItemLayout.NOTE) + self.setTreeItemValues(tHandle) + self._alertTreeChange(tHandle, flush=False) + else: + logger.info("Folder conversion cancelled") + return + + def _mergeDocuments(self, tHandle, newFile): + """Merge an item's child documents into a single document. + """ + logger.info("Request to merge items under handle '%s'", tHandle) + itemList = self.getTreeFromHandle(tHandle) + + tItem = self.theProject.tree[tHandle] + if tItem is None: + return False + + if tItem.isRootType(): + logger.error("Cannot merge root item") + return False + + if not newFile: + itemList.remove(tHandle) + + dlgMerge = GuiDocMerge(self.mainGui, tHandle, itemList) + dlgMerge.exec_() + + if dlgMerge.result() == QDialog.Accepted: + + mrgData = dlgMerge.getData() + mrgList = mrgData.get("finalItems", []) + if not mrgList: + self.mainGui.makeAlert([ + self.tr("No documents selected for merging.") + ], nwAlert.INFO) + return False + + # Save the open document first, in case it's part of merge + self.mainGui.saveDocument() + + # Create merge object, and append docs + docMerger = DocMerger(self.theProject) + mLabel = self.tr("Merged") + + if newFile: + docLabel = f"[{mLabel}] {tItem.itemName}" + mHandle = docMerger.newTargetDoc(tHandle, docLabel) + elif tItem.isFileType(): + docMerger.setTargetDoc(tHandle) + mHandle = tHandle + else: + return False + + for sHandle in mrgList: + docMerger.appendText(sHandle, True, mLabel) + + if not docMerger.writeTargetDoc(): + self.mainGui.makeAlert([ + self.tr("Could not write document content."), docMerger.getError() + ], nwAlert.ERROR) + return False + + self.theProject.index.reIndexHandle(mHandle) + if newFile: + self.revealNewTreeItem(mHandle, nHandle=tHandle, wordCount=True) + + self.mainGui.openDocument(mHandle, doScroll=True) + + if mrgData.get("moveToTrash", False): + for sHandle in reversed(mrgData.get("finalItems", [])): + trItem = self._getTreeItem(sHandle) + if isinstance(trItem, QTreeWidgetItem) and trItem.childCount() == 0: + self.moveItemToTrash(sHandle, askFirst=False, flush=False) + + self._alertTreeChange(mHandle, flush=True) + self.projView.wordCountsChanged.emit() + + else: + logger.info("Action cancelled by user") + return False + + return True + + def _splitDocument(self, tHandle): + """Split a document into multiple documents. + """ + logger.info("Request to split items with handle '%s'", tHandle) + + tItem = self.theProject.tree[tHandle] + if tItem is None: + return False + + if not tItem.isFileType(): + logger.error("Only documents can be split") + return False + + dlgSplit = GuiDocSplit(self.mainGui, tHandle) + dlgSplit.exec_() + + if dlgSplit.result() == QDialog.Accepted: + + splitData, splitText = dlgSplit.getData() + + headerList = splitData.get("headerList", []) + intoFolder = splitData.get("intoFolder", False) + docHierarchy = splitData.get("docHierarchy", False) + + docSplit = DocSplitter(self.theProject, tHandle) + if intoFolder: + fHandle = docSplit.newParentFolder(tItem.itemParent, tItem.itemName) + self.revealNewTreeItem(fHandle, nHandle=tHandle) + self._alertTreeChange(fHandle, flush=False) + else: + docSplit.setParentItem(tItem.itemParent) + + docSplit.splitDocument(headerList, splitText) + for writeOk, dHandle, nHandle in docSplit.writeDocuments(docHierarchy): + self.theProject.index.reIndexHandle(dHandle) + self.revealNewTreeItem(dHandle, nHandle=nHandle, wordCount=True) + self._alertTreeChange(dHandle, flush=False) + if not writeOk: + self.mainGui.makeAlert([ + self.tr("Could not write document content."), docSplit.getError() + ], nwAlert.ERROR) + + if splitData.get("moveToTrash", False): + self.moveItemToTrash(tHandle, askFirst=False, flush=True) + + self.saveTreeOrder() + + else: + logger.info("Action cancelled by user") + return False + + return True + def _scanChildren(self, theList, tItem, tIndex): """This is a recursive function returning all items in a tree starting at a given QTreeWidgetItem. """ - tHandle = tItem.data(self.C_NAME, Qt.UserRole) + tHandle = tItem.data(self.C_DATA, self.D_HANDLE) cCount = tItem.childCount() # Update tree-related meta data nwItem = self.theProject.tree[tHandle] - nwItem.setExpanded(tItem.isExpanded() and cCount > 0) - nwItem.setOrder(tIndex) + if nwItem is not None: + nwItem.setExpanded(tItem.isExpanded() and cCount > 0) + nwItem.setOrder(tIndex) theList.append(tHandle) for i in range(cCount): @@ -1275,24 +1651,24 @@ class GuiProjectTree(QTreeWidget): """ tHandle = nwItem.itemHandle pHandle = nwItem.itemParent - newItem = QTreeWidgetItem([""]*4) + newItem = QTreeWidgetItem() newItem.setText(self.C_NAME, "") newItem.setText(self.C_COUNT, "0") - newItem.setText(self.C_EXPORT, "") + newItem.setText(self.C_ACTIVE, "") newItem.setText(self.C_STATUS, "") newItem.setTextAlignment(self.C_NAME, Qt.AlignLeft) newItem.setTextAlignment(self.C_COUNT, Qt.AlignRight) - newItem.setTextAlignment(self.C_EXPORT, Qt.AlignLeft) + newItem.setTextAlignment(self.C_ACTIVE, Qt.AlignLeft) newItem.setTextAlignment(self.C_STATUS, Qt.AlignLeft) - newItem.setData(self.C_NAME, Qt.UserRole, tHandle) - newItem.setData(self.C_COUNT, Qt.UserRole, 0) + newItem.setData(self.C_DATA, self.D_HANDLE, tHandle) + newItem.setData(self.C_DATA, self.D_WORDS, 0) self._treeMap[tHandle] = newItem if pHandle is None: - if nwItem.itemType == nwItemType.ROOT: + if nwItem.isRootType(): newItem.setFlags(newItem.flags() ^ Qt.ItemIsDragEnabled) self.addTopLevelItem(newItem) else: @@ -1333,28 +1709,29 @@ class GuiProjectTree(QTreeWidget): trItem = self._addTreeItem(self.theProject.tree[trashHandle]) if trItem is not None: trItem.setExpanded(True) - self._alertTreeChange(tHandle=trashHandle, flush=True) + self._alertTreeChange(trashHandle, flush=True) return trItem - def _alertTreeChange(self, tHandle=None, flush=True): + def _alertTreeChange(self, tHandle, flush=False): """Update information on tree change state, and emit necessary - signals. + signals. A flush is only needed if an item is moved, created or + deleted. """ self._timeChanged = time() self.theProject.setProjectChanged(True) if flush: self.saveTreeOrder() - tItem = self.theProject.tree[tHandle] - if tItem is None: + if tHandle is None: return - itemType = tItem.itemType - if itemType == nwItemType.ROOT: + if tHandle not in self.theProject.tree: + return + + tItem = self.theProject.tree[tHandle] + if tItem.isRootType(): self.projView.rootFolderChanged.emit(tHandle) - elif itemType == nwItemType.FILE and tItem.isNovelLike(): - self.projView.novelItemChanged.emit(tHandle) self.projView.treeItemChanged.emit(tHandle) diff --git a/novelwriter/gui/statusbar.py b/novelwriter/gui/statusbar.py index a0cf9416..26830e2d 100644 --- a/novelwriter/gui/statusbar.py +++ b/novelwriter/gui/statusbar.py @@ -42,7 +42,7 @@ logger = logging.getLogger(__name__) class GuiMainStatus(QStatusBar): def __init__(self, mainGui): - QStatusBar.__init__(self, mainGui) + super().__init__(parent=mainGui) logger.debug("Initialising GuiMainStatus ...") @@ -66,7 +66,6 @@ class GuiMainStatus(QStatusBar): # The Spell Checker Language self.langIcon = QLabel("") self.langText = QLabel(self.tr("None")) - self.langIcon.setPixmap(self.mainTheme.getPixmap("status_lang", (iPx, iPx))) self.langIcon.setContentsMargins(0, 0, 0, 0) self.langText.setContentsMargins(0, 0, xM, 0) self.addPermanentWidget(self.langIcon) @@ -91,7 +90,6 @@ class GuiMainStatus(QStatusBar): # The Project and Session Stats self.statsIcon = QLabel() self.statsText = QLabel("") - self.statsIcon.setPixmap(self.mainTheme.getPixmap("status_stats", (iPx, iPx))) self.statsIcon.setContentsMargins(0, 0, 0, 0) self.statsText.setContentsMargins(0, 0, xM, 0) self.addPermanentWidget(self.statsIcon) @@ -99,12 +97,8 @@ class GuiMainStatus(QStatusBar): # The Session Clock # Set the mimimum width so the label doesn't rescale every second - self.timePixmap = self.mainTheme.getPixmap("status_time", (iPx, iPx)) - self.idlePixmap = self.mainTheme.getPixmap("status_idle", (iPx, iPx)) - self.timeIcon = QLabel() self.timeText = QLabel("") - self.timeIcon.setPixmap(self.timePixmap) self.timeText.setToolTip(self.tr("Session Time")) self.timeText.setMinimumWidth(self.mainTheme.getTextWidth("00:00:00:")) self.timeIcon.setContentsMargins(0, 0, 0, 0) @@ -117,6 +111,7 @@ class GuiMainStatus(QStatusBar): logger.debug("GuiMainStatus initialisation complete") + self.updateTheme() self.clearStatus() return @@ -132,6 +127,21 @@ class GuiMainStatus(QStatusBar): self.updateTime() return True + def updateTheme(self): + """Update theme elements. + """ + iPx = self.mainTheme.baseIconSize + + self.langIcon.setPixmap(self.mainTheme.getPixmap("status_lang", (iPx, iPx))) + self.statsIcon.setPixmap(self.mainTheme.getPixmap("status_stats", (iPx, iPx))) + + self.timePixmap = self.mainTheme.getPixmap("status_time", (iPx, iPx)) + self.idlePixmap = self.mainTheme.getPixmap("status_idle", (iPx, iPx)) + + self.timeIcon.setPixmap(self.timePixmap) + + return + ## # Setters ## diff --git a/novelwriter/gui/theme.py b/novelwriter/gui/theme.py index 6c4ff50e..cf10f78a 100644 --- a/novelwriter/gui/theme.py +++ b/novelwriter/gui/theme.py @@ -24,7 +24,6 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import os import logging import novelwriter @@ -38,7 +37,7 @@ from PyQt5.QtGui import ( from novelwriter.enum import nwItemLayout, nwItemType from novelwriter.error import logException -from novelwriter.common import NWConfigParser, readTextFile +from novelwriter.common import NWConfigParser, minmax from novelwriter.constants import nwLabels logger = logging.getLogger(__name__) @@ -67,6 +66,7 @@ class GuiTheme: self.themeUrl = "" self.themeLicense = "" self.themeLicenseUrl = "" + self.themeIcons = "" # GUI self.statNone = [120, 120, 120] @@ -104,43 +104,41 @@ class GuiTheme: self.colRepTag = [0, 0, 0] self.colMod = [0, 0, 0] - # Changeable Settings - self.guiTheme = None - self.guiSyntax = None - self.syntaxFile = None - self.cssFile = None - self.guiFontDB = QFontDatabase() - # Class Setup # =========== + # Init GUI Font + self.guiFontDB = QFontDatabase() + self._setGuiFont() + + # Load Themes self._guiPalette = QPalette() self._themeList = [] self._syntaxList = [] self._availThemes = {} self._availSyntax = {} - self._listConf(self._availSyntax, os.path.join(self.mainConf.dataPath, "syntax")) - self._listConf(self._availSyntax, os.path.join(self.mainConf.assetPath, "syntax")) - self._listConf(self._availThemes, os.path.join(self.mainConf.dataPath, "themes")) - self._listConf(self._availThemes, os.path.join(self.mainConf.assetPath, "themes")) + self._listConf(self._availSyntax, self.mainConf.assetPath("syntax")) + self._listConf(self._availThemes, self.mainConf.assetPath("themes")) + self._listConf(self._availSyntax, self.mainConf.dataPath("syntax")) + self._listConf(self._availThemes, self.mainConf.dataPath("themes")) - self.updateFont() - self.updateTheme() - self.iconCache.updateTheme() + self.loadTheme() + self.loadSyntax() # Icon Functions self.getIcon = self.iconCache.getIcon self.getPixmap = self.iconCache.getPixmap self.getItemIcon = self.iconCache.getItemIcon self.loadDecoration = self.iconCache.loadDecoration + self.getHeaderDecoration = self.iconCache.getHeaderDecoration # Extract Other Info self.guiDPI = qApp.primaryScreen().logicalDotsPerInchX() self.guiScale = qApp.primaryScreen().logicalDotsPerInchX()/96.0 self.mainConf.guiScale = self.guiScale - logger.verbose("GUI DPI: %.1f", self.guiDPI) - logger.verbose("GUI Scale: %.2f", self.guiScale) + logger.debug("GUI DPI: %.1f", self.guiDPI) + logger.debug("GUI Scale: %.2f", self.guiScale) # Fonts self.guiFont = qApp.font() @@ -157,12 +155,12 @@ class GuiTheme: self.guiFontFixed.setPointSizeF(0.95*self.fontPointSize) self.guiFontFixed.setFamily(QFontDatabase.systemFont(QFontDatabase.FixedFont).family()) - logger.verbose("GUI Font Family: %s", self.guiFont.family()) - logger.verbose("GUI Font Point Size: %.2f", self.fontPointSize) - logger.verbose("GUI Font Pixel Size: %d", self.fontPixelSize) - logger.verbose("GUI Base Icon Size: %d", self.baseIconSize) - logger.verbose("Text 'N' Height: %d", self.textNHeight) - logger.verbose("Text 'N' Width: %d", self.textNWidth) + logger.debug("GUI Font Family: %s", self.guiFont.family()) + logger.debug("GUI Font Point Size: %.2f", self.fontPointSize) + logger.debug("GUI Font Pixel Size: %d", self.fontPixelSize) + logger.debug("GUI Base Icon Size: %d", self.baseIconSize) + logger.debug("Text 'N' Height: %d", self.textNHeight) + logger.debug("Text 'N' Width: %d", self.textNWidth) return @@ -180,10 +178,192 @@ class GuiTheme: return int(ceil(qMetrics.boundingRect(theText).width())) ## - # Actions + # Theme Methods ## - def updateFont(self): + def loadTheme(self): + """Load the currently specified GUI theme. + """ + guiTheme = self.mainConf.guiTheme + if guiTheme not in self._availThemes: + logger.error("Could not find GUI theme '%s'", guiTheme) + guiTheme = "default" + self.mainConf.guiTheme = guiTheme + + themeFile = self._availThemes.get(guiTheme, None) + if themeFile is None: + logger.error("Could not load GUI theme") + return False + + # Config File + logger.info("Loading GUI theme '%s'", guiTheme) + confParser = NWConfigParser() + try: + with open(themeFile, mode="r", encoding="utf-8") as inFile: + confParser.read_file(inFile) + except Exception: + logger.error("Could not load theme settings from: %s", themeFile) + logException() + return False + + # Main + cnfSec = "Main" + if confParser.has_section(cnfSec): + self.themeName = confParser.rdStr(cnfSec, "name", "") + self.themeDescription = confParser.rdStr(cnfSec, "description", "N/A") + self.themeAuthor = confParser.rdStr(cnfSec, "author", "N/A") + self.themeCredit = confParser.rdStr(cnfSec, "credit", "N/A") + self.themeUrl = confParser.rdStr(cnfSec, "url", "") + self.themeLicense = confParser.rdStr(cnfSec, "license", "N/A") + self.themeLicenseUrl = confParser.rdStr(cnfSec, "licenseurl", "") + self.themeIcons = confParser.rdStr(cnfSec, "icontheme", "") + + # Palette + cnfSec = "Palette" + if confParser.has_section(cnfSec): + self._setPalette(confParser, cnfSec, "window", QPalette.Window) + self._setPalette(confParser, cnfSec, "windowtext", QPalette.WindowText) + self._setPalette(confParser, cnfSec, "base", QPalette.Base) + self._setPalette(confParser, cnfSec, "alternatebase", QPalette.AlternateBase) + self._setPalette(confParser, cnfSec, "text", QPalette.Text) + self._setPalette(confParser, cnfSec, "tooltipbase", QPalette.ToolTipBase) + self._setPalette(confParser, cnfSec, "tooltiptext", QPalette.ToolTipText) + self._setPalette(confParser, cnfSec, "button", QPalette.Button) + self._setPalette(confParser, cnfSec, "buttontext", QPalette.ButtonText) + self._setPalette(confParser, cnfSec, "brighttext", QPalette.BrightText) + self._setPalette(confParser, cnfSec, "highlight", QPalette.Highlight) + self._setPalette(confParser, cnfSec, "highlightedtext", QPalette.HighlightedText) + self._setPalette(confParser, cnfSec, "link", QPalette.Link) + self._setPalette(confParser, cnfSec, "linkvisited", QPalette.LinkVisited) + else: + self._guiPalette = qApp.style().standardPalette() + + # GUI + cnfSec = "GUI" + if confParser.has_section(cnfSec): + self.statNone = self._parseColour(confParser, cnfSec, "statusnone") + self.statUnsaved = self._parseColour(confParser, cnfSec, "statusunsaved") + self.statSaved = self._parseColour(confParser, cnfSec, "statussaved") + + # Icons + self.iconCache.loadTheme(self.themeIcons) + + # Update Dependant Colours + backCol = self._guiPalette.window().color() + textCol = self._guiPalette.windowText().color() + + backLCol = backCol.lightnessF() + textLCol = textCol.lightnessF() + + if backLCol > textLCol: + helpLCol = textLCol + 0.65*(backLCol - textLCol) + else: + helpLCol = backLCol + 0.65*(textLCol - backLCol) + + self.helpText = [int(255*helpLCol)]*3 + + # Apply Styles + qApp.setPalette(self._guiPalette) + + return True + + def loadSyntax(self): + """Load the currently specified syntax highlighter theme. + """ + guiSyntax = self.mainConf.guiSyntax + if guiSyntax not in self._availSyntax: + logger.error("Could not find syntax theme '%s'", guiSyntax) + guiSyntax = "default_light" + self.mainConf.guiSyntax = guiSyntax + + syntaxFile = self._availSyntax.get(guiSyntax, None) + if syntaxFile is None: + logger.error("Could not load syntax theme") + return False + + logger.info("Loading syntax theme '%s'", guiSyntax) + + confParser = NWConfigParser() + try: + with open(syntaxFile, mode="r", encoding="utf-8") as inFile: + confParser.read_file(inFile) + except Exception: + logger.error("Could not load syntax colours from: %s", syntaxFile) + logException() + return False + + # Main + cnfSec = "Main" + if confParser.has_section(cnfSec): + self.syntaxName = confParser.rdStr(cnfSec, "name", "") + self.syntaxDescription = confParser.rdStr(cnfSec, "description", "N/A") + self.syntaxAuthor = confParser.rdStr(cnfSec, "author", "N/A") + self.syntaxCredit = confParser.rdStr(cnfSec, "credit", "N/A") + self.syntaxUrl = confParser.rdStr(cnfSec, "url", "") + self.syntaxLicense = confParser.rdStr(cnfSec, "license", "N/A") + self.syntaxLicenseUrl = confParser.rdStr(cnfSec, "licenseurl", "") + + # Syntax + cnfSec = "Syntax" + if confParser.has_section(cnfSec): + self.colBack = self._parseColour(confParser, cnfSec, "background") + self.colText = self._parseColour(confParser, cnfSec, "text") + self.colLink = self._parseColour(confParser, cnfSec, "link") + self.colHead = self._parseColour(confParser, cnfSec, "headertext") + self.colHeadH = self._parseColour(confParser, cnfSec, "headertag") + self.colEmph = self._parseColour(confParser, cnfSec, "emphasis") + self.colDialN = self._parseColour(confParser, cnfSec, "straightquotes") + self.colDialD = self._parseColour(confParser, cnfSec, "doublequotes") + self.colDialS = self._parseColour(confParser, cnfSec, "singlequotes") + self.colHidden = self._parseColour(confParser, cnfSec, "hidden") + self.colKey = self._parseColour(confParser, cnfSec, "keyword") + self.colVal = self._parseColour(confParser, cnfSec, "value") + self.colSpell = self._parseColour(confParser, cnfSec, "spellcheckline") + self.colError = self._parseColour(confParser, cnfSec, "errorline") + self.colRepTag = self._parseColour(confParser, cnfSec, "replacetag") + self.colMod = self._parseColour(confParser, cnfSec, "modifier") + + return True + + def listThemes(self): + """Scan the GUI themes folder and list all themes. + """ + if self._themeList: + return self._themeList + + confParser = NWConfigParser() + for themeKey, themePath in self._availThemes.items(): + logger.debug("Checking theme config for '%s'", themeKey) + themeName = _loadInternalName(confParser, themePath) + if themeName: + self._themeList.append((themeKey, themeName)) + + self._themeList = sorted(self._themeList, key=lambda x: x[1]) + + return self._themeList + + def listSyntax(self): + """Scan the syntax themes folder and list all themes. + """ + if self._syntaxList: + return self._syntaxList + + confParser = NWConfigParser() + for syntaxKey, syntaxPath in self._availSyntax.items(): + logger.debug("Checking theme syntax for '%s'", syntaxKey) + syntaxName = _loadInternalName(confParser, syntaxPath) + if syntaxName: + self._syntaxList.append((syntaxKey, syntaxName)) + + self._syntaxList = sorted(self._syntaxList, key=lambda x: x[1]) + + return self._syntaxList + + ## + # Internal Functions + ## + + def _setGuiFont(self): """Update the GUI's font style from settings. """ theFont = QFont() @@ -204,233 +384,42 @@ class GuiTheme: return - def updateTheme(self): - """Update the GUI theme from theme files. - """ - self.guiTheme = self.mainConf.guiTheme - self.guiSyntax = self.mainConf.guiSyntax - - self.themeFile = self._availThemes.get(self.guiTheme, None) - if self.themeFile is None: - logger.error("Could not find GUI theme '%s'", self.guiTheme) - else: - self.cssFile = self.themeFile[:-5]+".css" - self.loadTheme() - - self.syntaxFile = self._availSyntax.get(self.guiSyntax, None) - if self.syntaxFile is None: - logger.error("Could not find syntax theme '%s'", self.guiSyntax) - else: - self.loadSyntax() - - # Update dependant colours - backCol = qApp.palette().window().color() - textCol = qApp.palette().windowText().color() - - backLCol = backCol.lightnessF() - textLCol = textCol.lightnessF() - - if backLCol > textLCol: - helpLCol = textLCol + 0.65*(backLCol - textLCol) - else: - helpLCol = backLCol + 0.65*(textLCol - backLCol) - - self.helpText = [int(255*helpLCol)]*3 - - return True - - def loadTheme(self): - """Load the currently specified GUI theme. - """ - logger.info("Loading GUI theme '%s'", self.guiTheme) - - # Config File - confParser = NWConfigParser() - try: - with open(self.themeFile, mode="r", encoding="utf-8") as inFile: - confParser.read_file(inFile) - except Exception: - logger.error("Could not load theme settings from: %s", self.themeFile) - logException() - return False - - # Main - cnfSec = "Main" - if confParser.has_section(cnfSec): - self.themeName = confParser.rdStr(cnfSec, "name", "") - self.themeDescription = confParser.rdStr(cnfSec, "description", "N/A") - self.themeAuthor = confParser.rdStr(cnfSec, "author", "N/A") - self.themeCredit = confParser.rdStr(cnfSec, "credit", "N/A") - self.themeUrl = confParser.rdStr(cnfSec, "url", "") - self.themeLicense = confParser.rdStr(cnfSec, "license", "N/A") - self.themeLicenseUrl = confParser.rdStr(cnfSec, "licenseurl", "") - - # Palette - cnfSec = "Palette" - if confParser.has_section(cnfSec): - self._setPalette(confParser, cnfSec, "window", QPalette.Window) - self._setPalette(confParser, cnfSec, "windowtext", QPalette.WindowText) - self._setPalette(confParser, cnfSec, "base", QPalette.Base) - self._setPalette(confParser, cnfSec, "alternatebase", QPalette.AlternateBase) - self._setPalette(confParser, cnfSec, "text", QPalette.Text) - self._setPalette(confParser, cnfSec, "tooltipbase", QPalette.ToolTipBase) - self._setPalette(confParser, cnfSec, "tooltiptext", QPalette.ToolTipText) - self._setPalette(confParser, cnfSec, "button", QPalette.Button) - self._setPalette(confParser, cnfSec, "buttontext", QPalette.ButtonText) - self._setPalette(confParser, cnfSec, "brighttext", QPalette.BrightText) - self._setPalette(confParser, cnfSec, "highlight", QPalette.Highlight) - self._setPalette(confParser, cnfSec, "highlightedtext", QPalette.HighlightedText) - self._setPalette(confParser, cnfSec, "link", QPalette.Link) - self._setPalette(confParser, cnfSec, "linkvisited", QPalette.LinkVisited) - - # GUI - cnfSec = "GUI" - if confParser.has_section(cnfSec): - self.statNone = self._loadColour(confParser, cnfSec, "statusnone") - self.statUnsaved = self._loadColour(confParser, cnfSec, "statusunsaved") - self.statSaved = self._loadColour(confParser, cnfSec, "statussaved") - - # CSS File - cssData = readTextFile(self.cssFile) - if cssData: - qApp.setStyleSheet(cssData) - - # Apply Styles - qApp.setPalette(self._guiPalette) - - return True - - def loadSyntax(self): - """Load the currently specified syntax highlighter theme. - """ - logger.info("Loading syntax theme '%s'", self.guiSyntax) - - confParser = NWConfigParser() - try: - with open(self.syntaxFile, mode="r", encoding="utf-8") as inFile: - confParser.read_file(inFile) - except Exception: - logger.error("Could not load syntax colours from: %s", self.syntaxFile) - logException() - return False - - # Main - cnfSec = "Main" - if confParser.has_section(cnfSec): - self.syntaxName = confParser.rdStr(cnfSec, "name", "") - self.syntaxDescription = confParser.rdStr(cnfSec, "description", "") - self.syntaxAuthor = confParser.rdStr(cnfSec, "author", "") - self.syntaxCredit = confParser.rdStr(cnfSec, "credit", "") - self.syntaxUrl = confParser.rdStr(cnfSec, "url", "") - self.syntaxLicense = confParser.rdStr(cnfSec, "license", "") - self.syntaxLicenseUrl = confParser.rdStr(cnfSec, "licenseurl", "") - - # Syntax - cnfSec = "Syntax" - if confParser.has_section(cnfSec): - self.colBack = self._loadColour(confParser, cnfSec, "background") - self.colText = self._loadColour(confParser, cnfSec, "text") - self.colLink = self._loadColour(confParser, cnfSec, "link") - self.colHead = self._loadColour(confParser, cnfSec, "headertext") - self.colHeadH = self._loadColour(confParser, cnfSec, "headertag") - self.colEmph = self._loadColour(confParser, cnfSec, "emphasis") - self.colDialN = self._loadColour(confParser, cnfSec, "straightquotes") - self.colDialD = self._loadColour(confParser, cnfSec, "doublequotes") - self.colDialS = self._loadColour(confParser, cnfSec, "singlequotes") - self.colHidden = self._loadColour(confParser, cnfSec, "hidden") - self.colKey = self._loadColour(confParser, cnfSec, "keyword") - self.colVal = self._loadColour(confParser, cnfSec, "value") - self.colSpell = self._loadColour(confParser, cnfSec, "spellcheckline") - self.colError = self._loadColour(confParser, cnfSec, "errorline") - self.colRepTag = self._loadColour(confParser, cnfSec, "replacetag") - self.colMod = self._loadColour(confParser, cnfSec, "modifier") - - return True - - def listThemes(self): - """Scan the GUI themes folder and list all themes. - """ - if self._themeList: - return self._themeList - - confParser = NWConfigParser() - for themeKey, themePath in self._availThemes.items(): - logger.verbose("Checking theme config for '%s'", themeKey) - themeName = _loadInternalName(confParser, themePath) - if themeName: - self._themeList.append((themeKey, themeName)) - - self._themeList = sorted(self._themeList, key=lambda x: x[1]) - - return self._themeList - - def listSyntax(self): - """Scan the syntax themes folder and list all themes. - """ - if self._syntaxList: - return self._syntaxList - - confParser = NWConfigParser() - for syntaxKey, syntaxPath in self._availSyntax.items(): - logger.verbose("Checking theme syntax for '%s'", syntaxKey) - syntaxName = _loadInternalName(confParser, syntaxPath) - if syntaxName: - self._syntaxList.append((syntaxKey, syntaxName)) - - self._syntaxList = sorted(self._syntaxList, key=lambda x: x[1]) - - return self._syntaxList - - ## - # Internal Functions - ## - def _listConf(self, targetDict, checkDir): - """Scan for syntax and gui themes and populate the dictionary. + """Scan for theme config files and populate the dictionary. """ - if not os.path.isdir(checkDir): - return + if not checkDir.is_dir(): + return False - for checkFile in os.listdir(checkDir): - confPath = os.path.join(checkDir, checkFile) - if os.path.isfile(confPath) and confPath.endswith(".conf"): - targetDict[checkFile[:-5]] = confPath + for checkFile in checkDir.iterdir(): + if checkFile.is_file() and checkFile.name.endswith(".conf"): + targetDict[checkFile.name[:-5]] = checkFile - return + return True - def _loadColour(self, confParser, cnfSec, cnfName): - """Load a colour value from a config string. + def _parseColour(self, confParser, cnfSec, cnfName): + """Parse a colour value from a config string. """ if confParser.has_option(cnfSec, cnfName): - inData = confParser.get(cnfSec, cnfName).split(",") - outData = [] + values = confParser.get(cnfSec, cnfName).split(",") + result = [] try: - outData.append(int(inData[0])) - outData.append(int(inData[1])) - outData.append(int(inData[2])) + result.append(minmax(int(values[0]), 0, 255)) + result.append(minmax(int(values[1]), 0, 255)) + result.append(minmax(int(values[2]), 0, 255)) except Exception: logger.error("Could not load theme colours for '%s' from config file", cnfName) - outData = [0, 0, 0] + result = [0, 0, 0] else: logger.warning("Could not find theme colours for '%s' in config file", cnfName) - outData = [0, 0, 0] - return outData + result = [0, 0, 0] + return result def _setPalette(self, confParser, cnfSec, cnfName, paletteVal): """Set a palette colour value from a config string. """ - readCol = [] - if confParser.has_option(cnfSec, cnfName): - inData = confParser.get(cnfSec, cnfName).split(",") - try: - readCol.append(int(inData[0])) - readCol.append(int(inData[1])) - readCol.append(int(inData[2])) - except Exception: - logger.error("Could not load theme colours for '%s' from config file", cnfName) - return - if len(readCol) == 3: - self._guiPalette.setColor(paletteVal, QColor(*readCol)) + self._guiPalette.setColor( + paletteVal, QColor(*self._parseColour(confParser, cnfSec, cnfName)) + ) return # End Class GuiTheme @@ -457,24 +446,24 @@ class GuiIcons: ICON_KEYS = { # Project and GUI icons "novelwriter", "cls_archive", "cls_character", "cls_custom", "cls_entity", "cls_none", - "cls_novel", "cls_object", "cls_plot", "cls_timeline", "cls_trash", "cls_world", "doc_h0", - "doc_h1", "doc_h2", "doc_h3", "doc_h4", "proj_chapter", "proj_details", "proj_document", - "proj_folder", "proj_note", "proj_nwx", "proj_scene", "proj_stats", "proj_title", - "search_cancel", "search_case", "search_loop", "search_preserve", "search_project", - "search_regex", "search_word", "status_idle", "status_lang", "status_lines", - "status_stats", "status_time", "view_build", "view_editor", "view_novel", "view_outline", + "cls_novel", "cls_object", "cls_plot", "cls_timeline", "cls_trash", "cls_world", + "proj_chapter", "proj_details", "proj_document", "proj_folder", "proj_note", "proj_nwx", + "proj_section", "proj_scene", "proj_stats", "proj_title", "search_cancel", "search_case", + "search_loop", "search_preserve", "search_project", "search_regex", "search_word", + "status_idle", "status_lang", "status_lines", "status_stats", "status_time", "view_build", + "view_editor", "view_novel", "view_outline", # General Button Icons - "add", "backward", "check", "clear", "close", "cross", "delete", "done", "down", "edit", - "forward", "hash", "maximise", "menu", "minimise", "reference", "refresh", "remove", - "save", "search_replace", "search", "settings", "up", + "add", "backward", "bookmark", "checked", "close", "cross", "down", "edit", "forward", + "maximise", "menu", "minimise", "noncheckable", "reference", "refresh", "remove", + "search_replace", "search", "settings", "unchecked", "up", # Switches "sticky-on", "sticky-off", "bullet-on", "bullet-off", # Decorations - "deco_doc_h0", "deco_doc_h1", "deco_doc_h2", "deco_doc_h3", "deco_doc_h4", + "deco_doc_h0", "deco_doc_h1", "deco_doc_h2", "deco_doc_h3", "deco_doc_h4", "deco_doc_more", } IMAGE_MAP = { @@ -489,12 +478,11 @@ class GuiIcons: # Storage self._qIcons = {} self._themeMap = {} - self._themeList = [] + self._headerDec = [] self._confName = "icons.conf" # Icon Theme Path - self._iconPath = os.path.join(self.mainConf.assetPath, "icons") - self._themePath = os.path.join(self._iconPath, "system") + self._iconPath = self.mainConf.assetPath("icons") # Icon Theme Meta self.themeName = "" @@ -511,20 +499,19 @@ class GuiIcons: # Actions ## - def updateTheme(self): + def loadTheme(self, iconTheme): """Update the theme map. This is more of an init, since many of the GUI icons cannot really be replaced without writing specific update functions for the classes where they're used. """ self._themeMap = {} - themePath = self._getThemePath() - if themePath is None: - logger.warning("No icons loaded") + themePath = self._iconPath / iconTheme + if not themePath.is_dir(): + logger.warning("No icons loaded for '%s'", iconTheme) return False - self._themePath = themePath - themeConf = os.path.join(themePath, self._confName) - logger.info("Loading icon theme '%s'", self.mainConf.guiIcons) + themeConf = themePath / self._confName + logger.info("Loading icon theme '%s'", iconTheme) # Config File confParser = NWConfigParser() @@ -554,10 +541,10 @@ class GuiIcons: if iconName not in self.ICON_KEYS: logger.error("Unknown icon name '%s' in config file", iconName) else: - iconPath = os.path.join(self._themePath, iconFile) - if os.path.isfile(iconPath): + iconPath = themePath / iconFile + if iconPath.is_file(): self._themeMap[iconName] = iconPath - logger.verbose("Icon slot '%s' using file '%s'", iconName, iconFile) + logger.debug("Icon slot '%s' using file '%s'", iconName, iconFile) else: logger.error("Icon file '%s' not in theme folder", iconFile) @@ -570,6 +557,14 @@ class GuiIcons: if iconKey not in self._themeMap: logger.error("No icon file specified for '%s'", iconKey) + # Refresh icons + for iconKey in self._qIcons: + logger.debug("Reloading icon: '%s'", iconKey) + qIcon = self._loadIcon(iconKey) + self._qIcons[iconKey] = qIcon + + self._headerDec = [] + return True ## @@ -583,18 +578,16 @@ class GuiIcons: if decoKey in self._themeMap: imgPath = self._themeMap[decoKey] elif decoKey in self.IMAGE_MAP: - imgPath = os.path.join( - self.mainConf.assetPath, "images", self.IMAGE_MAP[decoKey] - ) + imgPath = self.mainConf.assetPath("images") / self.IMAGE_MAP[decoKey] else: logger.error("Decoration with name '%s' does not exist", decoKey) return QPixmap() - if not os.path.isfile(imgPath): - logger.error("Asset '%s' not found", self.IMAGE_MAP[decoKey]) + if not imgPath.is_file(): + logger.error("Asset not found: %s", imgPath) return QPixmap() - theDeco = QPixmap(imgPath) + theDeco = QPixmap(str(imgPath)) if pxW is not None and pxH is not None: return theDeco.scaled(pxW, pxH, Qt.IgnoreAspectRatio, Qt.SmoothTransformation) elif pxW is None and pxH is not None: @@ -604,7 +597,7 @@ class GuiIcons: return theDeco - def getIcon(self, iconKey, iconSize=None): + def getIcon(self, iconKey): """Return an icon from the icon buffer. If it doesn't exist, return, load it, and if it still doesn't exist, return an empty icon. @@ -641,6 +634,8 @@ class GuiIcons: iconName = "proj_chapter" elif hLevel == "H3": iconName = "proj_scene" + elif hLevel == "H4": + iconName = "proj_section" elif tLayout == nwItemLayout.NOTE: iconName = "proj_note" if iconName is None: @@ -648,49 +643,24 @@ class GuiIcons: return self.getIcon(iconName) - def listThemes(self): - """Scan the icons themes folder and list all themes. + def getHeaderDecoration(self, hLevel): + """Get the decoration for a specific header level. """ - if self._themeList: - return self._themeList - - confParser = NWConfigParser() - for themeDir in os.listdir(self._iconPath): - themePath = os.path.join(self._iconPath, themeDir) - if not os.path.isdir(themePath): - continue - - logger.verbose("Checking icon theme config for '%s'", themeDir) - themeConf = os.path.join(themePath, self._confName) - themeName = _loadInternalName(confParser, themeConf) - if themeName: - self._themeList.append((themeDir, themeName)) - - self._themeList = sorted(self._themeList, key=lambda x: x[1]) - - return self._themeList + if not self._headerDec: + iPx = self.mainTheme.baseIconSize + self._headerDec = [ + self.loadDecoration("deco_doc_h0", pxH=iPx), + self.loadDecoration("deco_doc_h1", pxH=iPx), + self.loadDecoration("deco_doc_h2", pxH=iPx), + self.loadDecoration("deco_doc_h3", pxH=iPx), + self.loadDecoration("deco_doc_h4", pxH=iPx), + ] + return self._headerDec[minmax(hLevel, 0, 4)] ## # Internal Functions ## - def _getThemePath(self): - """Get a valid theme path. Returns None if it fails. - """ - themePath = os.path.join(self.mainConf.assetPath, "icons", self.mainConf.guiIcons) - if not os.path.isdir(themePath): - logger.warning( - "Icon theme '%s' not found, resetting to default", self.mainConf.guiIcons - ) - self.mainConf.setDefaultIconTheme() - - themePath = os.path.join(self.mainConf.assetPath, "icons", self.mainConf.guiIcons) - if not os.path.isdir(themePath): - logger.error("Default icon theme not found") - return None - - return themePath - def _loadIcon(self, iconKey): """Load an icon from the assets themes folder. Is guaranteed to return a QIcon. @@ -701,15 +671,14 @@ class GuiIcons: # If we just want the app icons, return right away if iconKey == "novelwriter": - return QIcon(os.path.join(self._iconPath, "novelwriter.svg")) + return QIcon(str(self._iconPath / "novelwriter.svg")) elif iconKey == "proj_nwx": - return QIcon(os.path.join(self._iconPath, "x-novelwriter-project.svg")) + return QIcon(str(self._iconPath / "x-novelwriter-project.svg")) # Otherwise, we load from the theme folder if iconKey in self._themeMap: - relPath = os.path.relpath(self._themeMap[iconKey], self._iconPath) - logger.verbose("Loading: %s", relPath) - return QIcon(self._themeMap[iconKey]) + logger.debug("Loading: %s", self._themeMap[iconKey].name) + return QIcon(str(self._themeMap[iconKey])) # If we didn't find one, give up and return an empty icon logger.warning("Did not load an icon for '%s'", iconKey) diff --git a/novelwriter/gui/viewsbar.py b/novelwriter/gui/viewsbar.py index 031f4316..da85275c 100644 --- a/novelwriter/gui/viewsbar.py +++ b/novelwriter/gui/viewsbar.py @@ -41,7 +41,7 @@ class GuiViewsBar(QToolBar): viewChangeRequested = pyqtSignal(nwView) def __init__(self, mainGui): - QToolBar.__init__(self, mainGui) + super().__init__(parent=mainGui) logger.debug("Initialising GuiViewsBar ...") @@ -61,63 +61,52 @@ class GuiViewsBar(QToolBar): self.setIconSize(QSize(iPx, iPx)) self.setMaximumWidth(mPx) self.setContentsMargins(0, 0, 0, 0) - self.setStyleSheet("QToolBar {border: 0px;}") stretch = QWidget(self) stretch.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) # Actions - self.aProject = QAction(self.tr("Project")) + self.aProject = QAction(self.tr("Project"), self) self.aProject.setFont(lblFont) - self.aProject.setToolTip(self.tr("Show project tree and editor")) - self.aProject.setIcon(self.mainTheme.getIcon("view_editor")) + self.aProject.setToolTip(self.tr("Project Tree View")) self.aProject.triggered.connect(lambda: self.viewChangeRequested.emit(nwView.PROJECT)) - self.aNovel = QAction(self.tr("Novel")) + self.aNovel = QAction(self.tr("Novel"), self) self.aNovel.setFont(lblFont) - self.aNovel.setToolTip(self.tr("Show novel tree and editor")) - self.aNovel.setIcon(self.mainTheme.getIcon("view_novel")) + self.aNovel.setToolTip(self.tr("Novel Tree View")) self.aNovel.triggered.connect(lambda: self.viewChangeRequested.emit(nwView.NOVEL)) - self.aOutline = QAction(self.tr("Outline")) + self.aOutline = QAction(self.tr("Outline"), self) self.aOutline.setFont(lblFont) - self.aOutline.setToolTip(self.tr("Show novel outline")) - self.aOutline.setIcon(self.mainTheme.getIcon("view_outline")) + self.aOutline.setToolTip(self.tr("Novel Outline View")) self.aOutline.triggered.connect(lambda: self.viewChangeRequested.emit(nwView.OUTLINE)) - self.aBuild = QAction(self.tr("Build")) + self.aBuild = QAction(self.tr("Build"), self) self.aBuild.setFont(lblFont) - self.aBuild.setToolTip(self.tr("Build novel project")) - self.aBuild.setIcon(self.mainTheme.getIcon("view_build")) + self.aBuild.setToolTip(self.tr("Build Novel Project")) self.aBuild.triggered.connect(lambda: self.mainGui.showBuildProjectDialog()) - self.aDetails = QAction(self.tr("Details")) + self.aDetails = QAction(self.tr("Details"), self) self.aDetails.setFont(lblFont) - self.aDetails.setToolTip(self.tr("Show project details")) - self.aDetails.setIcon(self.mainTheme.getIcon("proj_details")) + self.aDetails.setToolTip(self.tr("Project Details")) self.aDetails.triggered.connect(lambda: self.mainGui.showProjectDetailsDialog()) - self.aStats = QAction(self.tr("Stats")) + self.aStats = QAction(self.tr("Stats"), self) self.aStats.setFont(lblFont) - self.aStats.setToolTip(self.tr("Show project statistics")) - self.aStats.setIcon(self.mainTheme.getIcon("proj_stats")) + self.aStats.setToolTip(self.tr("Writing Statistics")) self.aStats.triggered.connect(lambda: self.mainGui.showWritingStatsDialog()) # Settings Menu self.mSettings = QMenu() - self.aPrjSettings = QAction(self.tr("Project Settings")) - self.aPrjSettings.triggered.connect(lambda: self.mainGui.showProjectSettingsDialog()) - self.mSettings.addAction(self.aPrjSettings) - - self.aPreferences = QAction(self.tr("Preferences")) - self.aPreferences.triggered.connect(lambda: self.mainGui.showPreferencesDialog()) - self.mSettings.addAction(self.aPreferences) + self.mSettings.addAction(self.mainGui.mainMenu.aEditWordList) + self.mSettings.addAction(self.mainGui.mainMenu.aProjectSettings) + self.mSettings.addSeparator() + self.mSettings.addAction(self.mainGui.mainMenu.aPreferences) self.tbSettings = QToolButton(self) self.tbSettings.setFont(lblFont) self.tbSettings.setText(self.tr("Settings")) - self.tbSettings.setIcon(self.mainTheme.getIcon("settings")) self.tbSettings.setMenu(self.mSettings) self.tbSettings.setToolButtonStyle(Qt.ToolButtonTextUnderIcon) self.tbSettings.setPopupMode(QToolButton.InstantPopup) @@ -132,8 +121,25 @@ class GuiViewsBar(QToolBar): self.addAction(self.aStats) self.addWidget(self.tbSettings) + self.updateTheme() + logger.debug("GuiViewsBar initialisation complete") return + def updateTheme(self): + """Initialise GUI elements that depend on specific settings. + """ + self.setStyleSheet("QToolBar {border: 0px;}") + + self.aProject.setIcon(self.mainTheme.getIcon("view_editor")) + self.aNovel.setIcon(self.mainTheme.getIcon("view_novel")) + self.aOutline.setIcon(self.mainTheme.getIcon("view_outline")) + self.aBuild.setIcon(self.mainTheme.getIcon("view_build")) + self.aDetails.setIcon(self.mainTheme.getIcon("proj_details")) + self.aStats.setIcon(self.mainTheme.getIcon("proj_stats")) + self.tbSettings.setIcon(self.mainTheme.getIcon("settings")) + + return + # END Class GuiViewsBar diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index e9535747..6a25e528 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -23,12 +23,12 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import os import logging import novelwriter from enum import Enum from time import time +from pathlib import Path from datetime import datetime from PyQt5.QtCore import Qt, QTimer, QThreadPool, pyqtSlot @@ -44,17 +44,18 @@ from novelwriter.gui import ( GuiViewsBar ) from novelwriter.dialogs import ( - GuiAbout, GuiDocMerge, GuiDocSplit, GuiPreferences, GuiProjectDetails, - GuiProjectLoad, GuiProjectSettings, GuiUpdates, GuiWordList + GuiAbout, GuiPreferences, GuiProjectDetails, GuiProjectLoad, + GuiProjectSettings, GuiUpdates, GuiWordList ) from novelwriter.tools import ( GuiBuildNovel, GuiLipsum, GuiProjectWizard, GuiWritingStats ) -from novelwriter.core import NWProject +from novelwriter.core import NWProject, ProjectBuilder from novelwriter.enum import ( - nwDocMode, nwItemType, nwItemClass, nwAlert, nwWidget, nwState, nwView + nwDocMode, nwItemType, nwItemClass, nwAlert, nwWidget, nwView ) from novelwriter.common import getGuiItem, hexToInt +from novelwriter.constants import nwFiles logger = logging.getLogger(__name__) @@ -62,7 +63,7 @@ logger = logging.getLogger(__name__) class GuiMain(QMainWindow): def __init__(self): - QMainWindow.__init__(self) + super().__init__() logger.debug("Initialising GUI ...") self.setObjectName("GuiMain") @@ -78,7 +79,7 @@ class GuiMain(QMainWindow): logger.info("Qt5: %s (%d)", self.mainConf.verQtString, self.mainConf.verQtValue) logger.info("PyQt5: %s (%d)", self.mainConf.verPyQtString, self.mainConf.verPyQtValue) logger.info("Python: %s (0x%x)", self.mainConf.verPyString, self.mainConf.verPyHexVal) - logger.info("GUI Language: %s", self.mainConf.guiLang) + logger.info("GUI Language: %s", self.mainConf.guiLocale) # Core Classes # ============ @@ -92,9 +93,13 @@ class GuiMain(QMainWindow): self.idleTime = 0.0 # Prepare Main Window - self.resize(*self.mainConf.getWinSize()) + self.resize(*self.mainConf.mainWinSize) self._updateWindowTitle() - self.setWindowIcon(QIcon(self.mainConf.appIcon)) + + nwIcon = self.mainConf.assetPath("icons") / "novelwriter.svg" + self.nwIcon = QIcon(str(nwIcon)) if nwIcon.is_file() else QIcon() + self.setWindowIcon(self.nwIcon) + qApp.setWindowIcon(self.nwIcon) # Build the GUI # ============= @@ -104,7 +109,7 @@ class GuiMain(QMainWindow): hWd = self.mainConf.pxInt(4) # Main GUI Elements - self.statusBar = GuiMainStatus(self) + self.mainStatus = GuiMainStatus(self) self.projView = GuiProjectView(self) self.novelView = GuiNovelView(self) self.docEditor = GuiDocEditor(self) @@ -135,7 +140,7 @@ class GuiMain(QMainWindow): self.splitView.addWidget(self.docViewer) self.splitView.addWidget(self.viewMeta) self.splitView.setHandleWidth(hWd) - self.splitView.setSizes(self.mainConf.getViewPanePos()) + self.splitView.setSizes(self.mainConf.viewPanePos) # Splitter : Document Editor / Document Viewer self.splitDocs = QSplitter(Qt.Horizontal) @@ -149,7 +154,7 @@ class GuiMain(QMainWindow): self.splitMain.addWidget(self.treePane) self.splitMain.addWidget(self.splitDocs) self.splitMain.setHandleWidth(hWd) - self.splitMain.setSizes(self.mainConf.getMainPanePos()) + self.splitMain.setSizes(self.mainConf.mainPanePos) # Main Stack : Editor / Outline self.mainStack = QStackedWidget() @@ -189,29 +194,33 @@ class GuiMain(QMainWindow): # Set Main Window Elements self.setMenuBar(self.mainMenu) self.setCentralWidget(self.mainStack) - self.setStatusBar(self.statusBar) + self.setStatusBar(self.mainStatus) self.addToolBar(Qt.LeftToolBarArea, self.viewsBar) + self.setContextMenuPolicy(Qt.NoContextMenu) # Issue #1147 # Connect Signals # =============== + self.theProject.projectStatusChanged.connect(self.mainStatus.doUpdateProjectStatus) + self.viewsBar.viewChangeRequested.connect(self._changeView) self.projView.selectedItemChanged.connect(self.itemDetails.updateViewBox) self.projView.openDocumentRequest.connect(self._openDocument) - self.projView.novelItemChanged.connect(self._treeNovelItemChanged) self.projView.wordCountsChanged.connect(self._updateStatusWordCount) self.projView.treeItemChanged.connect(self.docEditor.updateDocInfo) self.projView.treeItemChanged.connect(self.docViewer.updateDocInfo) self.projView.treeItemChanged.connect(self.itemDetails.updateViewBox) self.projView.rootFolderChanged.connect(self.outlineView.updateRootItem) self.projView.rootFolderChanged.connect(self.novelView.updateRootItem) + self.projView.rootFolderChanged.connect(self.projView.updateRootItem) + self.projView.projectSettingsRequest.connect(self.showProjectSettingsDialog) self.novelView.selectedItemChanged.connect(self.itemDetails.updateViewBox) self.novelView.openDocumentRequest.connect(self._openDocument) - self.docEditor.spellDictionaryChanged.connect(self.statusBar.setLanguage) - self.docEditor.docEditedStatusChanged.connect(self.statusBar.doUpdateDocumentStatus) + self.docEditor.spellDictionaryChanged.connect(self.mainStatus.setLanguage) + self.docEditor.docEditedStatusChanged.connect(self.mainStatus.doUpdateDocumentStatus) self.docEditor.docCountsChanged.connect(self.itemDetails.updateCounts) self.docEditor.docCountsChanged.connect(self.projView.updateCounts) self.docEditor.loadDocumentTagRequest.connect(self._followTag) @@ -253,7 +262,7 @@ class GuiMain(QMainWindow): keyEscape.activated.connect(self._keyPressEscape) # Forward Functions - self.setStatus = self.statusBar.setStatus + self.setStatus = self.mainStatus.setStatus # Force a show of the GUI self.show() @@ -265,7 +274,7 @@ class GuiMain(QMainWindow): self.initMain() self.asProjTimer.start() self.asDocTimer.start() - self.statusBar.clearStatus() + self.mainStatus.clearStatus() # Handle Windows Mode self.showNormal() @@ -281,11 +290,6 @@ class GuiMain(QMainWindow): "and make sure you take regular backups." ), nwAlert.WARN) - # If a project path was provided at command line, open it - if self.mainConf.cmdOpen is not None: - logger.debug("Opening project from additional command line option") - self.openProject(self.mainConf.cmdOpen) - logger.info("novelWriter is ready ...") self.setStatus(self.tr("novelWriter is ready ...")) @@ -303,10 +307,10 @@ class GuiMain(QMainWindow): self.docEditor.clearEditor() self.docEditor.setDictionaries() self.closeDocViewer() - self.outlineView.clearOutline() + self.outlineView.clearProject() # General - self.statusBar.clearStatus() + self.mainStatus.clearStatus() self._updateWindowTitle() return True @@ -318,13 +322,22 @@ class GuiMain(QMainWindow): self.asDocTimer.setInterval(int(self.mainConf.autoSaveDoc*1000)) return True - def releaseNotes(self): - """Determine whether release notes need to be shown, and show - them by calling the About dialog. + def postLaunchTasks(self, cmdOpen): + """This function is called after the main window is created to + determine what to open or show after initialisation. """ + if cmdOpen: + logger.info("Command line path: %s", cmdOpen) + self.openProject(cmdOpen) + + if not self.hasProject: + self.showProjectLoadDialog() + + # Determine whether release notes need to be shown or not if hexToInt(self.mainConf.lastNotes) < hexToInt(novelwriter.__hexversion__): self.mainConf.lastNotes = novelwriter.__hexversion__ self.showAboutNWDialog(showNotes=True) + return ## @@ -352,7 +365,7 @@ class GuiMain(QMainWindow): logger.error("No projData or projPath set") return False - if os.path.isfile(os.path.join(projPath, self.theProject.projFile)): + if (Path(projPath) / nwFiles.PROJ_FILE).is_file(): self.makeAlert(self.tr( "A project already exists in that location. " "Please choose another folder." @@ -360,21 +373,10 @@ class GuiMain(QMainWindow): return False logger.info("Creating new project") - if self.theProject.newProject(projData): - self.hasProject = True - self.rebuildTrees() - self.saveProject() - self.docEditor.setDictionaries() - self.outlineView.updateRootItem(None) - self.novelView.openProjectTasks() - self.rebuildIndex(beQuiet=True) - self.statusBar.setRefTime(self.theProject.projOpened) - self.statusBar.setProjectStatus(nwState.GOOD) - self.statusBar.setDocumentStatus(nwState.NONE) - self.statusBar.setStatus(self.tr("New project created ...")) - self._updateWindowTitle(self.theProject.projName) + nwProject = ProjectBuilder(self) + if nwProject.buildProject(projData): + self.openProject(projPath) else: - self.theProject.clearProject() return False return True @@ -402,34 +404,31 @@ class GuiMain(QMainWindow): if self.docEditor.docChanged(): self.saveDocument() - if self.theProject.projAltered: - saveOK = self.saveProject() - doBackup = False - if self.theProject.doBackup and self.mainConf.backupOnClose: - doBackup = True - if self.mainConf.askBeforeBackup: - msgYes = self.askQuestion( - self.tr("Backup Project"), - self.tr("Backup the current project?") - ) - if not msgYes: - doBackup = False - if doBackup: - self.theProject.zipIt(False) - else: - saveOK = True + saveOK = self.saveProject() + doBackup = False + if self.theProject.data.doBackup and self.mainConf.backupOnClose: + doBackup = True + if self.mainConf.askBeforeBackup: + msgYes = self.askQuestion( + self.tr("Backup Project"), + self.tr("Backup the current project?") + ) + if not msgYes: + doBackup = False + + if doBackup: + self.theProject.backupProject(False) if saveOK: self.closeDocument() self.docViewer.clearNavHistory() - self.outlineView.closeOutline() + self.outlineView.closeProjectTasks() self.novelView.closeProjectTasks() self.theProject.closeProject(self.idleTime) self.idleRefTime = time() self.idleTime = 0.0 - self.theProject.index.clearIndex() self.clearGUI() self.hasProject = False self._changeView(nwView.PROJECT) @@ -454,7 +453,8 @@ class GuiMain(QMainWindow): if not self.theProject.openProject(projFile): # The project open failed. - if self.theProject.lockedBy is None: + lockStatus = self.theProject.getLockStatus() + if lockStatus is None: # The project is not locked, so failed for some other # reason handled by the project class. return False @@ -466,10 +466,8 @@ class GuiMain(QMainWindow): "'{0}' ({1} {2}), last active on {3}." ) ).format( - self.theProject.lockedBy[0], - self.theProject.lockedBy[1], - self.theProject.lockedBy[2], - datetime.fromtimestamp(int(self.theProject.lockedBy[3])).strftime("%x %X") + lockStatus[0], lockStatus[1], lockStatus[2], + datetime.fromtimestamp(int(lockStatus[3])).strftime("%x %X") ) except Exception: lockDetails = "" @@ -505,25 +503,32 @@ class GuiMain(QMainWindow): self.idleRefTime = time() self.idleTime = 0.0 - # Load the tag index - self.theProject.index.loadIndex() - # Update GUI - self._updateWindowTitle(self.theProject.projName) + self._updateWindowTitle(self.theProject.data.name) self.rebuildTrees() self.docEditor.setDictionaries() - self.docEditor.toggleSpellCheck(self.theProject.spellCheck) - self.statusBar.setRefTime(self.theProject.projOpened) - self.outlineView.updateRootItem(None) + self.docEditor.toggleSpellCheck(self.theProject.data.spellCheck) + self.mainStatus.setRefTime(self.theProject.projOpened) + self.projView.openProjectTasks() self.novelView.openProjectTasks() + self.outlineView.openProjectTasks() self._updateStatusWordCount() # Restore previously open documents, if any - if self.theProject.lastEdited is not None: - self.openDocument(self.theProject.lastEdited, doScroll=True) + # If none was recorded, open the first document found + lastEdited = self.theProject.data.getLastHandle("editor") + if lastEdited is None: + for nwItem in self.theProject.tree: + if nwItem and nwItem.isFileType(): + lastEdited = nwItem.itemHandle + break - if self.theProject.lastViewed is not None: - self.viewDocument(self.theProject.lastViewed) + if lastEdited is not None: + self.openDocument(lastEdited, doScroll=True) + + lastViewed = self.theProject.data.getLastHandle("viewer") + if lastViewed is not None: + self.viewDocument(lastViewed) # Check if we need to rebuild the index if self.theProject.index.indexBroken: @@ -548,9 +553,8 @@ class GuiMain(QMainWindow): logger.error("No project open") return False - self.projView.saveProjectTree() - if self.theProject.saveProject(autoSave=autoSave): - self.theProject.index.saveIndex() + self.projView.saveProjectTasks() + self.theProject.saveProject(autoSave=autoSave) return True @@ -558,7 +562,7 @@ class GuiMain(QMainWindow): # Document Actions ## - def closeDocument(self): + def closeDocument(self, beforeOpen=False): """Close the document and clear the editor and title field. """ if not self.hasProject: @@ -573,6 +577,8 @@ class GuiMain(QMainWindow): if self.docEditor.docChanged(): self.saveDocument() self.docEditor.clearEditor() + if not beforeOpen: + self.novelView.setActiveHandle(None) return True @@ -587,13 +593,14 @@ class GuiMain(QMainWindow): logger.debug("Requested item '%s' is not a document", tHandle) return False - self.closeDocument() + self.closeDocument(beforeOpen=True) self._changeView(nwView.EDITOR) if self.docEditor.loadText(tHandle, tLine): if changeFocus: self.docEditor.setFocus() - self.theProject.setLastEdited(tHandle) + self.theProject.data.setLastHandle(tHandle, "editor") self.projView.setSelectedHandle(tHandle, doScroll=doScroll) + self.novelView.setActiveHandle(tHandle) else: return False @@ -611,7 +618,7 @@ class GuiMain(QMainWindow): fHandle = None # The first file handle we encounter foundIt = False # We've found tHandle, pick the next we see for tItem in self.theProject.tree: - if not self.theProject.tree.checkType(tItem.itemHandle, nwItemType.FILE): + if tItem is None or not tItem.isFileType(): continue if fHandle is None: fHandle = tItem.itemHandle @@ -652,21 +659,18 @@ class GuiMain(QMainWindow): logger.debug("Viewing document, but no handle provided") if self.docEditor.hasFocus(): - logger.verbose("Trying editor document") tHandle = self.docEditor.docHandle() if tHandle is not None: self.saveDocument() else: - logger.verbose("Trying selected document") tHandle = self.projView.getSelectedHandle() if tHandle is None: - logger.verbose("Trying last viewed document") - tHandle = self.theProject.lastViewed + tHandle = self.theProject.data.getLastHandle("viewer") if tHandle is None: - logger.verbose("No document to view, giving up") + logger.debug("No document to view, giving up") return False # Make sure main tab is in Editor view @@ -695,7 +699,7 @@ class GuiMain(QMainWindow): logger.error("No project open") return False - lastPath = self.mainConf.lastPath + lastPath = self.mainConf.lastPath() extFilter = [ self.tr("Text files ({0})").format("*.txt"), self.tr("Markdown files ({0})").format("*.md"), @@ -703,7 +707,7 @@ class GuiMain(QMainWindow): self.tr("All files ({0})").format("*"), ] loadFile, _ = QFileDialog.getOpenFileName( - self, self.tr("Import File"), lastPath, filter=";;".join(extFilter) + self, self.tr("Import File"), str(lastPath), filter=";;".join(extFilter) ) if not loadFile: return False @@ -743,30 +747,6 @@ class GuiMain(QMainWindow): return True - def mergeDocuments(self): - """Merge multiple documents to one single new document. - """ - if not self.hasProject: - logger.error("No project open") - return False - - dlgMerge = GuiDocMerge(self) - dlgMerge.exec_() - - return True - - def splitDocument(self): - """Split a single document into multiple documents. - """ - if not self.hasProject: - logger.error("No project open") - return False - - dlgSplit = GuiDocSplit(self) - dlgSplit.exec_() - - return True - def passDocumentAction(self, theAction): """Pass on document action to the document viewer if it has focus, or pass it to the document editor if it or any of @@ -818,15 +798,11 @@ class GuiMain(QMainWindow): logger.error("No project open") return False - if tHandle is None: - if self.docEditor.anyFocus() or self.isFocusMode: - tHandle = self.docEditor.docHandle() - else: - tHandle = self.projView.getSelectedHandle() - if tHandle: - return self.projView.renameTreeItem(tHandle) + if tHandle is None and (self.docEditor.anyFocus() or self.isFocusMode): + tHandle = self.docEditor.docHandle() + self.projView.renameTreeItem(tHandle) - return False + return True def rebuildTrees(self): """Rebuild the project tree. @@ -854,18 +830,9 @@ class GuiMain(QMainWindow): qApp.setOverrideCursor(QCursor(Qt.WaitCursor)) tStart = time() - self.projView.saveProjectTree() - self.theProject.index.clearIndex() - - for tItem in self.theProject.tree: - if tItem is None: # pragma: no cover - continue # This is a bug trap - - logger.verbose("Indexing '%s'", tItem.itemName) - if self.theProject.index.reIndexHandle(tItem.itemHandle): - # Update Word Counts - self.projView.propagateCount(tItem.itemHandle, tItem.wordCount, countChildren=True) - self.projView.setTreeItemValues(tItem.itemHandle) + self.projView.saveProjectTasks() + self.theProject.index.rebuildIndex() + self.projView.populateTree() tEnd = time() self.setStatus( @@ -894,6 +861,7 @@ class GuiMain(QMainWindow): """ dlgProj = GuiProjectLoad(self) dlgProj.exec_() + if dlgProj.result() == QDialog.Accepted: if dlgProj.openState == GuiProjectLoad.OPEN_STATE: self.openProject(dlgProj.openPath) @@ -922,25 +890,52 @@ class GuiMain(QMainWindow): if dlgConf.result() == QDialog.Accepted: logger.debug("Applying new preferences") self.initMain() - self.mainTheme.updateTheme() self.saveDocument() + + if dlgConf.needsRestart: + self.makeAlert(self.tr( + "Some changes will not be applied until novelWriter has been restarted." + ), nwAlert.INFO) + + if dlgConf.refreshTree: + self.projView.populateTree() + + if dlgConf.updateTheme: + # We are doing this manually instead of connecting to + # qApp.paletteChanged since the processing order matters + self.mainTheme.loadTheme() + self.docEditor.updateTheme() + self.docViewer.updateTheme() + self.viewsBar.updateTheme() + self.projView.updateTheme() + self.novelView.updateTheme() + self.outlineView.updateTheme() + self.itemDetails.updateTheme() + self.mainStatus.updateTheme() + + if dlgConf.updateSyntax: + self.mainTheme.loadSyntax() + self.docEditor.updateSyntaxColours() + self.docEditor.initEditor() self.docViewer.initViewer() self.projView.initSettings() self.novelView.initSettings() - self.outlineView.initOutline() + self.outlineView.initSettings() + self._updateStatusWordCount() return - def showProjectSettingsDialog(self): + @pyqtSlot(int) + def showProjectSettingsDialog(self, focusTab=GuiProjectSettings.TAB_MAIN): """Open the project settings dialog. """ if not self.hasProject: logger.error("No project open") return False - dlgProj = GuiProjectSettings(self) + dlgProj = GuiProjectSettings(self, focusTab=focusTab) dlgProj.exec_() if dlgProj.result() == QDialog.Accepted: @@ -948,7 +943,7 @@ class GuiMain(QMainWindow): if dlgProj.spellChanged: self.docEditor.setDictionaries() self.itemDetails.refreshDetails() - self._updateWindowTitle(self.theProject.projName) + self._updateWindowTitle(self.theProject.data.name) return True @@ -962,6 +957,7 @@ class GuiMain(QMainWindow): dlgDetails = getGuiItem("GuiProjectDetails") if dlgDetails is None: dlgDetails = GuiProjectDetails(self) + assert isinstance(dlgDetails, GuiProjectDetails) dlgDetails.setModal(False) dlgDetails.show() @@ -980,6 +976,7 @@ class GuiMain(QMainWindow): dlgBuild = getGuiItem("GuiBuildNovel") if dlgBuild is None: dlgBuild = GuiBuildNovel(self) + assert isinstance(dlgBuild, GuiBuildNovel) dlgBuild.setModal(False) dlgBuild.show() @@ -999,6 +996,7 @@ class GuiMain(QMainWindow): dlgLipsum = getGuiItem("GuiLipsum") if dlgLipsum is None: dlgLipsum = GuiLipsum(self) + assert isinstance(dlgLipsum, GuiLipsum) dlgLipsum.setModal(False) dlgLipsum.show() @@ -1033,6 +1031,7 @@ class GuiMain(QMainWindow): dlgStats = getGuiItem("GuiWritingStats") if dlgStats is None: dlgStats = GuiWritingStats(self) + assert isinstance(dlgStats, GuiWritingStats) dlgStats.setModal(False) dlgStats.show() @@ -1048,6 +1047,7 @@ class GuiMain(QMainWindow): dlgAbout = getGuiItem("GuiAbout") if dlgAbout is None: dlgAbout = GuiAbout(self) + assert isinstance(dlgAbout, GuiAbout) dlgAbout.setModal(True) dlgAbout.show() @@ -1073,6 +1073,7 @@ class GuiMain(QMainWindow): dlgUpdate = getGuiItem("GuiUpdates") if dlgUpdate is None: dlgUpdate = GuiUpdates(self) + assert isinstance(dlgUpdate, GuiUpdates) dlgUpdate.setModal(True) dlgUpdate.show() @@ -1136,7 +1137,7 @@ class GuiMain(QMainWindow): errors since it is initialised before the GUI itself. """ if self.mainConf.hasError: - self.makeAlert(self.mainConf.getErrData(), nwAlert.ERROR) + self.makeAlert(self.mainConf.errorText(), nwAlert.ERROR) return True return False @@ -1162,14 +1163,13 @@ class GuiMain(QMainWindow): if not self.isFocusMode: self.mainConf.setMainPanePos(self.splitMain.sizes()) - self.mainConf.setDocPanePos(self.splitDocs.sizes()) self.mainConf.setOutlinePanePos(self.outlineView.splitSizes()) if self.viewMeta.isVisible(): self.mainConf.setViewPanePos(self.splitView.sizes()) - self.mainConf.setShowRefPanel(self.viewMeta.isVisible()) + self.mainConf.showRefPanel = self.viewMeta.isVisible() if not self.mainConf.isFullScreen: - self.mainConf.setWinSize(self.width(), self.height()) + self.mainConf.setMainWinSize(self.width(), self.height()) if self.hasProject: self.closeProject(True) @@ -1189,7 +1189,7 @@ class GuiMain(QMainWindow): if tabIdx == self.idxProjView: self.projView.setFocus() elif tabIdx == self.idxNovelView: - self.novelView.setFocus() + self.novelView.setTreeFocus() elif paneNo == nwWidget.EDITOR: self._changeView(nwView.EDITOR) self.docEditor.setFocus() @@ -1205,14 +1205,14 @@ class GuiMain(QMainWindow): """Close the document edit panel. This does not hide the editor. """ self.closeDocument() - self.theProject.setLastEdited(None) + self.theProject.data.setLastHandle(None, "editor") return def closeDocViewer(self): """Close the document view panel. """ self.docViewer.clearViewer() - self.theProject.setLastViewed(None) + self.theProject.data.setLastHandle(None, "viewer") bPos = self.splitMain.sizes() self.splitView.setVisible(False) self.splitDocs.setSizes([bPos[1], 0]) @@ -1223,11 +1223,9 @@ class GuiMain(QMainWindow): """ if self.docEditor.docHandle() is None: logger.error("No document open, so not activating Focus Mode") - self.mainMenu.setFocusMode(self.isFocusMode) return False self.isFocusMode = not self.isFocusMode - self.mainMenu.setFocusMode(self.isFocusMode) if self.isFocusMode: logger.debug("Activating Focus Mode") self.switchFocus(nwWidget.EDITOR) @@ -1236,7 +1234,7 @@ class GuiMain(QMainWindow): isVisible = not self.isFocusMode self.treePane.setVisible(isVisible) - self.statusBar.setVisible(isVisible) + self.mainStatus.setVisible(isVisible) self.mainMenu.setVisible(isVisible) self.viewsBar.setVisible(isVisible) @@ -1324,6 +1322,7 @@ class GuiMain(QMainWindow): self.addAction(self.mainMenu.aInsMinus) self.addAction(self.mainMenu.aInsTimes) self.addAction(self.mainMenu.aInsDivide) + self.addAction(self.mainMenu.aInsSynopsis) for mAction, _ in self.mainMenu.mInsKWItems.values(): self.addAction(mAction) @@ -1360,7 +1359,7 @@ class GuiMain(QMainWindow): # Help self.addAction(self.mainMenu.aHelpDocs) - if self.mainConf.pdfDocs is not None: + if isinstance(self.mainConf.pdfDocs, Path): self.addAction(self.mainMenu.aPdfDocs) return True @@ -1379,7 +1378,7 @@ class GuiMain(QMainWindow): """ doSave = self.hasProject doSave &= self.theProject.projChanged - doSave &= self.theProject.projPath is not None + doSave &= self.theProject.storage.isOpen() if doSave: logger.debug("Autosaving project") @@ -1520,12 +1519,12 @@ class GuiMain(QMainWindow): if editIdle or userIdle: self.idleTime += currTime - self.idleRefTime - self.statusBar.setUserIdle(True) + self.mainStatus.setUserIdle(True) else: - self.statusBar.setUserIdle(False) + self.mainStatus.setUserIdle(False) self.idleRefTime = currTime - self.statusBar.updateTime(idleTime=self.idleTime) + self.mainStatus.updateTime(idleTime=self.idleTime) return @@ -1534,30 +1533,17 @@ class GuiMain(QMainWindow): """Update the word count on the status bar. """ if not self.hasProject: - self.statusBar.setProjectStats(0, 0) + self.mainStatus.setProjectStats(0, 0) - logger.verbose("Updating total word count") self.theProject.updateWordCounts() if self.mainConf.incNotesWCount: - currWords = self.theProject.currWCount - diffWords = currWords - self.theProject.lastWCount + iTotal = sum(self.theProject.data.initCounts) + cTotal = sum(self.theProject.data.currCounts) + self.mainStatus.setProjectStats(cTotal, cTotal - iTotal) else: - currWords = self.theProject.currNovelWC - diffWords = currWords - self.theProject.lastNovelWC - - self.statusBar.setProjectStats(currWords, diffWords) - - return - - @pyqtSlot() - def _treeNovelItemChanged(self): - """Triggered when there is a change to a novel item in the - project tree. - """ - if self.mainStack.currentIndex() == self.idxOutlineView: - logger.verbose("Novel tree changed while Outline tab active") - if self.hasProject: - self.outlineView.refreshView(novelChanged=True) + iNovel, _ = self.theProject.data.initCounts + cNovel, _ = self.theProject.data.currCounts + self.mainStatus.setProjectStats(cNovel, cNovel - iNovel) return @@ -1585,13 +1571,9 @@ class GuiMain(QMainWindow): def _mainStackChanged(self, stIndex): """Activated when the main window tab is changed. """ - if stIndex == self.idxEditorView: - logger.verbose("Editor View activated") - elif stIndex == self.idxOutlineView: - logger.verbose("Outline View activated") + if stIndex == self.idxOutlineView: if self.hasProject: - self.outlineView.refreshView() - + self.outlineView.refreshTree() return @pyqtSlot(int) @@ -1601,11 +1583,9 @@ class GuiMain(QMainWindow): sHandle = None if stIndex == self.idxProjView: - logger.verbose("Project Tree View activated") sHandle = self.projView.getSelectedHandle() elif stIndex == self.idxNovelView: - logger.verbose("Novel Tree View activated") if self.hasProject: self.novelView.refreshTree() sHandle, _ = self.novelView.getSelectedHandle() diff --git a/novelwriter/tools/build.py b/novelwriter/tools/build.py index efeff928..2935c556 100644 --- a/novelwriter/tools/build.py +++ b/novelwriter/tools/build.py @@ -23,12 +23,12 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import os import json import logging import novelwriter from time import time +from pathlib import Path from datetime import datetime from PyQt5.QtGui import ( @@ -47,8 +47,8 @@ from novelwriter.core import ToHtml, ToOdt, ToMarkdown from novelwriter.enum import nwAlert, nwItemType, nwItemLayout, nwItemClass from novelwriter.error import formatException, logException from novelwriter.common import fuzzyTime, makeFileNameSafe +from novelwriter.custom import QSwitch from novelwriter.constants import nwConst, nwFiles -from novelwriter.gui.custom import QSwitch logger = logging.getLogger(__name__) @@ -66,7 +66,7 @@ class GuiBuildNovel(QDialog): FMT_JSON_M = 9 # nW Markdown wrapped in JSON def __init__(self, mainGui): - QDialog.__init__(self, mainGui) + super().__init__(parent=mainGui) logger.debug("Initialising GuiBuildNovel ...") self.setObjectName("GuiBuildNovel") @@ -126,7 +126,7 @@ class GuiBuildNovel(QDialog): self.fmtTitle.setMinimumWidth(xFmt) self.fmtTitle.setToolTip(fmtHelp) self.fmtTitle.setText( - self._reFmtCodes(self.theProject.titleFormat["title"]) + self._reFmtCodes(self.theProject.data.getTitleFormat("title")) ) self.fmtChapter = QLineEdit() @@ -134,7 +134,7 @@ class GuiBuildNovel(QDialog): self.fmtChapter.setMinimumWidth(xFmt) self.fmtChapter.setToolTip(fmtHelp) self.fmtChapter.setText( - self._reFmtCodes(self.theProject.titleFormat["chapter"]) + self._reFmtCodes(self.theProject.data.getTitleFormat("chapter")) ) self.fmtUnnumbered = QLineEdit() @@ -142,7 +142,7 @@ class GuiBuildNovel(QDialog): self.fmtUnnumbered.setMinimumWidth(xFmt) self.fmtUnnumbered.setToolTip(fmtHelp) self.fmtUnnumbered.setText( - self._reFmtCodes(self.theProject.titleFormat["unnumbered"]) + self._reFmtCodes(self.theProject.data.getTitleFormat("unnumbered")) ) self.fmtScene = QLineEdit() @@ -150,7 +150,7 @@ class GuiBuildNovel(QDialog): self.fmtScene.setMinimumWidth(xFmt) self.fmtScene.setToolTip(fmtHelp + fmtScHelp) self.fmtScene.setText( - self._reFmtCodes(self.theProject.titleFormat["scene"]) + self._reFmtCodes(self.theProject.data.getTitleFormat("scene")) ) self.fmtSection = QLineEdit() @@ -158,7 +158,7 @@ class GuiBuildNovel(QDialog): self.fmtSection.setMinimumWidth(xFmt) self.fmtSection.setToolTip(fmtHelp + fmtScHelp) self.fmtSection.setText( - self._reFmtCodes(self.theProject.titleFormat["section"]) + self._reFmtCodes(self.theProject.data.getTitleFormat("section")) ) self.buildLang = QComboBox() @@ -168,7 +168,7 @@ class GuiBuildNovel(QDialog): for langID, langName in theLangs: self.buildLang.addItem(langName, langID) - langIdx = self.buildLang.findData(self.theProject.projLang) + langIdx = self.buildLang.findData(self.theProject.data.language) if langIdx != -1: self.buildLang.setCurrentIndex(langIdx) @@ -351,6 +351,36 @@ class GuiBuildNovel(QDialog): self.textForm.setColumnStretch(0, 1) self.textForm.setColumnStretch(1, 0) + # Root Filter Options + # =================== + + self.rootGroup = QGroupBox(self.tr("Root Filter Options"), self) + self.rootForm = QGridLayout(self) + self.rootGroup.setLayout(self.rootForm) + + rootFilter = pOptions.getValue("GuiBuildNovel", "rootFilter", []) + if not isinstance(rootFilter, list): + rootFilter = [] + + iRow = 0 + self.rootSelection = {} + for tHandle, nwItem in self.theProject.tree.iterRoots(None): + if not nwItem.isInactive(): + rootLabel = QLabel(nwItem.itemName) + rootLabel.setWordWrap(True) + + rootValue = QSwitch(width=wS, height=hS) + rootValue.setChecked(tHandle not in rootFilter) + + self.rootSelection[tHandle] = rootValue + self.rootForm.addWidget(rootLabel, iRow, 0, 1, 1, Qt.AlignLeft) + self.rootForm.addWidget(rootValue, iRow, 1, 1, 1, Qt.AlignRight) + + iRow += 1 + + self.rootForm.setColumnStretch(0, 1) + self.rootForm.setColumnStretch(1, 0) + # File Filter Options # =================== @@ -375,13 +405,13 @@ class GuiBuildNovel(QDialog): novelLabel = QLabel(self.tr("Include novel files")) notesLabel = QLabel(self.tr("Include note files")) - exportLabel = QLabel(self.tr("Ignore export flag")) + activeLabel = QLabel(self.tr("Include inactive files")) self.fileForm.addWidget(novelLabel, 0, 0, 1, 1, Qt.AlignLeft) self.fileForm.addWidget(self.novelFiles, 0, 1, 1, 1, Qt.AlignRight) self.fileForm.addWidget(notesLabel, 1, 0, 1, 1, Qt.AlignLeft) self.fileForm.addWidget(self.noteFiles, 1, 1, 1, 1, Qt.AlignRight) - self.fileForm.addWidget(exportLabel, 2, 0, 1, 1, Qt.AlignLeft) + self.fileForm.addWidget(activeLabel, 2, 0, 1, 1, Qt.AlignLeft) self.fileForm.addWidget(self.ignoreFlag, 2, 1, 1, 1, Qt.AlignRight) self.fileForm.setColumnStretch(0, 1) @@ -503,6 +533,7 @@ class GuiBuildNovel(QDialog): self.toolsBox.addWidget(self.fontGroup) self.toolsBox.addWidget(self.styleGroup) self.toolsBox.addWidget(self.textGroup) + self.toolsBox.addWidget(self.rootGroup) self.toolsBox.addWidget(self.fileGroup) self.toolsBox.addWidget(self.exportGroup) self.toolsBox.addStretch(1) @@ -716,6 +747,7 @@ class GuiBuildNovel(QDialog): self.buildProgress.setMaximum(len(self.theProject.tree)) self.buildProgress.setValue(0) + rootFilter = set(self._generateRootFilter()) for nItt, tItem in enumerate(self.theProject.tree): noteRoot = noteFiles @@ -730,7 +762,7 @@ class GuiBuildNovel(QDialog): if doConvert: bldObj.doConvert() - elif self._checkInclude(tItem, noteFiles, novelFiles, ignoreFlag): + elif self._checkInclude(tItem, noteFiles, novelFiles, ignoreFlag, rootFilter): bldObj.setText(tItem.itemHandle) bldObj.doPreProcessing() bldObj.tokenizeText() @@ -766,7 +798,7 @@ class GuiBuildNovel(QDialog): return - def _checkInclude(self, theItem, noteFiles, novelFiles, ignoreFlag): + def _checkInclude(self, theItem, noteFiles, novelFiles, ignoreFlag, rootFilter): """This function checks whether a file should be included in the export or not. For standard note and novel files, this is controlled by the options selected by the user. For other files @@ -781,14 +813,17 @@ class GuiBuildNovel(QDialog): if theItem is None: return False - if not (theItem.isExported or ignoreFlag): + if not (theItem.isActive or ignoreFlag): return False - isNone = theItem.itemType != nwItemType.FILE + if theItem.itemRoot in rootFilter: + return False + + isNone = not theItem.isFileType() isNone |= theItem.itemLayout == nwItemLayout.NO_LAYOUT isNone |= theItem.isInactive() isNone |= theItem.itemParent is None - isNote = theItem.itemLayout == nwItemLayout.NOTE + isNote = theItem.isNoteLayout() isNovel = not isNone and not isNote if isNone: @@ -853,15 +888,11 @@ class GuiBuildNovel(QDialog): # Generate File Name # ================== - cleanName = makeFileNameSafe(self.theProject.projName) + cleanName = makeFileNameSafe(self.theProject.data.name) fileName = "%s.%s" % (cleanName, fileExt) - saveDir = self.mainConf.lastPath - if not os.path.isdir(saveDir): - saveDir = os.path.expanduser("~") - - savePath = os.path.join(saveDir, fileName) + savePath = self.mainConf.lastPath() / fileName savePath, _ = QFileDialog.getSaveFileName( - self, self.tr("Save Document As"), savePath + self, self.tr("Save Document As"), str(savePath) ) if not savePath: return False @@ -937,9 +968,9 @@ class GuiBuildNovel(QDialog): elif theFmt == self.FMT_JSON_H or theFmt == self.FMT_JSON_M: jsonData = { "meta": { - "workingTitle": self.theProject.projName, - "novelTitle": self.theProject.bookTitle, - "authors": self.theProject.bookAuthors, + "workingTitle": self.theProject.data.name, + "novelTitle": self.theProject.data.title, + "authors": self.theProject.data.authors, "buildTime": self.buildTime, } } @@ -1045,12 +1076,20 @@ class GuiBuildNovel(QDialog): return + def _generateRootFilter(self): + """Return a list of all root folders that are filtered out. + """ + return [h for h, s in self.rootSelection.items() if not s.isChecked()] + def _loadCache(self): """Save the current data to cache. """ - buildCache = os.path.join(self.theProject.projCache, nwFiles.BUILD_CACHE) + buildCache = self.theProject.storage.getCacheFile(nwFiles.BUILD_CACHE) + if not isinstance(buildCache, Path): + return False + dataCount = 0 - if os.path.isfile(buildCache): + if buildCache.exists(): logger.debug("Loading build cache") try: with open(buildCache, mode="r", encoding="utf-8") as inFile: @@ -1075,7 +1114,10 @@ class GuiBuildNovel(QDialog): def _saveCache(self): """Save the current data to cache. """ - buildCache = os.path.join(self.theProject.projCache, nwFiles.BUILD_CACHE) + buildCache = self.theProject.storage.getCacheFile(nwFiles.BUILD_CACHE) + if not isinstance(buildCache, Path): + return False + logger.debug("Saving build cache") try: with open(buildCache, mode="w+", encoding="utf-8") as outFile: @@ -1119,7 +1161,7 @@ class GuiBuildNovel(QDialog): logger.debug("Saving GuiBuildNovel settings") # Formatting - self.theProject.setTitleFormat({ + self.theProject.data.setTitleFormat({ "title": self.fmtTitle.text().strip(), "chapter": self.fmtChapter.text().strip(), "unnumbered": self.fmtUnnumbered.text().strip(), @@ -1146,6 +1188,7 @@ class GuiBuildNovel(QDialog): incBodyText = self.includeBody.isChecked() replaceTabs = self.replaceTabs.isChecked() replaceUCode = self.replaceUCode.isChecked() + rootFilter = self._generateRootFilter() mainSplit = self.mainSplit.sizes() boxWidth = self.mainConf.rpxInt(mainSplit[0]) @@ -1175,6 +1218,7 @@ class GuiBuildNovel(QDialog): pOptions.setValue("GuiBuildNovel", "incBodyText", incBodyText) pOptions.setValue("GuiBuildNovel", "replaceTabs", replaceTabs) pOptions.setValue("GuiBuildNovel", "replaceUCode", replaceUCode) + pOptions.setValue("GuiBuildNovel", "rootFilter", rootFilter) pOptions.saveSettings() return @@ -1194,7 +1238,7 @@ class GuiBuildNovel(QDialog): class GuiBuildNovelDocView(QTextBrowser): def __init__(self, mainGui, theProject): - QTextBrowser.__init__(self, mainGui) + super().__init__(parent=mainGui) logger.debug("Initialising GuiBuildNovelDocView ...") @@ -1223,10 +1267,7 @@ class GuiBuildNovelDocView(QTextBrowser): self.setFont(theFont) # Set the tab stops - if self.mainConf.verQtValue >= 51000: - self.setTabStopDistance(self.mainConf.getTabWidth()) - else: - self.setTabStopWidth(self.mainConf.getTabWidth()) + self.setTabStopDistance(self.mainConf.getTabWidth()) docPalette = self.palette() docPalette.setColor(QPalette.Base, QColor(255, 255, 255)) @@ -1343,7 +1384,7 @@ class GuiBuildNovelDocView(QTextBrowser): def resizeEvent(self, theEvent): """Make sure the document title is the same width as the window. """ - QTextBrowser.resizeEvent(self, theEvent) + super().resizeEvent(theEvent) self._updateDocMargins() return diff --git a/novelwriter/tools/lipsum.py b/novelwriter/tools/lipsum.py index 2086d615..def306c5 100644 --- a/novelwriter/tools/lipsum.py +++ b/novelwriter/tools/lipsum.py @@ -23,7 +23,6 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import os import random import logging import novelwriter @@ -34,8 +33,8 @@ from PyQt5.QtWidgets import ( QSpinBox ) -from novelwriter.gui.custom import QSwitch from novelwriter.common import readTextFile +from novelwriter.custom import QSwitch logger = logging.getLogger(__name__) @@ -43,7 +42,7 @@ logger = logging.getLogger(__name__) class GuiLipsum(QDialog): def __init__(self, mainGui): - QDialog.__init__(self, mainGui) + super().__init__(parent=mainGui) logger.debug("Initialising GuiLipsum ...") self.setObjectName("GuiLipsum") @@ -120,7 +119,7 @@ class GuiLipsum(QDialog): def _doInsert(self): """Load the text and insert it in the open document. """ - lipsumFile = os.path.join(self.mainConf.assetPath, "text", "lipsum.txt") + lipsumFile = self.mainConf.assetPath("text") / "lipsum.txt" lipsumText = readTextFile(lipsumFile).splitlines() if self.randSwitch.isChecked(): diff --git a/novelwriter/tools/projwizard.py b/novelwriter/tools/projwizard.py index 5b21f69a..fbd2b8ac 100644 --- a/novelwriter/tools/projwizard.py +++ b/novelwriter/tools/projwizard.py @@ -35,7 +35,7 @@ from PyQt5.QtWidgets import ( ) from novelwriter.common import makeFileNameSafe -from novelwriter.gui.custom import QSwitch +from novelwriter.custom import QSwitch logger = logging.getLogger(__name__) @@ -49,7 +49,7 @@ PAGE_FINAL = 4 class GuiProjectWizard(QWizard): def __init__(self, mainGui): - QWizard.__init__(self, mainGui) + super().__init__(parent=mainGui) logger.debug("Initialising GuiProjectWizard ...") self.setObjectName("GuiProjectWizard") @@ -88,7 +88,7 @@ class GuiProjectWizard(QWizard): class ProjWizardIntroPage(QWizardPage): def __init__(self, theWizard): - QWizardPage.__init__(self) + super().__init__() self.mainConf = novelwriter.CONFIG self.theWizard = theWizard @@ -158,7 +158,7 @@ class ProjWizardIntroPage(QWizardPage): class ProjWizardFolderPage(QWizardPage): def __init__(self, theWizard): - QWizardPage.__init__(self) + super().__init__() self.mainConf = novelwriter.CONFIG self.theWizard = theWizard @@ -209,12 +209,12 @@ class ProjWizardFolderPage(QWizardPage): """Check that the selected path isn't already being used. """ self.errLabel.setText("") - if not QWizardPage.isComplete(self): + if not super().isComplete(): return False setPath = os.path.abspath(os.path.expanduser(self.projPath.text())) parPath = os.path.dirname(setPath) - logger.verbose("Path is: %s", setPath) + logger.debug("Path is: %s", setPath) if parPath and not os.path.isdir(parPath): self.errLabel.setText(self.tr( "Error: A project folder cannot be created using this path." @@ -236,12 +236,9 @@ class ProjWizardFolderPage(QWizardPage): def _doBrowse(self): """Select a project folder. """ - lastPath = self.mainConf.lastPath - if not os.path.isdir(lastPath): - lastPath = "" - + lastPath = self.mainConf.lastPath() projDir = QFileDialog.getExistingDirectory( - self, self.tr("Select Project Folder"), lastPath, options=QFileDialog.ShowDirsOnly + self, self.tr("Select Project Folder"), str(lastPath), options=QFileDialog.ShowDirsOnly ) if projDir: projName = self.field("projName") @@ -259,7 +256,7 @@ class ProjWizardFolderPage(QWizardPage): class ProjWizardPopulatePage(QWizardPage): def __init__(self, theWizard): - QWizardPage.__init__(self) + super().__init__() self.mainConf = novelwriter.CONFIG self.theWizard = theWizard @@ -315,7 +312,7 @@ class ProjWizardPopulatePage(QWizardPage): class ProjWizardCustomPage(QWizardPage): def __init__(self, theWizard): - QWizardPage.__init__(self) + super().__init__() self.mainConf = novelwriter.CONFIG self.theWizard = theWizard @@ -334,13 +331,18 @@ class ProjWizardCustomPage(QWizardPage): # Root Folders self.addPlot = QSwitch() - self.addChar = QSwitch() - self.addWorld = QSwitch() - self.addNotes = QSwitch() - self.addPlot.setChecked(True) + self.addPlot.clicked.connect(self._syncSwitches) + + self.addChar = QSwitch() self.addChar.setChecked(True) + self.addChar.clicked.connect(self._syncSwitches) + + self.addWorld = QSwitch() self.addWorld.setChecked(False) + self.addWorld.clicked.connect(self._syncSwitches) + + self.addNotes = QSwitch() self.addNotes.setChecked(False) # Generate Content @@ -391,13 +393,27 @@ class ProjWizardCustomPage(QWizardPage): return + ## + # Internal Functions + ## + + def _syncSwitches(self): + """Check if the add notes option should also be switched off. + """ + addPlot = self.addPlot.isChecked() + addChar = self.addChar.isChecked() + addWorld = self.addWorld.isChecked() + if not (addPlot or addChar or addWorld): + self.addNotes.setChecked(False) + return + # END Class ProjWizardCustomPage class ProjWizardFinalPage(QWizardPage): def __init__(self, theWizard): - QWizardPage.__init__(self) + super().__init__() self.mainConf = novelwriter.CONFIG self.theWizard = theWizard @@ -418,7 +434,7 @@ class ProjWizardFinalPage(QWizardPage): def initializePage(self): """Update the summary information on the final page. """ - QWizardPage.initializePage(self) + super().initializePage() sumList = [] sumList.append(self.tr("Project Name: {0}").format(self.field("projName"))) diff --git a/novelwriter/tools/writingstats.py b/novelwriter/tools/writingstats.py index add052ec..5e8606d3 100644 --- a/novelwriter/tools/writingstats.py +++ b/novelwriter/tools/writingstats.py @@ -23,11 +23,11 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import os import json import logging import novelwriter +from pathlib import Path from datetime import datetime from PyQt5.QtGui import QPixmap, QCursor @@ -39,9 +39,9 @@ from PyQt5.QtWidgets import ( from novelwriter.enum import nwAlert from novelwriter.error import formatException -from novelwriter.common import formatTime, checkInt, checkIntRange, checkIntTuple +from novelwriter.common import formatTime, checkInt, checkIntTuple, minmax +from novelwriter.custom import QSwitch from novelwriter.constants import nwConst, nwFiles -from novelwriter.gui.custom import QSwitch logger = logging.getLogger(__name__) @@ -58,7 +58,7 @@ class GuiWritingStats(QDialog): FMT_CSV = 1 def __init__(self, mainGui): - QDialog.__init__(self, mainGui) + super().__init__(parent=mainGui) logger.debug("Initialising GuiWritingStats ...") self.setObjectName("GuiWritingStats") @@ -112,11 +112,12 @@ class GuiWritingStats(QDialog): self.listBox.setColumnWidth(self.C_COUNT, wCol3) hHeader = self.listBox.headerItem() - hHeader.setTextAlignment(self.C_LENGTH, Qt.AlignRight) - hHeader.setTextAlignment(self.C_IDLE, Qt.AlignRight) - hHeader.setTextAlignment(self.C_COUNT, Qt.AlignRight) + if hHeader is not None: + hHeader.setTextAlignment(self.C_LENGTH, Qt.AlignRight) + hHeader.setTextAlignment(self.C_IDLE, Qt.AlignRight) + hHeader.setTextAlignment(self.C_COUNT, Qt.AlignRight) - sortCol = checkIntRange(pOptions.getInt("GuiWritingStats", "sortCol", 0), 0, 2, 0) + sortCol = minmax(pOptions.getInt("GuiWritingStats", "sortCol", 0), 0, 2) sortOrder = checkIntTuple( pOptions.getInt("GuiWritingStats", "sortOrder", Qt.DescendingOrder), (Qt.AscendingOrder, Qt.DescendingOrder), Qt.DescendingOrder @@ -361,15 +362,9 @@ class GuiWritingStats(QDialog): return False # Generate the file name - saveDir = self.mainConf.lastPath - if not os.path.isdir(saveDir): - saveDir = os.path.expanduser("~") - - fileName = "sessionStats.%s" % fileExt - savePath = os.path.join(saveDir, fileName) - + savePath = self.mainConf.lastPath() / f"sessionStats.{fileExt}" savePath, _ = QFileDialog.getSaveFileName( - self, self.tr("Save Data As"), savePath, "%s (*.%s)" % (textFmt, fileExt) + self, self.tr("Save Data As"), str(savePath), "%s (*.%s)" % (textFmt, fileExt) ) if not savePath: return False @@ -438,8 +433,8 @@ class GuiWritingStats(QDialog): ttTime = 0 ttIdle = 0 - logFile = os.path.join(self.theProject.projMeta, nwFiles.SESS_STATS) - if not os.path.isfile(logFile): + logFile = self.theProject.storage.getMetaFile(nwFiles.SESS_STATS) + if not isinstance(logFile, Path) or not logFile.exists(): logger.info("This project has no writing stats logfile") return False @@ -449,7 +444,7 @@ class GuiWritingStats(QDialog): if inLine.startswith("#"): if inLine.startswith("# Offset"): self.wordOffset = checkInt(inLine[9:].strip(), 0) - logger.verbose( + logger.debug( "Initial word count when log was started is %d" % self.wordOffset ) continue diff --git a/requirements.txt b/requirements.txt index 4c10636b..b47e1e91 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ -pyqt5>=5.3 +pyqt5>=5.10 lxml>=4.2.0 pyenchant>=3.0.0 diff --git a/sample/nwProject.nwx b/sample/nwProject.nwx index 6ba4b5f1..f3f151bc 100644 --- a/sample/nwProject.nwx +++ b/sample/nwProject.nwx @@ -1,38 +1,32 @@ - - + + Sample Project Sample Project Jane Smith Jay Doh - 1371 - 236 - 69222 - False + no en_GB - True - None - True - 636b6aa9b697b - 636b6aa9b697b - 7031beac91f75 - None - 1363 - 954 - 409 + None + + 636b6aa9b697b + 636b6aa9b697b + 7031beac91f75 + 7031beac91f75 + B E D - %title% - Chapter %chw%: %title% - %title% - Scene %ch%.%sc%: %title% -
+ %title% + Chapter %chw%: %title% + %title% + Scene %ch%.%sc%: %title% +
New @@ -50,114 +44,114 @@ Main
- + - + Novel - - Title Page + + Title Page - - Page + + Page - - Part One + + Part One - - Chapter One + + Chapter One - - Making a Scene + + Making a Scene - - Another Scene + + Another Scene - - Interlude + + Interlude - - A Note on Structure + + A Note on Structure - - Chapter Two + + Chapter Two - - We Found John! + + We Found John! - + Sequel - - Title Page + + Title Page - - Chapter One + + Chapter One - + Characters - + Main Characters - - John Smith + + John Smith - - Jane Smith + + Jane Smith - + Locations - - Earth + + Earth - - Space + + Space - - Mars + + Mars - + Archive - + Scenes - - Old File + + Old File - + Trash - - Delete Me! + + Delete Me!
diff --git a/setup.cfg b/setup.cfg index b88b656b..fc808a1e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -15,6 +15,7 @@ classifiers = Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.11 Programming Language :: Python :: Implementation :: CPython License :: OSI Approved :: GNU General Public License v3 (GPLv3) Development Status :: 5 - Production/Stable @@ -32,7 +33,7 @@ python_requires = >=3.7 include_package_data = True packages = find: install_requires = - pyqt5>=5.3 + pyqt5>=5.10 lxml>=4.2.0 pyenchant>=3.0.0 diff --git a/setup.py b/setup.py index f72ce936..facb5713 100755 --- a/setup.py +++ b/setup.py @@ -131,6 +131,7 @@ def makeCheckSum(sumFile, cwd=None): except Exception as exc: print("Could not generate sha256 file") print(str(exc)) + return "" return shaFile @@ -197,6 +198,7 @@ def cleanBuildDirs(): removeFolder("dist") removeFolder("dist_deb") removeFolder("dist_minimal") + removeFolder("dist_appimage") removeFolder("novelWriter.egg-info") print("") @@ -223,23 +225,20 @@ def buildPdfManual(): buildFile = os.path.join("docs", "build", "latex", "manual.pdf") finalFile = os.path.join("novelwriter", "assets", "manual.pdf") + if os.path.isfile(finalFile): + # Make sure a new file is always generated + os.unlink(finalFile) + try: subprocess.call(["make", "clean"], cwd="docs") - stdOut, stdErr, exCode = sysCall(["make latexpdf"], cwd="docs") + exCode = subprocess.call(["make", "latexpdf"], cwd="docs") if exCode == 0: if os.path.isfile(finalFile): os.unlink(finalFile) - outLines = stdOut.splitlines() - for aLine in outLines: - if aLine.startswith("processing manual.tex..."): - break - print(aLine) - print("\n[LaTeX output truncated ...]\n") - print("\n".join(outLines[-6:])) print("") os.rename(buildFile, finalFile) else: - raise Exception(stdErr) + raise Exception(f"Build returned error code {exCode}") print("PDF manual build: OK") print("") @@ -254,6 +253,13 @@ def buildPdfManual(): print(" * Package latexmk") print(" * LaTeX build system") print("") + print(" On Debian/Ubuntu, install: python3-sphinx latexmk texlive texlive-latex-extra") + print("") + sys.exit(1) + + if not os.path.isfile(finalFile): + print("No output file was found!") + print("") sys.exit(1) return @@ -431,6 +437,63 @@ def buildSampleZip(): return +def cleanBuiltAssets(): + """Remove assets built by this script. + """ + print("") + print("Removing Built Assets") + print("=====================") + print("") + + sampleZip = os.path.join("novelwriter", "assets", "sample.zip") + if os.path.isfile(sampleZip): + print(f"Deleted: {sampleZip}") + os.unlink(sampleZip) + + pdfManual = os.path.join("novelwriter", "assets", "manual.pdf") + if os.path.isfile(pdfManual): + print(f"Deleted: {pdfManual}") + os.unlink(pdfManual) + + i18nAssets = os.path.join("novelwriter", "assets", "i18n") + for i18nItem in os.listdir(i18nAssets): + i18nPath = os.path.join(i18nAssets, i18nItem) + if os.path.isfile(i18nPath) and i18nPath.endswith(".qm"): + print(f"Deleted: {i18nPath}") + os.unlink(i18nPath) + + print("") + + return + + +def checkAssetsExist(): + """Check that the necessary compiled assets exist ahead of a build. + """ + hasSample = False + hasManual = False + hasQmData = False + + sampleZip = os.path.join("novelwriter", "assets", "sample.zip") + if os.path.isfile(sampleZip): + print(f"Found: {sampleZip}") + hasSample = True + + pdfManual = os.path.join("novelwriter", "assets", "manual.pdf") + if os.path.isfile(pdfManual): + print(f"Found: {pdfManual}") + hasManual = True + + i18nAssets = os.path.join("novelwriter", "assets", "i18n") + for i18nItem in os.listdir(i18nAssets): + i18nPath = os.path.join(i18nAssets, i18nItem) + if os.path.isfile(i18nPath) and i18nPath.endswith(".qm"): + print(f"Found: {i18nPath}") + hasQmData = True + + return hasSample and hasManual and hasQmData + + # =============================================================================================== # # Python Packaging # =============================================================================================== # @@ -506,12 +569,12 @@ def makeMinimalPackage(targetOS): targName = "" print("") - # Build Additional Assets + # Check Additional Assets # ======================= - buildQtI18n() - buildSampleZip() - buildPdfManual() + if not checkAssetsExist(): + print("ERROR: Missing build assets") + sys.exit(1) # Build Minimal Zip # ================= @@ -603,6 +666,7 @@ def makeDebianPackage(signKey=None, sourceBuild=False, distName="unstable", buil print("") print("Build Debian Package") print("====================") + print("On Debian/Ubuntu install: dh-python python3-all debhelper devscripts") print("") # Version Info @@ -636,12 +700,12 @@ def makeDebianPackage(signKey=None, sourceBuild=False, distName="unstable", buil os.mkdir(outDir) - # Build Additional Assets + # Check Additional Assets # ======================= - buildQtI18n() - buildSampleZip() - buildPdfManual() + if not checkAssetsExist(): + print("ERROR: Missing build assets") + sys.exit(1) # Copy novelWriter Source # ======================= @@ -787,8 +851,8 @@ def makeForLaunchpad(doSign=False, isFirst=False, isSnapshot=False): distLoop = [ ("20.04", "focal"), - ("21.10", "impish"), ("22.04", "jammy"), + ("22.10", "kinetic"), ] tStamp = datetime.datetime.now().strftime("%Y%m%d~%H%M%S") @@ -835,6 +899,229 @@ def makeForLaunchpad(doSign=False, isFirst=False, isSnapshot=False): return +## +# Make AppImage (build-appimage) +## + +def makeAppImage(sysArgs): + """Build an Appimage + """ + import glob + import argparse + import platform + + try: + import python_appimage # noqa F401 + except ImportError: + print( + "ERROR: Package 'python-appimage' is missing on this system.\n" + " Please run 'pip install --user python-appimage' to install it.\n" + ) + sys.exit(1) + + print("") + print("Build AppImage") + print("==============") + print("") + + parser = argparse.ArgumentParser( + prog="build_appimage", + description="Build an AppImage", + epilog="see https://appimage.org/ for more details", + ) + parser.add_argument( + "--linux-tag", + nargs="?", + default=f"manylinux2010_{platform.machine()}", + help=( + "linux compatibility tag (e.g. manylinux1_x86_64) \n" + "see https://python-appimage.readthedocs.io/en/latest/#available-python-appimages \n" + "and https://github.com/pypa/manylinux for a list of valid tags" + ), + ) + parser.add_argument( + "--python-version", nargs="?", default="3.10", help="python version (e.g. 3.10)" + ) + + args, unparsedArgs = parser.parse_known_args(sysArgs) + + linuxTag = args.linux_tag + pythonVer = args.python_version + + # Version Info + # ============ + + numVers, _, relDate = extractVersion() + pkgVers = compactVersion(numVers) + relDate = datetime.datetime.strptime(relDate, "%Y-%m-%d") + print("") + + # Set Up Folder + # ============= + + bldDir = "dist_appimage" + bldPkg = f"novelwriter_{pkgVers}" + outDir = f"{bldDir}/{bldPkg}" + imageDir = f"{bldDir}/appimage" + + # Set Up Folders + # ============== + + if not os.path.isdir(bldDir): + os.mkdir(bldDir) + + if os.path.isdir(outDir): + print("Removing old build files ...") + print("") + shutil.rmtree(outDir) + + os.mkdir(outDir) + + if os.path.isdir(imageDir): + print("Removing old build metadata files ...") + print("") + shutil.rmtree(imageDir) + + os.mkdir(imageDir) + + # Remove old Appimages + outFiles = glob.glob(f"{bldDir}/*.AppImage") + + if outFiles: + print("Removing old AppImages") + print("") + for image in outFiles: + try: + os.remove(image) + except OSError: + print("Error while deleting file : ", image) + + # Build Additional Assets + # ======================= + + buildQtI18n() + buildSampleZip() + buildPdfManual() + + # Copy novelWriter Source + # ======================= + + print("Copying novelWriter source ...") + print("") + + for nPath, _, nFiles in os.walk("novelwriter"): + if nPath.endswith("__pycache__"): + print("Skipped: %s" % nPath) + continue + + pPath = f"{outDir}/{nPath}" + if not os.path.isdir(pPath): + os.mkdir(pPath) + + fCount = 0 + for fFile in nFiles: + nFile = f"{nPath}/{fFile}" + pFile = f"{pPath}/{fFile}" + + if fFile.endswith(".pyc"): + print("Skipped: %s" % nFile) + continue + + shutil.copyfile(nFile, pFile) + fCount += 1 + + print("Copied: %s/* [Files: %d]" % (nPath, fCount)) + + print("") + print("Copying or generating additional files ...") + print("") + + # Copy/Write Root Files + # ===================== + + copyFiles = ["LICENSE.md", "CREDITS.md", "CHANGELOG.md", "pyproject.toml"] + for copyFile in copyFiles: + shutil.copyfile(copyFile, f"{outDir}/{copyFile}") + print("Copied: %s" % copyFile) + + writeFile(f"{outDir}/MANIFEST.in", ( + "include LICENSE.md\n" + "include CREDITS.md\n" + "include CHANGELOG.md\n" + "include data/*\n" + "recursive-include novelwriter/assets *\n" + )) + print("Wrote: MANIFEST.in") + + writeFile(f"{outDir}/setup.py", ( + "import setuptools\n" + "setuptools.setup()\n" + )) + print("Wrote: setup.py") + + setupCfg = readFile("setup.cfg").replace( + "file: setup/description_pypi.md", "file: data/description_short.txt" + ) + writeFile(f"{outDir}/setup.cfg", setupCfg) + print("Wrote: setup.cfg") + + # Write Metadata + # ============== + + appDescription = readFile("setup/description_short.txt") + appdataXML = readFile("setup/novelwriter.appdata.xml").format(description=appDescription) + writeFile(f"{imageDir}/novelwriter.appdata.xml", appdataXML) + print("Wrote: novelwriter.appdata.xml") + + writeFile(f"{imageDir}/entrypoint.sh", ( + '#! /bin/bash \n' + '{{ python-executable }} -sE ${APPDIR}/opt/python{{ python-version }}/bin/novelwriter "$@"' + )) + print("Wrote: entrypoint.sh") + + writeFile(f"{imageDir}/requirements.txt", os.path.abspath(outDir)) + print("Wrote: requirements.txt") + + shutil.copyfile("setup/data/novelwriter.desktop", f"{imageDir}/novelwriter.desktop") + print("Copied: setup/data/novelwriter.desktop") + + shutil.copyfile("setup/icons/novelwriter.svg", f"{imageDir}/novelwriter.svg") + print("Copied: setup/icons/novelwriter.svg") + + shutil.copyfile( + "setup/data/hicolor/256x256/apps/novelwriter.png", f"{imageDir}/novelwriter.png" + ) + print("Copied: setup/data/hicolor/256x256/apps/novelwriter.png") + + # Build Appimage + # ============== + + try: + subprocess.call([ + sys.executable, "-m", "python_appimage", "build", "app", + "-l", linuxTag, "-p", pythonVer, "appimage" + ], cwd=bldDir) + except Exception as exc: + print("AppImage build: FAILED") + print("") + print(str(exc)) + print("") + print("Dependencies:") + print(" * pip install python-appimage") + print("") + sys.exit(1) + + bldFile = glob.glob(f"{bldDir}/*.AppImage")[0] + outFile = f"{bldDir}/novelWriter-{pkgVers}-py{pythonVer}-{linuxTag}.AppImage" + os.rename(bldFile, outFile) + shaFile = makeCheckSum(os.path.basename(outFile), cwd=bldDir) + + toUpload(outFile) + toUpload(shaFile) + + return unparsedArgs + + ## # Make Windows Setup EXE (build-win-exe) ## @@ -1056,6 +1343,13 @@ def makeWindowsEmbedded(sysArgs): print(str(exc)) sys.exit(1) + issName = os.path.join("dist", f"novelwriter-{packVersion}-win10-amd64-setup.exe") + newName = os.path.join("dist", f"novelwriter-{packVersion}-py{pyVers}-win10-amd64-setup.exe") + os.replace(issName, newName) + + print(f"Installer: {newName}") + print("") + return @@ -1566,6 +1860,7 @@ if __name__ == "__main__": " qtlupdate Update translation files for internationalisation.", " The files to be updated must be provided as arguments.", " qtlrelease Build the language files for internationalisation.", + " clean-assets Delete assets built by manual, sample and qtlrelease.", "", "Python Packaging:", "", @@ -1581,6 +1876,8 @@ if __name__ == "__main__": " Add --snapshot to make a snapshot package.", " build-win-exe Build a setup.exe file with Python embedded for Windows.", " The package must be built from a minimal windows zip file.", + " build-appimage Build an AppImage. Argument --linux-tag defaults to", + " manylinux1_x86_64 / i386, and --python-version to 3.10.", "", "System Install:", "", @@ -1642,6 +1939,10 @@ if __name__ == "__main__": sys.argv.remove("sample") buildSampleZip() + if "clean-assets" in sys.argv: + sys.argv.remove("clean-assets") + cleanBuiltAssets() + # Python Packaging # ================ @@ -1679,6 +1980,14 @@ if __name__ == "__main__": makeWindowsEmbedded(sys.argv) sys.exit(0) # Don't continue execution + if "build-appimage" in sys.argv: + sys.argv.remove("build-appimage") + if hostOS == OS_LINUX: + sys.argv = makeAppImage(sys.argv) # Build appimage and prune its args + else: + print("ERROR: Command 'build-appimage' can only be used on Linux") + sys.exit(1) + # General Installers # ================== diff --git a/setup/data/novelwriter.desktop b/setup/data/novelwriter.desktop index 2666fa13..8b140ffc 100644 --- a/setup/data/novelwriter.desktop +++ b/setup/data/novelwriter.desktop @@ -1,10 +1,9 @@ [Desktop Entry] Type=Application -Encoding=UTF-8 Name=novelWriter Comment=A markdown-like text editor for planning and writing novels Exec=novelwriter %f Icon=novelwriter Categories=Qt;Office;WordProcessor; Terminal=false -MimeType=application/x-novelwriter-project +MimeType=application/x-novelwriter-project; diff --git a/setup/debian/control b/setup/debian/control index e2d40f90..bbcb49d7 100644 --- a/setup/debian/control +++ b/setup/debian/control @@ -2,14 +2,14 @@ Source: novelwriter Maintainer: Veronica Berglyd Olsen Section: text Priority: optional -Build-Depends: dh-python, python3-setuptools, python3-all, debhelper (>= 9), python3 (>=3.7), python3-pyqt5 (>= 5.3), python3-lxml (>= 4.0), python3-enchant (>= 2.0) +Build-Depends: dh-python, python3-setuptools, python3-all, debhelper (>= 9), python3 (>=3.7), python3-pyqt5 (>= 5.10), python3-lxml (>= 4.0), python3-enchant (>= 2.0) Standards-Version: 4.5.1 Homepage: https://novelwriter.io X-Python3-Version: >= 3.7 Package: novelwriter Architecture: all -Depends: ${misc:Depends}, ${python3:Depends}, python3 (>=3.7), python3-pyqt5 (>= 5.3), python3-lxml (>= 4.0), python3-enchant (>= 2.0) +Depends: ${misc:Depends}, ${python3:Depends}, python3 (>=3.7), python3-pyqt5 (>= 5.10), python3-lxml (>= 4.0), python3-enchant (>= 2.0) Description: A markdown-like text editor for planning and writing novels novelWriter is a plain text editor designed for writing novels assembled from many smaller text documents. It uses a minimal formatting syntax inspired by diff --git a/setup/make_release.sh b/setup/make_release.sh index be75b962..98b142e6 100755 --- a/setup/make_release.sh +++ b/setup/make_release.sh @@ -6,6 +6,13 @@ if [ ! -f setup.py ]; then exit 1 fi +echo "" +echo " Building Dependencies" +echo "================================================================================" +echo "" +python3 setup.py clean-assets +python3 setup.py qtlrelease manual sample + echo "" echo " Building Minimal Packages" echo "================================================================================" diff --git a/setup/make_snapshot.sh b/setup/make_snapshot.sh new file mode 100755 index 00000000..371996a0 --- /dev/null +++ b/setup/make_snapshot.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -e + +if [ ! -f setup.py ]; then + echo "Must be called from the root folder of the source" + exit 1 +fi + +echo "" +echo " Building Dependencies" +echo "================================================================================" +echo "" +python3 setup.py clean-assets +python3 setup.py qtlrelease manual sample + +echo "" +echo " Building Linux Snapshots" +echo "================================================================================" +echo "" +python3 setup.py build-ubuntu --sign --snapshot diff --git a/setup/novelwriter.appdata.xml b/setup/novelwriter.appdata.xml new file mode 100644 index 00000000..98a6e33b --- /dev/null +++ b/setup/novelwriter.appdata.xml @@ -0,0 +1,21 @@ + + + novelwriter + GPL-3.0 + GPL-3.0 + novelWriter + A markdown-like text editor for planning and writing novels + +

{description}

+
+ novelwriter.desktop + https://novelwriter.io/ + + + https://novelwriter.io/images/screenshot-multi.png + + + + novelwriter.desktop + +
\ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 769f855c..39cc96cd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,15 +19,16 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import os import sys import pytest import shutil +from pathlib import Path + from mock import MockGuiMain from tools import cleanProject -sys.path.insert(1, os.path.abspath(os.path.join(os.path.dirname(__file__), os.path.pardir))) +sys.path.insert(1, str(Path(__file__).parent.parent.absolute())) import novelwriter # noqa: E402 @@ -48,71 +49,53 @@ def initQt(qtbot): ## @pytest.fixture(scope="session") -def tmpDir(): - """A temporary folder for the test session. This folder is - presistent after the test so that the status of generated files can - be checked. The folder is instead cleared before a new test session. +def tmpPath(): + """A temporary folder for the test session. Path version. """ - testDir = os.path.dirname(__file__) - theDir = os.path.join(testDir, "temp") - if os.path.isdir(theDir): - shutil.rmtree(theDir) - if not os.path.isdir(theDir): - os.mkdir(theDir) - return theDir + theTemp = Path(__file__).parent / "temp" + if theTemp.exists(): + shutil.rmtree(theTemp) + theTemp.mkdir(exist_ok=True) + return theTemp @pytest.fixture(scope="session") -def refDir(): - """The folder where all the reference files are stored for verifying - the results of tests. +def tstPaths(tmpPath): + """Returns an object that can provide the various paths needed for + running tests. """ - testDir = os.path.dirname(__file__) - theDir = os.path.join(testDir, "reference") - return theDir + class _Store: + testDir = Path(__file__).parent + filesDir = testDir / "files" + refDir = testDir / "reference" + outDir = tmpPath / "results" + store = _Store() + store.outDir.mkdir(exist_ok=True) -@pytest.fixture(scope="session") -def filesDir(): - """The folder where additional test files are stored. - """ - testDir = os.path.dirname(__file__) - theDir = os.path.join(testDir, "files") - return theDir - - -@pytest.fixture(scope="session") -def outDir(tmpDir): - """An output folder for test results - """ - theDir = os.path.join(tmpDir, "results") - if not os.path.isdir(theDir): - os.mkdir(theDir) - return theDir + return store @pytest.fixture(scope="function") -def fncDir(tmpDir): - """A temporary folder for a single test function. +def fncPath(tmpPath): + """A temporary folder for a single test function. Path version. """ - fncDir = os.path.join(tmpDir, "f_temp") - if os.path.isdir(fncDir): - shutil.rmtree(fncDir) - if not os.path.isdir(fncDir): - os.mkdir(fncDir) - return fncDir + fncPath = tmpPath / "function" + if fncPath.is_dir(): + shutil.rmtree(fncPath) + fncPath.mkdir(exist_ok=True) + return fncPath @pytest.fixture(scope="function") -def fncProj(fncDir): +def projPath(fncPath): """A temporary folder for a single test function, with a project folder. """ - prjDir = os.path.join(fncDir, "project") - if os.path.isdir(prjDir): + prjDir = fncPath / "project" + if prjDir.exists(): shutil.rmtree(prjDir) - if not os.path.isdir(prjDir): - os.mkdir(prjDir) + prjDir.mkdir(exist_ok=True) return prjDir @@ -121,30 +104,30 @@ def fncProj(fncDir): ## @pytest.fixture(scope="function") -def tmpConf(tmpDir): +def tmpConf(tmpPath): """Create a temporary novelWriter configuration object. """ - confFile = os.path.join(tmpDir, "novelwriter.conf") - if os.path.isfile(confFile): - os.unlink(confFile) + confFile = tmpPath / "novelwriter.conf" + if confFile.is_file(): + confFile.unlink() theConf = Config() - theConf.initConfig(tmpDir, tmpDir) - theConf.setLastPath("") - theConf.guiLang = "en_GB" + theConf.initConfig(tmpPath, tmpPath) + theConf.setLastPath(tmpPath) + theConf.guiLocale = "en_GB" return theConf @pytest.fixture(scope="function") -def fncConf(fncDir): +def fncConf(fncPath): """Create a temporary novelWriter configuration object. """ - confFile = os.path.join(fncDir, "novelwriter.conf") - if os.path.isfile(confFile): - os.unlink(confFile) + confFile = fncPath / "novelwriter.conf" + if confFile.is_file(): + confFile.unlink() theConf = Config() - theConf.initConfig(fncDir, fncDir) - theConf.setLastPath("") - theConf.guiLang = "en_GB" + theConf.initConfig(fncPath, fncPath) + theConf.setLastPath(fncPath) + theConf.guiLocale = "en_GB" return theConf @@ -159,17 +142,21 @@ def mockGUI(monkeypatch, tmpConf): @pytest.fixture(scope="function") -def nwGUI(qtbot, monkeypatch, fncDir, fncConf): +def nwGUI(qtbot, monkeypatch, fncPath, fncConf): """Create an instance of the novelWriter GUI. """ - monkeypatch.setattr(QMessageBox, "warning", lambda *a: QMessageBox.Yes) + monkeypatch.setattr(QMessageBox, "warning", lambda *a: QMessageBox.Ok) + monkeypatch.setattr(QMessageBox, "critical", lambda *a: QMessageBox.Ok) + monkeypatch.setattr(QMessageBox, "information", lambda *a: QMessageBox.Ok) + monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) + monkeypatch.setattr("novelwriter.CONFIG", fncConf) - nwGUI = novelwriter.main(["--testmode", "--config=%s" % fncDir, "--data=%s" % fncDir]) + nwGUI = novelwriter.main(["--testmode", f"--config={fncPath}", f"--data={fncPath}"]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.wait(20) - nwGUI.mainConf.lastPath = fncDir + nwGUI.mainConf.setLastPath(fncPath) yield nwGUI @@ -190,14 +177,20 @@ def mockRnd(monkeypatch): from 0. This one will generate status/importance flags and handles in a predictable sequence. """ - def rnd(n): - for x in range(n): - yield x + class MockRnd: - gen = rnd(1000) - monkeypatch.setattr("random.getrandbits", lambda *a: next(gen)) + def __init__(self): + self.reset() - return + def _rnd(self, n): + for x in range(n): + yield x + + def reset(self): + gen = self._rnd(1000) + monkeypatch.setattr("random.getrandbits", lambda *a: next(gen)) + + return MockRnd() ## @@ -205,35 +198,36 @@ def mockRnd(monkeypatch): ## @pytest.fixture(scope="function") -def nwMinimal(tmpDir): - """A minimal novelWriter example project. - """ - tstDir = os.path.dirname(__file__) - srcDir = os.path.join(tstDir, "minimal") - dstDir = os.path.join(tmpDir, "minimal") - if os.path.isdir(dstDir): - shutil.rmtree(dstDir) - - shutil.copytree(srcDir, dstDir) - cleanProject(dstDir) - - yield dstDir - - if os.path.isdir(dstDir): - shutil.rmtree(dstDir) - - return - - -@pytest.fixture(scope="function") -def nwLipsum(tmpDir): +def nwLipsum(tmpPath): """A medium sized novelWriter example project with a lot of Lorem Ipsum text. """ - tstDir = os.path.dirname(__file__) - srcDir = os.path.join(tstDir, "lipsum") - dstDir = os.path.join(tmpDir, "lipsum") - if os.path.isdir(dstDir): + tstDir = Path(__file__).parent + srcDir = tstDir / "lipsum" + dstDir = tmpPath / "lipsum" + if dstDir.exists(): + shutil.rmtree(dstDir) + + shutil.copytree(srcDir, dstDir) + cleanProject(dstDir) + + yield str(dstDir) + + if dstDir.exists(): + shutil.rmtree(dstDir) + + return + + +@pytest.fixture(scope="function") +def prjLipsum(tmpPath): + """A medium sized novelWriter example project with a lot of Lorem + Ipsum text. + """ + tstDir = Path(__file__).parent + srcDir = tstDir / "lipsum" + dstDir = tmpPath / "lipsum" + if dstDir.exists(): shutil.rmtree(dstDir) shutil.copytree(srcDir, dstDir) @@ -241,28 +235,7 @@ def nwLipsum(tmpDir): yield dstDir - if os.path.isdir(dstDir): - shutil.rmtree(dstDir) - - return - - -@pytest.fixture(scope="function") -def nwOldProj(tmpDir): - """A minimal movelWriter project using the old folder structure used - for storage versions < 1.2. - """ - tstDir = os.path.dirname(__file__) - srcDir = os.path.join(tstDir, "oldproj") - dstDir = os.path.join(tmpDir, "oldproj") - if os.path.isdir(dstDir): - shutil.rmtree(dstDir) - - shutil.copytree(srcDir, dstDir) - - yield dstDir - - if os.path.isdir(dstDir): + if dstDir.exists(): shutil.rmtree(dstDir) return diff --git a/tests/files/nwProject-1.0.nwx b/tests/files/nwProject-1.0.nwx new file mode 100644 index 00000000..6d1b1990 --- /dev/null +++ b/tests/files/nwProject-1.0.nwx @@ -0,0 +1,299 @@ + + + + Sample Project + Sample Project + Jane Smith + Jay Doh + False + + + True + True + 636b6aa9b697b + 636b6aa9b697b + 914 + + B + E + D + + + %title% + Chapter %ch%: %title% + %title% + Scene %ch%.%sc%: %title% +
+ True + True + False +
+ + New + Notes + Started + 1st Draft + 2nd Draft + 3rd Draft + Finished + + + None + Minor + Major + Main + +
+ + + Novel + ROOT + NOVEL + Started + True + + + Title Page + FILE + NOVEL + Started + False + True + TITLE + 72 + 15 + 2 + 78 + + + Page + FILE + NOVEL + New + False + True + PAGE + 208 + 40 + 2 + 213 + + + Part One + FILE + NOVEL + New + False + True + PARTITION + 23 + 5 + 1 + 0 + + + A Folder + FOLDER + NOVEL + 1st Draft + True + + + Chapter One + FILE + NOVEL + Notes + False + True + CHAPTER + 12 + 3 + 0 + 215 + + + Making a Scene + FILE + NOVEL + 1st Draft + False + True + SCENE + 1199 + 216 + 7 + 527 + + + Another Scene + FILE + NOVEL + 1st Draft + False + True + SCENE + 476 + 93 + 3 + 551 + + + Interlude + FILE + NOVEL + Finished + False + True + UNNUMBERED + 633 + 101 + 3 + 1238 + + + A Note on Structure + FILE + NOVEL + 2nd Draft + False + False + NOTE + 1692 + 313 + 6 + 1721 + + + Chapter Two + FILE + NOVEL + 1st Draft + False + True + CHAPTER + 139 + 28 + 1 + 343 + + + We Found John! + FILE + NOVEL + 1st Draft + False + True + SCENE + 189 + 37 + 1 + 224 + + + Characters + ROOT + CHARACTER + None + True + + + Main Characters + FOLDER + CHARACTER + None + True + + + John Smith + FILE + CHARACTER + Minor + False + True + NOTE + 49 + 9 + 1 + 24 + + + Jane Smith + FILE + CHARACTER + Major + False + True + NOTE + 55 + 9 + 1 + 25 + + + Locations + ROOT + WORLD + None + True + + + Earth + FILE + WORLD + Main + False + True + NOTE + 76 + 15 + 1 + 20 + + + Space + FILE + WORLD + Minor + False + True + NOTE + 115 + 24 + 1 + 133 + + + Mars + FILE + WORLD + Major + False + True + NOTE + 28 + 6 + 1 + 45 + + + Trash + TRASH + TRASH + None + True + + + Delete Me! + FILE + NOVEL + New + False + True + SCENE + 0 + 0 + 0 + 36 + + +
diff --git a/tests/files/nwProject-1.1.nwx b/tests/files/nwProject-1.1.nwx new file mode 100644 index 00000000..d31ab3ff --- /dev/null +++ b/tests/files/nwProject-1.1.nwx @@ -0,0 +1,283 @@ + + + + Sample Project + Sample Project + Jane Smith + Jay Doh + 5 + 10 + 1000 + + + True + True + True + 636b6aa9b697b + bb2c23b3c42cc + 967 + + B + E + D + + + %title% + Chapter %ch%: %title% + %title% + Scene %ch%.%sc%: %title% +
+
+ + New + Notes + Started + 1st Draft + 2nd Draft + 3rd Draft + Finished + + + None + Minor + Major + Main + +
+ + + Novel + ROOT + NOVEL + Started + True + + + Title Page + FILE + NOVEL + Started + True + TITLE + 72 + 15 + 2 + 78 + + + Page + FILE + NOVEL + New + True + PAGE + 210 + 40 + 2 + 213 + + + Part One + FILE + NOVEL + New + True + PARTITION + 23 + 5 + 1 + 0 + + + A Folder + FOLDER + NOVEL + 1st Draft + True + + + Chapter One + FILE + NOVEL + Notes + True + CHAPTER + 12 + 3 + 0 + 215 + + + Making a Scene + FILE + NOVEL + 1st Draft + True + SCENE + 1483 + 263 + 8 + 1086 + + + Another Scene + FILE + NOVEL + 1st Draft + True + SCENE + 476 + 93 + 3 + 428 + + + Interlude + FILE + NOVEL + Finished + True + UNNUMBERED + 633 + 101 + 3 + 1238 + + + A Note on Structure + FILE + NOVEL + 2nd Draft + False + NOTE + 1692 + 313 + 6 + 1721 + + + Chapter Two + FILE + NOVEL + 1st Draft + True + CHAPTER + 139 + 28 + 1 + 343 + + + We Found John! + FILE + NOVEL + 1st Draft + True + SCENE + 189 + 37 + 1 + 224 + + + Characters + ROOT + CHARACTER + None + True + + + Main Characters + FOLDER + CHARACTER + None + True + + + John Smith + FILE + CHARACTER + Minor + True + NOTE + 49 + 9 + 1 + 24 + + + Jane Smith + FILE + CHARACTER + Major + True + NOTE + 55 + 9 + 1 + 25 + + + Locations + ROOT + WORLD + None + True + + + Earth + FILE + WORLD + Main + True + NOTE + 76 + 15 + 1 + 20 + + + Space + FILE + WORLD + Minor + True + NOTE + 115 + 24 + 1 + 133 + + + Mars + FILE + WORLD + Major + True + NOTE + 28 + 6 + 1 + 45 + + + Trash + TRASH + TRASH + None + True + + + Delete Me! + FILE + NOVEL + New + True + SCENE + 30 + 6 + 1 + 36 + + +
diff --git a/tests/files/nwProject-1.2.nwx b/tests/files/nwProject-1.2.nwx new file mode 100644 index 00000000..e511afc9 --- /dev/null +++ b/tests/files/nwProject-1.2.nwx @@ -0,0 +1,313 @@ + + + + Sample Project + Sample Project + Jane Smith + Jay Doh + 5 + 10 + 1000 + + + True + en_GB + True + en_GB + True + 636b6aa9b697b + 636b6aa9b697b + 1216 + 840 + 376 + + B + E + D + + + %title% + Chapter %chw%: %title% + %title% + Scene %ch%.%sc%: %title% +
+
+ + New + Notes + Started + 1st Draft + 2nd Draft + 3rd Draft + Finished + + + None + Minor + Major + Main + +
+ + + Novel + ROOT + NOVEL + Started + True + + + Title Page + FILE + NOVEL + Started + True + TITLE + 241 + 42 + 3 + 252 + + + Page + FILE + NOVEL + New + True + PAGE + 125 + 26 + 2 + 127 + + + Part One + FILE + NOVEL + New + True + PARTITION + 26 + 6 + 1 + 30 + + + A Folder + FOLDER + NOVEL + 1st Draft + True + + + Chapter One + FILE + NOVEL + Notes + True + CHAPTER + 75 + 14 + 1 + 279 + + + Making a Scene + FILE + NOVEL + 1st Draft + True + SCENE + 2429 + 432 + 14 + 61 + + + Another Scene + FILE + NOVEL + 1st Draft + True + SCENE + 476 + 93 + 3 + 577 + + + Interlude + FILE + NOVEL + New + True + UNNUMBERED + 617 + 101 + 3 + 1137 + + + A Note on Structure + FILE + NOVEL + 2nd Draft + False + NOTE + 1692 + 313 + 6 + 1110 + + + Chapter Two + FILE + NOVEL + 1st Draft + True + CHAPTER + 139 + 28 + 1 + 343 + + + We Found John! + FILE + NOVEL + 1st Draft + True + SCENE + 189 + 37 + 1 + 224 + + + Characters + ROOT + CHARACTER + None + True + + + Main Characters + FOLDER + CHARACTER + None + True + + + John Smith + FILE + CHARACTER + Minor + True + NOTE + 49 + 9 + 1 + 24 + + + Jane Smith + FILE + CHARACTER + Major + True + NOTE + 55 + 9 + 1 + 25 + + + Locations + ROOT + WORLD + None + True + + + Earth + FILE + WORLD + Main + True + NOTE + 76 + 15 + 1 + 20 + + + Space + FILE + WORLD + Minor + True + NOTE + 115 + 24 + 1 + 133 + + + Mars + FILE + WORLD + Major + True + NOTE + 28 + 6 + 1 + 45 + + + Outtakes + ROOT + ARCHIVE + None + True + + + Scenes + FOLDER + ARCHIVE + None + True + + + Old File + FILE + NOVEL + 1st Draft + True + SCENE + 315 + 55 + 1 + 322 + + + Trash + TRASH + TRASH + None + True + + + Delete Me! + FILE + NOVEL + New + True + SCENE + 30 + 6 + 1 + 36 + + +
diff --git a/tests/files/nwProject-1.3.nwx b/tests/files/nwProject-1.3.nwx new file mode 100644 index 00000000..6c1d3a2e --- /dev/null +++ b/tests/files/nwProject-1.3.nwx @@ -0,0 +1,313 @@ + + + + Sample Project + Sample Project + Jane Smith + Jay Doh + 5 + 10 + 1000 + + + True + en_GB + True + en_GB + True + 636b6aa9b697b + 636b6aa9b697b + 1206 + 830 + 376 + + B + E + D + + + %title% + Chapter %chw%: %title% + %title% + Scene %ch%.%sc%: %title% +
+
+ + New + Notes + Started + 1st Draft + 2nd Draft + 3rd Draft + Finished + + + None + Minor + Major + Main + +
+ + + Novel + ROOT + NOVEL + Started + True + + + Title Page + FILE + NOVEL + Started + True + DOCUMENT + 93 + 19 + 2 + 2 + + + Page + FILE + NOVEL + New + True + DOCUMENT + 186 + 39 + 2 + 212 + + + Part One + FILE + NOVEL + New + True + DOCUMENT + 26 + 6 + 1 + 33 + + + A Folder + FOLDER + NOVEL + 1st Draft + True + + + Chapter One + FILE + NOVEL + Notes + True + DOCUMENT + 75 + 14 + 1 + 279 + + + Making a Scene + FILE + NOVEL + 1st Draft + True + DOCUMENT + 2429 + 432 + 14 + 62 + + + Another Scene + FILE + NOVEL + 1st Draft + True + DOCUMENT + 476 + 93 + 3 + 577 + + + Interlude + FILE + NOVEL + New + True + DOCUMENT + 617 + 101 + 3 + 4 + + + A Note on Structure + FILE + NOVEL + 2nd Draft + False + NOTE + 1692 + 313 + 6 + 1110 + + + Chapter Two + FILE + NOVEL + 1st Draft + True + DOCUMENT + 139 + 28 + 1 + 343 + + + We Found John! + FILE + NOVEL + 1st Draft + True + DOCUMENT + 189 + 37 + 1 + 224 + + + Characters + ROOT + CHARACTER + None + True + + + Main Characters + FOLDER + CHARACTER + None + True + + + John Smith + FILE + CHARACTER + Minor + True + NOTE + 49 + 9 + 1 + 24 + + + Jane Smith + FILE + CHARACTER + Major + True + NOTE + 55 + 9 + 1 + 25 + + + Locations + ROOT + WORLD + None + True + + + Earth + FILE + WORLD + Main + True + NOTE + 76 + 15 + 1 + 20 + + + Space + FILE + WORLD + Minor + True + NOTE + 115 + 24 + 1 + 133 + + + Mars + FILE + WORLD + Major + True + NOTE + 28 + 6 + 1 + 45 + + + Archive + ROOT + ARCHIVE + New + True + + + Scenes + FOLDER + ARCHIVE + New + True + + + Old File + FILE + NOVEL + 1st Draft + True + DOCUMENT + 314 + 55 + 1 + 322 + + + Trash + TRASH + TRASH + None + True + + + Delete Me! + FILE + NOVEL + New + True + DOCUMENT + 30 + 6 + 1 + 36 + + +
diff --git a/tests/files/nwProject-1.4.nwx b/tests/files/nwProject-1.4.nwx new file mode 100644 index 00000000..85ff2f63 --- /dev/null +++ b/tests/files/nwProject-1.4.nwx @@ -0,0 +1,162 @@ + + + + Sample Project + Sample Project + Jane Smith + Jay Doh + 5 + 10 + 1000 + + + True + en_GB + True + en_GB + 636b6aa9b697b + 636b6aa9b697b + 7031beac91f75 + 7031beac91f75 + 1363 + 954 + 409 + + B + E + D + + + %title% + Chapter %chw%: %title% + %title% + Scene %ch%.%sc%: %title% +
+
+ + New + Notes + Started + 1st Draft + 2nd Draft + 3rd Draft + Finished + + + None + Minor + Major + Main + +
+ + + + Novel + + + + Title Page + + + + Page + + + + Part One + + + + Chapter One + + + + Making a Scene + + + + Another Scene + + + + Interlude + + + + A Note on Structure + + + + Chapter Two + + + + We Found John! + + + + Sequel + + + + Title Page + + + + Chapter One + + + + Characters + + + + Main Characters + + + + John Smith + + + + Jane Smith + + + + Locations + + + + Earth + + + + Space + + + + Mars + + + + Archive + + + + Scenes + + + + Old File + + + + Trash + + + + Delete Me! + + +
diff --git a/tests/files/nwProject-1.5.nwx b/tests/files/nwProject-1.5.nwx new file mode 100644 index 00000000..ec8668f4 --- /dev/null +++ b/tests/files/nwProject-1.5.nwx @@ -0,0 +1,157 @@ + + + + Sample Project + Sample Project + Jane Smith + Jay Doh + + + yes + en_GB + en_GB + + 636b6aa9b697b + 636b6aa9b697b + 7031beac91f75 + 7031beac91f75 + + + B + E + D + + + %title% + Chapter %chw%: %title% + %title% + Scene %ch%.%sc%: %title% + + + + New + Notes + Started + 1st Draft + 2nd Draft + 3rd Draft + Finished + + + None + Minor + Major + Main + + + + + + Novel + + + + Title Page + + + + Page + + + + Part One + + + + Chapter One + + + + Making a Scene + + + + Another Scene + + + + Interlude + + + + A Note on Structure + + + + Chapter Two + + + + We Found John! + + + + Sequel + + + + Title Page + + + + Chapter One + + + + Characters + + + + Main Characters + + + + John Smith + + + + Jane Smith + + + + Locations + + + + Earth + + + + Space + + + + Mars + + + + Archive + + + + Scenes + + + + Old File + + + + Trash + + + + Delete Me! + + + diff --git a/tests/lipsum/nwProject.nwx b/tests/lipsum/nwProject.nwx index a8cf6393..086183c6 100644 --- a/tests/lipsum/nwProject.nwx +++ b/tests/lipsum/nwProject.nwx @@ -1,36 +1,30 @@ - - + + Lorem Ipsum Lorem Ipsum lipsum.com - 26 - 24 - 1863 - False + no en_GB - False - None - True - 7a992350f3eb6 - None - None - None - 3847 - 3109 - 738 + None + + 7a992350f3eb6 + None + b3643d0f92e32 + None + Replace Text 1 Replace Text 2 - %title% - Chapter %ch%: %title% - %title% - * * * -
+ %title% + Chapter %ch%: %title% + %title% + * * * +
New @@ -45,90 +39,90 @@ Main
- + - + Novel - - Lorem Ipsum + + Lorem Ipsum - - Front Matter + + Front Matter - - Prologue + + Prologue - - Act One + + Act One - + Chapter One - - Chapter One + + Chapter One - - Scene One + + Scene One - - Scene Two + + Scene Two - - Interlude + + Interlude - + Chapter Two - - Chapter Two + + Chapter Two - - Scene Three + + Scene Three - - Scene Four + + Scene Four - - Scene Five + + Scene Five - + Characters - - Mr. Nobody + + Mr. Nobody - + Plot - - Main + + Main - + World - - Ancient Europe + + Ancient Europe
diff --git a/tests/minimal/content/8c659a11cd429.nwd b/tests/minimal/content/8c659a11cd429.nwd deleted file mode 100644 index 2d7072eb..00000000 --- a/tests/minimal/content/8c659a11cd429.nwd +++ /dev/null @@ -1,5 +0,0 @@ -%%~name: New Scene -%%~path: a6d311a93600a/8c659a11cd429 -%%~kind: NOVEL/DOCUMENT -### New Scene - diff --git a/tests/minimal/content/a35baf2e93843.nwd b/tests/minimal/content/a35baf2e93843.nwd deleted file mode 100644 index b328746c..00000000 --- a/tests/minimal/content/a35baf2e93843.nwd +++ /dev/null @@ -1,6 +0,0 @@ -%%~name: Title Page -%%~path: a508bb932959c/a35baf2e93843 -%%~kind: NOVEL/DOCUMENT -#! Minimal - ->> By Jane Doe, John Doh << diff --git a/tests/minimal/content/f5ab3e30151e1.nwd b/tests/minimal/content/f5ab3e30151e1.nwd deleted file mode 100644 index b4bbf9c6..00000000 --- a/tests/minimal/content/f5ab3e30151e1.nwd +++ /dev/null @@ -1,5 +0,0 @@ -%%~name: New Chapter -%%~path: a6d311a93600a/f5ab3e30151e1 -%%~kind: NOVEL/DOCUMENT -## New Chapter - diff --git a/tests/minimal/nwProject.nwx b/tests/minimal/nwProject.nwx deleted file mode 100644 index 430ecf17..00000000 --- a/tests/minimal/nwProject.nwx +++ /dev/null @@ -1,80 +0,0 @@ - - - - Test Minimal - Minimal - Jane Doe - John Doh - 17 - 2 - 150 - - - True - en_GB - False - None - True - None - None - None - None - 10 - 10 - 0 - - - %title% - Chapter %ch%: %title% - %title% - * * * -
-
- - New - Note - Draft - Finished - - - New - Minor - Major - Main - -
- - - - Novel - - - - Title Page - - - - New Chapter - - - - New Chapter - - - - New Scene - - - - Plot - - - - Characters - - - - World - - -
diff --git a/tests/mock.py b/tests/mock.py index 23938d41..e70034bd 100644 --- a/tests/mock.py +++ b/tests/mock.py @@ -19,18 +19,23 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ +from PyQt5.QtCore import QObject + # =========================================================================== # # Mock GUI # =========================================================================== # -class MockGuiMain(): +class MockGuiMain(QObject): def __init__(self): + super().__init__() + self.mainConf = None self.hasProject = True self.theProject = None - self.statusBar = MockStatusBar() + self.mainStatus = MockStatusBar() + self.projPath = "" # Test Variables self.askResponse = True @@ -39,7 +44,7 @@ class MockGuiMain(): return - def releaseNotes(self): + def postLaunchTasks(self, cmdOpen): return def makeAlert(self, message, level=0, exception=None): @@ -57,6 +62,7 @@ class MockGuiMain(): return def openProject(self, projPath): + self.projPath = projPath return def rebuildIndex(self): @@ -81,7 +87,7 @@ class MockGuiMain(): # END Class MockGuiMain -class MockStatusBar(): +class MockStatusBar: def __init__(self): return diff --git a/tests/oldproj/data_1/9752e7f9d8af_main.nwd b/tests/oldproj/data_1/9752e7f9d8af_main.nwd deleted file mode 100644 index 5b25ad52..00000000 --- a/tests/oldproj/data_1/9752e7f9d8af_main.nwd +++ /dev/null @@ -1,4 +0,0 @@ -### Scene Four - -Scene Four - diff --git a/tests/oldproj/data_7/ff63b8afc4cd_main.nwd b/tests/oldproj/data_7/ff63b8afc4cd_main.nwd deleted file mode 100644 index 818712f7..00000000 --- a/tests/oldproj/data_7/ff63b8afc4cd_main.nwd +++ /dev/null @@ -1,4 +0,0 @@ -# Antagonist - -Antagonist - diff --git a/tests/oldproj/data_8/8124a4292d8b_main.nwd b/tests/oldproj/data_8/8124a4292d8b_main.nwd deleted file mode 100644 index 5fe1d9fe..00000000 --- a/tests/oldproj/data_8/8124a4292d8b_main.nwd +++ /dev/null @@ -1,4 +0,0 @@ -### Scene Two - -Scene Two - diff --git a/tests/oldproj/data_9/058ae29f0dfd_main.nwd b/tests/oldproj/data_9/058ae29f0dfd_main.nwd deleted file mode 100644 index 79e4dc06..00000000 --- a/tests/oldproj/data_9/058ae29f0dfd_main.nwd +++ /dev/null @@ -1,4 +0,0 @@ -# Protagonist - -Protagonist - diff --git a/tests/oldproj/data_9/1239bf2f8b69_main.nwd b/tests/oldproj/data_9/1239bf2f8b69_main.nwd deleted file mode 100644 index 2d701cd4..00000000 --- a/tests/oldproj/data_9/1239bf2f8b69_main.nwd +++ /dev/null @@ -1,4 +0,0 @@ -### Scene Three - -Scene Three - diff --git a/tests/oldproj/data_a/764d5acf5a21_main.nwd b/tests/oldproj/data_a/764d5acf5a21_main.nwd deleted file mode 100644 index 7ef7c622..00000000 --- a/tests/oldproj/data_a/764d5acf5a21_main.nwd +++ /dev/null @@ -1,4 +0,0 @@ -### Scene Five - -Scene Five - diff --git a/tests/oldproj/data_f/528d831f5b24_main.nwd b/tests/oldproj/data_f/528d831f5b24_main.nwd deleted file mode 100644 index 8fecdb8e..00000000 --- a/tests/oldproj/data_f/528d831f5b24_main.nwd +++ /dev/null @@ -1,4 +0,0 @@ -### Scene One - -Scene One - diff --git a/tests/oldproj/meta/sessionInfo.log b/tests/oldproj/meta/sessionInfo.log deleted file mode 100644 index 92da5e9a..00000000 --- a/tests/oldproj/meta/sessionInfo.log +++ /dev/null @@ -1,2 +0,0 @@ -Start: 2020-09-26 16:13:00 End: 2020-09-26 16:15:54 Words: 24 -Start: 2020-09-26 16:16:28 End: 2020-09-26 16:16:40 Words: -1 diff --git a/tests/oldproj/meta/tagsIndex.json b/tests/oldproj/meta/tagsIndex.json deleted file mode 100644 index d02515f6..00000000 --- a/tests/oldproj/meta/tagsIndex.json +++ /dev/null @@ -1,72 +0,0 @@ -{ - "tagIndex": {}, - "refIndex": { - "f528d831f5b24": [], - "88124a4292d8b": [], - "91239bf2f8b69": [], - "19752e7f9d8af": [], - "a764d5acf5a21": [], - "9058ae29f0dfd": [], - "7ff63b8afc4cd": [] - }, - "novelIndex": { - "f528d831f5b24": [ - [ - 1, - 3, - "Scene One", - "SCENE" - ] - ], - "88124a4292d8b": [ - [ - 1, - 3, - "Scene Two", - "SCENE" - ] - ], - "91239bf2f8b69": [ - [ - 1, - 3, - "Scene Three", - "SCENE" - ] - ], - "19752e7f9d8af": [ - [ - 1, - 3, - "Scene Four", - "SCENE" - ] - ], - "a764d5acf5a21": [ - [ - 1, - 3, - "Scene Five", - "SCENE" - ] - ] - }, - "noteIndex": { - "9058ae29f0dfd": [ - [ - 1, - 1, - "Protagonist", - "NOTE" - ] - ], - "7ff63b8afc4cd": [ - [ - 1, - 1, - "Antagonist", - "NOTE" - ] - ] - } -} \ No newline at end of file diff --git a/tests/oldproj/nwProject.nwx b/tests/oldproj/nwProject.nwx deleted file mode 100644 index 8ba21098..00000000 --- a/tests/oldproj/nwProject.nwx +++ /dev/null @@ -1,148 +0,0 @@ - - - - - - True - - - False - a764d5acf5a21 - None - 23 - - - New - Note - Draft - Finished - - - New - Minor - Major - Main - - - - - Novel - ROOT - NOVEL - New - True - - - Chapter One - FOLDER - NOVEL - New - True - - - Scene One - FILE - NOVEL - New - False - SCENE - 18 - 4 - 1 - 3 - - - Scene Two - FILE - NOVEL - New - False - SCENE - 18 - 4 - 1 - 2 - - - Scene Three - FILE - NOVEL - New - False - SCENE - 22 - 4 - 1 - 2 - - - Scene Four - FILE - NOVEL - New - False - SCENE - 20 - 4 - 1 - 2 - - - Scene Five - FILE - NOVEL - New - False - SCENE - 20 - 4 - 1 - 2 - - - Characters - ROOT - CHARACTER - New - True - - - Protagonist - FILE - CHARACTER - New - False - NOTE - 11 - 1 - 0 - 28 - - - Antagonist - FILE - CHARACTER - New - False - NOTE - 13 - 2 - 1 - 26 - - - Plot - ROOT - PLOT - New - False - - - World - ROOT - WORLD - New - False - - - diff --git a/tests/reference/baseConfig_novelwriter.conf b/tests/reference/baseConfig_novelwriter.conf index 8018b089..185fbd50 100644 --- a/tests/reference/baseConfig_novelwriter.conf +++ b/tests/reference/baseConfig_novelwriter.conf @@ -1,36 +1,37 @@ +[Meta] +timestamp = 2022-11-11 12:48:18 + [Main] -timestamp = 2021-12-31 16:45:32 theme = default syntax = default_light -icons = typicons_light -guifont = -guifontsize = 11 -lastnotes = 0x0 -guilang = en_GB +font = +fontsize = 11 +localisation = en_GB hidevscroll = False hidehscroll = False +lastnotes = 0x0 +lastpath = /home/vkbo [Sizes] -geometry = 1200, 650 +mainwindow = 1200, 650 preferences = 700, 615 -treecols = 200, 50, 30 -novelcols = 200, 50 -projcols = 200, 60, 140 +projloadcols = 280, 60, 160 mainpane = 300, 800 -docpane = 400, 400 viewpane = 500, 150 outlinepane = 500, 150 -fullscreen = False [Project] autosaveproject = 60 autosavedoc = 30 emphlabels = True +backuppath = +backuponclose = False +askbeforebackup = True [Editor] textfont = None textsize = 12 -width = 600 +width = 700 margin = 40 tabwidth = 40 focuswidth = 800 @@ -45,8 +46,10 @@ repdots = True scrollpastend = 25 autoscroll = False autoscrollpos = 30 -fmtsinglequote = ‘, ’ -fmtdoublequote = “, ” +fmtsquoteopen = ‘ +fmtsquoteclose = ’ +fmtdquoteopen = “ +fmtdquoteclose = ” fmtpadbefore = fmtpadafter = fmtpadthin = False @@ -65,12 +68,8 @@ highlightemph = True stopwhenidle = True useridletime = 300 -[Backup] -backuppath = -backuponclose = False -askbeforebackup = True - [State] +fullscreen = False showrefpanel = True viewcomments = True viewsynopsis = True @@ -81,6 +80,3 @@ searchloop = False searchnextfile = False searchmatchcap = False -[Path] -lastpath = - diff --git a/tests/reference/coreDocTools_DocMerger_0000000000010.nwd b/tests/reference/coreDocTools_DocMerger_0000000000010.nwd new file mode 100644 index 00000000..eb13ead2 --- /dev/null +++ b/tests/reference/coreDocTools_DocMerger_0000000000010.nwd @@ -0,0 +1,33 @@ +%%~name: Chapter 1 +%%~path: 0000000000008/0000000000010 +%%~kind: NOVEL/DOCUMENT +## Chapter 1 + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc maximus justo non dictum commodo. Curabitur lacinia tempor orci vel luctus. Phasellus porta metus eu massa luctus, eget euismod risus rhoncus. Vestibulum sed arcu nisi. Maecenas pretium facilisis velit, vel semper lacus aliquam sit amet. Vestibulum vulputate neque ligula, rhoncus blandit turpis consequat id. Mauris sagittis vehicula imperdiet. Duis sed nunc pretium, ornare purus vel, sodales augue. Maecenas a suscipit risus. Quisque volutpat justo eleifend est ullamcorper fermentum. Donec ullamcorper et tortor a laoreet. Nam id risus nisi. Vivamus non imperdiet erat, sit amet imperdiet felis. Mauris vitae neque et est aliquam scelerisque non non ipsum. + +Nullam laoreet lorem nec malesuada vehicula. Vivamus tempus sodales lectus sed viverra. Aenean lacinia sollicitudin quam, quis tempus eros suscipit id. Duis sed rutrum nisi, ut pulvinar magna. Nam et cursus tortor. Phasellus ac odio tellus. Nullam in iaculis ipsum. Vivamus ante sem, ultricies sed varius quis, tristique nec tellus. Nullam eu urna vitae lacus hendrerit gravida. Quisque pulvinar erat ex, id efficitur velit sodales vitae. Proin vestibulum, sapien eget mattis euismod, tortor quam viverra risus, at congue mauris tortor eu nunc. Mauris pellentesque elit leo, quis eleifend sem placerat a. Vivamus iaculis dui eget tellus volutpat, ac varius nisi facilisis. + +% Merge Novel Scene: Scene 1.1 [New] + +### Scene 1.1 + +Nullam laoreet lorem nec malesuada vehicula. Vivamus tempus sodales lectus sed viverra. Aenean lacinia sollicitudin quam, quis tempus eros suscipit id. Duis sed rutrum nisi, ut pulvinar magna. Nam et cursus tortor. Phasellus ac odio tellus. Nullam in iaculis ipsum. Vivamus ante sem, ultricies sed varius quis, tristique nec tellus. Nullam eu urna vitae lacus hendrerit gravida. Quisque pulvinar erat ex, id efficitur velit sodales vitae. Proin vestibulum, sapien eget mattis euismod, tortor quam viverra risus, at congue mauris tortor eu nunc. Mauris pellentesque elit leo, quis eleifend sem placerat a. Vivamus iaculis dui eget tellus volutpat, ac varius nisi facilisis. + +Nullam a nisl magna. Praesent commodo nec diam aliquet vestibulum. In sapien velit, sodales feugiat porta ut, rhoncus a elit. Quisque egestas nisi eu eros laoreet, quis facilisis est pretium. Nullam bibendum sed tellus nec lobortis. Duis elit massa, volutpat a lacinia a, ullamcorper in dui. Suspendisse ac laoreet dui. Curabitur elementum, tortor elementum ultricies laoreet, nunc massa vulputate augue, vitae tincidunt nunc enim eget nisl. + +% Merge Novel Scene: Scene 1.2 [New] + +### Scene 1.2 + +Nullam a nisl magna. Praesent commodo nec diam aliquet vestibulum. In sapien velit, sodales feugiat porta ut, rhoncus a elit. Quisque egestas nisi eu eros laoreet, quis facilisis est pretium. Nullam bibendum sed tellus nec lobortis. Duis elit massa, volutpat a lacinia a, ullamcorper in dui. Suspendisse ac laoreet dui. Curabitur elementum, tortor elementum ultricies laoreet, nunc massa vulputate augue, vitae tincidunt nunc enim eget nisl. + +Pellentesque nibh urna, volutpat et feugiat porta, rutrum sed lectus. Aliquam eget risus id orci tincidunt condimentum et sit amet purus. Curabitur tincidunt odio vel ante feugiat feugiat. Proin nunc lorem, molestie a sapien et, varius elementum nunc. Donec non fermentum nisl. In et massa placerat, faucibus felis eu, congue nisi. Proin sed tortor non lorem mattis cursus. Vestibulum magna neque, bibendum vel nibh et, tincidunt rhoncus nisi. Duis pulvinar mi a quam rutrum maximus. Nunc sollicitudin, urna in cursus facilisis, augue neque imperdiet metus, ac finibus lorem ante id nulla. Sed maximus eleifend justo id feugiat. Cras eget diam vel est blandit tempor nec a leo. Mauris risus est, fringilla in aliquam a, sagittis vel enim. Nullam sodales id erat placerat lobortis. + +% Merge Novel Scene: Scene 1.3 [New] + +### Scene 1.3 + +Pellentesque nibh urna, volutpat et feugiat porta, rutrum sed lectus. Aliquam eget risus id orci tincidunt condimentum et sit amet purus. Curabitur tincidunt odio vel ante feugiat feugiat. Proin nunc lorem, molestie a sapien et, varius elementum nunc. Donec non fermentum nisl. In et massa placerat, faucibus felis eu, congue nisi. Proin sed tortor non lorem mattis cursus. Vestibulum magna neque, bibendum vel nibh et, tincidunt rhoncus nisi. Duis pulvinar mi a quam rutrum maximus. Nunc sollicitudin, urna in cursus facilisis, augue neque imperdiet metus, ac finibus lorem ante id nulla. Sed maximus eleifend justo id feugiat. Cras eget diam vel est blandit tempor nec a leo. Mauris risus est, fringilla in aliquam a, sagittis vel enim. Nullam sodales id erat placerat lobortis. + +Integer ac gravida quam. Quisque eleifend nisl nec pretium tincidunt. Quisque sollicitudin nisi in hendrerit scelerisque. Sed ornare nisl lacus, sit amet consectetur lectus egestas et. Vivamus nec arcu lorem. Donec rhoncus, purus a porta accumsan, nunc lectus iaculis libero, et fringilla tellus augue et velit. Integer varius felis scelerisque, vulputate tellus eu, laoreet justo. Suspendisse sit amet sem vehicula, auctor odio sed, aliquet enim. In ac tortor sed tortor fringilla elementum. Nulla non odio at magna vulputate scelerisque. Nam elementum diam eu rutrum scelerisque. Sed fermentum, felis quis vulputate fermentum, libero metus sollicitudin est, in faucibus purus nulla non dolor. Ut vitae felis porta, feugiat nunc et, bibendum neque. Nullam nec lorem nec metus ullamcorper malesuada ut a nisl. Etiam eget tristique dui. Nulla sed mi finibus, venenatis tellus non, maximus enim. + diff --git a/tests/reference/coreDocTools_DocMerger_0000000000014.nwd b/tests/reference/coreDocTools_DocMerger_0000000000014.nwd new file mode 100644 index 00000000..199454dc --- /dev/null +++ b/tests/reference/coreDocTools_DocMerger_0000000000014.nwd @@ -0,0 +1,35 @@ +%%~name: All of Chapter 1 +%%~path: 0000000000008/0000000000014 +%%~kind: NOVEL/DOCUMENT +% Merge Novel Chapter: Chapter 1 [New] + +## Chapter 1 + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc maximus justo non dictum commodo. Curabitur lacinia tempor orci vel luctus. Phasellus porta metus eu massa luctus, eget euismod risus rhoncus. Vestibulum sed arcu nisi. Maecenas pretium facilisis velit, vel semper lacus aliquam sit amet. Vestibulum vulputate neque ligula, rhoncus blandit turpis consequat id. Mauris sagittis vehicula imperdiet. Duis sed nunc pretium, ornare purus vel, sodales augue. Maecenas a suscipit risus. Quisque volutpat justo eleifend est ullamcorper fermentum. Donec ullamcorper et tortor a laoreet. Nam id risus nisi. Vivamus non imperdiet erat, sit amet imperdiet felis. Mauris vitae neque et est aliquam scelerisque non non ipsum. + +Nullam laoreet lorem nec malesuada vehicula. Vivamus tempus sodales lectus sed viverra. Aenean lacinia sollicitudin quam, quis tempus eros suscipit id. Duis sed rutrum nisi, ut pulvinar magna. Nam et cursus tortor. Phasellus ac odio tellus. Nullam in iaculis ipsum. Vivamus ante sem, ultricies sed varius quis, tristique nec tellus. Nullam eu urna vitae lacus hendrerit gravida. Quisque pulvinar erat ex, id efficitur velit sodales vitae. Proin vestibulum, sapien eget mattis euismod, tortor quam viverra risus, at congue mauris tortor eu nunc. Mauris pellentesque elit leo, quis eleifend sem placerat a. Vivamus iaculis dui eget tellus volutpat, ac varius nisi facilisis. + +% Merge Novel Scene: Scene 1.1 [New] + +### Scene 1.1 + +Nullam laoreet lorem nec malesuada vehicula. Vivamus tempus sodales lectus sed viverra. Aenean lacinia sollicitudin quam, quis tempus eros suscipit id. Duis sed rutrum nisi, ut pulvinar magna. Nam et cursus tortor. Phasellus ac odio tellus. Nullam in iaculis ipsum. Vivamus ante sem, ultricies sed varius quis, tristique nec tellus. Nullam eu urna vitae lacus hendrerit gravida. Quisque pulvinar erat ex, id efficitur velit sodales vitae. Proin vestibulum, sapien eget mattis euismod, tortor quam viverra risus, at congue mauris tortor eu nunc. Mauris pellentesque elit leo, quis eleifend sem placerat a. Vivamus iaculis dui eget tellus volutpat, ac varius nisi facilisis. + +Nullam a nisl magna. Praesent commodo nec diam aliquet vestibulum. In sapien velit, sodales feugiat porta ut, rhoncus a elit. Quisque egestas nisi eu eros laoreet, quis facilisis est pretium. Nullam bibendum sed tellus nec lobortis. Duis elit massa, volutpat a lacinia a, ullamcorper in dui. Suspendisse ac laoreet dui. Curabitur elementum, tortor elementum ultricies laoreet, nunc massa vulputate augue, vitae tincidunt nunc enim eget nisl. + +% Merge Novel Scene: Scene 1.2 [New] + +### Scene 1.2 + +Nullam a nisl magna. Praesent commodo nec diam aliquet vestibulum. In sapien velit, sodales feugiat porta ut, rhoncus a elit. Quisque egestas nisi eu eros laoreet, quis facilisis est pretium. Nullam bibendum sed tellus nec lobortis. Duis elit massa, volutpat a lacinia a, ullamcorper in dui. Suspendisse ac laoreet dui. Curabitur elementum, tortor elementum ultricies laoreet, nunc massa vulputate augue, vitae tincidunt nunc enim eget nisl. + +Pellentesque nibh urna, volutpat et feugiat porta, rutrum sed lectus. Aliquam eget risus id orci tincidunt condimentum et sit amet purus. Curabitur tincidunt odio vel ante feugiat feugiat. Proin nunc lorem, molestie a sapien et, varius elementum nunc. Donec non fermentum nisl. In et massa placerat, faucibus felis eu, congue nisi. Proin sed tortor non lorem mattis cursus. Vestibulum magna neque, bibendum vel nibh et, tincidunt rhoncus nisi. Duis pulvinar mi a quam rutrum maximus. Nunc sollicitudin, urna in cursus facilisis, augue neque imperdiet metus, ac finibus lorem ante id nulla. Sed maximus eleifend justo id feugiat. Cras eget diam vel est blandit tempor nec a leo. Mauris risus est, fringilla in aliquam a, sagittis vel enim. Nullam sodales id erat placerat lobortis. + +% Merge Novel Scene: Scene 1.3 [New] + +### Scene 1.3 + +Pellentesque nibh urna, volutpat et feugiat porta, rutrum sed lectus. Aliquam eget risus id orci tincidunt condimentum et sit amet purus. Curabitur tincidunt odio vel ante feugiat feugiat. Proin nunc lorem, molestie a sapien et, varius elementum nunc. Donec non fermentum nisl. In et massa placerat, faucibus felis eu, congue nisi. Proin sed tortor non lorem mattis cursus. Vestibulum magna neque, bibendum vel nibh et, tincidunt rhoncus nisi. Duis pulvinar mi a quam rutrum maximus. Nunc sollicitudin, urna in cursus facilisis, augue neque imperdiet metus, ac finibus lorem ante id nulla. Sed maximus eleifend justo id feugiat. Cras eget diam vel est blandit tempor nec a leo. Mauris risus est, fringilla in aliquam a, sagittis vel enim. Nullam sodales id erat placerat lobortis. + +Integer ac gravida quam. Quisque eleifend nisl nec pretium tincidunt. Quisque sollicitudin nisi in hendrerit scelerisque. Sed ornare nisl lacus, sit amet consectetur lectus egestas et. Vivamus nec arcu lorem. Donec rhoncus, purus a porta accumsan, nunc lectus iaculis libero, et fringilla tellus augue et velit. Integer varius felis scelerisque, vulputate tellus eu, laoreet justo. Suspendisse sit amet sem vehicula, auctor odio sed, aliquet enim. In ac tortor sed tortor fringilla elementum. Nulla non odio at magna vulputate scelerisque. Nam elementum diam eu rutrum scelerisque. Sed fermentum, felis quis vulputate fermentum, libero metus sollicitudin est, in faucibus purus nulla non dolor. Ut vitae felis porta, feugiat nunc et, bibendum neque. Nullam nec lorem nec metus ullamcorper malesuada ut a nisl. Etiam eget tristique dui. Nulla sed mi finibus, venenatis tellus non, maximus enim. + diff --git a/tests/reference/coreIndex_LoadSave_tagsIndex.json b/tests/reference/coreIndex_LoadSave_tagsIndex.json index 60c59d86..cba693dc 100644 --- a/tests/reference/coreIndex_LoadSave_tagsIndex.json +++ b/tests/reference/coreIndex_LoadSave_tagsIndex.json @@ -6,31 +6,26 @@ }, "itemIndex": { "7a992350f3eb6": { - "level": "H1", "headings": { "T000001": {"level": "H1", "title": "Lorem Ipsum", "tag": "", "cCount": 230, "wCount": 40, "pCount": 3, "synopsis": ""} } }, "8c58a65414c23": { - "level": "H0", "headings": { "T000000": {"level": "H0", "title": "", "tag": "", "cCount": 1058, "wCount": 176, "pCount": 2, "synopsis": ""} } }, "88d59a277361b": { - "level": "H2", "headings": { "T000001": {"level": "H2", "title": "Prologue", "tag": "", "cCount": 584, "wCount": 92, "pCount": 1, "synopsis": "Explanation from the lipsum.com website."} } }, "db7e733775d4d": { - "level": "H1", "headings": { "T000001": {"level": "H1", "title": "Act One", "tag": "", "cCount": 35, "wCount": 6, "pCount": 1, "synopsis": ""} } }, "fb609cd8319dc": { - "level": "H2", "headings": { "T000001": {"level": "H2", "title": "Chapter One", "tag": "", "cCount": 419, "wCount": 67, "pCount": 1, "synopsis": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque at aliquam quam."} }, @@ -39,7 +34,6 @@ } }, "88243afbe5ed8": { - "level": "H3", "headings": { "T000001": {"level": "H3", "title": "Scene One", "tag": "", "cCount": 1197, "wCount": 174, "pCount": 2, "synopsis": "Aenean ut placerat velit. Etiam laoreet ullamcorper risus, eget lobortis enim scelerisque non. Suspendisse id maximus nunc, et mollis sapien. Curabitur vel semper sapien, non pulvinar dolor. Etiam finibus nisi vel mi molestie consectetur."}, "T000013": {"level": "H4", "title": "Scene One, Section Two", "tag": "", "cCount": 1561, "wCount": 230, "pCount": 2, "synopsis": ""} @@ -49,7 +43,6 @@ } }, "f96ec11c6a3da": { - "level": "H3", "headings": { "T000001": {"level": "H3", "title": "Scene Two", "tag": "", "cCount": 2034, "wCount": 299, "pCount": 3, "synopsis": "Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Integer sapien nulla, dictum at lacus a, dignissim consectetur dolor. Nunc vel eleifend lacus, eu dapibus orci."}, "T000015": {"level": "H4", "title": "Scene Two, Section Two", "tag": "", "cCount": 2009, "wCount": 301, "pCount": 3, "synopsis": ""} @@ -59,13 +52,11 @@ } }, "846352075de7d": { - "level": "H2", "headings": { "T000001": {"level": "H2", "title": "Why do we use it?", "tag": "", "cCount": 631, "wCount": 109, "pCount": 3, "synopsis": ""} } }, "441420a886d82": { - "level": "H2", "headings": { "T000001": {"level": "H2", "title": "Chapter Two", "tag": "", "cCount": 477, "wCount": 70, "pCount": 1, "synopsis": "Curabitur a elit posuere, varius ex et, convallis neque. Phasellus sagittis pharetra sem vitae dapibus. Curabitur varius lorem non pulvinar congue."} }, @@ -74,7 +65,6 @@ } }, "eb103bc70c90c": { - "level": "H3", "headings": { "T000001": {"level": "H3", "title": "Scene Three", "tag": "", "cCount": 3006, "wCount": 439, "pCount": 4, "synopsis": "Aenean ut libero ut lectus porttitor rhoncus vel et massa. Nam pretium, nibh et varius vehicula, urna metus blandit eros, euismod pharetra diam diam et libero. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos."} }, @@ -83,7 +73,6 @@ } }, "f8c0562e50f1b": { - "level": "H3", "headings": { "T000001": {"level": "H3", "title": "Scene Four", "tag": "", "cCount": 3839, "wCount": 563, "pCount": 6, "synopsis": "Nam tempor blandit magna laoreet aliquet. Vestibulum auctor posuere leo, ac gravida nisi rhoncus varius. Aenean posuere dolor vitae condimentum volutpat. Donec egestas volutpat risus, quis luctus justo."} }, @@ -92,7 +81,6 @@ } }, "47666c91c7ccf": { - "level": "H3", "headings": { "T000001": {"level": "H3", "title": "Scene Five", "tag": "", "cCount": 3644, "wCount": 543, "pCount": 5, "synopsis": "Praesent eget est porta, dictum ante in, egestas risus. Mauris risus mauris, consequat aliquam mauris et, feugiat iaculis ipsum. Aliquam arcu ipsum, fermentum ut arcu sed, lobortis euismod sem. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus."} }, @@ -101,7 +89,6 @@ } }, "4c4f28287af27": { - "level": "H1", "headings": { "T000001": {"level": "H1", "title": "Nobody Owens", "tag": "Bod", "cCount": 1864, "wCount": 284, "pCount": 3, "synopsis": ""} }, @@ -110,13 +97,11 @@ } }, "2426c6f0ca922": { - "level": "H1", "headings": { "T000001": {"level": "H1", "title": "Main Plot", "tag": "Main", "cCount": 1369, "wCount": 195, "pCount": 2, "synopsis": ""} } }, "04468803b92e1": { - "level": "H1", "headings": { "T000001": {"level": "H1", "title": "Ancient Europe", "tag": "Europe", "cCount": 1770, "wCount": 259, "pCount": 3, "synopsis": ""} } diff --git a/tests/reference/coreProject_NewCustomA_nwProject.nwx b/tests/reference/coreProject_NewCustomA_nwProject.nwx deleted file mode 100644 index f046fceb..00000000 --- a/tests/reference/coreProject_NewCustomA_nwProject.nwx +++ /dev/null @@ -1,136 +0,0 @@ - - - - Test Custom - Test Novel - Jane Doe - John Doh - 1 - 1 - 0 - - - True - None - False - None - True - None - None - None - None - 0 - 0 - 0 - - - %title% - %title% - %title% - * * * -
-
- - New - Note - Draft - Finished - - - New - Minor - Major - Main - -
- - - - Novel - - - - Title Page - - - - Chapter 1 - - - - Scene 1.1 - - - - Scene 1.2 - - - - Scene 1.3 - - - - Chapter 2 - - - - Scene 2.1 - - - - Scene 2.2 - - - - Scene 2.3 - - - - Chapter 3 - - - - Scene 3.1 - - - - Scene 3.2 - - - - Scene 3.3 - - - - Plot - - - - Main Plot - - - - Characters - - - - Protagonist - - - - Locations - - - - Main Location - - - - Archive - - - - Trash - - -
diff --git a/tests/reference/coreProject_NewCustomB_nwProject.nwx b/tests/reference/coreProject_NewCustomB_nwProject.nwx deleted file mode 100644 index 5d02172c..00000000 --- a/tests/reference/coreProject_NewCustomB_nwProject.nwx +++ /dev/null @@ -1,112 +0,0 @@ - - - - Test Custom - Test Novel - Jane Doe - John Doh - 1 - 1 - 0 - - - True - None - False - None - True - None - None - None - None - 0 - 0 - 0 - - - %title% - %title% - %title% - * * * -
-
- - New - Note - Draft - Finished - - - New - Minor - Major - Main - -
- - - - Novel - - - - Title Page - - - - Scene 1 - - - - Scene 2 - - - - Scene 3 - - - - Scene 4 - - - - Scene 5 - - - - Scene 6 - - - - Plot - - - - Main Plot - - - - Characters - - - - Protagonist - - - - Locations - - - - Main Location - - - - Archive - - - - Trash - - -
diff --git a/tests/reference/coreProject_NewFileFolder_nwProject.nwx b/tests/reference/coreProject_NewFileFolder_nwProject.nwx index 20aeb027..5000c7d9 100644 --- a/tests/reference/coreProject_NewFileFolder_nwProject.nwx +++ b/tests/reference/coreProject_NewFileFolder_nwProject.nwx @@ -1,91 +1,85 @@ - - + + New Project New Novel Jane Doe - 2 - 1 - 0 - True + yes None - False - None - True - None - None - None - None - 2 - 1 - 1 + None + + None + None + None + None + - %title% - %title% - %title% - * * * -
+ %title% + %title% + %title% + * * * +
- New - Note - Draft - Finished + New + Note + Draft + Finished - New - Minor - Major - Main + New + Minor + Major + Main
- - - - Novel + + + + Novel - - - Plot + + + Plot - - - Characters + + + Characters - - - World + + + World - - - Title Page + + + Title Page - - - New Chapter + + + New Chapter - - - New Chapter + + + New Chapter - - - New Scene + + + New Scene - - - Stuff + + + Stuff - - - Hello + + + Hello - - - Jane + + + Jane
diff --git a/tests/reference/coreProject_NewMinimal_nwProject.nwx b/tests/reference/coreProject_NewMinimal_nwProject.nwx deleted file mode 100644 index ba08600f..00000000 --- a/tests/reference/coreProject_NewMinimal_nwProject.nwx +++ /dev/null @@ -1,78 +0,0 @@ - - - - New Project - - 2 - 1 - 0 - - - True - None - False - None - True - None - None - None - None - 0 - 0 - 0 - - - %title% - %title% - %title% - * * * -
-
- - New - Note - Draft - Finished - - - New - Minor - Major - Main - -
- - - - Novel - - - - Title Page - - - - New Chapter - - - - New Scene - - - - Plot - - - - Characters - - - - Locations - - - - Archive - - -
diff --git a/tests/reference/coreProject_NewRoot_nwProject.nwx b/tests/reference/coreProject_NewRoot_nwProject.nwx index 0a606137..06a0ca07 100644 --- a/tests/reference/coreProject_NewRoot_nwProject.nwx +++ b/tests/reference/coreProject_NewRoot_nwProject.nwx @@ -1,111 +1,105 @@ - - + + New Project New Novel Jane Doe - 2 - 1 - 0 - True + yes None - False - None - True - None - None - None - None - 0 - 0 - 0 + None + + None + None + None + None + - %title% - %title% - %title% - * * * -
+ %title% + %title% + %title% + * * * +
- New - Note - Draft - Finished + New + Note + Draft + Finished - New - Minor - Major - Main + New + Minor + Major + Main
- + + + + Novel + + + + Plot + + + + Characters + + + + World + + + + Title Page + + + + New Chapter + + + + New Chapter + + + + New Scene + - - Novel + + Novel - - Plot + + Plot - - Characters + + Characters - - World + + Locations - - - Title Page + + + Timeline - - - New Chapter + + + Objects - - - New Chapter + + + Custom - - - New Scene - - - - Novel - - - - Plot - - - - Characters - - - - Locations - - - - Timeline - - - - Objects - - - - Custom - - - - Custom + + + Custom
diff --git a/tests/reference/coreTools_NewCustomA_nwProject.nwx b/tests/reference/coreTools_NewCustomA_nwProject.nwx new file mode 100644 index 00000000..48330e5f --- /dev/null +++ b/tests/reference/coreTools_NewCustomA_nwProject.nwx @@ -0,0 +1,130 @@ + + + + Test Custom + Test Novel + Jane Doe + John Doh + + + yes + None + None + + None + None + None + None + + + + %title% + %title% + %title% + * * * + + + + New + Note + Draft + Finished + + + New + Minor + Major + Main + + + + + + Novel + + + + Title Page + + + + Chapter 1 + + + + Scene 1.1 + + + + Scene 1.2 + + + + Scene 1.3 + + + + Chapter 2 + + + + Scene 2.1 + + + + Scene 2.2 + + + + Scene 2.3 + + + + Chapter 3 + + + + Scene 3.1 + + + + Scene 3.2 + + + + Scene 3.3 + + + + Plot + + + + Main Plot + + + + Characters + + + + Protagonist + + + + Locations + + + + Main Location + + + + Archive + + + + Trash + + + diff --git a/tests/reference/coreTools_NewCustomB_nwProject.nwx b/tests/reference/coreTools_NewCustomB_nwProject.nwx new file mode 100644 index 00000000..161d1157 --- /dev/null +++ b/tests/reference/coreTools_NewCustomB_nwProject.nwx @@ -0,0 +1,106 @@ + + + + Test Custom + Test Novel + Jane Doe + John Doh + + + yes + None + None + + None + None + None + None + + + + %title% + %title% + %title% + * * * + + + + New + Note + Draft + Finished + + + New + Minor + Major + Main + + + + + + Novel + + + + Title Page + + + + Scene 1 + + + + Scene 2 + + + + Scene 3 + + + + Scene 4 + + + + Scene 5 + + + + Scene 6 + + + + Plot + + + + Main Plot + + + + Characters + + + + Protagonist + + + + Locations + + + + Main Location + + + + Archive + + + + Trash + + + diff --git a/tests/reference/coreTools_NewMinimal_nwProject.nwx b/tests/reference/coreTools_NewMinimal_nwProject.nwx new file mode 100644 index 00000000..1ac190ee --- /dev/null +++ b/tests/reference/coreTools_NewMinimal_nwProject.nwx @@ -0,0 +1,72 @@ + + + + New Project + New Project + + + yes + None + None + + None + None + None + None + + + + %title% + %title% + %title% + * * * + + + + New + Note + Draft + Finished + + + New + Minor + Major + Main + + + + + + Novel + + + + Title Page + + + + New Chapter + + + + New Scene + + + + Plot + + + + Characters + + + + Locations + + + + Archive + + + diff --git a/tests/reference/guiEditor_Main_Final_000000000000f.nwd b/tests/reference/guiEditor_Main_Final_000000000000f.nwd index fcab1110..b45a9dca 100644 --- a/tests/reference/guiEditor_Main_Final_000000000000f.nwd +++ b/tests/reference/guiEditor_Main_Final_000000000000f.nwd @@ -21,12 +21,25 @@ This is a paragraph of nonsense text. +This is another paragraph +with a line separator in it. + This is another paragraph of much longer nonsense text. It is in fact 1 very very NONSENSICAL nonsense text! We can also try replacing “quotes”, even single ‘quotes’ are replaced. Isn’t that nice? We can hyphen-ate, make dashes – and even longer dashes — if we want. Ellipsis? Not a problem either … How about three hyphens — for long dash? It works too. “Full line double quoted text.” ‘Full line single quoted text.’ +Some “ double quoted text with spaces padded ”. + +@object: NoSpaceAdded + +% synopsis: No space before this colon. + +Add space before this colon : See? + +But don’t add a double space : See? + “Tab-indented text” >“Paragraph-indented text” diff --git a/tests/reference/guiEditor_Main_Final_0000000000020.nwd b/tests/reference/guiEditor_Main_Final_0000000000010.nwd similarity index 71% rename from tests/reference/guiEditor_Main_Final_0000000000020.nwd rename to tests/reference/guiEditor_Main_Final_0000000000010.nwd index c0316819..bc255b88 100644 --- a/tests/reference/guiEditor_Main_Final_0000000000020.nwd +++ b/tests/reference/guiEditor_Main_Final_0000000000010.nwd @@ -1,5 +1,5 @@ %%~name: New Note -%%~path: 000000000000a/0000000000020 +%%~path: 000000000000a/0000000000010 %%~kind: CHARACTER/NOTE # Jane Doe diff --git a/tests/reference/guiEditor_Main_Final_0000000000021.nwd b/tests/reference/guiEditor_Main_Final_0000000000011.nwd similarity index 74% rename from tests/reference/guiEditor_Main_Final_0000000000021.nwd rename to tests/reference/guiEditor_Main_Final_0000000000011.nwd index 5dddd23b..99705061 100644 --- a/tests/reference/guiEditor_Main_Final_0000000000021.nwd +++ b/tests/reference/guiEditor_Main_Final_0000000000011.nwd @@ -1,5 +1,5 @@ %%~name: New Note -%%~path: 0000000000009/0000000000021 +%%~path: 0000000000009/0000000000011 %%~kind: PLOT/NOTE # Main Plot diff --git a/tests/reference/guiEditor_Main_Final_0000000000022.nwd b/tests/reference/guiEditor_Main_Final_0000000000012.nwd similarity index 74% rename from tests/reference/guiEditor_Main_Final_0000000000022.nwd rename to tests/reference/guiEditor_Main_Final_0000000000012.nwd index 092f832a..ea19dae5 100644 --- a/tests/reference/guiEditor_Main_Final_0000000000022.nwd +++ b/tests/reference/guiEditor_Main_Final_0000000000012.nwd @@ -1,5 +1,5 @@ %%~name: New Note -%%~path: 000000000000b/0000000000022 +%%~path: 000000000000b/0000000000012 %%~kind: WORLD/NOTE # Main Location diff --git a/tests/reference/guiEditor_Main_Final_nwProject.nwx b/tests/reference/guiEditor_Main_Final_nwProject.nwx index ecc96604..e384ef9e 100644 --- a/tests/reference/guiEditor_Main_Final_nwProject.nwx +++ b/tests/reference/guiEditor_Main_Final_nwProject.nwx @@ -1,33 +1,27 @@ - - + + New Project New Novel Jane Doe - 4 - 2 - 3 - True + yes None - True - None - True - 000000000000f - None - 0000000000008 - None - 129 - 102 - 27 + None + + 000000000000f + None + 0000000000008 + 0000000000008 + - %title% - %title% - %title% - * * * -
+ %title% + %title% + %title% + * * * +
New @@ -42,53 +36,53 @@ Main
- + - + Novel - - Title Page + + Title Page - + New Chapter - - New Chapter + + New Chapter - - New Scene + + New Scene - + Plot - - - New Note + + + New Note - + Characters - - - New Note + + + New Note - + World - - - New Note + + + New Note - - + + Trash diff --git a/tests/reference/guiEditor_Main_Initial_nwProject.nwx b/tests/reference/guiEditor_Main_Initial_nwProject.nwx index 1a79d2c2..977f03c1 100644 --- a/tests/reference/guiEditor_Main_Initial_nwProject.nwx +++ b/tests/reference/guiEditor_Main_Initial_nwProject.nwx @@ -1,33 +1,27 @@ - - + + New Project New Novel Jane Doe - 2 - 1 - 0 - True + yes None - False - None - True - None - None - None - None - 9 - 9 - 0 + None + + None + None + None + None + - %title% - %title% - %title% - * * * -
+ %title% + %title% + %title% + * * * +
New @@ -42,37 +36,37 @@ Main
- + - + Novel - - Title Page + + Title Page - + New Chapter - - New Chapter + + New Chapter - - New Scene + + New Scene - + Plot - + Characters - + World diff --git a/tests/reference/guiPreferences_novelwriter.conf b/tests/reference/guiPreferences_novelwriter.conf index 71c08fb1..672f6f7b 100644 --- a/tests/reference/guiPreferences_novelwriter.conf +++ b/tests/reference/guiPreferences_novelwriter.conf @@ -1,31 +1,32 @@ +[Meta] +timestamp = 2022-11-11 12:48:20 + [Main] -timestamp = 2021-12-31 16:45:34 theme = default syntax = default_light -icons = typicons_light -guifont = Sans -guifontsize = 12 -lastnotes = 0x0 -guilang = en_GB +font = Cantarell +fontsize = 12 +localisation = en_GB hidevscroll = True hidehscroll = True +lastnotes = 0x0 +lastpath = /home/vkbo/Code/novelWriter/Source/tests/temp/function [Sizes] -geometry = 1200, 650 -preferences = 670, 589 -treecols = 200, 50, 30 -novelcols = 200, 50 -projcols = 200, 60, 140 +mainwindow = 1200, 650 +preferences = 699, 614 +projloadcols = 280, 60, 160 mainpane = 300, 800 -docpane = 400, 400 viewpane = 500, 150 outlinepane = 500, 150 -fullscreen = False [Project] autosaveproject = 40 autosavedoc = 20 emphlabels = True +backuppath = some/dir +backuponclose = True +askbeforebackup = True [Editor] textfont = None @@ -45,8 +46,10 @@ repdots = True scrollpastend = 0 autoscroll = True autoscrollpos = 30 -fmtsinglequote = ‘, ’ -fmtdoublequote = “, ” +fmtsquoteopen = ‘ +fmtsquoteclose = ’ +fmtdquoteopen = “ +fmtdquoteclose = ” fmtpadbefore = fmtpadafter = fmtpadthin = False @@ -65,12 +68,8 @@ highlightemph = False stopwhenidle = True useridletime = 300 -[Backup] -backuppath = some/dir -backuponclose = True -askbeforebackup = True - [State] +fullscreen = False showrefpanel = True viewcomments = True viewsynopsis = True @@ -81,6 +80,3 @@ searchloop = False searchnextfile = False searchmatchcap = False -[Path] -lastpath = - diff --git a/tests/reference/guiProjSettings_Dialog_nwProject.nwx b/tests/reference/guiProjSettings_Dialog_nwProject.nwx deleted file mode 100644 index 883cb26d..00000000 --- a/tests/reference/guiProjSettings_Dialog_nwProject.nwx +++ /dev/null @@ -1,84 +0,0 @@ - - - - Project Name - Project Title - Jane Doe - John Doh - 1 - 1 - 0 - - - True - None - False - en - True - None - None - None - None - 9 - 9 - 0 - - B - D - With This Stuff - - - %title% - %title% - %title% - * * * -
-
- - New - Note - Finished - Final - - - New - Minor - Major - Final - -
- - - - Novel - - - - Title Page - - - - New Chapter - - - - New Chapter - - - - New Scene - - - - Plot - - - - Characters - - - - World - - -
diff --git a/tests/reference/projectXML_ReadCurrent.json b/tests/reference/projectXML_ReadCurrent.json new file mode 100644 index 00000000..758a1f56 --- /dev/null +++ b/tests/reference/projectXML_ReadCurrent.json @@ -0,0 +1,677 @@ +[ + { + "name": "Novel", + "itemAttr": { + "handle": "7031beac91f75", + "parent": null, + "root": "7031beac91f75", + "order": 0, + "type": "ROOT", + "class": "NOVEL", + "layout": "NO_LAYOUT" + }, + "metaAttr": { + "expanded": true, + "heading": "H0", + "charCount": 0, + "wordCount": 0, + "paraCount": 0, + "cursorPos": 0 + }, + "nameAttr": { + "status": "sc24b8f", + "import": "ia857f0", + "active": false + } + }, + { + "name": "Title Page", + "itemAttr": { + "handle": "53b69b83cdafc", + "parent": "7031beac91f75", + "root": "7031beac91f75", + "order": 0, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "expanded": false, + "heading": "H1", + "charCount": 93, + "wordCount": 19, + "paraCount": 2, + "cursorPos": 119 + }, + "nameAttr": { + "status": "sc24b8f", + "import": "ia857f0", + "active": true + } + }, + { + "name": "Page", + "itemAttr": { + "handle": "974e400180a99", + "parent": "7031beac91f75", + "root": "7031beac91f75", + "order": 1, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "expanded": false, + "heading": "H0", + "charCount": 251, + "wordCount": 50, + "paraCount": 2, + "cursorPos": 277 + }, + "nameAttr": { + "status": "sf12341", + "import": "ia857f0", + "active": true + } + }, + { + "name": "Part One", + "itemAttr": { + "handle": "edca4be2fcaf8", + "parent": "7031beac91f75", + "root": "7031beac91f75", + "order": 2, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "expanded": false, + "heading": "H1", + "charCount": 26, + "wordCount": 6, + "paraCount": 1, + "cursorPos": 36 + }, + "nameAttr": { + "status": "s90e6c9", + "import": "ia857f0", + "active": true + } + }, + { + "name": "Chapter One", + "itemAttr": { + "handle": "6a2d6d5f4f401", + "parent": "7031beac91f75", + "root": "7031beac91f75", + "order": 3, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "expanded": true, + "heading": "H2", + "charCount": 95, + "wordCount": 18, + "paraCount": 1, + "cursorPos": 291 + }, + "nameAttr": { + "status": "sf24ce6", + "import": "ia857f0", + "active": true + } + }, + { + "name": "Making a Scene", + "itemAttr": { + "handle": "636b6aa9b697b", + "parent": "6a2d6d5f4f401", + "root": "7031beac91f75", + "order": 0, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "expanded": false, + "heading": "H3", + "charCount": 2687, + "wordCount": 479, + "paraCount": 14, + "cursorPos": 67 + }, + "nameAttr": { + "status": "s90e6c9", + "import": "ia857f0", + "active": true + } + }, + { + "name": "Another Scene", + "itemAttr": { + "handle": "bc0cbd2a407f3", + "parent": "6a2d6d5f4f401", + "root": "7031beac91f75", + "order": 1, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "expanded": false, + "heading": "H3", + "charCount": 548, + "wordCount": 108, + "paraCount": 3, + "cursorPos": 465 + }, + "nameAttr": { + "status": "s90e6c9", + "import": "ia857f0", + "active": true + } + }, + { + "name": "Interlude", + "itemAttr": { + "handle": "ba8a28a246524", + "parent": "7031beac91f75", + "root": "7031beac91f75", + "order": 4, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "expanded": false, + "heading": "H2", + "charCount": 617, + "wordCount": 101, + "paraCount": 3, + "cursorPos": 310 + }, + "nameAttr": { + "status": "s78ea90", + "import": "ia857f0", + "active": true + } + }, + { + "name": "A Note on Structure", + "itemAttr": { + "handle": "96b68994dfa3d", + "parent": "7031beac91f75", + "root": "7031beac91f75", + "order": 5, + "type": "FILE", + "class": "NOVEL", + "layout": "NOTE" + }, + "metaAttr": { + "expanded": false, + "heading": "H1", + "charCount": 1909, + "wordCount": 346, + "paraCount": 7, + "cursorPos": 0 + }, + "nameAttr": { + "status": "sf24ce6", + "import": "ia857f0", + "active": false + } + }, + { + "name": "Chapter Two", + "itemAttr": { + "handle": "88706ddc78b1b", + "parent": "7031beac91f75", + "root": "7031beac91f75", + "order": 6, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "expanded": true, + "heading": "H2", + "charCount": 139, + "wordCount": 28, + "paraCount": 1, + "cursorPos": 188 + }, + "nameAttr": { + "status": "s90e6c9", + "import": "ia857f0", + "active": true + } + }, + { + "name": "We Found John!", + "itemAttr": { + "handle": "ae7339df26ded", + "parent": "88706ddc78b1b", + "root": "7031beac91f75", + "order": 0, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "expanded": false, + "heading": "H3", + "charCount": 189, + "wordCount": 37, + "paraCount": 1, + "cursorPos": 0 + }, + "nameAttr": { + "status": "s90e6c9", + "import": "ia857f0", + "active": true + } + }, + { + "name": "Sequel", + "itemAttr": { + "handle": "e5e47ebf63b1c", + "parent": null, + "root": "e5e47ebf63b1c", + "order": 1, + "type": "ROOT", + "class": "NOVEL", + "layout": "NO_LAYOUT" + }, + "metaAttr": { + "expanded": true, + "heading": "H0", + "charCount": 0, + "wordCount": 0, + "paraCount": 0, + "cursorPos": 0 + }, + "nameAttr": { + "status": "sf12341", + "import": "ia857f0", + "active": false + } + }, + { + "name": "Title Page", + "itemAttr": { + "handle": "bacb7059e3083", + "parent": "e5e47ebf63b1c", + "root": "e5e47ebf63b1c", + "order": 0, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "expanded": false, + "heading": "H1", + "charCount": 27, + "wordCount": 5, + "paraCount": 1, + "cursorPos": 100 + }, + "nameAttr": { + "status": "sc24b8f", + "import": "ia857f0", + "active": true + } + }, + { + "name": "Chapter One", + "itemAttr": { + "handle": "a520879ca0b45", + "parent": "e5e47ebf63b1c", + "root": "e5e47ebf63b1c", + "order": 1, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "expanded": false, + "heading": "H2", + "charCount": 299, + "wordCount": 55, + "paraCount": 2, + "cursorPos": 104 + }, + "nameAttr": { + "status": "s90e6c9", + "import": "ia857f0", + "active": true + } + }, + { + "name": "Characters", + "itemAttr": { + "handle": "f6622b4617424", + "parent": null, + "root": "f6622b4617424", + "order": 2, + "type": "ROOT", + "class": "CHARACTER", + "layout": "NO_LAYOUT" + }, + "metaAttr": { + "expanded": true, + "heading": "H0", + "charCount": 0, + "wordCount": 0, + "paraCount": 0, + "cursorPos": 0 + }, + "nameAttr": { + "status": "sf12341", + "import": "ia857f0", + "active": false + } + }, + { + "name": "Main Characters", + "itemAttr": { + "handle": "f7e2d9f330615", + "parent": "f6622b4617424", + "root": "f6622b4617424", + "order": 0, + "type": "FOLDER", + "class": "CHARACTER", + "layout": "NO_LAYOUT" + }, + "metaAttr": { + "expanded": true, + "heading": "H0", + "charCount": 0, + "wordCount": 0, + "paraCount": 0, + "cursorPos": 0 + }, + "nameAttr": { + "status": "sf12341", + "import": "ia857f0", + "active": false + } + }, + { + "name": "John Smith", + "itemAttr": { + "handle": "14298de4d9524", + "parent": "f7e2d9f330615", + "root": "f6622b4617424", + "order": 0, + "type": "FILE", + "class": "CHARACTER", + "layout": "NOTE" + }, + "metaAttr": { + "expanded": false, + "heading": "H1", + "charCount": 49, + "wordCount": 9, + "paraCount": 1, + "cursorPos": 24 + }, + "nameAttr": { + "status": "sf12341", + "import": "icfb3a5", + "active": true + } + }, + { + "name": "Jane Smith", + "itemAttr": { + "handle": "bb2c23b3c42cc", + "parent": "f7e2d9f330615", + "root": "f6622b4617424", + "order": 1, + "type": "FILE", + "class": "CHARACTER", + "layout": "NOTE" + }, + "metaAttr": { + "expanded": false, + "heading": "H1", + "charCount": 55, + "wordCount": 9, + "paraCount": 1, + "cursorPos": 25 + }, + "nameAttr": { + "status": "sf12341", + "import": "i2d7a54", + "active": true + } + }, + { + "name": "Locations", + "itemAttr": { + "handle": "15c4492bd5107", + "parent": null, + "root": "15c4492bd5107", + "order": 3, + "type": "ROOT", + "class": "WORLD", + "layout": "NO_LAYOUT" + }, + "metaAttr": { + "expanded": true, + "heading": "H0", + "charCount": 0, + "wordCount": 0, + "paraCount": 0, + "cursorPos": 0 + }, + "nameAttr": { + "status": "sf12341", + "import": "ia857f0", + "active": false + } + }, + { + "name": "Earth", + "itemAttr": { + "handle": "b3e74dbc1f584", + "parent": "15c4492bd5107", + "root": "15c4492bd5107", + "order": 0, + "type": "FILE", + "class": "WORLD", + "layout": "NOTE" + }, + "metaAttr": { + "expanded": false, + "heading": "H1", + "charCount": 76, + "wordCount": 15, + "paraCount": 1, + "cursorPos": 20 + }, + "nameAttr": { + "status": "sf12341", + "import": "i56be10", + "active": true + } + }, + { + "name": "Space", + "itemAttr": { + "handle": "f1471bef9f2ae", + "parent": "15c4492bd5107", + "root": "15c4492bd5107", + "order": 1, + "type": "FILE", + "class": "WORLD", + "layout": "NOTE" + }, + "metaAttr": { + "expanded": false, + "heading": "H1", + "charCount": 115, + "wordCount": 24, + "paraCount": 1, + "cursorPos": 133 + }, + "nameAttr": { + "status": "sf12341", + "import": "icfb3a5", + "active": true + } + }, + { + "name": "Mars", + "itemAttr": { + "handle": "5eaea4e8cdee8", + "parent": "15c4492bd5107", + "root": "15c4492bd5107", + "order": 2, + "type": "FILE", + "class": "WORLD", + "layout": "NOTE" + }, + "metaAttr": { + "expanded": false, + "heading": "H1", + "charCount": 28, + "wordCount": 6, + "paraCount": 1, + "cursorPos": 45 + }, + "nameAttr": { + "status": "sf12341", + "import": "i2d7a54", + "active": true + } + }, + { + "name": "Archive", + "itemAttr": { + "handle": "6827118336ac1", + "parent": null, + "root": "6827118336ac1", + "order": 4, + "type": "ROOT", + "class": "ARCHIVE", + "layout": "NO_LAYOUT" + }, + "metaAttr": { + "expanded": true, + "heading": "H0", + "charCount": 0, + "wordCount": 0, + "paraCount": 0, + "cursorPos": 0 + }, + "nameAttr": { + "status": "sf12341", + "import": "ia857f0", + "active": false + } + }, + { + "name": "Scenes", + "itemAttr": { + "handle": "ae9bf3c3ea159", + "parent": "6827118336ac1", + "root": "6827118336ac1", + "order": 0, + "type": "FOLDER", + "class": "ARCHIVE", + "layout": "NO_LAYOUT" + }, + "metaAttr": { + "expanded": true, + "heading": "H0", + "charCount": 0, + "wordCount": 0, + "paraCount": 0, + "cursorPos": 0 + }, + "nameAttr": { + "status": "sf12341", + "import": "ia857f0", + "active": false + } + }, + { + "name": "Old File", + "itemAttr": { + "handle": "8a5deb88c0e97", + "parent": "ae9bf3c3ea159", + "root": "6827118336ac1", + "order": 0, + "type": "FILE", + "class": "ARCHIVE", + "layout": "DOCUMENT" + }, + "metaAttr": { + "expanded": false, + "heading": "H3", + "charCount": 232, + "wordCount": 42, + "paraCount": 1, + "cursorPos": 239 + }, + "nameAttr": { + "status": "s90e6c9", + "import": "ia857f0", + "active": true + } + }, + { + "name": "Trash", + "itemAttr": { + "handle": "98acd8c76c93a", + "parent": null, + "root": "98acd8c76c93a", + "order": 5, + "type": "ROOT", + "class": "TRASH", + "layout": "NO_LAYOUT" + }, + "metaAttr": { + "expanded": true, + "heading": "H0", + "charCount": 0, + "wordCount": 0, + "paraCount": 0, + "cursorPos": 0 + }, + "nameAttr": { + "status": "sf12341", + "import": "ia857f0", + "active": false + } + }, + { + "name": "Delete Me!", + "itemAttr": { + "handle": "b8136a5a774a0", + "parent": "98acd8c76c93a", + "root": "98acd8c76c93a", + "order": 0, + "type": "FILE", + "class": "TRASH", + "layout": "DOCUMENT" + }, + "metaAttr": { + "expanded": false, + "heading": "H3", + "charCount": 30, + "wordCount": 6, + "paraCount": 1, + "cursorPos": 36 + }, + "nameAttr": { + "status": "sf12341", + "import": "ia857f0", + "active": true + } + } +] diff --git a/tests/reference/projectXML_ReadLegacy10.json b/tests/reference/projectXML_ReadLegacy10.json new file mode 100644 index 00000000..2a44ede2 --- /dev/null +++ b/tests/reference/projectXML_ReadLegacy10.json @@ -0,0 +1,494 @@ +[ + { + "name": "Novel", + "itemAttr": { + "handle": "7031beac91f75", + "parent": null, + "root": null, + "order": 0, + "type": "ROOT", + "class": "NOVEL" + }, + "metaAttr": { + "heading": "H0", + "expanded": true + }, + "nameAttr": { + "status": "s000002" + } + }, + { + "name": "Title Page", + "itemAttr": { + "handle": "53b69b83cdafc", + "parent": "7031beac91f75", + "root": null, + "order": 0, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "heading": "H0", + "expanded": false, + "charCount": 72, + "wordCount": 15, + "paraCount": 2, + "cursorPos": 78 + }, + "nameAttr": { + "active": true, + "status": "s000002" + } + }, + { + "name": "Page", + "itemAttr": { + "handle": "974e400180a99", + "parent": "7031beac91f75", + "root": null, + "order": 1, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "heading": "H0", + "expanded": false, + "charCount": 208, + "wordCount": 40, + "paraCount": 2, + "cursorPos": 213 + }, + "nameAttr": { + "active": true, + "status": "s000000" + } + }, + { + "name": "Part One", + "itemAttr": { + "handle": "edca4be2fcaf8", + "parent": "7031beac91f75", + "root": null, + "order": 2, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "heading": "H0", + "expanded": false, + "charCount": 23, + "wordCount": 5, + "paraCount": 1, + "cursorPos": 0 + }, + "nameAttr": { + "active": true, + "status": "s000000" + } + }, + { + "name": "A Folder", + "itemAttr": { + "handle": "e7ded148d6e4a", + "parent": "7031beac91f75", + "root": null, + "order": 3, + "type": "FOLDER", + "class": "NOVEL" + }, + "metaAttr": { + "heading": "H0", + "expanded": true + }, + "nameAttr": { + "status": "s000003" + } + }, + { + "name": "Chapter One", + "itemAttr": { + "handle": "6a2d6d5f4f401", + "parent": "e7ded148d6e4a", + "root": null, + "order": 0, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "heading": "H0", + "expanded": false, + "charCount": 12, + "wordCount": 3, + "paraCount": 0, + "cursorPos": 215 + }, + "nameAttr": { + "active": true, + "status": "s000001" + } + }, + { + "name": "Making a Scene", + "itemAttr": { + "handle": "636b6aa9b697b", + "parent": "e7ded148d6e4a", + "root": null, + "order": 1, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "heading": "H0", + "expanded": false, + "charCount": 1199, + "wordCount": 216, + "paraCount": 7, + "cursorPos": 527 + }, + "nameAttr": { + "active": true, + "status": "s000003" + } + }, + { + "name": "Another Scene", + "itemAttr": { + "handle": "bc0cbd2a407f3", + "parent": "e7ded148d6e4a", + "root": null, + "order": 2, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "heading": "H0", + "expanded": false, + "charCount": 476, + "wordCount": 93, + "paraCount": 3, + "cursorPos": 551 + }, + "nameAttr": { + "active": true, + "status": "s000003" + } + }, + { + "name": "Interlude", + "itemAttr": { + "handle": "ba8a28a246524", + "parent": "e7ded148d6e4a", + "root": null, + "order": 3, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "heading": "H0", + "expanded": false, + "charCount": 633, + "wordCount": 101, + "paraCount": 3, + "cursorPos": 1238 + }, + "nameAttr": { + "active": true, + "status": "s000006" + } + }, + { + "name": "A Note on Structure", + "itemAttr": { + "handle": "96b68994dfa3d", + "parent": "e7ded148d6e4a", + "root": null, + "order": 4, + "type": "FILE", + "class": "NOVEL", + "layout": "NOTE" + }, + "metaAttr": { + "heading": "H0", + "expanded": false, + "charCount": 1692, + "wordCount": 313, + "paraCount": 6, + "cursorPos": 1721 + }, + "nameAttr": { + "active": false, + "status": "s000004" + } + }, + { + "name": "Chapter Two", + "itemAttr": { + "handle": "88706ddc78b1b", + "parent": "e7ded148d6e4a", + "root": null, + "order": 5, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "heading": "H0", + "expanded": false, + "charCount": 139, + "wordCount": 28, + "paraCount": 1, + "cursorPos": 343 + }, + "nameAttr": { + "active": true, + "status": "s000003" + } + }, + { + "name": "We Found John!", + "itemAttr": { + "handle": "ae7339df26ded", + "parent": "e7ded148d6e4a", + "root": null, + "order": 6, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "heading": "H0", + "expanded": false, + "charCount": 189, + "wordCount": 37, + "paraCount": 1, + "cursorPos": 224 + }, + "nameAttr": { + "active": true, + "status": "s000003" + } + }, + { + "name": "Characters", + "itemAttr": { + "handle": "f6622b4617424", + "parent": null, + "root": null, + "order": 1, + "type": "ROOT", + "class": "CHARACTER" + }, + "metaAttr": { + "heading": "H0", + "expanded": true + }, + "nameAttr": { + "import": null + } + }, + { + "name": "Main Characters", + "itemAttr": { + "handle": "f7e2d9f330615", + "parent": "f6622b4617424", + "root": null, + "order": 0, + "type": "FOLDER", + "class": "CHARACTER" + }, + "metaAttr": { + "heading": "H0", + "expanded": true + }, + "nameAttr": { + "import": null + } + }, + { + "name": "John Smith", + "itemAttr": { + "handle": "14298de4d9524", + "parent": "f7e2d9f330615", + "root": null, + "order": 0, + "type": "FILE", + "class": "CHARACTER", + "layout": "NOTE" + }, + "metaAttr": { + "heading": "H0", + "expanded": false, + "charCount": 49, + "wordCount": 9, + "paraCount": 1, + "cursorPos": 24 + }, + "nameAttr": { + "active": true, + "import": "i000008" + } + }, + { + "name": "Jane Smith", + "itemAttr": { + "handle": "bb2c23b3c42cc", + "parent": "f7e2d9f330615", + "root": null, + "order": 1, + "type": "FILE", + "class": "CHARACTER", + "layout": "NOTE" + }, + "metaAttr": { + "heading": "H0", + "expanded": false, + "charCount": 55, + "wordCount": 9, + "paraCount": 1, + "cursorPos": 25 + }, + "nameAttr": { + "active": true, + "import": "i000009" + } + }, + { + "name": "Locations", + "itemAttr": { + "handle": "15c4492bd5107", + "parent": null, + "root": null, + "order": 2, + "type": "ROOT", + "class": "WORLD" + }, + "metaAttr": { + "heading": "H0", + "expanded": true + }, + "nameAttr": { + "import": null + } + }, + { + "name": "Earth", + "itemAttr": { + "handle": "b3e74dbc1f584", + "parent": "15c4492bd5107", + "root": null, + "order": 0, + "type": "FILE", + "class": "WORLD", + "layout": "NOTE" + }, + "metaAttr": { + "heading": "H0", + "expanded": false, + "charCount": 76, + "wordCount": 15, + "paraCount": 1, + "cursorPos": 20 + }, + "nameAttr": { + "active": true, + "import": "i00000a" + } + }, + { + "name": "Space", + "itemAttr": { + "handle": "f1471bef9f2ae", + "parent": "15c4492bd5107", + "root": null, + "order": 1, + "type": "FILE", + "class": "WORLD", + "layout": "NOTE" + }, + "metaAttr": { + "heading": "H0", + "expanded": false, + "charCount": 115, + "wordCount": 24, + "paraCount": 1, + "cursorPos": 133 + }, + "nameAttr": { + "active": true, + "import": "i000008" + } + }, + { + "name": "Mars", + "itemAttr": { + "handle": "5eaea4e8cdee8", + "parent": "15c4492bd5107", + "root": null, + "order": 2, + "type": "FILE", + "class": "WORLD", + "layout": "NOTE" + }, + "metaAttr": { + "heading": "H0", + "expanded": false, + "charCount": 28, + "wordCount": 6, + "paraCount": 1, + "cursorPos": 45 + }, + "nameAttr": { + "active": true, + "import": "i000009" + } + }, + { + "name": "Trash", + "itemAttr": { + "handle": "98acd8c76c93a", + "parent": null, + "root": null, + "order": 3, + "type": "ROOT", + "class": "TRASH" + }, + "metaAttr": { + "heading": "H0", + "expanded": true + }, + "nameAttr": { + "import": null + } + }, + { + "name": "Delete Me!", + "itemAttr": { + "handle": "b8136a5a774a0", + "parent": "98acd8c76c93a", + "root": null, + "order": 0, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "heading": "H0", + "expanded": false, + "charCount": 0, + "wordCount": 0, + "paraCount": 0, + "cursorPos": 36 + }, + "nameAttr": { + "active": true, + "status": "s000000" + } + } +] diff --git a/tests/reference/projectXML_ReadLegacy10.nwx b/tests/reference/projectXML_ReadLegacy10.nwx new file mode 100644 index 00000000..d4ef1259 --- /dev/null +++ b/tests/reference/projectXML_ReadLegacy10.nwx @@ -0,0 +1,137 @@ + + + + Sample Project + Sample Project + Jane Smith + Jay Doh + + + yes + None + None + + None + None + None + None + + + B + E + D + + + %title% + Chapter %ch%: %title% + %title% + Scene %ch%.%sc%: %title% + + + + New + Notes + Started + 1st Draft + 2nd Draft + 3rd Draft + Finished + + + None + Minor + Major + Main + + + + + + Novel + + + + Title Page + + + + Page + + + + Part One + + + + A Folder + + + + Chapter One + + + + Making a Scene + + + + Another Scene + + + + Interlude + + + + A Note on Structure + + + + Chapter Two + + + + We Found John! + + + + Characters + + + + Main Characters + + + + John Smith + + + + Jane Smith + + + + Locations + + + + Earth + + + + Space + + + + Mars + + + + Trash + + + + Delete Me! + + + diff --git a/tests/reference/projectXML_ReadLegacy11.json b/tests/reference/projectXML_ReadLegacy11.json new file mode 100644 index 00000000..20ed9609 --- /dev/null +++ b/tests/reference/projectXML_ReadLegacy11.json @@ -0,0 +1,478 @@ +[ + { + "name": "Novel", + "itemAttr": { + "handle": "7031beac91f75", + "parent": null, + "root": null, + "order": 0, + "type": "ROOT", + "class": "NOVEL" + }, + "metaAttr": { + "heading": "H0", + "expanded": true + }, + "nameAttr": { + "status": "s000002" + } + }, + { + "name": "Title Page", + "itemAttr": { + "handle": "53b69b83cdafc", + "parent": "7031beac91f75", + "root": null, + "order": 0, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "heading": "H0", + "charCount": 72, + "wordCount": 15, + "paraCount": 2, + "cursorPos": 78 + }, + "nameAttr": { + "active": true, + "status": "s000002" + } + }, + { + "name": "Page", + "itemAttr": { + "handle": "974e400180a99", + "parent": "7031beac91f75", + "root": null, + "order": 1, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "heading": "H0", + "charCount": 210, + "wordCount": 40, + "paraCount": 2, + "cursorPos": 213 + }, + "nameAttr": { + "active": true, + "status": "s000000" + } + }, + { + "name": "Part One", + "itemAttr": { + "handle": "edca4be2fcaf8", + "parent": "7031beac91f75", + "root": null, + "order": 2, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "heading": "H0", + "charCount": 23, + "wordCount": 5, + "paraCount": 1, + "cursorPos": 0 + }, + "nameAttr": { + "active": true, + "status": "s000000" + } + }, + { + "name": "A Folder", + "itemAttr": { + "handle": "e7ded148d6e4a", + "parent": "7031beac91f75", + "root": null, + "order": 3, + "type": "FOLDER", + "class": "NOVEL" + }, + "metaAttr": { + "heading": "H0", + "expanded": true + }, + "nameAttr": { + "status": "s000003" + } + }, + { + "name": "Chapter One", + "itemAttr": { + "handle": "6a2d6d5f4f401", + "parent": "e7ded148d6e4a", + "root": null, + "order": 0, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "heading": "H0", + "charCount": 12, + "wordCount": 3, + "paraCount": 0, + "cursorPos": 215 + }, + "nameAttr": { + "active": true, + "status": "s000001" + } + }, + { + "name": "Making a Scene", + "itemAttr": { + "handle": "636b6aa9b697b", + "parent": "e7ded148d6e4a", + "root": null, + "order": 1, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "heading": "H0", + "charCount": 1483, + "wordCount": 263, + "paraCount": 8, + "cursorPos": 1086 + }, + "nameAttr": { + "active": true, + "status": "s000003" + } + }, + { + "name": "Another Scene", + "itemAttr": { + "handle": "bc0cbd2a407f3", + "parent": "e7ded148d6e4a", + "root": null, + "order": 2, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "heading": "H0", + "charCount": 476, + "wordCount": 93, + "paraCount": 3, + "cursorPos": 428 + }, + "nameAttr": { + "active": true, + "status": "s000003" + } + }, + { + "name": "Interlude", + "itemAttr": { + "handle": "ba8a28a246524", + "parent": "e7ded148d6e4a", + "root": null, + "order": 3, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "heading": "H0", + "charCount": 633, + "wordCount": 101, + "paraCount": 3, + "cursorPos": 1238 + }, + "nameAttr": { + "active": true, + "status": "s000006" + } + }, + { + "name": "A Note on Structure", + "itemAttr": { + "handle": "96b68994dfa3d", + "parent": "e7ded148d6e4a", + "root": null, + "order": 4, + "type": "FILE", + "class": "NOVEL", + "layout": "NOTE" + }, + "metaAttr": { + "heading": "H0", + "charCount": 1692, + "wordCount": 313, + "paraCount": 6, + "cursorPos": 1721 + }, + "nameAttr": { + "active": false, + "status": "s000004" + } + }, + { + "name": "Chapter Two", + "itemAttr": { + "handle": "88706ddc78b1b", + "parent": "e7ded148d6e4a", + "root": null, + "order": 5, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "heading": "H0", + "charCount": 139, + "wordCount": 28, + "paraCount": 1, + "cursorPos": 343 + }, + "nameAttr": { + "active": true, + "status": "s000003" + } + }, + { + "name": "We Found John!", + "itemAttr": { + "handle": "ae7339df26ded", + "parent": "e7ded148d6e4a", + "root": null, + "order": 6, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "heading": "H0", + "charCount": 189, + "wordCount": 37, + "paraCount": 1, + "cursorPos": 224 + }, + "nameAttr": { + "active": true, + "status": "s000003" + } + }, + { + "name": "Characters", + "itemAttr": { + "handle": "f6622b4617424", + "parent": null, + "root": null, + "order": 1, + "type": "ROOT", + "class": "CHARACTER" + }, + "metaAttr": { + "heading": "H0", + "expanded": true + }, + "nameAttr": { + "import": null + } + }, + { + "name": "Main Characters", + "itemAttr": { + "handle": "f7e2d9f330615", + "parent": "f6622b4617424", + "root": null, + "order": 0, + "type": "FOLDER", + "class": "CHARACTER" + }, + "metaAttr": { + "heading": "H0", + "expanded": true + }, + "nameAttr": { + "import": null + } + }, + { + "name": "John Smith", + "itemAttr": { + "handle": "14298de4d9524", + "parent": "f7e2d9f330615", + "root": null, + "order": 0, + "type": "FILE", + "class": "CHARACTER", + "layout": "NOTE" + }, + "metaAttr": { + "heading": "H0", + "charCount": 49, + "wordCount": 9, + "paraCount": 1, + "cursorPos": 24 + }, + "nameAttr": { + "active": true, + "import": "i000008" + } + }, + { + "name": "Jane Smith", + "itemAttr": { + "handle": "bb2c23b3c42cc", + "parent": "f7e2d9f330615", + "root": null, + "order": 1, + "type": "FILE", + "class": "CHARACTER", + "layout": "NOTE" + }, + "metaAttr": { + "heading": "H0", + "charCount": 55, + "wordCount": 9, + "paraCount": 1, + "cursorPos": 25 + }, + "nameAttr": { + "active": true, + "import": "i000009" + } + }, + { + "name": "Locations", + "itemAttr": { + "handle": "15c4492bd5107", + "parent": null, + "root": null, + "order": 2, + "type": "ROOT", + "class": "WORLD" + }, + "metaAttr": { + "heading": "H0", + "expanded": true + }, + "nameAttr": { + "import": null + } + }, + { + "name": "Earth", + "itemAttr": { + "handle": "b3e74dbc1f584", + "parent": "15c4492bd5107", + "root": null, + "order": 0, + "type": "FILE", + "class": "WORLD", + "layout": "NOTE" + }, + "metaAttr": { + "heading": "H0", + "charCount": 76, + "wordCount": 15, + "paraCount": 1, + "cursorPos": 20 + }, + "nameAttr": { + "active": true, + "import": "i00000a" + } + }, + { + "name": "Space", + "itemAttr": { + "handle": "f1471bef9f2ae", + "parent": "15c4492bd5107", + "root": null, + "order": 1, + "type": "FILE", + "class": "WORLD", + "layout": "NOTE" + }, + "metaAttr": { + "heading": "H0", + "charCount": 115, + "wordCount": 24, + "paraCount": 1, + "cursorPos": 133 + }, + "nameAttr": { + "active": true, + "import": "i000008" + } + }, + { + "name": "Mars", + "itemAttr": { + "handle": "5eaea4e8cdee8", + "parent": "15c4492bd5107", + "root": null, + "order": 2, + "type": "FILE", + "class": "WORLD", + "layout": "NOTE" + }, + "metaAttr": { + "heading": "H0", + "charCount": 28, + "wordCount": 6, + "paraCount": 1, + "cursorPos": 45 + }, + "nameAttr": { + "active": true, + "import": "i000009" + } + }, + { + "name": "Trash", + "itemAttr": { + "handle": "98acd8c76c93a", + "parent": null, + "root": null, + "order": 3, + "type": "ROOT", + "class": "TRASH" + }, + "metaAttr": { + "heading": "H0", + "expanded": true + }, + "nameAttr": { + "import": null + } + }, + { + "name": "Delete Me!", + "itemAttr": { + "handle": "b8136a5a774a0", + "parent": "98acd8c76c93a", + "root": null, + "order": 0, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "heading": "H0", + "charCount": 30, + "wordCount": 6, + "paraCount": 1, + "cursorPos": 36 + }, + "nameAttr": { + "active": true, + "status": "s000000" + } + } +] diff --git a/tests/reference/projectXML_ReadLegacy11.nwx b/tests/reference/projectXML_ReadLegacy11.nwx new file mode 100644 index 00000000..c2bb4cf4 --- /dev/null +++ b/tests/reference/projectXML_ReadLegacy11.nwx @@ -0,0 +1,137 @@ + + + + Sample Project + Sample Project + Jane Smith + Jay Doh + + + yes + None + None + + None + None + None + None + + + B + E + D + + + %title% + Chapter %ch%: %title% + %title% + Scene %ch%.%sc%: %title% + + + + New + Notes + Started + 1st Draft + 2nd Draft + 3rd Draft + Finished + + + None + Minor + Major + Main + + + + + + Novel + + + + Title Page + + + + Page + + + + Part One + + + + A Folder + + + + Chapter One + + + + Making a Scene + + + + Another Scene + + + + Interlude + + + + A Note on Structure + + + + Chapter Two + + + + We Found John! + + + + Characters + + + + Main Characters + + + + John Smith + + + + Jane Smith + + + + Locations + + + + Earth + + + + Space + + + + Mars + + + + Trash + + + + Delete Me! + + + diff --git a/tests/reference/projectXML_ReadLegacy12.json b/tests/reference/projectXML_ReadLegacy12.json new file mode 100644 index 00000000..2826bffa --- /dev/null +++ b/tests/reference/projectXML_ReadLegacy12.json @@ -0,0 +1,537 @@ +[ + { + "name": "Novel", + "itemAttr": { + "handle": "7031beac91f75", + "parent": null, + "root": null, + "order": 0, + "type": "ROOT", + "class": "NOVEL" + }, + "metaAttr": { + "heading": "H0", + "expanded": true + }, + "nameAttr": { + "status": "s000002" + } + }, + { + "name": "Title Page", + "itemAttr": { + "handle": "53b69b83cdafc", + "parent": "7031beac91f75", + "root": null, + "order": 0, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "heading": "H0", + "charCount": 241, + "wordCount": 42, + "paraCount": 3, + "cursorPos": 252 + }, + "nameAttr": { + "active": true, + "status": "s000002" + } + }, + { + "name": "Page", + "itemAttr": { + "handle": "974e400180a99", + "parent": "7031beac91f75", + "root": null, + "order": 1, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "heading": "H0", + "charCount": 125, + "wordCount": 26, + "paraCount": 2, + "cursorPos": 127 + }, + "nameAttr": { + "active": true, + "status": "s000000" + } + }, + { + "name": "Part One", + "itemAttr": { + "handle": "edca4be2fcaf8", + "parent": "7031beac91f75", + "root": null, + "order": 2, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "heading": "H0", + "charCount": 26, + "wordCount": 6, + "paraCount": 1, + "cursorPos": 30 + }, + "nameAttr": { + "active": true, + "status": "s000000" + } + }, + { + "name": "A Folder", + "itemAttr": { + "handle": "e7ded148d6e4a", + "parent": "7031beac91f75", + "root": null, + "order": 3, + "type": "FOLDER", + "class": "NOVEL" + }, + "metaAttr": { + "heading": "H0", + "expanded": true + }, + "nameAttr": { + "status": "s000003" + } + }, + { + "name": "Chapter One", + "itemAttr": { + "handle": "6a2d6d5f4f401", + "parent": "e7ded148d6e4a", + "root": null, + "order": 0, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "heading": "H0", + "charCount": 75, + "wordCount": 14, + "paraCount": 1, + "cursorPos": 279 + }, + "nameAttr": { + "active": true, + "status": "s000001" + } + }, + { + "name": "Making a Scene", + "itemAttr": { + "handle": "636b6aa9b697b", + "parent": "e7ded148d6e4a", + "root": null, + "order": 1, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "heading": "H0", + "charCount": 2429, + "wordCount": 432, + "paraCount": 14, + "cursorPos": 61 + }, + "nameAttr": { + "active": true, + "status": "s000003" + } + }, + { + "name": "Another Scene", + "itemAttr": { + "handle": "bc0cbd2a407f3", + "parent": "e7ded148d6e4a", + "root": null, + "order": 2, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "heading": "H0", + "charCount": 476, + "wordCount": 93, + "paraCount": 3, + "cursorPos": 577 + }, + "nameAttr": { + "active": true, + "status": "s000003" + } + }, + { + "name": "Interlude", + "itemAttr": { + "handle": "ba8a28a246524", + "parent": "e7ded148d6e4a", + "root": null, + "order": 3, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "heading": "H0", + "charCount": 617, + "wordCount": 101, + "paraCount": 3, + "cursorPos": 1137 + }, + "nameAttr": { + "active": true, + "status": "s000000" + } + }, + { + "name": "A Note on Structure", + "itemAttr": { + "handle": "96b68994dfa3d", + "parent": "e7ded148d6e4a", + "root": null, + "order": 4, + "type": "FILE", + "class": "NOVEL", + "layout": "NOTE" + }, + "metaAttr": { + "heading": "H0", + "charCount": 1692, + "wordCount": 313, + "paraCount": 6, + "cursorPos": 1110 + }, + "nameAttr": { + "active": false, + "status": "s000004" + } + }, + { + "name": "Chapter Two", + "itemAttr": { + "handle": "88706ddc78b1b", + "parent": "e7ded148d6e4a", + "root": null, + "order": 5, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "heading": "H0", + "charCount": 139, + "wordCount": 28, + "paraCount": 1, + "cursorPos": 343 + }, + "nameAttr": { + "active": true, + "status": "s000003" + } + }, + { + "name": "We Found John!", + "itemAttr": { + "handle": "ae7339df26ded", + "parent": "e7ded148d6e4a", + "root": null, + "order": 6, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "heading": "H0", + "charCount": 189, + "wordCount": 37, + "paraCount": 1, + "cursorPos": 224 + }, + "nameAttr": { + "active": true, + "status": "s000003" + } + }, + { + "name": "Characters", + "itemAttr": { + "handle": "f6622b4617424", + "parent": null, + "root": null, + "order": 1, + "type": "ROOT", + "class": "CHARACTER" + }, + "metaAttr": { + "heading": "H0", + "expanded": true + }, + "nameAttr": { + "import": null + } + }, + { + "name": "Main Characters", + "itemAttr": { + "handle": "f7e2d9f330615", + "parent": "f6622b4617424", + "root": null, + "order": 0, + "type": "FOLDER", + "class": "CHARACTER" + }, + "metaAttr": { + "heading": "H0", + "expanded": true + }, + "nameAttr": { + "import": null + } + }, + { + "name": "John Smith", + "itemAttr": { + "handle": "14298de4d9524", + "parent": "f7e2d9f330615", + "root": null, + "order": 0, + "type": "FILE", + "class": "CHARACTER", + "layout": "NOTE" + }, + "metaAttr": { + "heading": "H0", + "charCount": 49, + "wordCount": 9, + "paraCount": 1, + "cursorPos": 24 + }, + "nameAttr": { + "active": true, + "import": "i000008" + } + }, + { + "name": "Jane Smith", + "itemAttr": { + "handle": "bb2c23b3c42cc", + "parent": "f7e2d9f330615", + "root": null, + "order": 1, + "type": "FILE", + "class": "CHARACTER", + "layout": "NOTE" + }, + "metaAttr": { + "heading": "H0", + "charCount": 55, + "wordCount": 9, + "paraCount": 1, + "cursorPos": 25 + }, + "nameAttr": { + "active": true, + "import": "i000009" + } + }, + { + "name": "Locations", + "itemAttr": { + "handle": "15c4492bd5107", + "parent": null, + "root": null, + "order": 2, + "type": "ROOT", + "class": "WORLD" + }, + "metaAttr": { + "heading": "H0", + "expanded": true + }, + "nameAttr": { + "import": null + } + }, + { + "name": "Earth", + "itemAttr": { + "handle": "b3e74dbc1f584", + "parent": "15c4492bd5107", + "root": null, + "order": 0, + "type": "FILE", + "class": "WORLD", + "layout": "NOTE" + }, + "metaAttr": { + "heading": "H0", + "charCount": 76, + "wordCount": 15, + "paraCount": 1, + "cursorPos": 20 + }, + "nameAttr": { + "active": true, + "import": "i00000a" + } + }, + { + "name": "Space", + "itemAttr": { + "handle": "f1471bef9f2ae", + "parent": "15c4492bd5107", + "root": null, + "order": 1, + "type": "FILE", + "class": "WORLD", + "layout": "NOTE" + }, + "metaAttr": { + "heading": "H0", + "charCount": 115, + "wordCount": 24, + "paraCount": 1, + "cursorPos": 133 + }, + "nameAttr": { + "active": true, + "import": "i000008" + } + }, + { + "name": "Mars", + "itemAttr": { + "handle": "5eaea4e8cdee8", + "parent": "15c4492bd5107", + "root": null, + "order": 2, + "type": "FILE", + "class": "WORLD", + "layout": "NOTE" + }, + "metaAttr": { + "heading": "H0", + "charCount": 28, + "wordCount": 6, + "paraCount": 1, + "cursorPos": 45 + }, + "nameAttr": { + "active": true, + "import": "i000009" + } + }, + { + "name": "Outtakes", + "itemAttr": { + "handle": "6827118336ac1", + "parent": null, + "root": null, + "order": 3, + "type": "ROOT", + "class": "ARCHIVE" + }, + "metaAttr": { + "heading": "H0", + "expanded": true + }, + "nameAttr": { + "status": null + } + }, + { + "name": "Scenes", + "itemAttr": { + "handle": "ae9bf3c3ea159", + "parent": "6827118336ac1", + "root": null, + "order": 0, + "type": "FOLDER", + "class": "ARCHIVE" + }, + "metaAttr": { + "heading": "H0", + "expanded": true + }, + "nameAttr": { + "status": null + } + }, + { + "name": "Old File", + "itemAttr": { + "handle": "8a5deb88c0e97", + "parent": "ae9bf3c3ea159", + "root": null, + "order": 0, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "heading": "H0", + "charCount": 315, + "wordCount": 55, + "paraCount": 1, + "cursorPos": 322 + }, + "nameAttr": { + "active": true, + "status": "s000003" + } + }, + { + "name": "Trash", + "itemAttr": { + "handle": "98acd8c76c93a", + "parent": null, + "root": null, + "order": 4, + "type": "ROOT", + "class": "TRASH" + }, + "metaAttr": { + "heading": "H0", + "expanded": true + }, + "nameAttr": { + "import": null + } + }, + { + "name": "Delete Me!", + "itemAttr": { + "handle": "b8136a5a774a0", + "parent": "98acd8c76c93a", + "root": null, + "order": 0, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "heading": "H0", + "charCount": 30, + "wordCount": 6, + "paraCount": 1, + "cursorPos": 36 + }, + "nameAttr": { + "active": true, + "status": "s000000" + } + } +] diff --git a/tests/reference/projectXML_ReadLegacy12.nwx b/tests/reference/projectXML_ReadLegacy12.nwx new file mode 100644 index 00000000..90408b3f --- /dev/null +++ b/tests/reference/projectXML_ReadLegacy12.nwx @@ -0,0 +1,149 @@ + + + + Sample Project + Sample Project + Jane Smith + Jay Doh + + + yes + en_GB + en_GB + + None + None + None + None + + + B + E + D + + + %title% + Chapter %chw%: %title% + %title% + Scene %ch%.%sc%: %title% + + + + New + Notes + Started + 1st Draft + 2nd Draft + 3rd Draft + Finished + + + None + Minor + Major + Main + + + + + + Novel + + + + Title Page + + + + Page + + + + Part One + + + + A Folder + + + + Chapter One + + + + Making a Scene + + + + Another Scene + + + + Interlude + + + + A Note on Structure + + + + Chapter Two + + + + We Found John! + + + + Characters + + + + Main Characters + + + + John Smith + + + + Jane Smith + + + + Locations + + + + Earth + + + + Space + + + + Mars + + + + Outtakes + + + + Scenes + + + + Old File + + + + Trash + + + + Delete Me! + + + diff --git a/tests/reference/projectXML_ReadLegacy13.json b/tests/reference/projectXML_ReadLegacy13.json new file mode 100644 index 00000000..4398ac76 --- /dev/null +++ b/tests/reference/projectXML_ReadLegacy13.json @@ -0,0 +1,537 @@ +[ + { + "name": "Novel", + "itemAttr": { + "handle": "7031beac91f75", + "parent": null, + "root": null, + "order": 0, + "type": "ROOT", + "class": "NOVEL" + }, + "metaAttr": { + "heading": "H0", + "expanded": true + }, + "nameAttr": { + "status": "s000002" + } + }, + { + "name": "Title Page", + "itemAttr": { + "handle": "53b69b83cdafc", + "parent": "7031beac91f75", + "root": null, + "order": 0, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "heading": "H0", + "charCount": 93, + "wordCount": 19, + "paraCount": 2, + "cursorPos": 2 + }, + "nameAttr": { + "active": true, + "status": "s000002" + } + }, + { + "name": "Page", + "itemAttr": { + "handle": "974e400180a99", + "parent": "7031beac91f75", + "root": null, + "order": 1, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "heading": "H0", + "charCount": 186, + "wordCount": 39, + "paraCount": 2, + "cursorPos": 212 + }, + "nameAttr": { + "active": true, + "status": "s000000" + } + }, + { + "name": "Part One", + "itemAttr": { + "handle": "edca4be2fcaf8", + "parent": "7031beac91f75", + "root": null, + "order": 2, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "heading": "H0", + "charCount": 26, + "wordCount": 6, + "paraCount": 1, + "cursorPos": 33 + }, + "nameAttr": { + "active": true, + "status": "s000000" + } + }, + { + "name": "A Folder", + "itemAttr": { + "handle": "e7ded148d6e4a", + "parent": "7031beac91f75", + "root": null, + "order": 3, + "type": "FOLDER", + "class": "NOVEL" + }, + "metaAttr": { + "heading": "H0", + "expanded": true + }, + "nameAttr": { + "status": "s000003" + } + }, + { + "name": "Chapter One", + "itemAttr": { + "handle": "6a2d6d5f4f401", + "parent": "e7ded148d6e4a", + "root": null, + "order": 0, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "heading": "H0", + "charCount": 75, + "wordCount": 14, + "paraCount": 1, + "cursorPos": 279 + }, + "nameAttr": { + "active": true, + "status": "s000001" + } + }, + { + "name": "Making a Scene", + "itemAttr": { + "handle": "636b6aa9b697b", + "parent": "e7ded148d6e4a", + "root": null, + "order": 1, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "heading": "H0", + "charCount": 2429, + "wordCount": 432, + "paraCount": 14, + "cursorPos": 62 + }, + "nameAttr": { + "active": true, + "status": "s000003" + } + }, + { + "name": "Another Scene", + "itemAttr": { + "handle": "bc0cbd2a407f3", + "parent": "e7ded148d6e4a", + "root": null, + "order": 2, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "heading": "H0", + "charCount": 476, + "wordCount": 93, + "paraCount": 3, + "cursorPos": 577 + }, + "nameAttr": { + "active": true, + "status": "s000003" + } + }, + { + "name": "Interlude", + "itemAttr": { + "handle": "ba8a28a246524", + "parent": "e7ded148d6e4a", + "root": null, + "order": 3, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "heading": "H0", + "charCount": 617, + "wordCount": 101, + "paraCount": 3, + "cursorPos": 4 + }, + "nameAttr": { + "active": true, + "status": "s000000" + } + }, + { + "name": "A Note on Structure", + "itemAttr": { + "handle": "96b68994dfa3d", + "parent": "e7ded148d6e4a", + "root": null, + "order": 4, + "type": "FILE", + "class": "NOVEL", + "layout": "NOTE" + }, + "metaAttr": { + "heading": "H0", + "charCount": 1692, + "wordCount": 313, + "paraCount": 6, + "cursorPos": 1110 + }, + "nameAttr": { + "active": false, + "status": "s000004" + } + }, + { + "name": "Chapter Two", + "itemAttr": { + "handle": "88706ddc78b1b", + "parent": "e7ded148d6e4a", + "root": null, + "order": 5, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "heading": "H0", + "charCount": 139, + "wordCount": 28, + "paraCount": 1, + "cursorPos": 343 + }, + "nameAttr": { + "active": true, + "status": "s000003" + } + }, + { + "name": "We Found John!", + "itemAttr": { + "handle": "ae7339df26ded", + "parent": "e7ded148d6e4a", + "root": null, + "order": 6, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "heading": "H0", + "charCount": 189, + "wordCount": 37, + "paraCount": 1, + "cursorPos": 224 + }, + "nameAttr": { + "active": true, + "status": "s000003" + } + }, + { + "name": "Characters", + "itemAttr": { + "handle": "f6622b4617424", + "parent": null, + "root": null, + "order": 1, + "type": "ROOT", + "class": "CHARACTER" + }, + "metaAttr": { + "heading": "H0", + "expanded": true + }, + "nameAttr": { + "import": null + } + }, + { + "name": "Main Characters", + "itemAttr": { + "handle": "f7e2d9f330615", + "parent": "f6622b4617424", + "root": null, + "order": 0, + "type": "FOLDER", + "class": "CHARACTER" + }, + "metaAttr": { + "heading": "H0", + "expanded": true + }, + "nameAttr": { + "import": null + } + }, + { + "name": "John Smith", + "itemAttr": { + "handle": "14298de4d9524", + "parent": "f7e2d9f330615", + "root": null, + "order": 0, + "type": "FILE", + "class": "CHARACTER", + "layout": "NOTE" + }, + "metaAttr": { + "heading": "H0", + "charCount": 49, + "wordCount": 9, + "paraCount": 1, + "cursorPos": 24 + }, + "nameAttr": { + "active": true, + "import": "i000008" + } + }, + { + "name": "Jane Smith", + "itemAttr": { + "handle": "bb2c23b3c42cc", + "parent": "f7e2d9f330615", + "root": null, + "order": 1, + "type": "FILE", + "class": "CHARACTER", + "layout": "NOTE" + }, + "metaAttr": { + "heading": "H0", + "charCount": 55, + "wordCount": 9, + "paraCount": 1, + "cursorPos": 25 + }, + "nameAttr": { + "active": true, + "import": "i000009" + } + }, + { + "name": "Locations", + "itemAttr": { + "handle": "15c4492bd5107", + "parent": null, + "root": null, + "order": 2, + "type": "ROOT", + "class": "WORLD" + }, + "metaAttr": { + "heading": "H0", + "expanded": true + }, + "nameAttr": { + "import": null + } + }, + { + "name": "Earth", + "itemAttr": { + "handle": "b3e74dbc1f584", + "parent": "15c4492bd5107", + "root": null, + "order": 0, + "type": "FILE", + "class": "WORLD", + "layout": "NOTE" + }, + "metaAttr": { + "heading": "H0", + "charCount": 76, + "wordCount": 15, + "paraCount": 1, + "cursorPos": 20 + }, + "nameAttr": { + "active": true, + "import": "i00000a" + } + }, + { + "name": "Space", + "itemAttr": { + "handle": "f1471bef9f2ae", + "parent": "15c4492bd5107", + "root": null, + "order": 1, + "type": "FILE", + "class": "WORLD", + "layout": "NOTE" + }, + "metaAttr": { + "heading": "H0", + "charCount": 115, + "wordCount": 24, + "paraCount": 1, + "cursorPos": 133 + }, + "nameAttr": { + "active": true, + "import": "i000008" + } + }, + { + "name": "Mars", + "itemAttr": { + "handle": "5eaea4e8cdee8", + "parent": "15c4492bd5107", + "root": null, + "order": 2, + "type": "FILE", + "class": "WORLD", + "layout": "NOTE" + }, + "metaAttr": { + "heading": "H0", + "charCount": 28, + "wordCount": 6, + "paraCount": 1, + "cursorPos": 45 + }, + "nameAttr": { + "active": true, + "import": "i000009" + } + }, + { + "name": "Archive", + "itemAttr": { + "handle": "6827118336ac1", + "parent": null, + "root": null, + "order": 3, + "type": "ROOT", + "class": "ARCHIVE" + }, + "metaAttr": { + "heading": "H0", + "expanded": true + }, + "nameAttr": { + "status": "s000000" + } + }, + { + "name": "Scenes", + "itemAttr": { + "handle": "ae9bf3c3ea159", + "parent": "6827118336ac1", + "root": null, + "order": 0, + "type": "FOLDER", + "class": "ARCHIVE" + }, + "metaAttr": { + "heading": "H0", + "expanded": true + }, + "nameAttr": { + "status": "s000000" + } + }, + { + "name": "Old File", + "itemAttr": { + "handle": "8a5deb88c0e97", + "parent": "ae9bf3c3ea159", + "root": null, + "order": 0, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "heading": "H0", + "charCount": 314, + "wordCount": 55, + "paraCount": 1, + "cursorPos": 322 + }, + "nameAttr": { + "active": true, + "status": "s000003" + } + }, + { + "name": "Trash", + "itemAttr": { + "handle": "98acd8c76c93a", + "parent": null, + "root": null, + "order": 4, + "type": "ROOT", + "class": "TRASH" + }, + "metaAttr": { + "heading": "H0", + "expanded": true + }, + "nameAttr": { + "import": null + } + }, + { + "name": "Delete Me!", + "itemAttr": { + "handle": "b8136a5a774a0", + "parent": "98acd8c76c93a", + "root": null, + "order": 0, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "heading": "H0", + "charCount": 30, + "wordCount": 6, + "paraCount": 1, + "cursorPos": 36 + }, + "nameAttr": { + "active": true, + "status": "s000000" + } + } +] diff --git a/tests/reference/projectXML_ReadLegacy13.nwx b/tests/reference/projectXML_ReadLegacy13.nwx new file mode 100644 index 00000000..226affe4 --- /dev/null +++ b/tests/reference/projectXML_ReadLegacy13.nwx @@ -0,0 +1,149 @@ + + + + Sample Project + Sample Project + Jane Smith + Jay Doh + + + yes + en_GB + en_GB + + None + None + None + None + + + B + E + D + + + %title% + Chapter %chw%: %title% + %title% + Scene %ch%.%sc%: %title% + + + + New + Notes + Started + 1st Draft + 2nd Draft + 3rd Draft + Finished + + + None + Minor + Major + Main + + + + + + Novel + + + + Title Page + + + + Page + + + + Part One + + + + A Folder + + + + Chapter One + + + + Making a Scene + + + + Another Scene + + + + Interlude + + + + A Note on Structure + + + + Chapter Two + + + + We Found John! + + + + Characters + + + + Main Characters + + + + John Smith + + + + Jane Smith + + + + Locations + + + + Earth + + + + Space + + + + Mars + + + + Archive + + + + Scenes + + + + Old File + + + + Trash + + + + Delete Me! + + + diff --git a/tests/reference/projectXML_ReadLegacy14.json b/tests/reference/projectXML_ReadLegacy14.json new file mode 100644 index 00000000..71afa3c9 --- /dev/null +++ b/tests/reference/projectXML_ReadLegacy14.json @@ -0,0 +1,677 @@ +[ + { + "name": "Novel", + "itemAttr": { + "handle": "7031beac91f75", + "parent": null, + "root": "7031beac91f75", + "order": 0, + "type": "ROOT", + "class": "NOVEL", + "layout": "NO_LAYOUT" + }, + "metaAttr": { + "expanded": true, + "heading": "H0", + "charCount": 0, + "wordCount": 0, + "paraCount": 0, + "cursorPos": 0 + }, + "nameAttr": { + "status": "sc24b8f", + "import": "ia857f0", + "active": false + } + }, + { + "name": "Title Page", + "itemAttr": { + "handle": "53b69b83cdafc", + "parent": "7031beac91f75", + "root": "7031beac91f75", + "order": 0, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "expanded": false, + "heading": "H0", + "charCount": 93, + "wordCount": 19, + "paraCount": 2, + "cursorPos": 119 + }, + "nameAttr": { + "status": "sc24b8f", + "import": "ia857f0", + "active": true + } + }, + { + "name": "Page", + "itemAttr": { + "handle": "974e400180a99", + "parent": "7031beac91f75", + "root": "7031beac91f75", + "order": 1, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "expanded": false, + "heading": "H0", + "charCount": 251, + "wordCount": 50, + "paraCount": 2, + "cursorPos": 277 + }, + "nameAttr": { + "status": "sf12341", + "import": "ia857f0", + "active": true + } + }, + { + "name": "Part One", + "itemAttr": { + "handle": "edca4be2fcaf8", + "parent": "7031beac91f75", + "root": "7031beac91f75", + "order": 2, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "expanded": false, + "heading": "H0", + "charCount": 26, + "wordCount": 6, + "paraCount": 1, + "cursorPos": 36 + }, + "nameAttr": { + "status": "s90e6c9", + "import": "ia857f0", + "active": true + } + }, + { + "name": "Chapter One", + "itemAttr": { + "handle": "6a2d6d5f4f401", + "parent": "7031beac91f75", + "root": "7031beac91f75", + "order": 3, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "expanded": true, + "heading": "H0", + "charCount": 95, + "wordCount": 18, + "paraCount": 1, + "cursorPos": 291 + }, + "nameAttr": { + "status": "sf24ce6", + "import": "ia857f0", + "active": true + } + }, + { + "name": "Making a Scene", + "itemAttr": { + "handle": "636b6aa9b697b", + "parent": "6a2d6d5f4f401", + "root": "7031beac91f75", + "order": 0, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "expanded": false, + "heading": "H0", + "charCount": 2687, + "wordCount": 479, + "paraCount": 14, + "cursorPos": 67 + }, + "nameAttr": { + "status": "s90e6c9", + "import": "ia857f0", + "active": true + } + }, + { + "name": "Another Scene", + "itemAttr": { + "handle": "bc0cbd2a407f3", + "parent": "6a2d6d5f4f401", + "root": "7031beac91f75", + "order": 1, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "expanded": false, + "heading": "H0", + "charCount": 548, + "wordCount": 108, + "paraCount": 3, + "cursorPos": 465 + }, + "nameAttr": { + "status": "s90e6c9", + "import": "ia857f0", + "active": true + } + }, + { + "name": "Interlude", + "itemAttr": { + "handle": "ba8a28a246524", + "parent": "7031beac91f75", + "root": "7031beac91f75", + "order": 4, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "expanded": false, + "heading": "H0", + "charCount": 617, + "wordCount": 101, + "paraCount": 3, + "cursorPos": 310 + }, + "nameAttr": { + "status": "s78ea90", + "import": "ia857f0", + "active": true + } + }, + { + "name": "A Note on Structure", + "itemAttr": { + "handle": "96b68994dfa3d", + "parent": "7031beac91f75", + "root": "7031beac91f75", + "order": 5, + "type": "FILE", + "class": "NOVEL", + "layout": "NOTE" + }, + "metaAttr": { + "expanded": false, + "heading": "H0", + "charCount": 1909, + "wordCount": 346, + "paraCount": 7, + "cursorPos": 0 + }, + "nameAttr": { + "status": "sf24ce6", + "import": "ia857f0", + "active": false + } + }, + { + "name": "Chapter Two", + "itemAttr": { + "handle": "88706ddc78b1b", + "parent": "7031beac91f75", + "root": "7031beac91f75", + "order": 6, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "expanded": true, + "heading": "H0", + "charCount": 139, + "wordCount": 28, + "paraCount": 1, + "cursorPos": 188 + }, + "nameAttr": { + "status": "s90e6c9", + "import": "ia857f0", + "active": true + } + }, + { + "name": "We Found John!", + "itemAttr": { + "handle": "ae7339df26ded", + "parent": "88706ddc78b1b", + "root": "7031beac91f75", + "order": 0, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "expanded": false, + "heading": "H0", + "charCount": 189, + "wordCount": 37, + "paraCount": 1, + "cursorPos": 0 + }, + "nameAttr": { + "status": "s90e6c9", + "import": "ia857f0", + "active": true + } + }, + { + "name": "Sequel", + "itemAttr": { + "handle": "e5e47ebf63b1c", + "parent": null, + "root": "e5e47ebf63b1c", + "order": 1, + "type": "ROOT", + "class": "NOVEL", + "layout": "NO_LAYOUT" + }, + "metaAttr": { + "expanded": true, + "heading": "H0", + "charCount": 0, + "wordCount": 0, + "paraCount": 0, + "cursorPos": 0 + }, + "nameAttr": { + "status": "sf12341", + "import": "ia857f0", + "active": false + } + }, + { + "name": "Title Page", + "itemAttr": { + "handle": "bacb7059e3083", + "parent": "e5e47ebf63b1c", + "root": "e5e47ebf63b1c", + "order": 0, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "expanded": false, + "heading": "H0", + "charCount": 27, + "wordCount": 5, + "paraCount": 1, + "cursorPos": 100 + }, + "nameAttr": { + "status": "sc24b8f", + "import": "ia857f0", + "active": true + } + }, + { + "name": "Chapter One", + "itemAttr": { + "handle": "a520879ca0b45", + "parent": "e5e47ebf63b1c", + "root": "e5e47ebf63b1c", + "order": 1, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT" + }, + "metaAttr": { + "expanded": false, + "heading": "H0", + "charCount": 299, + "wordCount": 55, + "paraCount": 2, + "cursorPos": 104 + }, + "nameAttr": { + "status": "s90e6c9", + "import": "ia857f0", + "active": true + } + }, + { + "name": "Characters", + "itemAttr": { + "handle": "f6622b4617424", + "parent": null, + "root": "f6622b4617424", + "order": 2, + "type": "ROOT", + "class": "CHARACTER", + "layout": "NO_LAYOUT" + }, + "metaAttr": { + "expanded": true, + "heading": "H0", + "charCount": 0, + "wordCount": 0, + "paraCount": 0, + "cursorPos": 0 + }, + "nameAttr": { + "status": "sf12341", + "import": "ia857f0", + "active": false + } + }, + { + "name": "Main Characters", + "itemAttr": { + "handle": "f7e2d9f330615", + "parent": "f6622b4617424", + "root": "f6622b4617424", + "order": 0, + "type": "FOLDER", + "class": "CHARACTER", + "layout": "NO_LAYOUT" + }, + "metaAttr": { + "expanded": true, + "heading": "H0", + "charCount": 0, + "wordCount": 0, + "paraCount": 0, + "cursorPos": 0 + }, + "nameAttr": { + "status": "sf12341", + "import": "ia857f0", + "active": false + } + }, + { + "name": "John Smith", + "itemAttr": { + "handle": "14298de4d9524", + "parent": "f7e2d9f330615", + "root": "f6622b4617424", + "order": 0, + "type": "FILE", + "class": "CHARACTER", + "layout": "NOTE" + }, + "metaAttr": { + "expanded": false, + "heading": "H0", + "charCount": 49, + "wordCount": 9, + "paraCount": 1, + "cursorPos": 24 + }, + "nameAttr": { + "status": "sf12341", + "import": "icfb3a5", + "active": true + } + }, + { + "name": "Jane Smith", + "itemAttr": { + "handle": "bb2c23b3c42cc", + "parent": "f7e2d9f330615", + "root": "f6622b4617424", + "order": 1, + "type": "FILE", + "class": "CHARACTER", + "layout": "NOTE" + }, + "metaAttr": { + "expanded": false, + "heading": "H0", + "charCount": 55, + "wordCount": 9, + "paraCount": 1, + "cursorPos": 25 + }, + "nameAttr": { + "status": "sf12341", + "import": "i2d7a54", + "active": true + } + }, + { + "name": "Locations", + "itemAttr": { + "handle": "15c4492bd5107", + "parent": null, + "root": "15c4492bd5107", + "order": 3, + "type": "ROOT", + "class": "WORLD", + "layout": "NO_LAYOUT" + }, + "metaAttr": { + "expanded": true, + "heading": "H0", + "charCount": 0, + "wordCount": 0, + "paraCount": 0, + "cursorPos": 0 + }, + "nameAttr": { + "status": "sf12341", + "import": "ia857f0", + "active": false + } + }, + { + "name": "Earth", + "itemAttr": { + "handle": "b3e74dbc1f584", + "parent": "15c4492bd5107", + "root": "15c4492bd5107", + "order": 0, + "type": "FILE", + "class": "WORLD", + "layout": "NOTE" + }, + "metaAttr": { + "expanded": false, + "heading": "H0", + "charCount": 76, + "wordCount": 15, + "paraCount": 1, + "cursorPos": 20 + }, + "nameAttr": { + "status": "sf12341", + "import": "i56be10", + "active": true + } + }, + { + "name": "Space", + "itemAttr": { + "handle": "f1471bef9f2ae", + "parent": "15c4492bd5107", + "root": "15c4492bd5107", + "order": 1, + "type": "FILE", + "class": "WORLD", + "layout": "NOTE" + }, + "metaAttr": { + "expanded": false, + "heading": "H0", + "charCount": 115, + "wordCount": 24, + "paraCount": 1, + "cursorPos": 133 + }, + "nameAttr": { + "status": "sf12341", + "import": "icfb3a5", + "active": true + } + }, + { + "name": "Mars", + "itemAttr": { + "handle": "5eaea4e8cdee8", + "parent": "15c4492bd5107", + "root": "15c4492bd5107", + "order": 2, + "type": "FILE", + "class": "WORLD", + "layout": "NOTE" + }, + "metaAttr": { + "expanded": false, + "heading": "H0", + "charCount": 28, + "wordCount": 6, + "paraCount": 1, + "cursorPos": 45 + }, + "nameAttr": { + "status": "sf12341", + "import": "i2d7a54", + "active": true + } + }, + { + "name": "Archive", + "itemAttr": { + "handle": "6827118336ac1", + "parent": null, + "root": "6827118336ac1", + "order": 4, + "type": "ROOT", + "class": "ARCHIVE", + "layout": "NO_LAYOUT" + }, + "metaAttr": { + "expanded": true, + "heading": "H0", + "charCount": 0, + "wordCount": 0, + "paraCount": 0, + "cursorPos": 0 + }, + "nameAttr": { + "status": "sf12341", + "import": "ia857f0", + "active": false + } + }, + { + "name": "Scenes", + "itemAttr": { + "handle": "ae9bf3c3ea159", + "parent": "6827118336ac1", + "root": "6827118336ac1", + "order": 0, + "type": "FOLDER", + "class": "ARCHIVE", + "layout": "NO_LAYOUT" + }, + "metaAttr": { + "expanded": true, + "heading": "H0", + "charCount": 0, + "wordCount": 0, + "paraCount": 0, + "cursorPos": 0 + }, + "nameAttr": { + "status": "sf12341", + "import": "ia857f0", + "active": false + } + }, + { + "name": "Old File", + "itemAttr": { + "handle": "8a5deb88c0e97", + "parent": "ae9bf3c3ea159", + "root": "6827118336ac1", + "order": 0, + "type": "FILE", + "class": "ARCHIVE", + "layout": "DOCUMENT" + }, + "metaAttr": { + "expanded": false, + "heading": "H0", + "charCount": 232, + "wordCount": 42, + "paraCount": 1, + "cursorPos": 239 + }, + "nameAttr": { + "status": "s90e6c9", + "import": "ia857f0", + "active": true + } + }, + { + "name": "Trash", + "itemAttr": { + "handle": "98acd8c76c93a", + "parent": null, + "root": "98acd8c76c93a", + "order": 5, + "type": "ROOT", + "class": "TRASH", + "layout": "NO_LAYOUT" + }, + "metaAttr": { + "expanded": true, + "heading": "H0", + "charCount": 0, + "wordCount": 0, + "paraCount": 0, + "cursorPos": 0 + }, + "nameAttr": { + "status": "sf12341", + "import": "ia857f0", + "active": false + } + }, + { + "name": "Delete Me!", + "itemAttr": { + "handle": "b8136a5a774a0", + "parent": "98acd8c76c93a", + "root": "98acd8c76c93a", + "order": 0, + "type": "FILE", + "class": "TRASH", + "layout": "DOCUMENT" + }, + "metaAttr": { + "expanded": false, + "heading": "H0", + "charCount": 30, + "wordCount": 6, + "paraCount": 1, + "cursorPos": 36 + }, + "nameAttr": { + "status": "sf12341", + "import": "ia857f0", + "active": true + } + } +] diff --git a/tests/reference/projectXML_ReadLegacy14.nwx b/tests/reference/projectXML_ReadLegacy14.nwx new file mode 100644 index 00000000..8cd18728 --- /dev/null +++ b/tests/reference/projectXML_ReadLegacy14.nwx @@ -0,0 +1,157 @@ + + + + Sample Project + Sample Project + Jane Smith + Jay Doh + + + yes + en_GB + en_GB + + None + None + None + None + + + B + E + D + + + %title% + Chapter %chw%: %title% + %title% + Scene %ch%.%sc%: %title% + + + + New + Notes + Started + 1st Draft + 2nd Draft + 3rd Draft + Finished + + + None + Minor + Major + Main + + + + + + Novel + + + + Title Page + + + + Page + + + + Part One + + + + Chapter One + + + + Making a Scene + + + + Another Scene + + + + Interlude + + + + A Note on Structure + + + + Chapter Two + + + + We Found John! + + + + Sequel + + + + Title Page + + + + Chapter One + + + + Characters + + + + Main Characters + + + + John Smith + + + + Jane Smith + + + + Locations + + + + Earth + + + + Space + + + + Mars + + + + Archive + + + + Scenes + + + + Old File + + + + Trash + + + + Delete Me! + + + diff --git a/tests/test_base/test_base_common.py b/tests/test_base/test_base_common.py index 0ce153b4..e11895dc 100644 --- a/tests/test_base/test_base_common.py +++ b/tests/test_base/test_base_common.py @@ -19,82 +19,132 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import hashlib -import os import time import pytest +import hashlib + +from pathlib import Path from mock import causeOSError from tools import writeFile from novelwriter.guimain import GuiMain from novelwriter.common import ( - checkString, checkInt, checkFloat, checkBool, checkHandle, isHandle, - isTitleTag, isItemClass, isItemType, isItemLayout, hexToInt, checkIntRange, - minmax, checkIntTuple, formatInt, formatTimeStamp, formatTime, simplified, - splitVersionNumber, transferCase, fuzzyTime, numberToRoman, jsonEncode, - readTextFile, makeFileNameSafe, sha256sum, getGuiItem, NWConfigParser + checkStringNone, checkString, checkInt, checkFloat, checkBool, checkHandle, + checkUuid, checkPath, isHandle, isTitleTag, isItemClass, isItemType, + isItemLayout, hexToInt, minmax, checkIntTuple, formatInt, formatTimeStamp, + formatTime, simplified, yesNo, splitVersionNumber, transferCase, fuzzyTime, + numberToRoman, jsonEncode, readTextFile, makeFileNameSafe, sha256sum, + getGuiItem, NWConfigParser ) @pytest.mark.base -def testBaseCommon_CheckString(): - """Test the checkString function. +def testBaseCommon_CheckStringNone(): + """Test the checkStringNone function. """ - assert checkString(None, "NotNone", True) is None - assert checkString("None", "NotNone", True) is None - assert checkString("None", "NotNone", False) == "None" - assert checkString(None, "NotNone", False) == "NotNone" - assert checkString(1, "NotNone", False) == "NotNone" - assert checkString(1.0, "NotNone", False) == "NotNone" - assert checkString(True, "NotNone", False) == "NotNone" + assert checkStringNone("Stuff", "NotNone") == "Stuff" + assert checkStringNone("None", "NotNone") is None + assert checkStringNone(None, "NotNone") is None + assert checkStringNone(1, "NotNone") == "NotNone" + assert checkStringNone(1.0, "NotNone") == "NotNone" + assert checkStringNone(True, "NotNone") == "NotNone" + +# END Test testBaseCommon_CheckStringNone + + +@pytest.mark.base +def testBaseCommon_CheckString(): + """Test the checkString function. Anything that is a string should + be returned, otherwise it returns the default. + """ + assert checkString("None", "default") == "None" + assert checkString("Text", "default") == "Text" + assert checkString(None, "default") == "default" + assert checkString(1, "default") == "default" + assert checkString(1.0, "default") == "default" + assert checkString(True, "default") == "default" # END Test testBaseCommon_CheckString @pytest.mark.base def testBaseCommon_CheckInt(): - """Test the checkInt function. + """Test the checkInt function. Anything that can be converted to an + integer should be returned, otherwise it returns the default. """ - assert checkInt(None, 3, True) is None - assert checkInt("None", 3, True) is None - assert checkInt(None, 3, False) == 3 - assert checkInt(1, 3, False) == 1 - assert checkInt(1.0, 3, False) == 1 - assert checkInt(True, 3, False) == 1 + assert checkInt(1, 3) == 1 + assert checkInt(1.0, 3) == 1 + assert checkInt(True, 3) == 1 + assert checkInt(False, 3) == 0 + assert checkInt(None, 3) == 3 + assert checkInt("1", 3) == 1 + assert checkInt("1.0", 3) == 3 # END Test testBaseCommon_CheckInt @pytest.mark.base def testBaseCommon_CheckFloat(): - """Test the checkFloat function. + """Test the checkFloat function. Anything that can be converted to an + integer should be returned, otherwise it returns the default. """ - assert checkFloat(None, 3.0, True) is None - assert checkFloat("None", 3.0, True) is None - assert checkFloat(None, 3.0, False) == 3.0 - assert checkFloat(1, 3.0, False) == 1.0 - assert checkFloat(1.0, 3.0, False) == 1.0 - assert checkFloat(True, 3.0, False) == 1.0 + assert checkFloat(1, 3.0) == 1.0 + assert checkFloat(1.0, 3.0) == 1.0 + assert checkFloat(True, 3.0) == 1.0 + assert checkFloat(False, 3.0) == 0.0 + assert checkFloat(None, 3.0) == 3.0 + assert checkFloat("1", 3.0) == 1.0 + assert checkFloat("1.0", 3.0) == 1.0 # END Test testBaseCommon_CheckInt @pytest.mark.base def testBaseCommon_CheckBool(): - """Test the checkBool function. + """Test the checkBool function. Any bool, string version of Python + bool, or integer 1 or 0, are returned as bool. Otherwise, the + default is returned. """ - assert checkBool(None, 3, True) is None - assert checkBool("None", 3, True) is None - assert checkBool("True", False, False) is True - assert checkBool("False", True, False) is False - assert checkBool("Boo", None, False) is None - assert checkBool(0, None, False) is False - assert checkBool(1, None, False) is True - assert checkBool(2, None, False) is None - assert checkBool(0.0, None, False) is None - assert checkBool(1.0, None, False) is None - assert checkBool(2.0, None, False) is None + # Bools + assert checkBool(True, False) is True + assert checkBool(False, True) is False + + # Valid Strings + assert checkBool("True", False) is True + assert checkBool("False", True) is False + assert checkBool("true", False) is True + assert checkBool("false", True) is False + assert checkBool("Yes", False) is True + assert checkBool("No", True) is False + assert checkBool("yes", False) is True + assert checkBool("no", True) is False + assert checkBool("On", False) is True + assert checkBool("Off", True) is False + assert checkBool("on", False) is True + assert checkBool("off", True) is False + + # Invalid Strings + assert checkBool("Foo", False) is False + assert checkBool("Foo", True) is True + assert checkBool("bar", False) is False + assert checkBool("bar", True) is True + + # Valid Integers + assert checkBool(0, True) is False + assert checkBool(1, False) is True + + # Inalid Integers + assert checkBool(2, True) is True + assert checkBool(2, False) is False + + # Other Types + assert checkBool(None, True) is True + assert checkBool(None, False) is False + assert checkBool(0.0, True) is True + assert checkBool(1.0, False) is False + assert checkBool(2.0, True) is True + assert checkBool(2.0, False) is False # END Test testBaseCommon_CheckBool @@ -113,6 +163,33 @@ def testBaseCommon_CheckHandle(): # END Test testBaseCommon_CheckHandle +@pytest.mark.base +def testBaseCommon_CheckUuid(): + """Test the checkUuid function. + """ + testUuid = "e2be99af-f9bf-4403-857a-c3d1ac25abea" + assert checkUuid("", None) is None + assert checkUuid("e2be99af-f9bf-4403-857a-c3d1ac25abe", None) is None + assert checkUuid("e2be99af-f9bf-qq03-857a-c3d1ac25abea", None) is None + assert checkUuid("e2be99af-f9bf-4403-857a-c3d1ac25abeaa", None) is None + assert checkUuid(testUuid, None) == testUuid + +# END Test testBaseCommon_CheckUuid + + +@pytest.mark.base +def testBaseCommon_CheckPath(): + """Test the checkPath function. + """ + assert checkPath(Path("test"), None) == Path("test") + assert checkPath("test", None) == Path("test") + assert checkPath(None, None) is None + assert checkPath("", None) is None + assert checkPath(" ", None) is None + +# END Test testBaseCommon_CheckPath + + @pytest.mark.base def testBaseCommon_IsHandle(): """Test the isHandle function. @@ -228,18 +305,6 @@ def testBaseCommon_HexToInt(): # END Test testBaseCommon_HexToInt -@pytest.mark.base -def testBaseCommon_CheckIntRange(): - """Test the checkIntRange function. - """ - assert checkIntRange(5, 0, 9, 3) == 5 - assert checkIntRange(5, 0, 4, 3) == 3 - assert checkIntRange(5, 0, 5, 3) == 5 - assert checkIntRange(0, 0, 5, 3) == 0 - -# END Test testBaseCommon_CheckIntRange - - @pytest.mark.base def testBaseCommon_MinMax(): """Test the minmax function. @@ -305,21 +370,49 @@ def testBaseCommon_Simplified(): # END Test testBaseCommon_Simplified +@pytest.mark.base +def testBaseCommon_YesNo(): + """Test the yesNo function. + """ + # Bool + assert yesNo(True) == "yes" + assert yesNo(False) == "no" + + # None + assert yesNo(None) == "no" + + # String + assert yesNo("foo") == "yes" + assert yesNo("") == "no" + + # Integer + assert yesNo(0) == "no" + assert yesNo(1) == "yes" + assert yesNo(2) == "yes" + + # Float + assert yesNo(0.0) == "no" + assert yesNo(1.0) == "yes" + assert yesNo(2.0) == "yes" + +# END Test testBaseCommon_YesNo + + @pytest.mark.base def testBaseCommon_SplitVersionNumber(): """Test the splitVersionNumber function. """ # OK Values - assert splitVersionNumber("1") == [1, 0, 0, 10000] - assert splitVersionNumber("1.2") == [1, 2, 0, 10200] - assert splitVersionNumber("1.2.3") == [1, 2, 3, 10203] - assert splitVersionNumber("1.2.3.4") == [1, 2, 3, 10203] - assert splitVersionNumber("99.99.99") == [99, 99, 99, 999999] + assert splitVersionNumber("1") == (1, 0, 0, 10000) + assert splitVersionNumber("1.2") == (1, 2, 0, 10200) + assert splitVersionNumber("1.2.3") == (1, 2, 3, 10203) + assert splitVersionNumber("1.2.3.4") == (1, 2, 3, 10203) + assert splitVersionNumber("99.99.99") == (99, 99, 99, 999999) # Failed Values - assert splitVersionNumber(None) == [0, 0, 0, 0] - assert splitVersionNumber(1234) == [0, 0, 0, 0] - assert splitVersionNumber("1.2abc") == [1, 0, 0, 10000] + assert splitVersionNumber(None) == (0, 0, 0, 0) + assert splitVersionNumber(1234) == (0, 0, 0, 0) + assert splitVersionNumber("1.2abc") == (1, 0, 0, 10000) # END Test testBaseCommon_SplitVersionNumber @@ -512,18 +605,18 @@ def testBaseCommon_JsonEncode(): @pytest.mark.base -def testBaseCommon_ReadTextFile(monkeypatch, fncDir, ipsumText): +def testBaseCommon_ReadTextFile(monkeypatch, fncPath, ipsumText): """Test the readTextFile function. """ testText = "\n\n".join(ipsumText) + "\n" - testFile = os.path.join(fncDir, "ipsum.txt") + testFile = fncPath / "ipsum.txt" writeFile(testFile, testText) - assert readTextFile(os.path.join(fncDir, "not_a_file.txt")) == "" + assert readTextFile(fncPath / "not_a_file.txt") == "" assert readTextFile(testFile) == testText with monkeypatch.context() as mp: - mp.setattr("builtins.open", causeOSError) + mp.setattr("pathlib.Path.read_text", causeOSError) assert readTextFile(testFile) == "" # END Test testBaseCommon_ReadTextFile @@ -542,7 +635,7 @@ def testBaseCommon_MakeFileNameSafe(): @pytest.mark.base -def testBaseCommon_Sha256Sum(monkeypatch, fncDir, ipsumText): +def testBaseCommon_Sha256Sum(monkeypatch, fncPath, ipsumText): """Test the sha256sum function. """ longText = 50*(" ".join(ipsumText) + " ") @@ -551,9 +644,9 @@ def testBaseCommon_Sha256Sum(monkeypatch, fncDir, ipsumText): assert len(longText) == 175650 - longFile = os.path.join(fncDir, "long_file.txt") - shortFile = os.path.join(fncDir, "short_file.txt") - noneFile = os.path.join(fncDir, "none_file.txt") + longFile = fncPath / "long_file.txt" + shortFile = fncPath / "short_file.txt" + noneFile = fncPath / "none_file.txt" writeFile(longFile, longText) writeFile(shortFile, shortText) @@ -592,10 +685,10 @@ def testBaseCommon_GetGuiItem(nwGUI): @pytest.mark.base -def testBaseCommon_NWConfigParser(fncDir): +def testBaseCommon_NWConfigParser(fncPath): """Test the NWConfigParser subclass. """ - tstConf = os.path.join(fncDir, "test.cfg") + tstConf = fncPath / "test.cfg" writeFile(tstConf, ( "[main]\n" "stropt = value\n" @@ -645,10 +738,10 @@ def testBaseCommon_NWConfigParser(fncDir): # Read Float assert cfgParser.rdFlt("main", "intopt1", 13.0) == 42.0 assert cfgParser.rdFlt("main", "float1", 13.0) == 4.2 - assert cfgParser.rdInt("main", "stropt", 13.0) == 13.0 + assert cfgParser.rdFlt("main", "stropt", 13.0) == 13.0 - assert cfgParser.rdInt("nope", "intopt1", 13.0) == 13.0 - assert cfgParser.rdInt("main", "blabla", 13.0) == 13.0 + assert cfgParser.rdFlt("nope", "intopt1", 13.0) == 13.0 + assert cfgParser.rdFlt("main", "blabla", 13.0) == 13.0 # Read String List assert cfgParser.rdStrList("main", "list1", []) == [] @@ -676,9 +769,4 @@ def testBaseCommon_NWConfigParser(fncDir): assert cfgParser.rdIntList("nope", "list2", [1]) == [1] assert cfgParser.rdIntList("main", "blabla", [1]) == [1] - # Internal - # ======== - - assert cfgParser._parseLine("main", "stropt", None, 999) is None - # END Test testBaseCommon_NWConfigParser diff --git a/tests/test_base/test_base_config.py b/tests/test_base/test_base_config.py index 2e055884..8282e558 100644 --- a/tests/test_base/test_base_config.py +++ b/tests/test_base/test_base_config.py @@ -19,16 +19,16 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import os import sys import pytest from shutil import copyfile +from pathlib import Path from mock import causeOSError, MockApp from tools import cmpFiles, writeFile -from novelwriter.config import Config +from novelwriter.config import Config, RecentProjects from novelwriter.constants import nwFiles @@ -37,204 +37,142 @@ def testBaseConfig_Constructor(monkeypatch): """Test config contructor. """ # Linux - monkeypatch.setattr("sys.platform", "linux") - tstConf = Config() - assert tstConf.osLinux is True - assert tstConf.osDarwin is False - assert tstConf.osWindows is False - assert tstConf.osUnknown is False + with monkeypatch.context() as mp: + mp.setattr("sys.platform", "linux") + tstConf = Config() + assert tstConf.osLinux is True + assert tstConf.osDarwin is False + assert tstConf.osWindows is False + assert tstConf.osUnknown is False # macOS - monkeypatch.setattr("sys.platform", "darwin") - tstConf = Config() - assert tstConf.osLinux is False - assert tstConf.osDarwin is True - assert tstConf.osWindows is False - assert tstConf.osUnknown is False + with monkeypatch.context() as mp: + mp.setattr("sys.platform", "darwin") + tstConf = Config() + assert tstConf.osLinux is False + assert tstConf.osDarwin is True + assert tstConf.osWindows is False + assert tstConf.osUnknown is False # Windows - monkeypatch.setattr("sys.platform", "win32") - tstConf = Config() - assert tstConf.osLinux is False - assert tstConf.osDarwin is False - assert tstConf.osWindows is True - assert tstConf.osUnknown is False + with monkeypatch.context() as mp: + mp.setattr("sys.platform", "win32") + tstConf = Config() + assert tstConf.osLinux is False + assert tstConf.osDarwin is False + assert tstConf.osWindows is True + assert tstConf.osUnknown is False # Cygwin - monkeypatch.setattr("sys.platform", "cygwin") - tstConf = Config() - assert tstConf.osLinux is False - assert tstConf.osDarwin is False - assert tstConf.osWindows is True - assert tstConf.osUnknown is False + with monkeypatch.context() as mp: + mp.setattr("sys.platform", "cygwin") + tstConf = Config() + assert tstConf.osLinux is False + assert tstConf.osDarwin is False + assert tstConf.osWindows is True + assert tstConf.osUnknown is False # Other - monkeypatch.setattr("sys.platform", "some_other_os") - tstConf = Config() - assert tstConf.osLinux is False - assert tstConf.osDarwin is False - assert tstConf.osWindows is False - assert tstConf.osUnknown is True + with monkeypatch.context() as mp: + mp.setattr("sys.platform", "some_other_os") + tstConf = Config() + assert tstConf.osLinux is False + assert tstConf.osDarwin is False + assert tstConf.osWindows is False + assert tstConf.osUnknown is True + + # App is single file + with monkeypatch.context() as mp: + mp.setattr("pathlib.Path.is_file", lambda *a: True) + tstConf = Config() + assert tstConf._appPath == tstConf._appRoot # END Test testBaseConfig_Constructor @pytest.mark.base -def testBaseConfig_Init(monkeypatch, tmpDir, fncDir, outDir, refDir, filesDir): +def testBaseConfig_InitLoadSave(monkeypatch, fncPath, tstPaths): """Test config intialisation. """ tstConf = Config() - confFile = os.path.join(tmpDir, "novelwriter.conf") - testFile = os.path.join(outDir, "baseConfig_novelwriter.conf") - compFile = os.path.join(refDir, "baseConfig_novelwriter.conf") + confFile = fncPath / nwFiles.CONF_FILE + testFile = tstPaths.outDir / "baseConfig_novelwriter.conf" + compFile = tstPaths.refDir / "baseConfig_novelwriter.conf" # Make sure we don't have any old conf file - if os.path.isfile(confFile): - os.unlink(confFile) + if confFile.is_file(): + confFile.unlink() - # Let the config class figure out the path - with monkeypatch.context() as mp: - mp.setattr("PyQt5.QtCore.QStandardPaths.writableLocation", lambda *a: fncDir) - tstConf.verQtValue = 50600 - tstConf.initConfig() - assert tstConf.confPath == os.path.join(fncDir, tstConf.appHandle) - assert tstConf.dataPath == os.path.join(fncDir, tstConf.appHandle) - assert not os.path.isfile(confFile) - tstConf.verQtValue = 50000 - tstConf.initConfig() - assert tstConf.confPath == os.path.join(fncDir, tstConf.appHandle) - assert tstConf.dataPath == os.path.join(fncDir, tstConf.appHandle) - assert not os.path.isfile(confFile) + # Running init against a new oath should write a new config file + tstConf.initConfig(confPath=fncPath, dataPath=fncPath) + assert tstConf._confPath == fncPath + assert tstConf._dataPath == fncPath + assert confFile.exists() - # Fail to make folders - with monkeypatch.context() as mp: - mp.setattr("os.mkdir", causeOSError) + # Check that we have a default file + copyfile(confFile, testFile) + ignore = ("timestamp", "lastnotes", "localisation", "lastpath") + assert cmpFiles(testFile, compFile, ignoreStart=ignore) + tstConf.errorText() # This clears the error cache - tstConfDir = os.path.join(fncDir, "test_conf") - tstConf.initConfig(confPath=tstConfDir, dataPath=tmpDir) - assert tstConf.confPath is None - assert tstConf.dataPath == tmpDir - assert not os.path.isfile(confFile) - - tstDataDir = os.path.join(fncDir, "test_data") - tstConf.initConfig(confPath=tmpDir, dataPath=tstDataDir) - assert tstConf.confPath == tmpDir - assert tstConf.dataPath is None - assert os.path.isfile(confFile) - os.unlink(confFile) - - # Test load/save with no path - tstConf.confPath = None - assert tstConf.loadConfig() is False - assert tstConf.saveConfig() is False - - # Run again and set the paths directly and correctly - # This should create a config file as well - with monkeypatch.context() as mp: - mp.setattr("os.path.expanduser", lambda *a: "") - tstConf.initConfig(confPath=tmpDir, dataPath=tmpDir) - assert tstConf.confPath == tmpDir - assert tstConf.dataPath == tmpDir - assert os.path.isfile(confFile) - - copyfile(confFile, testFile) - assert cmpFiles(testFile, compFile, ignoreStart=("timestamp", "lastnotes", "guilang")) - - # Load and save with OSError + # Block saving the file with monkeypatch.context() as mp: mp.setattr("builtins.open", causeOSError) - - assert not tstConf.loadConfig() + assert tstConf.saveConfig() is False assert tstConf.hasError is True - assert tstConf.errData != [] - assert tstConf.getErrData().startswith("Could not") - assert tstConf.hasError is False - assert tstConf.errData == [] + assert tstConf.errorText().startswith("Could not save config file") - assert not tstConf.saveConfig() - assert tstConf.hasError is True - assert tstConf.errData != [] - assert tstConf.getErrData().startswith("Could not") - assert tstConf.hasError is False - assert tstConf.errData == [] - - # Check handling of novelWriter as a package + # Block loading the file with monkeypatch.context() as mp: - tstConf.initConfig(confPath=tmpDir, dataPath=tmpDir) - assert tstConf.confPath == tmpDir - assert tstConf.dataPath == tmpDir - appRoot = tstConf.appRoot + mp.setattr("builtins.open", causeOSError) + assert tstConf.loadConfig() is False + assert tstConf.hasError is True + assert tstConf.errorText().startswith("Could not load config file") - mp.setattr("os.path.isfile", lambda *a: True) - tstConf.initConfig(confPath=tmpDir, dataPath=tmpDir) - assert tstConf.confPath == tmpDir - assert tstConf.dataPath == tmpDir - assert tstConf.appRoot == os.path.dirname(appRoot) - assert tstConf.appPath == os.path.dirname(appRoot) - - assert tstConf.loadConfig() is True + # Change a few settings, save, reset, and reload + tstConf.guiTheme = "foo" + tstConf.guiSyntax = "bar" assert tstConf.saveConfig() is True - # Test Correcting Quote Settings - origDbl = tstConf.fmtDoubleQuotes - origSng = tstConf.fmtSingleQuotes - orDoDbl = tstConf.doReplaceDQuote - orDoSng = tstConf.doReplaceSQuote + newConf = Config() + newConf.initConfig(confPath=fncPath, dataPath=fncPath) + assert newConf.guiTheme == "foo" + assert newConf.guiSyntax == "bar" - tstConf.fmtDoubleQuotes = ["\"", "\""] - tstConf.fmtSingleQuotes = ["'", "'"] + # Test Correcting Quote Settings + tstConf.fmtDQuoteOpen = "\"" + tstConf.fmtDQuoteClose = "\"" + tstConf.fmtSQuoteOpen = "'" + tstConf.fmtSQuoteClose = "'" tstConf.doReplaceDQuote = True tstConf.doReplaceSQuote = True assert tstConf.saveConfig() is True - assert tstConf.loadConfig() is True - assert tstConf.doReplaceDQuote is False - assert tstConf.doReplaceSQuote is False + assert newConf.loadConfig() is True + assert newConf.doReplaceDQuote is False + assert newConf.doReplaceSQuote is False - tstConf.fmtDoubleQuotes = origDbl - tstConf.fmtSingleQuotes = origSng - tstConf.doReplaceDQuote = orDoDbl - tstConf.doReplaceSQuote = orDoSng - assert tstConf.saveConfig() is True +# END Test testBaseConfig_InitLoadSave - # Test Correcting icon theme - origIcons = tstConf.guiIcons - tstConf.guiIcons = "typicons_colour_dark" - assert tstConf.saveConfig() is True - assert tstConf.loadConfig() is True - assert tstConf.guiIcons == "typicons_dark" - - tstConf.guiIcons = "typicons_grey_dark" - assert tstConf.saveConfig() is True - assert tstConf.loadConfig() is True - assert tstConf.guiIcons == "typicons_dark" - - tstConf.guiIcons = "typicons_colour_light" - assert tstConf.saveConfig() is True - assert tstConf.loadConfig() is True - assert tstConf.guiIcons == "typicons_light" - - tstConf.guiIcons = "typicons_grey_light" - assert tstConf.saveConfig() is True - assert tstConf.loadConfig() is True - assert tstConf.guiIcons == "typicons_light" - - tstConf.guiIcons = origIcons - assert tstConf.saveConfig() +@pytest.mark.base +def testBaseConfig_Localisation(fncPath, tstPaths): + """Test localisation. + """ + tstConf = Config() + tstConf.initConfig(confPath=fncPath, dataPath=fncPath) # Localisation # ============ - i18nDir = os.path.join(fncDir, "i18n") - os.mkdir(i18nDir) - os.mkdir(os.path.join(i18nDir, "stuff")) - tstConf.nwLangPath = i18nDir + i18nDir = fncPath / "i18n" + i18nDir.mkdir() + tstConf._nwLangPath = str(i18nDir) - copyfile(os.path.join(filesDir, "nw_en_GB.qm"), os.path.join(i18nDir, "nw_en_GB.qm")) - writeFile(os.path.join(i18nDir, "nw_en_GB.ts"), "") - writeFile(os.path.join(i18nDir, "nw_abcd.qm"), "") + copyfile(tstPaths.filesDir / "nw_en_GB.qm", i18nDir / "nw_en_GB.qm") + writeFile(i18nDir / "nw_en_GB.ts", "") + writeFile(i18nDir / "nw_abcd.qm", "") tstApp = MockApp() tstConf.initLocalisation(tstApp) @@ -248,120 +186,55 @@ def testBaseConfig_Init(monkeypatch, tmpDir, fncDir, outDir, refDir, filesDir): assert theList == [] # Add Language - copyfile(os.path.join(filesDir, "nw_en_GB.qm"), os.path.join(i18nDir, "nw_fr.qm")) - writeFile(os.path.join(i18nDir, "nw_fr.ts"), "") + copyfile(tstPaths.filesDir / "nw_en_GB.qm", i18nDir / "nw_fr.qm") + writeFile(i18nDir / "nw_fr.ts", "") theList = tstConf.listLanguages(tstConf.LANG_NW) assert theList == [("en_GB", "British English"), ("fr", "Français")] - copyfile(confFile, testFile) - assert cmpFiles(testFile, compFile, ignoreStart=("timestamp", "lastnotes", "guilang")) - -# END Test testBaseConfig_Init +# END Test testBaseConfig_Localisation @pytest.mark.base -def testBaseConfig_RecentCache(monkeypatch, tmpConf, tmpDir, fncDir): - """Test recent cache file. +def testBaseConfig_Methods(tmpConf, tmpPath): + """Check class methods. """ - # Check failing - tmpConf.dataPath = None - assert not tmpConf.loadRecentCache() - assert not tmpConf.saveRecentCache() - tmpConf.dataPath = tmpDir - - # Add a couple of values - pathOne = os.path.join(fncDir, "projPathOne", nwFiles.PROJ_FILE) - pathTwo = os.path.join(fncDir, "projPathTwo", nwFiles.PROJ_FILE) - assert tmpConf.updateRecentCache(pathOne, "Proj One", 100, 1600002000) - assert tmpConf.updateRecentCache(pathTwo, "Proj Two", 200, 1600005600) - assert tmpConf.recentProj == { - pathOne: {"time": 1600002000, "title": "Proj One", "words": 100}, - pathTwo: {"time": 1600005600, "title": "Proj Two", "words": 200}, - } - - # Fail to Save - with monkeypatch.context() as mp: - mp.setattr("builtins.open", causeOSError) - assert not tmpConf.saveRecentCache() - - # Save Proper - cacheFile = os.path.join(tmpDir, nwFiles.RECENT_FILE) - assert tmpConf.saveRecentCache() - assert tmpConf.saveRecentCache() - assert os.path.isfile(cacheFile) - - # Fail to Load - with monkeypatch.context() as mp: - mp.setattr("builtins.open", causeOSError) - tmpConf.recentProj = {} - assert not tmpConf.loadRecentCache() - assert tmpConf.recentProj == {} - - # Load Proper - tmpConf.recentProj = {} - assert tmpConf.loadRecentCache() - assert tmpConf.recentProj == { - pathOne: {"time": 1600002000, "title": "Proj One", "words": 100}, - pathTwo: {"time": 1600005600, "title": "Proj Two", "words": 200}, - } - - # Remove Non-Existent Entry - assert not tmpConf.removeFromRecentCache("stuff") - assert tmpConf.recentProj == { - pathOne: {"time": 1600002000, "title": "Proj One", "words": 100}, - pathTwo: {"time": 1600005600, "title": "Proj Two", "words": 200}, - } - - # Remove Second Entry - assert tmpConf.removeFromRecentCache(pathTwo) - assert tmpConf.recentProj == { - pathOne: {"time": 1600002000, "title": "Proj One", "words": 100}, - } - -# END Test testBaseConfig_RecentCache - - -@pytest.mark.base -def testBaseConfig_SetPath(tmpConf, tmpDir): - """Test path setters. - """ - # Conf Path - assert tmpConf.setConfPath(None) - assert not tmpConf.setConfPath(os.path.join("somewhere", "over", "the", "rainbow")) - assert tmpConf.setConfPath(os.path.join(tmpDir, "novelwriter.conf")) - assert tmpConf.confPath == tmpDir - assert tmpConf.confFile == "novelwriter.conf" - assert not tmpConf.confChanged - # Data Path - assert tmpConf.setDataPath(None) - assert not tmpConf.setDataPath(os.path.join("somewhere", "over", "the", "rainbow")) - assert tmpConf.setDataPath(tmpDir) - assert tmpConf.dataPath == tmpDir - assert not tmpConf.confChanged + assert tmpConf.dataPath() == tmpPath + assert tmpConf.dataPath("stuff") == tmpPath / "stuff" + + # Assets Path + appPath = tmpConf._appPath + assert tmpConf.assetPath() == appPath / "assets" + assert tmpConf.assetPath("stuff") == appPath / "assets" / "stuff" # Last Path - assert tmpConf.setLastPath(None) - assert tmpConf.lastPath == "" + assert tmpConf.lastPath() == tmpPath - assert tmpConf.setLastPath(os.path.join(tmpDir, "file.tmp")) - assert tmpConf.lastPath == tmpDir + tmpStuff = tmpPath / "stuff" + tmpStuff.mkdir() + tmpConf.setLastPath(tmpStuff) + assert tmpConf.lastPath() == tmpStuff - assert tmpConf.setLastPath("") - assert tmpConf.lastPath == "" + fileStuff = tmpStuff / "more_stuff.txt" + fileStuff.write_text("Stuff") + tmpConf.setLastPath(fileStuff) + assert tmpConf.lastPath() == tmpStuff -# END Test testBaseConfig_SetPath + fileStuff.unlink() + tmpStuff.rmdir() + assert tmpConf.lastPath() == Path.home().absolute() + + # Recent Projects + assert isinstance(tmpConf.recentProjects, RecentProjects) + +# END Test testBaseConfig_Methods @pytest.mark.base -def testBaseConfig_SettersGetters(tmpConf, tmpDir, outDir, refDir): +def testBaseConfig_SettersGetters(tmpConf): """Set various sizes and positions """ - confFile = os.path.join(tmpDir, "novelwriter.conf") - testFile = os.path.join(outDir, "baseConfig_novelwriter.conf") - compFile = os.path.join(refDir, "baseConfig_novelwriter.conf") - # GUI Scaling # =========== @@ -382,165 +255,101 @@ def testBaseConfig_SettersGetters(tmpConf, tmpDir, outDir, refDir): # Window Size tmpConf.guiScale = 1.0 - assert tmpConf.setWinSize(1205, 655) - assert not tmpConf.confChanged + tmpConf.setMainWinSize(1205, 655) + assert tmpConf.mainWinSize == [1200, 650] tmpConf.guiScale = 2.0 - assert tmpConf.setWinSize(70, 70) - assert tmpConf.getWinSize() == [70, 70] - assert tmpConf.winGeometry == [35, 35] + tmpConf.setMainWinSize(70, 70) + assert tmpConf.mainWinSize == [70, 70] + assert tmpConf._mainWinSize == [35, 35] tmpConf.guiScale = 1.0 - assert tmpConf.setWinSize(70, 70) - assert tmpConf.getWinSize() == [70, 70] - assert tmpConf.winGeometry == [70, 70] + tmpConf.setMainWinSize(70, 70) + assert tmpConf.mainWinSize == [70, 70] + assert tmpConf._mainWinSize == [70, 70] - assert tmpConf.setWinSize(1200, 650) + tmpConf.setMainWinSize(1200, 650) # Preferences Size tmpConf.guiScale = 2.0 - assert tmpConf.setPreferencesSize(70, 70) - assert tmpConf.getPreferencesSize() == [70, 70] - assert tmpConf.prefGeometry == [35, 35] + tmpConf.setPreferencesWinSize(70, 70) + assert tmpConf.preferencesWinSize == [70, 70] + assert tmpConf._prefsWinSize == [35, 35] tmpConf.guiScale = 1.0 - assert tmpConf.setPreferencesSize(70, 70) - assert tmpConf.getPreferencesSize() == [70, 70] - assert tmpConf.prefGeometry == [70, 70] + tmpConf.setPreferencesWinSize(70, 70) + assert tmpConf.preferencesWinSize == [70, 70] + assert tmpConf._prefsWinSize == [70, 70] - assert tmpConf.setPreferencesSize(700, 615) - - # Project Tree Columns - tmpConf.guiScale = 2.0 - assert tmpConf.setTreeColWidths([10, 20, 25]) - assert tmpConf.getTreeColWidths() == [10, 20, 24] - assert tmpConf.treeColWidth == [5, 10, 12] - - tmpConf.guiScale = 1.0 - assert tmpConf.setTreeColWidths([10, 20, 25]) - assert tmpConf.getTreeColWidths() == [10, 20, 25] - assert tmpConf.treeColWidth == [10, 20, 25] - - assert tmpConf.setTreeColWidths([200, 50, 30]) - - # Novel Tree Columns - tmpConf.guiScale = 2.0 - assert tmpConf.setNovelColWidths([10, 20]) - assert tmpConf.getNovelColWidths() == [10, 20] - assert tmpConf.novelColWidth == [5, 10] - - tmpConf.guiScale = 1.0 - assert tmpConf.setNovelColWidths([10, 20]) - assert tmpConf.getNovelColWidths() == [10, 20] - assert tmpConf.novelColWidth == [10, 20] - - assert tmpConf.setNovelColWidths([200, 50]) + tmpConf.setPreferencesWinSize(700, 615) # Project Settings Tree Columns tmpConf.guiScale = 2.0 - assert tmpConf.setProjColWidths([10, 20, 30]) - assert tmpConf.getProjColWidths() == [10, 20, 30] - assert tmpConf.projColWidth == [5, 10, 15] + tmpConf.setProjLoadColWidths([10, 20, 30]) + assert tmpConf.projLoadColWidths == [10, 20, 30] + assert tmpConf._projLoadCols == [5, 10, 15] tmpConf.guiScale = 1.0 - assert tmpConf.setProjColWidths([10, 20, 30]) - assert tmpConf.getProjColWidths() == [10, 20, 30] - assert tmpConf.projColWidth == [10, 20, 30] + tmpConf.setProjLoadColWidths([10, 20, 30]) + assert tmpConf.projLoadColWidths == [10, 20, 30] + assert tmpConf._projLoadCols == [10, 20, 30] - assert tmpConf.setProjColWidths([200, 60, 140]) + tmpConf.setProjLoadColWidths([200, 60, 140]) # Main Pane Splitter tmpConf.guiScale = 2.0 - assert tmpConf.setMainPanePos([200, 700]) - assert tmpConf.getMainPanePos() == [200, 700] - assert tmpConf.mainPanePos == [100, 350] - - tmpConf.guiScale = 1.0 - assert tmpConf.setMainPanePos([200, 700]) - assert tmpConf.getMainPanePos() == [200, 700] + tmpConf.setMainPanePos([200, 700]) assert tmpConf.mainPanePos == [200, 700] - - assert tmpConf.setMainPanePos([300, 800]) - - # Doc Pane Splitter - tmpConf.guiScale = 2.0 - assert tmpConf.setDocPanePos([300, 300]) - assert tmpConf.getDocPanePos() == [300, 300] - assert tmpConf.docPanePos == [150, 150] + assert tmpConf._mainPanePos == [100, 350] tmpConf.guiScale = 1.0 - assert tmpConf.setDocPanePos([300, 300]) - assert tmpConf.getDocPanePos() == [300, 300] - assert tmpConf.docPanePos == [300, 300] + tmpConf.setMainPanePos([200, 700]) + assert tmpConf.mainPanePos == [200, 700] + assert tmpConf._mainPanePos == [200, 700] - assert tmpConf.setDocPanePos([400, 400]) + tmpConf.setMainPanePos([300, 800]) # View Pane Splitter tmpConf.guiScale = 2.0 - assert tmpConf.setViewPanePos([400, 250]) - assert tmpConf.getViewPanePos() == [400, 250] - assert tmpConf.viewPanePos == [200, 125] + tmpConf.setViewPanePos([400, 250]) + assert tmpConf.viewPanePos == [400, 250] + assert tmpConf._viewPanePos == [200, 125] tmpConf.guiScale = 1.0 - assert tmpConf.setViewPanePos([400, 250]) - assert tmpConf.getViewPanePos() == [400, 250] + tmpConf.setViewPanePos([400, 250]) assert tmpConf.viewPanePos == [400, 250] + assert tmpConf._viewPanePos == [400, 250] - assert tmpConf.setViewPanePos([500, 150]) + tmpConf.setViewPanePos([500, 150]) # Outline Pane Splitter tmpConf.guiScale = 2.0 - assert tmpConf.setOutlinePanePos([400, 250]) - assert tmpConf.getOutlinePanePos() == [400, 250] - assert tmpConf.outlnPanePos == [200, 125] + tmpConf.setOutlinePanePos([400, 250]) + assert tmpConf.outlinePanePos == [400, 250] + assert tmpConf._outlnPanePos == [200, 125] tmpConf.guiScale = 1.0 - assert tmpConf.setOutlinePanePos([400, 250]) - assert tmpConf.getOutlinePanePos() == [400, 250] - assert tmpConf.outlnPanePos == [400, 250] + tmpConf.setOutlinePanePos([400, 250]) + assert tmpConf.outlinePanePos == [400, 250] + assert tmpConf._outlnPanePos == [400, 250] - assert tmpConf.setOutlinePanePos([500, 150]) + tmpConf.setOutlinePanePos([500, 150]) # Getters Only # ============ tmpConf.guiScale = 1.0 - assert tmpConf.getTextWidth(False) == 600 + assert tmpConf.getTextWidth(False) == 700 assert tmpConf.getTextWidth(True) == 800 assert tmpConf.getTextMargin() == 40 assert tmpConf.getTabWidth() == 40 tmpConf.guiScale = 2.0 - assert tmpConf.getTextWidth(False) == 1200 + assert tmpConf.getTextWidth(False) == 1400 assert tmpConf.getTextWidth(True) == 1600 assert tmpConf.getTextMargin() == 80 assert tmpConf.getTabWidth() == 80 - # Flag Setters - # ============ - - assert tmpConf.setShowRefPanel(False) is False - assert tmpConf.showRefPanel is False - assert tmpConf.setShowRefPanel(True) is True - - assert tmpConf.setViewComments(False) is False - assert tmpConf.viewComments is False - assert tmpConf.setViewComments(True) is True - - assert tmpConf.setViewSynopsis(False) is False - assert tmpConf.viewSynopsis is False - assert tmpConf.setViewSynopsis(True) is True - - # Check Final File - # ================ - - assert tmpConf.confChanged is True - assert tmpConf.saveConfig() is True - assert tmpConf.confChanged is False - - copyfile(confFile, testFile) - assert cmpFiles(testFile, compFile, ignoreStart=("timestamp", "lastnotes", "guilang")) - # END Test testBaseConfig_SettersGetters @@ -570,3 +379,68 @@ def testBaseConfig_Internal(monkeypatch, tmpConf): assert tmpConf.hasEnchant is False # END Test testBaseConfig_Internal + + +@pytest.mark.base +def testBaseConfig_RecentCache(monkeypatch, fncConf, fncPath): + """Test recent cache file. + """ + cacheFile = fncPath / nwFiles.RECENT_FILE + recent = RecentProjects(fncConf) + + # Load when there is no file should pass, but load nothing + assert not cacheFile.exists() + assert recent.loadCache() is True + assert recent.listEntries() == [] + + # Add a couple of values + pathOne = fncPath / "projPathOne" / nwFiles.PROJ_FILE + pathTwo = fncPath / "projPathTwo" / nwFiles.PROJ_FILE + + recent.update(pathOne, "Proj One", 100, 1600002000) + recent.update(pathTwo, "Proj Two", 200, 1600005600) + assert recent.listEntries() == [ + (str(pathOne), "Proj One", 100, 1600002000), + (str(pathTwo), "Proj Two", 200, 1600005600), + ] + assert cacheFile.exists() + cacheFile.unlink() + assert not cacheFile.exists() + + # Fail to Save + with monkeypatch.context() as mp: + mp.setattr("builtins.open", causeOSError) + assert recent.saveCache() is False + assert not cacheFile.exists() + + # Save Proper + assert recent.saveCache() is True + assert cacheFile.exists() + + # Fail to Load + with monkeypatch.context() as mp: + mp.setattr("builtins.open", causeOSError) + assert recent.loadCache() is False + assert recent.listEntries() == [] + + # Load Proper + assert recent.loadCache() is True + assert recent.listEntries() == [ + (str(pathOne), "Proj One", 100, 1600002000), + (str(pathTwo), "Proj Two", 200, 1600005600), + ] + + # Remove Non-Existent Entry + recent.remove("stuff") + assert recent.listEntries() == [ + (str(pathOne), "Proj One", 100, 1600002000), + (str(pathTwo), "Proj Two", 200, 1600005600), + ] + + # Remove Second Entry + recent.remove(pathTwo) + assert recent.listEntries() == [ + (str(pathOne), "Proj One", 100, 1600002000), + ] + +# END Test testBaseConfig_RecentCache diff --git a/tests/test_base/test_base_error.py b/tests/test_base/test_base_error.py index d2707b29..9ba9b9b2 100644 --- a/tests/test_base/test_base_error.py +++ b/tests/test_base/test_base_error.py @@ -20,9 +20,6 @@ along with this program. If not, see . """ import pytest -import novelwriter - -from PyQt5.QtWidgets import QMessageBox, qApp from mock import causeException @@ -30,18 +27,9 @@ from novelwriter.error import NWErrorMessage, exceptionHandler @pytest.mark.base -def testBaseError_Dialog(qtbot, monkeypatch, fncDir, tmpDir): +def testBaseError_Dialog(qtbot, monkeypatch, nwGUI): """Test the error dialog. """ - # Block message box - monkeypatch.setattr(QMessageBox, "warning", lambda *a: QMessageBox.Yes) - - qApp.closeAllWindows() - nwGUI = novelwriter.main(["--testmode", "--config=%s" % fncDir, "--data=%s" % tmpDir]) - qtbot.addWidget(nwGUI) - nwGUI.show() - qtbot.wait(20) - nwErr = NWErrorMessage(nwGUI) qtbot.addWidget(nwErr) nwErr.show() @@ -76,19 +64,11 @@ def testBaseError_Dialog(qtbot, monkeypatch, fncDir, tmpDir): @pytest.mark.base -def testBaseError_Handler(qtbot, monkeypatch, fncDir, tmpDir): +def testBaseError_Handler(qtbot, monkeypatch, nwGUI): """Test the error handler. This test doesn'thave any asserts, but it checks that the error handler handles potential exceptions. The test will fail if excpetions are not handled. """ - monkeypatch.setattr(QMessageBox, "warning", lambda *a: QMessageBox.Yes) - - qApp.closeAllWindows() - nwGUI = novelwriter.main(["--testmode", "--config=%s" % fncDir, "--data=%s" % tmpDir]) - qtbot.addWidget(nwGUI) - nwGUI.show() - qtbot.wait(20) - # Normal shutdown with monkeypatch.context() as mp: mp.setattr(NWErrorMessage, "exec_", lambda *a: None) diff --git a/tests/test_base/test_base_init.py b/tests/test_base/test_base_init.py index 9b7a6171..2cc4d255 100644 --- a/tests/test_base/test_base_init.py +++ b/tests/test_base/test_base_init.py @@ -28,13 +28,13 @@ from mock import MockGuiMain @pytest.mark.base -def testBaseInit_Launch(caplog, monkeypatch, tmpDir): +def testBaseInit_Launch(caplog, monkeypatch, tmpPath): """Check launching the main GUI. """ monkeypatch.setattr("novelwriter.guimain.GuiMain", MockGuiMain) # TestMode Launch - nwGUI = novelwriter.main(["--testmode", "--config=%s" % tmpDir, "--data=%s" % tmpDir]) + nwGUI = novelwriter.main(["--testmode", f"--config={tmpPath}", f"--data={tmpPath}"]) assert isinstance(nwGUI, MockGuiMain) # Darwin Launch @@ -43,7 +43,7 @@ def testBaseInit_Launch(caplog, monkeypatch, tmpDir): novelwriter.CONFIG.osDarwin = True with monkeypatch.context() as mp: mp.setitem(sys.modules, "Foundation", None) - nwGUI = novelwriter.main(["--testmode", "--config=%s" % tmpDir, "--data=%s" % tmpDir]) + nwGUI = novelwriter.main(["--testmode", f"--config={tmpPath}", f"--data={tmpPath}"]) assert isinstance(nwGUI, MockGuiMain) assert "Failed" in caplog.text @@ -55,7 +55,7 @@ def testBaseInit_Launch(caplog, monkeypatch, tmpDir): novelwriter.CONFIG.osWindows = True with monkeypatch.context() as mp: mp.setitem(sys.modules, "ctypes", None) - nwGUI = novelwriter.main(["--testmode", "--config=%s" % tmpDir, "--data=%s" % tmpDir]) + nwGUI = novelwriter.main(["--testmode", f"--config={tmpPath}", f"--data={tmpPath}"]) assert isinstance(nwGUI, MockGuiMain) if not sys.platform.startswith("darwin"): # For some reason, the test doesn't work on macOS @@ -71,19 +71,19 @@ def testBaseInit_Launch(caplog, monkeypatch, tmpDir): monkeypatch.setattr("PyQt5.QtWidgets.QApplication.setOrganizationDomain", lambda *a: None) monkeypatch.setattr("PyQt5.QtWidgets.QApplication.exec_", lambda *a: 0) with pytest.raises(SystemExit) as ex: - novelwriter.main(["--config=%s" % tmpDir, "--data=%s" % tmpDir]) + novelwriter.main([f"--config={tmpPath}", f"--data={tmpPath}"]) assert ex.value.code == 0 # END Test testBaseInit_Launch @pytest.mark.base -def testBaseInit_Options(monkeypatch, tmpDir): +def testBaseInit_Options(monkeypatch, tmpPath): """Test command line options for logging level. """ monkeypatch.setattr("novelwriter.guimain.GuiMain", MockGuiMain) monkeypatch.setattr(sys, "argv", [ - "novelWriter.py", "--testmode", "--config=%s" % tmpDir, "--data=%s" % tmpDir + "novelWriter.py", "--testmode", f"--config={tmpPath}", f"--data={tmpPath}" ]) # Defaults w/None Args @@ -93,41 +93,35 @@ def testBaseInit_Options(monkeypatch, tmpDir): # Defaults nwGUI = novelwriter.main( - ["--testmode", "--config=%s" % tmpDir, "--data=%s" % tmpDir, "--style=Fusion"] + ["--testmode", f"--config={tmpPath}", f"--data={tmpPath}", "--style=Fusion"] ) assert novelwriter.logger.getEffectiveLevel() == logging.WARNING assert nwGUI.closeMain() == "closeMain" # Log Levels nwGUI = novelwriter.main( - ["--testmode", "--info", "--config=%s" % tmpDir, "--data=%s" % tmpDir] + ["--testmode", "--info", f"--config={tmpPath}", f"--data={tmpPath}"] ) assert novelwriter.logger.getEffectiveLevel() == logging.INFO assert nwGUI.closeMain() == "closeMain" nwGUI = novelwriter.main( - ["--testmode", "--debug", "--config=%s" % tmpDir, "--data=%s" % tmpDir] + ["--testmode", "--debug", f"--config={tmpPath}", f"--data={tmpPath}"] ) assert novelwriter.logger.getEffectiveLevel() == logging.DEBUG assert nwGUI.closeMain() == "closeMain" - nwGUI = novelwriter.main( - ["--testmode", "--verbose", "--config=%s" % tmpDir, "--data=%s" % tmpDir] - ) - assert novelwriter.logger.getEffectiveLevel() == 5 - assert nwGUI.closeMain() == "closeMain" - # Help and Version with pytest.raises(SystemExit) as ex: nwGUI = novelwriter.main( - ["--testmode", "--help", "--config=%s" % tmpDir, "--data=%s" % tmpDir] + ["--testmode", "--help", f"--config={tmpPath}", f"--data={tmpPath}"] ) assert nwGUI.closeMain() == "closeMain" assert ex.value.code == 0 with pytest.raises(SystemExit) as ex: nwGUI = novelwriter.main( - ["--testmode", "--version", "--config=%s" % tmpDir, "--data=%s" % tmpDir] + ["--testmode", "--version", f"--config={tmpPath}", f"--data={tmpPath}"] ) assert nwGUI.closeMain() == "closeMain" assert ex.value.code == 0 @@ -135,23 +129,22 @@ def testBaseInit_Options(monkeypatch, tmpDir): # Invalid options with pytest.raises(SystemExit) as ex: nwGUI = novelwriter.main( - ["--testmode", "--invalid", "--config=%s" % tmpDir, "--data=%s" % tmpDir] + ["--testmode", "--invalid", f"--config={tmpPath}", f"--data={tmpPath}"] ) assert nwGUI.closeMain() == "closeMain" assert ex.value.code == 2 # Project Path nwGUI = novelwriter.main( - ["--testmode", "--config=%s" % tmpDir, "--data=%s" % tmpDir, "sample/"] + ["--testmode", f"--config={tmpPath}", f"--data={tmpPath}", "sample/"] ) - assert novelwriter.CONFIG.cmdOpen == "sample/" assert nwGUI.closeMain() == "closeMain" # END Test testBaseInit_Options @pytest.mark.base -def testBaseInit_Imports(caplog, monkeypatch, tmpDir): +def testBaseInit_Imports(caplog, monkeypatch, tmpPath): """Check import error handling. """ monkeypatch.setattr("novelwriter.guimain.GuiMain", MockGuiMain) @@ -167,7 +160,7 @@ def testBaseInit_Imports(caplog, monkeypatch, tmpDir): with pytest.raises(SystemExit) as ex: _ = novelwriter.main( - ["--testmode", "--config=%s" % tmpDir, "--data=%s" % tmpDir] + ["--testmode", f"--config={tmpPath}", f"--data={tmpPath}"] ) assert ex.value.code & 4 == 4 # Python version not satisfied diff --git a/tests/test_core/test_core_coretools.py b/tests/test_core/test_core_coretools.py new file mode 100644 index 00000000..0f9a54f0 --- /dev/null +++ b/tests/test_core/test_core_coretools.py @@ -0,0 +1,419 @@ +""" +novelWriter – Project Document Tools Tester +=========================================== + +This file is a part of novelWriter +Copyright 2018–2022, Veronica Berglyd Olsen + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, but +WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +import uuid +import pytest + +from shutil import copyfile +from zipfile import ZipFile + +from mock import causeOSError +from tools import C, buildTestProject, cmpFiles, XML_IGNORE + +from novelwriter.constants import nwItemClass +from novelwriter.core.project import NWProject +from novelwriter.core.coretools import DocMerger, DocSplitter, ProjectBuilder + + +@pytest.mark.core +def testCoreTools_DocMerger(monkeypatch, mockGUI, fncPath, tstPaths, mockRnd, ipsumText): + """Test the DocMerger utility. + """ + theProject = NWProject(mockGUI) + mockRnd.reset() + buildTestProject(theProject, fncPath) + + # Create Files to Merge + # ===================== + + hChapter1 = theProject.newFile("Chapter 1", C.hNovelRoot) + hSceneOne11 = theProject.newFile("Scene 1.1", hChapter1) + hSceneOne12 = theProject.newFile("Scene 1.2", hChapter1) + hSceneOne13 = theProject.newFile("Scene 1.3", hChapter1) + + docText1 = "\n\n".join(ipsumText[0:2]) + "\n\n" + docText2 = "\n\n".join(ipsumText[1:3]) + "\n\n" + docText3 = "\n\n".join(ipsumText[2:4]) + "\n\n" + docText4 = "\n\n".join(ipsumText[3:5]) + "\n\n" + + theProject.writeNewFile(hChapter1, 2, True, docText1) + theProject.writeNewFile(hSceneOne11, 3, True, docText2) + theProject.writeNewFile(hSceneOne12, 3, True, docText3) + theProject.writeNewFile(hSceneOne13, 3, True, docText4) + + # Basic Checks + # ============ + + docMerger = DocMerger(theProject) + + # No writing without a target set + assert docMerger.writeTargetDoc() is False + + # Cannot append invalid handle + assert docMerger.appendText(C.hInvalid, True, "Merge") is False + + # Cannot create new target from invalid handle + assert docMerger.newTargetDoc(C.hInvalid, "Test") is None + + # Merge to New + # ============ + + saveFile = fncPath / "content" / "0000000000014.nwd" + testFile = tstPaths.outDir / "coreDocTools_DocMerger_0000000000014.nwd" + compFile = tstPaths.refDir / "coreDocTools_DocMerger_0000000000014.nwd" + + assert docMerger.newTargetDoc(hChapter1, "All of Chapter 1") == "0000000000014" + + assert docMerger.appendText(hChapter1, True, "Merge") is True + assert docMerger.appendText(hSceneOne11, True, "Merge") is True + assert docMerger.appendText(hSceneOne12, True, "Merge") is True + assert docMerger.appendText(hSceneOne13, True, "Merge") is True + + # Block writing and check error handling + with monkeypatch.context() as mp: + mp.setattr("builtins.open", causeOSError) + assert docMerger.writeTargetDoc() is False + assert not saveFile.exists() + assert docMerger.getError() != "" + + # Write properly, and compare + assert docMerger.writeTargetDoc() is True + copyfile(saveFile, testFile) + assert cmpFiles(testFile, compFile) + + # Merge into Existing + # =================== + + saveFile = fncPath / "content" / "0000000000010.nwd" + testFile = tstPaths.outDir / "coreDocTools_DocMerger_0000000000010.nwd" + compFile = tstPaths.refDir / "coreDocTools_DocMerger_0000000000010.nwd" + + docMerger.setTargetDoc(hChapter1) + + assert docMerger.appendText(hSceneOne11, True, "Merge") is True + assert docMerger.appendText(hSceneOne12, True, "Merge") is True + assert docMerger.appendText(hSceneOne13, True, "Merge") is True + + assert docMerger.writeTargetDoc() is True + copyfile(saveFile, testFile) + assert cmpFiles(testFile, compFile) + + # Just for debugging + docMerger.writeTargetDoc() + +# END Test testCoreTools_DocMerger + + +@pytest.mark.core +def testCoreTools_DocSplitter(monkeypatch, mockGUI, fncPath, mockRnd, ipsumText): + """Test the DocSplitter utility. + """ + theProject = NWProject(mockGUI) + mockRnd.reset() + buildTestProject(theProject, fncPath) + + # Create File to Split + # ==================== + + hSplitDoc = theProject.newFile("Split Doc", C.hNovelRoot) + + docData = [ + "# Part One", ipsumText[0], + "## Chapter One", ipsumText[1], + "### Scene One", ipsumText[2], + "#### Section One", ipsumText[3], + "#### Section Two", ipsumText[4], + "### Scene Two", ipsumText[0], + "## Chapter Two", ipsumText[1], + "### Scene Three", ipsumText[2], + "### Scene Four", ipsumText[3], + "### Scene Five", ipsumText[4], + ] + splitData = [ + (0, 1, "Part One"), + (4, 2, "Chapter One"), + (8, 3, "Scene One"), + (12, 4, "Section One"), + (16, 4, "Section Two"), + (20, 3, "Scene Two"), + (24, 2, "Chapter Two"), + (28, 3, "Scene Three"), + (32, 3, "Scene Four"), + (36, 3, "Scene Five"), + ] + + docText = "\n\n".join(docData) + docRaw = docText.splitlines() + assert theProject.storage.getDocument(hSplitDoc).writeDocument(docText) is True + theProject.tree[hSplitDoc].setStatus(C.sFinished) + theProject.tree[hSplitDoc].setImport(C.iMain) + + docSplitter = DocSplitter(theProject, hSplitDoc) + assert docSplitter._srcItem.isFileType() + assert docSplitter.getError() == "" + + # Run the split algorithm + docSplitter.splitDocument(splitData, docRaw) + for i, (lineNo, hLevel, hLabel) in enumerate(splitData): + assert docSplitter._rawData[i] == (docRaw[lineNo:lineNo+4], hLevel, hLabel) + + # Test flat split into same parent + docSplitter.setParentItem(C.hNovelRoot) + assert docSplitter._inFolder is False + + # Cause write error on all chunks + with monkeypatch.context() as mp: + mp.setattr("builtins.open", causeOSError) + resStatus = [] + for status, _, _ in docSplitter.writeDocuments(False): + resStatus.append(status) + assert not any(resStatus) + assert docSplitter.getError() == "OSError: Mock OSError" + + # Generate as flat structure in root folder + resStatus = [] + resDocHandle = [] + resNearHandle = [] + for status, dHandle, nHandle in docSplitter.writeDocuments(False): + resStatus.append(status) + resDocHandle.append(dHandle) + resNearHandle.append(nHandle) + + assert all(resStatus) + assert resDocHandle == [ + "000000000001b", "000000000001c", "000000000001d", "000000000001e", "000000000001f", + "0000000000020", "0000000000021", "0000000000022", "0000000000023", "0000000000024", + ] + assert resNearHandle == [ # Each document should be next to the previous one + hSplitDoc, "000000000001b", "000000000001c", "000000000001d", "000000000001e", + "000000000001f", "0000000000020", "0000000000021", "0000000000022", "0000000000023", + ] + + # Generate as hierarchy in new folder + hSplitFolder = docSplitter.newParentFolder(C.hNovelRoot, "Split Folder") + assert docSplitter._inFolder is True + + resStatus = [] + resDocHandle = [] + resNearHandle = [] + for status, dHandle, nHandle in docSplitter.writeDocuments(True): + resStatus.append(status) + resDocHandle.append(dHandle) + resNearHandle.append(nHandle) + + assert all(resStatus) + assert resDocHandle == [ + "0000000000026", # Part One + "0000000000027", # Chapter One + "0000000000028", # Scene One + "0000000000029", # Section One + "000000000002a", # Section Two + "000000000002b", # Scene Two + "000000000002c", # Chapter Two + "000000000002d", # Scene Three + "000000000002e", # Scene Four + "000000000002f", # Scene Five + ] + assert resNearHandle == [ + hSplitFolder, # Part One is after Split Folder + "0000000000026", # Chapter One is after Part One + "0000000000027", # Scene One is after Chapter One + "0000000000028", # Section One is after Scene One + "0000000000029", # Section Two is after Section One + "0000000000028", # Scene Two is after Scene One + "0000000000027", # Chapter Two is after Chapter One + "000000000002c", # Scene Three is after Chapter Two + "000000000002d", # Scene Four is after Scene Three + "000000000002e", # Scene Five is after Scene Four + ] + + # Check that status and importance has been preserved + for rHandle in resDocHandle: + assert theProject.tree[rHandle].itemStatus == C.sFinished + assert theProject.tree[rHandle].itemImport == C.iMain + + # Check handling of improper initialisation + docSplitter = DocSplitter(theProject, C.hInvalid) + assert docSplitter._srcHandle is None + assert docSplitter._srcItem is None + assert docSplitter.newParentFolder(C.hNovelRoot, "Split Folder") is None + assert list(docSplitter.writeDocuments(False)) == [] + + theProject.saveProject() + +# END Test testCoreTools_DocSplitter + + +@pytest.mark.core +def testCoreTools_NewMinimal(monkeypatch, fncPath, tstPaths, mockGUI, mockRnd): + """Create a new project from a project wizard dictionary. With + default setting, creating a Minimal project. + """ + monkeypatch.setattr("uuid.uuid4", lambda *a: uuid.UUID("d0f3fe10-c6e6-4310-8bfd-181eb4224eed")) + + projFile = fncPath / "nwProject.nwx" + testFile = tstPaths.outDir / "coreTools_NewMinimal_nwProject.nwx" + compFile = tstPaths.refDir / "coreTools_NewMinimal_nwProject.nwx" + + projBuild = ProjectBuilder(mockGUI) + + # Setting no data should fail + assert projBuild.buildProject({}) is False + + # Wrong type should also fail + assert projBuild.buildProject("stuff") is False + + # Try again with a proper path + assert projBuild.buildProject({"projPath": fncPath}) is True + + # Creating the project once more should fail + assert projBuild.buildProject({"projPath": fncPath}) is False + + # Save and close + copyfile(projFile, testFile) + assert cmpFiles(testFile, compFile, ignoreStart=XML_IGNORE) + +# END Test testCoreTools_NewMinimal + + +@pytest.mark.core +def testCoreTools_NewCustomA(monkeypatch, fncPath, tstPaths, mockGUI, mockRnd): + """Create a new project from a project wizard dictionary. + Custom type with chapters and scenes. + """ + monkeypatch.setattr("uuid.uuid4", lambda *a: uuid.UUID("d0f3fe10-c6e6-4310-8bfd-181eb4224eed")) + + projFile = fncPath / "nwProject.nwx" + testFile = tstPaths.outDir / "coreTools_NewCustomA_nwProject.nwx" + compFile = tstPaths.refDir / "coreTools_NewCustomA_nwProject.nwx" + + projData = { + "projName": "Test Custom", + "projTitle": "Test Novel", + "projAuthors": "Jane Doe\nJohn Doh\n", + "projPath": fncPath, + "popSample": False, + "popMinimal": False, + "popCustom": True, + "addRoots": [ + nwItemClass.PLOT, + nwItemClass.CHARACTER, + nwItemClass.WORLD, + ], + "addNotes": True, + "numChapters": 3, + "numScenes": 3, + } + + projBuild = ProjectBuilder(mockGUI) + assert projBuild.buildProject(projData) is True + + copyfile(projFile, testFile) + assert cmpFiles(testFile, compFile, ignoreStart=XML_IGNORE) + +# END Test testCoreTools_NewCustomA + + +@pytest.mark.core +def testCoreTools_NewCustomB(monkeypatch, fncPath, tstPaths, mockGUI, mockRnd): + """Create a new project from a project wizard dictionary. + Custom type without chapters, but with scenes. + """ + monkeypatch.setattr("uuid.uuid4", lambda *a: uuid.UUID("d0f3fe10-c6e6-4310-8bfd-181eb4224eed")) + + projFile = fncPath / "nwProject.nwx" + testFile = tstPaths.outDir / "coreTools_NewCustomB_nwProject.nwx" + compFile = tstPaths.refDir / "coreTools_NewCustomB_nwProject.nwx" + + projData = { + "projName": "Test Custom", + "projTitle": "Test Novel", + "projAuthors": "Jane Doe\nJohn Doh\n", + "projPath": fncPath, + "popSample": False, + "popMinimal": False, + "popCustom": True, + "addRoots": [ + nwItemClass.PLOT, + nwItemClass.CHARACTER, + nwItemClass.WORLD, + ], + "addNotes": True, + "numChapters": 0, + "numScenes": 6, + } + + projBuild = ProjectBuilder(mockGUI) + assert projBuild.buildProject(projData) is True + + copyfile(projFile, testFile) + assert cmpFiles(testFile, compFile, ignoreStart=XML_IGNORE) + +# END Test testCoreTools_NewCustomB + + +@pytest.mark.core +def testCoreTools_NewSample(monkeypatch, fncPath, tmpConf, tmpPath, mockGUI): + """Check that we can create a new project can be created from the + provided sample project via a zip file. + """ + projData = { + "projName": "Test Sample", + "projTitle": "Test Novel", + "projAuthors": "Jane Doe\nJohn Doh\n", + "projPath": fncPath, + "popSample": True, + "popMinimal": False, + "popCustom": False, + } + + projBuild = ProjectBuilder(mockGUI) + + # No path set + assert projBuild.buildProject({"popSample": True}) is False + + # Force the lookup path for assets to our temp folder + srcSample = tmpConf._appRoot / "sample" + dstSample = tmpPath / "sample.zip" + monkeypatch.setattr( + "novelwriter.config.Config.assetPath", lambda *a: tmpPath / "sample.zip" + ) + + # Cannot extract when the zip does not exist + assert projBuild.buildProject(projData) is False + + # Create and open a defective zip file + with open(dstSample, mode="w+") as outFile: + outFile.write("foo") + + assert projBuild.buildProject(projData) is False + dstSample.unlink() + + # Create a real zip file, and unpack it + with ZipFile(dstSample, "w") as zipObj: + zipObj.write(srcSample / "nwProject.nwx", "nwProject.nwx") + for docFile in (srcSample / "content").iterdir(): + zipObj.write(docFile, f"content/{docFile.name}") + + assert projBuild.buildProject(projData) is True + dstSample.unlink() + +# END Test testCoreTools_NewSample diff --git a/tests/test_core/test_core_document.py b/tests/test_core/test_core_document.py index 2f80e45a..50dd28bf 100644 --- a/tests/test_core/test_core_document.py +++ b/tests/test_core/test_core_document.py @@ -1,6 +1,6 @@ """ -novelWriter – NWDoc Class Tester -================================ +novelWriter – NWDocument Class Tester +===================================== This file is a part of novelWriter Copyright 2018–2022, Veronica Berglyd Olsen @@ -19,76 +19,84 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import os import pytest from mock import causeOSError -from tools import readFile, writeFile +from tools import C, buildTestProject, readFile, writeFile -from novelwriter.core import NWProject, NWDoc from novelwriter.enum import nwItemClass, nwItemLayout +from novelwriter.core.project import NWProject +from novelwriter.core.document import NWDocument @pytest.mark.core -def testCoreDocument_LoadSave(monkeypatch, mockGUI, nwMinimal): - """Test loading and saving a document with the NWDoc class. +def testCoreDocument_LoadSave(monkeypatch, mockGUI, fncPath, mockRnd): + """Test loading and saving a document with the NWDocument class. """ theProject = NWProject(mockGUI) - assert theProject.openProject(nwMinimal) is True - assert theProject.projPath == nwMinimal - - sHandle = "8c659a11cd429" + mockRnd.reset() + buildTestProject(theProject, fncPath) # Read Document # ============= # Not a valid handle - theDoc = NWDoc(theProject, "stuff") + theDoc = NWDocument(theProject, "stuff") assert bool(theDoc) is False assert theDoc.readDocument() is None # Non-existent handle - theDoc = NWDoc(theProject, "0000000000000") + theDoc = NWDocument(theProject, C.hInvalid) assert theDoc.readDocument() is None assert theDoc._currHash is None + # No content path + with monkeypatch.context() as mp: + mp.setattr("novelwriter.core.storage.NWStorage.contentPath", property(lambda *a: None)) + theDoc = NWDocument(theProject, C.hSceneDoc) + assert theDoc.readDocument() is None + # Cause open() to fail while loading with monkeypatch.context() as mp: mp.setattr("builtins.open", causeOSError) - theDoc = NWDoc(theProject, sHandle) + theDoc = NWDocument(theProject, C.hSceneDoc) assert theDoc.readDocument() is None assert theDoc.getError() == "OSError: Mock OSError" # Load the text - theDoc = NWDoc(theProject, sHandle) + theDoc = NWDocument(theProject, C.hSceneDoc) assert theDoc.readDocument() == "### New Scene\n\n" # Try to open a new (non-existent) file - nHandle = theProject.tree.findRoot(nwItemClass.NOVEL) - assert nHandle is not None - xHandle = theProject.newFile("New File", nHandle) - theDoc = NWDoc(theProject, xHandle) + xHandle = theProject.newFile("New File", C.hNovelRoot) + theDoc = NWDocument(theProject, xHandle) assert bool(theDoc) is True - assert repr(theDoc) == f"" + assert repr(theDoc) == f"" assert theDoc.readDocument() == "" # Write Document # ============== - # Set handle and save again + # No content path + with monkeypatch.context() as mp: + mp.setattr("novelwriter.core.storage.NWStorage.contentPath", property(lambda *a: None)) + theDoc = NWDocument(theProject, xHandle) + assert theDoc.writeDocument("") is False + + # Set handle and save theText = "### Test File\n\nText ...\n\n" - theDoc = NWDoc(theProject, xHandle) + theDoc = NWDocument(theProject, xHandle) assert theDoc.readDocument(xHandle) == "" assert theDoc.writeDocument(theText) is True # Save again to ensure temp file and previous file is handled - assert theDoc.writeDocument(theText) + assert theDoc.writeDocument(theText) is True # Check file content - docPath = os.path.join(nwMinimal, "content", xHandle+".nwd") + docPath = fncPath / "content" / f"{xHandle}.nwd" assert readFile(docPath) == ( "%%~name: New File\n" - f"%%~path: a508bb932959c/{xHandle}\n" + f"%%~path: {C.hNovelRoot}/{xHandle}\n" "%%~kind: NOVEL/DOCUMENT\n" "### Test File\n\n" "Text ...\n\n" @@ -117,7 +125,7 @@ def testCoreDocument_LoadSave(monkeypatch, mockGUI, nwMinimal): # Cause os.replace() to fail while saving with monkeypatch.context() as mp: - mp.setattr("os.replace", causeOSError) + mp.setattr("pathlib.Path.replace", causeOSError) assert theDoc.writeDocument(theText) is False assert theDoc.getError() == "OSError: Mock OSError" @@ -131,51 +139,56 @@ def testCoreDocument_LoadSave(monkeypatch, mockGUI, nwMinimal): # Delete Document # =============== - # Delete the last document - theDoc = NWDoc(theProject, "stuff") + # Delete a non-existing document + theDoc = NWDocument(theProject, "stuff") assert theDoc.deleteDocument() is False - assert os.path.isfile(docPath) + assert docPath.exists() + + # No content path + with monkeypatch.context() as mp: + mp.setattr("novelwriter.core.storage.NWStorage.contentPath", property(lambda *a: None)) + theDoc = NWDocument(theProject, xHandle) + assert theDoc.deleteDocument() is False # Cause the delete to fail with monkeypatch.context() as mp: - mp.setattr("os.unlink", causeOSError) - theDoc = NWDoc(theProject, xHandle) + mp.setattr("pathlib.Path.unlink", causeOSError) + theDoc = NWDocument(theProject, xHandle) assert theDoc.deleteDocument() is False assert theDoc.getError() == "OSError: Mock OSError" # Make the delete pass - theDoc = NWDoc(theProject, xHandle) + theDoc = NWDocument(theProject, xHandle) assert theDoc.deleteDocument() is True - assert not os.path.isfile(docPath) + assert not docPath.exists() # END Test testCoreDocument_Load @pytest.mark.core -def testCoreDocument_Methods(mockGUI, nwMinimal): - """Test other methods of the NWDoc class. +def testCoreDocument_Methods(mockGUI, fncPath, mockRnd): + """Test other methods of the NWDocument class. """ theProject = NWProject(mockGUI) - assert theProject.openProject(nwMinimal) - assert theProject.projPath == nwMinimal + mockRnd.reset() + buildTestProject(theProject, fncPath) - sHandle = "8c659a11cd429" - theDoc = NWDoc(theProject, sHandle) - docPath = os.path.join(nwMinimal, "content", sHandle+".nwd") + theDoc = NWDocument(theProject, C.hSceneDoc) + docPath = fncPath / "content" / f"{C.hSceneDoc}.nwd" assert theDoc.readDocument() == "### New Scene\n\n" # Check location - assert theDoc.getFileLocation() == docPath + assert theDoc.getFileLocation() == str(docPath) # Check the item assert theDoc.getCurrentItem() is not None - assert theDoc.getCurrentItem().itemHandle == sHandle + assert theDoc.getCurrentItem().itemHandle == C.hSceneDoc # Check the meta theName, theParent, theClass, theLayout = theDoc.getMeta() assert theName == "New Scene" - assert theParent == "a6d311a93600a" + assert theParent == C.hChapterDir assert theClass == nwItemClass.NOVEL assert theLayout == nwItemLayout.DOCUMENT @@ -183,7 +196,7 @@ def testCoreDocument_Methods(mockGUI, nwMinimal): assert theDoc.writeDocument("%%~ stuff\n### Test File\n\nText ...\n\n") assert readFile(docPath) == ( "%%~name: New Scene\n" - f"%%~path: a6d311a93600a/{sHandle}\n" + f"%%~path: {C.hChapterDir}/{C.hSceneDoc}\n" "%%~kind: NOVEL/DOCUMENT\n" "%%~ stuff\n" "### Test File\n\n" diff --git a/tests/test_core/test_core_index.py b/tests/test_core/test_core_index.py index 8f126e56..6d85dfb0 100644 --- a/tests/test_core/test_core_index.py +++ b/tests/test_core/test_core_index.py @@ -19,31 +19,31 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import os import json import pytest from shutil import copyfile from mock import causeException -from tools import buildTestProject, cmpFiles, writeFile +from tools import C, buildTestProject, cmpFiles, writeFile -from novelwriter.core.project import NWProject -from novelwriter.core.index import NWIndex, countWords, TagsIndex from novelwriter.enum import nwItemClass, nwItemLayout +from novelwriter.constants import nwFiles +from novelwriter.core.index import NWIndex, countWords, TagsIndex +from novelwriter.core.project import NWProject @pytest.mark.core -def testCoreIndex_LoadSave(monkeypatch, nwLipsum, mockGUI, outDir, refDir): +def testCoreIndex_LoadSave(monkeypatch, prjLipsum, mockGUI, tstPaths): """Test core functionality of scaning, saving, loading and checking the index cache file. """ - projFile = os.path.join(nwLipsum, "meta", "tagsIndex.json") - testFile = os.path.join(outDir, "coreIndex_LoadSave_tagsIndex.json") - compFile = os.path.join(refDir, "coreIndex_LoadSave_tagsIndex.json") + projFile = prjLipsum / "meta" / nwFiles.INDEX_FILE + testFile = tstPaths.outDir / "coreIndex_LoadSave_tagsIndex.json" + compFile = tstPaths.refDir / "coreIndex_LoadSave_tagsIndex.json" theProject = NWProject(mockGUI) - assert theProject.openProject(nwLipsum) + assert theProject.openProject(prjLipsum) theIndex = NWIndex(theProject) assert repr(theIndex) == "" @@ -61,6 +61,11 @@ def testCoreIndex_LoadSave(monkeypatch, nwLipsum, mockGUI, outDir, refDir): assert theIndex.reIndexHandle(None) is False + # No folder for saving + with monkeypatch.context() as mp: + mp.setattr("novelwriter.core.storage.NWStorage.getMetaFile", lambda *a: None) + assert theIndex.saveIndex() is False + # Make the save fail with monkeypatch.context() as mp: mp.setattr("builtins.open", causeException) @@ -85,6 +90,11 @@ def testCoreIndex_LoadSave(monkeypatch, nwLipsum, mockGUI, outDir, refDir): assert theIndex._tagsIndex._tags == {} assert theIndex._itemIndex._items == {} + # No folder for loading + with monkeypatch.context() as mp: + mp.setattr("novelwriter.core.storage.NWStorage.getMetaFile", lambda *a: None) + assert theIndex.loadIndex() is False + # Make the load fail with monkeypatch.context() as mp: mp.setattr(json, "load", causeException) @@ -98,6 +108,13 @@ def testCoreIndex_LoadSave(monkeypatch, nwLipsum, mockGUI, outDir, refDir): assert str(theIndex._tagsIndex.packData()) == tagIndex assert str(theIndex._itemIndex.packData()) == itemsIndex + # Rebuild index + theIndex.clearIndex() + theIndex.rebuildIndex() + + assert str(theIndex._tagsIndex.packData()) == tagIndex + assert str(theIndex._itemIndex.packData()) == itemsIndex + # Check File copyfile(projFile, testFile) assert cmpFiles(testFile, compFile) @@ -185,19 +202,21 @@ def testCoreIndex_ScanThis(mockGUI): @pytest.mark.core -def testCoreIndex_CheckThese(mockGUI, fncDir, mockRnd): +def testCoreIndex_CheckThese(mockGUI, fncPath, mockRnd): """Test the tag checker function checkThese. """ theProject = NWProject(mockGUI) - buildTestProject(theProject, fncDir) + mockRnd.reset() + buildTestProject(theProject, fncPath) theIndex = theProject.index + theIndex.clearIndex() - nHandle = theProject.newFile("Hello", "0000000000010") - cHandle = theProject.newFile("Jane", "0000000000012") + nHandle = theProject.newFile("Hello", C.hNovelRoot) + cHandle = theProject.newFile("Jane", C.hCharRoot) nItem = theProject.tree[nHandle] cItem = theProject.tree[cHandle] - assert theIndex.rootChangedSince("0000000000010", 0) is False + assert theIndex.rootChangedSince(C.hNovelRoot, 0) is False assert theIndex.indexChangedSince(0) is False assert theIndex.scanText(cHandle, ( @@ -227,11 +246,11 @@ def testCoreIndex_CheckThese(mockGUI, fncDir, mockRnd): "@time": [] } - assert theIndex.rootChangedSince("0000000000010", 0) is True + assert theIndex.rootChangedSince(C.hNovelRoot, 0) is True assert theIndex.indexChangedSince(0) is True - assert theIndex.getHandleHeaderLevel(cHandle) == "H1" - assert theIndex.getHandleHeaderLevel(nHandle) == "H1" + assert cItem.mainHeading == "H1" + assert nItem.mainHeading == "H1" # Zero Items assert theIndex.checkThese([], cItem) == [] @@ -261,16 +280,17 @@ def testCoreIndex_CheckThese(mockGUI, fncDir, mockRnd): @pytest.mark.core -def testCoreIndex_ScanText(mockGUI, fncDir, mockRnd): +def testCoreIndex_ScanText(mockGUI, fncPath, mockRnd): """Check the index text scanner. """ theProject = NWProject(mockGUI) - buildTestProject(theProject, fncDir) + mockRnd.reset() + buildTestProject(theProject, fncPath) theIndex = theProject.index # Some items for fail to scan tests - dHandle = theProject.newFolder("Folder", "0000000000010") - xHandle = theProject.newFile("No Layout", "0000000000010") + dHandle = theProject.newFolder("Folder", C.hNovelRoot) + xHandle = theProject.newFile("No Layout", C.hNovelRoot) xItem = theProject.tree[xHandle] xItem.setLayout(nwItemLayout.NO_LAYOUT) @@ -290,21 +310,23 @@ def testCoreIndex_ScanText(mockGUI, fncDir, mockRnd): theProject.tree.updateItemData(xItem.itemHandle) assert xItem.itemRoot == tHandle assert xItem.itemClass == nwItemClass.TRASH - assert theIndex.scanText(xHandle, "Hello World!") is False + assert theIndex.scanText(xHandle, "## Hello World!") is True + assert xItem.mainHeading == "H2" # Create the archive root aHandle = theProject.newRoot(nwItemClass.ARCHIVE) assert theProject.tree[aHandle] is not None xItem.setParent(aHandle) theProject.tree.updateItemData(xItem.itemHandle) - assert theIndex.scanText(xHandle, "Hello World!") is False + assert theIndex.scanText(xHandle, "### Hello World!") is True + assert xItem.mainHeading == "H3" # Make some usable items - tHandle = theProject.newFile("Title", "0000000000010") - pHandle = theProject.newFile("Page", "0000000000010") - nHandle = theProject.newFile("Hello", "0000000000010") - cHandle = theProject.newFile("Jane", "0000000000012") - sHandle = theProject.newFile("Scene", "0000000000010") + tHandle = theProject.newFile("Title", C.hNovelRoot) + pHandle = theProject.newFile("Page", C.hNovelRoot) + nHandle = theProject.newFile("Hello", C.hNovelRoot) + cHandle = theProject.newFile("Jane", C.hCharRoot) + sHandle = theProject.newFile("Scene", C.hNovelRoot) # Text Indexing # ============= @@ -470,27 +492,28 @@ def testCoreIndex_ScanText(mockGUI, fncDir, mockRnd): @pytest.mark.core -def testCoreIndex_ExtractData(mockGUI, fncDir, mockRnd): +def testCoreIndex_ExtractData(mockGUI, fncPath, mockRnd): """Check the index data extraction functions. """ theProject = NWProject(mockGUI) - buildTestProject(theProject, fncDir) + mockRnd.reset() + buildTestProject(theProject, fncPath) theIndex = theProject.index - theIndex.reIndexHandle("0000000000010") - theIndex.reIndexHandle("0000000000011") - theIndex.reIndexHandle("0000000000012") - theIndex.reIndexHandle("0000000000013") - theIndex.reIndexHandle("0000000000014") - theIndex.reIndexHandle("0000000000015") - theIndex.reIndexHandle("0000000000016") - theIndex.reIndexHandle("0000000000017") + theIndex.reIndexHandle(C.hNovelRoot) + theIndex.reIndexHandle(C.hPlotRoot) + theIndex.reIndexHandle(C.hCharRoot) + theIndex.reIndexHandle(C.hWorldRoot) + theIndex.reIndexHandle(C.hTitlePage) + theIndex.reIndexHandle(C.hChapterDir) + theIndex.reIndexHandle(C.hChapterDoc) + theIndex.reIndexHandle(C.hSceneDoc) - nHandle = theProject.newFile("Hello", "0000000000010") - cHandle = theProject.newFile("Jane", "0000000000012") + nHandle = theProject.newFile("Hello", C.hNovelRoot) + cHandle = theProject.newFile("Jane", C.hCharRoot) assert theIndex.getNovelData("", "") is None - assert theIndex.getNovelData("0000000000010", "") is None + assert theIndex.getNovelData(C.hNovelRoot, "") is None assert theIndex.scanText(cHandle, ( "# Jane Smith\n" @@ -511,24 +534,24 @@ def testCoreIndex_ExtractData(mockGUI, fncDir, mockRnd): theKeys.append(aKey) assert theKeys == [ - "0000000000014:T000001", - "0000000000016:T000001", - "0000000000017:T000001", - "%s:T000001" % nHandle, + f"{C.hTitlePage}:T000001", + f"{C.hChapterDoc}:T000001", + f"{C.hSceneDoc}:T000001", + f"{nHandle}:T000001", ] # Check that excluded files can be skipped - theProject.tree[nHandle].setExported(False) + theProject.tree[nHandle].setActive(False) theKeys = [] for aKey, _, _, _ in theIndex.novelStructure(skipExcl=False): theKeys.append(aKey) assert theKeys == [ - "0000000000014:T000001", - "0000000000016:T000001", - "0000000000017:T000001", - "%s:T000001" % nHandle, + f"{C.hTitlePage}:T000001", + f"{C.hChapterDoc}:T000001", + f"{C.hSceneDoc}:T000001", + f"{nHandle}:T000001", ] theKeys = [] @@ -536,9 +559,9 @@ def testCoreIndex_ExtractData(mockGUI, fncDir, mockRnd): theKeys.append(aKey) assert theKeys == [ - "0000000000014:T000001", - "0000000000016:T000001", - "0000000000017:T000001", + f"{C.hTitlePage}:T000001", + f"{C.hChapterDoc}:T000001", + f"{C.hSceneDoc}:T000001", ] # The novel file should have the correct counts @@ -567,7 +590,7 @@ def testCoreIndex_ExtractData(mockGUI, fncDir, mockRnd): assert theIndex.getBackReferenceList(None) == {} # The Title Page file should have no references as it has no tag - assert theIndex.getBackReferenceList("0000000000014") == {} + assert theIndex.getBackReferenceList(C.hTitlePage) == {} # The character file should have a record of the reference from the novel file theRefs = theIndex.getBackReferenceList(cHandle) @@ -656,9 +679,9 @@ def testCoreIndex_ExtractData(mockGUI, fncDir, mockRnd): # Novel Stats # =========== - hHandle = theProject.newFile("Chapter", "0000000000010") - sHandle = theProject.newFile("Scene One", "0000000000010") - tHandle = theProject.newFile("Scene Two", "0000000000010") + hHandle = theProject.newFile("Chapter", C.hNovelRoot) + sHandle = theProject.newFile("Scene One", C.hNovelRoot) + tHandle = theProject.newFile("Scene Two", C.hNovelRoot) theProject.tree[hHandle].itemLayout == nwItemLayout.DOCUMENT theProject.tree[sHandle].itemLayout == nwItemLayout.DOCUMENT @@ -669,9 +692,9 @@ def testCoreIndex_ExtractData(mockGUI, fncDir, mockRnd): assert theIndex.scanText(tHandle, "### Scene Two\n\n") assert [(h, t) for h, t, _ in theIndex._itemIndex.iterNovelStructure(skipExcl=False)] == [ - ("0000000000014", "T000001"), - ("0000000000016", "T000001"), - ("0000000000017", "T000001"), + (C.hTitlePage, "T000001"), + (C.hChapterDoc, "T000001"), + (C.hSceneDoc, "T000001"), (nHandle, "T000001"), (nHandle, "T000011"), (hHandle, "T000001"), @@ -680,9 +703,9 @@ def testCoreIndex_ExtractData(mockGUI, fncDir, mockRnd): ] assert [(h, t) for h, t, _ in theIndex._itemIndex.iterNovelStructure(skipExcl=True)] == [ - ("0000000000014", "T000001"), - ("0000000000016", "T000001"), - ("0000000000017", "T000001"), + (C.hTitlePage, "T000001"), + (C.hChapterDoc, "T000001"), + (C.hSceneDoc, "T000001"), (hHandle, "T000001"), (sHandle, "T000001"), (tHandle, "T000001"), @@ -691,9 +714,9 @@ def testCoreIndex_ExtractData(mockGUI, fncDir, mockRnd): # Add a fake handle to the tree and check that it's ignored theProject.tree._treeOrder.append("0000000000000") assert [(h, t) for h, t, _ in theIndex._itemIndex.iterNovelStructure(skipExcl=False)] == [ - ("0000000000014", "T000001"), - ("0000000000016", "T000001"), - ("0000000000017", "T000001"), + (C.hTitlePage, "T000001"), + (C.hChapterDoc, "T000001"), + (C.hSceneDoc, "T000001"), (nHandle, "T000001"), (nHandle, "T000011"), (hHandle, "T000001"), @@ -709,29 +732,29 @@ def testCoreIndex_ExtractData(mockGUI, fncDir, mockRnd): assert theIndex.getNovelTitleCounts(skipExcl=True) == [0, 1, 2, 3, 0] # Table of Contents - assert theIndex.getTableOfContents(0, skipExcl=True) == [] - assert theIndex.getTableOfContents(1, skipExcl=True) == [ - ("0000000000014:T000001", 1, "New Novel", 15), + assert theIndex.getTableOfContents(C.hNovelRoot, 0, skipExcl=True) == [] + assert theIndex.getTableOfContents(C.hNovelRoot, 1, skipExcl=True) == [ + (f"{C.hTitlePage}:T000001", 1, "New Novel", 15), ] - assert theIndex.getTableOfContents(2, skipExcl=True) == [ - ("0000000000014:T000001", 1, "New Novel", 5), - ("0000000000016:T000001", 2, "New Chapter", 4), - ("%s:T000001" % hHandle, 2, "Chapter One", 6), + assert theIndex.getTableOfContents(C.hNovelRoot, 2, skipExcl=True) == [ + (f"{C.hTitlePage}:T000001", 1, "New Novel", 5), + (f"{C.hChapterDoc}:T000001", 2, "New Chapter", 4), + (f"{hHandle}:T000001", 2, "Chapter One", 6), ] - assert theIndex.getTableOfContents(3, skipExcl=True) == [ - ("0000000000014:T000001", 1, "New Novel", 5), - ("0000000000016:T000001", 2, "New Chapter", 2), - ("0000000000017:T000001", 3, "New Scene", 2), - ("%s:T000001" % hHandle, 2, "Chapter One", 2), - ("%s:T000001" % sHandle, 3, "Scene One", 2), - ("%s:T000001" % tHandle, 3, "Scene Two", 2), + assert theIndex.getTableOfContents(C.hNovelRoot, 3, skipExcl=True) == [ + (f"{C.hTitlePage}:T000001", 1, "New Novel", 5), + (f"{C.hChapterDoc}:T000001", 2, "New Chapter", 2), + (f"{C.hSceneDoc}:T000001", 3, "New Scene", 2), + (f"{hHandle}:T000001", 2, "Chapter One", 2), + (f"{sHandle}:T000001", 3, "Scene One", 2), + (f"{tHandle}:T000001", 3, "Scene Two", 2), ] - assert theIndex.getTableOfContents(0, skipExcl=False) == [] - assert theIndex.getTableOfContents(1, skipExcl=False) == [ - ("0000000000014:T000001", 1, "New Novel", 9), - ("%s:T000001" % nHandle, 1, "Hello World!", 12), - ("%s:T000011" % nHandle, 1, "Hello World!", 22), + assert theIndex.getTableOfContents(C.hNovelRoot, 0, skipExcl=False) == [] + assert theIndex.getTableOfContents(C.hNovelRoot, 1, skipExcl=False) == [ + (f"{C.hTitlePage}:T000001", 1, "New Novel", 9), + (f"{nHandle}:T000001", 1, "Hello World!", 12), + (f"{nHandle}:T000011", 1, "Hello World!", 22), ] # Header Word Counts @@ -741,12 +764,11 @@ def testCoreIndex_ExtractData(mockGUI, fncDir, mockRnd): assert theIndex.getHandleWordCounts(sHandle) == [("%s:T000001" % sHandle, 2)] assert theIndex.getHandleWordCounts(tHandle) == [("%s:T000001" % tHandle, 2)] assert theIndex.getHandleWordCounts(nHandle) == [ - ("%s:T000001" % nHandle, 12), ("%s:T000011" % nHandle, 16) + (f"{nHandle}:T000001", 12), (f"{nHandle}:T000011", 16) ] assert theIndex.saveIndex() is True assert theProject.saveProject() is True - assert theProject.closeProject() is True # Header Record bHandle = "0000000000000" @@ -758,6 +780,8 @@ def testCoreIndex_ExtractData(mockGUI, fncDir, mockRnd): ("T000001", "H1", "Hello World!"), ("T000011", "H1", "Hello World!") ] + assert theProject.closeProject() is True + # END Test testCoreIndex_ExtractData @@ -922,15 +946,17 @@ def testCoreIndex_TagsIndex(): @pytest.mark.core -def testCoreIndex_ItemIndex(mockGUI, fncDir, mockRnd): +def testCoreIndex_ItemIndex(mockGUI, fncPath, mockRnd): """Check the ItemIndex class. """ theProject = NWProject(mockGUI) - buildTestProject(theProject, fncDir) + mockRnd.reset() + buildTestProject(theProject, fncPath) + theProject.index.clearIndex() - nHandle = "0000000000014" - cHandle = "0000000000016" - sHandle = "0000000000017" + nHandle = C.hTitlePage + cHandle = C.hChapterDoc + sHandle = C.hSceneDoc assert theProject.index.saveIndex() is True itemIndex = theProject.index._itemIndex @@ -948,13 +974,11 @@ def testCoreIndex_ItemIndex(mockGUI, fncDir, mockRnd): itemIndex.add(cHandle, theProject.tree[cHandle]) assert cHandle in itemIndex assert itemIndex[cHandle].item == theProject.tree[cHandle] - assert itemIndex.mainItemHeader(cHandle) == "H0" assert itemIndex.allItemTags(cHandle) == [] assert list(itemIndex.iterItemHeaders(cHandle))[0][0] == "T000000" # Add a heading to the item, which should replace the T000000 heading itemIndex.addItemHeading(cHandle, "T000001", "H2", "Chapter One") - assert itemIndex.mainItemHeader(cHandle) == "H2" assert list(itemIndex.iterItemHeaders(cHandle))[0][0] == "T000001" # Set the remainig data values @@ -966,7 +990,6 @@ def testCoreIndex_ItemIndex(mockGUI, fncDir, mockRnd): itemIndex.addHeadingReferences(cHandle, "T000001", ["Jane", "John"], "@char") idxData = itemIndex.packData() - assert idxData[cHandle]["level"] == "H2" assert idxData[cHandle]["headings"]["T000001"] == { "level": "H2", "title": "Chapter One", "tag": "One", "cCount": 60, "wCount": 10, "pCount": 2, "synopsis": "In the beginning ...", @@ -1026,7 +1049,6 @@ def testCoreIndex_ItemIndex(mockGUI, fncDir, mockRnd): assert allHeads[2][1] == "T000001" # Ask for stuff that doesn't exist - assert itemIndex.mainItemHeader("blablabla") == "H0" assert itemIndex.allItemTags("blablabla") == [] # Novel Structure @@ -1048,7 +1070,7 @@ def testCoreIndex_ItemIndex(mockGUI, fncDir, mockRnd): assert nStruct[3][0] == uHandle # Novel structure with root handle set - nStruct = list(itemIndex.iterNovelStructure(rootHandle="0000000000010")) + nStruct = list(itemIndex.iterNovelStructure(rootHandle=C.hNovelRoot)) assert len(nStruct) == 3 assert nStruct[0][0] == nHandle assert nStruct[1][0] == cHandle @@ -1068,7 +1090,7 @@ def testCoreIndex_ItemIndex(mockGUI, fncDir, mockRnd): assert nStruct[3][0] == uHandle # Skip excluded - theProject.tree[sHandle].setExported(False) + theProject.tree[sHandle].setActive(False) nStruct = list(itemIndex.iterNovelStructure(skipExcl=True)) assert len(nStruct) == 3 assert nStruct[0][0] == nHandle @@ -1098,7 +1120,7 @@ def testCoreIndex_ItemIndex(mockGUI, fncDir, mockRnd): itemIndex.unpackData({"stuff": "more stuff"}) # Unknown keys should be skipped - itemIndex.unpackData({"0000000000000": {}}) + itemIndex.unpackData({C.hInvalid: {}}) assert itemIndex._items == {} # Known keys can be added, even witout data diff --git a/tests/test_core/test_core_item.py b/tests/test_core/test_core_item.py index fbc0ded3..034240e1 100644 --- a/tests/test_core/test_core_item.py +++ b/tests/test_core/test_core_item.py @@ -21,20 +21,22 @@ along with this program. If not, see . import pytest -from lxml import etree - from PyQt5.QtGui import QIcon -from novelwriter.core import NWProject +from tools import C, buildTestProject + from novelwriter.core.item import NWItem +from novelwriter.core.project import NWProject from novelwriter.enum import nwItemClass, nwItemType, nwItemLayout @pytest.mark.core -def testCoreItem_Setters(mockGUI, mockRnd): +def testCoreItem_Setters(mockGUI, mockRnd, fncPath): """Test all the simple setters for the NWItem class. """ theProject = NWProject(mockGUI) + mockRnd.reset() + buildTestProject(theProject, fncPath) theItem = NWItem(theProject) statusKeys = ["s000000", "s000001", "s000002", "s000003"] @@ -131,29 +133,29 @@ def testCoreItem_Setters(mockGUI, mockRnd): theItem.setExpanded("What?") assert theItem.isExpanded is False theItem.setExpanded("True") - assert theItem.isExpanded is True + assert theItem.isExpanded is False theItem.setExpanded(True) assert theItem.isExpanded is True - # Exported - theItem.setExported(8) - assert theItem.isExported is False - theItem.setExported(None) - assert theItem.isExported is False - theItem.setExported("None") - assert theItem.isExported is False - theItem.setExported("What?") - assert theItem.isExported is False - theItem.setExported("True") - assert theItem.isExported is True - theItem.setExported(True) - assert theItem.isExported is True + # Active + theItem.setActive(8) + assert theItem.isActive is False + theItem.setActive(None) + assert theItem.isActive is False + theItem.setActive("None") + assert theItem.isActive is False + theItem.setActive("What?") + assert theItem.isActive is False + theItem.setActive("True") + assert theItem.isActive is False + theItem.setActive(True) + assert theItem.isActive is True # CharCount theItem.setCharCount(None) assert theItem.charCount == 0 theItem.setCharCount("1") - assert theItem.charCount == 1 + assert theItem.charCount == 0 theItem.setCharCount(1) assert theItem.charCount == 1 @@ -161,7 +163,7 @@ def testCoreItem_Setters(mockGUI, mockRnd): theItem.setWordCount(None) assert theItem.wordCount == 0 theItem.setWordCount("1") - assert theItem.wordCount == 1 + assert theItem.wordCount == 0 theItem.setWordCount(1) assert theItem.wordCount == 1 @@ -169,7 +171,7 @@ def testCoreItem_Setters(mockGUI, mockRnd): theItem.setParaCount(None) assert theItem.paraCount == 0 theItem.setParaCount("1") - assert theItem.paraCount == 1 + assert theItem.paraCount == 0 theItem.setParaCount(1) assert theItem.paraCount == 1 @@ -177,7 +179,7 @@ def testCoreItem_Setters(mockGUI, mockRnd): theItem.setCursorPos(None) assert theItem.cursorPos == 0 theItem.setCursorPos("1") - assert theItem.cursorPos == 1 + assert theItem.cursorPos == 0 theItem.setCursorPos(1) assert theItem.cursorPos == 1 @@ -190,10 +192,12 @@ def testCoreItem_Setters(mockGUI, mockRnd): @pytest.mark.core -def testCoreItem_Methods(mockGUI): +def testCoreItem_Methods(mockGUI, mockRnd, fncPath): """Test the simple methods of the NWItem class. """ theProject = NWProject(mockGUI) + mockRnd.reset() + buildTestProject(theProject, fncPath) theItem = NWItem(theProject) # Describe Me @@ -203,35 +207,62 @@ def testCoreItem_Methods(mockGUI): theItem.setType("ROOT") assert theItem.describeMe() == "Root Folder" + assert theItem.isRootType() is True theItem.setType("FOLDER") assert theItem.describeMe() == "Folder" + assert theItem.isFolderType() is True theItem.setType("FILE") theItem.setLayout("DOCUMENT") + assert theItem.isFileType() is True + assert theItem.isDocumentLayout() is True + + theItem.setMainHeading("HH") + assert theItem.mainHeading == "H0" assert theItem.describeMe() == "Novel Document" - assert theItem.describeMe("H0") == "Novel Document" - assert theItem.describeMe("H1") == "Novel Title Page" - assert theItem.describeMe("H2") == "Novel Chapter" - assert theItem.describeMe("H3") == "Novel Scene" - assert theItem.describeMe("H4") == "Novel Document" + + theItem.setMainHeading("H0") + assert theItem.mainHeading == "H0" + assert theItem.describeMe() == "Novel Document" + + theItem.setMainHeading("H1") + assert theItem.mainHeading == "H1" + assert theItem.describeMe() == "Novel Title Page" + + theItem.setMainHeading("H2") + assert theItem.mainHeading == "H2" + assert theItem.describeMe() == "Novel Chapter" + + theItem.setMainHeading("H3") + assert theItem.mainHeading == "H3" + assert theItem.describeMe() == "Novel Scene" + + theItem.setMainHeading("H4") + assert theItem.mainHeading == "H4" + assert theItem.describeMe() == "Novel Section" + + theItem.setMainHeading("H5") + assert theItem.mainHeading == "H4" + assert theItem.describeMe() == "Novel Section" theItem.setLayout("NOTE") + assert theItem.isNoteLayout() is True assert theItem.describeMe() == "Project Note" # Status + Icon # ============= theItem.setType("FILE") - theItem.setStatus("Note") - theItem.setImport("Minor") + theItem.setStatus(C.sNote) + theItem.setImport(C.iMinor) theItem.setClass("NOVEL") stT, stI = theItem.getImportStatus() assert stT == "Note" assert isinstance(stI, QIcon) - theItem.setImportStatus("Draft") + theItem.setImportStatus(C.sDraft) stT, stI = theItem.getImportStatus() assert stT == "Draft" @@ -240,7 +271,7 @@ def testCoreItem_Methods(mockGUI): assert stT == "Minor" assert isinstance(stI, QIcon) - theItem.setImportStatus("Major") + theItem.setImportStatus(C.iMajor) stT, stI = theItem.getImportStatus() assert stT == "Major" @@ -464,254 +495,217 @@ def testCoreItem_ClassDefaults(mockGUI): @pytest.mark.core -def testCoreItem_XMLPackUnpack(mockGUI, caplog, mockRnd): - """Test packing and unpacking XML objects for the NWItem class. +def testCoreItem_PackUnpack(mockGUI, caplog, mockRnd): + """Test packing and unpacking entries for the NWItem class. """ theProject = NWProject(mockGUI) - nwXML = etree.Element("novelWriterXML") + theProject.data.itemStatus.write(None, "New", (100, 100, 100)) + theProject.data.itemImport.write(None, "New", (100, 100, 100)) - statusKeys = ["s000000", "s000001", "s000002", "s000003"] - importKeys = ["i000004", "i000005", "i000006", "i000007"] + # Invalid + theItem = NWItem(theProject) + assert theItem.unpack({}) is False # File - # ==== - theItem = NWItem(theProject) - theItem.setHandle("0123456789abc") - theItem.setParent("0123456789abc") - theItem.setRoot("0123456789abc") - theItem.setOrder(1) - theItem.setName("A Name") - theItem.setClass("NOVEL") - theItem.setType("FILE") - theItem.setImport(importKeys[3]) - theItem.setLayout("NOTE") - theItem.setExported(False) - theItem.setParaCount(3) - theItem.setWordCount(5) - theItem.setCharCount(7) - theItem.setCursorPos(11) + assert theItem.unpack({ + "name": "A File", + "itemAttr": { + "handle": "0000000000003", + "parent": "0000000000002", + "root": "0000000000001", + "order": 1, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT", + }, + "metaAttr": { + "expanded": True, + "heading": "H1", + "charCount": 100, + "wordCount": 20, + "paraCount": 2, + "cursorPos": 50, + }, + "nameAttr": { + "status": None, + "import": None, + "active": False, + }, + }) is True - # Pack - xContent = etree.SubElement(nwXML, "content") - theItem.packXML(xContent) - assert etree.tostring(xContent, pretty_print=False, encoding="utf-8") == ( - b'' - b'A Name' - b'' - ) % bytes(importKeys[3], encoding="utf8") - - # Unpack - theItem = NWItem(theProject) - assert theItem.unpackXML(xContent[0]) - assert theItem.itemHandle == "0123456789abc" - assert theItem.itemParent == "0123456789abc" - assert theItem.itemRoot == "0123456789abc" + assert theItem.itemName == "A File" + assert theItem.itemHandle == "0000000000003" + assert theItem.itemParent == "0000000000002" + assert theItem.itemRoot == "0000000000001" assert theItem.itemOrder == 1 - assert theItem.isExported is False - assert theItem.paraCount == 3 - assert theItem.wordCount == 5 - assert theItem.charCount == 7 - assert theItem.cursorPos == 11 - assert theItem.itemClass == nwItemClass.NOVEL assert theItem.itemType == nwItemType.FILE - assert theItem.itemLayout == nwItemLayout.NOTE - assert theItem.itemStatus == statusKeys[0] # Was None, should now be default - assert theItem.itemImport == importKeys[3] - - # Folder - # ====== - - theItem = NWItem(theProject) - theItem.setHandle("0123456789abc") - theItem.setParent("0123456789abc") - theItem.setRoot("0123456789abc") - theItem.setOrder(1) - theItem.setName("A Name") - theItem.setClass("NOVEL") - theItem.setType("FOLDER") - theItem.setStatus(statusKeys[1]) - theItem.setLayout("NOTE") - theItem.setExpanded(True) - theItem.setExported(False) - theItem.setParaCount(3) - theItem.setWordCount(5) - theItem.setCharCount(7) - theItem.setCursorPos(11) - - # Pack - xContent = etree.SubElement(nwXML, "content") - theItem.packXML(xContent) - assert etree.tostring(xContent, pretty_print=False, encoding="utf-8") == ( - b'' - b'A Name' - b'' - ) % bytes(statusKeys[1], encoding="utf8") - - # Unpack - theItem = NWItem(theProject) - assert theItem.unpackXML(xContent[0]) - assert theItem.itemHandle == "0123456789abc" - assert theItem.itemParent == "0123456789abc" - assert theItem.itemRoot == "0123456789abc" - assert theItem.itemOrder == 1 - assert theItem.isExpanded is True - assert theItem.isExported is True - assert theItem.paraCount == 0 - assert theItem.wordCount == 0 - assert theItem.charCount == 0 - assert theItem.cursorPos == 0 assert theItem.itemClass == nwItemClass.NOVEL - assert theItem.itemType == nwItemType.FOLDER - assert theItem.itemLayout == nwItemLayout.NO_LAYOUT - assert theItem.itemStatus == statusKeys[1] - assert theItem.itemImport == importKeys[0] # Was None, should now be default - - # Errors - # ====== - - # Not an Item - mockXml = etree.SubElement(nwXML, "stuff") - assert theItem.unpackXML(mockXml) is False - - # Item without Handle - mockXml = etree.SubElement(nwXML, "item", attrib={"stuff": "nah"}) - assert theItem.unpackXML(mockXml) is False - - # Item with Invalid SubElement is Accepted w/Error - mockXml = etree.SubElement(nwXML, "item", attrib={"handle": "0123456789abc"}) - xParam = etree.SubElement(mockXml, "invalid") - xParam.text = "stuff" - caplog.clear() - assert theItem.unpackXML(mockXml) is True - assert "Unknown tag 'invalid'" in caplog.text - - # Pack Valid Item - mockXml = etree.SubElement(nwXML, "group") - theItem._subPack(mockXml, "subGroup", {"one": "two"}, "value", False) - assert etree.tostring(mockXml, pretty_print=False, encoding="utf-8") == ( - b"value" - ) - - # Pack Not Allowed None - mockXml = etree.SubElement(nwXML, "group") - assert theItem._subPack(mockXml, "subGroup", {}, None, False) is None - assert theItem._subPack(mockXml, "subGroup", {}, "None", False) is None - assert etree.tostring(mockXml, pretty_print=False, encoding="utf-8") == ( - b"" - ) - -# END Test testCoreItem_XMLPackUnpack - - -@pytest.mark.core -def testCoreItem_ConvertFromFmt12(mockGUI): - """Test the setter for all the nwItemLayout values for the NWItem - class using the class names that were present in file format 1.2. - """ - theProject = NWProject(mockGUI) - theItem = NWItem(theProject) - - # Deprecated Layouts - theItem.setLayout("TITLE") assert theItem.itemLayout == nwItemLayout.DOCUMENT - theItem.setLayout("PAGE") - assert theItem.itemLayout == nwItemLayout.DOCUMENT - theItem.setLayout("BOOK") - assert theItem.itemLayout == nwItemLayout.DOCUMENT - theItem.setLayout("PARTITION") - assert theItem.itemLayout == nwItemLayout.DOCUMENT - theItem.setLayout("UNNUMBERED") - assert theItem.itemLayout == nwItemLayout.DOCUMENT - theItem.setLayout("CHAPTER") - assert theItem.itemLayout == nwItemLayout.DOCUMENT - theItem.setLayout("SCENE") - assert theItem.itemLayout == nwItemLayout.DOCUMENT - theItem.setLayout("MUMBOJUMBO") - assert theItem.itemLayout == nwItemLayout.NO_LAYOUT - -# END Test testCoreItem_ConvertFromFmt12 - - -@pytest.mark.core -def testCoreItem_ConvertFromFmt13(mockGUI): - """Test packing and unpacking XML objects for the NWItem class from - format version 1.3 - """ - theProject = NWProject(mockGUI) - - # Make Version 1.3 XML - nwXML = etree.Element("novelWriterXML") - xContent = etree.SubElement(nwXML, "content") - - # Folder - xPack = etree.SubElement(xContent, "item", attrib={ - "handle": "a000000000001", - "order": "1", - "parent": "b000000000001", - }) - NWItem._subPack(xPack, "name", text="Folder") - NWItem._subPack(xPack, "type", text="FOLDER") - NWItem._subPack(xPack, "class", text="NOVEL") - NWItem._subPack(xPack, "status", text="New") - NWItem._subPack(xPack, "expanded", text="True") - - # Unpack Folder - theItem = NWItem(theProject) - theItem.unpackXML(xContent[0]) - assert theItem.itemHandle == "a000000000001" - assert theItem.itemParent == "b000000000001" - assert theItem.itemOrder == 1 + assert theItem.itemStatus == "s000000" + assert theItem.itemImport == "i000001" + assert theItem.isActive is False assert theItem.isExpanded is True - assert theItem.isExported is True - assert theItem.charCount == 0 - assert theItem.wordCount == 0 - assert theItem.paraCount == 0 - assert theItem.cursorPos == 0 - assert theItem.itemClass == nwItemClass.NOVEL - assert theItem.itemType == nwItemType.FOLDER - assert theItem.itemLayout == nwItemLayout.NO_LAYOUT - - # File - xPack = etree.SubElement(xContent, "item", attrib={ - "handle": "c000000000001", - "order": "2", - "parent": "a000000000001", - }) - NWItem._subPack(xPack, "name", text="Scene") - NWItem._subPack(xPack, "type", text="FILE") - NWItem._subPack(xPack, "class", text="NOVEL") - NWItem._subPack(xPack, "status", text="New") - NWItem._subPack(xPack, "exported", text="True") - NWItem._subPack(xPack, "layout", text="DOCUMENT") - NWItem._subPack(xPack, "charCount", text="600") - NWItem._subPack(xPack, "wordCount", text="100") - NWItem._subPack(xPack, "paraCount", text="6") - NWItem._subPack(xPack, "cursorPos", text="50") - - # Unpack File - theItem = NWItem(theProject) - theItem.unpackXML(xContent[1]) - assert theItem.itemHandle == "c000000000001" - assert theItem.itemParent == "a000000000001" - assert theItem.itemOrder == 2 - assert theItem.isExpanded is False - assert theItem.isExported is True - assert theItem.charCount == 600 - assert theItem.wordCount == 100 - assert theItem.paraCount == 6 + assert theItem.mainHeading == "H1" + assert theItem.charCount == 100 + assert theItem.wordCount == 20 + assert theItem.paraCount == 2 assert theItem.cursorPos == 50 + + assert theItem.pack() == { + "name": "A File", + "itemAttr": { + "handle": "0000000000003", + "parent": "0000000000002", + "root": "0000000000001", + "order": "1", + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT", + }, + "metaAttr": { + "expanded": "yes", + "heading": "H1", + "charCount": "100", + "wordCount": "20", + "paraCount": "2", + "cursorPos": "50", + }, + "nameAttr": { + "status": "s000000", + "import": "i000001", + "active": "no", + } + } + + # Folder + theItem = NWItem(theProject) + assert theItem.unpack({ + "name": "A Folder", + "itemAttr": { + "handle": "0000000000003", + "parent": "0000000000002", + "root": "0000000000001", + "order": 1, + "type": "FOLDER", + "class": "NOVEL", + "layout": "DOCUMENT", + }, + "metaAttr": { + "expanded": True, + "heading": "H1", + "charCount": 100, + "wordCount": 20, + "paraCount": 2, + "cursorPos": 50, + }, + "nameAttr": { + "status": "", + "import": "", + "active": True, + } + }) is True + + assert theItem.itemName == "A Folder" + assert theItem.itemHandle == "0000000000003" + assert theItem.itemParent == "0000000000002" + assert theItem.itemRoot == "0000000000001" + assert theItem.itemOrder == 1 + assert theItem.itemType == nwItemType.FOLDER assert theItem.itemClass == nwItemClass.NOVEL - assert theItem.itemType == nwItemType.FILE - assert theItem.itemLayout == nwItemLayout.DOCUMENT + assert theItem.itemLayout == nwItemLayout.NO_LAYOUT + assert theItem.itemStatus == "s000000" + assert theItem.itemImport == "i000001" + assert theItem.isActive is False + assert theItem.isExpanded is True + assert theItem.mainHeading == "H0" + assert theItem.charCount == 0 + assert theItem.wordCount == 0 + assert theItem.paraCount == 0 + assert theItem.cursorPos == 0 - # Deprecated Type - theItem.setType("TRASH") + assert theItem.pack() == { + "name": "A Folder", + "itemAttr": { + "handle": "0000000000003", + "parent": "0000000000002", + "root": "0000000000001", + "order": "1", + "type": "FOLDER", + "class": "NOVEL", + }, + "metaAttr": { + "expanded": "yes", + }, + "nameAttr": { + "status": "s000000", + "import": "i000001", + } + } + + # Root + theItem = NWItem(theProject) + assert theItem.unpack({ + "name": "A Novel", + "itemAttr": { + "handle": "0000000000003", + "parent": "0000000000002", + "root": "0000000000001", + "order": 1, + "type": "ROOT", + "class": "NOVEL", + "layout": "DOCUMENT", + }, + "metaAttr": { + "expanded": True, + "heading": "H1", + "charCount": 100, + "wordCount": 20, + "paraCount": 2, + "cursorPos": 50, + }, + "nameAttr": { + "status": None, + "import": None, + "active": True, + }, + }) is True + + assert theItem.itemName == "A Novel" + assert theItem.itemHandle == "0000000000003" + assert theItem.itemParent is None + assert theItem.itemRoot == "0000000000003" + assert theItem.itemOrder == 1 assert theItem.itemType == nwItemType.ROOT + assert theItem.itemClass == nwItemClass.NOVEL + assert theItem.itemLayout == nwItemLayout.NO_LAYOUT + assert theItem.itemStatus == "s000000" + assert theItem.itemImport == "i000001" + assert theItem.isActive is False + assert theItem.isExpanded is True + assert theItem.mainHeading == "H0" + assert theItem.charCount == 0 + assert theItem.wordCount == 0 + assert theItem.paraCount == 0 + assert theItem.cursorPos == 0 -# END Test testCoreItem_ConvertFromFmt13 + assert theItem.pack() == { + "name": "A Novel", + "itemAttr": { + "handle": "0000000000003", + "parent": "None", + "root": "0000000000003", + "order": "1", + "type": "ROOT", + "class": "NOVEL", + }, + "metaAttr": { + "expanded": "yes", + }, + "nameAttr": { + "status": "s000000", + "import": "i000001", + } + } + +# END Test testCoreItem_PackUnpack diff --git a/tests/test_core/test_core_options.py b/tests/test_core/test_core_options.py index b13d4799..8d1b74fb 100644 --- a/tests/test_core/test_core_options.py +++ b/tests/test_core/test_core_options.py @@ -19,28 +19,30 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import os import json import pytest from mock import causeOSError -from tools import writeFile -from novelwriter.core import NWProject -from novelwriter.core.options import OptionState from novelwriter.constants import nwFiles +from novelwriter.core.options import OptionState +from novelwriter.core.project import NWProject +from novelwriter.gui.noveltree import NovelTreeColumn @pytest.mark.core -def testCoreOptions_LoadSave(monkeypatch, mockGUI, tmpDir): +def testCoreOptions_LoadSave(monkeypatch, mockGUI, fncPath): """Test loading and saving from the OptionState class. """ theProject = NWProject(mockGUI) theOpts = OptionState(theProject) + metaDir = fncPath / "meta" + metaDir.mkdir() + # Write a test file - optFile = os.path.join(tmpDir, nwFiles.OPTS_FILE) - writeFile(optFile, json.dumps({ + optFile = metaDir / nwFiles.OPTS_FILE + optFile.write_text(json.dumps({ "GuiBuildNovel": { "winWidth": 1000, "winHeight": 700, @@ -52,22 +54,22 @@ def testCoreOptions_LoadSave(monkeypatch, mockGUI, tmpDir): "MockGroup": { "mockItem": None, }, - })) + }), encoding="utf-8") # Load and save with no path set - theProject.projMeta = None - assert not theOpts.loadSettings() - assert not theOpts.saveSettings() + theProject.storage._runtimePath = None + assert theOpts.loadSettings() is False + assert theOpts.saveSettings() is False # Set path - theProject.projMeta = tmpDir - assert theProject.projMeta == tmpDir + theProject.storage._runtimePath = fncPath + assert theProject.storage.getMetaFile(nwFiles.OPTS_FILE) == optFile # Cause open() to fail with monkeypatch.context() as mp: mp.setattr("builtins.open", causeOSError) - assert not theOpts.loadSettings() - assert not theOpts.saveSettings() + assert theOpts.loadSettings() is False + assert theOpts.saveSettings() is False # Load proper assert theOpts.loadSettings() @@ -108,9 +110,11 @@ def testCoreOptions_SetGet(mockGUI): theProject = NWProject(mockGUI) theOpts = OptionState(theProject) + nwColHidden = NovelTreeColumn.HIDDEN + # Set invalid values - assert not theOpts.setValue("MockGroup", "mockItem", None) - assert not theOpts.setValue("GuiBuildNovel", "mockItem", None) + assert theOpts.setValue("MockGroup", "mockItem", None) is False + assert theOpts.setValue("GuiBuildNovel", "mockItem", None) is False # Set valid value assert theOpts.setValue("GuiBuildNovel", "winWidth", 100) @@ -120,6 +124,7 @@ def testCoreOptions_SetGet(mockGUI): assert theOpts.setValue("GuiBuildNovel", "winHeight", 12.34) assert theOpts.setValue("GuiBuildNovel", "addNovel", True) assert theOpts.setValue("GuiBuildNovel", "textFont", "Cantarell") + assert theOpts.setValue("GuiNovelView", "lastCol", nwColHidden) # Generic get, doesn't check type assert theOpts.getValue("GuiBuildNovel", "winWidth", None) == 100 @@ -139,5 +144,14 @@ def testCoreOptions_SetGet(mockGUI): assert theOpts.getFloat("GuiBuildNovel", "mockItem", None) is None assert theOpts.getBool("GuiBuildNovel", "addNovel", None) is True assert theOpts.getBool("GuiBuildNovel", "mockItem", None) is None + assert theOpts.getEnum("GuiNovelView", "lastCol", NovelTreeColumn, None) == nwColHidden + + # Get from non-existent groups + assert theOpts.getValue("SomeGroup", "mockItem", None) is None + assert theOpts.getString("SomeGroup", "mockItem", None) is None + assert theOpts.getInt("SomeGroup", "mockItem", None) is None + assert theOpts.getFloat("SomeGroup", "mockItem", None) is None + assert theOpts.getBool("SomeGroup", "mockItem", None) is None + assert theOpts.getEnum("SomeGroup", "mockItem", NovelTreeColumn, None) is None # END Test testCoreOptions_SetGet diff --git a/tests/test_core/test_core_project.py b/tests/test_core/test_core_project.py index a6409b98..52b68859 100644 --- a/tests/test_core/test_core_project.py +++ b/tests/test_core/test_core_project.py @@ -19,15 +19,15 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import os import pytest +from time import time from shutil import copyfile +from pathlib import Path from zipfile import ZipFile -from lxml import etree -from tools import cmpFiles, writeFile, readFile, buildTestProject, XML_IGNORE from mock import causeOSError +from tools import C, cmpFiles, writeFile, buildTestProject, XML_IGNORE from novelwriter.enum import nwItemClass, nwItemType, nwItemLayout from novelwriter.common import formatTimeStamp @@ -36,241 +36,29 @@ from novelwriter.core.tree import NWTree from novelwriter.core.index import NWIndex from novelwriter.core.project import NWProject from novelwriter.core.options import OptionState -from novelwriter.core.document import NWDoc +from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter, XMLReadState @pytest.mark.core -def testCoreProject_NewMinimal(fncDir, outDir, refDir, mockGUI, mockRnd): - """Create a new project from a project wizard dictionary. With - default setting, creating a Minimal project. - """ - projFile = os.path.join(fncDir, "nwProject.nwx") - testFile = os.path.join(outDir, "coreProject_NewMinimal_nwProject.nwx") - compFile = os.path.join(refDir, "coreProject_NewMinimal_nwProject.nwx") - - theProject = NWProject(mockGUI) - - # Setting no data should fail - assert theProject.newProject({}) is False - - # Wrong type should also fail - assert theProject.newProject("stuff") is False - - # Try again with a proper path - assert theProject.newProject({"projPath": fncDir}) is True - assert theProject.saveProject() is True - assert theProject.closeProject() is True - - # Creating the project once more should fail - assert theProject.newProject({"projPath": fncDir}) is False - - # Open again - assert theProject.openProject(projFile) is True - - # Save and close - assert theProject.saveProject() is True - assert theProject.closeProject() is True - copyfile(projFile, testFile) - assert cmpFiles(testFile, compFile, ignoreStart=XML_IGNORE) - assert theProject.projChanged is False - - # Open a second time - assert theProject.openProject(projFile) is True - assert theProject.openProject(projFile) is False - assert theProject.openProject(projFile, overrideLock=True) is True - assert theProject.saveProject() is True - assert theProject.closeProject() is True - copyfile(projFile, testFile) - assert cmpFiles(testFile, compFile, ignoreStart=XML_IGNORE) - -# END Test testCoreProject_NewMinimal - - -@pytest.mark.core -def testCoreProject_NewCustomA(fncDir, outDir, refDir, mockGUI, mockRnd): - """Create a new project from a project wizard dictionary. - Custom type with chapters and scenes. - """ - projFile = os.path.join(fncDir, "nwProject.nwx") - testFile = os.path.join(outDir, "coreProject_NewCustomA_nwProject.nwx") - compFile = os.path.join(refDir, "coreProject_NewCustomA_nwProject.nwx") - - projData = { - "projName": "Test Custom", - "projTitle": "Test Novel", - "projAuthors": "Jane Doe\nJohn Doh\n", - "projPath": fncDir, - "popSample": False, - "popMinimal": False, - "popCustom": True, - "addRoots": [ - nwItemClass.PLOT, - nwItemClass.CHARACTER, - nwItemClass.WORLD, - ], - "addNotes": True, - "numChapters": 3, - "numScenes": 3, - } - theProject = NWProject(mockGUI) - - assert theProject.newProject(projData) is True - assert theProject.saveProject() is True - assert theProject.closeProject() is True - - copyfile(projFile, testFile) - assert cmpFiles(testFile, compFile, ignoreStart=XML_IGNORE) - -# END Test testCoreProject_NewCustomA - - -@pytest.mark.core -def testCoreProject_NewCustomB(fncDir, outDir, refDir, mockGUI, mockRnd): - """Create a new project from a project wizard dictionary. - Custom type without chapters, but with scenes. - """ - projFile = os.path.join(fncDir, "nwProject.nwx") - testFile = os.path.join(outDir, "coreProject_NewCustomB_nwProject.nwx") - compFile = os.path.join(refDir, "coreProject_NewCustomB_nwProject.nwx") - - projData = { - "projName": "Test Custom", - "projTitle": "Test Novel", - "projAuthors": "Jane Doe\nJohn Doh\n", - "projPath": fncDir, - "popSample": False, - "popMinimal": False, - "popCustom": True, - "addRoots": [ - nwItemClass.PLOT, - nwItemClass.CHARACTER, - nwItemClass.WORLD, - ], - "addNotes": True, - "numChapters": 0, - "numScenes": 6, - } - theProject = NWProject(mockGUI) - - assert theProject.newProject(projData) is True - assert theProject.saveProject() is True - assert theProject.closeProject() is True - - copyfile(projFile, testFile) - assert cmpFiles(testFile, compFile, ignoreStart=XML_IGNORE) - -# END Test testCoreProject_NewCustomB - - -@pytest.mark.core -def testCoreProject_NewSampleA(fncDir, tmpConf, mockGUI, tmpDir): - """Check that we can create a new project can be created from the - provided sample project via a zip file. - """ - projData = { - "projName": "Test Sample", - "projTitle": "Test Novel", - "projAuthors": "Jane Doe\nJohn Doh\n", - "projPath": fncDir, - "popSample": True, - "popMinimal": False, - "popCustom": False, - } - theProject = NWProject(mockGUI) - - # Sample set, but no path - assert not theProject.newProject({"popSample": True}) - - # Force the lookup path for assets to our temp folder - srcSample = os.path.abspath(os.path.join(tmpConf.appRoot, "sample")) - dstSample = os.path.join(tmpDir, "sample.zip") - tmpConf.assetPath = tmpDir - - # Create and open a defective zip file - with open(dstSample, mode="w+") as outFile: - outFile.write("foo") - - assert not theProject.newProject(projData) - os.unlink(dstSample) - - # Create a real zip file, and unpack it - with ZipFile(dstSample, "w") as zipObj: - zipObj.write(os.path.join(srcSample, "nwProject.nwx"), "nwProject.nwx") - for docFile in os.listdir(os.path.join(srcSample, "content")): - srcDoc = os.path.join(srcSample, "content", docFile) - zipObj.write(srcDoc, "content/"+docFile) - - assert theProject.newProject(projData) is True - assert theProject.openProject(fncDir) is True - assert theProject.projName == "Sample Project" - assert theProject.saveProject() is True - assert theProject.closeProject() is True - os.unlink(dstSample) - -# END Test testCoreProject_NewSampleA - - -@pytest.mark.core -def testCoreProject_NewSampleB(monkeypatch, fncDir, tmpConf, mockGUI, tmpDir): - """Check that we can create a new project can be created from the - provided sample project folder. - """ - projData = { - "projName": "Test Sample", - "projTitle": "Test Novel", - "projAuthors": "Jane Doe\nJohn Doh\n", - "projPath": fncDir, - "popSample": True, - "popMinimal": False, - "popCustom": False, - } - theProject = NWProject(mockGUI) - - # Make sure we do not pick up the novelwriter/assets/sample.zip file - tmpConf.assetPath = tmpDir - - # Set a fake project file name - monkeypatch.setattr(nwFiles, "PROJ_FILE", "nothing.nwx") - assert not theProject.newProject(projData) - - monkeypatch.setattr(nwFiles, "PROJ_FILE", "nwProject.nwx") - assert theProject.newProject(projData) is True - assert theProject.openProject(fncDir) is True - assert theProject.projName == "Sample Project" - assert theProject.saveProject() is True - assert theProject.closeProject() is True - - # Misdirect the appRoot path so neither is possible - tmpConf.appRoot = tmpDir - assert not theProject.newProject(projData) - -# END Test testCoreProject_NewSampleB - - -@pytest.mark.core -def testCoreProject_NewRoot(fncDir, outDir, refDir, mockGUI, mockRnd): +def testCoreProject_NewRoot(fncPath, tstPaths, mockGUI, mockRnd): """Check that new root folders can be added to the project. """ - projFile = os.path.join(fncDir, "nwProject.nwx") - testFile = os.path.join(outDir, "coreProject_NewRoot_nwProject.nwx") - compFile = os.path.join(refDir, "coreProject_NewRoot_nwProject.nwx") + projFile = fncPath / "nwProject.nwx" + testFile = tstPaths.outDir / "coreProject_NewRoot_nwProject.nwx" + compFile = tstPaths.refDir / "coreProject_NewRoot_nwProject.nwx" theProject = NWProject(mockGUI) - buildTestProject(theProject, fncDir) + mockRnd.reset() + buildTestProject(theProject, fncPath) - assert theProject.setProjectPath(fncDir) is True - assert theProject.saveProject() is True - assert theProject.closeProject() is True - assert theProject.openProject(projFile) is True - - assert isinstance(theProject.newRoot(nwItemClass.NOVEL), str) - assert isinstance(theProject.newRoot(nwItemClass.PLOT), str) - assert isinstance(theProject.newRoot(nwItemClass.CHARACTER), str) - assert isinstance(theProject.newRoot(nwItemClass.WORLD), str) - assert isinstance(theProject.newRoot(nwItemClass.TIMELINE), str) - assert isinstance(theProject.newRoot(nwItemClass.OBJECT), str) - assert isinstance(theProject.newRoot(nwItemClass.CUSTOM), str) - assert isinstance(theProject.newRoot(nwItemClass.CUSTOM), str) + assert theProject.newRoot(nwItemClass.NOVEL) == "0000000000010" + assert theProject.newRoot(nwItemClass.PLOT) == "0000000000011" + assert theProject.newRoot(nwItemClass.CHARACTER) == "0000000000012" + assert theProject.newRoot(nwItemClass.WORLD) == "0000000000013" + assert theProject.newRoot(nwItemClass.TIMELINE) == "0000000000014" + assert theProject.newRoot(nwItemClass.OBJECT) == "0000000000015" + assert theProject.newRoot(nwItemClass.CUSTOM) == "0000000000016" + assert theProject.newRoot(nwItemClass.CUSTOM) == "0000000000017" assert theProject.projChanged is True assert theProject.saveProject() is True @@ -280,197 +68,192 @@ def testCoreProject_NewRoot(fncDir, outDir, refDir, mockGUI, mockRnd): assert cmpFiles(testFile, compFile, ignoreStart=XML_IGNORE) assert theProject.projChanged is False + # Delete the new items + assert theProject.removeItem("0000000000010") is True + assert theProject.removeItem("0000000000011") is True + assert theProject.removeItem("0000000000012") is True + assert theProject.removeItem("0000000000013") is True + assert theProject.removeItem("0000000000014") is True + assert theProject.removeItem("0000000000015") is True + assert theProject.removeItem("0000000000016") is True + assert theProject.removeItem("0000000000017") is True + + assert "0000000000010" not in theProject.tree + assert "0000000000011" not in theProject.tree + assert "0000000000012" not in theProject.tree + assert "0000000000013" not in theProject.tree + assert "0000000000014" not in theProject.tree + assert "0000000000015" not in theProject.tree + assert "0000000000016" not in theProject.tree + assert "0000000000017" not in theProject.tree + # END Test testCoreProject_NewRoot @pytest.mark.core -def testCoreProject_NewFileFolder(fncDir, outDir, refDir, mockGUI, mockRnd): +def testCoreProject_NewFileFolder(monkeypatch, fncPath, tstPaths, mockGUI, mockRnd): """Check that new files can be added to the project. """ - projFile = os.path.join(fncDir, "nwProject.nwx") - testFile = os.path.join(outDir, "coreProject_NewFileFolder_nwProject.nwx") - compFile = os.path.join(refDir, "coreProject_NewFileFolder_nwProject.nwx") + projFile = fncPath / "nwProject.nwx" + testFile = tstPaths.outDir / "coreProject_NewFileFolder_nwProject.nwx" + compFile = tstPaths.refDir / "coreProject_NewFileFolder_nwProject.nwx" theProject = NWProject(mockGUI) - buildTestProject(theProject, fncDir) - - assert theProject.setProjectPath(fncDir) is True - assert theProject.saveProject() is True - assert theProject.closeProject() is True - assert theProject.openProject(projFile) is True + mockRnd.reset() + buildTestProject(theProject, fncPath) # Invalid call assert theProject.newFolder("New Folder", "1234567890abc") is None assert theProject.newFile("New File", "1234567890abc") is None # Add files properly - assert theProject.newFolder("Stuff", "0000000000015") == "0000000000028" - assert theProject.newFile("Hello", "0000000000015") == "0000000000029" - assert theProject.newFile("Jane", "0000000000012") == "000000000002a" + assert theProject.newFolder("Stuff", C.hNovelRoot) == "0000000000010" + assert theProject.newFile("Hello", "0000000000010") == "0000000000011" + assert theProject.newFile("Jane", C.hCharRoot) == "0000000000012" - assert "0000000000028" in theProject.tree - assert "0000000000029" in theProject.tree - assert "000000000002a" in theProject.tree + assert "0000000000010" in theProject.tree + assert "0000000000011" in theProject.tree + assert "0000000000012" in theProject.tree # Write to file, failed assert theProject.writeNewFile("blabla", 1, True) is False # Not a handle - assert theProject.writeNewFile("0000000000028", 1, True) is False # Not a file - assert theProject.writeNewFile("0000000000014", 1, True) is False # Already has content + assert theProject.writeNewFile("0000000000010", 1, True) is False # Not a file + assert theProject.writeNewFile(C.hTitlePage, 1, True) is False # Already has content # Write to file, success - assert theProject.writeNewFile("0000000000029", 2, True) is True - assert NWDoc(theProject, "0000000000029").readDocument() == "## Hello\n\n" + assert theProject.writeNewFile("0000000000011", 2, True) is True + assert theProject.storage.getDocument("0000000000011").readDocument() == "## Hello\n\n" - assert theProject.writeNewFile("000000000002a", 1, False) is True - assert NWDoc(theProject, "000000000002a").readDocument() == "# Jane\n\n" + # Write to file with additional text, success + assert theProject.writeNewFile("0000000000012", 1, False, "Hi Jane\n\n") is True + assert theProject.storage.getDocument("0000000000012").readDocument() == ( + "# Jane\n\nHi Jane\n\n" + ) # Save, close and check assert theProject.projChanged is True assert theProject.saveProject() is True - assert theProject.closeProject() is True copyfile(projFile, testFile) assert cmpFiles(testFile, compFile, ignoreStart=XML_IGNORE) assert theProject.projChanged is False + # Delete new file, but block access + with monkeypatch.context() as mp: + mp.setattr("pathlib.Path.unlink", causeOSError) + assert theProject.removeItem("0000000000011") is False + assert "0000000000011" in theProject.tree + + # Delete new files and folders + assert (fncPath / "content" / "0000000000012.nwd").exists() + assert (fncPath / "content" / "0000000000011.nwd").exists() + + assert theProject.removeItem("0000000000012") is True + assert theProject.removeItem("0000000000011") is True + assert theProject.removeItem("0000000000010") is True + + assert not (fncPath / "content" / "0000000000012.nwd").exists() + assert not (fncPath / "content" / "0000000000011.nwd").exists() + + assert "0000000000010" not in theProject.tree + assert "0000000000011" not in theProject.tree + assert "0000000000012" not in theProject.tree + + assert theProject.closeProject() is True + # END Test testCoreProject_NewFileFolder @pytest.mark.core -def testCoreProject_Open(monkeypatch, nwMinimal, mockGUI): +def testCoreProject_Open(monkeypatch, caplog, mockGUI, fncPath, mockRnd): """Test opening a project. """ theProject = NWProject(mockGUI) + mockRnd.reset() + buildTestProject(theProject, fncPath) - # Rename the project file to check handling - rName = os.path.join(nwMinimal, nwFiles.PROJ_FILE) - wName = os.path.join(nwMinimal, nwFiles.PROJ_FILE+"_sdfghj") - os.rename(rName, wName) - assert theProject.openProject(nwMinimal) is False - os.rename(wName, rName) - - # Fail on folder structure check + # Initialising the storage class fails with monkeypatch.context() as mp: - mp.setattr("os.mkdir", causeOSError) - assert theProject.openProject(nwMinimal) is False + mp.setattr("novelwriter.core.storage.NWStorage.openProjectInPlace", lambda *a, **k: False) + assert theProject.openProject(fncPath) is False # Fail on lock file - theProject.setProjectPath(nwMinimal) - assert theProject._writeLockFile() - assert theProject.openProject(nwMinimal) is False + assert theProject._storage.writeLockFile() + assert theProject.openProject(fncPath) is False + assert isinstance(theProject.getLockStatus(), list) # Fail to read lockfile (which still opens the project) with monkeypatch.context() as mp: - mp.setattr("builtins.open", causeOSError) - assert theProject.openProject(nwMinimal) is True - assert theProject.closeProject() + mp.setattr("novelwriter.core.storage.NWStorage.readLockFile", lambda *a: ["ERROR"]) + caplog.clear() + assert theProject.openProject(fncPath) is True + assert "Failed to check lock file" in caplog.text + assert theProject.closeProject() # Force open with lockfile - theProject.setProjectPath(nwMinimal) - assert theProject._writeLockFile() - assert theProject.openProject(nwMinimal, overrideLock=True) is True + assert theProject._storage.writeLockFile() + assert theProject.openProject(fncPath, overrideLock=True) is True assert theProject.closeProject() + assert theProject.getLockStatus() is None - # Make a junk XML file - oName = os.path.join(nwMinimal, nwFiles.PROJ_FILE[:-3]+"orig") - bName = os.path.join(nwMinimal, nwFiles.PROJ_FILE[:-3]+"bak") - os.rename(rName, oName) - writeFile(rName, "stuff") - assert theProject.openProject(nwMinimal) is False - - # Also write a jun XML backup file - writeFile(bName, "stuff") - assert theProject.openProject(nwMinimal) is False - - # Wrong root item - writeFile(rName, "\n") - assert theProject.openProject(nwMinimal) is False - - # Wrong file version - writeFile(rName, ( - "\n" - "\n" - "\n" - )) - mockGUI.askResponse = False - assert theProject.openProject(nwMinimal) is False - mockGUI.undo() - - # Future file version - writeFile(rName, ( - "\n" - "\n" - "\n" - )) - assert theProject.openProject(nwMinimal) is False - - # Update file version - writeFile(rName, ( - "\n" - "\n" - "\n" - )) - mockGUI.askResponse = False - assert theProject.openProject(nwMinimal) is False - assert mockGUI.lastQuestion[0] == "File Version" - mockGUI.undo() - - # Larger hex version - writeFile(rName, ( - "\n" - "\n" - "\n" - ) % theProject.FILE_VERSION) - mockGUI.askResponse = False - assert theProject.openProject(nwMinimal) is False - assert mockGUI.lastQuestion[0] == "Version Conflict" - mockGUI.undo() - - # Test skipping XML entries - writeFile(rName, ( - "\n" - "\n" - "\n" - "\n" - "\n" - )) - assert theProject.openProject(nwMinimal) is True - assert theProject.closeProject() - - # Clean up XML files - os.unlink(rName) - os.unlink(bName) - os.rename(oName, rName) - - # Add some legacy stuff that cannot be removed + # Fail getting xml reader with monkeypatch.context() as mp: - mp.setattr(theProject, "_legacyDataFolder", causeOSError) - os.mkdir(os.path.join(nwMinimal, "data_0")) - writeFile(os.path.join(nwMinimal, "data_0", "123456789abc_main.nwd"), "stuff") - writeFile(os.path.join(nwMinimal, "data_0", "123456789abc_main.bak"), "stuff") - mockGUI.clear() - assert theProject.openProject(nwMinimal) is True - assert "There was an error updating the project." in mockGUI.lastAlert + mp.setattr("novelwriter.core.storage.NWStorage.getXmlReader", lambda *a: None) + assert theProject.openProject(fncPath) is False + + # Not a novelwriter XML file + with monkeypatch.context() as mp: + mp.setattr(ProjectXMLReader, "read", lambda *a: False) + mp.setattr(ProjectXMLReader, "state", property(lambda *a: XMLReadState.NOT_NWX_FILE)) + assert theProject.openProject(fncPath) is False + assert "Project file does not appear" in mockGUI.lastAlert + + # Unknown project file version + with monkeypatch.context() as mp: + mp.setattr(ProjectXMLReader, "read", lambda *a: False) + mp.setattr(ProjectXMLReader, "state", property(lambda *a: XMLReadState.UNKNOWN_VERSION)) + assert theProject.openProject(fncPath) is False + assert "Unknown or unsupported novelWriter project file" in mockGUI.lastAlert + + # Other parse error + with monkeypatch.context() as mp: + mp.setattr(ProjectXMLReader, "read", lambda *a: False) + mp.setattr(ProjectXMLReader, "state", property(lambda *a: XMLReadState.CANNOT_PARSE)) + assert theProject.openProject(fncPath) is False + assert "Failed to parse project xml" in mockGUI.lastAlert + + # Won't convert legacy file + with monkeypatch.context() as mp: + mp.setattr(ProjectXMLReader, "state", property(lambda *a: XMLReadState.WAS_LEGACY)) + mockGUI.askResponse = False + assert theProject.openProject(fncPath) is False + assert "The file format of your project is about to be" in mockGUI.lastQuestion[1] + mockGUI.askResponse = True + + # Won't open project from newer version + with monkeypatch.context() as mp: + mp.setattr(ProjectXMLReader, "hexVersion", property(lambda *a: 0x99999999)) + mockGUI.askResponse = False + assert theProject.openProject(fncPath) is False + assert "This project was saved by a newer version" in mockGUI.lastQuestion[1] + mockGUI.askResponse = True + + # Fail checking items should still pass + with monkeypatch.context() as mp: + mp.setattr("novelwriter.core.tree.NWTree.updateItemData", lambda *a: False) + assert theProject.openProject(fncPath) is True + + assert theProject.closeProject() + + # Trigger an index rebuild + with monkeypatch.context() as mp: + mp.setattr(ProjectXMLReader, "state", property(lambda *a: XMLReadState.WAS_LEGACY)) + mp.setattr("novelwriter.core.index.NWIndex.loadIndex", lambda *a: True) + mockGUI.askResponse = True + theProject.index._indexBroken = True + assert theProject.openProject(fncPath) is True + assert "The file format of your project is about to be" in mockGUI.lastQuestion[1] + assert theProject.index._indexBroken is False assert theProject.closeProject() @@ -478,171 +261,41 @@ def testCoreProject_Open(monkeypatch, nwMinimal, mockGUI): @pytest.mark.core -def testCoreProject_Save(monkeypatch, nwMinimal, mockGUI, refDir): +def testCoreProject_Save(monkeypatch, mockGUI, mockRnd, fncPath): """Test saving a project. """ theProject = NWProject(mockGUI) - testFile = os.path.join(nwMinimal, "nwProject.nwx") - backFile = os.path.join(nwMinimal, "nwProject.bak") - compFile = os.path.join(refDir, os.path.pardir, "minimal", "nwProject.nwx") # Nothing to save assert theProject.saveProject() is False - # Open test project - assert theProject.openProject(nwMinimal) + mockRnd.reset() + buildTestProject(theProject, fncPath) - # Fail on folder structure check + # Fail getting xml writer with monkeypatch.context() as mp: - mp.setattr("os.path.isdir", lambda *a: False) + mp.setattr("novelwriter.core.storage.NWStorage.getXmlWriter", lambda *a: None) assert theProject.saveProject() is False - # Fail on open file + # Fail writing with monkeypatch.context() as mp: - mp.setattr("builtins.open", causeOSError) + mp.setattr(ProjectXMLWriter, "write", lambda *a: False) assert theProject.saveProject() is False - # Fail on creating .bak file - with monkeypatch.context() as mp: - mp.setattr("os.replace", causeOSError) - assert theProject.saveProject() is False - assert os.path.isfile(backFile) is False - - # Successful save - saveCount = theProject.saveCount - autoCount = theProject.autoCount - assert theProject.saveProject() is True - assert theProject.saveCount == saveCount + 1 - assert theProject.autoCount == autoCount - assert cmpFiles(testFile, compFile, ignoreStart=XML_IGNORE) - - # Check that a second save creates a .bak file - assert os.path.isfile(backFile) is True - - # Successful autosave - saveCount = theProject.saveCount - autoCount = theProject.autoCount + # Save with and without autosave + assert theProject.saveProject(autoSave=False) is True assert theProject.saveProject(autoSave=True) is True - assert theProject.saveCount == saveCount - assert theProject.autoCount == autoCount + 1 - assert cmpFiles(testFile, compFile, ignoreStart=XML_IGNORE) - - # Close test project assert theProject.closeProject() # END Test testCoreProject_Save @pytest.mark.core -def testCoreProject_LockFile(monkeypatch, fncDir, mockGUI): - """Test lock file functions for the project folder. - """ - theProject = NWProject(mockGUI) - - lockFile = os.path.join(fncDir, nwFiles.PROJ_LOCK) - - # No project - assert theProject._writeLockFile() is False - assert theProject._readLockFile() == ["ERROR"] - assert theProject._clearLockFile() is False - - theProject.projPath = fncDir - theProject.mainConf.hostName = "TestHost" - theProject.mainConf.osType = "TestOS" - theProject.mainConf.kernelVer = "1.0" - - # Block open - with monkeypatch.context() as mp: - mp.setattr("builtins.open", causeOSError) - assert theProject._writeLockFile() is False - - # Write lock file - with monkeypatch.context() as mp: - mp.setattr("novelwriter.core.project.time", lambda: 123.4) - assert theProject._writeLockFile() is True - assert readFile(lockFile) == "TestHost\nTestOS\n1.0\n123\n" - - # Block open - with monkeypatch.context() as mp: - mp.setattr("builtins.open", causeOSError) - assert theProject._readLockFile() == ["ERROR"] - - # Read lock file - assert theProject._readLockFile() == ["TestHost", "TestOS", "1.0", "123"] - - # Block unlink - with monkeypatch.context() as mp: - mp.setattr("os.unlink", causeOSError) - assert os.path.isfile(lockFile) - assert theProject._clearLockFile() is False - assert os.path.isfile(lockFile) - - # Clear file - assert os.path.isfile(lockFile) - assert theProject._clearLockFile() is True - assert not os.path.isfile(lockFile) - - # Read again, no file - assert theProject._readLockFile() == [] - - # Read an invalid lock file - writeFile(lockFile, "A\nB") - assert theProject._readLockFile() == ["ERROR"] - assert theProject._clearLockFile() is True - -# END Test testCoreProject_LockFile - - -@pytest.mark.core -def testCoreProject_Helpers(monkeypatch, fncDir, mockGUI): +def testCoreProject_AccessItems(mockGUI, fncPath, mockRnd): """Test helper functions for the project folder. """ theProject = NWProject(mockGUI) - - # No path - assert theProject.ensureFolderStructure() is False - - # Set the correct dir - theProject.projPath = fncDir - - # Block user's home folder - with monkeypatch.context() as mp: - mp.setattr("os.path.expanduser", lambda *a, **k: fncDir) - assert theProject.ensureFolderStructure() is False - - # Create a file to block meta folder - metaDir = os.path.join(fncDir, "meta") - writeFile(metaDir, "stuff") - assert theProject.ensureFolderStructure() is False - os.unlink(metaDir) - - # Create a file to block cache folder - cacheDir = os.path.join(fncDir, "cache") - writeFile(cacheDir, "stuff") - assert theProject.ensureFolderStructure() is False - os.unlink(cacheDir) - - # Create a file to block content folder - contentDir = os.path.join(fncDir, "content") - writeFile(contentDir, "stuff") - assert theProject.ensureFolderStructure() is False - os.unlink(contentDir) - - # Now, do it right - assert theProject.ensureFolderStructure() is True - assert os.path.isdir(metaDir) - assert os.path.isdir(cacheDir) - assert os.path.isdir(contentDir) - -# END Test testCoreProject_Helpers - - -@pytest.mark.core -def testCoreProject_AccessItems(nwMinimal, mockGUI): - """Test helper functions for the project folder. - """ - theProject = NWProject(mockGUI) - theProject.openProject(nwMinimal) + buildTestProject(theProject, fncPath) # Storage Objects assert isinstance(theProject.index, NWIndex) @@ -651,34 +304,34 @@ def testCoreProject_AccessItems(nwMinimal, mockGUI): # Move Novel ROOT to after its files oldOrder = [ - "a508bb932959c", # ROOT: Novel - "a35baf2e93843", # FILE: Title Page - "a6d311a93600a", # FOLDER: New Chapter - "f5ab3e30151e1", # FILE: New Chapter - "8c659a11cd429", # FILE: New Scene - "7695ce551d265", # ROOT: Plot - "afb3043c7b2b3", # ROOT: Characters - "9d5247ab588e0", # ROOT: World + C.hNovelRoot, + C.hPlotRoot, + C.hCharRoot, + C.hWorldRoot, + C.hTitlePage, + C.hChapterDir, + C.hChapterDoc, + C.hSceneDoc, ] newOrder = [ - "a35baf2e93843", # FILE: Title Page - "f5ab3e30151e1", # FILE: New Chapter - "8c659a11cd429", # FILE: New Scene - "a6d311a93600a", # FOLDER: New Chapter - "a508bb932959c", # ROOT: Novel - "7695ce551d265", # ROOT: Plot - "afb3043c7b2b3", # ROOT: Characters - "9d5247ab588e0", # ROOT: World + C.hTitlePage, + C.hChapterDoc, + C.hSceneDoc, + C.hChapterDir, + C.hNovelRoot, + C.hPlotRoot, + C.hCharRoot, + C.hWorldRoot, ] assert theProject.tree.handles() == oldOrder assert theProject.setTreeOrder(newOrder) assert theProject.tree.handles() == newOrder # Add a non-existing item - theProject.tree._treeOrder.append("01234567789abc") + theProject.tree._treeOrder.append(C.hInvalid) # Add an item with a non-existent parent - nHandle = theProject.newFile("Test File", "a6d311a93600a") + nHandle = theProject.newFile("Test File", C.hChapterDir) theProject.tree[nHandle].setParent("cba9876543210") assert theProject.tree[nHandle].itemParent == "cba9876543210" @@ -687,15 +340,15 @@ def testCoreProject_AccessItems(nwMinimal, mockGUI): retOrder.append(tItem.itemHandle) assert retOrder == [ - "a508bb932959c", # ROOT: Novel - "7695ce551d265", # ROOT: Plot - "afb3043c7b2b3", # ROOT: Characters - "9d5247ab588e0", # ROOT: World - nHandle, # FILE: Test File - "a35baf2e93843", # FILE: Title Page - "a6d311a93600a", # FOLDER: New Chapter - "f5ab3e30151e1", # FILE: New Chapter - "8c659a11cd429", # FILE: New Scene + C.hNovelRoot, + C.hPlotRoot, + C.hCharRoot, + C.hWorldRoot, + nHandle, + C.hTitlePage, + C.hChapterDir, + C.hChapterDoc, + C.hSceneDoc, ] assert theProject.tree[nHandle].itemParent is None @@ -703,27 +356,28 @@ def testCoreProject_AccessItems(nwMinimal, mockGUI): @pytest.mark.core -def testCoreProject_StatusImport(mockGUI, fncDir, mockRnd): +def testCoreProject_StatusImport(mockGUI, fncPath, mockRnd): """Test the status and importance flag handling. """ theProject = NWProject(mockGUI) - buildTestProject(theProject, fncDir) + mockRnd.reset() + buildTestProject(theProject, fncPath) - statusKeys = ["s000008", "s000009", "s00000a", "s00000b"] - importKeys = ["i00000c", "i00000d", "i00000e", "i00000f"] + statusKeys = [C.sNew, C.sNote, C.sDraft, C.sFinished] + importKeys = [C.iNew, C.iMinor, C.iMajor, C.iMain] # Change Status # ============= - theProject.tree["0000000000014"].setStatus("Finished") - theProject.tree["0000000000015"].setStatus("Draft") - theProject.tree["0000000000016"].setStatus("Note") - theProject.tree["0000000000017"].setStatus("Finished") + theProject.tree[C.hNovelRoot].setStatus(statusKeys[3]) + theProject.tree[C.hPlotRoot].setStatus(statusKeys[2]) + theProject.tree[C.hCharRoot].setStatus(statusKeys[1]) + theProject.tree[C.hWorldRoot].setStatus(statusKeys[3]) - assert theProject.tree["0000000000014"].itemStatus == statusKeys[3] - assert theProject.tree["0000000000015"].itemStatus == statusKeys[2] - assert theProject.tree["0000000000016"].itemStatus == statusKeys[1] - assert theProject.tree["0000000000017"].itemStatus == statusKeys[3] + assert theProject.tree[C.hNovelRoot].itemStatus == statusKeys[3] + assert theProject.tree[C.hPlotRoot].itemStatus == statusKeys[2] + assert theProject.tree[C.hCharRoot].itemStatus == statusKeys[1] + assert theProject.tree[C.hWorldRoot].itemStatus == statusKeys[3] newList = [ {"key": statusKeys[0], "name": "New", "cols": (1, 1, 1)}, @@ -736,30 +390,30 @@ def testCoreProject_StatusImport(mockGUI, fncDir, mockRnd): assert theProject.setStatusColours([], []) is False assert theProject.setStatusColours(newList, []) is True - assert theProject.statusItems.name(statusKeys[0]) == "New" - assert theProject.statusItems.name(statusKeys[1]) == "Draft" - assert theProject.statusItems.name(statusKeys[2]) == "Note" - assert theProject.statusItems.name(statusKeys[3]) == "Edited" - assert theProject.statusItems.cols(statusKeys[0]) == (1, 1, 1) - assert theProject.statusItems.cols(statusKeys[1]) == (2, 2, 2) - assert theProject.statusItems.cols(statusKeys[2]) == (3, 3, 3) - assert theProject.statusItems.cols(statusKeys[3]) == (4, 4, 4) + assert theProject.data.itemStatus.name(statusKeys[0]) == "New" + assert theProject.data.itemStatus.name(statusKeys[1]) == "Draft" + assert theProject.data.itemStatus.name(statusKeys[2]) == "Note" + assert theProject.data.itemStatus.name(statusKeys[3]) == "Edited" + assert theProject.data.itemStatus.cols(statusKeys[0]) == (1, 1, 1) + assert theProject.data.itemStatus.cols(statusKeys[1]) == (2, 2, 2) + assert theProject.data.itemStatus.cols(statusKeys[2]) == (3, 3, 3) + assert theProject.data.itemStatus.cols(statusKeys[3]) == (4, 4, 4) # Check the new entry - lastKey = theProject.statusItems.check("Finished") - assert lastKey == "s000018" - assert theProject.statusItems.name(lastKey) == "Finished" - assert theProject.statusItems.cols(lastKey) == (5, 5, 5) + lastKey = theProject.data.itemStatus.check("s000010") + assert lastKey == "s000010" + assert theProject.data.itemStatus.name(lastKey) == "Finished" + assert theProject.data.itemStatus.cols(lastKey) == (5, 5, 5) # Delete last entry assert theProject.setStatusColours([], [lastKey]) is True - assert theProject.statusItems.name(lastKey) == "New" + assert theProject.data.itemStatus.name(lastKey) == "New" # Change Importance # ================= - fHandle = theProject.newFile("Jane Doe", "0000000000012") - theProject.tree[fHandle].setImport("Main") + fHandle = theProject.newFile("Jane Doe", C.hCharRoot) + theProject.tree[fHandle].setImport(importKeys[3]) assert theProject.tree[fHandle].itemImport == importKeys[3] newList = [ @@ -773,201 +427,165 @@ def testCoreProject_StatusImport(mockGUI, fncDir, mockRnd): assert theProject.setImportColours([], []) is False assert theProject.setImportColours(newList, []) is True - assert theProject.importItems.name(importKeys[0]) == "New" - assert theProject.importItems.name(importKeys[1]) == "Minor" - assert theProject.importItems.name(importKeys[2]) == "Major" - assert theProject.importItems.name(importKeys[3]) == "Min" - assert theProject.importItems.cols(importKeys[0]) == (1, 1, 1) - assert theProject.importItems.cols(importKeys[1]) == (2, 2, 2) - assert theProject.importItems.cols(importKeys[2]) == (3, 3, 3) - assert theProject.importItems.cols(importKeys[3]) == (4, 4, 4) + assert theProject.data.itemImport.name(importKeys[0]) == "New" + assert theProject.data.itemImport.name(importKeys[1]) == "Minor" + assert theProject.data.itemImport.name(importKeys[2]) == "Major" + assert theProject.data.itemImport.name(importKeys[3]) == "Min" + assert theProject.data.itemImport.cols(importKeys[0]) == (1, 1, 1) + assert theProject.data.itemImport.cols(importKeys[1]) == (2, 2, 2) + assert theProject.data.itemImport.cols(importKeys[2]) == (3, 3, 3) + assert theProject.data.itemImport.cols(importKeys[3]) == (4, 4, 4) # Check the new entry - lastKey = theProject.importItems.check("Max") - assert lastKey == "i00001a" - assert theProject.importItems.name(lastKey) == "Max" - assert theProject.importItems.cols(lastKey) == (5, 5, 5) + lastKey = theProject.data.itemImport.check("i000012") + assert lastKey == "i000012" + assert theProject.data.itemImport.name(lastKey) == "Max" + assert theProject.data.itemImport.cols(lastKey) == (5, 5, 5) # Delete last entry assert theProject.setImportColours([], [lastKey]) is True - assert theProject.importItems.name(lastKey) == "New" + assert theProject.data.itemImport.name(lastKey) == "New" # Delete Status/Import # ==================== - theProject.statusItems.resetCounts() - for key in list(theProject.statusItems.keys()): - assert theProject.statusItems.remove(key) is True + theProject.data.itemStatus.resetCounts() + for key in list(theProject.data.itemStatus.keys()): + assert theProject.data.itemStatus.remove(key) is True - theProject.importItems.resetCounts() - for key in list(theProject.importItems.keys()): - assert theProject.importItems.remove(key) is True + theProject.data.itemImport.resetCounts() + for key in list(theProject.data.itemImport.keys()): + assert theProject.data.itemImport.remove(key) is True - assert len(theProject.statusItems) == 0 - assert len(theProject.importItems) == 0 + assert len(theProject.data.itemStatus) == 0 + assert len(theProject.data.itemImport) == 0 assert theProject.saveProject() is True assert theProject.closeProject() is True - # This should restore the default status/import labels - assert theProject.openProject(fncDir) is True - assert theProject.saveProject() is True - assert theProject.statusItems.name("s000023") == "New" - assert theProject.statusItems.name("s000024") == "Note" - assert theProject.statusItems.name("s000025") == "Draft" - assert theProject.statusItems.name("s000026") == "Finished" - assert theProject.importItems.name("i000027") == "New" - assert theProject.importItems.name("i000028") == "Minor" - assert theProject.importItems.name("i000029") == "Major" - assert theProject.importItems.name("i00002a") == "Main" - # END Test testCoreProject_StatusImport @pytest.mark.core -def testCoreProject_Methods(monkeypatch, mockGUI, tmpDir, fncDir, mockRnd): +def testCoreProject_Methods(monkeypatch, mockGUI, fncPath, mockRnd): """Test other project class methods and functions. """ theProject = NWProject(mockGUI) - buildTestProject(theProject, fncDir) - - # Setting project path - assert theProject.setProjectPath(None) - assert theProject.projPath is None - assert theProject.setProjectPath("") - assert theProject.projPath is None - assert theProject.setProjectPath("~") - assert theProject.projPath == os.path.expanduser("~") - - # Create a new folder and populate it - projPath = os.path.join(fncDir, "mock1") - assert theProject.setProjectPath(projPath, newProject=True) - - # Make os.mkdir fail - monkeypatch.setattr("os.mkdir", causeOSError) - projPath = os.path.join(fncDir, "mock2") - assert not theProject.setProjectPath(projPath, newProject=True) - - # Set back - assert theProject.setProjectPath(fncDir) + buildTestProject(theProject, fncPath) # Project Name - assert theProject.setProjectName(" A Name ") - assert theProject.projName == "A Name" + theProject.data.setName(" A Name ") + assert theProject.data.name == "A Name" # Project Title - assert theProject.setBookTitle(" A Title ") - assert theProject.bookTitle == "A Title" + theProject.data.setTitle(" A Title ") + assert theProject.data.title == "A Title" # Project Authors # Check that the list is cleaned up and that it can be extracted as # a properly formatted string, depending on number of names - assert not theProject.setBookAuthors([]) - assert theProject.setBookAuthors(" Jane Doe \n John Doh \n ") - assert theProject.bookAuthors == ["Jane Doe", "John Doh"] + theProject.data.setAuthors([]) + assert theProject.data.authors == [] + theProject.data.setAuthors(" Jane Doe \n John Doh \n ") + assert theProject.data.authors == ["Jane Doe", "John Doh"] - assert theProject.setBookAuthors("") - assert theProject.getAuthors() == "" + theProject.data.setAuthors("") + assert theProject.getFormattedAuthors() == "" - assert theProject.setBookAuthors("Jane Doe") - assert theProject.getAuthors() == "Jane Doe" + theProject.data.setAuthors("Jane Doe") + assert theProject.getFormattedAuthors() == "Jane Doe" - assert theProject.setBookAuthors("Jane Doe\nJohn Doh") - assert theProject.getAuthors() == "Jane Doe and John Doh" + theProject.data.setAuthors("Jane Doe\nJohn Doh") + assert theProject.getFormattedAuthors() == "Jane Doe and John Doh" - assert theProject.setBookAuthors("Jane Doe\nJohn Doh\nBod Owens") - assert theProject.getAuthors() == "Jane Doe, John Doh and Bod Owens" + theProject.data.setAuthors("Jane Doe\nJohn Doh\nBod Owens") + assert theProject.getFormattedAuthors() == "Jane Doe, John Doh and Bod Owens" # Edit Time - theProject.editTime = 1234 - theProject.projOpened = 1600000000 + theProject.data.setEditTime(1234) + theProject._projOpened = 1600000000 with monkeypatch.context() as mp: mp.setattr("novelwriter.core.project.time", lambda: 1600005600) assert theProject.getCurrentEditTime() == 6834 # Trash folder # Should create on first call, and just returned on later calls - hTrash = "0000000000018" + hTrash = "0000000000010" assert theProject.tree[hTrash] is None assert theProject.trashFolder() == hTrash assert theProject.trashFolder() == hTrash - # Project backup - assert theProject.doBackup is True - assert theProject.setProjBackup(False) - assert theProject.doBackup is False - - assert not theProject.setProjBackup(True) - theProject.mainConf.backupPath = tmpDir - assert theProject.setProjBackup(True) - - assert theProject.setProjectName("") - assert not theProject.setProjBackup(True) - assert theProject.setProjectName("A Name") - assert theProject.setProjBackup(True) - # Spell check - theProject.projChanged = False - assert theProject.setSpellCheck(True) - assert not theProject.setSpellCheck(False) - assert theProject.projChanged + theProject.setProjectChanged(False) + theProject.data.setSpellCheck(True) + theProject.data.setSpellCheck(False) + assert theProject.projChanged is True + assert theProject.projOpened > 0 # Spell language - theProject.projChanged = False - assert theProject.projSpell is None - assert theProject.setSpellLang(None) is False - assert theProject.projSpell is None - assert theProject.setSpellLang("None") is False # Should be interpreded as None - assert theProject.projSpell is None - assert theProject.setSpellLang("en_GB") - assert theProject.projSpell == "en_GB" - assert theProject.projChanged + theProject.setProjectChanged(False) + assert theProject.data.spellLang is None + theProject.data.setSpellLang(None) + assert theProject.data.spellLang is None + theProject.data.setSpellLang("None") # Should be interpreded as None + assert theProject.data.spellLang is None + theProject.data.setSpellLang("en_GB") + assert theProject.data.spellLang == "en_GB" + assert theProject.projChanged is True # Project Language - theProject.projChanged = False - theProject.projLang = "en" + theProject.setProjectChanged(False) + theProject.data.setLanguage("en") assert theProject.setProjectLang(None) is True - assert theProject.projLang is None + assert theProject.data.language is None assert theProject.setProjectLang("en_GB") is True - assert theProject.projLang == "en_GB" + assert theProject.data.language == "en_GB" # Language Lookup assert theProject.localLookup(1) == "One" assert theProject.localLookup(10) == "Ten" - # Automatic outline update - theProject.projChanged = False - assert theProject.setAutoOutline(True) - assert not theProject.setAutoOutline(False) - assert theProject.projChanged + # Set invalid language + theProject.data.setLanguage("foo") + theProject._loadProjectLocalisation() + assert theProject.localLookup(1) == "One" + assert theProject.localLookup(10) == "Ten" + + # Block reading language data + theProject.data.setLanguage("en") + with monkeypatch.context() as mp: + mp.setattr("builtins.open", causeOSError) + theProject._loadProjectLocalisation() + assert theProject.localLookup(1) == "One" + assert theProject.localLookup(10) == "Ten" # Last edited - theProject.projChanged = False - assert theProject.setLastEdited("0123456789abc") - assert theProject.lastEdited == "0123456789abc" + theProject.setProjectChanged(False) + theProject._data.setLastHandle("0123456789abc", "editor") + assert theProject._data.getLastHandle("editor") == "0123456789abc" assert theProject.projChanged # Last viewed - theProject.projChanged = False - assert theProject.setLastViewed("0123456789abc") - assert theProject.lastViewed == "0123456789abc" + theProject.setProjectChanged(False) + theProject._data.setLastHandle("0123456789abc", "viewer") + assert theProject._data.getLastHandle("viewer") == "0123456789abc" assert theProject.projChanged # Autoreplace - theProject.projChanged = False - assert theProject.setAutoReplace({"A": "B", "C": "D"}) - assert theProject.autoReplace == {"A": "B", "C": "D"} + theProject.setProjectChanged(False) + theProject.data.setAutoReplace({"A": "B", "C": "D"}) + assert theProject.data.autoReplace == {"A": "B", "C": "D"} assert theProject.projChanged # Change project tree order oldOrder = [ - "0000000000010", "0000000000011", "0000000000012", - "0000000000013", "0000000000014", "0000000000015", - "0000000000016", "0000000000017", "0000000000018", + "0000000000008", "0000000000009", "000000000000a", + "000000000000b", "000000000000c", "000000000000d", + "000000000000e", "000000000000f", "0000000000010", ] newOrder = [ - "0000000000013", "0000000000014", "0000000000015", - "0000000000010", "0000000000011", "0000000000012", - "0000000000016", "0000000000017", + "000000000000b", "000000000000c", "000000000000d", + "0000000000008", "0000000000009", "000000000000a", + "000000000000e", "000000000000f", ] assert theProject.tree.handles() == oldOrder assert theProject.setTreeOrder(newOrder) @@ -976,65 +594,50 @@ def testCoreProject_Methods(monkeypatch, mockGUI, tmpDir, fncDir, mockRnd): assert theProject.tree.handles() == oldOrder # Session stats - theProject.currWCount = 200 - theProject.lastWCount = 100 + theProject.data.setInitCounts(50, 50) + theProject.data.setCurrCounts(100, 100) + + # No path for writing with monkeypatch.context() as mp: - mp.setattr("os.path.isdir", lambda *a, **k: False) - assert not theProject._appendSessionStats(idleTime=0) + mp.setattr("novelwriter.core.storage.NWStorage.getMetaFile", lambda *a: None) + assert theProject._appendSessionStats(idleTime=0) is False # Block open with monkeypatch.context() as mp: mp.setattr("builtins.open", causeOSError) - assert not theProject._appendSessionStats(idleTime=0) + assert theProject._appendSessionStats(idleTime=0) is False + + # Session too short + theProject._projOpened = time() + theProject.data.setInitCounts(50, 50) + theProject.data.setCurrCounts(50, 50) + assert theProject._appendSessionStats(idleTime=0) is False # Write entry - assert theProject.projMeta == os.path.join(fncDir, "meta") - statsFile = os.path.join(theProject.projMeta, nwFiles.SESS_STATS) + statsFile = theProject.storage.getMetaFile(nwFiles.SESS_STATS) + assert isinstance(statsFile, Path) + if statsFile.exists(): + statsFile.unlink() - theProject.projOpened = 1600002000 - theProject.currNovelWC = 200 - theProject.currNotesWC = 100 + theProject._projOpened = 1600002000 + theProject.data._initCounts = [50, 50] + theProject.data._currCounts = [200, 100] with monkeypatch.context() as mp: mp.setattr("novelwriter.core.project.time", lambda: 1600005600) assert theProject._appendSessionStats(idleTime=99) - assert readFile(statsFile) == ( + assert statsFile.read_text(encoding="utf-8") == ( "# Offset 100\n" "# Start Time End Time Novel Notes Idle\n" "%s %s 200 100 99\n" ) % (formatTimeStamp(1600002000), formatTimeStamp(1600005600)) - # Pack XML Value - xElem = etree.Element("element") - theProject._packProjectValue(xElem, "A", "B", allowNone=False) - assert etree.tostring(xElem, pretty_print=False, encoding="utf-8") == ( - b"B" - ) - - xElem = etree.Element("element") - theProject._packProjectValue(xElem, "A", "", allowNone=False) - assert etree.tostring(xElem, pretty_print=False, encoding="utf-8") == ( - b"" - ) - - # Pack XML Key/Value - xElem = etree.Element("element") - theProject._packProjectKeyValue(xElem, "item", {"A": "B", "C": "D"}) - assert etree.tostring(xElem, pretty_print=False, encoding="utf-8") == ( - b"" - b"" - b"B" - b"D" - b"" - b"" - ) - # END Test testCoreProject_Methods @pytest.mark.core -def testCoreProject_OrphanedFiles(mockGUI, nwLipsum): +def testCoreProject_OrphanedFiles(mockGUI, prjLipsum): """Check that files in the content folder that are not tracked in the project XML file are handled correctly by the orphaned files function. It should also restore as much meta data as possible from @@ -1042,7 +645,7 @@ def testCoreProject_OrphanedFiles(mockGUI, nwLipsum): """ theProject = NWProject(mockGUI) - assert theProject.openProject(nwLipsum) is True + assert theProject.openProject(prjLipsum) is True assert theProject.tree["636b6aa9b697b"] is None # Add a file with non-existent parent @@ -1055,7 +658,7 @@ def testCoreProject_OrphanedFiles(mockGUI, nwLipsum): assert theProject.closeProject() is True # First Item with Meta Data - orphPath = os.path.join(nwLipsum, "content", "636b6aa9b697b.nwd") + orphPath = prjLipsum / "content" / "636b6aa9b697b.nwd" writeFile(orphPath, ( "%%~name:[Recovered] Mars\n" "%%~path:5eaea4e8cdee8/636b6aa9b697b\n" @@ -1065,23 +668,24 @@ def testCoreProject_OrphanedFiles(mockGUI, nwLipsum): )) # Second Item without Meta Data - orphPath = os.path.join(nwLipsum, "content", "736b6aa9b697b.nwd") + orphPath = prjLipsum / "content" / "736b6aa9b697b.nwd" writeFile(orphPath, "\n") # Invalid File Name - tstPath = os.path.join(nwLipsum, "content", "636b6aa9b697b.txt") + tstPath = prjLipsum / "content" / "636b6aa9b697b.txt" writeFile(tstPath, "\n") # Invalid File Name - tstPath = os.path.join(nwLipsum, "content", "636b6aa9b697bb.nwd") + tstPath = prjLipsum / "content" / "636b6aa9b697bb.nwd" writeFile(tstPath, "\n") # Invalid File Name - tstPath = os.path.join(nwLipsum, "content", "abcdefghijklm.nwd") + tstPath = prjLipsum / "content" / "abcdefghijklm.nwd" writeFile(tstPath, "\n") - assert theProject.openProject(nwLipsum) - assert theProject.projPath is not None + assert theProject.openProject(prjLipsum) + assert theProject.storage.storagePath is not None + assert theProject.storage.runtimePath is not None assert theProject.tree["636b6aa9b697bb"] is None assert theProject.tree["abcdefghijklm"] is None @@ -1105,7 +709,7 @@ def testCoreProject_OrphanedFiles(mockGUI, nwLipsum): assert oItem.itemType == nwItemType.FILE assert oItem.itemLayout == nwItemLayout.NOTE - assert theProject.saveProject(nwLipsum) + assert theProject.saveProject(prjLipsum) assert theProject.closeProject() # Finally, check that the orphaned files function returns @@ -1116,226 +720,69 @@ def testCoreProject_OrphanedFiles(mockGUI, nwLipsum): @pytest.mark.core -def testCoreProject_OldFormat(mockGUI, nwOldProj): - """Test that a project folder structure of version 1.0 can be - converted to the latest folder structure. Version 1.0 split the - documents into 'data_0' ... 'data_f' folders, which are now all - contained in a single 'content' folder. - """ - theProject = NWProject(mockGUI) - - # Create mock files for known legacy files - deleteFiles = [ - os.path.join(nwOldProj, "cache", "nwProject.nwx.0"), - os.path.join(nwOldProj, "cache", "nwProject.nwx.1"), - os.path.join(nwOldProj, "cache", "nwProject.nwx.2"), - os.path.join(nwOldProj, "cache", "nwProject.nwx.3"), - os.path.join(nwOldProj, "cache", "nwProject.nwx.4"), - os.path.join(nwOldProj, "cache", "nwProject.nwx.5"), - os.path.join(nwOldProj, "cache", "nwProject.nwx.6"), - os.path.join(nwOldProj, "cache", "nwProject.nwx.7"), - os.path.join(nwOldProj, "cache", "nwProject.nwx.8"), - os.path.join(nwOldProj, "cache", "nwProject.nwx.9"), - os.path.join(nwOldProj, "meta", "mainOptions.json"), - os.path.join(nwOldProj, "meta", "exportOptions.json"), - os.path.join(nwOldProj, "meta", "outlineOptions.json"), - os.path.join(nwOldProj, "meta", "timelineOptions.json"), - os.path.join(nwOldProj, "meta", "docMergeOptions.json"), - os.path.join(nwOldProj, "meta", "sessionLogOptions.json"), - ] - - # Create mock files - os.mkdir(os.path.join(nwOldProj, "cache")) - for aFile in deleteFiles: - writeFile(aFile, "Hi") - for aFile in deleteFiles: - assert os.path.isfile(aFile) - - # Open project and check that files that are not supposed to be - # there have been removed - assert theProject.openProject(nwOldProj) - for aFile in deleteFiles: - assert not os.path.isfile(aFile) - - assert not os.path.isdir(os.path.join(nwOldProj, "data_1")) - assert not os.path.isdir(os.path.join(nwOldProj, "data_7")) - assert not os.path.isdir(os.path.join(nwOldProj, "data_8")) - assert not os.path.isdir(os.path.join(nwOldProj, "data_9")) - assert not os.path.isdir(os.path.join(nwOldProj, "data_a")) - assert not os.path.isdir(os.path.join(nwOldProj, "data_f")) - - # Check that files we want to keep are in the right place - assert os.path.isdir(os.path.join(nwOldProj, "cache")) - assert os.path.isdir(os.path.join(nwOldProj, "content")) - assert os.path.isdir(os.path.join(nwOldProj, "meta")) - - assert os.path.isfile(os.path.join(nwOldProj, "content", "f528d831f5b24.nwd")) - assert os.path.isfile(os.path.join(nwOldProj, "content", "88124a4292d8b.nwd")) - assert os.path.isfile(os.path.join(nwOldProj, "content", "91239bf2f8b69.nwd")) - assert os.path.isfile(os.path.join(nwOldProj, "content", "19752e7f9d8af.nwd")) - assert os.path.isfile(os.path.join(nwOldProj, "content", "a764d5acf5a21.nwd")) - assert os.path.isfile(os.path.join(nwOldProj, "content", "9058ae29f0dfd.nwd")) - assert os.path.isfile(os.path.join(nwOldProj, "content", "7ff63b8afc4cd.nwd")) - - assert os.path.isfile(os.path.join(nwOldProj, "meta", "tagsIndex.json")) - assert os.path.isfile(os.path.join(nwOldProj, "meta", "sessionInfo.log")) - - # Close the project - theProject.closeProject() - - # Check that new files have been created - assert os.path.isfile(os.path.join(nwOldProj, "meta", "guiOptions.json")) - assert os.path.isfile(os.path.join(nwOldProj, "ToC.txt")) - -# END Test testCoreProject_OldFormat - - -@pytest.mark.core -def testCoreProject_LegacyData(monkeypatch, mockGUI, fncDir): - """Test the functins that handle legacy data folders and structure - with additional tests of failure handling. - """ - theProject = NWProject(mockGUI) - theProject.setProjectPath(fncDir) - - # Check behaviour of deprecated files function on OSError - tstFile = os.path.join(fncDir, "ToC.json") - writeFile(tstFile, "stuff") - assert os.path.isfile(tstFile) - - with monkeypatch.context() as mp: - mp.setattr("os.unlink", causeOSError) - assert theProject._deprecatedFiles() is False - - assert theProject._deprecatedFiles() - assert not os.path.isfile(tstFile) - - # Check processing non-folders - tstFile = os.path.join(fncDir, "data_0") - writeFile(tstFile, "stuff") - assert os.path.isfile(tstFile) - assert theProject._legacyDataFolder(tstFile) is False - - # Check renaming/deleting of old document files - tstData2 = os.path.join(fncDir, "data_2") - tstData3 = os.path.join(fncDir, "data_3") - tstDoc1m = os.path.join(tstData2, "000000000001_main.nwd") - tstDoc1b = os.path.join(tstData2, "000000000001_main.bak") - tstDoc2m = os.path.join(tstData2, "000000000002_main.nwd") - tstDoc2b = os.path.join(tstData2, "000000000002_main.bak") - tstDoc3m = os.path.join(tstData3, "tooshort003_main.nwd") - tstDoc3b = os.path.join(tstData3, "tooshort003_main.bak") - tstDir4a = os.path.join(tstData3, "stuff") - - os.mkdir(tstData2) - os.mkdir(tstData3) - writeFile(tstDoc1m, "stuff") - writeFile(tstDoc1b, "stuff") - writeFile(tstDoc2m, "stuff") - writeFile(tstDoc2b, "stuff") - writeFile(tstDoc3m, "stuff") - writeFile(tstDoc3b, "stuff") - os.mkdir(tstDir4a) - - # Make the above fail - with monkeypatch.context() as mp: - mp.setattr("os.rename", causeOSError) - mp.setattr("os.unlink", causeOSError) - with pytest.raises(OSError): - theProject._legacyDataFolder(tstData2) - theProject._legacyDataFolder(tstData3) - assert os.path.isfile(tstDoc1m) - assert os.path.isfile(tstDoc1b) - assert os.path.isfile(tstDoc2m) - assert os.path.isfile(tstDoc2b) - assert os.path.isfile(tstDoc3m) - assert os.path.isfile(tstDoc3b) - - # And succeed ... - assert theProject._legacyDataFolder(tstData2) is True - assert theProject._legacyDataFolder(tstData3) is True - - assert not os.path.isdir(tstData2) - assert os.path.isdir(tstData3) - assert os.path.isfile(os.path.join(fncDir, "content", "2000000000001.nwd")) - assert os.path.isfile(os.path.join(fncDir, "content", "2000000000002.nwd")) - assert os.path.isfile(os.path.join(fncDir, tstData3, "tooshort003_main.nwd")) - assert os.path.isfile(os.path.join(fncDir, tstData3, "tooshort003_main.bak")) - assert os.path.isdir(tstDir4a) - -# END Test testCoreProject_LegacyData - - -@pytest.mark.core -def testCoreProject_Backup(monkeypatch, mockGUI, nwMinimal, tmpDir): +def testCoreProject_Backup(monkeypatch, mockGUI, fncPath, tmpPath): """Test the automated backup feature of the project class. The test creates a backup of the Minimal test project, and then unzips the backupd file and checks that the project XML file is identical to the original file. """ theProject = NWProject(mockGUI) - assert theProject.openProject(nwMinimal) - # Test faulty settings + # No Project + assert theProject.backupProject(doNotify=False) is False + + buildTestProject(theProject, fncPath) + + # Invalid Settings + # ================ # No project mockGUI.hasProject = False - assert theProject.zipIt(doNotify=False) is False + assert theProject.backupProject(doNotify=False) is False mockGUI.hasProject = True # Invalid path - theProject.mainConf.backupPath = None - assert theProject.zipIt(doNotify=False) is False + theProject.mainConf._backupPath = None + assert theProject.backupProject(doNotify=False) is False # Missing project name - theProject.mainConf.backupPath = tmpDir - theProject.projName = "" - assert theProject.zipIt(doNotify=False) is False + theProject.mainConf._backupPath = tmpPath + theProject.data.setName("") + assert theProject.backupProject(doNotify=False) is False - # Non-existent folder - theProject.mainConf.backupPath = os.path.join(tmpDir, "nonexistent") - theProject.projName = "Test Minimal" - assert theProject.zipIt(doNotify=False) is False - - # Same folder as project (causes infinite loop in zipping) - theProject.mainConf.backupPath = nwMinimal - assert theProject.zipIt(doNotify=False) is False - - # Subfolder of project (causes infinite loop in zipping) - theProject.mainConf.backupPath = os.path.join(nwMinimal, "subdir") - assert theProject.zipIt(doNotify=False) is False - - # Set a valid folder - theProject.mainConf.backupPath = tmpDir + # Valid Settings + # ============== + theProject.mainConf._backupPath = tmpPath + theProject.data.setName("Test Minimal") # Can't make folder with monkeypatch.context() as mp: - mp.setattr("os.mkdir", causeOSError) - assert theProject.zipIt(doNotify=False) is False + mp.setattr("pathlib.Path.mkdir", causeOSError) + assert theProject.backupProject(doNotify=False) is False # Can't write archive with monkeypatch.context() as mp: - mp.setattr("shutil.make_archive", causeOSError) - assert theProject.zipIt(doNotify=False) is False + mp.setattr("zipfile.ZipFile.write", causeOSError) + assert theProject.backupProject(doNotify=False) is False # Test correct settings - assert theProject.zipIt(doNotify=True) is True + assert theProject.backupProject(doNotify=True) is True - theFiles = os.listdir(os.path.join(tmpDir, "Test Minimal")) + theFiles = list((tmpPath / "Test Minimal").iterdir()) assert len(theFiles) == 1 - theZip = theFiles[0] + theZip = theFiles[0].name assert theZip[:12] == "Backup from " assert theZip[-4:] == ".zip" # Extract the archive - with ZipFile(os.path.join(tmpDir, "Test Minimal", theZip), "r") as inZip: - inZip.extractall(os.path.join(tmpDir, "extract")) + with ZipFile(tmpPath / "Test Minimal" / theZip, mode="r") as inZip: + inZip.extractall(tmpPath / "extract") # Check that the main project file was restored assert cmpFiles( - os.path.join(nwMinimal, "nwProject.nwx"), - os.path.join(tmpDir, "extract", "nwProject.nwx") + fncPath / "nwProject.nwx", + tmpPath / "extract" / "nwProject.nwx" ) # END Test testCoreProject_Backup diff --git a/tests/test_core/test_core_projectxml.py b/tests/test_core/test_core_projectxml.py new file mode 100644 index 00000000..65fd23b9 --- /dev/null +++ b/tests/test_core/test_core_projectxml.py @@ -0,0 +1,965 @@ +""" +novelWriter – ProjectXMLReader/Writer Class Tester +================================================== + +This file is a part of novelWriter +Copyright 2018–2022, Veronica Berglyd Olsen + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, but +WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +import json +import pytest + +from shutil import copyfile +from datetime import datetime + +from mock import causeOSError +from tools import cmpFiles, writeFile + +from novelwriter.core.item import NWItem +from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter, XMLReadState +from novelwriter.core.projectdata import NWProjectData + + +class MockProject: + def setProjectChanged(self, *a): + pass + + +@pytest.fixture(scope="function", autouse=True) +def mockVersion(monkeypatch): + monkeypatch.setattr("novelwriter.__version__", "2.0-rc1") + monkeypatch.setattr("novelwriter.__hexversion__", "0x020000c1") + return + + +@pytest.mark.core +def testCoreProjectXML_ReadCurrent(monkeypatch, tstPaths, fncPath): + """Test reading the current XML file format. + """ + refFile = tstPaths.filesDir / "nwProject-1.5.nwx" + tstFile = tstPaths.outDir / "ProjectXML_ReadCurrent.nwx" + xmlFile = fncPath / "nwProject-1.5.nwx" + bakFile = fncPath / "nwProject-1.5.bak" + outFile = fncPath / "nwProject.nwx" + + xmlReader = ProjectXMLReader(xmlFile) + assert xmlReader.state == XMLReadState.NO_ACTION + + data = NWProjectData(MockProject()) + content = [] + + # With no valid files, the read should fail + writeFile(xmlFile, "") + assert xmlReader.read(data, content) is False + assert xmlReader.state == XMLReadState.CANNOT_PARSE + + # Also add an invalid backup file + writeFile(bakFile, "") + assert xmlReader.read(data, content) is False + assert xmlReader.state == XMLReadState.CANNOT_PARSE + + # Add a valid backup file, that is not novelWriter + writeFile(bakFile, "") + assert xmlReader.read(data, content) is False + assert xmlReader.state == XMLReadState.NOT_NWX_FILE + + # Add a valid project file, that is not novelWriter + writeFile(xmlFile, "") + assert xmlReader.read(data, content) is False + assert xmlReader.state == XMLReadState.NOT_NWX_FILE + + # Add a valid novelwriter file without a file version + writeFile(xmlFile, "") + assert xmlReader.read(data, content) is False + assert xmlReader.state == XMLReadState.UNKNOWN_VERSION + + # Check parsing of unkown sections + writeFile(xmlFile, ( + "" + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + "" + )) + assert xmlReader.read(data, content) is True + assert xmlReader.state == XMLReadState.PARSED_OK + + writeFile(xmlFile, ( + "" + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + "" + )) + assert xmlReader.read(data, content) is True + assert xmlReader.state == XMLReadState.WAS_LEGACY + + # Reset data objects + data = NWProjectData(MockProject()) + content = [] + + # Parse a valid, complete file + copyfile(refFile, xmlFile) + assert xmlReader.read(data, content) is True + assert xmlReader.state == XMLReadState.PARSED_OK + assert xmlReader.xmlRoot == "novelWriterXML" + assert xmlReader.xmlVersion == 0x0105 + assert xmlReader.appVersion == "2.0-rc1" + assert xmlReader.hexVersion == 0x020000c1 + + # Check loaded data + assert data.name == "Sample Project" + assert data.title == "Sample Project" + assert data.authors == ["Jane Smith", "Jay Doh"] + assert data.saveCount == 5 + assert data.autoCount == 10 + assert data.editTime == 1000 + + assert data.doBackup is True + assert data.language == "en_GB" + assert data.spellCheck is True + assert data.spellLang == "en_GB" + assert data.initCounts == (954, 409) + assert data.currCounts == (954, 409) + + assert data.getLastHandle("editor") == "636b6aa9b697b" + assert data.getLastHandle("viewer") == "636b6aa9b697b" + assert data.getLastHandle("novelTree") == "7031beac91f75" + assert data.getLastHandle("outline") == "7031beac91f75" + + assert data.getTitleFormat("title") == "%title%" + assert data.getTitleFormat("chapter") == "Chapter %chw%: %title%" + assert data.getTitleFormat("unnumbered") == "%title%" + assert data.getTitleFormat("scene") == "Scene %ch%.%sc%: %title%" + assert data.getTitleFormat("section") == "" + + assert data.itemStatus.name("sf12341") == "New" + assert data.itemStatus.name("sf24ce6") == "Notes" + assert data.itemStatus.name("sc24b8f") == "Started" + assert data.itemStatus.name("s90e6c9") == "1st Draft" + assert data.itemStatus.name("sd51c5b") == "2nd Draft" + assert data.itemStatus.name("s8ae72a") == "3rd Draft" + assert data.itemStatus.name("s78ea90") == "Finished" + + assert data.itemImport.name("ia857f0") == "None" + assert data.itemImport.name("icfb3a5") == "Minor" + assert data.itemImport.name("i2d7a54") == "Major" + assert data.itemImport.name("i56be10") == "Main" + + assert data.itemStatus.cols("sf12341") == (100, 100, 100) + assert data.itemStatus.cols("sf24ce6") == (200, 50, 0) + assert data.itemStatus.cols("sc24b8f") == (182, 60, 0) + assert data.itemStatus.cols("s90e6c9") == (193, 129, 0) + assert data.itemStatus.cols("sd51c5b") == (193, 129, 0) + assert data.itemStatus.cols("s8ae72a") == (193, 129, 0) + assert data.itemStatus.cols("s78ea90") == (58, 180, 58) + + assert data.itemImport.cols("ia857f0") == (100, 100, 100) + assert data.itemImport.cols("icfb3a5") == (0, 122, 188) + assert data.itemImport.cols("i2d7a54") == (21, 0, 180) + assert data.itemImport.cols("i56be10") == (117, 0, 175) + + assert data.itemStatus.count("sf12341") == 4 + assert data.itemStatus.count("sf24ce6") == 2 + assert data.itemStatus.count("sc24b8f") == 3 + assert data.itemStatus.count("s90e6c9") == 7 + assert data.itemStatus.count("sd51c5b") == 0 + assert data.itemStatus.count("s8ae72a") == 0 + assert data.itemStatus.count("s78ea90") == 1 + + assert data.itemImport.count("ia857f0") == 5 + assert data.itemImport.count("icfb3a5") == 2 + assert data.itemImport.count("i2d7a54") == 2 + assert data.itemImport.count("i56be10") == 1 + + # Compare content + dumpFile = tstPaths.outDir / "projectXML_ReadCurrent.json" + compFile = tstPaths.refDir / "projectXML_ReadCurrent.json" + with open(dumpFile, mode="w", encoding="utf-8") as dump: + json.dump(content, dump, indent=2) + assert cmpFiles(dumpFile, compFile) + + packedContent = [] + mockProject = MockProject() + mockProject.__setattr__("data", data) + for entry in content: + item = NWItem(mockProject) + item.unpack(entry) + packedContent.append(item.pack()) + + # Save the project again, which should produce an identical project xml + timeStamp = int(datetime.fromisoformat(xmlReader.timeStamp).timestamp()) + xmlWriter = ProjectXMLWriter(fncPath) + + # Fail saving + with monkeypatch.context() as mp: + mp.setattr("pathlib.Path.write_bytes", causeOSError) + assert xmlWriter.write(data, packedContent, timeStamp, 1000) is False + assert str(xmlWriter.error) == "Mock OSError" + + with monkeypatch.context() as mp: + mp.setattr("pathlib.Path.replace", causeOSError) + assert xmlWriter.write(data, packedContent, timeStamp, 1000) is False + assert str(xmlWriter.error) == "Mock OSError" + + # Successful save (should be twice) + assert xmlWriter.write(data, packedContent, timeStamp, 1000) is True + assert xmlWriter.write(data, packedContent, timeStamp, 1000) is True + copyfile(outFile, tstFile) + assert cmpFiles(tstFile, refFile) + +# END Test testCoreProjectXML_ReadCurrent + + +@pytest.mark.core +def testCoreProjectXML_ReadLegacy10(tstPaths, fncPath, mockRnd): + """Test reading the version 1.0 XML file format. + """ + refFile = tstPaths.filesDir / "nwProject-1.0.nwx" + xmlFile = fncPath / "nwProject-1.0.nwx" + outFile = fncPath / "nwProject.nwx" + copyfile(refFile, xmlFile) + + xmlReader = ProjectXMLReader(xmlFile) + assert xmlReader.state == XMLReadState.NO_ACTION + + data = NWProjectData(MockProject()) + content = [] + + assert xmlReader.read(data, content) is True + assert xmlReader.state == XMLReadState.WAS_LEGACY + assert xmlReader.xmlRoot == "novelWriterXML" + assert xmlReader.xmlVersion == 0x0100 + assert xmlReader.appVersion == "0.6.1" + assert xmlReader.hexVersion == 0x000601f0 + + # Check loaded data + assert data.name == "Sample Project" + assert data.title == "Sample Project" + assert data.authors == ["Jane Smith", "Jay Doh"] + assert data.saveCount == 0 # Doesn't exist in 1.0 + assert data.autoCount == 0 # Doesn't exist in 1.0 + assert data.editTime == 0 # Doesn't exist in 1.0 + + assert data.doBackup is True + assert data.language is None # Doesn't exist in 1.0 + assert data.spellCheck is True + assert data.spellLang is None # Doesn't exist in 1.0 + assert data.initCounts == (0, 0) + assert data.currCounts == (0, 0) + + assert data.getLastHandle("editor") is None # Dropped by conversion + assert data.getLastHandle("viewer") is None # Dropped by conversion + assert data.getLastHandle("novelTree") is None # Doesn't exist in 1.0 + assert data.getLastHandle("outline") is None # Doesn't exist in 1.0 + + assert data.getTitleFormat("title") == "%title%" + assert data.getTitleFormat("chapter") == "Chapter %ch%: %title%" + assert data.getTitleFormat("unnumbered") == "%title%" + assert data.getTitleFormat("scene") == "Scene %ch%.%sc%: %title%" + assert data.getTitleFormat("section") == "" + + assert data.itemStatus.name("s000000") == "New" + assert data.itemStatus.name("s000001") == "Notes" + assert data.itemStatus.name("s000002") == "Started" + assert data.itemStatus.name("s000003") == "1st Draft" + assert data.itemStatus.name("s000004") == "2nd Draft" + assert data.itemStatus.name("s000005") == "3rd Draft" + assert data.itemStatus.name("s000006") == "Finished" + + assert data.itemImport.name("i000007") == "None" + assert data.itemImport.name("i000008") == "Minor" + assert data.itemImport.name("i000009") == "Major" + assert data.itemImport.name("i00000a") == "Main" + + assert data.itemStatus.cols("s000000") == (100, 100, 100) + assert data.itemStatus.cols("s000001") == (200, 50, 0) + assert data.itemStatus.cols("s000002") == (182, 60, 0) + assert data.itemStatus.cols("s000003") == (193, 129, 0) + assert data.itemStatus.cols("s000004") == (193, 129, 0) + assert data.itemStatus.cols("s000005") == (193, 129, 0) + assert data.itemStatus.cols("s000006") == (58, 180, 58) + + assert data.itemImport.cols("i000007") == (100, 100, 100) + assert data.itemImport.cols("i000008") == (0, 122, 188) + assert data.itemImport.cols("i000009") == (21, 0, 180) + assert data.itemImport.cols("i00000a") == (117, 0, 175) + + assert data.itemStatus.count("s000000") == 0 + assert data.itemStatus.count("s000001") == 0 + assert data.itemStatus.count("s000002") == 0 + assert data.itemStatus.count("s000003") == 0 + assert data.itemStatus.count("s000004") == 0 + assert data.itemStatus.count("s000005") == 0 + assert data.itemStatus.count("s000006") == 0 + + assert data.itemImport.count("i000007") == 0 + assert data.itemImport.count("i000008") == 0 + assert data.itemImport.count("i000009") == 0 + assert data.itemImport.count("i00000a") == 0 + + # Compare content + dumpFile = tstPaths.outDir / "projectXML_ReadLegacy10.json" + compFile = tstPaths.refDir / "projectXML_ReadLegacy10.json" + with open(dumpFile, mode="w", encoding="utf-8") as dump: + json.dump(content, dump, indent=2) + assert cmpFiles(dumpFile, compFile) + + packedContent = [] + mockProject = MockProject() + mockProject.__setattr__("data", data) + status = {} + for entry in content: + item = NWItem(mockProject) + item.unpack(entry) + status[item.itemHandle] = item.getImportStatus(incIcon=False)[0] + packedContent.append(item.pack()) + + assert status == { + "7031beac91f75": "Started", + "53b69b83cdafc": "Started", + "974e400180a99": "New", + "edca4be2fcaf8": "New", + "e7ded148d6e4a": "1st Draft", + "6a2d6d5f4f401": "Notes", + "636b6aa9b697b": "1st Draft", + "bc0cbd2a407f3": "1st Draft", + "ba8a28a246524": "Finished", + "96b68994dfa3d": "2nd Draft", + "88706ddc78b1b": "1st Draft", + "ae7339df26ded": "1st Draft", + "f6622b4617424": "None", + "f7e2d9f330615": "None", + "14298de4d9524": "Minor", + "bb2c23b3c42cc": "Major", + "15c4492bd5107": "None", + "b3e74dbc1f584": "Main", + "f1471bef9f2ae": "Minor", + "5eaea4e8cdee8": "Major", + "98acd8c76c93a": "None", + "b8136a5a774a0": "New", + } + + # Save the project again, which should produce an identical project xml + timeStamp = int(datetime.fromisoformat(xmlReader.timeStamp).timestamp()) + xmlWriter = ProjectXMLWriter(fncPath) + data.setUuid("d0f3fe10-c6e6-4310-8bfd-181eb4224eed") + assert xmlWriter.write(data, packedContent, timeStamp, 1000) is True + testFile = tstPaths.outDir / "projectXML_ReadLegacy10.nwx" + compFile = tstPaths.refDir / "projectXML_ReadLegacy10.nwx" + copyfile(outFile, testFile) + assert cmpFiles(testFile, compFile) + +# END Test testCoreProjectXML_ReadLegacy10 + + +@pytest.mark.core +def testCoreProjectXML_ReadLegacy11(tstPaths, fncPath, mockRnd): + """Test reading the version 1.1 XML file format. + """ + refFile = tstPaths.filesDir / "nwProject-1.1.nwx" + xmlFile = fncPath / "nwProject-1.1.nwx" + outFile = fncPath / "nwProject.nwx" + copyfile(refFile, xmlFile) + + xmlReader = ProjectXMLReader(xmlFile) + assert xmlReader.state == XMLReadState.NO_ACTION + + data = NWProjectData(MockProject()) + content = [] + + assert xmlReader.read(data, content) is True + assert xmlReader.state == XMLReadState.WAS_LEGACY + assert xmlReader.xmlRoot == "novelWriterXML" + assert xmlReader.xmlVersion == 0x0101 + assert xmlReader.appVersion == "0.9.2" + assert xmlReader.hexVersion == 0x000902f0 + + # Check loaded data + assert data.name == "Sample Project" + assert data.title == "Sample Project" + assert data.authors == ["Jane Smith", "Jay Doh"] + assert data.saveCount == 5 + assert data.autoCount == 10 + assert data.editTime == 1000 + + assert data.doBackup is True + assert data.language is None # Doesn't exist in 1.1 + assert data.spellCheck is True + assert data.spellLang is None # Doesn't exist in 1.1 + assert data.initCounts == (0, 0) + assert data.currCounts == (0, 0) + + assert data.getLastHandle("editor") is None # Dropped by conversion + assert data.getLastHandle("viewer") is None # Dropped by conversion + assert data.getLastHandle("novelTree") is None # Doesn't exist in 1.1 + assert data.getLastHandle("outline") is None # Doesn't exist in 1.1 + + assert data.getTitleFormat("title") == "%title%" + assert data.getTitleFormat("chapter") == "Chapter %ch%: %title%" + assert data.getTitleFormat("unnumbered") == "%title%" + assert data.getTitleFormat("scene") == "Scene %ch%.%sc%: %title%" + assert data.getTitleFormat("section") == "" + + assert data.itemStatus.name("s000000") == "New" + assert data.itemStatus.name("s000001") == "Notes" + assert data.itemStatus.name("s000002") == "Started" + assert data.itemStatus.name("s000003") == "1st Draft" + assert data.itemStatus.name("s000004") == "2nd Draft" + assert data.itemStatus.name("s000005") == "3rd Draft" + assert data.itemStatus.name("s000006") == "Finished" + + assert data.itemImport.name("i000007") == "None" + assert data.itemImport.name("i000008") == "Minor" + assert data.itemImport.name("i000009") == "Major" + assert data.itemImport.name("i00000a") == "Main" + + assert data.itemStatus.cols("s000000") == (100, 100, 100) + assert data.itemStatus.cols("s000001") == (200, 50, 0) + assert data.itemStatus.cols("s000002") == (182, 60, 0) + assert data.itemStatus.cols("s000003") == (193, 129, 0) + assert data.itemStatus.cols("s000004") == (193, 129, 0) + assert data.itemStatus.cols("s000005") == (193, 129, 0) + assert data.itemStatus.cols("s000006") == (58, 180, 58) + + assert data.itemImport.cols("i000007") == (100, 100, 100) + assert data.itemImport.cols("i000008") == (0, 122, 188) + assert data.itemImport.cols("i000009") == (21, 0, 180) + assert data.itemImport.cols("i00000a") == (117, 0, 175) + + assert data.itemStatus.count("s000000") == 0 + assert data.itemStatus.count("s000001") == 0 + assert data.itemStatus.count("s000002") == 0 + assert data.itemStatus.count("s000003") == 0 + assert data.itemStatus.count("s000004") == 0 + assert data.itemStatus.count("s000005") == 0 + assert data.itemStatus.count("s000006") == 0 + + assert data.itemImport.count("i000007") == 0 + assert data.itemImport.count("i000008") == 0 + assert data.itemImport.count("i000009") == 0 + assert data.itemImport.count("i00000a") == 0 + + # Compare content + dumpFile = tstPaths.outDir / "projectXML_ReadLegacy11.json" + compFile = tstPaths.refDir / "projectXML_ReadLegacy11.json" + with open(dumpFile, mode="w", encoding="utf-8") as dump: + json.dump(content, dump, indent=2) + assert cmpFiles(dumpFile, compFile) + + packedContent = [] + mockProject = MockProject() + mockProject.__setattr__("data", data) + status = {} + for entry in content: + item = NWItem(mockProject) + item.unpack(entry) + status[item.itemHandle] = item.getImportStatus(incIcon=False)[0] + packedContent.append(item.pack()) + + assert status == { + "7031beac91f75": "Started", + "53b69b83cdafc": "Started", + "974e400180a99": "New", + "edca4be2fcaf8": "New", + "e7ded148d6e4a": "1st Draft", + "6a2d6d5f4f401": "Notes", + "636b6aa9b697b": "1st Draft", + "bc0cbd2a407f3": "1st Draft", + "ba8a28a246524": "Finished", + "96b68994dfa3d": "2nd Draft", + "88706ddc78b1b": "1st Draft", + "ae7339df26ded": "1st Draft", + "f6622b4617424": "None", + "f7e2d9f330615": "None", + "14298de4d9524": "Minor", + "bb2c23b3c42cc": "Major", + "15c4492bd5107": "None", + "b3e74dbc1f584": "Main", + "f1471bef9f2ae": "Minor", + "5eaea4e8cdee8": "Major", + "98acd8c76c93a": "None", + "b8136a5a774a0": "New", + } + + # Save the project again, which should produce an identical project xml + timeStamp = int(datetime.fromisoformat(xmlReader.timeStamp).timestamp()) + xmlWriter = ProjectXMLWriter(fncPath) + data.setUuid("d0f3fe10-c6e6-4310-8bfd-181eb4224eed") + assert xmlWriter.write(data, packedContent, timeStamp, 1000) is True + testFile = tstPaths.outDir / "projectXML_ReadLegacy11.nwx" + compFile = tstPaths.refDir / "projectXML_ReadLegacy11.nwx" + copyfile(outFile, testFile) + assert cmpFiles(testFile, compFile) + +# END Test testCoreProjectXML_ReadLegacy11 + + +@pytest.mark.core +def testCoreProjectXML_ReadLegacy12(tstPaths, fncPath, mockRnd): + """Test reading the version 1.2 XML file format. + """ + refFile = tstPaths.filesDir / "nwProject-1.2.nwx" + xmlFile = fncPath / "nwProject-1.2.nwx" + outFile = fncPath / "nwProject.nwx" + copyfile(refFile, xmlFile) + + xmlReader = ProjectXMLReader(xmlFile) + assert xmlReader.state == XMLReadState.NO_ACTION + + data = NWProjectData(MockProject()) + content = [] + + assert xmlReader.read(data, content) is True + assert xmlReader.state == XMLReadState.WAS_LEGACY + assert xmlReader.xmlRoot == "novelWriterXML" + assert xmlReader.xmlVersion == 0x0102 + assert xmlReader.appVersion == "1.4.2" + assert xmlReader.hexVersion == 0x010402f0 + + # Check loaded data + assert data.name == "Sample Project" + assert data.title == "Sample Project" + assert data.authors == ["Jane Smith", "Jay Doh"] + assert data.saveCount == 5 + assert data.autoCount == 10 + assert data.editTime == 1000 + + assert data.doBackup is True + assert data.language == "en_GB" + assert data.spellCheck is True + assert data.spellLang == "en_GB" + assert data.initCounts == (840, 376) + assert data.currCounts == (840, 376) + + assert data.getLastHandle("editor") is None # Dropped by conversion + assert data.getLastHandle("viewer") is None # Dropped by conversion + assert data.getLastHandle("novelTree") is None # Doesn't exist in 1.2 + assert data.getLastHandle("outline") is None # Doesn't exist in 1.2 + + assert data.getTitleFormat("title") == "%title%" + assert data.getTitleFormat("chapter") == "Chapter %chw%: %title%" + assert data.getTitleFormat("unnumbered") == "%title%" + assert data.getTitleFormat("scene") == "Scene %ch%.%sc%: %title%" + assert data.getTitleFormat("section") == "" + + assert data.itemStatus.name("s000000") == "New" + assert data.itemStatus.name("s000001") == "Notes" + assert data.itemStatus.name("s000002") == "Started" + assert data.itemStatus.name("s000003") == "1st Draft" + assert data.itemStatus.name("s000004") == "2nd Draft" + assert data.itemStatus.name("s000005") == "3rd Draft" + assert data.itemStatus.name("s000006") == "Finished" + + assert data.itemImport.name("i000007") == "None" + assert data.itemImport.name("i000008") == "Minor" + assert data.itemImport.name("i000009") == "Major" + assert data.itemImport.name("i00000a") == "Main" + + assert data.itemStatus.cols("s000000") == (100, 100, 100) + assert data.itemStatus.cols("s000001") == (200, 50, 0) + assert data.itemStatus.cols("s000002") == (182, 60, 0) + assert data.itemStatus.cols("s000003") == (193, 129, 0) + assert data.itemStatus.cols("s000004") == (193, 129, 0) + assert data.itemStatus.cols("s000005") == (193, 129, 0) + assert data.itemStatus.cols("s000006") == (58, 180, 58) + + assert data.itemImport.cols("i000007") == (100, 100, 100) + assert data.itemImport.cols("i000008") == (0, 122, 188) + assert data.itemImport.cols("i000009") == (21, 0, 180) + assert data.itemImport.cols("i00000a") == (117, 0, 175) + + assert data.itemStatus.count("s000000") == 0 + assert data.itemStatus.count("s000001") == 0 + assert data.itemStatus.count("s000002") == 0 + assert data.itemStatus.count("s000003") == 0 + assert data.itemStatus.count("s000004") == 0 + assert data.itemStatus.count("s000005") == 0 + assert data.itemStatus.count("s000006") == 0 + + assert data.itemImport.count("i000007") == 0 + assert data.itemImport.count("i000008") == 0 + assert data.itemImport.count("i000009") == 0 + assert data.itemImport.count("i00000a") == 0 + + # Compare content + dumpFile = tstPaths.outDir / "projectXML_ReadLegacy12.json" + compFile = tstPaths.refDir / "projectXML_ReadLegacy12.json" + with open(dumpFile, mode="w", encoding="utf-8") as dump: + json.dump(content, dump, indent=2) + assert cmpFiles(dumpFile, compFile) + + packedContent = [] + mockProject = MockProject() + mockProject.__setattr__("data", data) + status = {} + for entry in content: + item = NWItem(mockProject) + item.unpack(entry) + status[item.itemHandle] = item.getImportStatus(incIcon=False)[0] + packedContent.append(item.pack()) + + assert status == { + "7031beac91f75": "Started", + "53b69b83cdafc": "Started", + "974e400180a99": "New", + "edca4be2fcaf8": "New", + "e7ded148d6e4a": "1st Draft", + "6a2d6d5f4f401": "Notes", + "636b6aa9b697b": "1st Draft", + "bc0cbd2a407f3": "1st Draft", + "ba8a28a246524": "New", + "96b68994dfa3d": "2nd Draft", + "88706ddc78b1b": "1st Draft", + "ae7339df26ded": "1st Draft", + "f6622b4617424": "None", + "f7e2d9f330615": "None", + "14298de4d9524": "Minor", + "bb2c23b3c42cc": "Major", + "15c4492bd5107": "None", + "b3e74dbc1f584": "Main", + "f1471bef9f2ae": "Minor", + "5eaea4e8cdee8": "Major", + "6827118336ac1": "New", # Is now treated as novel-like + "ae9bf3c3ea159": "New", # Is now treated as novel-like + "8a5deb88c0e97": "1st Draft", + "98acd8c76c93a": "None", + "b8136a5a774a0": "New", + } + + # Save the project again, which should produce an identical project xml + timeStamp = int(datetime.fromisoformat(xmlReader.timeStamp).timestamp()) + xmlWriter = ProjectXMLWriter(fncPath) + data.setUuid("d0f3fe10-c6e6-4310-8bfd-181eb4224eed") + assert xmlWriter.write(data, packedContent, timeStamp, 1000) is True + testFile = tstPaths.outDir / "projectXML_ReadLegacy12.nwx" + compFile = tstPaths.refDir / "projectXML_ReadLegacy12.nwx" + copyfile(outFile, testFile) + assert cmpFiles(testFile, compFile) + +# END Test testCoreProjectXML_ReadLegacy12 + + +@pytest.mark.core +def testCoreProjectXML_ReadLegacy13(tstPaths, fncPath, mockRnd): + """Test reading the version 1.3 XML file format. + """ + refFile = tstPaths.filesDir / "nwProject-1.3.nwx" + xmlFile = fncPath / "nwProject-1.3.nwx" + outFile = fncPath / "nwProject.nwx" + copyfile(refFile, xmlFile) + + xmlReader = ProjectXMLReader(xmlFile) + assert xmlReader.state == XMLReadState.NO_ACTION + + data = NWProjectData(MockProject()) + content = [] + + assert xmlReader.read(data, content) is True + assert xmlReader.state == XMLReadState.WAS_LEGACY + assert xmlReader.xmlRoot == "novelWriterXML" + assert xmlReader.xmlVersion == 0x0103 + assert xmlReader.appVersion == "1.6.6" + assert xmlReader.hexVersion == 0x010606f0 + + # Check loaded data + assert data.name == "Sample Project" + assert data.title == "Sample Project" + assert data.authors == ["Jane Smith", "Jay Doh"] + assert data.saveCount == 5 + assert data.autoCount == 10 + assert data.editTime == 1000 + + assert data.doBackup is True + assert data.language == "en_GB" + assert data.spellCheck is True + assert data.spellLang == "en_GB" + assert data.initCounts == (830, 376) + assert data.currCounts == (830, 376) + + assert data.getLastHandle("editor") is None # Dropped by conversion + assert data.getLastHandle("viewer") is None # Dropped by conversion + assert data.getLastHandle("novelTree") is None # Doesn't exist in 1.3 + assert data.getLastHandle("outline") is None # Doesn't exist in 1.3 + + assert data.getTitleFormat("title") == "%title%" + assert data.getTitleFormat("chapter") == "Chapter %chw%: %title%" + assert data.getTitleFormat("unnumbered") == "%title%" + assert data.getTitleFormat("scene") == "Scene %ch%.%sc%: %title%" + assert data.getTitleFormat("section") == "" + + assert data.itemStatus.name("s000000") == "New" + assert data.itemStatus.name("s000001") == "Notes" + assert data.itemStatus.name("s000002") == "Started" + assert data.itemStatus.name("s000003") == "1st Draft" + assert data.itemStatus.name("s000004") == "2nd Draft" + assert data.itemStatus.name("s000005") == "3rd Draft" + assert data.itemStatus.name("s000006") == "Finished" + + assert data.itemImport.name("i000007") == "None" + assert data.itemImport.name("i000008") == "Minor" + assert data.itemImport.name("i000009") == "Major" + assert data.itemImport.name("i00000a") == "Main" + + assert data.itemStatus.cols("s000000") == (100, 100, 100) + assert data.itemStatus.cols("s000001") == (200, 50, 0) + assert data.itemStatus.cols("s000002") == (182, 60, 0) + assert data.itemStatus.cols("s000003") == (193, 129, 0) + assert data.itemStatus.cols("s000004") == (193, 129, 0) + assert data.itemStatus.cols("s000005") == (193, 129, 0) + assert data.itemStatus.cols("s000006") == (58, 180, 58) + + assert data.itemImport.cols("i000007") == (100, 100, 100) + assert data.itemImport.cols("i000008") == (0, 122, 188) + assert data.itemImport.cols("i000009") == (21, 0, 180) + assert data.itemImport.cols("i00000a") == (117, 0, 175) + + assert data.itemStatus.count("s000000") == 0 + assert data.itemStatus.count("s000001") == 0 + assert data.itemStatus.count("s000002") == 0 + assert data.itemStatus.count("s000003") == 0 + assert data.itemStatus.count("s000004") == 0 + assert data.itemStatus.count("s000005") == 0 + assert data.itemStatus.count("s000006") == 0 + + assert data.itemImport.count("i000007") == 0 + assert data.itemImport.count("i000008") == 0 + assert data.itemImport.count("i000009") == 0 + assert data.itemImport.count("i00000a") == 0 + + # Compare content + dumpFile = tstPaths.outDir / "projectXML_ReadLegacy13.json" + compFile = tstPaths.refDir / "projectXML_ReadLegacy13.json" + with open(dumpFile, mode="w", encoding="utf-8") as dump: + json.dump(content, dump, indent=2) + assert cmpFiles(dumpFile, compFile) + + packedContent = [] + mockProject = MockProject() + mockProject.__setattr__("data", data) + status = {} + for entry in content: + item = NWItem(mockProject) + item.unpack(entry) + status[item.itemHandle] = item.getImportStatus(incIcon=False)[0] + packedContent.append(item.pack()) + + assert status == { + "7031beac91f75": "Started", + "53b69b83cdafc": "Started", + "974e400180a99": "New", + "edca4be2fcaf8": "New", + "e7ded148d6e4a": "1st Draft", + "6a2d6d5f4f401": "Notes", + "636b6aa9b697b": "1st Draft", + "bc0cbd2a407f3": "1st Draft", + "ba8a28a246524": "New", + "96b68994dfa3d": "2nd Draft", + "88706ddc78b1b": "1st Draft", + "ae7339df26ded": "1st Draft", + "f6622b4617424": "None", + "f7e2d9f330615": "None", + "14298de4d9524": "Minor", + "bb2c23b3c42cc": "Major", + "15c4492bd5107": "None", + "b3e74dbc1f584": "Main", + "f1471bef9f2ae": "Minor", + "5eaea4e8cdee8": "Major", + "6827118336ac1": "New", # Is now treated as novel-like + "ae9bf3c3ea159": "New", # Is now treated as novel-like + "8a5deb88c0e97": "1st Draft", + "98acd8c76c93a": "None", + "b8136a5a774a0": "New", + } + + # Save the project again, which should produce an identical project xml + timeStamp = int(datetime.fromisoformat(xmlReader.timeStamp).timestamp()) + xmlWriter = ProjectXMLWriter(fncPath) + data.setUuid("d0f3fe10-c6e6-4310-8bfd-181eb4224eed") + assert xmlWriter.write(data, packedContent, timeStamp, 1000) is True + testFile = tstPaths.outDir / "projectXML_ReadLegacy13.nwx" + compFile = tstPaths.refDir / "projectXML_ReadLegacy13.nwx" + copyfile(outFile, testFile) + assert cmpFiles(testFile, compFile) + +# END Test testCoreProjectXML_ReadLegacy13 + + +@pytest.mark.core +def testCoreProjectXML_ReadLegacy14(tstPaths, fncPath, mockRnd): + """Test reading the version 1.4 XML file format. + """ + refFile = tstPaths.filesDir / "nwProject-1.4.nwx" + xmlFile = fncPath / "nwProject-1.4.nwx" + outFile = fncPath / "nwProject.nwx" + copyfile(refFile, xmlFile) + + xmlReader = ProjectXMLReader(xmlFile) + assert xmlReader.state == XMLReadState.NO_ACTION + + data = NWProjectData(MockProject()) + content = [] + + assert xmlReader.read(data, content) is True + assert xmlReader.state == XMLReadState.WAS_LEGACY + assert xmlReader.xmlRoot == "novelWriterXML" + assert xmlReader.xmlVersion == 0x0104 + assert xmlReader.appVersion == "2.0-rc1" + assert xmlReader.hexVersion == 0x020000c1 + + # Check loaded data + assert data.name == "Sample Project" + assert data.title == "Sample Project" + assert data.authors == ["Jane Smith", "Jay Doh"] + assert data.saveCount == 5 + assert data.autoCount == 10 + assert data.editTime == 1000 + + assert data.doBackup is True + assert data.language == "en_GB" + assert data.spellCheck is True + assert data.spellLang == "en_GB" + assert data.initCounts == (954, 409) + assert data.currCounts == (954, 409) + + assert data.getLastHandle("editor") is None # Dropped by conversion + assert data.getLastHandle("viewer") is None # Dropped by conversion + assert data.getLastHandle("novelTree") is None # Doesn't exist in 1.3 + assert data.getLastHandle("outline") is None # Doesn't exist in 1.3 + + assert data.getTitleFormat("title") == "%title%" + assert data.getTitleFormat("chapter") == "Chapter %chw%: %title%" + assert data.getTitleFormat("unnumbered") == "%title%" + assert data.getTitleFormat("scene") == "Scene %ch%.%sc%: %title%" + assert data.getTitleFormat("section") == "" + + assert data.itemStatus.name("sf12341") == "New" + assert data.itemStatus.name("sf24ce6") == "Notes" + assert data.itemStatus.name("sc24b8f") == "Started" + assert data.itemStatus.name("s90e6c9") == "1st Draft" + assert data.itemStatus.name("sd51c5b") == "2nd Draft" + assert data.itemStatus.name("s8ae72a") == "3rd Draft" + assert data.itemStatus.name("s78ea90") == "Finished" + + assert data.itemImport.name("ia857f0") == "None" + assert data.itemImport.name("icfb3a5") == "Minor" + assert data.itemImport.name("i2d7a54") == "Major" + assert data.itemImport.name("i56be10") == "Main" + + assert data.itemStatus.cols("sf12341") == (100, 100, 100) + assert data.itemStatus.cols("sf24ce6") == (200, 50, 0) + assert data.itemStatus.cols("sc24b8f") == (182, 60, 0) + assert data.itemStatus.cols("s90e6c9") == (193, 129, 0) + assert data.itemStatus.cols("sd51c5b") == (193, 129, 0) + assert data.itemStatus.cols("s8ae72a") == (193, 129, 0) + assert data.itemStatus.cols("s78ea90") == (58, 180, 58) + + assert data.itemImport.cols("ia857f0") == (100, 100, 100) + assert data.itemImport.cols("icfb3a5") == (0, 122, 188) + assert data.itemImport.cols("i2d7a54") == (21, 0, 180) + assert data.itemImport.cols("i56be10") == (117, 0, 175) + + assert data.itemStatus.count("sf12341") == 4 + assert data.itemStatus.count("sf24ce6") == 2 + assert data.itemStatus.count("sc24b8f") == 3 + assert data.itemStatus.count("s90e6c9") == 7 + assert data.itemStatus.count("sd51c5b") == 0 + assert data.itemStatus.count("s8ae72a") == 0 + assert data.itemStatus.count("s78ea90") == 1 + + assert data.itemImport.count("ia857f0") == 5 + assert data.itemImport.count("icfb3a5") == 2 + assert data.itemImport.count("i2d7a54") == 2 + assert data.itemImport.count("i56be10") == 1 + + # Compare content + dumpFile = tstPaths.outDir / "projectXML_ReadLegacy14.json" + compFile = tstPaths.refDir / "projectXML_ReadLegacy14.json" + with open(dumpFile, mode="w", encoding="utf-8") as dump: + json.dump(content, dump, indent=2) + assert cmpFiles(dumpFile, compFile) + + packedContent = [] + mockProject = MockProject() + mockProject.__setattr__("data", data) + status = {} + for entry in content: + item = NWItem(mockProject) + item.unpack(entry) + status[item.itemHandle] = item.getImportStatus(incIcon=False)[0] + packedContent.append(item.pack()) + + assert status == { + "7031beac91f75": "Started", + "53b69b83cdafc": "Started", + "974e400180a99": "New", + "edca4be2fcaf8": "1st Draft", + "6a2d6d5f4f401": "Notes", + "636b6aa9b697b": "1st Draft", + "bc0cbd2a407f3": "1st Draft", + "ba8a28a246524": "Finished", + "96b68994dfa3d": "Notes", + "88706ddc78b1b": "1st Draft", + "ae7339df26ded": "1st Draft", + "e5e47ebf63b1c": "New", + "bacb7059e3083": "Started", + "a520879ca0b45": "1st Draft", + "f6622b4617424": "None", + "f7e2d9f330615": "None", + "14298de4d9524": "Minor", + "bb2c23b3c42cc": "Major", + "15c4492bd5107": "None", + "b3e74dbc1f584": "Main", + "f1471bef9f2ae": "Minor", + "5eaea4e8cdee8": "Major", + "6827118336ac1": "New", + "ae9bf3c3ea159": "New", + "8a5deb88c0e97": "1st Draft", + "98acd8c76c93a": "None", + "b8136a5a774a0": "None", + } + + # Save the project again, which should produce an identical project xml + timeStamp = int(datetime.fromisoformat(xmlReader.timeStamp).timestamp()) + xmlWriter = ProjectXMLWriter(fncPath) + data.setUuid("d0f3fe10-c6e6-4310-8bfd-181eb4224eed") + assert xmlWriter.write(data, packedContent, timeStamp, 1000) is True + testFile = tstPaths.outDir / "projectXML_ReadLegacy14.nwx" + compFile = tstPaths.refDir / "projectXML_ReadLegacy14.nwx" + copyfile(outFile, testFile) + assert cmpFiles(testFile, compFile) + +# END Test testCoreProjectXML_ReadLegacy14 diff --git a/tests/test_core/test_core_spellcheck.py b/tests/test_core/test_core_spellcheck.py index 4835e667..b3b00bf4 100644 --- a/tests/test_core/test_core_spellcheck.py +++ b/tests/test_core/test_core_spellcheck.py @@ -19,36 +19,63 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import os import sys import pytest from mock import causeOSError from tools import readFile, writeFile -from novelwriter.core.spellcheck import NWSpellEnchant +from novelwriter.core.spellcheck import FakeEnchant, NWSpellEnchant @pytest.mark.core -def testCoreSpell_Enchant(monkeypatch, tmpDir): - """Test the pyenchant spell checker +def testCoreSpell_FakeEnchant(monkeypatch): + """Test the FakeEnchant spell checker fallback. """ - wList = os.path.join(tmpDir, "wordlist.txt") - writeFile(wList, "a_word\nb_word\nc_word\n") - - # Block the enchant package (and trigger the default class) + # Make package import fail with monkeypatch.context() as mp: mp.setitem(sys.modules, "enchant", None) spChk = NWSpellEnchant() + spChk.setLanguage("en_US", "") + assert isinstance(spChk._theDict, FakeEnchant) - spChk.setLanguage("en", wList) - assert spChk.setLanguage("", "") is None - assert spChk.checkWord("") is True - assert spChk.suggestWords("") == [] + # Request a non-existent dictionary + spChk = NWSpellEnchant() + spChk.setLanguage("whatchamajig", "") + assert isinstance(spChk._theDict, FakeEnchant) + + # Request an emety language string + # See issue https://github.com/vkbo/novelWriter/issues/1096 + spChk = NWSpellEnchant() + spChk.setLanguage("", "") + assert isinstance(spChk._theDict, FakeEnchant) + + # FakeEnchant should handle requests + fkChk = FakeEnchant() + assert fkChk.tag == "" + assert fkChk.provider.name == "" + assert fkChk.check("whatchamajig") is True + assert fkChk.suggest("whatchamajig") == [] + assert fkChk.add_to_session("whatchamajig") is None + +# END Test testCoreSpell_FakeEnchant + + +@pytest.mark.core +def testCoreSpell_Enchant(monkeypatch, fncPath): + """Test the pyenchant spell checker. + """ + wList = fncPath / "wordlist.txt" + writeFile(wList, "a_word\nb_word\nc_word\n") + + # Break the enchant package, and check error handling + with monkeypatch.context() as mp: + mp.setitem(sys.modules, "enchant", None) + spChk = NWSpellEnchant() assert spChk.listDictionaries() == [] assert spChk.describeDict() == ("", "") - # Break the enchant package, and check error handling + # Set the dict to None, and check dictionary call error handling spChk = NWSpellEnchant() spChk.theDict = None assert spChk.checkWord("word") is True @@ -57,8 +84,9 @@ def testCoreSpell_Enchant(monkeypatch, tmpDir): # Load the proper enchant package (twice) spChk = NWSpellEnchant() - spChk.setLanguage("en", wList) - spChk.setLanguage("en", wList) + spChk.setLanguage("en_US", wList) + spChk.setLanguage("en_US", wList) + assert spChk.spellLanguage == "en_US" # Add a word to the user's dictionary assert spChk._readProjectDictionary("stuff") is False @@ -98,7 +126,39 @@ def testCoreSpell_Enchant(monkeypatch, tmpDir): assert len(dList) > 0 aTag, aName = spChk.describeDict() - assert aTag == "en" + assert aTag == "en_US" assert aName != "" # END Test testCoreSpell_Enchant + + +@pytest.mark.core +def testCoreSpell_SessionWords(fncPath): + """Test the handling of the custom word list in the spell checker. + New project sessions should not inherit the project word list from + other sessions, so this test checks that they don't bleed through. + """ + wList1 = fncPath / "wordlist1.txt" + wList2 = fncPath / "wordlist2.txt" + writeFile(wList1, "a_word\nb_word\nc_word\n") + writeFile(wList2, "d_word\ne_word\nf_word\n") + + spChk = NWSpellEnchant() + + spChk.setLanguage("en_US", wList1) + assert spChk.checkWord("a_word") is True + assert spChk.checkWord("b_word") is True + assert spChk.checkWord("c_word") is True + assert spChk.checkWord("d_word") is False + assert spChk.checkWord("e_word") is False + assert spChk.checkWord("f_word") is False + + spChk.setLanguage("en_US", wList2) + assert spChk.checkWord("a_word") is False + assert spChk.checkWord("b_word") is False + assert spChk.checkWord("c_word") is False + assert spChk.checkWord("d_word") is True + assert spChk.checkWord("e_word") is True + assert spChk.checkWord("f_word") is True + +# END Test testCoreSpell_SessionWords diff --git a/tests/test_core/test_core_status.py b/tests/test_core/test_core_status.py index 868a3034..760f31f5 100644 --- a/tests/test_core/test_core_status.py +++ b/tests/test_core/test_core_status.py @@ -20,23 +20,21 @@ along with this program. If not, see . """ import pytest -import random -from lxml import etree +from tools import C from PyQt5.QtGui import QIcon from novelwriter.core.status import NWStatus -statusKeys = ["sa3b179", "s1c8031", "s06671a", "sbdd640"] -importKeys = ["i466852", "i3eb13b", "i392456", "i23b8c1"] +statusKeys = [C.sNew, C.sNote, C.sDraft, C.sFinished] +importKeys = [C.iNew, C.iMinor, C.iMajor, C.iMain] @pytest.mark.core -def testCoreStatus_Internal(): +def testCoreStatus_Internal(mockRnd): """Test all the internal functions of the NWStatus class. """ - random.seed(42) theStatus = NWStatus(NWStatus.STATUS) theImport = NWStatus(NWStatus.IMPORT) @@ -87,10 +85,9 @@ def testCoreStatus_Internal(): @pytest.mark.core -def testCoreStatus_Iterator(): +def testCoreStatus_Iterator(mockRnd): """Test the iterator functions of the NWStatus class. """ - random.seed(42) theStatus = NWStatus(NWStatus.STATUS) theStatus.write(None, "New", (100, 100, 100)) @@ -132,10 +129,9 @@ def testCoreStatus_Iterator(): @pytest.mark.core -def testCoreStatus_Entries(): +def testCoreStatus_Entries(mockRnd): """Test all the simple setters for the NWStatus class. """ - random.seed(42) theStatus = NWStatus(NWStatus.STATUS) # Write @@ -161,14 +157,6 @@ def testCoreStatus_Entries(): assert theStatus[statusKeys[3]]["name"] == "Entry 4" assert theStatus[statusKeys[3]]["cols"] == (100, 100, 100) - # Check reverse map - assert theStatus._reverse == { - "Entry 1": statusKeys[0], - "Entry 2": statusKeys[1], - "Entry 3": statusKeys[2], - "Entry 4": statusKeys[3], - } - # Check # ===== @@ -176,14 +164,8 @@ def testCoreStatus_Entries(): for key in statusKeys: assert theStatus.check(key) == key - # Reverse map lookup - assert theStatus.check("Entry 1") == statusKeys[0] - assert theStatus.check("Entry 2") == statusKeys[1] - assert theStatus.check("Entry 3") == statusKeys[2] - assert theStatus.check("Entry 4") == statusKeys[3] - # Non-existing name - assert theStatus.check("Entry 5") == statusKeys[0] + assert theStatus.check("s987654") == statusKeys[0] # Name Access # =========== @@ -314,10 +296,9 @@ def testCoreStatus_Entries(): @pytest.mark.core -def testCoreStatus_XMLPackUnpack(): - """Test all the XML pack/unpack of the NWStatus class. +def testCoreStatus_PackUnpack(mockRnd): + """Test all the pack/unpack of the NWStatus class. """ - random.seed(42) theStatus = NWStatus(NWStatus.STATUS) theStatus.write(None, "New", (100, 100, 100)) theStatus.write(None, "Note", (200, 50, 0)) @@ -329,36 +310,59 @@ def testCoreStatus_XMLPackUnpack(): for _ in range(n): theStatus.increment(statusKeys[i]) - nwXML = etree.Element("novelWriterXML") - # Pack - xStatus = etree.SubElement(nwXML, "status") - theStatus.packXML(xStatus) - assert etree.tostring(xStatus, pretty_print=False, encoding="utf-8") == ( - b'' - b'New' - b'Note' - b'Draft' - b'Finished' - b'' - ) + assert list(theStatus.pack()) == [ + ("New", { + "key": statusKeys[0], + "count": "3", + "red": "100", + "green": "100", + "blue": "100" + }), + ("Note", { + "key": statusKeys[1], + "count": "5", + "red": "200", + "green": "50", + "blue": "0" + }), + ("Draft", { + "key": statusKeys[2], + "count": "7", + "red": "200", + "green": "150", + "blue": "0" + }), + ("Finished", { + "key": statusKeys[3], + "count": "9", + "red": "50", + "green": "200", + "blue": "0" + }), + ] # Unpack theStatus = NWStatus(NWStatus.STATUS) - assert theStatus.unpackXML(xStatus) + assert theStatus.unpack({ + statusKeys[0]: {"label": "New0", "colour": (100, 100, 100), "count": countTo[0]}, + statusKeys[1]: {"label": "New1", "colour": (150, 150, 150), "count": countTo[1]}, + statusKeys[2]: {"label": "New2", "colour": (200, 200, 200), "count": countTo[2]}, + statusKeys[3]: {"label": "New3", "colour": (250, 250, 250), "count": countTo[3]}, + }) assert len(theStatus._store) == 4 assert list(theStatus._store.keys()) == statusKeys - assert theStatus._store[statusKeys[0]]["name"] == "New" - assert theStatus._store[statusKeys[1]]["name"] == "Note" - assert theStatus._store[statusKeys[2]]["name"] == "Draft" - assert theStatus._store[statusKeys[3]]["name"] == "Finished" + assert theStatus._store[statusKeys[0]]["name"] == "New0" + assert theStatus._store[statusKeys[1]]["name"] == "New1" + assert theStatus._store[statusKeys[2]]["name"] == "New2" + assert theStatus._store[statusKeys[3]]["name"] == "New3" assert theStatus._store[statusKeys[0]]["cols"] == (100, 100, 100) - assert theStatus._store[statusKeys[1]]["cols"] == (200, 50, 0) - assert theStatus._store[statusKeys[2]]["cols"] == (200, 150, 0) - assert theStatus._store[statusKeys[3]]["cols"] == (50, 200, 0) + assert theStatus._store[statusKeys[1]]["cols"] == (150, 150, 150) + assert theStatus._store[statusKeys[2]]["cols"] == (200, 200, 200) + assert theStatus._store[statusKeys[3]]["cols"] == (250, 250, 250) assert theStatus._store[statusKeys[0]]["count"] == countTo[0] assert theStatus._store[statusKeys[1]]["count"] == countTo[1] assert theStatus._store[statusKeys[2]]["count"] == countTo[2] assert theStatus._store[statusKeys[3]]["count"] == countTo[3] -# END Test testCoreStatus_XMLPackUnpack +# END Test testCoreStatus_PackUnpack diff --git a/tests/test_core/test_core_storage.py b/tests/test_core/test_core_storage.py new file mode 100644 index 00000000..7545a870 --- /dev/null +++ b/tests/test_core/test_core_storage.py @@ -0,0 +1,332 @@ +""" +novelWriter – NWStorage Class Tester +==================================== + +This file is a part of novelWriter +Copyright 2018–2022, 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 . +""" + +from zipfile import ZipFile +import pytest + +from mock import causeOSError +from tools import C, buildTestProject, writeFile + +from novelwriter.constants import nwFiles +from novelwriter.core.project import NWProject +from novelwriter.core.storage import NWStorage +from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter + + +class MockProject: + pass + + +@pytest.mark.core +def testCoreStorage_OpenProjectInPlace(mockGUI, fncPath, mockRnd): + """Test opening a project in a folder. + """ + theProject = NWProject(mockGUI) + mockRnd.reset() + buildTestProject(theProject, fncPath) + theProject.closeProject() + + # Create instance + storage = NWStorage(theProject) + + # Check defaults + assert storage.storagePath is None + assert storage.runtimePath is None + assert storage.contentPath is None + assert storage._openMode == NWStorage.MODE_INACTIVE + + # Check closed project return values + assert storage.isOpen() is False + assert storage.getXmlReader() is None + assert storage.getXmlWriter() is None + assert bool(storage.getDocument(C.hSceneDoc)) is False + assert storage.getMetaFile("file") is None + assert storage.getCacheFile("file") is None + + # Open project as a new project should fail + assert storage.openProjectInPlace(fncPath, newProject=True) is False + + # Opening as a no-new project is fine + assert storage.openProjectInPlace(fncPath, newProject=False) is True + + # Opening the project file is also fine + assert storage.openProjectInPlace(fncPath / nwFiles.PROJ_FILE, newProject=False) is True + + # Check settings + assert storage.storagePath == fncPath + assert storage.runtimePath == fncPath + assert storage.contentPath == fncPath / "content" + assert storage._openMode == NWStorage.MODE_INPLACE + + # Open the project itself + theProject.openProject(fncPath) + storage = theProject.storage + + # Get XML components + assert isinstance(storage.getXmlReader(), ProjectXMLReader) + assert isinstance(storage.getXmlWriter(), ProjectXMLWriter) + + # Get document + assert storage.getDocument(C.hSceneDoc).readDocument() == "### New Scene\n\n" + + # Get paths + assert storage.getMetaFile("stuff") == fncPath / "meta" / "stuff" + assert storage.getCacheFile("stuff") == fncPath / "cache" / "stuff" + + # Clean up + assert theProject.closeProject() is True + + # Check closed project return values (again) + assert storage.isOpen() is False + assert storage.getXmlReader() is None + assert storage.getXmlWriter() is None + assert bool(storage.getDocument(C.hSceneDoc)) is False + assert storage.getMetaFile("file") is None + assert storage.getCacheFile("file") is None + +# END Test testCoreStorage_ProjectInPlace + + +@pytest.mark.core +def testCoreStorage_LockFile(monkeypatch, fncPath): + """Test the project lock file. + """ + monkeypatch.setattr("novelwriter.core.storage.time", lambda: 1000.0) + + storage = NWStorage(MockProject()) + assert storage.isOpen() is False + + # Project not open, so cannot read/write lock file + assert storage.readLockFile() == ["ERROR"] + assert storage.writeLockFile() is False + assert storage.clearLockFile() is False + + # Set a path to work with + lockFilePath = fncPath / nwFiles.PROJ_LOCK + storage._lockFilePath = lockFilePath + + # Path is set, but there is no lockfile + assert storage.readLockFile() == [] + + # Write lockfile fails + with monkeypatch.context() as mp: + mp.setattr("pathlib.Path.write_text", causeOSError) + assert storage.writeLockFile() is False + assert not lockFilePath.exists() + + # Successful write + assert storage.writeLockFile() is True + assert lockFilePath.exists() + assert lockFilePath.read_text().split(";")[3] == "1000" + + # Read lockfile fails + with monkeypatch.context() as mp: + mp.setattr("pathlib.Path.read_text", causeOSError) + assert storage.readLockFile() == ["ERROR"] + assert lockFilePath.exists() + + # Successful read + assert storage.readLockFile() == [ + storage.mainConf.hostName, + storage.mainConf.osType, + storage.mainConf.kernelVer, + "1000", + ] + + # Write an invalid lockfile + writeFile(lockFilePath, "a;b;c") + assert storage.readLockFile() == ["ERROR"] + + # Fail to remove lockfile + with monkeypatch.context() as mp: + mp.setattr("pathlib.Path.unlink", causeOSError) + assert storage.clearLockFile() is False + assert lockFilePath.exists() + + # Successful remove + assert storage.clearLockFile() is True + assert not lockFilePath.exists() + +# END Test testCoreStorage_LockFile + + +@pytest.mark.core +def testCoreStorage_PrepareStorage(monkeypatch, fncPath): + """Test the project path preparation functions. + """ + storage = NWStorage(MockProject()) + assert storage.isOpen() is False + + # No path set + assert storage._prepareStorage() is False + + # Set path to home + storage._runtimePath = fncPath + with monkeypatch.context() as mp: + mp.setattr("pathlib.Path.home", lambda: fncPath) + assert storage._prepareStorage() is False + + # Fail on mkdir + storage._runtimePath = fncPath + with monkeypatch.context() as mp: + mp.setattr("pathlib.Path.mkdir", causeOSError) + assert storage._prepareStorage() is False + + # Set up the folder + storage._runtimePath = fncPath + assert storage._prepareStorage(checkLegacy=False) is True + assert (fncPath / "content").exists() + assert (fncPath / "cache").exists() + assert (fncPath / "meta").exists() + + # Add a legacy folder + storage._runtimePath = fncPath + dataDir = fncPath / "data_0" + dataDir.mkdir() + assert storage._prepareStorage(checkLegacy=True) is True + assert not dataDir.exists() + + # We cannot add a new project here + storage._runtimePath = fncPath + assert storage._prepareStorage(checkLegacy=False, newProject=True) is False + + # Legacy Data Folder + # ================== + storage._runtimePath = fncPath + + data = [] + files = [] + for c in "0123456789abcdefX": + dataDir = fncPath / f"data_{c}" + dataDir.mkdir() + data.append(dataDir) + + nwdFile = dataDir / f"00000000000{c}_main.nwd" + bakFile = dataDir / f"00000000000{c}_main.bak" + nwdFile.write_text("#") + bakFile.write_text("#") + files.append(nwdFile) + files.append(bakFile) + + for item in files: + assert item.exists() + + # Pollute folder 7 and 8 + (data[7] / "stuff.txt").write_text("foo") + (data[8] / "bar").mkdir() + + # Process folders + for i in range(9): + storage._legacyDataFolder(fncPath, data[i]) + + # Files form 0 to 8 should now be in content + for c in "012345678": + assert (fncPath / "content" / f"{c}00000000000{c}.nwd").exists() + + # Folders 0 to 6 should be deleted + for i in range(7): + assert not data[i].exists() + + # While 7 and 8 remain + assert data[7].exists() + assert data[8].exists() + + # So does folder X, which is invalid + storage._legacyDataFolder(fncPath, data[16]) + assert data[16].exists() + + # Fail cleanup of folder 9 + with monkeypatch.context() as mp: + mp.setattr("pathlib.Path.rename", causeOSError) + mp.setattr("pathlib.Path.unlink", causeOSError) + storage._legacyDataFolder(fncPath, data[9]) + assert data[9].exists() + assert not (fncPath / "content" / "9000000000009.nwd").exists() + + # Run the remaining through the prepare storage call + assert storage._prepareStorage(checkLegacy=True) is True + for c in "0123456789abcdef": + assert (fncPath / "content" / f"{c}00000000000{c}.nwd").exists() + + # Deprecated Files + # ================ + + remove = [ + fncPath / "meta" / "mainOptions.json", + fncPath / "meta" / "exportOptions.json", + fncPath / "meta" / "outlineOptions.json", + fncPath / "meta" / "timelineOptions.json", + fncPath / "meta" / "docMergeOptions.json", + fncPath / "meta" / "sessionLogOptions.json", + fncPath / "ToC.json", + ] + for depFile in remove: + depFile.write_text("foo") + assert depFile.exists() + + with monkeypatch.context() as mp: + mp.setattr("pathlib.Path.unlink", causeOSError) + storage._deleteDeprecatedFiles(fncPath) + for depFile in remove: + assert depFile.exists() + + storage._deleteDeprecatedFiles(fncPath) + for depFile in remove: + assert not depFile.exists() + +# END Test testCoreStorage_PrepareStorage + + +@pytest.mark.core +def testCoreStorage_ZipIt(monkeypatch, mockGUI, fncPath, tmpPath, mockRnd): + """Test making a zip archive of a project. + """ + zipFile = tmpPath / "project.zip" + + theProject = NWProject(mockGUI) + storage = theProject.storage + assert storage.zipIt(zipFile) is False + + # Make a project + mockRnd.reset() + buildTestProject(theProject, fncPath) + + # Fail to create archive + with monkeypatch.context() as mp: + mp.setattr("novelwriter.core.storage.ZipFile.write", causeOSError) + assert storage.zipIt(zipFile) is False + + # Create archive + assert storage.zipIt(zipFile) is True + + # Check content + with ZipFile(zipFile, mode="r") as archive: + names = archive.namelist() + assert nwFiles.PROJ_FILE in names + assert f"meta/{nwFiles.OPTS_FILE}" in names + assert f"meta/{nwFiles.INDEX_FILE}" in names + assert f"content/{C.hTitlePage}.nwd" in names + assert f"content/{C.hChapterDoc}.nwd" in names + assert f"content/{C.hSceneDoc}.nwd" in names + + theProject.closeProject() + +# END Test testCoreStorage_ZipIt diff --git a/tests/test_core/test_core_tohtml.py b/tests/test_core/test_core_tohtml.py index f21d5d78..44f16673 100644 --- a/tests/test_core/test_core_tohtml.py +++ b/tests/test_core/test_core_tohtml.py @@ -19,12 +19,12 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import os import pytest from tools import readFile -from novelwriter.core import NWProject, ToHtml +from novelwriter.core.tohtml import ToHtml +from novelwriter.core.project import NWProject @pytest.mark.core @@ -440,7 +440,7 @@ def testCoreToHtml_SpecialCases(mockGUI): @pytest.mark.core -def testCoreToHtml_Complex(mockGUI, fncDir): +def testCoreToHtml_Complex(mockGUI, fncPath): """Test the save method of the ToHtml class. """ theProject = NWProject(mockGUI) @@ -528,7 +528,7 @@ def testCoreToHtml_Complex(mockGUI, fncDir): bodyText="".join(resText).rstrip() ) - saveFile = os.path.join(fncDir, "outFile.htm") + saveFile = fncPath / "outFile.htm" theHtml.saveHTML5(saveFile) assert readFile(saveFile) == htmlDoc diff --git a/tests/test_core/test_core_tokenizer.py b/tests/test_core/test_core_tokenizer.py index 97023201..f2e939a2 100644 --- a/tests/test_core/test_core_tokenizer.py +++ b/tests/test_core/test_core_tokenizer.py @@ -19,12 +19,11 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import os import pytest -from tools import readFile +from tools import C, buildTestProject, readFile -from novelwriter.core import NWProject, NWDoc +from novelwriter.core.project import NWProject from novelwriter.core.tokenizer import Tokenizer @@ -132,21 +131,20 @@ def testCoreToken_Setters(mockGUI): @pytest.mark.core -def testCoreToken_TextOps(monkeypatch, nwMinimal, mockGUI): +def testCoreToken_TextOps(monkeypatch, mockGUI, mockRnd, fncPath): """Test handling files and text in the Tokenizer class. """ theProject = NWProject(mockGUI) - theProject.projLang = "en" + mockRnd.reset() + buildTestProject(theProject, fncPath) + + theProject.data.setLanguage("en") theProject._loadProjectLocalisation() theToken = BareTokenizer(theProject) theToken.setKeepMarkdown(True) - assert theProject.openProject(nwMinimal) - sHandle = "8c659a11cd429" - # Set some content to work with - docText = ( "### Scene Six\n\n" "This is text with _italic text_, some **bold text**, some ~~deleted text~~, " @@ -156,26 +154,26 @@ def testCoreToken_TextOps(monkeypatch, nwMinimal, mockGUI): ) docTextR = docText.replace("", "this").replace("", "that") - nDoc = NWDoc(theProject, sHandle) + nDoc = theProject.storage.getDocument(C.hSceneDoc) assert nDoc.writeDocument(docText) - theProject.setAutoReplace({"A": "this", "B": "that"}) + theProject.data.setAutoReplace({"A": "this", "B": "that"}) assert theProject.saveProject() # Root Heading assert theToken.addRootHeading("stuff") is False - assert theToken.addRootHeading(sHandle) is False + assert theToken.addRootHeading(C.hSceneDoc) is False # First Page - assert theToken.addRootHeading("7695ce551d265") is True + assert theToken.addRootHeading(C.hPlotRoot) is True assert theToken.theMarkdown[-1] == "# Notes: Plot\n\n" assert theToken._theTokens[-1] == ( Tokenizer.T_TITLE, 0, "Notes: Plot", None, Tokenizer.A_CENTRE ) # Not First Page - assert theToken.addRootHeading("7695ce551d265") is True + assert theToken.addRootHeading(C.hPlotRoot) is True assert theToken.theMarkdown[-1] == "# Notes: Plot\n\n" assert theToken._theTokens[-1] == ( Tokenizer.T_TITLE, 0, "Notes: Plot", None, Tokenizer.A_CENTRE | Tokenizer.A_PBB @@ -183,18 +181,18 @@ def testCoreToken_TextOps(monkeypatch, nwMinimal, mockGUI): # Set Text assert theToken.setText("stuff") is False - assert theToken.setText(sHandle) is True + assert theToken.setText(C.hSceneDoc) is True assert theToken._theText == docText with monkeypatch.context() as mp: mp.setattr("novelwriter.constants.nwConst.MAX_DOCSIZE", 100) - assert theToken.setText(sHandle, docText) is True + assert theToken.setText(C.hSceneDoc, docText) is True assert theToken._theText == ( "# ERROR\n\n" "Document 'New Scene' is too big (0.00 MB). Skipping.\n\n" ) - assert theToken.setText(sHandle, docText) is True + assert theToken.setText(C.hSceneDoc, docText) is True assert theToken._theText == docText assert theToken._isNone is False @@ -211,7 +209,7 @@ def testCoreToken_TextOps(monkeypatch, nwMinimal, mockGUI): assert theToken.theResult == "This is text with escapes: ** ~~ __" # Save File - savePath = os.path.join(nwMinimal, "dump.nwd") + savePath = fncPath / "dump.nwd" theToken.saveRawMarkdown(savePath) assert readFile(savePath) == ( "# Notes: Plot\n\n" @@ -883,7 +881,7 @@ def testCoreToken_ProcessHeaders(mockGUI): """Test the header and page parser of the Tokenizer class. """ theProject = NWProject(mockGUI) - theProject.projLang = "en" + theProject.data.setLanguage("en") theProject._loadProjectLocalisation() theToken = BareTokenizer(theProject) diff --git a/tests/test_core/test_core_tomd.py b/tests/test_core/test_core_tomd.py index 2e49d16b..aec51566 100644 --- a/tests/test_core/test_core_tomd.py +++ b/tests/test_core/test_core_tomd.py @@ -19,12 +19,12 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import os import pytest from tools import readFile -from novelwriter.core import NWProject, ToMarkdown +from novelwriter.core.tomd import ToMarkdown +from novelwriter.core.project import NWProject @pytest.mark.core @@ -207,7 +207,7 @@ def testCoreToMarkdown_ConvertDirect(mockGUI): @pytest.mark.core -def testCoreToMarkdown_Complex(mockGUI, fncDir): +def testCoreToMarkdown_Complex(mockGUI, fncPath): """Test the save method of the ToMarkdown class. """ theProject = NWProject(mockGUI) @@ -252,7 +252,7 @@ def testCoreToMarkdown_Complex(mockGUI, fncDir): # Check File # ========== - saveFile = os.path.join(fncDir, "outFile.md") + saveFile = fncPath / "outFile.md" theMD.saveMarkdown(saveFile) assert readFile(saveFile) == "".join(resText) diff --git a/tests/test_core/test_core_toodt.py b/tests/test_core/test_core_toodt.py index c714b5f5..cbc9ce17 100644 --- a/tests/test_core/test_core_toodt.py +++ b/tests/test_core/test_core_toodt.py @@ -19,7 +19,6 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import os import pytest import zipfile @@ -28,8 +27,8 @@ from shutil import copyfile from tools import cmpFiles -from novelwriter.core import NWProject, ToOdt -from novelwriter.core.toodt import ODTParagraphStyle, ODTTextStyle, XMLParagraph, _mkTag +from novelwriter.core.toodt import ToOdt, ODTParagraphStyle, ODTTextStyle, XMLParagraph, _mkTag +from novelwriter.core.project import NWProject XML_NS = [ ' xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"', @@ -612,7 +611,7 @@ def testCoreToOdt_ConvertDirect(mockGUI): @pytest.mark.core -def testCoreToOdt_SaveFlat(mockGUI, fncDir, outDir, refDir): +def testCoreToOdt_SaveFlat(mockGUI, fncPath, tstPaths): """Test the document save functions. """ theProject = NWProject(mockGUI) @@ -634,12 +633,12 @@ def testCoreToOdt_SaveFlat(mockGUI, fncDir, outDir, refDir): theDoc.doConvert() theDoc.closeDocument() - flatFile = os.path.join(fncDir, "document.fodt") - testFile = os.path.join(outDir, "coreToOdt_SaveFlat_document.fodt") - compFile = os.path.join(refDir, "coreToOdt_SaveFlat_document.fodt") + flatFile = fncPath / "document.fodt" + testFile = tstPaths.outDir / "coreToOdt_SaveFlat_document.fodt" + compFile = tstPaths.refDir / "coreToOdt_SaveFlat_document.fodt" theDoc.saveFlatXML(flatFile) - assert os.path.isfile(flatFile) + assert flatFile.exists() copyfile(flatFile, testFile) assert cmpFiles(testFile, compFile, [4, 5]) @@ -648,7 +647,7 @@ def testCoreToOdt_SaveFlat(mockGUI, fncDir, outDir, refDir): @pytest.mark.core -def testCoreToOdt_SaveFull(mockGUI, fncDir, outDir, refDir): +def testCoreToOdt_SaveFull(mockGUI, fncPath, tstPaths): """Test the document save functions. """ theProject = NWProject(mockGUI) @@ -667,25 +666,25 @@ def testCoreToOdt_SaveFull(mockGUI, fncDir, outDir, refDir): theDoc.doConvert() theDoc.closeDocument() - fullFile = os.path.join(fncDir, "document.odt") + fullFile = fncPath / "document.odt" theDoc.saveOpenDocText(fullFile) - assert os.path.isfile(fullFile) + assert fullFile.exists() assert zipfile.is_zipfile(fullFile) - maniFile = os.path.join(outDir, "coreToOdt_SaveFull_manifest.xml") - settFile = os.path.join(outDir, "coreToOdt_SaveFull_settings.xml") - contFile = os.path.join(outDir, "coreToOdt_SaveFull_content.xml") - metaFile = os.path.join(outDir, "coreToOdt_SaveFull_meta.xml") - stylFile = os.path.join(outDir, "coreToOdt_SaveFull_styles.xml") + maniFile = tstPaths.outDir / "coreToOdt_SaveFull_manifest.xml" + settFile = tstPaths.outDir / "coreToOdt_SaveFull_settings.xml" + contFile = tstPaths.outDir / "coreToOdt_SaveFull_content.xml" + metaFile = tstPaths.outDir / "coreToOdt_SaveFull_meta.xml" + stylFile = tstPaths.outDir / "coreToOdt_SaveFull_styles.xml" - maniComp = os.path.join(refDir, "coreToOdt_SaveFull_manifest.xml") - settComp = os.path.join(refDir, "coreToOdt_SaveFull_settings.xml") - contComp = os.path.join(refDir, "coreToOdt_SaveFull_content.xml") - metaComp = os.path.join(refDir, "coreToOdt_SaveFull_meta.xml") - stylComp = os.path.join(refDir, "coreToOdt_SaveFull_styles.xml") + maniComp = tstPaths.refDir / "coreToOdt_SaveFull_manifest.xml" + settComp = tstPaths.refDir / "coreToOdt_SaveFull_settings.xml" + contComp = tstPaths.refDir / "coreToOdt_SaveFull_content.xml" + metaComp = tstPaths.refDir / "coreToOdt_SaveFull_meta.xml" + stylComp = tstPaths.refDir / "coreToOdt_SaveFull_styles.xml" - extaxtTo = os.path.join(outDir, "coreToOdt_SaveFull") + extaxtTo = tstPaths.outDir / "coreToOdt_SaveFull" with zipfile.ZipFile(fullFile, mode="r") as theZip: theZip.extract("META-INF/manifest.xml", extaxtTo) @@ -694,17 +693,17 @@ def testCoreToOdt_SaveFull(mockGUI, fncDir, outDir, refDir): theZip.extract("meta.xml", extaxtTo) theZip.extract("styles.xml", extaxtTo) - maniOut = os.path.join(outDir, "coreToOdt_SaveFull", "META-INF", "manifest.xml") - settOut = os.path.join(outDir, "coreToOdt_SaveFull", "settings.xml") - contOut = os.path.join(outDir, "coreToOdt_SaveFull", "content.xml") - metaOut = os.path.join(outDir, "coreToOdt_SaveFull", "meta.xml") - stylOut = os.path.join(outDir, "coreToOdt_SaveFull", "styles.xml") + maniOut = tstPaths.outDir / "coreToOdt_SaveFull" / "META-INF" / "manifest.xml" + settOut = tstPaths.outDir / "coreToOdt_SaveFull" / "settings.xml" + contOut = tstPaths.outDir / "coreToOdt_SaveFull" / "content.xml" + metaOut = tstPaths.outDir / "coreToOdt_SaveFull" / "meta.xml" + stylOut = tstPaths.outDir / "coreToOdt_SaveFull" / "styles.xml" def prettifyXml(inFile, outFile): with open(outFile, mode="wb") as fileStream: fileStream.write( etree.tostring( - etree.parse(inFile), + etree.parse(str(inFile)), pretty_print=True, encoding="utf-8", xml_declaration=True diff --git a/tests/test_core/test_core_tree.py b/tests/test_core/test_core_tree.py index 23eb7f73..731e11aa 100644 --- a/tests/test_core/test_core_tree.py +++ b/tests/test_core/test_core_tree.py @@ -19,17 +19,19 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import os import pytest import random -from lxml import etree +from pathlib import Path +from mock import causeOSError from tools import readFile -from novelwriter.core.project import NWProject, NWItem, NWTree from novelwriter.enum import nwItemClass, nwItemType, nwItemLayout from novelwriter.constants import nwFiles +from novelwriter.core.item import NWItem +from novelwriter.core.tree import NWTree +from novelwriter.core.project import NWProject @pytest.fixture(scope="function") @@ -196,7 +198,7 @@ def testCoreTree_BuildTree(mockGUI, mockItems): assert len(theTree) == len(mockItems) + 1 theList = theTree.handles() - nHandle = "0000000000010" + nHandle = "0000000000000" assert theList[-1] == nHandle # Try to add existing handle @@ -229,6 +231,36 @@ def testCoreTree_BuildTree(mockGUI, mockItems): # END Test testCoreTree_BuildTree +@pytest.mark.core +def testCoreTree_PackUnpack(mockGUI, mockItems): + """Test packing and unpacking data. + """ + theProject = NWProject(mockGUI) + theTree = NWTree(theProject) + + aHandles = [] + for tHandle, pHandle, nwItem in mockItems: + aHandles.append(tHandle) + theTree.append(tHandle, pHandle, nwItem) + theTree.updateItemData(tHandle) + + assert len(theTree) == len(mockItems) + + # Pack + tree = theTree.pack() + for i, (tHandle, pHandle, nwItem) in enumerate(mockItems): + assert tree[i]["itemAttr"]["handle"] == tHandle + + # Unpack + theTree.clear() + assert len(theTree) == 0 + assert theTree.handles() == [] + assert theTree.unpack(tree) is True + assert theTree.handles() == aHandles + +# END Test testCoreTree_PackUnpack + + @pytest.mark.core def testCoreTree_Methods(mockGUI, mockItems): """Test various class methods. @@ -271,6 +303,13 @@ def testCoreTree_Methods(mockGUI, mockItems): assert theTree.findRoot(nwItemClass.NOVEL) == "a000000000001" assert theTree.findRoot(nwItemClass.CHARACTER) == "a000000000004" + # Iter roots + roots = list(theTree.iterRoots(None)) + assert roots[0][0] == "a000000000001" + assert roots[1][0] == "a000000000002" + assert roots[2][0] == "a000000000003" + assert roots[3][0] == "a000000000004" + # Add a fake item to root and check that it can handle it theTree._treeRoots["0000000000000"] = NWItem(theProject) assert theTree.findRoot(nwItemClass.WORLD) is None @@ -362,7 +401,7 @@ def testCoreTree_Stats(mockGUI, mockItems): @pytest.mark.core -def testCoreTree_Reorder(mockGUI, mockItems): +def testCoreTree_Reorder(caplog, mockGUI, mockItems): """Test changing tree order. """ theProject = NWProject(mockGUI) @@ -383,76 +422,22 @@ def testCoreTree_Reorder(mockGUI, mockItems): theTree.setOrder(bHandle) assert theTree.handles() == bHandle + caplog.clear() theTree.setOrder(bHandle + ["stuff"]) assert theTree.handles() == bHandle + assert "Handle 'stuff' in new tree order is not in old order" in caplog.text + caplog.clear() theTree._treeOrder.append("stuff") theTree.setOrder(bHandle) assert theTree.handles() == bHandle + assert "Handle 'stuff' in old tree order is not in new order" in caplog.text # END Test testCoreTree_Reorder @pytest.mark.core -def testCoreTree_XMLPackUnpack(mockGUI, mockItems): - """Test packing and unpacking the tree to and from XML. - """ - theProject = NWProject(mockGUI) - theTree = NWTree(theProject) - - for tHandle, pHandle, nwItem in mockItems: - theTree.append(tHandle, pHandle, nwItem) - theTree.updateItemData(tHandle) - - assert len(theTree) == len(mockItems) - - nwXML = etree.Element("novelWriterXML") - theTree.packXML(nwXML) - assert etree.tostring(nwXML, pretty_print=False, encoding="utf-8") == ( - b'' - b'' - b'Novel' - b'Act One' - b'Chapter One' - b'Scene One' - b'Outtakes' - b'Trash' - b'Characters' - b'Jane Doe' - b'' - b'' - ) - - theTree.clear() - assert len(theTree) == 0 - assert not theTree.unpackXML(nwXML) - assert theTree.unpackXML(nwXML[0]) - assert len(theTree) == len(mockItems) - -# END Test testCoreTree_XMLPackUnpack - - -@pytest.mark.core -def testCoreTree_ToCFile(monkeypatch, mockGUI, mockItems, tmpDir): +def testCoreTree_ToCFile(monkeypatch, mockGUI, mockItems, tmpPath): """Test writing the ToC.txt file. """ theProject = NWProject(mockGUI) @@ -469,24 +454,28 @@ def testCoreTree_ToCFile(monkeypatch, mockGUI, mockItems, tmpDir): """Return True for items that are files in novelWriter and should thus also be files in the project folder structure. """ - dItem = theTree[fileName[8:21]] + dItem = theTree[fileName.name[:13]] assert dItem is not None return dItem.itemType == nwItemType.FILE - monkeypatch.setattr("os.path.isfile", mockIsFile) + monkeypatch.setattr("pathlib.Path.is_file", mockIsFile) - theProject.projContent = "content" - theProject.projPath = None - assert not theTree.writeToCFile() + theProject._storage._runtimePath = None + assert theTree.writeToCFile() is False - theProject.projPath = tmpDir - assert theTree.writeToCFile() + theProject._storage._runtimePath = tmpPath + with monkeypatch.context() as mp: + mp.setattr("builtins.open", causeOSError) + assert theTree.writeToCFile() is False - pathA = os.path.join("content", "c000000000001.nwd") - pathB = os.path.join("content", "c000000000002.nwd") - pathC = os.path.join("content", "b000000000002.nwd") + theProject._storage._runtimePath = tmpPath + assert theTree.writeToCFile() is True - assert readFile(os.path.join(tmpDir, nwFiles.TOC_TXT)) == ( + pathA = str(Path("content") / "c000000000001.nwd") + pathB = str(Path("content") / "c000000000002.nwd") + pathC = str(Path("content") / "b000000000002.nwd") + + assert readFile(tmpPath / nwFiles.TOC_TXT) == ( "\n" "Table of Contents\n" "=================\n" diff --git a/tests/test_dialogs/test_dlg_about.py b/tests/test_dialogs/test_dlg_about.py index a4623c1f..1b650ec1 100644 --- a/tests/test_dialogs/test_dlg_about.py +++ b/tests/test_dialogs/test_dlg_about.py @@ -21,20 +21,19 @@ along with this program. If not, see . import pytest +from pathlib import Path + from tools import getGuiItem from PyQt5.QtWidgets import QAction, QMessageBox -from novelwriter.dialogs import GuiAbout +from novelwriter.dialogs.about import GuiAbout @pytest.mark.gui def testDlgAbout_NWDialog(qtbot, monkeypatch, nwGUI): """Test the novelWriter about dialogs. """ - # Block message box - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - # NW About nwGUI.mainTheme.themeName = "A Theme" nwGUI.mainTheme.themeAuthor = "An Author" @@ -48,13 +47,12 @@ def testDlgAbout_NWDialog(qtbot, monkeypatch, nwGUI): assert msgAbout.pageNotes.document().characterCount() > 100 assert msgAbout.pageLicense.document().characterCount() > 100 - msgAbout.mainConf.assetPath = "whatever" - - msgAbout._fillNotesPage() - assert msgAbout.pageNotes.toPlainText() == "Error loading release notes text ..." - - msgAbout._fillLicensePage() - assert msgAbout.pageLicense.toPlainText() == "Error loading licence text ..." + with monkeypatch.context() as mp: + mp.setattr("novelwriter.config.Config.assetPath", lambda *a: Path("whatever")) + msgAbout._fillNotesPage() + assert msgAbout.pageNotes.toPlainText() == "Error loading release notes text ..." + msgAbout._fillLicensePage() + assert msgAbout.pageLicense.toPlainText() == "Error loading licence text ..." msgAbout.showReleaseNotes() assert msgAbout.tabBox.currentWidget() == msgAbout.pageNotes @@ -74,8 +72,6 @@ def testDlgAbout_NWDialog(qtbot, monkeypatch, nwGUI): def testDlgAbout_QtDialog(monkeypatch, nwGUI): """Test the Qt about dialogs. """ - # Block message box - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) monkeypatch.setattr(QMessageBox, "aboutQt", lambda *a, **k: None) # Open About diff --git a/tests/test_dialogs/test_dlg_dialogs.py b/tests/test_dialogs/test_dlg_dialogs.py index 3423beb5..772554ea 100644 --- a/tests/test_dialogs/test_dlg_dialogs.py +++ b/tests/test_dialogs/test_dlg_dialogs.py @@ -22,18 +22,17 @@ along with this program. If not, see . import pytest from PyQt5.QtCore import QItemSelectionModel -from PyQt5.QtWidgets import QAction, QListWidgetItem, QDialog, QMessageBox +from PyQt5.QtWidgets import QAction, QListWidgetItem, QDialog -from novelwriter.dialogs import GuiQuoteSelect, GuiUpdates, GuiEditLabel +from novelwriter.dialogs.quotes import GuiQuoteSelect +from novelwriter.dialogs.updates import GuiUpdates +from novelwriter.dialogs.editlabel import GuiEditLabel @pytest.mark.gui -def testDlgOther_QuoteSelect(qtbot, monkeypatch, nwGUI): +def testDlgOther_QuoteSelect(qtbot, nwGUI): """Test the quote symbols dialog. """ - # Block message box - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - nwQuot = GuiQuoteSelect(nwGUI) nwQuot.show() @@ -50,7 +49,7 @@ def testDlgOther_QuoteSelect(qtbot, monkeypatch, nwGUI): assert nwQuot.result() == QDialog.Accepted assert nwQuot.selectedQuote == lastItem - # qtbot.stopForInteraction() + # qtbot.stop() nwQuot._doReject() nwQuot.close() @@ -61,9 +60,6 @@ def testDlgOther_QuoteSelect(qtbot, monkeypatch, nwGUI): def testDlgOther_Updates(qtbot, monkeypatch, nwGUI): """Test the check for updates dialog. """ - # Block message box - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - nwUpdate = GuiUpdates(nwGUI) nwUpdate.show() @@ -93,7 +89,7 @@ def testDlgOther_Updates(qtbot, monkeypatch, nwGUI): # Trigger from Menu nwGUI.mainMenu.aUpdates.activate(QAction.Trigger) - # qtbot.stopForInteraction() + # qtbot.stop() nwUpdate._doClose() # END Test testDlgOther_Updates diff --git a/tests/test_dialogs/test_dlg_docmerge.py b/tests/test_dialogs/test_dlg_docmerge.py index 4e3138f6..134ed5a2 100644 --- a/tests/test_dialogs/test_dlg_docmerge.py +++ b/tests/test_dialogs/test_dlg_docmerge.py @@ -19,162 +19,70 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import os import pytest -from mock import causeOSError -from tools import getGuiItem, readFile, writeFile, buildTestProject +from tools import buildTestProject, C -from PyQt5.QtWidgets import QAction, QMessageBox +from PyQt5.QtCore import Qt -from novelwriter.enum import nwItemType, nwWidget -from novelwriter.dialogs import GuiDocMerge, GuiEditLabel -from novelwriter.core.tree import NWTree +from novelwriter.dialogs.docmerge import GuiDocMerge @pytest.mark.gui -def testDlgMerge_Main(qtbot, monkeypatch, nwGUI, fncProj, mockRnd): +def testDlgMerge_Main(qtbot, nwGUI, projPath, mockRnd): """Test the merge documents tool. """ - # Block message box - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "critical", lambda *a: QMessageBox.Ok) - monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True)) - # Create a new project - buildTestProject(nwGUI, fncProj) + buildTestProject(nwGUI, projPath) - # Handles for new objects - hNovelRoot = "0000000000008" - hChapterDir = "000000000000d" - hChapterOne = "000000000000e" - hSceneOne = "000000000000f" - hSceneTwo = "0000000000010" - hSceneThree = "0000000000011" - hSceneFour = "0000000000012" - hMergedDoc = "0000000000023" - - # Add Project Content - nwGUI.switchFocus(nwWidget.TREE) - nwGUI.projView.projTree.clearSelection() - nwGUI.projView.projTree._getTreeItem(hChapterDir).setSelected(True) - nwGUI.projView.projTree.newTreeItem(nwItemType.FILE) - nwGUI.projView.projTree.newTreeItem(nwItemType.FILE) - nwGUI.projView.projTree.newTreeItem(nwItemType.FILE) - - assert nwGUI.saveProject() is True - assert nwGUI.closeProject() is True - - tChapterOne = "## Chapter One\n\n% Chapter one comment\n" - tSceneOne = "### Scene One\n\nThere once was a man from Nantucket" - tSceneTwo = "### Scene Two\n\nWho kept all his cash in a bucket." - tSceneThree = "### Scene Three\n\n\tBut his daughter, named Nan, \n\tRan away with a man" - tSceneFour = "### Scene Four\n\nAnd as for the bucket, Nantucket." - - contentDir = os.path.join(fncProj, "content") - writeFile(os.path.join(contentDir, hChapterOne+".nwd"), tChapterOne) - writeFile(os.path.join(contentDir, hSceneOne+".nwd"), tSceneOne) - writeFile(os.path.join(contentDir, hSceneTwo+".nwd"), tSceneTwo) - writeFile(os.path.join(contentDir, hSceneThree+".nwd"), tSceneThree) - writeFile(os.path.join(contentDir, hSceneFour+".nwd"), tSceneFour) - - assert nwGUI.openProject(fncProj) is True - - # Open the Merge tool - nwGUI.switchFocus(nwWidget.TREE) - nwGUI.projView.projTree.clearSelection() - nwGUI.projView.projTree._getTreeItem(hChapterDir).setSelected(True) - - monkeypatch.setattr(GuiDocMerge, "exec_", lambda *a: None) - nwGUI.mainMenu.aMergeDocs.activate(QAction.Trigger) - qtbot.waitUntil(lambda: getGuiItem("GuiDocMerge") is not None, timeout=1000) - - nwMerge = getGuiItem("GuiDocMerge") - assert isinstance(nwMerge, GuiDocMerge) + # Check that the dialog kan handle invalid items + nwMerge = GuiDocMerge(nwGUI, C.hInvalid, [C.hInvalid]) + qtbot.addWidget(nwMerge) nwMerge.show() - qtbot.wait(50) - - # Populate List - # ============= - - nwMerge.listBox.clear() assert nwMerge.listBox.count() == 0 + nwMerge.reject() - # No item selected - nwGUI.projView.projTree.clearSelection() - assert nwMerge._populateList() is False - assert nwMerge.listBox.count() == 0 + # Load items from chapter dir + nwMerge = GuiDocMerge(nwGUI, C.hChapterDir, [C.hChapterDir, C.hChapterDoc, C.hSceneDoc]) + qtbot.addWidget(nwMerge) + nwMerge.show() - # Non-existing item - with monkeypatch.context() as mp: - mp.setattr(NWTree, "__getitem__", lambda *a: None) - nwGUI.projView.projTree.clearSelection() - nwGUI.projView.projTree._getTreeItem(hChapterDir).setSelected(True) - assert nwMerge._populateList() is False - assert nwMerge.listBox.count() == 0 + assert nwMerge.listBox.count() == 2 - # Select a non-folder - nwGUI.projView.projTree.clearSelection() - nwGUI.projView.projTree._getTreeItem(hChapterOne).setSelected(True) - assert nwMerge._populateList() is False - assert nwMerge.listBox.count() == 0 + itemOne = nwMerge.listBox.item(0) + itemTwo = nwMerge.listBox.item(1) - # Select the chapter folder - nwGUI.projView.projTree.clearSelection() - nwGUI.projView.projTree._getTreeItem(hChapterDir).setSelected(True) - assert nwMerge._populateList() is True - assert nwMerge.listBox.count() == 5 + assert itemOne.data(Qt.UserRole) == C.hChapterDoc + assert itemTwo.data(Qt.UserRole) == C.hSceneDoc - # Merge Documents - # =============== + assert itemOne.checkState() == Qt.Checked + assert itemTwo.checkState() == Qt.Checked - # First, a successful merge - with monkeypatch.context() as mp: - mp.setattr(GuiDocMerge, "_doClose", lambda *a: None) - assert nwMerge._doMerge() is True - assert nwGUI.saveProject() is True - mergedFile = os.path.join(contentDir, hMergedDoc+".nwd") - assert os.path.isfile(mergedFile) - assert readFile(mergedFile) == ( - "%%%%~name: New Chapter\n" - "%%%%~path: %s/%s\n" - "%%%%~kind: NOVEL/DOCUMENT\n" - "%s\n\n" - "%s\n\n" - "%s\n\n" - "%s\n\n" - "%s\n\n" - ) % ( - hNovelRoot, - hMergedDoc, - tChapterOne.strip(), - tSceneOne.strip(), - tSceneTwo.strip(), - tSceneThree.strip(), - tSceneFour.strip(), - ) + data = nwMerge.getData() + assert data["sHandle"] == C.hChapterDir + assert data["origItems"] == [C.hChapterDir, C.hChapterDoc, C.hSceneDoc] + assert data["moveToTrash"] is False + assert data["finalItems"] == [C.hChapterDoc, C.hSceneDoc] - # OS error - with monkeypatch.context() as mp: - mp.setattr("builtins.open", causeOSError) - assert nwMerge._doMerge() is False + # Uncheck second item and toggle trash switch + itemTwo.setCheckState(Qt.Unchecked) + nwMerge.trashSwitch.setChecked(True) - # Can't find the source item - with monkeypatch.context() as mp: - mp.setattr(NWTree, "__getitem__", lambda *a: None) - assert nwMerge._doMerge() is False + data = nwMerge.getData() + assert data["sHandle"] == C.hChapterDir + assert data["origItems"] == [C.hChapterDir, C.hChapterDoc, C.hSceneDoc] + assert data["moveToTrash"] is True + assert data["finalItems"] == [C.hChapterDoc] - # No source handle set - nwMerge.sourceItem = None - assert nwMerge._doMerge() is False + # Restore default values + nwMerge._resetList() - # No documents to merge - nwMerge.listBox.clear() - assert nwMerge._doMerge() is False + data = nwMerge.getData() + assert data["sHandle"] == C.hChapterDir + assert data["origItems"] == [C.hChapterDir, C.hChapterDoc, C.hSceneDoc] + assert data["moveToTrash"] is True + assert data["finalItems"] == [C.hChapterDoc, C.hSceneDoc] - # Close up - nwMerge._doClose() - - # qtbot.stopForInteraction() + # qtbot.stop() # END Test testDlgMerge_Main diff --git a/tests/test_dialogs/test_dlg_docsplit.py b/tests/test_dialogs/test_dlg_docsplit.py index d0201d6a..9eb296f2 100644 --- a/tests/test_dialogs/test_dlg_docsplit.py +++ b/tests/test_dialogs/test_dlg_docsplit.py @@ -19,233 +19,83 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import os import pytest -from mock import causeOSError -from tools import getGuiItem, readFile, writeFile, buildTestProject +from tools import C, buildTestProject -from PyQt5.QtWidgets import QAction, QMessageBox - -from novelwriter.enum import nwItemType, nwWidget -from novelwriter.dialogs import GuiDocSplit, GuiEditLabel -from novelwriter.core.tree import NWTree -from novelwriter.core.document import NWDoc +from novelwriter.dialogs.docsplit import GuiDocSplit +from novelwriter.dialogs.editlabel import GuiEditLabel @pytest.mark.gui -def testDlgSplit_Main(qtbot, monkeypatch, nwGUI, fncProj, mockRnd): +def testDlgSplit_Main(qtbot, monkeypatch, nwGUI, projPath, mockRnd): """Test the split document tool. """ - # Block message box - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "critical", lambda *a: QMessageBox.Ok) monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True)) # Create a new project - buildTestProject(nwGUI, fncProj) + buildTestProject(nwGUI, projPath) - # Handles for new objects - hNovelRoot = "0000000000008" - hChapterDir = "000000000000d" - hToSplit = "0000000000010" - hNewFolder = "0000000000021" - hPartition = "0000000000022" - hChapterOne = "0000000000023" - hSceneOne = "0000000000024" - hSceneTwo = "0000000000025" - hSceneThree = "0000000000026" - hSceneFour = "0000000000027" - hSceneFive = "0000000000028" + theProject = nwGUI.theProject + projTree = nwGUI.projView.projTree - # Add Project Content - nwGUI.switchFocus(nwWidget.TREE) - nwGUI.projView.projTree.clearSelection() - nwGUI.projView.projTree._getTreeItem(hNovelRoot).setSelected(True) - nwGUI.projView.projTree.newTreeItem(nwItemType.FILE) - - assert nwGUI.saveProject() is True - assert nwGUI.closeProject() is True - - tPartition = "# Nantucket" - tChapterOne = "## Chapter One\n\n% Chapter one comment" - tSceneOne = "### Scene One\n\nThere once was a man from Nantucket" - tSceneTwo = "### Scene Two\n\nWho kept all his cash in a bucket." - tSceneThree = "### Scene Three\n\n\tBut his daughter, named Nan, \n\tRan away with a man" - tSceneFour = "### Scene Four\n\nAnd as for the bucket, Nantucket." - tSceneFive = "#### The End\n\nend" - - tToSplit = ( - f"{tPartition}\n\n{tChapterOne}\n\n" - f"{tSceneOne}\n\n{tSceneTwo}\n\n" - f"{tSceneThree}\n\n{tSceneFour}\n\n" - f"{tSceneFive}\n\n" + docText = ( + "Text\n\n" + "##! Prologue\n\nText\n\n" + "## Chapter One\n\nText\n\n" + "### Scene One\n\nText\n\n" + "### Scene Two\n\nText\n\n" + "## Chapter Two\n\nText\n\n" + "### Scene Three\n\nText\n\n" + "### Scene Four\n\nText\n\n" + "#! New Title\n\nText\n\n" + "## New Chapter\n\nText\n\n" + "### New Scene\n\nText\n\n" + "#### New Section\n\nText\n\n" ) - contentDir = os.path.join(fncProj, "content") - writeFile(os.path.join(contentDir, hToSplit+".nwd"), tToSplit) + hSplitDoc = theProject.newFile("Split Doc", C.hNovelRoot) + theProject.writeNewFile(hSplitDoc, 1, True, docText) + projTree.revealNewTreeItem(hSplitDoc, nHandle=C.hNovelRoot, wordCount=True) - assert nwGUI.openProject(fncProj) is True + docText = f"# Split Doc\n\n{docText}" - # Open the Split tool - nwGUI.switchFocus(nwWidget.TREE) - nwGUI.projView.projTree.clearSelection() - nwGUI.projView.projTree._getTreeItem(hToSplit).setSelected(True) - - monkeypatch.setattr(GuiDocSplit, "exec_", lambda *a: None) - nwGUI.mainMenu.aSplitDoc.activate(QAction.Trigger) - qtbot.waitUntil(lambda: getGuiItem("GuiDocSplit") is not None, timeout=1000) - - nwSplit = getGuiItem("GuiDocSplit") - assert isinstance(nwSplit, GuiDocSplit) + nwSplit = GuiDocSplit(nwGUI, hSplitDoc) nwSplit.show() - qtbot.wait(50) + qtbot.addWidget(nwSplit) - # Populate List - # ============= + # By default, only up to level three headinsg should be listed + assert nwSplit.splitLevel.currentData() == 3 + assert nwSplit.listBox.count() == 11 - nwSplit.listBox.clear() - assert nwSplit.listBox.count() == 0 - - # No item selected - nwSplit.sourceItem = None - nwGUI.projView.projTree.clearSelection() - assert nwSplit._populateList() is False - assert nwSplit.listBox.count() == 0 - - # Non-existing item - with monkeypatch.context() as mp: - mp.setattr(NWTree, "__getitem__", lambda *a: None) - nwSplit.sourceItem = None - nwGUI.projView.projTree.clearSelection() - nwGUI.projView.projTree._getTreeItem(hToSplit).setSelected(True) - assert nwSplit._populateList() is False - assert nwSplit.listBox.count() == 0 - - # Select a non-file - nwSplit.sourceItem = None - nwGUI.projView.projTree.clearSelection() - nwGUI.projView.projTree._getTreeItem(hChapterDir).setSelected(True) - assert nwSplit._populateList() is False - assert nwSplit.listBox.count() == 0 - - # Error when reading documents - with monkeypatch.context() as mp: - mp.setattr(NWDoc, "readDocument", lambda *a: None) - nwSplit.sourceItem = hToSplit - assert nwSplit._populateList() is False - assert nwSplit.listBox.count() == 0 - - # Read properly, and check split levels - - # Level 1 - nwSplit.splitLevel.setCurrentIndex(0) - nwSplit.sourceItem = hToSplit - assert nwSplit._populateList() is True - assert nwSplit.listBox.count() == 1 - - # Level 2 - nwSplit.splitLevel.setCurrentIndex(1) - nwSplit.sourceItem = hToSplit - assert nwSplit._populateList() is True - assert nwSplit.listBox.count() == 2 - - # Level 3 - nwSplit.splitLevel.setCurrentIndex(2) - nwSplit.sourceItem = hToSplit - assert nwSplit._populateList() is True - assert nwSplit.listBox.count() == 6 - - # Level 4 + # Changing to level 4, should reload and add the last section nwSplit.splitLevel.setCurrentIndex(3) - nwSplit.sourceItem = hToSplit - assert nwSplit._populateList() is True - assert nwSplit.listBox.count() == 7 + assert nwSplit.listBox.count() == 12 - # Split Document - # ============== + data, text = nwSplit.getData() + assert text == docText.splitlines() + assert data["sHandle"] == hSplitDoc + assert data["spLevel"] == 4 + assert data["intoFolder"] is True + assert data["docHierarchy"] is True + assert data["headerList"][0] == (0, 1, "Split Doc") + assert data["headerList"][1] == (4, 2, "Prologue") + assert data["headerList"][2] == (8, 2, "Chapter One") + assert data["headerList"][3] == (12, 3, "Scene One") + assert data["headerList"][4] == (16, 3, "Scene Two") + assert data["headerList"][5] == (20, 2, "Chapter Two") + assert data["headerList"][6] == (24, 3, "Scene Three") + assert data["headerList"][7] == (28, 3, "Scene Four") + assert data["headerList"][8] == (32, 1, "New Title") + assert data["headerList"][9] == (36, 2, "New Chapter") + assert data["headerList"][10] == (40, 3, "New Scene") + assert data["headerList"][11] == (44, 4, "New Section") - # Test a proper split first - with monkeypatch.context() as mp: - mp.setattr(GuiDocSplit, "_doClose", lambda *a: None) - assert nwSplit._doSplit() is True - assert nwGUI.saveProject() + # Loading the dialog on a non-file item produces an empty list + nwSplit._loadContent(C.hNovelRoot) + assert nwSplit.listBox.count() == 0 - assert readFile(os.path.join(contentDir, hPartition+".nwd")) == ( - "%%%%~name: Nantucket\n" - "%%%%~path: %s/%s\n" - "%%%%~kind: NOVEL/DOCUMENT\n" - "%s\n\n" - ) % (hNewFolder, hPartition, tPartition) - - assert readFile(os.path.join(contentDir, hChapterOne+".nwd")) == ( - "%%%%~name: Chapter One\n" - "%%%%~path: %s/%s\n" - "%%%%~kind: NOVEL/DOCUMENT\n" - "%s\n\n" - ) % (hNewFolder, hChapterOne, tChapterOne) - - assert readFile(os.path.join(contentDir, hSceneOne+".nwd")) == ( - "%%%%~name: Scene One\n" - "%%%%~path: %s/%s\n" - "%%%%~kind: NOVEL/DOCUMENT\n" - "%s\n\n" - ) % (hNewFolder, hSceneOne, tSceneOne) - - assert readFile(os.path.join(contentDir, hSceneTwo+".nwd")) == ( - "%%%%~name: Scene Two\n" - "%%%%~path: %s/%s\n" - "%%%%~kind: NOVEL/DOCUMENT\n" - "%s\n\n" - ) % (hNewFolder, hSceneTwo, tSceneTwo) - - assert readFile(os.path.join(contentDir, hSceneThree+".nwd")) == ( - "%%%%~name: Scene Three\n" - "%%%%~path: %s/%s\n" - "%%%%~kind: NOVEL/DOCUMENT\n" - "%s\n\n" - ) % (hNewFolder, hSceneThree, tSceneThree) - - assert readFile(os.path.join(contentDir, hSceneFour+".nwd")) == ( - "%%%%~name: Scene Four\n" - "%%%%~path: %s/%s\n" - "%%%%~kind: NOVEL/DOCUMENT\n" - "%s\n\n" - ) % (hNewFolder, hSceneFour, tSceneFour) - - assert readFile(os.path.join(contentDir, hSceneFive+".nwd")) == ( - "%%%%~name: The End\n" - "%%%%~path: %s/%s\n" - "%%%%~kind: NOVEL/DOCUMENT\n" - "%s\n\n" - ) % (hNewFolder, hSceneFive, tSceneFive) - - # OS error - with monkeypatch.context() as mp: - mp.setattr("builtins.open", causeOSError) - assert nwSplit._doSplit() is False - - # Select to not split - with monkeypatch.context() as mp: - mp.setattr(QMessageBox, "question", lambda *a: QMessageBox.No) - assert nwSplit._doSplit() is False - - # Clear the list - nwSplit.listBox.clear() - assert nwSplit._doSplit() is False - - # Can't find sourcv item - with monkeypatch.context() as mp: - mp.setattr(NWTree, "__getitem__", lambda *a: None) - assert nwSplit._doSplit() is False - - # No source item set - nwSplit.sourceItem = None - assert nwSplit._doSplit() is False - - # Close - nwSplit._doClose() - - # qtbot.stopForInteraction() + nwSplit.reject() + # qtbot.stop() # END Test testDlgSplit_Main diff --git a/tests/test_dialogs/test_dlg_preferences.py b/tests/test_dialogs/test_dlg_preferences.py index aa33ba82..a5990d95 100644 --- a/tests/test_dialogs/test_dlg_preferences.py +++ b/tests/test_dialogs/test_dlg_preferences.py @@ -19,82 +19,68 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import os import pytest -import novelwriter from shutil import copyfile + from tools import cmpFiles, getGuiItem from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( - QDialogButtonBox, QDialog, QAction, QFileDialog, QFontDialog, QMessageBox + QDialogButtonBox, QDialog, QAction, QFileDialog, QFontDialog ) -from novelwriter.config import Config -from novelwriter.dialogs import GuiPreferences, GuiQuoteSelect +from novelwriter.dialogs.quotes import GuiQuoteSelect +from novelwriter.dialogs.preferences import GuiPreferences -keyDelay = 2 -typeDelay = 1 -stepDelay = 20 +KEY_DELAY = 1 @pytest.mark.gui -def testDlgPreferences_Main(qtbot, monkeypatch, fncDir, outDir, refDir): +def testDlgPreferences_Main(qtbot, monkeypatch, nwGUI, fncPath, tstPaths): """Test the load project wizard. """ - # Block message box - monkeypatch.setattr(QMessageBox, "warning", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "information", lambda *a: QMessageBox.Yes) - - # Must create a clean config and GUI object as the test-wide - # novelwriter.CONFIG object is created on import an can be tainted by other tests - confFile = os.path.join(fncDir, "novelwriter.conf") - if os.path.isfile(confFile): - os.unlink(confFile) - theConf = Config() - theConf.initConfig(fncDir, fncDir) - theConf.setLastPath("") - origConf = novelwriter.CONFIG - novelwriter.CONFIG = theConf - - nwGUI = novelwriter.main(["--testmode", "--config=%s" % fncDir, "--data=%s" % fncDir]) - qtbot.addWidget(nwGUI) - nwGUI.show() - qtbot.wait(stepDelay) - theConf = nwGUI.mainConf - assert theConf.confPath == fncDir + assert theConf._confPath == fncPath monkeypatch.setattr(GuiPreferences, "exec_", lambda *a: None) monkeypatch.setattr(GuiPreferences, "result", lambda *a: QDialog.Accepted) monkeypatch.setattr(nwGUI.docEditor.spEnchant, "listDictionaries", lambda: [("en", "none")]) - nwGUI.mainMenu.aPreferences.activate(QAction.Trigger) - qtbot.waitUntil(lambda: getGuiItem("GuiPreferences") is not None, timeout=1000) + with monkeypatch.context() as mp: + mp.setattr(GuiPreferences, "updateTheme", lambda *a: True) + mp.setattr(GuiPreferences, "updateSyntax", lambda *a: True) + mp.setattr(GuiPreferences, "needsRestart", lambda *a: True) + mp.setattr(GuiPreferences, "refreshTree", lambda *a: True) + nwGUI.mainMenu.aPreferences.activate(QAction.Trigger) + qtbot.waitUntil(lambda: getGuiItem("GuiPreferences") is not None, timeout=1000) nwPrefs = getGuiItem("GuiPreferences") assert isinstance(nwPrefs, GuiPreferences) nwPrefs.show() - assert nwPrefs.mainConf.confPath == fncDir + assert nwPrefs.mainConf._confPath == fncPath + + assert nwPrefs.updateTheme is False + assert nwPrefs.updateSyntax is False + assert nwPrefs.needsRestart is False + assert nwPrefs.refreshTree is False # General Settings - qtbot.wait(keyDelay) + qtbot.wait(KEY_DELAY) tabGeneral = nwPrefs.tabGeneral nwPrefs._tabBox.setCurrentWidget(tabGeneral) - qtbot.wait(keyDelay) + qtbot.wait(KEY_DELAY) assert tabGeneral.showFullPath.isChecked() qtbot.mouseClick(tabGeneral.showFullPath, Qt.LeftButton) assert not tabGeneral.showFullPath.isChecked() - qtbot.wait(keyDelay) + qtbot.wait(KEY_DELAY) assert not tabGeneral.hideVScroll.isChecked() qtbot.mouseClick(tabGeneral.hideVScroll, Qt.LeftButton) assert tabGeneral.hideVScroll.isChecked() - qtbot.wait(keyDelay) + qtbot.wait(KEY_DELAY) assert not tabGeneral.hideHScroll.isChecked() qtbot.mouseClick(tabGeneral.hideHScroll, Qt.LeftButton) assert tabGeneral.hideHScroll.isChecked() @@ -103,21 +89,21 @@ def testDlgPreferences_Main(qtbot, monkeypatch, fncDir, outDir, refDir): monkeypatch.setattr(QFontDialog, "getFont", lambda font, obj: (font, True)) qtbot.mouseClick(tabGeneral.fontButton, Qt.LeftButton) - qtbot.wait(keyDelay) + qtbot.wait(KEY_DELAY) tabGeneral.guiFontSize.setValue(12) # Projects Settings - qtbot.wait(keyDelay) + qtbot.wait(KEY_DELAY) tabProjects = nwPrefs.tabProjects nwPrefs._tabBox.setCurrentWidget(tabProjects) tabProjects.backupPath = "no/where" - qtbot.wait(keyDelay) + qtbot.wait(KEY_DELAY) assert not tabProjects.backupOnClose.isChecked() qtbot.mouseClick(tabProjects.backupOnClose, Qt.LeftButton) assert tabProjects.backupOnClose.isChecked() - # qtbot.stopForInteraction() + # qtbot.stop() # Check Browse button monkeypatch.setattr(QFileDialog, "getExistingDirectory", lambda *a, **k: "") @@ -125,129 +111,125 @@ def testDlgPreferences_Main(qtbot, monkeypatch, fncDir, outDir, refDir): monkeypatch.setattr(QFileDialog, "getExistingDirectory", lambda *a, **k: "some/dir") qtbot.mouseClick(tabProjects.backupGetPath, Qt.LeftButton) - qtbot.wait(keyDelay) + qtbot.wait(KEY_DELAY) tabProjects.autoSaveDoc.setValue(20) tabProjects.autoSaveProj.setValue(40) # Document Settings - qtbot.wait(keyDelay) + qtbot.wait(KEY_DELAY) tabDocs = nwPrefs.tabDocs nwPrefs._tabBox.setCurrentWidget(tabDocs) - qtbot.wait(keyDelay) + qtbot.wait(KEY_DELAY) qtbot.mouseClick(tabDocs.fontButton, Qt.LeftButton) - qtbot.wait(keyDelay) + qtbot.wait(KEY_DELAY) tabDocs.textSize.setValue(13) tabDocs.textWidth.setValue(700) tabDocs.focusWidth.setValue(900) tabDocs.textMargin.setValue(45) tabDocs.tabWidth.setValue(45) - qtbot.wait(keyDelay) + qtbot.wait(KEY_DELAY) assert not tabDocs.hideFocusFooter.isChecked() qtbot.mouseClick(tabDocs.hideFocusFooter, Qt.LeftButton) assert tabDocs.hideFocusFooter.isChecked() - qtbot.wait(keyDelay) + qtbot.wait(KEY_DELAY) assert not tabDocs.doJustify.isChecked() qtbot.mouseClick(tabDocs.doJustify, Qt.LeftButton) assert tabDocs.doJustify.isChecked() # Editor Settings - qtbot.wait(keyDelay) + qtbot.wait(KEY_DELAY) tabEditor = nwPrefs.tabEditor nwPrefs._tabBox.setCurrentWidget(tabEditor) - qtbot.wait(keyDelay) + qtbot.wait(KEY_DELAY) assert not tabEditor.showTabsNSpaces.isChecked() qtbot.mouseClick(tabEditor.showTabsNSpaces, Qt.LeftButton) assert tabEditor.showTabsNSpaces.isChecked() - qtbot.wait(keyDelay) + qtbot.wait(KEY_DELAY) assert not tabEditor.showLineEndings.isChecked() qtbot.mouseClick(tabEditor.showLineEndings, Qt.LeftButton) assert tabEditor.showLineEndings.isChecked() - qtbot.wait(keyDelay) + qtbot.wait(KEY_DELAY) assert not tabEditor.autoScroll.isChecked() qtbot.mouseClick(tabEditor.autoScroll, Qt.LeftButton) assert tabEditor.autoScroll.isChecked() - qtbot.wait(keyDelay) + qtbot.wait(KEY_DELAY) tabEditor.scrollPastEnd.setValue(0) - qtbot.wait(keyDelay) + qtbot.wait(KEY_DELAY) tabEditor.bigDocLimit.setValue(500) # Syntax Settings - qtbot.wait(keyDelay) + qtbot.wait(KEY_DELAY) tabSyntax = nwPrefs.tabSyntax nwPrefs._tabBox.setCurrentWidget(tabSyntax) - qtbot.wait(keyDelay) + qtbot.wait(KEY_DELAY) assert tabSyntax.highlightQuotes.isChecked() qtbot.mouseClick(tabSyntax.highlightQuotes, Qt.LeftButton) assert not tabSyntax.highlightQuotes.isChecked() - qtbot.wait(keyDelay) + qtbot.wait(KEY_DELAY) assert tabSyntax.highlightEmph.isChecked() qtbot.mouseClick(tabSyntax.highlightEmph, Qt.LeftButton) assert not tabSyntax.highlightEmph.isChecked() # Automation Settings - qtbot.wait(keyDelay) + qtbot.wait(KEY_DELAY) tabAuto = nwPrefs.tabAuto nwPrefs._tabBox.setCurrentWidget(tabAuto) - qtbot.wait(keyDelay) + qtbot.wait(KEY_DELAY) assert tabAuto.autoSelect.isChecked() qtbot.mouseClick(tabAuto.autoSelect, Qt.LeftButton) assert not tabAuto.autoSelect.isChecked() - qtbot.wait(keyDelay) + qtbot.wait(KEY_DELAY) assert tabAuto.doReplace.isChecked() qtbot.mouseClick(tabAuto.doReplace, Qt.LeftButton) assert not tabAuto.doReplace.isChecked() - qtbot.wait(keyDelay) + qtbot.wait(KEY_DELAY) assert not tabAuto.doReplaceSQuote.isEnabled() assert not tabAuto.doReplaceDQuote.isEnabled() assert not tabAuto.doReplaceDash.isEnabled() assert not tabAuto.doReplaceDots.isEnabled() # Quotation Style - qtbot.wait(keyDelay) + qtbot.wait(KEY_DELAY) tabQuote = nwPrefs.tabQuote nwPrefs._tabBox.setCurrentWidget(tabQuote) monkeypatch.setattr(GuiQuoteSelect, "selectedQuote", "'") - monkeypatch.setattr(GuiQuoteSelect, "exec_", lambda *args: QDialog.Accepted) + monkeypatch.setattr(GuiQuoteSelect, "exec_", lambda *a: QDialog.Accepted) qtbot.mouseClick(tabQuote.btnDoubleStyleC, Qt.LeftButton) # Save and Check Config qtbot.mouseClick(nwPrefs.buttonBox.button(QDialogButtonBox.Ok), Qt.LeftButton) nwPrefs._doClose() - assert theConf.confChanged - theConf.lastPath = "" - assert nwGUI.mainConf.saveConfig() - projFile = os.path.join(fncDir, "novelwriter.conf") - testFile = os.path.join(outDir, "guiPreferences_novelwriter.conf") - compFile = os.path.join(refDir, "guiPreferences_novelwriter.conf") + projFile = fncPath / "novelwriter.conf" + testFile = tstPaths.outDir / "guiPreferences_novelwriter.conf" + compFile = tstPaths.refDir / "guiPreferences_novelwriter.conf" copyfile(projFile, testFile) ignTuple = ( - "timestamp", "guifont", "lastnotes", "guilang", "geometry", - "preferences", "treecols", "novelcols", "projcols", "mainpane", - "docpane", "viewpane", "outlinepane", "textfont", "textsize" + "timestamp", "font", "lastnotes", "localisation", "geometry", + "preferences", "projcols", "mainpane", "docpane", "viewpane", + "outlinepane", "textfont", "textsize", "lastpath", "backuppath" ) assert cmpFiles(testFile, compFile, ignoreStart=ignTuple) # Clean up - novelwriter.CONFIG = origConf nwGUI.closeMain() - # qtbot.stopForInteraction() + # qtbot.stop() # END Test testDlgPreferences_Main diff --git a/tests/test_dialogs/test_dlg_projdetails.py b/tests/test_dialogs/test_dlg_projdetails.py index 16f73e1e..e8d6053c 100644 --- a/tests/test_dialogs/test_dlg_projdetails.py +++ b/tests/test_dialogs/test_dlg_projdetails.py @@ -23,38 +23,26 @@ import pytest from tools import getGuiItem -from PyQt5.QtWidgets import QAction, QMessageBox +from PyQt5.QtWidgets import QAction -from novelwriter.dialogs import GuiProjectDetails - -keyDelay = 2 -typeDelay = 1 -stepDelay = 20 +from novelwriter.dialogs.projdetails import GuiProjectDetails @pytest.mark.gui -def testDlgProjDetails_Dialog(qtbot, monkeypatch, nwGUI, nwLipsum): +def testDlgProjDetails_Dialog(qtbot, nwGUI, nwLipsum): """Test the project details dialog. """ - # Block message box - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "information", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "warning", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "critical", lambda *a: QMessageBox.Yes) - # Create a project to work on assert nwGUI.openProject(nwLipsum) assert nwGUI.rebuildIndex(beQuiet=True) qtbot.wait(100) # Open the Writing Stats dialog - nwGUI.mainConf.lastPath = "" nwGUI.mainMenu.aProjectDetails.activate(QAction.Trigger) qtbot.waitUntil(lambda: getGuiItem("GuiProjectDetails") is not None, timeout=1000) projDet = getGuiItem("GuiProjectDetails") assert isinstance(projDet, GuiProjectDetails) - qtbot.wait(stepDelay) # Overview Page # ============= @@ -66,7 +54,7 @@ def testDlgProjDetails_Dialog(qtbot, monkeypatch, nwGUI, nwLipsum): assert projDet.tabMain.wordCountVal.text() == f"{3000:n}" assert projDet.tabMain.chapCountVal.text() == f"{3:n}" assert projDet.tabMain.sceneCountVal.text() == f"{5:n}" - assert projDet.tabMain.revCountVal.text() == f"{nwGUI.theProject.saveCount:n}" + assert projDet.tabMain.revCountVal.text() == f"{nwGUI.theProject.data.saveCount:n}" assert projDet.tabMain.projPathVal.text() == nwLipsum @@ -105,7 +93,7 @@ def testDlgProjDetails_Dialog(qtbot, monkeypatch, nwGUI, nwLipsum): assert tocTree.topLevelItem(i).text(tocTab.C_PAGES) == thePages[i] assert tocTree.topLevelItem(i).text(tocTab.C_PAGE) == thePage[i] - # qtbot.stopForInteraction() + # qtbot.stop() # Clean Up projDet._doClose() diff --git a/tests/test_dialogs/test_dlg_projload.py b/tests/test_dialogs/test_dlg_projload.py index 6edb7a0d..d5019f11 100644 --- a/tests/test_dialogs/test_dlg_projload.py +++ b/tests/test_dialogs/test_dlg_projload.py @@ -20,35 +20,24 @@ along with this program. If not, see . """ import pytest -import os -from tools import getGuiItem +from tools import buildTestProject, getGuiItem from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( - QDialogButtonBox, QTreeWidgetItem, QDialog, QAction, QFileDialog, - QMessageBox + QDialogButtonBox, QTreeWidgetItem, QDialog, QAction, QFileDialog ) -from novelwriter.dialogs import GuiProjectLoad - -keyDelay = 2 -typeDelay = 1 -stepDelay = 20 +from novelwriter.dialogs.projload import GuiProjectLoad @pytest.mark.gui -def testDlgLoadProject_Main(qtbot, monkeypatch, nwGUI, nwMinimal): +def testDlgLoadProject_Main(qtbot, monkeypatch, nwGUI, projPath): """Test the load project wizard. """ - # Block message box - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "information", lambda *a: QMessageBox.Yes) - - assert nwGUI.openProject(nwMinimal) + buildTestProject(nwGUI, projPath) assert nwGUI.closeProject() - qtbot.wait(stepDelay) monkeypatch.setattr(GuiProjectLoad, "exec_", lambda *a: None) monkeypatch.setattr(GuiProjectLoad, "result", lambda *a: QDialog.Accepted) nwGUI.mainMenu.aOpenProject.activate(QAction.Trigger) @@ -58,22 +47,18 @@ def testDlgLoadProject_Main(qtbot, monkeypatch, nwGUI, nwMinimal): assert isinstance(nwLoad, GuiProjectLoad) nwLoad.show() - qtbot.wait(stepDelay) recentCount = nwLoad.listBox.topLevelItemCount() assert recentCount > 0 - qtbot.wait(stepDelay) selItem = nwLoad.listBox.topLevelItem(0) selPath = selItem.data(nwLoad.C_NAME, Qt.UserRole) assert isinstance(selItem, QTreeWidgetItem) - qtbot.wait(stepDelay) nwLoad.selPath.setText("") nwLoad.listBox.setCurrentItem(selItem) nwLoad._doSelectRecent() assert nwLoad.selPath.text() == selPath - qtbot.wait(stepDelay) qtbot.mouseClick(nwLoad.buttonBox.button(QDialogButtonBox.Open), Qt.LeftButton) assert nwLoad.openPath == selPath assert nwLoad.openState == nwLoad.OPEN_STATE @@ -81,38 +66,33 @@ def testDlgLoadProject_Main(qtbot, monkeypatch, nwGUI, nwMinimal): # Just create a new project load from scratch for the rest of the test del nwLoad - qtbot.wait(stepDelay) nwGUI.mainMenu.aOpenProject.activate(QAction.Trigger) qtbot.waitUntil(lambda: getGuiItem("GuiProjectLoad") is not None, timeout=1000) - qtbot.wait(stepDelay) nwLoad = getGuiItem("GuiProjectLoad") assert isinstance(nwLoad, GuiProjectLoad) nwLoad.show() - qtbot.wait(stepDelay) qtbot.mouseClick(nwLoad.buttonBox.button(QDialogButtonBox.Cancel), Qt.LeftButton) assert nwLoad.openPath is None assert nwLoad.openState == nwLoad.NONE_STATE - qtbot.wait(stepDelay) nwLoad.show() qtbot.mouseClick(nwLoad.newButton, Qt.LeftButton) assert nwLoad.openPath is None assert nwLoad.openState == nwLoad.NEW_STATE - qtbot.wait(stepDelay) nwLoad.show() nwLoad._doDeleteRecent() assert nwLoad.listBox.topLevelItemCount() == recentCount - 1 - getFile = os.path.join(nwMinimal, "nwProject.nwx") + getFile = str(projPath / "nwProject.nwx") monkeypatch.setattr(QFileDialog, "getOpenFileName", lambda *a, **k: (getFile, None)) qtbot.mouseClick(nwLoad.browseButton, Qt.LeftButton) - assert nwLoad.openPath == nwMinimal + assert nwLoad.openPath == projPath / "nwProject.nwx" assert nwLoad.openState == nwLoad.OPEN_STATE nwLoad.close() - # qtbot.stopForInteraction() + # qtbot.stop() # END Test testDlgLoadProject_Main diff --git a/tests/test_dialogs/test_dlg_projsettings.py b/tests/test_dialogs/test_dlg_projsettings.py index f193194a..2f51da30 100644 --- a/tests/test_dialogs/test_dlg_projsettings.py +++ b/tests/test_dialogs/test_dlg_projsettings.py @@ -19,56 +19,40 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import os import pytest -from shutil import copyfile -from tools import cmpFiles, getGuiItem, buildTestProject +from novelwriter.enum import nwItemType +from tools import C, getGuiItem, buildTestProject from PyQt5.QtGui import QColor from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QDialog, QAction, QMessageBox, QColorDialog +from PyQt5.QtWidgets import QDialog, QAction, QColorDialog -from novelwriter.dialogs import GuiProjectSettings +from novelwriter.dialogs.editlabel import GuiEditLabel +from novelwriter.dialogs.projsettings import GuiProjectSettings -keyDelay = 2 -typeDelay = 1 -stepDelay = 20 -statusKeys = ["s000000", "s000001", "s000002", "s000003"] -importKeys = ["i000004", "i000005", "i000006", "i000007"] +KEY_DELAY = 1 @pytest.mark.gui -def testDlgProjSettings_Dialog( - qtbot, monkeypatch, nwGUI, fncDir, fncProj, outDir, refDir, mockRnd -): - """Test the full project settings dialog. +def testDlgProjSettings_Dialog(qtbot, monkeypatch, nwGUI): + """Test the main dialog class. Saving settings is not tested in this + test, but are instead tested in the individual tab tests. """ - projFile = os.path.join(fncProj, "nwProject.nwx") - testFile = os.path.join(outDir, "guiProjSettings_Dialog_nwProject.nwx") - compFile = os.path.join(refDir, "guiProjSettings_Dialog_nwProject.nwx") - - # Block message box - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "critical", lambda *a: QMessageBox.Yes) + # Block the GUI blocking thread + monkeypatch.setattr(GuiProjectSettings, "exec_", lambda *a: None) + monkeypatch.setattr(GuiProjectSettings, "result", lambda *a: QDialog.Accepted) + monkeypatch.setattr(GuiProjectSettings, "spellChanged", lambda *a: True) # Check that we cannot open when there is no project nwGUI.mainMenu.aProjectSettings.activate(QAction.Trigger) assert getGuiItem("GuiProjectSettings") is None - # Create new project - buildTestProject(nwGUI, fncProj) - nwGUI.mainConf.backupPath = fncDir - - nwGUI.theProject.setSpellLang("en") - nwGUI.theProject.setBookAuthors("Jane Smith\nJohn Smith") - nwGUI.theProject.setAutoReplace({"A": "B", "C": "D"}) + # Pretend we have a project + nwGUI.hasProject = True + nwGUI.theProject.data.setSpellLang("en") # Get the dialog object - monkeypatch.setattr(GuiProjectSettings, "exec_", lambda *a: None) - monkeypatch.setattr(GuiProjectSettings, "result", lambda *a: QDialog.Accepted) - monkeypatch.setattr(nwGUI.docEditor.spEnchant, "listDictionaries", lambda: [("en", "none")]) - nwGUI.mainMenu.aProjectSettings.activate(QAction.Trigger) qtbot.waitUntil(lambda: getGuiItem("GuiProjectSettings") is not None, timeout=1000) @@ -77,83 +61,180 @@ def testDlgProjSettings_Dialog( projEdit.show() qtbot.addWidget(projEdit) + # Switch Tabs + projEdit._focusTab(GuiProjectSettings.TAB_REPLACE) + assert projEdit._tabBox.currentWidget() == projEdit.tabReplace + + projEdit._focusTab(GuiProjectSettings.TAB_IMPORT) + assert projEdit._tabBox.currentWidget() == projEdit.tabImport + + projEdit._focusTab(GuiProjectSettings.TAB_STATUS) + assert projEdit._tabBox.currentWidget() == projEdit.tabStatus + + projEdit._focusTab(GuiProjectSettings.TAB_MAIN) + assert projEdit._tabBox.currentWidget() == projEdit.tabMain + + # Clean Up + projEdit._doClose() + # qtbot.stop() + +# END Test testDlgProjSettings_Dialog + + +@pytest.mark.gui +def testDlgProjSettings_Main(qtbot, monkeypatch, nwGUI, fncPath, projPath, mockRnd): + """Test the main tab of the project settings dialog. + """ + # Mock components + monkeypatch.setattr(nwGUI.docEditor.spEnchant, "listDictionaries", lambda: [("en", "none")]) + + # Create new project + buildTestProject(nwGUI, projPath) + mockRnd.reset() + nwGUI.mainConf.backupPath = fncPath + + # Set some values + theProject = nwGUI.theProject + theProject.data.setSpellLang("en") + theProject.data.setAuthors("Jane Smith\nJohn Smith") + theProject.data.setAutoReplace({"A": "B", "C": "D"}) + + # Create Dialog + projSettings = GuiProjectSettings(nwGUI, GuiProjectSettings.TAB_MAIN) + projSettings.show() + qtbot.addWidget(projSettings) + # Settings Tab # ============ - assert projEdit.tabMain.editName.text() == "New Project" - assert projEdit.tabMain.editTitle.text() == "New Novel" - assert projEdit.tabMain.editAuthors.toPlainText() == "Jane Smith\nJohn Smith" - assert projEdit.tabMain.spellLang.currentData() == "en" - assert projEdit.tabMain.doBackup.isChecked() is False + tabMain = projSettings.tabMain - qtbot.wait(stepDelay) - projEdit.tabMain.editName.setText("") + assert tabMain.editName.text() == "New Project" + assert tabMain.editTitle.text() == "New Novel" + assert tabMain.editAuthors.toPlainText() == "Jane Smith\nJohn Smith" + assert tabMain.spellLang.currentData() == "en" + assert tabMain.doBackup.isChecked() is False + + tabMain.editName.setText("") for c in "Project Name": - qtbot.keyClick(projEdit.tabMain.editName, c, delay=typeDelay) - projEdit.tabMain.editTitle.setText("") + qtbot.keyClick(tabMain.editName, c, delay=KEY_DELAY) + tabMain.editTitle.setText("") for c in "Project Title": - qtbot.keyClick(projEdit.tabMain.editTitle, c, delay=typeDelay) + qtbot.keyClick(tabMain.editTitle, c, delay=KEY_DELAY) - projEdit.tabMain.editAuthors.clear() + tabMain.editAuthors.clear() for c in "Jane Doe": - qtbot.keyClick(projEdit.tabMain.editAuthors, c, delay=typeDelay) - qtbot.keyClick(projEdit.tabMain.editAuthors, Qt.Key_Return, delay=keyDelay) + qtbot.keyClick(tabMain.editAuthors, c, delay=KEY_DELAY) + qtbot.keyClick(tabMain.editAuthors, Qt.Key_Return, delay=KEY_DELAY) for c in "John Doh": - qtbot.keyClick(projEdit.tabMain.editAuthors, c, delay=typeDelay) - qtbot.keyClick(projEdit.tabMain.editAuthors, Qt.Key_Return, delay=keyDelay) + qtbot.keyClick(tabMain.editAuthors, c, delay=KEY_DELAY) + qtbot.keyClick(tabMain.editAuthors, Qt.Key_Return, delay=KEY_DELAY) - qtbot.wait(stepDelay) - assert projEdit.tabMain.editName.text() == "Project Name" - assert projEdit.tabMain.editTitle.text() == "Project Title" - assert projEdit.tabMain.editAuthors.toPlainText() == "Jane Doe\nJohn Doh\n" + assert tabMain.editName.text() == "Project Name" + assert tabMain.editTitle.text() == "Project Title" + assert tabMain.editAuthors.toPlainText() == "Jane Doe\nJohn Doh\n" + assert projSettings.spellChanged is False + + projSettings._doSave() + assert theProject.data.name == "Project Name" + assert theProject.data.title == "Project Title" + assert theProject.data.authors == ["Jane Doe", "John Doh"] + + # Clean up + projSettings._doClose() + # qtbot.stop() + +# END Test testDlgProjSettings_Main + + +@pytest.mark.gui +def testDlgProjSettings_StatusImport(qtbot, monkeypatch, nwGUI, fncPath, projPath, mockRnd): + """Test the status and importance tabs of the project settings + dialog. + """ + monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True)) + + # Mock components + monkeypatch.setattr(nwGUI.docEditor.spEnchant, "listDictionaries", lambda: [("en", "none")]) + + # Create new project + mockRnd.reset() + buildTestProject(nwGUI, projPath) + nwGUI.mainConf.backupPath = fncPath + + # Set some values + theProject = nwGUI.theProject + theProject.tree[C.hTitlePage].setStatus(C.sFinished) + theProject.tree[C.hChapterDoc].setStatus(C.sDraft) + theProject.tree[C.hSceneDoc].setStatus(C.sDraft) + + nwGUI.projView.projTree.setSelectedHandle(C.hPlotRoot) + nwGUI.projView.projTree.newTreeItem(nwItemType.FILE, hLevel=1, isNote=True) + nwGUI.projView.projTree.setSelectedHandle(C.hCharRoot) + nwGUI.projView.projTree.newTreeItem(nwItemType.FILE, hLevel=1, isNote=True) + nwGUI.projView.projTree.setSelectedHandle(C.hWorldRoot) + nwGUI.projView.projTree.newTreeItem(nwItemType.FILE, hLevel=1, isNote=True) + + hPlotNote = "0000000000010" + hCharNote = "0000000000011" + hWorldNote = "0000000000012" + + theProject.tree[hPlotNote].setImport(C.iMajor) + theProject.tree[hCharNote].setImport(C.iMajor) + theProject.tree[hWorldNote].setImport(C.iMain) + + # Create Dialog + projSettings = GuiProjectSettings(nwGUI, GuiProjectSettings.TAB_STATUS) + projSettings.show() + qtbot.addWidget(projSettings) # Status Tab # ========== - projEdit._tabBox.setCurrentWidget(projEdit.tabStatus) + tabStatus = projSettings.tabStatus - assert projEdit.tabStatus.colChanged is False - assert projEdit.tabStatus.getNewList() == ([], []) - assert projEdit.tabStatus.listBox.topLevelItemCount() == 4 + assert tabStatus.colChanged is False + assert tabStatus.getNewList() == ([], []) + assert tabStatus.listBox.topLevelItemCount() == 4 # Can't delete the first item (it's in use) - projEdit.tabStatus.listBox.clearSelection() - projEdit.tabStatus.listBox.topLevelItem(0).setSelected(True) - qtbot.mouseClick(projEdit.tabStatus.delButton, Qt.LeftButton) - assert projEdit.tabStatus.listBox.topLevelItemCount() == 4 + tabStatus.listBox.clearSelection() + tabStatus.listBox.setCurrentItem(tabStatus.listBox.topLevelItem(0)) + qtbot.mouseClick(tabStatus.delButton, Qt.LeftButton) + assert tabStatus.listBox.topLevelItemCount() == 4 - # Can delete the third item - projEdit.tabStatus.listBox.clearSelection() - projEdit.tabStatus.listBox.topLevelItem(2).setSelected(True) - qtbot.mouseClick(projEdit.tabStatus.delButton, Qt.LeftButton) - assert projEdit.tabStatus.listBox.topLevelItemCount() == 3 + # Can delete the second item + tabStatus.listBox.clearSelection() + tabStatus.listBox.setCurrentItem(tabStatus.listBox.topLevelItem(1)) + qtbot.mouseClick(tabStatus.delButton, Qt.LeftButton) + assert tabStatus.listBox.topLevelItemCount() == 3 # Add a new item - monkeypatch.setattr(QColorDialog, "getColor", lambda *a: QColor(20, 30, 40)) - qtbot.mouseClick(projEdit.tabStatus.addButton, Qt.LeftButton) - projEdit.tabStatus.listBox.topLevelItem(3).setSelected(True) - for n in range(8): - qtbot.keyClick(projEdit.tabStatus.editName, Qt.Key_Backspace, delay=typeDelay) - for c in "Final": - qtbot.keyClick(projEdit.tabStatus.editName, c, delay=typeDelay) - qtbot.mouseClick(projEdit.tabStatus.colButton, Qt.LeftButton) - qtbot.mouseClick(projEdit.tabStatus.saveButton, Qt.LeftButton) - assert projEdit.tabStatus.listBox.topLevelItemCount() == 4 - qtbot.wait(stepDelay) + with monkeypatch.context() as mp: + mp.setattr(QColorDialog, "getColor", lambda *a: QColor(20, 30, 40)) + qtbot.mouseClick(tabStatus.addButton, Qt.LeftButton) + tabStatus.listBox.setCurrentItem(tabStatus.listBox.topLevelItem(3)) + for _ in range(8): + qtbot.keyClick(tabStatus.editName, Qt.Key_Backspace, delay=KEY_DELAY) + for c in "Final": + qtbot.keyClick(tabStatus.editName, c, delay=KEY_DELAY) + qtbot.mouseClick(tabStatus.colButton, Qt.LeftButton) + qtbot.mouseClick(tabStatus.saveButton, Qt.LeftButton) + assert tabStatus.listBox.topLevelItemCount() == 4 - assert projEdit.tabStatus.colChanged is True - assert projEdit.tabStatus.getNewList() == ( + assert tabStatus.colChanged is True + assert tabStatus.getNewList() == ( [ { - "key": statusKeys[0], + "key": C.sNew, "name": "New", "cols": (100, 100, 100) }, { - "key": statusKeys[1], - "name": "Note", - "cols": (200, 50, 0) + "key": C.sDraft, + "name": "Draft", + "cols": (200, 150, 0) }, { - "key": statusKeys[3], + "key": C.sFinished, "name": "Finished", "cols": (50, 200, 0) }, { @@ -162,121 +243,198 @@ def testDlgProjSettings_Dialog( "cols": (20, 30, 40) } ], [ - statusKeys[2] # Deleted item + C.sNote # Deleted item ] ) - # Move items - projEdit.tabStatus.listBox.clearSelection() - projEdit.tabStatus._moveItem(1) - assert [x["key"] for x in projEdit.tabStatus.getNewList()[0]] == [ - statusKeys[0], statusKeys[1], statusKeys[3], None + # Move items, none selected -> no change + tabStatus.listBox.clearSelection() + tabStatus._moveItem(1) + assert [x["key"] for x in tabStatus.getNewList()[0]] == [ + C.sNew, C.sDraft, C.sFinished, None ] - projEdit.tabStatus.listBox.clearSelection() - projEdit.tabStatus.listBox.topLevelItem(0).setSelected(True) - projEdit.tabStatus._moveItem(-1) - assert [x["key"] for x in projEdit.tabStatus.getNewList()[0]] == [ - statusKeys[0], statusKeys[1], statusKeys[3], None + # Move items, first selected, move up -> no change + tabStatus.listBox.clearSelection() + tabStatus.listBox.setCurrentItem(tabStatus.listBox.topLevelItem(0)) + tabStatus._moveItem(-1) + assert [x["key"] for x in tabStatus.getNewList()[0]] == [ + C.sNew, C.sDraft, C.sFinished, None ] - projEdit.tabStatus.listBox.clearSelection() - projEdit.tabStatus.listBox.topLevelItem(3).setSelected(True) - projEdit.tabStatus._moveItem(-1) - assert [x["key"] for x in projEdit.tabStatus.getNewList()[0]] == [ - statusKeys[0], statusKeys[1], None, statusKeys[3] + # Move items, last selected, move up -> allowed + tabStatus.listBox.clearSelection() + tabStatus.listBox.setCurrentItem(tabStatus.listBox.topLevelItem(3)) + tabStatus._moveItem(-1) + assert [x["key"] for x in tabStatus.getNewList()[0]] == [ + C.sNew, C.sDraft, None, C.sFinished ] - projEdit.tabStatus._moveItem(1) - assert [x["key"] for x in projEdit.tabStatus.getNewList()[0]] == [ - statusKeys[0], statusKeys[1], statusKeys[3], None + + # Move items, same selected, move down -> allowed + tabStatus._moveItem(1) + assert [x["key"] for x in tabStatus.getNewList()[0]] == [ + C.sNew, C.sDraft, C.sFinished, None ] # Importance Tab # ============== - projEdit._tabBox.setCurrentWidget(projEdit.tabImport) - projEdit.tabStatus.listBox.clearSelection() - projEdit.tabImport.listBox.topLevelItem(3).setSelected(True) - qtbot.mouseClick(projEdit.tabImport.delButton, Qt.LeftButton) - qtbot.mouseClick(projEdit.tabImport.addButton, Qt.LeftButton) - projEdit.tabStatus.listBox.clearSelection() - projEdit.tabImport.listBox.topLevelItem(3).setSelected(True) - for n in range(8): - qtbot.keyClick(projEdit.tabImport.editName, Qt.Key_Backspace, delay=typeDelay) - for c in "Final": - qtbot.keyClick(projEdit.tabImport.editName, c, delay=typeDelay) - qtbot.mouseClick(projEdit.tabImport.saveButton, Qt.LeftButton) - qtbot.wait(stepDelay) + tabImport = projSettings.tabImport + projSettings._focusTab(GuiProjectSettings.TAB_IMPORT) + + # Delete unused entry + tabImport.listBox.clearSelection() + tabImport.listBox.setCurrentItem(tabImport.listBox.topLevelItem(1)) + qtbot.mouseClick(tabImport.delButton, Qt.LeftButton) + assert tabImport.listBox.topLevelItemCount() == 3 + + # Add a new entry + with monkeypatch.context() as mp: + mp.setattr(QColorDialog, "getColor", lambda *a: QColor(20, 30, 40)) + qtbot.mouseClick(tabImport.addButton, Qt.LeftButton) + tabImport.listBox.clearSelection() + tabImport.listBox.setCurrentItem(tabImport.listBox.topLevelItem(3)) + for _ in range(8): + qtbot.keyClick(tabImport.editName, Qt.Key_Backspace, delay=KEY_DELAY) + for c in "Final": + qtbot.keyClick(tabImport.editName, c, delay=KEY_DELAY) + qtbot.mouseClick(tabImport.colButton, Qt.LeftButton) + qtbot.mouseClick(tabImport.saveButton, Qt.LeftButton) + assert tabImport.listBox.topLevelItemCount() == 4 + + assert tabImport.colChanged is True + assert tabImport.getNewList() == ( + [ + { + "key": C.iNew, + "name": "New", + "cols": (100, 100, 100) + }, { + "key": C.iMajor, + "name": "Major", + "cols": (200, 150, 0) + }, { + "key": C.iMain, + "name": "Main", + "cols": (50, 200, 0) + }, { + "key": None, + "name": "Final", + "cols": (20, 30, 40) + } + ], [ + C.iMinor # Deleted item + ] + ) + + # Check Project + projSettings._doSave() + + statusItems = dict(theProject.data.itemStatus.items()) + assert statusItems[C.sNew]["name"] == "New" + assert statusItems[C.sDraft]["name"] == "Draft" + assert statusItems[C.sFinished]["name"] == "Finished" + assert statusItems["s000013"]["name"] == "Final" + + importItems = dict(theProject.data.itemImport.items()) + assert importItems[C.iNew]["name"] == "New" + assert importItems[C.iMajor]["name"] == "Major" + assert importItems[C.iMain]["name"] == "Main" + assert importItems["i000014"]["name"] == "Final" + + # Clean up + # qtbot.stop() + projSettings._doClose() + +# END Test testDlgProjSettings_StatusImport + + +@pytest.mark.gui +def testDlgProjSettings_Replace(qtbot, monkeypatch, nwGUI, fncPath, projPath, mockRnd): + """Test the auto-replace tab of the project settings dialog. + """ + monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True)) + + # Mock components + monkeypatch.setattr(nwGUI.docEditor.spEnchant, "listDictionaries", lambda: [("en", "none")]) + + # Create new project + mockRnd.reset() + buildTestProject(nwGUI, projPath) + nwGUI.mainConf.backupPath = fncPath + + # Set some values + theProject = nwGUI.theProject + theProject.data.setAutoReplace({ + "A": "B", "C": "D" + }) + + # Create Dialog + projSettings = GuiProjectSettings(nwGUI, GuiProjectSettings.TAB_REPLACE) + projSettings.show() + qtbot.addWidget(projSettings) # Auto-Replace Tab # ================ - qtbot.wait(stepDelay) - projEdit._tabBox.setCurrentWidget(projEdit.tabReplace) + tabReplace = projSettings.tabReplace - assert projEdit.tabReplace.listBox.topLevelItem(0).text(0) == "" - assert projEdit.tabReplace.listBox.topLevelItem(0).text(1) == "B" - assert projEdit.tabReplace.listBox.topLevelItem(1).text(0) == "" - assert projEdit.tabReplace.listBox.topLevelItem(1).text(1) == "D" + assert tabReplace.listBox.topLevelItem(0).text(0) == "" + assert tabReplace.listBox.topLevelItem(0).text(1) == "B" + assert tabReplace.listBox.topLevelItem(1).text(0) == "" + assert tabReplace.listBox.topLevelItem(1).text(1) == "D" + assert tabReplace.listBox.topLevelItemCount() == 2 - qtbot.mouseClick(projEdit.tabReplace.addButton, Qt.LeftButton) - projEdit.tabReplace.listBox.topLevelItem(2).setSelected(True) - projEdit.tabReplace.editKey.setText("") + # Nothing to save or delete + tabReplace.listBox.clearSelection() + assert tabReplace._saveEntry() is False + assert tabReplace._delEntry() is False + assert tabReplace.listBox.topLevelItemCount() == 2 + + # Create a new entry + qtbot.mouseClick(tabReplace.addButton, Qt.LeftButton) + assert tabReplace.listBox.topLevelItemCount() == 3 + assert tabReplace.listBox.topLevelItem(2).text(0) == "" + assert tabReplace.listBox.topLevelItem(2).text(1) == "" + + # Edit the entry + tabReplace.listBox.setCurrentItem(tabReplace.listBox.topLevelItem(2)) + tabReplace.editKey.setText("") for c in "Th is ": - qtbot.keyClick(projEdit.tabReplace.editKey, c, delay=typeDelay) - projEdit.tabReplace.editValue.setText("") + qtbot.keyClick(tabReplace.editKey, c, delay=KEY_DELAY) + tabReplace.editValue.setText("") for c in "With This Stuff ": - qtbot.keyClick(projEdit.tabReplace.editValue, c, delay=typeDelay) - qtbot.mouseClick(projEdit.tabReplace.saveButton, Qt.LeftButton) + qtbot.keyClick(tabReplace.editValue, c, delay=KEY_DELAY) + qtbot.mouseClick(tabReplace.saveButton, Qt.LeftButton) + assert tabReplace.listBox.topLevelItem(2).text(0) == "" + assert tabReplace.listBox.topLevelItem(2).text(1) == "With This Stuff " - qtbot.wait(stepDelay) - projEdit.tabReplace.listBox.clearSelection() - assert not projEdit.tabReplace._saveEntry() - assert not projEdit.tabReplace._delEntry() - qtbot.mouseClick(projEdit.tabReplace.addButton, Qt.LeftButton) + # Create a new entry again + tabReplace.listBox.clearSelection() + qtbot.mouseClick(tabReplace.addButton, Qt.LeftButton) + assert tabReplace.listBox.topLevelItemCount() == 4 + # The list is sorted, so we must find it newIdx = -1 - for i in range(projEdit.tabReplace.listBox.topLevelItemCount()): - if projEdit.tabReplace.listBox.topLevelItem(i).text(0) == "": + for i in range(tabReplace.listBox.topLevelItemCount()): + if tabReplace.listBox.topLevelItem(i).text(0) == "": newIdx = i break - assert newIdx >= 0 - newItem = projEdit.tabReplace.listBox.topLevelItem(newIdx) - projEdit.tabReplace.listBox.setCurrentItem(newItem) - qtbot.mouseClick(projEdit.tabReplace.delButton, Qt.LeftButton) - qtbot.wait(stepDelay) - # Save & Check - # ============ + # Then delete the new item + tabReplace.listBox.setCurrentItem(tabReplace.listBox.topLevelItem(newIdx)) + qtbot.mouseClick(tabReplace.delButton, Qt.LeftButton) + assert tabReplace.listBox.topLevelItemCount() == 3 - projEdit._doSave() + # Check Project + projSettings._doSave() + assert theProject.data.autoReplace == { + "A": "B", "C": "D", "This": "With This Stuff" + } - # Open again, and check project settings - nwGUI.mainMenu.aProjectSettings.activate(QAction.Trigger) - qtbot.waitUntil(lambda: getGuiItem("GuiProjectSettings") is not None, timeout=1000) + # Clean up + # qtbot.stop() + projSettings._doClose() - projEdit = getGuiItem("GuiProjectSettings") - assert isinstance(projEdit, GuiProjectSettings) - - qtbot.addWidget(projEdit) - assert projEdit.tabMain.editName.text() == "Project Name" - assert projEdit.tabMain.editTitle.text() == "Project Title" - theAuth = projEdit.tabMain.editAuthors.toPlainText().strip().splitlines() - assert len(theAuth) == 2 - assert theAuth[0] == "Jane Doe" - assert theAuth[1] == "John Doh" - - projEdit._doClose() - qtbot.wait(stepDelay) - - assert nwGUI.saveProject() - qtbot.wait(stepDelay) - - # Check the files - copyfile(projFile, testFile) - assert cmpFiles(testFile, compFile, [2, 8, 9, 10]) - - # qtbot.stopForInteraction() - -# END Test testDlgProjSettings_Dialog +# END Test testDlgProjSettings_Replace diff --git a/tests/test_dialogs/test_dlg_wordlist.py b/tests/test_dialogs/test_dlg_wordlist.py index d9993d4d..7f8d989c 100644 --- a/tests/test_dialogs/test_dlg_wordlist.py +++ b/tests/test_dialogs/test_dlg_wordlist.py @@ -19,38 +19,31 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import os import pytest from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QDialog, QMessageBox, QAction +from PyQt5.QtWidgets import QDialog, QAction -from tools import writeFile, readFile, getGuiItem +from tools import buildTestProject, writeFile, readFile, getGuiItem from mock import causeOSError -from novelwriter.dialogs import GuiWordList from novelwriter.constants import nwFiles - -keyDelay = 2 -typeDelay = 1 -stepDelay = 20 +from novelwriter.dialogs.wordlist import GuiWordList @pytest.mark.gui -def testDlgWordList_Dialog(qtbot, monkeypatch, nwGUI, nwMinimal): +def testDlgWordList_Dialog(qtbot, monkeypatch, nwGUI, projPath): """test the word list editor. """ - monkeypatch.setattr(QMessageBox, "information", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "critical", lambda *a: QMessageBox.Yes) + buildTestProject(nwGUI, projPath) + monkeypatch.setattr(GuiWordList, "exec_", lambda *a: None) monkeypatch.setattr(GuiWordList, "result", lambda *a: QDialog.Accepted) monkeypatch.setattr(GuiWordList, "accept", lambda *a: None) # Open project - nwGUI.openProject(nwMinimal) - qtbot.wait(stepDelay) - dictFile = os.path.join(nwMinimal, "meta", nwFiles.PROJ_DICT) + nwGUI.openProject(projPath) + dictFile = projPath / "meta" / nwFiles.PROJ_DICT # Load the dialog nwGUI.mainMenu.aEditWordList.activate(QAction.Trigger) @@ -59,7 +52,6 @@ def testDlgWordList_Dialog(qtbot, monkeypatch, nwGUI, nwMinimal): wList = getGuiItem("GuiWordList") assert isinstance(wList, GuiWordList) wList.show() - qtbot.wait(stepDelay) # List should be blank assert wList.listBox.count() == 0 @@ -73,7 +65,6 @@ def testDlgWordList_Dialog(qtbot, monkeypatch, nwGUI, nwMinimal): "word_f\n" "word_b\n" )) - qtbot.wait(stepDelay) assert wList._loadWordList() # Check that the content was loaded @@ -130,7 +121,7 @@ def testDlgWordList_Dialog(qtbot, monkeypatch, nwGUI, nwMinimal): monkeypatch.setattr("builtins.open", causeOSError) assert not wList._doSave() - # qtbot.stopForInteraction() + # qtbot.stop() wList._doClose() # END Test testDlgWordList_Dialog diff --git a/tests/test_gui/test_gui_doceditor.py b/tests/test_gui/test_gui_doceditor.py index d4a3d749..7f47fd60 100644 --- a/tests/test_gui/test_gui_doceditor.py +++ b/tests/test_gui/test_gui_doceditor.py @@ -22,36 +22,30 @@ along with this program. If not, see . import pytest from mock import causeOSError +from tools import C, buildTestProject from PyQt5.QtCore import Qt from PyQt5.QtGui import QTextBlock, QTextCursor, QTextOption -from PyQt5.QtWidgets import QAction, QMessageBox, qApp +from PyQt5.QtWidgets import QAction, qApp -from novelwriter.gui.doceditor import GuiDocEditor -from novelwriter.core import countWords from novelwriter.enum import nwDocAction, nwDocInsert, nwItemLayout from novelwriter.constants import nwKeyWords, nwUnicode +from novelwriter.core.index import countWords +from novelwriter.gui.doceditor import GuiDocEditor -keyDelay = 2 -typeDelay = 1 -stepDelay = 20 +KEY_DELAY = 1 @pytest.mark.gui -def testGuiEditor_Init(qtbot, monkeypatch, nwGUI, nwMinimal, ipsumText): +def testGuiEditor_Init(qtbot, nwGUI, projPath, ipsumText, mockRnd): """Test initialising the editor. """ - # Block message box - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "information", lambda *a: QMessageBox.Yes) - # Open project - assert nwGUI.openProject(nwMinimal) - assert nwGUI.openDocument("8c659a11cd429") + buildTestProject(nwGUI, projPath) + assert nwGUI.openDocument(C.hSceneDoc) nwGUI.docEditor.setText("### Lorem Ipsum\n\n%s" % ipsumText[0]) assert nwGUI.saveDocument() - qtbot.wait(stepDelay) # Check Defaults qDoc = nwGUI.docEditor.document() @@ -80,29 +74,22 @@ def testGuiEditor_Init(qtbot, monkeypatch, nwGUI, nwMinimal, ipsumText): assert nwGUI.docEditor.horizontalScrollBarPolicy() == Qt.ScrollBarAlwaysOff assert nwGUI.docEditor._typPadChar == nwUnicode.U_THNBSP - # qtbot.stopForInteraction() + # qtbot.stop() # END Test testGuiEditor_Init @pytest.mark.gui -def testGuiEditor_LoadText(qtbot, monkeypatch, caplog, nwGUI, nwMinimal, ipsumText): +def testGuiEditor_LoadText(qtbot, monkeypatch, caplog, nwGUI, projPath, ipsumText, mockRnd): """Test loading text into the editor. """ - # Block message box - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "critical", lambda *a: QMessageBox.Yes) - - # Open project - sHandle = "8c659a11cd429" - assert nwGUI.openProject(nwMinimal) is True - assert nwGUI.openDocument(sHandle) is True + buildTestProject(nwGUI, projPath) + assert nwGUI.openDocument(C.hSceneDoc) is True longText = "### Lorem Ipsum\n\n%s" % "\n\n".join(ipsumText*20) nwGUI.docEditor.replaceText(longText) assert nwGUI.saveDocument() is True assert nwGUI.closeDocument() is True - qtbot.wait(stepDelay) # Load Text # ========= @@ -113,11 +100,11 @@ def testGuiEditor_LoadText(qtbot, monkeypatch, caplog, nwGUI, nwMinimal, ipsumTe # Document too big with monkeypatch.context() as mp: mp.setattr("novelwriter.constants.nwConst.MAX_DOCSIZE", 100) - assert nwGUI.docEditor.loadText(sHandle) is False + assert nwGUI.docEditor.loadText(C.hSceneDoc) is False assert "The document you are trying to open is too big." in caplog.text # Regular open - assert nwGUI.docEditor.loadText(sHandle) is True + assert nwGUI.docEditor.loadText(C.hSceneDoc) is True assert nwGUI.docEditor._bigDoc is False # Reload too big text @@ -128,38 +115,31 @@ def testGuiEditor_LoadText(qtbot, monkeypatch, caplog, nwGUI, nwMinimal, ipsumTe # Big doc handling nwGUI.mainConf.bigDocLimit = 50 - assert nwGUI.docEditor.loadText(sHandle) is True + assert nwGUI.docEditor.loadText(C.hSceneDoc) is True assert nwGUI.docEditor._bigDoc is True # Regular open, with line number - assert nwGUI.docEditor.loadText(sHandle, tLine=4) is True + assert nwGUI.docEditor.loadText(C.hSceneDoc, tLine=4) is True cursPos = nwGUI.docEditor.getCursorPosition() assert nwGUI.docEditor.document().findBlock(cursPos).blockNumber() == 4 # Load empty document nwGUI.docEditor.replaceText("") assert nwGUI.saveDocument() is True - assert nwGUI.docEditor.loadText(sHandle) is True + assert nwGUI.docEditor.loadText(C.hSceneDoc) is True assert nwGUI.docEditor.toPlainText() == "" - # qtbot.stopForInteraction() + # qtbot.stop() # END Test testGuiEditor_LoadText @pytest.mark.gui -def testGuiEditor_SaveText(qtbot, monkeypatch, caplog, nwGUI, nwMinimal, ipsumText): +def testGuiEditor_SaveText(qtbot, monkeypatch, caplog, nwGUI, projPath, ipsumText, mockRnd): """Test saving text from the editor. """ - # Block message box - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "critical", lambda *a: QMessageBox.Yes) - - # Open project - sHandle = "8c659a11cd429" - assert nwGUI.openProject(nwMinimal) is True - assert nwGUI.openDocument(sHandle) is True - qtbot.wait(stepDelay) + buildTestProject(nwGUI, projPath) + assert nwGUI.openDocument(C.hSceneDoc) is True # Save Text # ========= @@ -176,7 +156,7 @@ def testGuiEditor_SaveText(qtbot, monkeypatch, caplog, nwGUI, nwMinimal, ipsumTe # Unkown handle nwGUI.docEditor._docHandle = "0123456789abcdef" assert nwGUI.docEditor.saveText() is False - nwGUI.docEditor._docHandle = sHandle + nwGUI.docEditor._docHandle = C.hSceneDoc # Cause error when saving with monkeypatch.context() as mp: @@ -185,35 +165,29 @@ def testGuiEditor_SaveText(qtbot, monkeypatch, caplog, nwGUI, nwMinimal, ipsumTe assert "Could not save document." in caplog.text # Change header level - assert nwGUI.theProject.tree[sHandle].itemLayout == nwItemLayout.DOCUMENT + assert nwGUI.theProject.tree[C.hSceneDoc].itemLayout == nwItemLayout.DOCUMENT nwGUI.docEditor.replaceText(longText[1:]) assert nwGUI.docEditor.saveText() is True - assert nwGUI.theProject.tree[sHandle].itemLayout == nwItemLayout.DOCUMENT + assert nwGUI.theProject.tree[C.hSceneDoc].itemLayout == nwItemLayout.DOCUMENT # Regular save assert nwGUI.docEditor.saveText() is True - # qtbot.stopForInteraction() + # qtbot.stop() # END Test testGuiEditor_SaveText @pytest.mark.gui -def testGuiEditor_MetaData(qtbot, monkeypatch, nwGUI, nwMinimal): +def testGuiEditor_MetaData(qtbot, nwGUI, projPath, mockRnd): """Test extracting various meta data and other values. """ - # Block message box - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "critical", lambda *a: QMessageBox.Yes) - - # Open project - sHandle = "8c659a11cd429" - assert nwGUI.openProject(nwMinimal) is True - assert nwGUI.openDocument(sHandle) is True - qtbot.wait(stepDelay) + buildTestProject(nwGUI, projPath) + assert nwGUI.openDocument(C.hSceneDoc) is True # Get Text - # Both methods should return the same result for line breaks, but not for spaces + # This should replace line and paragraph separators, but preserve + # non-breaking spaces. newText = ( "### New Scene\u2029\u2029" "Some\u2028text.\u2029" @@ -221,14 +195,10 @@ def testGuiEditor_MetaData(qtbot, monkeypatch, nwGUI, nwMinimal): ) assert nwGUI.docEditor.replaceText(newText) assert nwGUI.docEditor.getText() == "### New Scene\n\nSome\ntext.\nMore\u00a0text.\n" - verQtValue = nwGUI.mainConf.verQtValue - nwGUI.mainConf.verQtValue = 50800 - assert nwGUI.docEditor.getText() == "### New Scene\n\nSome\ntext.\nMore text.\n" - nwGUI.mainConf.verQtValue = verQtValue # Check Propertoes assert nwGUI.docEditor.docChanged() is True - assert nwGUI.docEditor.docHandle() == sHandle + assert nwGUI.docEditor.docHandle() == C.hSceneDoc assert nwGUI.docEditor.lastActive() > 0.0 assert nwGUI.docEditor.isEmpty() is False @@ -236,9 +206,9 @@ def testGuiEditor_MetaData(qtbot, monkeypatch, nwGUI, nwMinimal): assert nwGUI.docEditor.setCursorPosition(None) is False assert nwGUI.docEditor.setCursorPosition(10) is True assert nwGUI.docEditor.getCursorPosition() == 10 - assert nwGUI.theProject.tree[sHandle].cursorPos != 10 + assert nwGUI.theProject.tree[C.hSceneDoc].cursorPos != 10 nwGUI.docEditor.saveCursorPosition() - assert nwGUI.theProject.tree[sHandle].cursorPos == 10 + assert nwGUI.theProject.tree[C.hSceneDoc].cursorPos == 10 assert nwGUI.docEditor.setCursorLine(None) is False assert nwGUI.docEditor.setCursorLine(2) is True @@ -250,27 +220,20 @@ def testGuiEditor_MetaData(qtbot, monkeypatch, nwGUI, nwMinimal): nwGUI.docEditor.setDocumentChanged(True) assert nwGUI.docEditor._docChanged is True - # qtbot.stopForInteraction() + # qtbot.stop() # END Test testGuiEditor_MetaData @pytest.mark.gui -def testGuiEditor_Actions(qtbot, monkeypatch, nwGUI, nwMinimal, ipsumText): +def testGuiEditor_Actions(qtbot, nwGUI, projPath, ipsumText, mockRnd): """Test the document actions. This is not an extensive test of the action features, just that the actions are actually called. The various action features are tested when their respective functions are tested. """ - # Block message box - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "critical", lambda *a: QMessageBox.Yes) - - # Open project - sHandle = "8c659a11cd429" - assert nwGUI.openProject(nwMinimal) is True - assert nwGUI.openDocument(sHandle) is True - qtbot.wait(stepDelay) + buildTestProject(nwGUI, projPath) + assert nwGUI.openDocument(C.hSceneDoc) is True theText = "### A Scene\n\n%s" % "\n\n".join(ipsumText) assert nwGUI.docEditor.replaceText(theText) is True @@ -482,7 +445,7 @@ def testGuiEditor_Actions(qtbot, monkeypatch, nwGUI, nwMinimal, ipsumText): # No Document Handle nwGUI.docEditor._docHandle = None assert nwGUI.docEditor.docAction(nwDocAction.BLOCK_TXT) is False - nwGUI.docEditor._docHandle = sHandle + nwGUI.docEditor._docHandle = C.hSceneDoc # Wrong Action Type assert nwGUI.docEditor.docAction(None) is False @@ -490,24 +453,17 @@ def testGuiEditor_Actions(qtbot, monkeypatch, nwGUI, nwMinimal, ipsumText): # Unknown Action assert nwGUI.docEditor.docAction(nwDocAction.NO_ACTION) is False - # qtbot.stopForInteraction() + # qtbot.stop() # END Test testGuiEditor_Actions @pytest.mark.gui -def testGuiEditor_Insert(qtbot, monkeypatch, nwGUI, nwMinimal, ipsumText): +def testGuiEditor_Insert(qtbot, monkeypatch, nwGUI, projPath, ipsumText, mockRnd): """Test the document insert functions. """ - # Block message box - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "critical", lambda *a: QMessageBox.Yes) - - # Open project - sHandle = "8c659a11cd429" - assert nwGUI.openProject(nwMinimal) is True - assert nwGUI.openDocument(sHandle) is True - qtbot.wait(stepDelay) + buildTestProject(nwGUI, projPath) + assert nwGUI.openDocument(C.hSceneDoc) is True theText = "### A Scene\n\n%s" % "\n\n".join(ipsumText) assert nwGUI.docEditor.replaceText(theText) is True @@ -522,7 +478,7 @@ def testGuiEditor_Insert(qtbot, monkeypatch, nwGUI, nwMinimal, ipsumText): nwGUI.docEditor._docHandle = None assert nwGUI.docEditor.setCursorPosition(24) is True assert nwGUI.docEditor.insertText("Stuff") is False - nwGUI.docEditor._docHandle = sHandle + nwGUI.docEditor._docHandle = C.hSceneDoc # Insert String assert nwGUI.docEditor.setCursorPosition(24) is True @@ -580,24 +536,17 @@ def testGuiEditor_Insert(qtbot, monkeypatch, nwGUI, nwMinimal, ipsumText): "\n\n\n", "\n\n@pov: Jane\n@char: John\n\n", 1 ) - # qtbot.stopForInteraction() + # qtbot.stop() # END Test testGuiEditor_Insert @pytest.mark.gui -def testGuiEditor_TextManipulation(qtbot, monkeypatch, nwGUI, nwMinimal, ipsumText): +def testGuiEditor_TextManipulation(qtbot, monkeypatch, nwGUI, projPath, ipsumText, mockRnd): """Test the text manipulation functions. """ - # Block message box - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "critical", lambda *a: QMessageBox.Yes) - - # Open project - sHandle = "8c659a11cd429" - assert nwGUI.openProject(nwMinimal) is True - assert nwGUI.openDocument(sHandle) is True - qtbot.wait(stepDelay) + buildTestProject(nwGUI, projPath) + assert nwGUI.openDocument(C.hSceneDoc) is True theText = "### A Scene\n\n%s" % "\n\n".join(ipsumText) assert nwGUI.docEditor.replaceText(theText) is True @@ -794,24 +743,17 @@ def testGuiEditor_TextManipulation(qtbot, monkeypatch, nwGUI, nwMinimal, ipsumTe assert newPara[6] == twoBits[4] assert newPara[7] == " ".join(twoBits[5:]) - # qtbot.stopForInteraction() + # qtbot.stop() # END Test testGuiEditor_TextManipulation @pytest.mark.gui -def testGuiEditor_BlockFormatting(qtbot, monkeypatch, nwGUI, nwMinimal, ipsumText): +def testGuiEditor_BlockFormatting(qtbot, monkeypatch, nwGUI, projPath, ipsumText, mockRnd): """Test the block formatting function. """ - # Block message box - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "critical", lambda *a: QMessageBox.Yes) - - # Open project - sHandle = "8c659a11cd429" - assert nwGUI.openProject(nwMinimal) is True - assert nwGUI.openDocument(sHandle) is True - qtbot.wait(stepDelay) + buildTestProject(nwGUI, projPath) + assert nwGUI.openDocument(C.hSceneDoc) is True theText = "### A Scene\n\n%s" % "\n\n".join(ipsumText) assert nwGUI.docEditor.replaceText(theText) is True @@ -828,10 +770,6 @@ def testGuiEditor_BlockFormatting(qtbot, monkeypatch, nwGUI, nwMinimal, ipsumTex mp.setattr(QTextBlock, "isValid", lambda *a, **k: False) assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_TXT) is False - # Empty Block - assert nwGUI.docEditor.setCursorLine(1) is True - assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_TXT) is False - # Keyword assert nwGUI.docEditor.replaceText("@pov: Jane\n\n") is True assert nwGUI.docEditor.setCursorPosition(5) is True @@ -1118,24 +1056,17 @@ def testGuiEditor_BlockFormatting(qtbot, monkeypatch, nwGUI, nwMinimal, ipsumTex assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_COM) is True assert nwGUI.docEditor.getText() == "#### Title\n\n% The Text\n\n" - # qtbot.stopForInteraction() + # qtbot.stop() # END Test testGuiEditor_BlockFormatting @pytest.mark.gui -def testGuiEditor_Tags(qtbot, monkeypatch, nwGUI, nwMinimal, ipsumText): +def testGuiEditor_Tags(qtbot, nwGUI, projPath, ipsumText, mockRnd): """Test the document editor tags functionality. """ - # Block message box - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "critical", lambda *a: QMessageBox.Yes) - - # Open project - sHandle = "8c659a11cd429" - assert nwGUI.openProject(nwMinimal) is True - assert nwGUI.openDocument(sHandle) is True - qtbot.wait(stepDelay) + buildTestProject(nwGUI, projPath) + assert nwGUI.openDocument(C.hSceneDoc) is True # Create Scene theText = "### A Scene\n\n@char: Jane, John\n\n" + ipsumText[0] + "\n\n" @@ -1143,16 +1074,16 @@ def testGuiEditor_Tags(qtbot, monkeypatch, nwGUI, nwMinimal, ipsumText): # Create Character theText = "### Jane Doe\n\n@tag: Jane\n\n" + ipsumText[1] + "\n\n" - cHandle = nwGUI.theProject.newFile("Jane Doe", "afb3043c7b2b3") + cHandle = nwGUI.theProject.newFile("Jane Doe", C.hCharRoot) assert nwGUI.openDocument(cHandle) is True assert nwGUI.docEditor.replaceText(theText) is True assert nwGUI.saveDocument() is True - assert nwGUI.projView.revealNewTreeItem(cHandle) + assert nwGUI.projView.projTree.revealNewTreeItem(cHandle) nwGUI.docEditor.updateTagHighLighting() # Follow Tag # ========== - assert nwGUI.openDocument(sHandle) is True + assert nwGUI.openDocument(C.hSceneDoc) is True # Empty Block assert nwGUI.docEditor.setCursorLine(1) is True @@ -1184,19 +1115,15 @@ def testGuiEditor_Tags(qtbot, monkeypatch, nwGUI, nwMinimal, ipsumText): assert nwGUI.closeDocViewer() is True assert nwGUI.docViewer._docHandle is None - # qtbot.stopForInteraction() + # qtbot.stop() # END Test testGuiEditor_Tags @pytest.mark.gui -def testGuiEditor_WordCounters(qtbot, monkeypatch, caplog, nwGUI, nwMinimal, ipsumText): +def testGuiEditor_WordCounters(qtbot, monkeypatch, nwGUI, projPath, ipsumText, mockRnd): """Test saving text from the editor. """ - # Block message box - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "critical", lambda *a: QMessageBox.Yes) - class MockThreadPool: def __init__(self): @@ -1211,7 +1138,8 @@ def testGuiEditor_WordCounters(qtbot, monkeypatch, caplog, nwGUI, nwMinimal, ips nwGUI.threadPool = MockThreadPool() nwGUI.docEditor.wcTimerDoc.blockSignals(True) nwGUI.docEditor.wcTimerSel.blockSignals(True) - assert nwGUI.openProject(nwMinimal) is True + + buildTestProject(nwGUI, projPath) # Run on an empty document nwGUI.docEditor._runDocCounter() @@ -1225,11 +1153,9 @@ def testGuiEditor_WordCounters(qtbot, monkeypatch, caplog, nwGUI, nwMinimal, ips assert nwGUI.docEditor.docFooter.wordsText.text() == "Words: 0 (+0)" # Open a document and populate it - sHandle = "8c659a11cd429" - nwGUI.theProject.tree[sHandle]._initCount = 0 # Clear item's count - nwGUI.theProject.tree[sHandle]._wordCount = 0 # Clear item's count - assert nwGUI.openDocument(sHandle) is True - qtbot.wait(stepDelay) + nwGUI.theProject.tree[C.hSceneDoc]._initCount = 0 # Clear item's count + nwGUI.theProject.tree[C.hSceneDoc]._wordCount = 0 # Clear item's count + assert nwGUI.openDocument(C.hSceneDoc) is True theText = "\n\n".join(ipsumText) cC, wC, pC = countWords(theText) @@ -1252,16 +1178,14 @@ def testGuiEditor_WordCounters(qtbot, monkeypatch, caplog, nwGUI, nwMinimal, ips nwGUI.docEditor.wCounterDoc.run() # nwGUI.docEditor._updateDocCounts(cC, wC, pC) - qtbot.wait(stepDelay) - assert nwGUI.theProject.tree[sHandle]._charCount == cC - assert nwGUI.theProject.tree[sHandle]._wordCount == wC - assert nwGUI.theProject.tree[sHandle]._paraCount == pC + assert nwGUI.theProject.tree[C.hSceneDoc]._charCount == cC + assert nwGUI.theProject.tree[C.hSceneDoc]._wordCount == wC + assert nwGUI.theProject.tree[C.hSceneDoc]._paraCount == pC assert nwGUI.docEditor.docFooter.wordsText.text() == f"Words: {wC} (+{wC})" # Select all text assert nwGUI.docEditor.docFooter._docSelection is False nwGUI.docEditor.docAction(nwDocAction.SEL_ALL) - qtbot.wait(stepDelay) assert nwGUI.docEditor.docFooter._docSelection is True # Run the selection word counter @@ -1270,10 +1194,9 @@ def testGuiEditor_WordCounters(qtbot, monkeypatch, caplog, nwGUI, nwMinimal, ips nwGUI.docEditor.wCounterSel.run() # nwGUI.docEditor._updateSelCounts(cC, wC, pC) - qtbot.wait(stepDelay) assert nwGUI.docEditor.docFooter.wordsText.text() == f"Words: {wC} selected" - # qtbot.stopForInteraction() + # qtbot.stop() # END Test testGuiEditor_WordCounters @@ -1282,14 +1205,11 @@ def testGuiEditor_WordCounters(qtbot, monkeypatch, caplog, nwGUI, nwMinimal, ips def testGuiEditor_Search(qtbot, monkeypatch, nwGUI, nwLipsum): """Test the document editor search functionality. """ - # Block message box - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) monkeypatch.setattr(GuiDocEditor, "hasFocus", lambda *a: True) assert nwGUI.openProject(nwLipsum) is True assert nwGUI.openDocument("4c4f28287af27") is True origText = nwGUI.docEditor.getText() - qtbot.wait(stepDelay) # Select the Word "est" nwGUI.docEditor.setCursorPosition(630) @@ -1304,11 +1224,11 @@ def testGuiEditor_Search(qtbot, monkeypatch, nwGUI, nwLipsum): # Find next by enter key monkeypatch.setattr(nwGUI.docEditor.docSearch.searchBox, "hasFocus", lambda: True) - qtbot.keyClick(nwGUI.docEditor.docSearch.searchBox, Qt.Key_Return, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor.docSearch.searchBox, Qt.Key_Return, delay=KEY_DELAY) assert abs(nwGUI.docEditor.getCursorPosition() - 1284) < 3 # Find next by button - qtbot.mouseClick(nwGUI.docEditor.docSearch.searchButton, Qt.LeftButton, delay=keyDelay) + qtbot.mouseClick(nwGUI.docEditor.docSearch.searchButton, Qt.LeftButton, delay=KEY_DELAY) assert abs(nwGUI.docEditor.getCursorPosition() - 1498) < 3 # Activate loop search @@ -1326,14 +1246,14 @@ def testGuiEditor_Search(qtbot, monkeypatch, nwGUI, nwLipsum): assert nwGUI.docEditor.setCursorPosition(15) # Toggle search again with header button - qtbot.mouseClick(nwGUI.docEditor.docHeader.searchButton, Qt.LeftButton, delay=keyDelay) + qtbot.mouseClick(nwGUI.docEditor.docHeader.searchButton, Qt.LeftButton, delay=KEY_DELAY) assert nwGUI.docEditor.docSearch.setSearchText("") assert nwGUI.docEditor.docSearch.isVisible() is True # Search for non-existing nwGUI.docEditor.setCursorPosition(0) assert nwGUI.docEditor.docSearch.setSearchText("abcdef") - qtbot.mouseClick(nwGUI.docEditor.docSearch.searchButton, Qt.LeftButton, delay=keyDelay) + qtbot.mouseClick(nwGUI.docEditor.docSearch.searchButton, Qt.LeftButton, delay=KEY_DELAY) assert nwGUI.docEditor.getCursorPosition() < 3 # No result # Enable RegEx search @@ -1344,19 +1264,19 @@ def testGuiEditor_Search(qtbot, monkeypatch, nwGUI, nwLipsum): # Set invalid RegEx nwGUI.docEditor.setCursorPosition(0) assert nwGUI.docEditor.docSearch.setSearchText(r"\bSus[") - qtbot.mouseClick(nwGUI.docEditor.docSearch.searchButton, Qt.LeftButton, delay=keyDelay) + qtbot.mouseClick(nwGUI.docEditor.docSearch.searchButton, Qt.LeftButton, delay=KEY_DELAY) assert nwGUI.docEditor.getCursorPosition() < 3 # No result # Set dangerous RegEx (issue #1015) # If this doesn't get caught, the app will hang nwGUI.docEditor.setCursorPosition(0) assert nwGUI.docEditor.docSearch.setSearchText(r".*") - qtbot.mouseClick(nwGUI.docEditor.docSearch.searchButton, Qt.LeftButton, delay=keyDelay) + qtbot.mouseClick(nwGUI.docEditor.docSearch.searchButton, Qt.LeftButton, delay=KEY_DELAY) assert abs(nwGUI.docEditor.getCursorPosition() - 14) < 3 # Set valid RegEx assert nwGUI.docEditor.docSearch.setSearchText(r"\bSus") - qtbot.mouseClick(nwGUI.docEditor.docSearch.searchButton, Qt.LeftButton, delay=keyDelay) + qtbot.mouseClick(nwGUI.docEditor.docSearch.searchButton, Qt.LeftButton, delay=KEY_DELAY) assert abs(nwGUI.docEditor.getCursorPosition() - 208) < 3 # Find next and then prev @@ -1407,7 +1327,7 @@ def testGuiEditor_Search(qtbot, monkeypatch, nwGUI, nwLipsum): assert abs(nwGUI.docEditor.getCursorPosition() - 208) < 3 # Replace "sus" with "foo" via replace button - qtbot.mouseClick(nwGUI.docEditor.docSearch.replaceButton, Qt.LeftButton, delay=keyDelay) + qtbot.mouseClick(nwGUI.docEditor.docSearch.replaceButton, Qt.LeftButton, delay=KEY_DELAY) assert nwGUI.docEditor.getText()[205:213] == "foocipit" # Revert last two replaces @@ -1485,6 +1405,33 @@ def testGuiEditor_Search(qtbot, monkeypatch, nwGUI, nwLipsum): monkeypatch.setattr(nwGUI.docEditor.docSearch.replaceBox, "hasFocus", lambda: True) assert nwGUI.docEditor.focusNextPrevChild(True) is True - # qtbot.stopForInteraction() + # qtbot.stop() # END Test testGuiEditor_Search + + +@pytest.mark.gui +def testGuiEditor_StaticMethods(): + """Test the document editor's static methods. + """ + # Check the method that decides if it is allowed to insert a space + # before a colon using the French, Spanish, etc language feature + assert GuiDocEditor._allowSpaceBeforeColon("", "") is True + assert GuiDocEditor._allowSpaceBeforeColon("", ":") is True + assert GuiDocEditor._allowSpaceBeforeColon("some text", ":") is True + + assert GuiDocEditor._allowSpaceBeforeColon("@:", ":") is False + assert GuiDocEditor._allowSpaceBeforeColon("@>", ">") is True + + assert GuiDocEditor._allowSpaceBeforeColon("%", ":") is True + assert GuiDocEditor._allowSpaceBeforeColon("%:", ":") is True + assert GuiDocEditor._allowSpaceBeforeColon("%synopsis:", ":") is False + assert GuiDocEditor._allowSpaceBeforeColon("%Synopsis:", ":") is False + assert GuiDocEditor._allowSpaceBeforeColon("% synopsis:", ":") is False + assert GuiDocEditor._allowSpaceBeforeColon("% Synopsis:", ":") is False + assert GuiDocEditor._allowSpaceBeforeColon("% synopsis:", ":") is False + assert GuiDocEditor._allowSpaceBeforeColon("% Synopsis:", ":") is False + assert GuiDocEditor._allowSpaceBeforeColon("%synopsis :", ":") is True + assert GuiDocEditor._allowSpaceBeforeColon("%Synopsis :", ":") is True + +# END Test testGuiEditor_StaticMethods diff --git a/tests/test_gui/test_gui_docviewer.py b/tests/test_gui/test_gui_docviewer.py index 2226a4c3..22057e15 100644 --- a/tests/test_gui/test_gui_docviewer.py +++ b/tests/test_gui/test_gui_docviewer.py @@ -23,25 +23,17 @@ import pytest from PyQt5.QtCore import Qt, QUrl from PyQt5.QtGui import QTextCursor -from PyQt5.QtWidgets import qApp, QAction, QMessageBox +from PyQt5.QtWidgets import qApp, QAction from mock import causeException from novelwriter.enum import nwDocAction -from novelwriter.core import ToHtml - -keyDelay = 2 -typeDelay = 1 -stepDelay = 20 +from novelwriter.core.tohtml import ToHtml @pytest.mark.gui def testGuiViewer_Main(qtbot, monkeypatch, nwGUI, nwLipsum): """Test the document viewer. """ - # Block message box - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "information", lambda *a: QMessageBox.Yes) - # Open project assert nwGUI.openProject(nwLipsum) @@ -184,6 +176,6 @@ def testGuiViewer_Main(qtbot, monkeypatch, nwGUI, nwLipsum): assert nwGUI.docViewer.loadText("846352075de7d") is False assert nwGUI.docViewer.toPlainText() == "An error occurred while generating the preview." - # qtbot.stopForInteraction() + # qtbot.stop() # END Test testGuiViewer_Main diff --git a/tests/test_gui/test_gui_guimain.py b/tests/test_gui/test_gui_guimain.py index 6407b84b..9d4f2d02 100644 --- a/tests/test_gui/test_gui_guimain.py +++ b/tests/test_gui/test_gui_guimain.py @@ -19,32 +19,33 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import os import pytest from shutil import copyfile -from tools import cmpFiles, buildTestProject, XML_IGNORE, writeFile + +from tools import ( + C, cmpFiles, buildTestProject, XML_IGNORE, getGuiItem, writeFile +) from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QMessageBox, QInputDialog +from PyQt5.QtWidgets import QDialog, QMessageBox, QInputDialog -from novelwriter.gui import GuiDocEditor, GuiNovelView, GuiOutlineView from novelwriter.enum import nwItemType, nwView, nwWidget from novelwriter.tools import GuiProjectWizard +from novelwriter.dialogs import GuiEditLabel, GuiAbout, GuiProjectLoad +from novelwriter.constants import nwFiles +from novelwriter.gui.outline import GuiOutlineView from novelwriter.gui.projtree import GuiProjectTree -from novelwriter.dialogs import GuiEditLabel +from novelwriter.gui.doceditor import GuiDocEditor +from novelwriter.gui.noveltree import GuiNovelView -keyDelay = 2 -typeDelay = 1 -stepDelay = 20 +KEY_DELAY = 1 @pytest.mark.gui -def testGuiMain_ProjectBlocker(monkeypatch, nwGUI): +def testGuiMain_ProjectBlocker(nwGUI): """Test the blocking of features when there's no project open. """ - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - # Test no-project blocking assert nwGUI.closeProject() is True assert nwGUI.saveProject() is False @@ -54,8 +55,6 @@ def testGuiMain_ProjectBlocker(monkeypatch, nwGUI): assert nwGUI.saveDocument() is False assert nwGUI.viewDocument(None) is False assert nwGUI.importDocument() is False - assert nwGUI.mergeDocuments() is False - assert nwGUI.splitDocument() is False assert nwGUI.openSelectedItem() is False assert nwGUI.editItemLabel() is False assert nwGUI.requestNovelTreeRefresh() is False @@ -66,16 +65,45 @@ def testGuiMain_ProjectBlocker(monkeypatch, nwGUI): assert nwGUI.showProjectWordListDialog() is False assert nwGUI.showWritingStatsDialog() is False -# END Test testGuiMain_NoProject +# END Test testGuiMain_ProjectBlocker @pytest.mark.gui -def testGuiMain_NewProject(monkeypatch, nwGUI, fncProj): +def testGuiMain_Launch(qtbot, monkeypatch, nwGUI, prjLipsum): + """Test the handling of launch tasks. + """ + monkeypatch.setattr(GuiProjectLoad, "exec_", lambda *a: None) + monkeypatch.setattr(GuiProjectLoad, "result", lambda *a: QDialog.Accepted) + nwGUI.mainConf.lastNotes = "0x0" + + # Open Lipsum project + nwGUI.postLaunchTasks(prjLipsum) + nwGUI.closeProject() + + # Check that release notes opened + qtbot.waitUntil(lambda: getGuiItem("GuiAbout") is not None, timeout=1000) + msgAbout = getGuiItem("GuiAbout") + assert isinstance(msgAbout, GuiAbout) + assert msgAbout.tabBox.currentWidget() == msgAbout.pageNotes + msgAbout.accept() + + # Check that project open dialog launches + nwGUI.postLaunchTasks(None) + qtbot.waitUntil(lambda: getGuiItem("GuiProjectLoad") is not None, timeout=1000) + nwLoad = getGuiItem("GuiProjectLoad") + assert isinstance(nwLoad, GuiProjectLoad) + nwLoad.show() + nwLoad.reject() + + # qtbot.stop() + +# END Test testGuiMain_Launch + + +@pytest.mark.gui +def testGuiMain_NewProject(monkeypatch, nwGUI, projPath): """Test creating a new project. """ - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "critical", lambda *a: QMessageBox.Yes) - # No data with monkeypatch.context() as mp: mp.setattr(GuiProjectWizard, "exec_", lambda *a: None) @@ -85,36 +113,34 @@ def testGuiMain_NewProject(monkeypatch, nwGUI, fncProj): with monkeypatch.context() as mp: nwGUI.hasProject = True mp.setattr(QMessageBox, "question", lambda *a: QMessageBox.No) - assert nwGUI.newProject(projData={"projPath": fncProj}) is False + assert nwGUI.newProject(projData={"projPath": projPath}) is False # No project path assert nwGUI.newProject(projData={}) is False # Project file already exists - projFile = os.path.join(fncProj, nwGUI.theProject.projFile) + projFile = projPath / nwFiles.PROJ_FILE writeFile(projFile, "Stuff") - assert nwGUI.newProject(projData={"projPath": fncProj}) is False - os.unlink(projFile) + assert nwGUI.newProject(projData={"projPath": projPath}) is False + projFile.unlink() # An unreachable path should also fail - projPath = os.path.join(fncProj, "stuff", "stuff", "stuff") - assert nwGUI.newProject(projData={"projPath": projPath}) is False + stuffPath = projPath / "stuff" / "stuff" / "stuff" + assert nwGUI.newProject(projData={"projPath": stuffPath}) is False # This one should work just fine - assert nwGUI.newProject(projData={"projPath": fncProj}) is True - assert os.path.isfile(os.path.join(fncProj, nwGUI.theProject.projFile)) - assert os.path.isdir(os.path.join(fncProj, "content")) + assert nwGUI.newProject(projData={"projPath": projPath}) is True + assert (projPath / nwFiles.PROJ_FILE).is_file() + assert (projPath / "content").is_dir() # END Test testGuiMain_NewProject @pytest.mark.gui -def testGuiMain_ProjectTreeItems(qtbot, monkeypatch, nwGUI, fncProj, mockRnd): +def testGuiMain_ProjectTreeItems(qtbot, monkeypatch, nwGUI, projPath, mockRnd): """Test handling of project tree items based on GUI focus states. """ - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - - buildTestProject(nwGUI, fncProj) + buildTestProject(nwGUI, projPath) sHandle = "000000000000f" assert nwGUI.openSelectedItem() is False @@ -149,9 +175,7 @@ def testGuiMain_ProjectTreeItems(qtbot, monkeypatch, nwGUI, fncProj, mockRnd): with monkeypatch.context() as mp: mp.setattr(GuiOutlineView, "treeHasFocus", lambda *a: True) assert nwGUI.docEditor.docHandle() is None - actItem = nwGUI.outlineView.outlineTree.topLevelItem(0) - chpItem = actItem.child(0) - selItem = chpItem.child(0) + selItem = nwGUI.outlineView.outlineTree.topLevelItem(2) nwGUI.outlineView.outlineTree.setCurrentItem(selItem) nwGUI._keyPressReturn() assert nwGUI.docEditor.docHandle() == sHandle @@ -163,19 +187,16 @@ def testGuiMain_ProjectTreeItems(qtbot, monkeypatch, nwGUI, fncProj, mockRnd): @pytest.mark.gui -def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, fncProj, refDir, outDir, mockRnd): +def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd): """Test the document editor. """ - # Block message box - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "information", lambda *a: QMessageBox.Yes) monkeypatch.setattr(GuiProjectTree, "hasFocus", lambda *a: True) monkeypatch.setattr(GuiDocEditor, "hasFocus", lambda *a: True) monkeypatch.setattr(QInputDialog, "getText", lambda *a, text: (text, True)) monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True)) # Create new, save, close project - buildTestProject(nwGUI, fncProj) + buildTestProject(nwGUI, projPath) assert nwGUI.saveProject() assert nwGUI.closeProject() @@ -183,50 +204,40 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, fncProj, refDir, outDir, mock assert len(nwGUI.theProject.tree._treeOrder) == 0 assert len(nwGUI.theProject.tree._treeRoots) == 0 assert nwGUI.theProject.tree.trashRoot() is None - assert nwGUI.theProject.projPath is None - assert nwGUI.theProject.projMeta is None - assert nwGUI.theProject.projFile == "nwProject.nwx" - assert nwGUI.theProject.projName == "" - assert nwGUI.theProject.bookTitle == "" - assert len(nwGUI.theProject.bookAuthors) == 0 - assert not nwGUI.theProject.spellCheck + assert nwGUI.theProject.data.name == "" + assert nwGUI.theProject.data.title == "" + assert nwGUI.theProject.data.authors == [] + assert nwGUI.theProject.data.spellCheck is False # Check the files - projFile = os.path.join(fncProj, "nwProject.nwx") - testFile = os.path.join(outDir, "guiEditor_Main_Initial_nwProject.nwx") - compFile = os.path.join(refDir, "guiEditor_Main_Initial_nwProject.nwx") + projFile = projPath / "nwProject.nwx" + testFile = tstPaths.outDir / "guiEditor_Main_Initial_nwProject.nwx" + compFile = tstPaths.refDir / "guiEditor_Main_Initial_nwProject.nwx" copyfile(projFile, testFile) assert cmpFiles(testFile, compFile, ignoreStart=XML_IGNORE) - qtbot.wait(stepDelay) - - # qtbot.stopForInteraction() # Re-open project - assert nwGUI.openProject(fncProj) - qtbot.wait(stepDelay) + assert nwGUI.openProject(projPath) # Check that we loaded the data assert len(nwGUI.theProject.tree) == 8 assert len(nwGUI.theProject.tree._treeOrder) == 8 assert len(nwGUI.theProject.tree._treeRoots) == 4 assert nwGUI.theProject.tree.trashRoot() is None - assert nwGUI.theProject.projPath == fncProj - assert nwGUI.theProject.projMeta == os.path.join(fncProj, "meta") - assert nwGUI.theProject.projFile == "nwProject.nwx" - assert nwGUI.theProject.projName == "New Project" - assert nwGUI.theProject.bookTitle == "New Novel" - assert len(nwGUI.theProject.bookAuthors) == 1 - assert nwGUI.theProject.spellCheck is False + assert nwGUI.theProject.data.name == "New Project" + assert nwGUI.theProject.data.title == "New Novel" + assert nwGUI.theProject.data.authors == ["Jane Doe"] + assert nwGUI.theProject.data.spellCheck is False # Check that tree items have been created - assert nwGUI.projView.projTree._getTreeItem("0000000000008") is not None - assert nwGUI.projView.projTree._getTreeItem("0000000000009") is not None - assert nwGUI.projView.projTree._getTreeItem("000000000000a") is not None - assert nwGUI.projView.projTree._getTreeItem("000000000000b") is not None - assert nwGUI.projView.projTree._getTreeItem("000000000000c") is not None - assert nwGUI.projView.projTree._getTreeItem("000000000000d") is not None - assert nwGUI.projView.projTree._getTreeItem("000000000000e") is not None - assert nwGUI.projView.projTree._getTreeItem("000000000000f") is not None + assert nwGUI.projView.projTree._getTreeItem(C.hNovelRoot) is not None + assert nwGUI.projView.projTree._getTreeItem(C.hPlotRoot) is not None + assert nwGUI.projView.projTree._getTreeItem(C.hCharRoot) is not None + assert nwGUI.projView.projTree._getTreeItem(C.hWorldRoot) is not None + assert nwGUI.projView.projTree._getTreeItem(C.hTitlePage) is not None + assert nwGUI.projView.projTree._getTreeItem(C.hChapterDir) is not None + assert nwGUI.projView.projTree._getTreeItem(C.hChapterDoc) is not None + assert nwGUI.projView.projTree._getTreeItem(C.hSceneDoc) is not None nwGUI.mainMenu.aSpellCheck.setChecked(True) assert nwGUI.mainMenu._toggleSpellCheck() @@ -240,51 +251,51 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, fncProj, refDir, outDir, mock # Add a Character File nwGUI.switchFocus(nwWidget.TREE) nwGUI.projView.projTree.clearSelection() - nwGUI.projView.projTree._getTreeItem("000000000000a").setSelected(True) + nwGUI.projView.projTree._getTreeItem(C.hCharRoot).setSelected(True) nwGUI.projView.projTree.newTreeItem(nwItemType.FILE, None, isNote=True) assert nwGUI.openSelectedItem() # Type something into the document nwGUI.switchFocus(nwWidget.EDITOR) - qtbot.keyClick(nwGUI.docEditor, "a", modifier=Qt.ControlModifier, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, "a", modifier=Qt.ControlModifier, delay=KEY_DELAY) for c in "# Jane Doe": - qtbot.keyClick(nwGUI.docEditor, c, delay=typeDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) for c in "@tag: Jane": - qtbot.keyClick(nwGUI.docEditor, c, delay=typeDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) for c in "This is a file about Jane.": - qtbot.keyClick(nwGUI.docEditor, c, delay=typeDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) # Add a Plot File nwGUI.switchFocus(nwWidget.TREE) nwGUI.projView.projTree.clearSelection() - nwGUI.projView.projTree._getTreeItem("0000000000009").setSelected(True) + nwGUI.projView.projTree._getTreeItem(C.hPlotRoot).setSelected(True) nwGUI.projView.projTree.newTreeItem(nwItemType.FILE, None, isNote=True) assert nwGUI.openSelectedItem() # Type something into the document nwGUI.switchFocus(nwWidget.EDITOR) - qtbot.keyClick(nwGUI.docEditor, "a", modifier=Qt.ControlModifier, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, "a", modifier=Qt.ControlModifier, delay=KEY_DELAY) for c in "# Main Plot": - qtbot.keyClick(nwGUI.docEditor, c, delay=typeDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) for c in "@tag: MainPlot": - qtbot.keyClick(nwGUI.docEditor, c, delay=typeDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) for c in "This is a file detailing the main plot.": - qtbot.keyClick(nwGUI.docEditor, c, delay=typeDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) # Add a World File nwGUI.switchFocus(nwWidget.TREE) nwGUI.projView.projTree.clearSelection() - nwGUI.projView.projTree._getTreeItem("000000000000b").setSelected(True) + nwGUI.projView.projTree._getTreeItem(C.hWorldRoot).setSelected(True) nwGUI.projView.projTree.newTreeItem(nwItemType.FILE, None, isNote=True) assert nwGUI.openSelectedItem() @@ -295,18 +306,18 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, fncProj, refDir, outDir, mock # Type something into the document nwGUI.switchFocus(nwWidget.EDITOR) - qtbot.keyClick(nwGUI.docEditor, "a", modifier=Qt.ControlModifier, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, "a", modifier=Qt.ControlModifier, delay=KEY_DELAY) for c in "# Main Location": - qtbot.keyClick(nwGUI.docEditor, c, delay=typeDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) for c in "@tag: Home": - qtbot.keyClick(nwGUI.docEditor, c, delay=typeDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) for c in "This is a file describing Jane's home.": - qtbot.keyClick(nwGUI.docEditor, c, delay=typeDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) # Trigger autosaves before making more changes nwGUI._autoSaveDocument() @@ -315,203 +326,248 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, fncProj, refDir, outDir, mock # Select the 'New Scene' file nwGUI.switchFocus(nwWidget.TREE) nwGUI.projView.projTree.clearSelection() - nwGUI.projView.projTree._getTreeItem("0000000000008").setExpanded(True) - nwGUI.projView.projTree._getTreeItem("000000000000d").setExpanded(True) - nwGUI.projView.projTree._getTreeItem("000000000000f").setSelected(True) + nwGUI.projView.projTree._getTreeItem(C.hNovelRoot).setExpanded(True) + nwGUI.projView.projTree._getTreeItem(C.hChapterDir).setExpanded(True) + nwGUI.projView.projTree._getTreeItem(C.hSceneDoc).setSelected(True) assert nwGUI.openSelectedItem() # Type something into the document nwGUI.switchFocus(nwWidget.EDITOR) - qtbot.keyClick(nwGUI.docEditor, "a", modifier=Qt.ControlModifier, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, "a", modifier=Qt.ControlModifier, delay=KEY_DELAY) for c in "# Novel": - qtbot.keyClick(nwGUI.docEditor, c, delay=typeDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) for c in "## Chapter": - qtbot.keyClick(nwGUI.docEditor, c, delay=typeDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) for c in "@pov: Jane": - qtbot.keyClick(nwGUI.docEditor, c, delay=typeDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) for c in "@plot: MainPlot": - qtbot.keyClick(nwGUI.docEditor, c, delay=typeDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) for c in "### Scene": - qtbot.keyClick(nwGUI.docEditor, c, delay=typeDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) for c in "% How about a comment?": - qtbot.keyClick(nwGUI.docEditor, c, delay=typeDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) for c in "@pov: Jane": - qtbot.keyClick(nwGUI.docEditor, c, delay=typeDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) for c in "@plot: MainPlot": - qtbot.keyClick(nwGUI.docEditor, c, delay=typeDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) for c in "@location: Home": - qtbot.keyClick(nwGUI.docEditor, c, delay=typeDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) for c in "#### Some Section": - qtbot.keyClick(nwGUI.docEditor, c, delay=typeDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) for c in "@char: Jane": - qtbot.keyClick(nwGUI.docEditor, c, delay=typeDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) for c in "This is a paragraph of nonsense text.": - qtbot.keyClick(nwGUI.docEditor, c, delay=typeDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) + + # Don't allow Shift+Enter to insert a line separator (issue #1150) + for c in "This is another paragraph": + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Enter, modifier=Qt.ShiftModifier, delay=KEY_DELAY) + for c in "with a line separator in it.": + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) + + # Auto-Replace + # ============ for c in ( "This is another paragraph of much longer nonsense text. " "It is in fact 1 very very NONSENSICAL nonsense text! " ): - qtbot.keyClick(nwGUI.docEditor, c, delay=typeDelay) + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) for c in "We can also try replacing \"quotes\", even single 'quotes' are replaced. ": - qtbot.keyClick(nwGUI.docEditor, c, delay=typeDelay) + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) for c in "Isn't that nice? ": - qtbot.keyClick(nwGUI.docEditor, c, delay=typeDelay) + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) for c in "We can hyphen-ate, make dashes -- and even longer dashes --- if we want. ": - qtbot.keyClick(nwGUI.docEditor, c, delay=typeDelay) + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) for c in "Ellipsis? Not a problem either ... ": - qtbot.keyClick(nwGUI.docEditor, c, delay=typeDelay) + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) for c in "How about three hyphens - -": - qtbot.keyClick(nwGUI.docEditor, c, delay=typeDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Left, delay=keyDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Backspace, delay=keyDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Right, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Left, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Backspace, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Right, delay=KEY_DELAY) for c in "- for long dash? It works too.": - qtbot.keyClick(nwGUI.docEditor, c, delay=typeDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) for c in "\"Full line double quoted text.\"": - qtbot.keyClick(nwGUI.docEditor, c, delay=typeDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) for c in "'Full line single quoted text.'": - qtbot.keyClick(nwGUI.docEditor, c, delay=typeDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) + + # Insert spaces before and after quotes + nwGUI.docEditor._typPadBefore = "\u201d" + nwGUI.docEditor._typPadAfter = "\u201c" + + for c in "Some \"double quoted text with spaces padded\".": + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) + + nwGUI.docEditor._typPadBefore = "" + nwGUI.docEditor._typPadAfter = "" + + # Insert spaces before colon, but ignore tags and synopsis + nwGUI.docEditor._typPadBefore = ":" + + for c in "@object: NoSpaceAdded": + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) + + for c in "% synopsis: No space before this colon.": + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) + + for c in "Add space before this colon: See?": + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) + + for c in "But don't add a double space : See?": + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) + + nwGUI.docEditor._typPadBefore = "" + + # Indent and Align + # ================ for c in "\t\"Tab-indented text\"": - qtbot.keyClick(nwGUI.docEditor, c, delay=typeDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) for c in ">\"Paragraph-indented text\"": - qtbot.keyClick(nwGUI.docEditor, c, delay=typeDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) for c in ">>\"Right-aligned text\"": - qtbot.keyClick(nwGUI.docEditor, c, delay=typeDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) for c in "\t'Tab-indented text'": - qtbot.keyClick(nwGUI.docEditor, c, delay=typeDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) for c in ">'Paragraph-indented text'": - qtbot.keyClick(nwGUI.docEditor, c, delay=typeDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) for c in ">>'Right-aligned text'": - qtbot.keyClick(nwGUI.docEditor, c, delay=typeDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, c, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=KEY_DELAY) - qtbot.wait(stepDelay) nwGUI.docEditor.wCounterDoc.run() - qtbot.wait(stepDelay) # Save the document assert nwGUI.docEditor.docChanged() assert nwGUI.saveDocument() assert not nwGUI.docEditor.docChanged() - qtbot.wait(stepDelay) nwGUI.rebuildIndex() - qtbot.wait(stepDelay) # Open and view the edited document nwGUI.switchFocus(nwWidget.VIEWER) - assert nwGUI.openDocument("000000000000f") - assert nwGUI.viewDocument("000000000000f") - qtbot.wait(stepDelay) + assert nwGUI.openDocument(C.hSceneDoc) + assert nwGUI.viewDocument(C.hSceneDoc) assert nwGUI.saveProject() assert nwGUI.closeDocViewer() - qtbot.wait(stepDelay) # Check a Quick Create and Delete assert nwGUI.projView.projTree.newTreeItem(nwItemType.FILE, None) newHandle = nwGUI.projView.getSelectedHandle() - assert nwGUI.theProject.tree["0000000000020"] is not None - assert nwGUI.projView.deleteItem() + assert newHandle == "0000000000013" + assert nwGUI.theProject.tree[newHandle] is not None + assert nwGUI.projView.requestDeleteItem() assert nwGUI.projView.setSelectedHandle(newHandle) - assert nwGUI.projView.deleteItem() - assert nwGUI.theProject.tree["0000000000024"] is not None # Trash + assert nwGUI.projView.requestDeleteItem() + assert nwGUI.theProject.tree["0000000000014"] is not None # Trash assert nwGUI.saveProject() # Check the files - projFile = os.path.join(fncProj, "nwProject.nwx") - testFile = os.path.join(outDir, "guiEditor_Main_Final_nwProject.nwx") - compFile = os.path.join(refDir, "guiEditor_Main_Final_nwProject.nwx") + projFile = projPath / "nwProject.nwx" + testFile = tstPaths.outDir / "guiEditor_Main_Final_nwProject.nwx" + compFile = tstPaths.refDir / "guiEditor_Main_Final_nwProject.nwx" copyfile(projFile, testFile) - assert cmpFiles(testFile, compFile, ignoreStart=XML_IGNORE) + assert cmpFiles(testFile, compFile, ignoreStart=(*XML_IGNORE, ". """ import pytest -import os -from PyQt5.QtCore import Qt from PyQt5.QtGui import QTextCursor, QTextBlock +from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QAction, QFileDialog, QMessageBox -from tools import writeFile, buildTestProject +from tools import C, writeFile, buildTestProject -from novelwriter.gui.doceditor import GuiDocEditor from novelwriter.enum import nwDocAction, nwDocInsert from novelwriter.constants import nwKeyWords, nwUnicode - -keyDelay = 2 -typeDelay = 1 -stepDelay = 20 +from novelwriter.gui.doceditor import GuiDocEditor @pytest.mark.gui def testGuiMenu_EditFormat(qtbot, monkeypatch, nwGUI, nwLipsum): """Test the main menu Edit and Format entries. """ - # Block message box - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) monkeypatch.setattr(GuiDocEditor, "hasFocus", lambda *a: True) # Test Document Action with No Project assert nwGUI.docEditor.docAction(nwDocAction.COPY) is False assert nwGUI.openProject(nwLipsum) is True - qtbot.wait(stepDelay) # Split By Chapter assert nwGUI.openDocument("4c4f28287af27") is True @@ -61,57 +53,43 @@ def testGuiMenu_EditFormat(qtbot, monkeypatch, nwGUI, nwLipsum): nwGUI.mainMenu.aFmtStrong.activate(QAction.Trigger) fmtStr = "**Pellentesque** nec erat ut nulla posuere commodo." assert nwGUI.docEditor.getText()[39:90] == fmtStr - qtbot.wait(stepDelay) nwGUI.mainMenu.aFmtStrong.activate(QAction.Trigger) assert nwGUI.docEditor.getText()[39:86] == cleanText - qtbot.wait(stepDelay) # Italic nwGUI.mainMenu.aFmtEmph.activate(QAction.Trigger) fmtStr = "_Pellentesque_ nec erat ut nulla posuere commodo." assert nwGUI.docEditor.getText()[39:88] == fmtStr - qtbot.wait(stepDelay) nwGUI.mainMenu.aFmtEmph.activate(QAction.Trigger) assert nwGUI.docEditor.getText()[39:86] == cleanText - qtbot.wait(stepDelay) # Strikethrough nwGUI.mainMenu.aFmtStrike.activate(QAction.Trigger) fmtStr = "~~Pellentesque~~ nec erat ut nulla posuere commodo." assert nwGUI.docEditor.getText()[39:90] == fmtStr - qtbot.wait(stepDelay) nwGUI.mainMenu.aFmtStrike.activate(QAction.Trigger) assert nwGUI.docEditor.getText()[39:86] == cleanText - qtbot.wait(stepDelay) # Should get us back to plain nwGUI.mainMenu.aFmtStrong.activate(QAction.Trigger) - qtbot.wait(stepDelay) nwGUI.mainMenu.aFmtEmph.activate(QAction.Trigger) - qtbot.wait(stepDelay) nwGUI.mainMenu.aFmtEmph.activate(QAction.Trigger) - qtbot.wait(stepDelay) nwGUI.mainMenu.aFmtStrong.activate(QAction.Trigger) assert nwGUI.docEditor.getText()[39:86] == cleanText - qtbot.wait(stepDelay) # Double Quotes nwGUI.mainMenu.aFmtDQuote.activate(QAction.Trigger) fmtStr = "“Pellentesque” nec erat ut nulla posuere commodo." assert nwGUI.docEditor.getText()[39:88] == fmtStr - qtbot.wait(stepDelay) nwGUI.mainMenu.aEditUndo.activate(QAction.Trigger) assert nwGUI.docEditor.getText()[39:86] == cleanText - qtbot.wait(stepDelay) # Single Quotes nwGUI.mainMenu.aFmtSQuote.activate(QAction.Trigger) fmtStr = "‘Pellentesque’ nec erat ut nulla posuere commodo." assert nwGUI.docEditor.getText()[39:88] == fmtStr - qtbot.wait(stepDelay) nwGUI.mainMenu.aEditUndo.activate(QAction.Trigger) assert nwGUI.docEditor.getText()[39:86] == cleanText - qtbot.wait(stepDelay) # Block Formats # ============= @@ -121,61 +99,60 @@ def testGuiMenu_EditFormat(qtbot, monkeypatch, nwGUI, nwLipsum): nwGUI.mainMenu.aFmtHead1.activate(QAction.Trigger) fmtStr = "# Pellentesque nec erat ut nulla posuere commodo." assert nwGUI.docEditor.getText()[39:88] == fmtStr - qtbot.wait(stepDelay) # Header 2 nwGUI.mainMenu.aFmtHead2.activate(QAction.Trigger) fmtStr = "## Pellentesque nec erat ut nulla posuere commodo." assert nwGUI.docEditor.getText()[39:89] == fmtStr - qtbot.wait(stepDelay) # Header 3 nwGUI.mainMenu.aFmtHead3.activate(QAction.Trigger) fmtStr = "### Pellentesque nec erat ut nulla posuere commodo." assert nwGUI.docEditor.getText()[39:90] == fmtStr - qtbot.wait(stepDelay) # Header 4 nwGUI.mainMenu.aFmtHead4.activate(QAction.Trigger) fmtStr = "#### Pellentesque nec erat ut nulla posuere commodo." assert nwGUI.docEditor.getText()[39:91] == fmtStr - qtbot.wait(stepDelay) + + # Title Format + nwGUI.mainMenu.aFmtTitle.activate(QAction.Trigger) + fmtStr = "#! Pellentesque nec erat ut nulla posuere commodo." + assert nwGUI.docEditor.getText()[39:89] == fmtStr + + # Unnumbered Chapter + nwGUI.mainMenu.aFmtUnNum.activate(QAction.Trigger) + fmtStr = "##! Pellentesque nec erat ut nulla posuere commodo." + assert nwGUI.docEditor.getText()[39:90] == fmtStr # Clear Format nwGUI.mainMenu.aFmtNoFormat.activate(QAction.Trigger) assert nwGUI.docEditor.getText()[39:86] == cleanText - qtbot.wait(stepDelay) # Comment On nwGUI.mainMenu.aFmtComment.activate(QAction.Trigger) fmtStr = "% Pellentesque nec erat ut nulla posuere commodo." assert nwGUI.docEditor.getText()[39:88] == fmtStr - qtbot.wait(stepDelay) # Comment Off nwGUI.mainMenu.aFmtComment.activate(QAction.Trigger) assert nwGUI.docEditor.getText()[39:86] == cleanText - qtbot.wait(stepDelay) # Check comment with no space before text assert nwGUI.docEditor.setCursorPosition(39) assert nwGUI.docEditor.insertText("%") fmtStr = "%Pellentesque nec erat ut nulla posuere commodo." assert nwGUI.docEditor.getText()[39:87] == fmtStr - qtbot.wait(stepDelay) nwGUI.mainMenu.aFmtNoFormat.activate(QAction.Trigger) assert nwGUI.docEditor.getText()[39:86] == cleanText - qtbot.wait(stepDelay) # Undo/Redo nwGUI.mainMenu.aEditUndo.activate(QAction.Trigger) fmtStr = "%Pellentesque nec erat ut nulla posuere commodo." assert nwGUI.docEditor.getText()[39:87] == fmtStr - qtbot.wait(stepDelay) nwGUI.mainMenu.aEditRedo.activate(QAction.Trigger) assert nwGUI.docEditor.getText()[39:86] == cleanText - qtbot.wait(stepDelay) # Cut, Copy and Paste assert nwGUI.docEditor.setCursorPosition(39) @@ -240,36 +217,30 @@ def testGuiMenu_EditFormat(qtbot, monkeypatch, nwGUI, nwLipsum): nwGUI.mainMenu.aFmtAlignLeft.activate(QAction.Trigger) fmtStr = "A single, short paragraph. <<" assert nwGUI.docEditor.getText()[:29] == fmtStr - qtbot.wait(stepDelay) # Right Align nwGUI.mainMenu.aFmtAlignRight.activate(QAction.Trigger) fmtStr = ">> A single, short paragraph." assert nwGUI.docEditor.getText()[:29] == fmtStr - qtbot.wait(stepDelay) # Centre Align nwGUI.mainMenu.aFmtAlignCentre.activate(QAction.Trigger) fmtStr = ">> A single, short paragraph. <<" assert nwGUI.docEditor.getText()[:32] == fmtStr - qtbot.wait(stepDelay) # Left Indent nwGUI.mainMenu.aFmtIndentLeft.activate(QAction.Trigger) fmtStr = "> A single, short paragraph." assert nwGUI.docEditor.getText()[:28] == fmtStr - qtbot.wait(stepDelay) # Right Indent nwGUI.mainMenu.aFmtIndentRight.activate(QAction.Trigger) fmtStr = "> A single, short paragraph. <" assert nwGUI.docEditor.getText()[:30] == fmtStr - qtbot.wait(stepDelay) # No Format nwGUI.mainMenu.aFmtNoFormat.activate(QAction.Trigger) assert nwGUI.docEditor.getText()[:30] == cleanText - qtbot.wait(stepDelay) # Other Checks @@ -352,10 +323,6 @@ def testGuiMenu_EditFormat(qtbot, monkeypatch, nwGUI, nwLipsum): assert nwGUI.docEditor.setCursorPosition(17) assert not nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_TXT) - # Cannot Format Empty Line - assert nwGUI.docEditor.setCursorPosition(13) - assert not nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_TXT) - # Invalid Action assert nwGUI.docEditor.setCursorPosition(30) assert not nwGUI.docEditor._formatBlock(nwDocAction.NO_ACTION) @@ -368,21 +335,17 @@ def testGuiMenu_EditFormat(qtbot, monkeypatch, nwGUI, nwLipsum): "Also text with \"double\" quotes which are \"less tricky\".\n\n" ) - # qtbot.stopForInteraction() + # qtbot.stop() # END Test testGuiMenu_EditFormat @pytest.mark.gui -def testGuiMenu_ContextMenus(qtbot, monkeypatch, nwGUI, nwLipsum): +def testGuiMenu_ContextMenus(qtbot, nwGUI, nwLipsum): """Test the context menus. """ - # Block message box - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - assert nwGUI.openProject(nwLipsum) assert nwGUI.openDocument("4c4f28287af27") - qtbot.wait(stepDelay) # Editor Context Menu theCursor = nwGUI.docEditor.textCursor() @@ -452,23 +415,19 @@ def testGuiMenu_ContextMenus(qtbot, monkeypatch, nwGUI, nwLipsum): assert nwGUI.docViewer.docHeader.backButton.isEnabled() assert not nwGUI.docViewer.docHeader.forwardButton.isEnabled() - # qtbot.stopForInteraction() + # qtbot.stop() # END Test testGuiMenu_ContextMenus @pytest.mark.gui -def testGuiMenu_Insert(qtbot, monkeypatch, nwGUI, fncDir, fncProj, mockRnd): +def testGuiMenu_Insert(qtbot, monkeypatch, nwGUI, fncPath, projPath, mockRnd): """Test the Insert menu. """ - # Block message box - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "critical", lambda *a: QMessageBox.Yes) + buildTestProject(nwGUI, projPath) - buildTestProject(nwGUI, fncProj) - - assert nwGUI.projView.projTree._getTreeItem("000000000000f") is not None - assert nwGUI.openDocument("000000000000f") is True + assert nwGUI.projView.projTree._getTreeItem(C.hSceneDoc) is not None + assert nwGUI.openDocument(C.hSceneDoc) is True nwGUI.docEditor.clear() # Test Faulty Inserts @@ -482,7 +441,7 @@ def testGuiMenu_Insert(qtbot, monkeypatch, nwGUI, fncDir, fncProj, mockRnd): assert nwGUI.docEditor.insertText(None) is False assert nwGUI.docEditor.isEmpty() - # qtbot.stopForInteraction() + # qtbot.stop() # Check Menu Entries nwGUI.mainMenu.aInsENDash.activate(QAction.Trigger) @@ -502,19 +461,19 @@ def testGuiMenu_Insert(qtbot, monkeypatch, nwGUI, fncDir, fncProj, mockRnd): nwGUI.docEditor.clear() nwGUI.mainMenu.aInsQuoteLS.activate(QAction.Trigger) - assert nwGUI.docEditor.getText() == nwGUI.mainConf.fmtSingleQuotes[0] + assert nwGUI.docEditor.getText() == nwGUI.mainConf.fmtSQuoteOpen nwGUI.docEditor.clear() nwGUI.mainMenu.aInsQuoteRS.activate(QAction.Trigger) - assert nwGUI.docEditor.getText() == nwGUI.mainConf.fmtSingleQuotes[1] + assert nwGUI.docEditor.getText() == nwGUI.mainConf.fmtSQuoteClose nwGUI.docEditor.clear() nwGUI.mainMenu.aInsQuoteLD.activate(QAction.Trigger) - assert nwGUI.docEditor.getText() == nwGUI.mainConf.fmtDoubleQuotes[0] + assert nwGUI.docEditor.getText() == nwGUI.mainConf.fmtDQuoteOpen nwGUI.docEditor.clear() nwGUI.mainMenu.aInsQuoteRD.activate(QAction.Trigger) - assert nwGUI.docEditor.getText() == nwGUI.mainConf.fmtDoubleQuotes[1] + assert nwGUI.docEditor.getText() == nwGUI.mainConf.fmtDQuoteClose nwGUI.docEditor.clear() nwGUI.mainMenu.aInsMSApos.activate(QAction.Trigger) @@ -566,10 +525,7 @@ def testGuiMenu_Insert(qtbot, monkeypatch, nwGUI, fncDir, fncProj, mockRnd): nwGUI.docEditor.clear() nwGUI.mainMenu.aInsNBSpace.activate(QAction.Trigger) - if nwGUI.mainConf.verQtValue >= 50900: - assert nwGUI.docEditor.getText() == nwUnicode.U_NBSP - else: - assert nwGUI.docEditor.getText() == " " + assert nwGUI.docEditor.getText() == nwUnicode.U_NBSP nwGUI.docEditor.clear() nwGUI.mainMenu.aInsThinSpace.activate(QAction.Trigger) @@ -577,15 +533,11 @@ def testGuiMenu_Insert(qtbot, monkeypatch, nwGUI, fncDir, fncProj, mockRnd): nwGUI.docEditor.clear() nwGUI.mainMenu.aInsThinNBSpace.activate(QAction.Trigger) - if nwGUI.mainConf.verQtValue >= 50900: - assert nwGUI.docEditor.getText() == nwUnicode.U_THNBSP - else: - assert nwGUI.docEditor.getText() == " " + assert nwGUI.docEditor.getText() == nwUnicode.U_THNBSP nwGUI.docEditor.clear() - ## - # Insert Keywords - ## + # Insert Keywords + # =============== nwGUI.docEditor.setText("Stuff") nwGUI.mainMenu.mInsKWItems[nwKeyWords.TAG_KEY][0].activate(QAction.Trigger) @@ -635,9 +587,15 @@ def testGuiMenu_Insert(qtbot, monkeypatch, nwGUI, fncDir, fncProj, mockRnd): nwGUI.docEditor.clear() - ## - # Insert Break or Space - ## + # Insert Special Comments + # ======================= + + nwGUI.docEditor.setText("Stuff\n") + nwGUI.mainMenu.aInsSynopsis.activate(QAction.Trigger) + assert nwGUI.docEditor.getText() == "Stuff\n% Synopsis: \n" + + # Insert Break or Space + # ===================== nwGUI.docEditor.setText("### Stuff\n") nwGUI.mainMenu.aInsNewPage.activate(QAction.Trigger) @@ -653,9 +611,8 @@ def testGuiMenu_Insert(qtbot, monkeypatch, nwGUI, fncDir, fncProj, mockRnd): nwGUI.docEditor.clear() - ## - # Insert text from file - ## + # Insert Text from File + # ===================== nwGUI.closeDocument() @@ -668,8 +625,8 @@ def testGuiMenu_Insert(qtbot, monkeypatch, nwGUI, fncDir, fncProj, mockRnd): assert not nwGUI.importDocument() # Then a valid path, but bot a file that exists - theFile = os.path.join(fncDir, "import.txt") - monkeypatch.setattr(QFileDialog, "getOpenFileName", lambda *a, **k: (theFile, "")) + theFile = fncPath / "import.txt" + monkeypatch.setattr(QFileDialog, "getOpenFileName", lambda *a, **k: (str(theFile), "")) assert not nwGUI.importDocument() # Create the file and try again, but with no target document open @@ -677,23 +634,22 @@ def testGuiMenu_Insert(qtbot, monkeypatch, nwGUI, fncDir, fncProj, mockRnd): assert not nwGUI.importDocument() # Open the document from before, and add some text to it - nwGUI.openDocument("000000000000f") + nwGUI.openDocument(C.hSceneDoc) nwGUI.docEditor.setText("Bar") assert nwGUI.docEditor.getText() == "Bar" # The document isn't empty, so the message box should pop - monkeypatch.setattr(QMessageBox, "question", lambda *a, **k: QMessageBox.No) - assert not nwGUI.importDocument() - assert nwGUI.docEditor.getText() == "Bar" + with monkeypatch.context() as mp: + mp.setattr(QMessageBox, "question", lambda *a, **k: QMessageBox.No) + assert not nwGUI.importDocument() + assert nwGUI.docEditor.getText() == "Bar" # Finally, accept the replaced text, this time we use the menu entry to trigger it - monkeypatch.setattr(QMessageBox, "question", lambda *a, **k: QMessageBox.Yes) nwGUI.mainMenu.aImportFile.activate(QAction.Trigger) assert nwGUI.docEditor.getText() == "Foo" - ## - # Reveal file location - ## + # Reveal File Location + # ==================== theMessage = "" @@ -709,8 +665,8 @@ def testGuiMenu_Insert(qtbot, monkeypatch, nwGUI, fncDir, fncProj, mockRnd): theBits = theMessage.split("
") assert len(theBits) == 2 assert theBits[0] == "The currently open file is saved in:" - assert theBits[1] == os.path.join(fncProj, "content", "000000000000f.nwd") + assert theBits[1] == str(projPath / "content" / "000000000000f.nwd") - # qtbot.stopForInteraction() + # qtbot.stop() # END Test testGuiMenu_Insert diff --git a/tests/test_gui/test_gui_noveltree.py b/tests/test_gui/test_gui_noveltree.py index 102b03d2..20c38d03 100644 --- a/tests/test_gui/test_gui_noveltree.py +++ b/tests/test_gui/test_gui_noveltree.py @@ -19,43 +19,46 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import os import pytest -from tools import buildTestProject, writeFile +from pathlib import Path -from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QMessageBox +from tools import C, buildTestProject + +from PyQt5.QtGui import QFocusEvent +from PyQt5.QtCore import Qt, QEvent +from PyQt5.QtWidgets import QToolTip from novelwriter.enum import nwWidget, nwItemType -from novelwriter.dialogs import GuiEditLabel from novelwriter.gui.noveltree import NovelTreeColumn +from novelwriter.dialogs.editlabel import GuiEditLabel @pytest.mark.gui -def testGuiNovelTree_TreeItems(qtbot, monkeypatch, nwGUI, fncProj, mockRnd): +def testGuiNovelTree_TreeItems(qtbot, monkeypatch, nwGUI, projPath, mockRnd): """Test navigating the novel tree. """ - # Block message box - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "information", lambda *a: QMessageBox.Yes) monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True)) - buildTestProject(nwGUI, fncProj) + buildTestProject(nwGUI, projPath) nwGUI.switchFocus(nwWidget.TREE) nwGUI.projView.projTree.clearSelection() - nwGUI.projView.projTree._getTreeItem("000000000000a").setSelected(True) + nwGUI.projView.projTree._getTreeItem(C.hCharRoot).setSelected(True) nwGUI.projView.projTree.newTreeItem(nwItemType.FILE) - writeFile( - os.path.join(nwGUI.theProject.projContent, "0000000000010.nwd"), - "# Jane Doe\n\n@tag: Jane\n\n" - ) - writeFile( - os.path.join(nwGUI.theProject.projContent, "000000000000f.nwd"), - "### Scene One\n\n@pov: Jane\n@focus: Jane\n\n" + contentPath = nwGUI.theProject.storage.contentPath + assert isinstance(contentPath, Path) + + (contentPath / "0000000000010.nwd").write_text( + "# Jane Doe\n\n@tag: Jane\n\n", encoding="utf-8" ) + (contentPath / "000000000000f.nwd").write_text(( + "### Scene One\n\n" + "@pov: Jane\n" + "@focus: Jane\n\n" + "% Synopsis: This is a scene." + ), encoding="utf-8") novelView = nwGUI.novelView novelTree = novelView.novelTree @@ -89,7 +92,7 @@ def testGuiNovelTree_TreeItems(qtbot, monkeypatch, nwGUI, fncProj, mockRnd): assert not topItem.isSelected() topItem.setSelected(True) assert novelTree.selectedItems()[0] == topItem - assert novelView.getSelectedHandle() == ("000000000000c", 0) + assert novelView.getSelectedHandle() == (C.hTitlePage, 0) # Refresh using the slot for the butoom novelBar._refreshNovelTree() @@ -114,7 +117,7 @@ def testGuiNovelTree_TreeItems(qtbot, monkeypatch, nwGUI, fncProj, mockRnd): assert scItem.isSelected() assert nwGUI.docEditor.docHandle() is None novelTree._treeDoubleClick(scItem, 0) - assert nwGUI.docEditor.docHandle() == "000000000000f" + assert nwGUI.docEditor.docHandle() == C.hSceneDoc # Open item with middle mouse button scItem.setSelected(True) @@ -124,47 +127,74 @@ def testGuiNovelTree_TreeItems(qtbot, monkeypatch, nwGUI, fncProj, mockRnd): assert nwGUI.docViewer.docHandle() is None scRect = novelTree.visualItemRect(scItem) - oldData = scItem.data(novelTree.C_TITLE, Qt.UserRole) - scItem.setData(novelTree.C_TITLE, Qt.UserRole, (None, "", "")) + oldData = scItem.data(novelTree.C_TITLE, novelTree.D_HANDLE) + scItem.setData(novelTree.C_TITLE, novelTree.D_HANDLE, None) qtbot.mouseClick(vPort, Qt.MiddleButton, pos=scRect.center(), delay=10) assert nwGUI.docViewer.docHandle() is None - scItem.setData(novelTree.C_TITLE, Qt.UserRole, oldData) + scItem.setData(novelTree.C_TITLE, novelTree.D_HANDLE, oldData) qtbot.mouseClick(vPort, Qt.MiddleButton, pos=scRect.center(), delay=10) - assert nwGUI.docViewer.docHandle() == "000000000000f" + assert nwGUI.docViewer.docHandle() == C.hSceneDoc # Last Column # =========== novelBar.setLastColType(NovelTreeColumn.HIDDEN) - assert novelTree.isColumnHidden(novelTree.C_LAST) is True + assert novelTree.isColumnHidden(novelTree.C_EXTRA) is True assert novelTree.lastColType == NovelTreeColumn.HIDDEN - assert novelTree._getLastColumnText("000000000000f", "T000001") == ("", "") + assert novelTree._getLastColumnText(C.hSceneDoc, "T000001") == ("", "") novelBar.setLastColType(NovelTreeColumn.POV) - assert novelTree.isColumnHidden(novelTree.C_LAST) is False + assert novelTree.isColumnHidden(novelTree.C_EXTRA) is False assert novelTree.lastColType == NovelTreeColumn.POV - assert novelTree._getLastColumnText("000000000000f", "T000001") == ( + assert novelTree._getLastColumnText(C.hSceneDoc, "T000001") == ( "Jane", "Point of View: Jane" ) novelBar.setLastColType(NovelTreeColumn.FOCUS) - assert novelTree.isColumnHidden(novelTree.C_LAST) is False + assert novelTree.isColumnHidden(novelTree.C_EXTRA) is False assert novelTree.lastColType == NovelTreeColumn.FOCUS - assert novelTree._getLastColumnText("000000000000f", "T000001") == ( + assert novelTree._getLastColumnText(C.hSceneDoc, "T000001") == ( "Jane", "Focus: Jane" ) novelBar.setLastColType(NovelTreeColumn.PLOT) - assert novelTree.isColumnHidden(novelTree.C_LAST) is False + assert novelTree.isColumnHidden(novelTree.C_EXTRA) is False assert novelTree.lastColType == NovelTreeColumn.PLOT - assert novelTree._getLastColumnText("000000000000f", "T000001") == ( + assert novelTree._getLastColumnText(C.hSceneDoc, "T000001") == ( "", "Plot: " ) novelTree._lastCol = None assert novelTree._getLastColumnText("0000000000000", "T000000") == ("", "") + # Item Meta + # ========= + + ttText = "" + + def showText(pos, text): + nonlocal ttText + ttText = text + + mIndex = novelTree.model().index(2, novelTree.C_MORE) + with monkeypatch.context() as mp: + mp.setattr(QToolTip, "showText", showText) + novelTree._treeItemClicked(mIndex) + assert ttText == ( + "

Point of View: Jane
Focus: Jane

" + "

Synopsis: This is a scene.

" + ) + + # Other Checks + # ============ + + scItem = novelTree.topLevelItem(2) + scItem.setSelected(True) + assert scItem.isSelected() + novelTree.focusOutEvent(QFocusEvent(QEvent.None_, Qt.MouseFocusReason)) + assert not scItem.isSelected() + # Close # ===== diff --git a/tests/test_gui/test_gui_outline.py b/tests/test_gui/test_gui_outline.py index 26ca9803..23f61c5b 100644 --- a/tests/test_gui/test_gui_outline.py +++ b/tests/test_gui/test_gui_outline.py @@ -26,22 +26,17 @@ import pytest from tools import buildTestProject, writeFile from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QWidget, QMessageBox, QAction +from PyQt5.QtWidgets import QWidget, QAction from novelwriter.enum import nwItemClass, nwOutline, nwView @pytest.mark.gui -def testGuiOutline_Main(qtbot, monkeypatch, nwGUI, fncDir): +def testGuiOutline_Main(qtbot, monkeypatch, nwGUI, projPath): """Test the outline view. """ - # Block message box - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "information", lambda *a: QMessageBox.Yes) - # Create a project - prjDir = os.path.join(fncDir, "project") - buildTestProject(nwGUI, prjDir) + buildTestProject(nwGUI, projPath) nwGUI.rebuildIndex() nwGUI._changeView(nwView.OUTLINE) @@ -54,7 +49,7 @@ def testGuiOutline_Main(qtbot, monkeypatch, nwGUI, fncDir): # Toggle scrollbars nwGUI.mainConf.hideVScroll = True nwGUI.mainConf.hideHScroll = True - outlineView.initOutline() + outlineView.initSettings() assert outlineTree.verticalScrollBarPolicy() == Qt.ScrollBarAlwaysOff assert outlineTree.horizontalScrollBarPolicy() == Qt.ScrollBarAlwaysOff assert outlineData.verticalScrollBarPolicy() == Qt.ScrollBarAlwaysOff @@ -62,7 +57,7 @@ def testGuiOutline_Main(qtbot, monkeypatch, nwGUI, fncDir): nwGUI.mainConf.hideVScroll = False nwGUI.mainConf.hideHScroll = False - outlineView.initOutline() + outlineView.initSettings() assert outlineTree.verticalScrollBarPolicy() == Qt.ScrollBarAsNeeded assert outlineTree.horizontalScrollBarPolicy() == Qt.ScrollBarAsNeeded assert outlineData.verticalScrollBarPolicy() == Qt.ScrollBarAsNeeded @@ -156,15 +151,10 @@ def testGuiOutline_Main(qtbot, monkeypatch, nwGUI, fncDir): @pytest.mark.gui -def testGuiOutline_Content(qtbot, monkeypatch, nwGUI, nwLipsum): +def testGuiOutline_Content(qtbot, nwGUI, nwLipsum): """Test the outline view. """ - # Block message box - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "information", lambda *a: QMessageBox.Yes) - assert nwGUI.openProject(nwLipsum) - nwGUI.mainConf.lastPath = nwLipsum nwGUI.rebuildIndex() nwGUI._changeView(nwView.OUTLINE) @@ -183,7 +173,7 @@ def testGuiOutline_Content(qtbot, monkeypatch, nwGUI, nwLipsum): # Add a second novel folder newHandle = nwGUI.theProject.newRoot(nwItemClass.NOVEL) - nwGUI.projView.revealNewTreeItem(newHandle) + nwGUI.projView.projTree.revealNewTreeItem(newHandle) # Check new values in dropdown list assert outlineBar.novelValue.itemData(0) == lipHandle @@ -202,7 +192,7 @@ def testGuiOutline_Content(qtbot, monkeypatch, nwGUI, nwLipsum): aHandle = nwGUI.theProject.newFile(dTitle, newHandle) hHash = "#"*hLevel writeFile(os.path.join(nwLipsum, "content", f"{aHandle}.nwd"), f"{hHash} {dTitle}\n\n") - nwGUI.projView.revealNewTreeItem(aHandle) + nwGUI.projView.projTree.revealNewTreeItem(aHandle) nwGUI.rebuildIndex() @@ -232,9 +222,7 @@ def testGuiOutline_Content(qtbot, monkeypatch, nwGUI, nwLipsum): assert outlineData.pCValue.text() == "3" # Scene One - actItem = outlineTree.topLevelItem(1) - chpItem = actItem.child(0) - selItem = chpItem.child(0) + selItem = outlineTree.topLevelItem(4) outlineTree.setCurrentItem(selItem) tHandle, tLine = outlineTree.getSelectedHandle() @@ -252,10 +240,7 @@ def testGuiOutline_Content(qtbot, monkeypatch, nwGUI, nwLipsum): assert nwGUI.docViewer.docHandle() == "4c4f28287af27" # Scene One, Section Two - actItem = outlineTree.topLevelItem(1) - chpItem = actItem.child(0) - scnItem = chpItem.child(0) - selItem = scnItem.child(0) + selItem = outlineTree.topLevelItem(5) outlineTree.setCurrentItem(selItem) tHandle, tLine = outlineTree.getSelectedHandle() diff --git a/tests/test_gui/test_gui_projtree.py b/tests/test_gui/test_gui_projtree.py index feef859d..cbf607d8 100644 --- a/tests/test_gui/test_gui_projtree.py +++ b/tests/test_gui/test_gui_projtree.py @@ -20,481 +20,536 @@ along with this program. If not, see . """ import pytest -import os -from tools import buildTestProject +from mock import causeOSError +from tools import C, buildTestProject -from PyQt5.QtWidgets import QMessageBox, QMenu +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import QMessageBox, QMenu, QTreeWidgetItem, QDialog from novelwriter.enum import nwItemLayout, nwItemType, nwItemClass -from novelwriter.dialogs import GuiEditLabel from novelwriter.gui.projtree import GuiProjectTree +from novelwriter.dialogs.docmerge import GuiDocMerge +from novelwriter.dialogs.docsplit import GuiDocSplit +from novelwriter.dialogs.editlabel import GuiEditLabel @pytest.mark.gui -def testGuiProjTree_NewItems(qtbot, caplog, monkeypatch, nwGUI, fncDir, mockRnd): +def testGuiProjTree_NewItems(qtbot, caplog, monkeypatch, nwGUI, projPath, mockRnd): """Test adding and removing items from the project tree. """ - # Block message box - monkeypatch.setattr(QMessageBox, "warning", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "critical", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "information", lambda *a: QMessageBox.Yes) monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True)) - nwTree = nwGUI.projView + projView = nwGUI.projView + projTree = nwGUI.projView.projTree + theProject = nwGUI.theProject # Try to add item with no project - assert nwTree.projTree.newTreeItem(nwItemType.FILE) is False + assert projView.projTree.newTreeItem(nwItemType.FILE) is False # Create a project - prjDir = os.path.join(fncDir, "project") - buildTestProject(nwGUI, prjDir) + buildTestProject(nwGUI, projPath) # No itemType set - nwTree.projTree.clearSelection() - assert nwTree.projTree.newTreeItem(None) is False + projView.projTree.clearSelection() + assert projView.projTree.newTreeItem(None) is False # Root Items # ========== # No class set - assert nwTree.projTree.newTreeItem(nwItemType.ROOT) is False + assert projView.projTree.newTreeItem(nwItemType.ROOT) is False # Create root item - assert nwTree.projTree.newTreeItem(nwItemType.ROOT, nwItemClass.WORLD) is True - assert "0000000000010" in nwGUI.theProject.tree + assert projView.projTree.newTreeItem(nwItemType.ROOT, nwItemClass.WORLD) is True + assert "0000000000010" in theProject.tree # File/Folder Items # ================= # No location selected for new item - nwTree.projTree.clearSelection() + projView.projTree.clearSelection() caplog.clear() - assert nwTree.projTree.newTreeItem(nwItemType.FILE) is False - assert nwTree.projTree.newTreeItem(nwItemType.FOLDER) is False + assert projView.projTree.newTreeItem(nwItemType.FILE) is False + assert projView.projTree.newTreeItem(nwItemType.FOLDER) is False assert "Did not find anywhere" in caplog.text # Create new folder as child of Novel folder - nwTree.setSelectedHandle("0000000000008") - assert nwTree.projTree.newTreeItem(nwItemType.FOLDER) is True - assert nwGUI.theProject.tree["0000000000011"].itemParent == "0000000000008" - assert nwGUI.theProject.tree["0000000000011"].itemRoot == "0000000000008" - assert nwGUI.theProject.tree["0000000000011"].itemClass == nwItemClass.NOVEL + projView.setSelectedHandle(C.hNovelRoot) + assert projView.projTree.newTreeItem(nwItemType.FOLDER) is True + assert theProject.tree["0000000000011"].itemParent == C.hNovelRoot + assert theProject.tree["0000000000011"].itemRoot == C.hNovelRoot + assert theProject.tree["0000000000011"].itemClass == nwItemClass.NOVEL # Add a new file in the new folder - nwTree.setSelectedHandle("0000000000011") - assert nwTree.projTree.newTreeItem(nwItemType.FILE) is True - assert nwGUI.theProject.tree["0000000000012"].itemParent == "0000000000011" - assert nwGUI.theProject.tree["0000000000012"].itemRoot == "0000000000008" - assert nwGUI.theProject.tree["0000000000012"].itemClass == nwItemClass.NOVEL + projView.setSelectedHandle("0000000000011") + assert projView.projTree.newTreeItem(nwItemType.FILE) is True + assert theProject.tree["0000000000012"].itemParent == "0000000000011" + assert theProject.tree["0000000000012"].itemRoot == C.hNovelRoot + assert theProject.tree["0000000000012"].itemClass == nwItemClass.NOVEL # Add a new chapter next to the other new file - nwTree.setSelectedHandle("0000000000012") - assert nwTree.projTree.newTreeItem(nwItemType.FILE, hLevel=2) is True - assert nwGUI.theProject.tree["0000000000013"].itemParent == "0000000000011" - assert nwGUI.theProject.tree["0000000000013"].itemRoot == "0000000000008" - assert nwGUI.theProject.tree["0000000000013"].itemClass == nwItemClass.NOVEL + projView.setSelectedHandle("0000000000012") + assert projView.projTree.newTreeItem(nwItemType.FILE, hLevel=2) is True + assert theProject.tree["0000000000013"].itemParent == "0000000000011" + assert theProject.tree["0000000000013"].itemRoot == C.hNovelRoot + assert theProject.tree["0000000000013"].itemClass == nwItemClass.NOVEL assert nwGUI.openDocument("0000000000013") assert nwGUI.docEditor.getText() == "## New Chapter\n\n" # Add a new scene next to the other new file - nwTree.setSelectedHandle("0000000000012") - assert nwTree.projTree.newTreeItem(nwItemType.FILE, hLevel=3) is True - assert nwGUI.theProject.tree["0000000000014"].itemParent == "0000000000011" - assert nwGUI.theProject.tree["0000000000014"].itemRoot == "0000000000008" - assert nwGUI.theProject.tree["0000000000014"].itemClass == nwItemClass.NOVEL + projView.setSelectedHandle("0000000000012") + assert projView.projTree.newTreeItem(nwItemType.FILE, hLevel=3) is True + assert theProject.tree["0000000000014"].itemParent == "0000000000011" + assert theProject.tree["0000000000014"].itemRoot == C.hNovelRoot + assert theProject.tree["0000000000014"].itemClass == nwItemClass.NOVEL assert nwGUI.openDocument("0000000000014") assert nwGUI.docEditor.getText() == "### New Scene\n\n" # Add a new file to the characters folder - nwTree.setSelectedHandle("000000000000a") - assert nwTree.projTree.newTreeItem(nwItemType.FILE, hLevel=1, isNote=True) is True - assert nwGUI.theProject.tree["0000000000015"].itemParent == "000000000000a" - assert nwGUI.theProject.tree["0000000000015"].itemRoot == "000000000000a" - assert nwGUI.theProject.tree["0000000000015"].itemClass == nwItemClass.CHARACTER + projView.setSelectedHandle(C.hCharRoot) + assert projView.projTree.newTreeItem(nwItemType.FILE, hLevel=1, isNote=True) is True + assert theProject.tree["0000000000015"].itemParent == C.hCharRoot + assert theProject.tree["0000000000015"].itemRoot == C.hCharRoot + assert theProject.tree["0000000000015"].itemClass == nwItemClass.CHARACTER assert nwGUI.openDocument("0000000000015") assert nwGUI.docEditor.getText() == "# New Note\n\n" # Make sure the sibling folder bug trap works - nwTree.setSelectedHandle("0000000000013") - nwGUI.theProject.tree["0000000000013"].setParent(None) # This should not happen + projView.setSelectedHandle("0000000000013") + theProject.tree["0000000000013"].setParent(None) # This should not happen caplog.clear() - assert nwTree.projTree.newTreeItem(nwItemType.FILE) is False + assert projView.projTree.newTreeItem(nwItemType.FILE) is False assert "Internal error" in caplog.text - nwGUI.theProject.tree["0000000000013"].setParent("0000000000011") + theProject.tree["0000000000013"].setParent("0000000000011") # Cancel during creation with monkeypatch.context() as mp: mp.setattr(GuiEditLabel, "getLabel", lambda *a, **k: ("", False)) - nwTree.setSelectedHandle("0000000000013") - assert nwTree.projTree.newTreeItem(nwItemType.FILE) is False + projView.setSelectedHandle("0000000000013") + assert projView.projTree.newTreeItem(nwItemType.FILE) is False # Get the trash folder - nwTree.projTree._addTrashRoot() - trashHandle = nwGUI.theProject.trashFolder() - nwTree.setSelectedHandle(trashHandle) - assert nwTree.projTree.newTreeItem(nwItemType.FILE) is False + projView.projTree._addTrashRoot() + trashHandle = theProject.trashFolder() + projView.setSelectedHandle(trashHandle) + assert projView.projTree.newTreeItem(nwItemType.FILE) is False assert "Cannot add new files or folders to the Trash folder" in caplog.text + # Rename Item + # =========== + + # Rename plot folder + with monkeypatch.context() as mp: + mp.setattr(GuiEditLabel, "getLabel", lambda *a, **k: ("Stuff", True)) + projTree.renameTreeItem(C.hPlotRoot) is True + assert theProject.tree[C.hPlotRoot].itemName == "Stuff" + + # Rename invalid folder + projTree.renameTreeItem("0000000000000") is False + # Other Checks # ============ # Also check error handling in reveal function - assert nwTree.revealNewTreeItem("abc") is False + assert projView.projTree.revealNewTreeItem("abc") is False # Add an item that cannot be displayed in the tree - nHandle = nwGUI.theProject.newFile("Test", None) - assert nwTree.revealNewTreeItem(nHandle) is False + nHandle = theProject.newFile("Test", None) + assert projView.projTree.revealNewTreeItem(nHandle) is False # Clean up - # qtbot.stopForInteraction() + # qtbot.stop() nwGUI.closeProject() # END Test testGuiProjTree_NewItems @pytest.mark.gui -def testGuiProjTree_MoveItems(qtbot, monkeypatch, nwGUI, fncDir, mockRnd): +def testGuiProjTree_MoveItems(qtbot, monkeypatch, nwGUI, projPath, mockRnd): """Test adding and removing items from the project tree. """ - # Block message box - monkeypatch.setattr(QMessageBox, "warning", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "critical", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "information", lambda *a: QMessageBox.Yes) monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True)) - nwTree = nwGUI.projView + projView = nwGUI.projView + projTree = nwGUI.projView.projTree # Try to move item with no project - assert nwTree.projTree.moveTreeItem(1) is False + assert projView.projTree.moveTreeItem(1) is False # Create a project - prjDir = os.path.join(fncDir, "project") - buildTestProject(nwGUI, prjDir) + buildTestProject(nwGUI, projPath) # Move Documents # ============== # Add some files - nwTree.setSelectedHandle("000000000000d") - assert nwTree.projTree.newTreeItem(nwItemType.FILE) is True - assert nwTree.projTree.newTreeItem(nwItemType.FILE) is True - assert nwTree.projTree.newTreeItem(nwItemType.FILE) is True - assert nwTree.getTreeFromHandle("000000000000d") == [ - "000000000000d", "000000000000e", "000000000000f", + projView.setSelectedHandle(C.hChapterDir) + assert projTree.newTreeItem(nwItemType.FILE) is True + assert projTree.newTreeItem(nwItemType.FILE) is True + assert projTree.newTreeItem(nwItemType.FILE) is True + assert projTree.getTreeFromHandle(C.hChapterDir) == [ + C.hChapterDir, C.hChapterDoc, C.hSceneDoc, "0000000000010", "0000000000011", "0000000000012", ] # Move with no selections - nwTree.projTree.clearSelection() - assert nwTree.projTree.moveTreeItem(1) is False + projTree.clearSelection() + assert projTree.moveTreeItem(1) is False # Move second item up twice (should give same result) - nwTree.setSelectedHandle("000000000000f") - assert nwTree.projTree.moveTreeItem(-1) is True - assert nwTree.getTreeFromHandle("000000000000d") == [ - "000000000000d", "000000000000f", "000000000000e", + projView.setSelectedHandle(C.hSceneDoc) + assert projTree.moveTreeItem(-1) is True + assert projTree.getTreeFromHandle(C.hChapterDir) == [ + C.hChapterDir, C.hSceneDoc, C.hChapterDoc, "0000000000010", "0000000000011", "0000000000012", ] - assert nwTree.projTree.moveTreeItem(-1) is False - assert nwTree.getTreeFromHandle("000000000000d") == [ - "000000000000d", "000000000000f", "000000000000e", + assert projTree.moveTreeItem(-1) is False + assert projTree.getTreeFromHandle(C.hChapterDir) == [ + C.hChapterDir, C.hSceneDoc, C.hChapterDoc, "0000000000010", "0000000000011", "0000000000012", ] # Restore - assert nwTree.projTree.moveTreeItem(1) is True - assert nwTree.getTreeFromHandle("000000000000d") == [ - "000000000000d", "000000000000e", "000000000000f", + assert projTree.moveTreeItem(1) is True + assert projTree.getTreeFromHandle(C.hChapterDir) == [ + C.hChapterDir, C.hChapterDoc, C.hSceneDoc, "0000000000010", "0000000000011", "0000000000012", ] # Move fifth item down twice (should give same result) - nwTree.setSelectedHandle("0000000000011") - assert nwTree.projTree.moveTreeItem(1) is True - assert nwTree.getTreeFromHandle("000000000000d") == [ - "000000000000d", "000000000000e", "000000000000f", + projView.setSelectedHandle("0000000000011") + assert projTree.moveTreeItem(1) is True + assert projTree.getTreeFromHandle(C.hChapterDir) == [ + C.hChapterDir, C.hChapterDoc, C.hSceneDoc, "0000000000010", "0000000000012", "0000000000011", ] - assert nwTree.projTree.moveTreeItem(1) is False - assert nwTree.getTreeFromHandle("000000000000d") == [ - "000000000000d", "000000000000e", "000000000000f", + assert projTree.moveTreeItem(1) is False + assert projTree.getTreeFromHandle(C.hChapterDir) == [ + C.hChapterDir, C.hChapterDoc, C.hSceneDoc, "0000000000010", "0000000000012", "0000000000011", ] # Restore - assert nwTree.projTree.moveTreeItem(-1) is True - assert nwTree.getTreeFromHandle("000000000000d") == [ - "000000000000d", "000000000000e", "000000000000f", + assert projTree.moveTreeItem(-1) is True + assert projTree.getTreeFromHandle(C.hChapterDir) == [ + C.hChapterDir, C.hChapterDoc, C.hSceneDoc, "0000000000010", "0000000000011", "0000000000012", ] # Move down again, and restore via undo - nwTree.setSelectedHandle("0000000000011") - assert nwTree.projTree.moveTreeItem(1) is True - assert nwTree.getTreeFromHandle("000000000000d") == [ - "000000000000d", "000000000000e", "000000000000f", + projView.setSelectedHandle("0000000000011") + assert projTree.moveTreeItem(1) is True + assert projTree.getTreeFromHandle(C.hChapterDir) == [ + C.hChapterDir, C.hChapterDoc, C.hSceneDoc, "0000000000010", "0000000000012", "0000000000011", ] - assert nwTree.projTree.undoLastMove() is True - assert nwTree.getTreeFromHandle("000000000000d") == [ - "000000000000d", "000000000000e", "000000000000f", + assert projTree.undoLastMove() is True + assert projTree.getTreeFromHandle(C.hChapterDir) == [ + C.hChapterDir, C.hChapterDoc, C.hSceneDoc, "0000000000010", "0000000000011", "0000000000012", ] # Root Folder # =========== - nwTree.setSelectedHandle("0000000000008") - assert nwGUI.theProject.tree._treeOrder.index("0000000000008") == 0 + projView.setSelectedHandle(C.hNovelRoot) + assert nwGUI.theProject.tree._treeOrder.index(C.hNovelRoot) == 0 # Move novel folder up - assert nwTree.projTree.moveTreeItem(-1) is False - assert nwGUI.theProject.tree._treeOrder.index("0000000000008") == 0 + assert projTree.moveTreeItem(-1) is False + assert nwGUI.theProject.tree._treeOrder.index(C.hNovelRoot) == 0 # Move novel folder down - assert nwTree.projTree.moveTreeItem(1) is True - assert nwGUI.theProject.tree._treeOrder.index("0000000000008") == 1 + assert projTree.moveTreeItem(1) is True + assert nwGUI.theProject.tree._treeOrder.index(C.hNovelRoot) == 1 # Move novel folder up again - assert nwTree.projTree.moveTreeItem(-1) is True - assert nwGUI.theProject.tree._treeOrder.index("0000000000008") == 0 + assert projTree.moveTreeItem(-1) is True + assert nwGUI.theProject.tree._treeOrder.index(C.hNovelRoot) == 0 # Clean up - # qtbot.stopForInteraction() + # qtbot.stop() nwGUI.closeProject() # END Test testGuiProjTree_MoveItems @pytest.mark.gui -def testGuiProjTree_DeleteItems(qtbot, caplog, monkeypatch, nwGUI, fncDir, mockRnd): - """Test adding and removing items from the project tree. +def testGuiProjTree_RequestDeleteItem(qtbot, caplog, monkeypatch, nwGUI, projPath, mockRnd): + """Test external requests for removing items from project tree. """ - # Block message box - monkeypatch.setattr(QMessageBox, "warning", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "critical", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "information", lambda *a: QMessageBox.Yes) monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True)) - nwTree = nwGUI.projView + projView = nwGUI.projView + projTree = nwGUI.projView.projTree # Try to run with no project - assert nwTree.emptyTrash() is False - assert nwTree.deleteItem() is False + assert projView.requestDeleteItem() is False # Create a project - prjDir = os.path.join(fncDir, "project") - buildTestProject(nwGUI, prjDir) + buildTestProject(nwGUI, projPath) # Try emptying the trash already now, when there is no trash folder - assert nwTree.emptyTrash() is False + assert projView.emptyTrash() is False # Add some files - nwTree.setSelectedHandle("000000000000d") - assert nwTree.projTree.newTreeItem(nwItemType.FILE) is True - assert nwTree.projTree.newTreeItem(nwItemType.FILE) is True - assert nwTree.projTree.newTreeItem(nwItemType.FILE) is True - assert nwTree.getTreeFromHandle("000000000000d") == [ - "000000000000d", "000000000000e", "000000000000f", + projView.setSelectedHandle(C.hChapterDir) + assert projTree.newTreeItem(nwItemType.FILE) is True + assert projTree.newTreeItem(nwItemType.FILE) is True + assert projTree.newTreeItem(nwItemType.FILE) is True + assert projTree.getTreeFromHandle(C.hChapterDir) == [ + C.hChapterDir, C.hChapterDoc, C.hSceneDoc, "0000000000010", "0000000000011", "0000000000012", ] # Delete item without focus -> blocked monkeypatch.setattr(GuiProjectTree, "hasFocus", lambda *a: False) - nwTree.setSelectedHandle("0000000000012") - assert nwTree.deleteItem() is False + projView.setSelectedHandle("0000000000012") + assert projView.requestDeleteItem() is False monkeypatch.setattr(GuiProjectTree, "hasFocus", lambda *a: True) # No selection made - nwTree.projTree.clearSelection() + projTree.clearSelection() caplog.clear() - assert nwTree.deleteItem() is False + assert projView.requestDeleteItem() is False assert "no item to delete" in caplog.text # Not a valid handle - nwTree.projTree.clearSelection() + projTree.clearSelection() caplog.clear() - assert nwTree.deleteItem("0000000000000") is False - assert "Could not find tree item" in caplog.text + assert projView.requestDeleteItem("0000000000000") is False + assert "No tree item with handle '0000000000000'" in caplog.text - # Delete Folder/Root - # ================== + # Delete Root Folders + # =================== - # Deleting non-empty folders is blocked - assert nwTree.deleteItem("0000000000008") is False # Novel Root - assert nwTree.deleteItem("000000000000a") is True # Character Root + assert projView.requestDeleteItem(C.hNovelRoot) is False # Novel Root is blocked + assert projView.requestDeleteItem(C.hCharRoot) is True # Character Root # Delete File # =========== # Block adding trash folder - funcPointer = nwTree.projTree._addTrashRoot - nwTree.projTree._addTrashRoot = lambda *a: None - assert nwTree.deleteItem("0000000000012") is False - nwTree.projTree._addTrashRoot = funcPointer + funcPointer = projTree._addTrashRoot + projTree._addTrashRoot = lambda *a: None + assert projView.requestDeleteItem("0000000000012") is False + projTree._addTrashRoot = funcPointer # Delete last two documents, which also adds the trash folder - assert nwTree.deleteItem("0000000000012") is True - assert nwTree.deleteItem("0000000000011") is True - assert nwTree.getTreeFromHandle("000000000000d") == [ - "000000000000d", "000000000000e", "000000000000f", + assert projView.requestDeleteItem("0000000000012") is True + assert projView.requestDeleteItem("0000000000011") is True + assert projTree.getTreeFromHandle(C.hChapterDir) == [ + C.hChapterDir, C.hChapterDoc, C.hSceneDoc, "0000000000010" ] trashHandle = nwGUI.theProject.tree.trashRoot() - assert nwTree.getTreeFromHandle(trashHandle) == [ + assert projTree.getTreeFromHandle(trashHandle) == [ trashHandle, "0000000000012", "0000000000011" ] - # Delete the first file again (permanent), and ask for permission - # Also open the document in the editor, which should trigger a close - assert os.path.isfile(os.path.join(prjDir, "content", "0000000000012.nwd")) - assert "0000000000012" in nwGUI.theProject.tree - assert nwGUI.docEditor.docHandle() is None - assert nwGUI.openDocument("0000000000012") is True - assert nwGUI.docEditor.docHandle() == "0000000000012" - assert nwTree.deleteItem("0000000000012") is True - assert nwGUI.docEditor.docHandle() is None - assert not os.path.isfile(os.path.join(prjDir, "content", "0000000000012.nwd")) - assert "0000000000012" not in nwGUI.theProject.tree - assert nwTree.getTreeFromHandle(trashHandle) == [ - trashHandle, "0000000000011" - ] + # Try to delete the trash folder + caplog.clear() + assert projView.requestDeleteItem("0000000000013") is False + assert "Cannot delete the Trash folder" in caplog.text - # Delete the second file, and skip asking for permission - assert os.path.isfile(os.path.join(prjDir, "content", "0000000000011.nwd")) - assert "0000000000011" in nwGUI.theProject.tree - assert nwTree.deleteItem("0000000000011", alreadyAsked=True) is True - assert not os.path.isfile(os.path.join(prjDir, "content", "0000000000011.nwd")) - assert "0000000000011" not in nwGUI.theProject.tree - assert nwTree.getTreeFromHandle(trashHandle) == [trashHandle] - - # Delete Folder - # ============= - - trashHandle = nwGUI.theProject.tree.trashRoot() - - # Add a folder with two files - nwTree.setSelectedHandle("0000000000009") - assert nwTree.projTree.newTreeItem(nwItemType.FOLDER) is True - nwTree.setSelectedHandle("0000000000014") - assert nwTree.projTree.newTreeItem(nwItemType.FILE) is True - assert nwTree.projTree.newTreeItem(nwItemType.FILE) is True - assert os.path.isfile(os.path.join(fncDir, "project", "content", "0000000000015.nwd")) - assert os.path.isfile(os.path.join(fncDir, "project", "content", "0000000000016.nwd")) - - # Delete the folder, which moves everything to Trash - assert nwTree.getTreeFromHandle("0000000000014") == [ - "0000000000014", "0000000000015", "0000000000016" - ] - assert nwTree.deleteItem("0000000000014") is True - assert nwTree.getTreeFromHandle(trashHandle) == [ - trashHandle, "0000000000014", "0000000000015", "0000000000016" - ] - assert os.path.isfile(os.path.join(fncDir, "project", "content", "0000000000015.nwd")) - assert os.path.isfile(os.path.join(fncDir, "project", "content", "0000000000016.nwd")) - - # Delete again, which should delete folder and all files - assert nwTree.deleteItem("0000000000014") is True - assert nwTree.getTreeFromHandle(trashHandle) == [trashHandle] - assert not os.path.isfile(os.path.join(fncDir, "project", "content", "0000000000015.nwd")) - assert not os.path.isfile(os.path.join(fncDir, "project", "content", "0000000000016.nwd")) - - # Add an empty folder, which can be deleted with no further restrictions - nwTree.setSelectedHandle("0000000000009") - assert nwTree.projTree.newTreeItem(nwItemType.FOLDER) is True - assert nwTree.getTreeFromHandle("0000000000009") == ["0000000000009", "0000000000017"] - - nwTree.setSelectedHandle("0000000000017") - assert nwTree.deleteItem("0000000000017") is True - assert nwTree.getTreeFromHandle("0000000000009") == ["0000000000009"] - - # Empty Trash - # =========== - - # Try to empty trash that is already empty - assert nwTree.getTreeFromHandle(trashHandle) == [trashHandle] - assert nwTree.emptyTrash() is False - - # Move the two remaining scene documents to trash - assert nwTree.deleteItem("000000000000f") is True - assert nwTree.deleteItem("0000000000010") is True - assert nwTree.getTreeFromHandle("000000000000d") == [ - "000000000000d", "000000000000e" - ] - assert nwTree.getTreeFromHandle(trashHandle) == [ - trashHandle, "000000000000f", "0000000000010" - ] - - # Empty trash, but select no on question - with monkeypatch.context() as mp: - mp.setattr(QMessageBox, "question", lambda *a: QMessageBox.No) - assert nwTree.emptyTrash() is False - - # Empty the trash proper - assert nwTree.emptyTrash() is True - assert nwTree.getTreeFromHandle(trashHandle) == [trashHandle] - - # Try to delete a file, but block the underlying deletion of the file on disk - assert os.path.isfile(os.path.join(fncDir, "project", "content", "000000000000e.nwd")) - with monkeypatch.context() as mp: - mp.setattr("novelwriter.core.document.NWDoc.deleteDocument", lambda *a: False) - assert nwTree.deleteItem("000000000000e") is True - assert nwTree.deleteItem("000000000000e") is True - assert os.path.isfile(os.path.join(fncDir, "project", "content", "000000000000e.nwd")) - - # Delete proper - assert nwTree.projTree._deleteTreeItem("000000000000e") is True - assert not os.path.isfile(os.path.join(fncDir, "project", "content", "000000000000e.nwd")) - - # Clean up - # qtbot.stopForInteraction() nwGUI.closeProject() -# END Test testGuiProjTree_DeleteItems +# END Test testGuiProjTree_RequestDeleteItem @pytest.mark.gui -def testGuiProjTree_ContextMenu(qtbot, caplog, monkeypatch, nwGUI, fncDir, mockRnd): +def testGuiProjTree_MoveItemToTrash(qtbot, caplog, monkeypatch, nwGUI, projPath, mockRnd): + """Test moving items to Trash. + """ + monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True)) + + theProject = nwGUI.theProject + projTree = nwGUI.projView.projTree + + # Create a project + buildTestProject(nwGUI, projPath) + + # Invalid item + caplog.clear() + assert projTree.moveItemToTrash(C.hInvalid) is False + assert "Could not find tree item for deletion" in caplog.text + + # Root folders cannot be moved to Trash + caplog.clear() + assert projTree.moveItemToTrash(C.hNovelRoot) is False + assert "Root folders cannot be moved to Trash" in caplog.text + + # Block adding trash folder + funcPointer = projTree._addTrashRoot + projTree._addTrashRoot = lambda *a: None + + caplog.clear() + assert projTree.moveItemToTrash(C.hTitlePage) is False + assert theProject.tree.isTrash(C.hTitlePage) is False + assert "Could not delete item" in caplog.text + + projTree._addTrashRoot = funcPointer + + # User cancels action + with monkeypatch.context() as mp: + mp.setattr(QMessageBox, "question", lambda *a: QMessageBox.No) + assert projTree.moveItemToTrash(C.hTitlePage) is False + assert theProject.tree.isTrash(C.hTitlePage) is False + + # Move a document to Trash + assert projTree.moveItemToTrash(C.hTitlePage) is True + assert theProject.tree.isTrash(C.hTitlePage) is True + + # Cannot be moved again + caplog.clear() + assert projTree.moveItemToTrash(C.hTitlePage) is False + assert "Item is already in the Trash folder" in caplog.text + + nwGUI.closeProject() + +# END Test testGuiProjTree_MoveItemToTrash + + +@pytest.mark.gui +def testGuiProjTree_PermanentlyDeleteItem(qtbot, caplog, monkeypatch, nwGUI, projPath, mockRnd): + """Test permanently deleting items. + """ + monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True)) + + theProject = nwGUI.theProject + projTree = nwGUI.projView.projTree + + # Create a project + buildTestProject(nwGUI, projPath) + + # Invalid item + caplog.clear() + assert projTree.permanentlyDeleteItem(C.hInvalid) is False + assert "Could not find tree item for deletion" in caplog.text + + # Not deleting root item in use + caplog.clear() + assert projTree.permanentlyDeleteItem(C.hNovelRoot) is False + assert "Root folders can only be deleted when they are empty" in caplog.text + assert C.hNovelRoot in theProject.tree + + # Deleting unused root item is allowed + caplog.clear() + assert projTree.permanentlyDeleteItem(C.hPlotRoot) is True + assert C.hPlotRoot not in theProject.tree + + # User cancels action + with monkeypatch.context() as mp: + mp.setattr(QMessageBox, "question", lambda *a: QMessageBox.No) + assert projTree.permanentlyDeleteItem(C.hTitlePage) is False + assert C.hTitlePage in theProject.tree + + # Deleting file is OK, and if it is open, it should close + assert nwGUI.openDocument(C.hTitlePage) is True + assert nwGUI.docEditor.docHandle() == C.hTitlePage + assert projTree.permanentlyDeleteItem(C.hTitlePage) is True + assert C.hTitlePage not in theProject.tree + assert nwGUI.docEditor.docHandle() is None + + # Deleting folder + files recursiely is ok + assert projTree.permanentlyDeleteItem(C.hChapterDir) is True + assert C.hChapterDir not in theProject.tree + assert C.hChapterDoc not in theProject.tree + assert C.hSceneDoc not in theProject.tree + + nwGUI.closeProject() + +# END Test testGuiProjTree_PermanentlyDeleteItem + + +@pytest.mark.gui +def testGuiProjTree_EmptyTrash(qtbot, caplog, monkeypatch, nwGUI, projPath, mockRnd): + """Test emptying Trash. + """ + monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True)) + + theProject = nwGUI.theProject + projTree = nwGUI.projView.projTree + + # No project open + caplog.clear() + assert projTree.emptyTrash() is False + assert "No project open" in caplog.text + + # Create a project + buildTestProject(nwGUI, projPath) + + # No Trash folder + assert projTree.emptyTrash() is False + + # Move some documents to Trash + assert projTree.moveItemToTrash(C.hTitlePage) is True + assert projTree.moveItemToTrash(C.hChapterDir) is True + + assert theProject.tree.isTrash(C.hTitlePage) is True + assert theProject.tree.isTrash(C.hChapterDir) is True + assert theProject.tree.isTrash(C.hChapterDoc) is True + assert theProject.tree.isTrash(C.hSceneDoc) is True + + # User cancels + with monkeypatch.context() as mp: + mp.setattr(QMessageBox, "question", lambda *a: QMessageBox.No) + assert projTree.emptyTrash() is False + assert C.hTitlePage in theProject.tree + assert C.hChapterDir in theProject.tree + assert C.hChapterDoc in theProject.tree + assert C.hSceneDoc in theProject.tree + + # Run again to empty all items + assert projTree.emptyTrash() is True + assert C.hTitlePage not in theProject.tree + assert C.hChapterDir not in theProject.tree + assert C.hChapterDoc not in theProject.tree + assert C.hSceneDoc not in theProject.tree + + # Running Emtpy Trash again is cancelled due to empty folder + assert projTree.emptyTrash() is False + + nwGUI.closeProject() + +# END Test testGuiProjTree_EmptyTrash + + +@pytest.mark.gui +def testGuiProjTree_ContextMenu(qtbot, monkeypatch, nwGUI, projPath, mockRnd): """Test the building of the project tree context menu. All this does is test that the menu builds. It doesn't open the actual menu, """ - # Block message box - monkeypatch.setattr(QMessageBox, "warning", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "critical", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "information", lambda *a: QMessageBox.Yes) monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True)) monkeypatch.setattr(QMenu, "exec_", lambda *a: None) # Create a project - prjDir = os.path.join(fncDir, "project") - buildTestProject(nwGUI, prjDir) + buildTestProject(nwGUI, projPath) # Handles for new objects - hNovelRoot = "0000000000008" - hTitlePage = "000000000000c" - hChapterDir = "000000000000d" - hChapterFile = "000000000000e" - hCharRoot = "000000000000a" - hCharNote = "0000000000011" - hNovelNote = "0000000000012" + hCharNote = "0000000000011" + hNovelNote = "0000000000012" + hSubNote = "0000000000013" + hNewFolderOne = "0000000000014" + hNewFolderTwo = "0000000000016" + projView = nwGUI.projView projTree = nwGUI.projView.projTree - projTree._getTreeItem(hNovelRoot).setExpanded(True) - projTree._getTreeItem(hChapterDir).setExpanded(True) + projTree.setExpandedFromHandle(None, True) projTree._addTrashRoot() hTrashRoot = projTree.theProject.tree.trashRoot() - projTree.setSelectedHandle(hCharRoot) + projTree.setSelectedHandle(C.hCharRoot) projTree.newTreeItem(nwItemType.FILE) - projTree.setSelectedHandle(hNovelRoot) + projTree.setSelectedHandle(C.hNovelRoot) projTree.newTreeItem(nwItemType.FILE, isNote=True) + nwGUI.theProject.newFile("SubNote", hNovelNote) + projTree.revealNewTreeItem(hSubNote) + assert nwGUI.theProject.tree[hSubNote].itemParent == hNovelNote + def itemPos(tHandle): return projTree.visualItemRect(projTree._getTreeItem(tHandle)).center() @@ -503,16 +558,17 @@ def testGuiProjTree_ContextMenu(qtbot, caplog, monkeypatch, nwGUI, fncDir, mockR # Generate the possible menu combinarions assert projTree._openContextMenu(itemPos(hTrashRoot)) is True - assert projTree._openContextMenu(itemPos(hNovelRoot)) is True + assert projTree._openContextMenu(itemPos(C.hNovelRoot)) is True assert projTree._openContextMenu(itemPos(hNovelNote)) is True - assert projTree._openContextMenu(itemPos(hTitlePage)) is True - assert projTree._openContextMenu(itemPos(hChapterDir)) is True - assert projTree._openContextMenu(itemPos(hChapterFile)) is True - assert projTree._openContextMenu(itemPos(hCharRoot)) is True + assert projTree._openContextMenu(itemPos(C.hTitlePage)) is True + assert projTree._openContextMenu(itemPos(C.hChapterDir)) is True + assert projTree._openContextMenu(itemPos(C.hChapterDoc)) is True + assert projTree._openContextMenu(itemPos(C.hCharRoot)) is True assert projTree._openContextMenu(itemPos(hCharNote)) is True + assert projTree._openContextMenu(itemPos(hNovelNote)) is True # Check the keyboard shortcut handler as well - projTree.setSelectedHandle(hNovelRoot) + projTree.setSelectedHandle(C.hNovelRoot) assert projTree.openContextOnSelected() is True projTree.clearSelection() assert projTree.openContextOnSelected() is False @@ -522,10 +578,10 @@ def testGuiProjTree_ContextMenu(qtbot, caplog, monkeypatch, nwGUI, fncDir, mockR # Trigger the dedicated functions the menu entries connect to nwItem = projTree.theProject.tree[hNovelNote] - # Toggle exported flag - assert nwItem.isExported is True - projTree._toggleItemExported(hNovelNote) - assert nwItem.isExported is False + # Toggle active flag + assert nwItem.isActive is True + projTree._toggleItemActive(hNovelNote) + assert nwItem.isActive is False # Change item status assert nwItem.itemStatus == "s000000" @@ -544,6 +600,334 @@ def testGuiProjTree_ContextMenu(qtbot, caplog, monkeypatch, nwGUI, fncDir, mockR projTree._changeItemLayout(hNovelNote, nwItemLayout.NOTE) assert nwItem.itemLayout == nwItemLayout.NOTE + # Convert Folders to Documents + # ============================ + + projView.setSelectedHandle(hNovelNote) + assert projView.projTree.newTreeItem(nwItemType.FOLDER) is True + projView.setSelectedHandle(hNewFolderOne) + assert projView.projTree.newTreeItem(nwItemType.FILE) is True + + projView.setSelectedHandle(hNovelNote) + assert projView.projTree.newTreeItem(nwItemType.FOLDER) is True + projView.setSelectedHandle(hNewFolderTwo) + assert projView.projTree.newTreeItem(nwItemType.FILE, isNote=True) is True + + # Click no on the dialog + with monkeypatch.context() as mp: + mp.setattr(QMessageBox, "question", lambda *a: QMessageBox.No) + projTree._covertFolderToFile(hNewFolderOne, nwItemLayout.DOCUMENT) + assert nwGUI.theProject.tree[hNewFolderOne].isFolderType() + + # Convert the first folder to a document + projTree._covertFolderToFile(hNewFolderOne, nwItemLayout.DOCUMENT) + assert nwGUI.theProject.tree[hNewFolderOne].isFileType() + assert nwGUI.theProject.tree[hNewFolderOne].isDocumentLayout() + + # Convert the second folder to a note + projTree._covertFolderToFile(hNewFolderTwo, nwItemLayout.NOTE) + assert nwGUI.theProject.tree[hNewFolderTwo].isFileType() + assert nwGUI.theProject.tree[hNewFolderTwo].isNoteLayout() + # qtbot.stop() # END Test testGuiProjTree_ContextMenu + + +@pytest.mark.gui +def testGuiProjTree_MergeDocuments(qtbot, monkeypatch, nwGUI, projPath, mockRnd, ipsumText): + """Test the merge document function. + """ + mergeData = {} + + monkeypatch.setattr(GuiDocMerge, "__init__", lambda *a: None) + monkeypatch.setattr(GuiDocMerge, "exec_", lambda *a: None) + monkeypatch.setattr(GuiDocMerge, "result", lambda *a: QDialog.Accepted) + monkeypatch.setattr(GuiDocMerge, "getData", lambda *a: mergeData) + + # Create a project + buildTestProject(nwGUI, projPath) + + theProject = nwGUI.theProject + projTree = nwGUI.projView.projTree + + mergedDoc1 = "0000000000014" + + # Create File to Merge + hChapter1 = theProject.newFile("Chapter 1", C.hNovelRoot) + hSceneOne11 = theProject.newFile("Scene 1.1", hChapter1) + hSceneOne12 = theProject.newFile("Scene 1.2", hChapter1) + hSceneOne13 = theProject.newFile("Scene 1.3", hChapter1) + + docText1 = "\n\n".join(ipsumText[0:2]) + "\n\n" + docText2 = "\n\n".join(ipsumText[1:3]) + "\n\n" + docText3 = "\n\n".join(ipsumText[2:4]) + "\n\n" + docText4 = "\n\n".join(ipsumText[3:5]) + "\n\n" + + lenText1 = len(docText1) + lenText2 = len(docText2) + lenText3 = len(docText3) + lenText4 = len(docText4) + lenAll = lenText1 + lenText2 + lenText3 + lenText4 + + theProject.writeNewFile(hChapter1, 2, True, docText1) + theProject.writeNewFile(hSceneOne11, 3, True, docText2) + theProject.writeNewFile(hSceneOne12, 3, True, docText3) + theProject.writeNewFile(hSceneOne13, 3, True, docText4) + + projTree.revealNewTreeItem(hChapter1) + projTree.revealNewTreeItem(hSceneOne11) + projTree.revealNewTreeItem(hSceneOne12) + projTree.revealNewTreeItem(hSceneOne13) + + # Invalid file handle + assert projTree._mergeDocuments(C.hInvalid, False) is False + + # Cannot merge root item + assert projTree._mergeDocuments(C.hNovelRoot, False) is False + + # Merge to new file, but there is now merge data + mergeData.clear() + assert projTree._mergeDocuments(hChapter1, True) is False + + # Merge to New Doc + # ================ + + # Set merge job for new documents + mergeData["finalItems"] = [hChapter1, hSceneOne11, hSceneOne12, hSceneOne13] + mergeData["moveToTrash"] = False + + # User cancels merge + with monkeypatch.context() as mp: + mp.setattr(GuiDocMerge, "result", lambda *a: QDialog.Rejected) + assert projTree._mergeDocuments(hChapter1, True) is False + + # The merge goes through + assert projTree._mergeDocuments(hChapter1, True) is True + assert len(theProject.storage.getDocument(mergedDoc1).readDocument()) > lenAll + + # Merge to Existing Doc + # ===================== + + # Set merge job for parent document + mergeData["finalItems"] = [hSceneOne11, hSceneOne12, hSceneOne13] + mergeData["moveToTrash"] = False + + # Merging to a folder is not allowed + assert projTree._mergeDocuments(C.hChapterDir, False) is False + + # Block writing and check error handling + with monkeypatch.context() as mp: + mp.setattr("builtins.open", causeOSError) + assert projTree._mergeDocuments(hChapter1, False) is False + + # Successful merge, and move to trash + mergeData["moveToTrash"] = True + assert len(theProject.storage.getDocument(hChapter1).readDocument()) < lenAll + assert projTree._mergeDocuments(hChapter1, False) is True + assert len(theProject.storage.getDocument(hChapter1).readDocument()) > lenAll + + assert theProject.tree.isTrash(hSceneOne11) + assert theProject.tree.isTrash(hSceneOne12) + assert theProject.tree.isTrash(hSceneOne13) + + # qtbot.stop() + +# END Test testGuiProjTree_MergeDocuments + + +@pytest.mark.gui +def testGuiProjTree_SplitDocument(qtbot, monkeypatch, nwGUI, projPath, mockRnd, ipsumText): + """Test the split document function. + """ + splitData = {} + splitText = [] + + monkeypatch.setattr(GuiDocSplit, "__init__", lambda *a: None) + monkeypatch.setattr(GuiDocSplit, "exec_", lambda *a: None) + monkeypatch.setattr(GuiDocSplit, "result", lambda *a: QDialog.Accepted) + monkeypatch.setattr(GuiDocSplit, "getData", lambda *a: (splitData, splitText)) + + # Create a project + buildTestProject(nwGUI, projPath) + + theProject = nwGUI.theProject + projTree = nwGUI.projView.projTree + + docText = ( + "Text\n\n" + "##! Prologue\n\nText\n\n" + "## Chapter One\n\nText\n\n" + "### Scene One\n\nText\n\n" + "### Scene Two\n\nText\n\n" + "## Chapter Two\n\nText\n\n" + "### Scene Three\n\nText\n\n" + "### Scene Four\n\nText\n\n" + "#! New Title\n\nText\n\n" + "## New Chapter\n\nText\n\n" + "### New Scene\n\nText\n\n" + "#### New Section\n\nText\n\n" + ) + + hSplitDoc = theProject.newFile("Split Doc", C.hNovelRoot) + theProject.writeNewFile(hSplitDoc, 1, True, docText) + projTree.revealNewTreeItem(hSplitDoc, nHandle=C.hNovelRoot, wordCount=True) + + docText = f"# Split Doc\n\n{docText}" + splitData["headerList"] = [ + (0, 1, "Split Doc"), + (4, 2, "Prologue"), + (8, 2, "Chapter One"), + (12, 3, "Scene One"), + (16, 3, "Scene Two"), + (20, 2, "Chapter Two"), + (24, 3, "Scene Three"), + (28, 3, "Scene Four"), + (32, 1, "New Title"), + (36, 2, "New Chapter"), + (40, 3, "New Scene"), + (44, 4, "New Section"), + ] + + fstSet = [ + "0000000000011", "0000000000012", "0000000000013", "0000000000014", + "0000000000015", "0000000000016", "0000000000017", "0000000000018", + "0000000000019", "000000000001a", "000000000001b", "000000000001c", + ] + sndSet = [ + "000000000001d", "000000000001e", "000000000001f", "0000000000020", + "0000000000021", "0000000000022", "0000000000023", "0000000000024", + "0000000000025", "0000000000026", "0000000000027", "0000000000028", + ] + trdSet = [ + "000000000002a", "000000000002b", "000000000002c", "000000000002d", + "000000000002e", "000000000002f", "0000000000030", "0000000000031", + "0000000000032", "0000000000033", "0000000000034", "0000000000035", + ] + + # Try to split an invalid document and a non-document + assert projTree._splitDocument(C.hInvalid) is False + assert projTree._splitDocument(C.hNovelRoot) is False + + # Split into same root folder + splitData["intoFolder"] = False + + # Writing fails + with monkeypatch.context() as mp: + mp.setattr("builtins.open", causeOSError) + assert projTree._splitDocument(hSplitDoc) is True + for tHandle in fstSet: + assert tHandle in theProject.tree + assert not (projPath / "content" / f"{tHandle}.nwd").is_file() + + # Writing succeeds + assert projTree._splitDocument(hSplitDoc) is True + for tHandle in sndSet: + assert tHandle in theProject.tree + assert (projPath / "content" / f"{tHandle}.nwd").is_file() + + # Add to a folder and move source to trash + splitData["intoFolder"] = True + splitData["moveToTrash"] = True + assert projTree._splitDocument(hSplitDoc) is True + assert "0000000000029" in theProject.tree # The folder + for tHandle in trdSet: + assert tHandle in theProject.tree + assert (projPath / "content" / f"{tHandle}.nwd").is_file() + + assert theProject.tree.isTrash(hSplitDoc) is True + + # Cancelled by user + with monkeypatch.context() as mp: + mp.setattr(GuiDocSplit, "result", lambda *a: QDialog.Rejected) + assert projTree._splitDocument(hSplitDoc) is False + + # qtbot.stop() + +# END Test testGuiProjTree_SplitDocument + + +@pytest.mark.gui +def testGuiProjTree_Other(qtbot, monkeypatch, nwGUI, projPath, mockRnd): + """Test various parts of the project tree class not covered by + other tests. + """ + # Create a project + buildTestProject(nwGUI, projPath) + + projView = nwGUI.projView + projTree = nwGUI.projView.projTree + + # Method: initSettings + # ==================== + + # Test that the scrollbar setting works + nwGUI.mainConf.hideVScroll = True + nwGUI.mainConf.hideHScroll = True + projView.initSettings() + assert projTree.verticalScrollBarPolicy() == Qt.ScrollBarAlwaysOff + assert projTree.horizontalScrollBarPolicy() == Qt.ScrollBarAlwaysOff + + nwGUI.mainConf.hideVScroll = False + nwGUI.mainConf.hideHScroll = False + projView.initSettings() + assert projTree.verticalScrollBarPolicy() == Qt.ScrollBarAsNeeded + assert projTree.horizontalScrollBarPolicy() == Qt.ScrollBarAsNeeded + + # Method: revealNewTreeItem + # ========================= + + # Send invalid handle + assert projTree.revealNewTreeItem(C.hInvalid) is False + + # Try to add an oprhaned file to the tree + nHandle = nwGUI.theProject.newFile("Test", C.hNovelRoot) + nwGUI.theProject.tree[nHandle].setParent(None) + assert projTree.revealNewTreeItem(nHandle) is False + + # Method: undoLastMove + # ==================== + + # Nothing to move + assert projTree.undoLastMove() is False + + projTree._lastMove["item"] = QTreeWidgetItem() + projTree._lastMove["parent"] = QTreeWidgetItem() + projTree._lastMove["index"] = 0 + assert projTree.undoLastMove() is False + + projTree._lastMove["item"] = projTree._treeMap[C.hTitlePage] + projTree._lastMove["parent"] = QTreeWidgetItem() + projTree._lastMove["index"] = 0 + assert projTree.undoLastMove() is False + + # Slot: _treeDoubleClick + # ====================== + + # Try to open a file with nothings selected + projTree.clearSelection() + projTree._treeDoubleClick(QTreeWidgetItem(), 0) + assert nwGUI.docEditor.docHandle() is None + + # When the item cannot be found + projTree._getTreeItem(C.hTitlePage).setSelected(True) + with monkeypatch.context() as mp: + mp.setattr("novelwriter.core.tree.NWTree.__getitem__", lambda *a: None) + projTree._treeDoubleClick(QTreeWidgetItem(), 0) + assert nwGUI.docEditor.docHandle() is None + + # Successfully open a file + projTree._treeDoubleClick(projTree._getTreeItem(C.hTitlePage), 0) + assert nwGUI.docEditor.docHandle() == C.hTitlePage + projTree._getTreeItem(C.hTitlePage).setSelected(False) + + # A non-file item should be expanded instead + projTree._getTreeItem(C.hNovelRoot).setExpanded(False) + projTree._getTreeItem(C.hNovelRoot).setSelected(True) + projTree._treeDoubleClick(projTree._getTreeItem(C.hNovelRoot), 1) + assert nwGUI.docEditor.docHandle() == C.hTitlePage + assert projTree._getTreeItem(C.hNovelRoot).isExpanded() is True + + # qtbot.stop() + +# END Test testGuiProjTree_Other diff --git a/tests/test_gui/test_gui_statusbar.py b/tests/test_gui/test_gui_statusbar.py index 0c6b1e81..a057fb36 100644 --- a/tests/test_gui/test_gui_statusbar.py +++ b/tests/test_gui/test_gui_statusbar.py @@ -22,80 +22,75 @@ along with this program. If not, see . import time import pytest -from tools import buildTestProject +from tools import C, buildTestProject -from PyQt5.QtWidgets import QMessageBox - -from novelwriter.core import NWDoc from novelwriter.enum import nwState @pytest.mark.gui -def testGuiStatusBar_Main(qtbot, monkeypatch, nwGUI, fncProj, mockRnd): +def testGuiStatusBar_Main(qtbot, nwGUI, projPath, mockRnd): """Test the the various features of the status bar. """ - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - - buildTestProject(nwGUI, fncProj) - cHandle = nwGUI.theProject.newFile("A Note", "000000000000a") - newDoc = NWDoc(nwGUI.theProject, cHandle) + buildTestProject(nwGUI, projPath) + cHandle = nwGUI.theProject.newFile("A Note", C.hCharRoot) + newDoc = nwGUI.theProject.storage.getDocument(cHandle) newDoc.writeDocument("# A Note\n\n") - nwGUI.projView.revealNewTreeItem(cHandle) + nwGUI.projView.projTree.revealNewTreeItem(cHandle) nwGUI.rebuildIndex(beQuiet=True) # Reference Time refTime = time.time() - nwGUI.statusBar.setRefTime(refTime) - assert nwGUI.statusBar.refTime == refTime + nwGUI.mainStatus.setRefTime(refTime) + assert nwGUI.mainStatus.refTime == refTime # Project Status - nwGUI.statusBar.setProjectStatus(nwState.NONE) - assert nwGUI.statusBar.projIcon._theCol == nwGUI.statusBar.projIcon._colNone - nwGUI.statusBar.setProjectStatus(nwState.BAD) - assert nwGUI.statusBar.projIcon._theCol == nwGUI.statusBar.projIcon._colBad - nwGUI.statusBar.setProjectStatus(nwState.GOOD) - assert nwGUI.statusBar.projIcon._theCol == nwGUI.statusBar.projIcon._colGood + nwGUI.mainStatus.setProjectStatus(nwState.NONE) + assert nwGUI.mainStatus.projIcon._theCol == nwGUI.mainStatus.projIcon._colNone + nwGUI.mainStatus.setProjectStatus(nwState.BAD) + assert nwGUI.mainStatus.projIcon._theCol == nwGUI.mainStatus.projIcon._colBad + nwGUI.mainStatus.setProjectStatus(nwState.GOOD) + assert nwGUI.mainStatus.projIcon._theCol == nwGUI.mainStatus.projIcon._colGood # Document Status - nwGUI.statusBar.setDocumentStatus(nwState.NONE) - assert nwGUI.statusBar.docIcon._theCol == nwGUI.statusBar.docIcon._colNone - nwGUI.statusBar.setDocumentStatus(nwState.BAD) - assert nwGUI.statusBar.docIcon._theCol == nwGUI.statusBar.docIcon._colBad - nwGUI.statusBar.setDocumentStatus(nwState.GOOD) - assert nwGUI.statusBar.docIcon._theCol == nwGUI.statusBar.docIcon._colGood + nwGUI.mainStatus.setDocumentStatus(nwState.NONE) + assert nwGUI.mainStatus.docIcon._theCol == nwGUI.mainStatus.docIcon._colNone + nwGUI.mainStatus.setDocumentStatus(nwState.BAD) + assert nwGUI.mainStatus.docIcon._theCol == nwGUI.mainStatus.docIcon._colBad + nwGUI.mainStatus.setDocumentStatus(nwState.GOOD) + assert nwGUI.mainStatus.docIcon._theCol == nwGUI.mainStatus.docIcon._colGood # Idle Status - nwGUI.statusBar.mainConf.stopWhenIdle = False - nwGUI.statusBar.setUserIdle(True) - nwGUI.statusBar.updateTime() - assert nwGUI.statusBar.userIdle is False - assert nwGUI.statusBar.timeText.text() == "00:00:00" + nwGUI.mainStatus.mainConf.stopWhenIdle = False + nwGUI.mainStatus.setUserIdle(True) + nwGUI.mainStatus.updateTime() + assert nwGUI.mainStatus.userIdle is False + assert nwGUI.mainStatus.timeText.text() == "00:00:00" - nwGUI.statusBar.mainConf.stopWhenIdle = True - nwGUI.statusBar.setUserIdle(True) - nwGUI.statusBar.updateTime(5) - assert nwGUI.statusBar.userIdle is True - assert nwGUI.statusBar.timeText.text() != "00:00:00" + nwGUI.mainStatus.mainConf.stopWhenIdle = True + nwGUI.mainStatus.setUserIdle(True) + nwGUI.mainStatus.updateTime(5) + assert nwGUI.mainStatus.userIdle is True + assert nwGUI.mainStatus.timeText.text() != "00:00:00" - nwGUI.statusBar.setUserIdle(False) - nwGUI.statusBar.updateTime(5) - assert nwGUI.statusBar.userIdle is False - assert nwGUI.statusBar.timeText.text() != "00:00:00" + nwGUI.mainStatus.setUserIdle(False) + nwGUI.mainStatus.updateTime(5) + assert nwGUI.mainStatus.userIdle is False + assert nwGUI.mainStatus.timeText.text() != "00:00:00" # Language - nwGUI.statusBar.setLanguage("None", "None") - assert nwGUI.statusBar.langText.text() == "None" - nwGUI.statusBar.setLanguage("en", "None") - assert nwGUI.statusBar.langText.text() == "American English" + nwGUI.mainStatus.setLanguage("None", "None") + assert nwGUI.mainStatus.langText.text() == "None" + nwGUI.mainStatus.setLanguage("en", "None") + assert nwGUI.mainStatus.langText.text() == "American English" # Project Stats - nwGUI.statusBar.mainConf.incNotesWCount = False + nwGUI.mainStatus.mainConf.incNotesWCount = False nwGUI._updateStatusWordCount() - assert nwGUI.statusBar.statsText.text() == "Words: 9 (+9)" - nwGUI.statusBar.mainConf.incNotesWCount = True + assert nwGUI.mainStatus.statsText.text() == "Words: 9 (+9)" + nwGUI.mainStatus.mainConf.incNotesWCount = True nwGUI._updateStatusWordCount() - assert nwGUI.statusBar.statsText.text() == "Words: 11 (+11)" + assert nwGUI.mainStatus.statsText.text() == "Words: 11 (+11)" - # qtbot.stopForInteraction() + # qtbot.stop() # END Test testGuiStatusBar_Init diff --git a/tests/test_gui/test_gui_theme.py b/tests/test_gui/test_gui_theme.py index 8c7b934a..5ec03c30 100644 --- a/tests/test_gui/test_gui_theme.py +++ b/tests/test_gui/test_gui_theme.py @@ -19,161 +19,421 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ +import shutil import pytest -import novelwriter -from PyQt5.QtGui import QColor, QPixmap, QIcon -from PyQt5.QtWidgets import QMessageBox +from pathlib import Path +from configparser import ConfigParser -keyDelay = 2 -typeDelay = 1 -stepDelay = 20 +from mock import causeOSError +from novelwriter.constants import nwLabels +from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType +from tools import writeFile + +from PyQt5.QtGui import QIcon, QPalette, QPixmap +from PyQt5.QtWidgets import QApplication + +from novelwriter.config import Config +from novelwriter.gui.theme import GuiIcons, GuiTheme @pytest.mark.gui -def testGuiTheme_Main(qtbot, monkeypatch, nwMinimal, tmpDir): - """Test the theme and icon classes. +def testGuiTheme_Main(qtbot, nwGUI, fncPath): + """Test the theme class init. """ - # Block message box - monkeypatch.setattr(QMessageBox, "information", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "warning", lambda *a: QMessageBox.Yes) + mainTheme: GuiTheme = nwGUI.mainTheme + mainConf: Config = nwGUI.mainConf - nwGUI = novelwriter.main( - ["--testmode", "--config=%s" % nwMinimal, "--data=%s" % tmpDir, nwMinimal] - ) - qtbot.addWidget(nwGUI) - nwGUI.show() - qtbot.wait(stepDelay) + # Methods + # ======= - # Change Settings - assert novelwriter.CONFIG.confPath == nwMinimal - novelwriter.CONFIG.guiTheme = "default_dark" - novelwriter.CONFIG.guiSyntax = "tomorrow_night_eighties" - novelwriter.CONFIG.guiIcons = "typicons_colour_dark" - novelwriter.CONFIG.guiFont = "Cantarell" - novelwriter.CONFIG.guiFontSize = 11 - novelwriter.CONFIG.confChanged = True - assert novelwriter.CONFIG.saveConfig() + mSize = mainTheme.getTextWidth("m") + assert mSize > 0 + assert mainTheme.getTextWidth("m", mainTheme.guiFont) == mSize - nwGUI.closeMain() - nwGUI.close() - del nwGUI + # Init Fonts + # ========== - # Re-open - assert novelwriter.CONFIG.confPath == nwMinimal - nwGUI = novelwriter.main( - ["--testmode", "--config=%s" % nwMinimal, "--data=%s" % tmpDir, nwMinimal] - ) - assert nwGUI.mainConf.confPath == nwMinimal - qtbot.addWidget(nwGUI) - nwGUI.show() - qtbot.wait(stepDelay) + # The defaults should be set + defaultFont = mainConf.guiFont + defaultSize = mainConf.guiFontSize - assert novelwriter.CONFIG.guiTheme == "default_dark" - assert novelwriter.CONFIG.guiSyntax == "tomorrow_night_eighties" - assert novelwriter.CONFIG.guiIcons == "typicons_dark" - assert novelwriter.CONFIG.guiFont != "" - assert novelwriter.CONFIG.guiFontSize > 0 + # CHange them to nonsense values + mainConf.guiFont = "notafont" + mainConf.guiFontSize = 99 - # Check GUI Colours - thePalette = nwGUI.palette() - assert thePalette.window().color() == QColor(54, 54, 54) - assert thePalette.windowText().color() == QColor(174, 174, 174) - assert thePalette.base().color() == QColor(62, 62, 62) - assert thePalette.alternateBase().color() == QColor(67, 67, 67) - assert thePalette.text().color() == QColor(174, 174, 174) - assert thePalette.toolTipBase().color() == QColor(255, 255, 192) - assert thePalette.toolTipText().color() == QColor(21, 21, 13) - assert thePalette.button().color() == QColor(62, 62, 62) - assert thePalette.buttonText().color() == QColor(174, 174, 174) - assert thePalette.brightText().color() == QColor(174, 174, 174) - assert thePalette.highlight().color() == QColor(44, 152, 247) - assert thePalette.highlightedText().color() == QColor(255, 255, 255) - assert thePalette.link().color() == QColor(44, 152, 247) - assert thePalette.linkVisited().color() == QColor(44, 152, 247) + # Let the theme class set them back to default + mainTheme._setGuiFont() + assert mainConf.guiFont == defaultFont + assert mainConf.guiFontSize == defaultSize - assert nwGUI.mainTheme.statNone == [150, 152, 150] - assert nwGUI.mainTheme.statSaved == [39, 135, 78] - assert nwGUI.mainTheme.statUnsaved == [138, 32, 32] + # A second call should just restore the defaults again + mainTheme._setGuiFont() + assert mainConf.guiFont == defaultFont + assert mainConf.guiFontSize == defaultSize - # Check Syntax Colours - assert nwGUI.mainTheme.colBack == [45, 45, 45] - assert nwGUI.mainTheme.colText == [204, 204, 204] - assert nwGUI.mainTheme.colLink == [102, 153, 204] - assert nwGUI.mainTheme.colHead == [102, 153, 204] - assert nwGUI.mainTheme.colHeadH == [102, 153, 204] - assert nwGUI.mainTheme.colEmph == [249, 145, 57] - assert nwGUI.mainTheme.colDialN == [242, 119, 122] - assert nwGUI.mainTheme.colDialD == [153, 204, 153] - assert nwGUI.mainTheme.colDialS == [255, 204, 102] - assert nwGUI.mainTheme.colHidden == [153, 153, 153] - assert nwGUI.mainTheme.colKey == [242, 119, 122] - assert nwGUI.mainTheme.colVal == [204, 153, 204] - assert nwGUI.mainTheme.colSpell == [242, 119, 122] - assert nwGUI.mainTheme.colError == [153, 204, 153] - assert nwGUI.mainTheme.colRepTag == [102, 204, 204] - assert nwGUI.mainTheme.colMod == [249, 145, 57] + # Scan for Themes + # =============== - # Test Icon class - iconCache = nwGUI.mainTheme.iconCache - novelwriter.CONFIG.guiIcons = "invalid" - assert iconCache.updateTheme() is True - assert novelwriter.CONFIG.guiIcons == "typicons_light" + assert mainTheme._listConf({}, Path("not_a_path")) is False - # Ask for a non-existent key - anImg = iconCache.loadDecoration("nonsense", 20, 20) - assert isinstance(anImg, QPixmap) - assert anImg.isNull() + themeOne = fncPath / "themes" / "themeone.conf" + themeTwo = fncPath / "themes" / "themetwo.conf" + writeFile(themeOne, "# Stuff") + writeFile(themeTwo, "# Stuff") - # Add a non-existent file and request it - iconCache.IMAGE_MAP["nonsense"] = "nofile.jpg" - anImg = iconCache.loadDecoration("nonsense", 20, 20) - assert isinstance(anImg, QPixmap) - assert anImg.isNull() + result = {} + assert mainTheme._listConf(result, fncPath / "themes") is True + assert result["themeone"] == themeOne + assert result["themetwo"] == themeTwo - # Get a real image, with different size parameters - anImg = iconCache.loadDecoration("wiz-back", 20, None) - assert isinstance(anImg, QPixmap) - assert not anImg.isNull() - assert anImg.width() == 20 - assert anImg.height() >= 56 + # Parse Colours + # ============= - anImg = iconCache.loadDecoration("wiz-back", None, 70) - assert isinstance(anImg, QPixmap) - assert not anImg.isNull() - assert anImg.height() == 70 - assert anImg.width() >= 24 + parser = ConfigParser() + parser["Palette"] = { + "colour1": "100, 150, 200", + "colour2": "100, 150, 200, 250", + "colour3": "250, 250", + "colour4": "-10, 127, 300", + } - anImg = iconCache.loadDecoration("wiz-back", 30, 70) - assert isinstance(anImg, QPixmap) - assert not anImg.isNull() - assert anImg.height() == 70 - assert anImg.width() == 30 + # Test the parser for several valid and invalid values + assert mainTheme._parseColour(parser, "Palette", "colour1") == [100, 150, 200] + assert mainTheme._parseColour(parser, "Palette", "colour2") == [100, 150, 200] + assert mainTheme._parseColour(parser, "Palette", "colour3") == [0, 0, 0] + assert mainTheme._parseColour(parser, "Palette", "colour4") == [0, 127, 255] + assert mainTheme._parseColour(parser, "Palette", "colour5") == [0, 0, 0] - anImg = iconCache.loadDecoration("wiz-back", None, None) - assert isinstance(anImg, QPixmap) - assert not anImg.isNull() - assert anImg.height() >= 1500 - assert anImg.width() >= 500 + # The palette should load with the parsed values + mainTheme._setPalette(parser, "Palette", "colour1", QPalette.Window) + assert mainTheme._guiPalette.color(QPalette.Window).getRgb() == (100, 150, 200, 255) + mainTheme._setPalette(parser, "Palette", "colour2", QPalette.Window) + assert mainTheme._guiPalette.color(QPalette.Window).getRgb() == (100, 150, 200, 255) + mainTheme._setPalette(parser, "Palette", "colour3", QPalette.Window) + assert mainTheme._guiPalette.color(QPalette.Window).getRgb() == (0, 0, 0, 255) + mainTheme._setPalette(parser, "Palette", "colour4", QPalette.Window) + assert mainTheme._guiPalette.color(QPalette.Window).getRgb() == (0, 127, 255, 255) + mainTheme._setPalette(parser, "Palette", "colour5", QPalette.Window) + assert mainTheme._guiPalette.color(QPalette.Window).getRgb() == (0, 0, 0, 255) - # Load icons - anIcon = iconCache.getIcon("nonsense") - assert isinstance(anIcon, QIcon) - assert anIcon.isNull() - - anIcon = iconCache.getIcon("novelwriter") - assert isinstance(anIcon, QIcon) - assert not anIcon.isNull() - - # Check return empty icon if file not found - iconCache.ICON_KEYS.add("testicon3") - anIcon = iconCache.getIcon("testicon3") - assert isinstance(anIcon, QIcon) - assert anIcon.isNull() - - # qtbot.stopForInteraction() - nwGUI.closeMain() - nwGUI.close() + # qtbot.stop() # END Test testGuiTheme_Main + + +@pytest.mark.gui +def testGuiTheme_Theme(qtbot, monkeypatch, nwGUI, fncPath): + """Test the theme part of the class. + """ + mainTheme: GuiTheme = nwGUI.mainTheme + mainConf: Config = nwGUI.mainConf + + # List Themes + # =========== + + shutil.copy(mainConf.assetPath("themes") / "default_dark.conf", fncPath / "themes") + shutil.copy(mainConf.assetPath("themes") / "default.conf", fncPath / "themes") + + # Block the reading of the files + with monkeypatch.context() as mp: + mp.setattr("builtins.open", causeOSError) + assert mainTheme.listThemes() == [] + + # Load the theme info + themesList = mainTheme.listThemes() + assert themesList[0] == ("default_dark", "Default Dark Theme") + assert themesList[1] == ("default", "Default Theme") + + # A second call should returned the cached list + assert mainTheme.listThemes() == mainTheme._themeList + + # Check handling of broken theme settings + mainConf.guiTheme = "not_a_theme" + availThemes = mainTheme._availThemes + mainTheme._availThemes = {} + assert mainTheme.loadTheme() is False + mainTheme._availThemes = availThemes + + # Check handling of unreadable file + mainConf.guiTheme = "default" + with monkeypatch.context() as mp: + mp.setattr("builtins.open", causeOSError) + assert mainTheme.loadTheme() is False + + # Load Default Theme + # ================== + + # Set a mock colour for the window background + mainTheme._guiPalette.color(QPalette.Window).setRgb(0, 0, 0, 0) + + # Load the default theme + mainConf.guiTheme = "default" + assert mainTheme.loadTheme() is True + + # This should load a standard palette + wCol = QApplication.style().standardPalette().color(QPalette.Window).getRgb() + assert mainTheme._guiPalette.color(QPalette.Window).getRgb() == wCol + + # Load Default Dark Theme + # ======================= + + mainConf.guiTheme = "default_dark" + assert mainTheme.loadTheme() is True + + # Check a few values + assert mainTheme._guiPalette.color(QPalette.Window).getRgb() == (54, 54, 54, 255) + assert mainTheme._guiPalette.color(QPalette.WindowText).getRgb() == (174, 174, 174, 255) + assert mainTheme._guiPalette.color(QPalette.Base).getRgb() == (62, 62, 62, 255) + assert mainTheme._guiPalette.color(QPalette.AlternateBase).getRgb() == (78, 78, 78, 255) + + # qtbot.stop() + +# END Test testGuiTheme_Theme + + +@pytest.mark.gui +def testGuiTheme_Syntax(qtbot, monkeypatch, nwGUI, fncPath): + """Test the syntax part of the class. + """ + mainTheme: GuiTheme = nwGUI.mainTheme + mainConf: Config = nwGUI.mainConf + + # List Themes + # =========== + + shutil.copy(mainConf.assetPath("syntax") / "default_dark.conf", fncPath / "syntax") + shutil.copy(mainConf.assetPath("syntax") / "default_light.conf", fncPath / "syntax") + + # Block the reading of the files + with monkeypatch.context() as mp: + mp.setattr("builtins.open", causeOSError) + assert mainTheme.listThemes() == [] + + # Load the syntax info + syntaxList = mainTheme.listSyntax() + assert syntaxList[0] == ("default_dark", "Default Dark") + assert syntaxList[1] == ("default_light", "Default Light") + + # A second call should returned the cached list + assert mainTheme.listSyntax() == mainTheme._syntaxList + + # Check handling of broken theme settings + availSyntax = mainTheme._availSyntax + mainTheme._availSyntax = {} + mainConf.guiSyntax = "not_a_syntax" + assert mainTheme.loadSyntax() is False + mainTheme._availSyntax = availSyntax + + # Check handling of unreadable file + mainConf.guiSyntax = "default_light" + with monkeypatch.context() as mp: + mp.setattr("builtins.open", causeOSError) + assert mainTheme.loadSyntax() is False + + # Load Default Light Syntax + # ========================= + + # Load the default syntax + mainConf.guiSyntax = "default_light" + assert mainTheme.loadSyntax() is True + + # Check some values + assert mainTheme.syntaxName == "Default Light" + assert mainTheme.colBack == [255, 255, 255] + assert mainTheme.colText == [0, 0, 0] + assert mainTheme.colLink == [0, 0, 200] + + # Load Default Dark Theme + # ======================= + + # Load the default syntax + mainConf.guiSyntax = "default_dark" + assert mainTheme.loadSyntax() is True + + # Check some values + assert mainTheme.syntaxName == "Default Dark" + assert mainTheme.colBack == [54, 54, 54] + assert mainTheme.colText == [199, 207, 208] + assert mainTheme.colLink == [184, 200, 0] + + # qtbot.stop() + +# END Test testGuiTheme_Syntax + + +@pytest.mark.gui +def testGuiTheme_Icons(qtbot, caplog, monkeypatch, nwGUI, fncPath): + """Test the icon cache class. + """ + iconCache: GuiIcons = nwGUI.mainTheme.iconCache + + # Load Theme + # ========== + + # Invalid theme name + assert iconCache.loadTheme("not_a_theme") is False + + # Check handling of unreadable file + with monkeypatch.context() as mp: + mp.setattr("builtins.open", causeOSError) + assert iconCache.loadTheme("typicons_dark") is False + + # Load a broken theme file + iconsDir = fncPath / "icons" + testIcons = iconsDir / "testicons" + iconsDir.mkdir() + testIcons.mkdir() + writeFile(testIcons / "icons.conf", ( + "[Main]\n" + "name = Test Icons\n" + "\n" + "[Map]\n" + "add = add.svg\n" + "stuff = stuff.svg\n" + )) + + iconPath = iconCache._iconPath + iconCache._iconPath = fncPath / "icons" + + caplog.clear() + assert iconCache.loadTheme("testicons") is True + assert "Unknown icon name 'stuff' in config file" in caplog.text + assert "Icon file 'add.svg' not in theme folder" in caplog.text + + iconCache._iconPath = iconPath + + # Load working theme file + assert iconCache.loadTheme("typicons_dark") is True + assert "add" in iconCache._themeMap + + # Load Decorations + # ================ + + # Invalid name should return empty pixmap + qPix = iconCache.loadDecoration("stuff") + assert qPix.isNull() is True + + # Load an image + qPix = iconCache.loadDecoration("wiz-back") + assert qPix.isNull() is False + + # Fail finding the file + with monkeypatch.context() as mp: + mp.setattr("pathlib.Path.is_file", lambda *a: False) + qPix = iconCache.loadDecoration("wiz-back") + assert qPix.isNull() is True + + # Test image sizes + qPix = iconCache.loadDecoration("wiz-back", pxW=100, pxH=None) + assert qPix.isNull() is False + assert qPix.width() == 100 + assert qPix.height() > 100 + + qPix = iconCache.loadDecoration("wiz-back", pxW=None, pxH=100) + assert qPix.isNull() is False + assert qPix.width() < 100 + assert qPix.height() == 100 + + qPix = iconCache.loadDecoration("wiz-back", pxW=100, pxH=100) + assert qPix.isNull() is False + assert qPix.width() == 100 + assert qPix.height() == 100 + + # Load Icons + # ========== + + # Load an unknown icon + qIcon = iconCache.getIcon("stuff") + assert isinstance(qIcon, QIcon) + assert qIcon.isNull() is True + + # Load an icon, it is likelyu already cached + qIcon = iconCache.getIcon("add") + assert isinstance(qIcon, QIcon) + assert qIcon.isNull() is False + + # Load it as a pixmap with a size + qPix = iconCache.getPixmap("add", (50, 50)) + assert isinstance(qPix, QPixmap) + assert qPix.isNull() is False + assert qPix.width() == 50 + assert qPix.height() == 50 + + # Load app icon + qIcon = iconCache.getIcon("novelwriter") + assert isinstance(qIcon, QIcon) + assert qIcon.isNull() is False + + # Load mime icon + qIcon = iconCache.getIcon("proj_nwx") + assert isinstance(qIcon, QIcon) + assert qIcon.isNull() is False + + # Load Item Icons + # =============== + + # Root -> Not Null + assert iconCache.getItemIcon( + nwItemType.ROOT, nwItemClass.NOVEL, nwItemLayout.NO_LAYOUT, hLevel="H0" + ) == iconCache.getIcon(nwLabels.CLASS_ICON[nwItemClass.NOVEL]) + + # Folder -> Not Null + assert iconCache.getItemIcon( + nwItemType.FOLDER, nwItemClass.NOVEL, nwItemLayout.NO_LAYOUT, hLevel="H0" + ) == iconCache.getIcon("proj_folder") + + # Document H0 -> Not Null + assert iconCache.getItemIcon( + nwItemType.FILE, nwItemClass.NOVEL, nwItemLayout.NO_LAYOUT, hLevel="H0" + ) == iconCache.getIcon("proj_document") + + # Document H1 -> Not Null + assert iconCache.getItemIcon( + nwItemType.FILE, nwItemClass.NOVEL, nwItemLayout.DOCUMENT, hLevel="H1" + ) == iconCache.getIcon("proj_title") + + # Document H2 -> Not Null + assert iconCache.getItemIcon( + nwItemType.FILE, nwItemClass.NOVEL, nwItemLayout.DOCUMENT, hLevel="H2" + ) == iconCache.getIcon("proj_chapter") + + # Document H3 -> Not Null + assert iconCache.getItemIcon( + nwItemType.FILE, nwItemClass.NOVEL, nwItemLayout.DOCUMENT, hLevel="H3" + ) == iconCache.getIcon("proj_scene") + + # Document H4 -> Not Null + assert iconCache.getItemIcon( + nwItemType.FILE, nwItemClass.NOVEL, nwItemLayout.DOCUMENT, hLevel="H4" + ) == iconCache.getIcon("proj_section") + + # Document H5 -> Not Null + assert iconCache.getItemIcon( + nwItemType.FILE, nwItemClass.NOVEL, nwItemLayout.NO_LAYOUT, hLevel="H4" + ) == iconCache.getIcon("proj_document") + + # Note -> Not Null + assert iconCache.getItemIcon( + nwItemType.FILE, nwItemClass.NOVEL, nwItemLayout.NOTE, hLevel="H5" + ) == iconCache.getIcon("proj_note") + + # No Type -> Null + assert iconCache.getItemIcon( + nwItemType.NO_TYPE, nwItemClass.NOVEL, nwItemLayout.DOCUMENT, hLevel="H0" + ).isNull() is True + + # Header Decorations + # ================== + + assert iconCache.getHeaderDecoration(-1) == iconCache._headerDec[0] + assert iconCache.getHeaderDecoration(0) == iconCache._headerDec[0] + assert iconCache.getHeaderDecoration(1) == iconCache._headerDec[1] + assert iconCache.getHeaderDecoration(2) == iconCache._headerDec[2] + assert iconCache.getHeaderDecoration(3) == iconCache._headerDec[3] + assert iconCache.getHeaderDecoration(4) == iconCache._headerDec[4] + assert iconCache.getHeaderDecoration(5) == iconCache._headerDec[4] + + # qtbot.stop() + +# END Test testGuiTheme_Icons diff --git a/tests/test_tools/test_tools_build.py b/tests/test_tools/test_tools_build.py index 40e30dea..33bd8ecc 100644 --- a/tests/test_tools/test_tools_build.py +++ b/tests/test_tools/test_tools_build.py @@ -20,28 +20,22 @@ along with this program. If not, see . """ import pytest -import os from shutil import copyfile + from tools import cmpFiles, getGuiItem from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QAction, QMessageBox, QFileDialog +from PyQt5.QtWidgets import QAction, QFileDialog from novelwriter.tools import GuiBuildNovel -keyDelay = 2 -typeDelay = 1 -stepDelay = 20 - @pytest.mark.gui -def testToolBuild_Main(qtbot, monkeypatch, nwGUI, nwLipsum, refDir, outDir): +def testToolBuild_Main(qtbot, monkeypatch, nwGUI, prjLipsum, tstPaths): """Test the build tool. """ # Block message box - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "information", lambda *a: QMessageBox.Yes) monkeypatch.setattr(QFileDialog, "getSaveFileName", lambda a, b, c, **k: (c, None)) # Check that we cannot open when there is no project @@ -49,7 +43,7 @@ def testToolBuild_Main(qtbot, monkeypatch, nwGUI, nwLipsum, refDir, outDir): assert getGuiItem("GuiBuildNovel") is None # Open a project - assert nwGUI.openProject(nwLipsum) + assert nwGUI.openProject(prjLipsum) # Open the tool nwGUI.mainMenu.aBuildProject.activate(QAction.Trigger) @@ -67,205 +61,179 @@ def testToolBuild_Main(qtbot, monkeypatch, nwGUI, nwLipsum, refDir, outDir): # Invalid file format assert not nwBuild._saveDocument(-1) - # Non-existent path - with monkeypatch.context() as mp: - mp.setattr("os.path.expanduser", lambda *a, **k: nwLipsum) - assert nwGUI.mainConf.lastPath != nwLipsum - nwGUI.mainConf.lastPath = "no_such_path" - assert nwBuild._saveDocument(nwBuild.FMT_NWD) - assert nwGUI.mainConf.lastPath == nwLipsum - # No path selected with monkeypatch.context() as mp: mp.setattr(QFileDialog, "getSaveFileName", lambda *a, **k: ("", "")) assert not nwBuild._saveDocument(nwBuild.FMT_NWD) # Default Settings - nwGUI.mainConf.lastPath = nwLipsum + nwGUI.mainConf._lastPath = prjLipsum qtbot.mouseClick(nwBuild.buildNovel, Qt.LeftButton) assert nwBuild._saveDocument(nwBuild.FMT_NWD) - projFile = os.path.join(nwLipsum, "Lorem Ipsum.nwd") - testFile = os.path.join(outDir, "guiBuild_Tool_Step1_Lorem_Ipsum.nwd") - compFile = os.path.join(refDir, "guiBuild_Tool_Step1_Lorem_Ipsum.nwd") + projFile = prjLipsum / "Lorem Ipsum.nwd" + testFile = tstPaths.outDir / "guiBuild_Tool_Step1_Lorem_Ipsum.nwd" + compFile = tstPaths.refDir / "guiBuild_Tool_Step1_Lorem_Ipsum.nwd" copyfile(projFile, testFile) assert cmpFiles(testFile, compFile) assert nwBuild._saveDocument(nwBuild.FMT_HTM) - projFile = os.path.join(nwLipsum, "Lorem Ipsum.htm") - testFile = os.path.join(outDir, "guiBuild_Tool_Step1_Lorem_Ipsum.htm") - compFile = os.path.join(refDir, "guiBuild_Tool_Step1_Lorem_Ipsum.htm") + projFile = prjLipsum / "Lorem Ipsum.htm" + testFile = tstPaths.outDir / "guiBuild_Tool_Step1_Lorem_Ipsum.htm" + compFile = tstPaths.refDir / "guiBuild_Tool_Step1_Lorem_Ipsum.htm" copyfile(projFile, testFile) assert cmpFiles(testFile, compFile) assert nwBuild._saveDocument(nwBuild.FMT_MD) - projFile = os.path.join(nwLipsum, "Lorem Ipsum.md") - testFile = os.path.join(outDir, "guiBuild_Tool_Step1_Lorem_Ipsum.md") - compFile = os.path.join(refDir, "guiBuild_Tool_Step1_Lorem_Ipsum.md") + projFile = prjLipsum / "Lorem Ipsum.md" + testFile = tstPaths.outDir / "guiBuild_Tool_Step1_Lorem_Ipsum.md" + compFile = tstPaths.refDir / "guiBuild_Tool_Step1_Lorem_Ipsum.md" copyfile(projFile, testFile) assert cmpFiles(testFile, compFile) assert nwBuild._saveDocument(nwBuild.FMT_GH) - projFile = os.path.join(nwLipsum, "Lorem Ipsum.md") - testFile = os.path.join(outDir, "guiBuild_Tool_Step1G_Lorem_Ipsum.md") - compFile = os.path.join(refDir, "guiBuild_Tool_Step1G_Lorem_Ipsum.md") + projFile = prjLipsum / "Lorem Ipsum.md" + testFile = tstPaths.outDir / "guiBuild_Tool_Step1G_Lorem_Ipsum.md" + compFile = tstPaths.refDir / "guiBuild_Tool_Step1G_Lorem_Ipsum.md" copyfile(projFile, testFile) assert cmpFiles(testFile, compFile) assert nwBuild._saveDocument(nwBuild.FMT_FODT) - projFile = os.path.join(nwLipsum, "Lorem Ipsum.fodt") - testFile = os.path.join(outDir, "guiBuild_Tool_Step1_Lorem_Ipsum.fodt") - compFile = os.path.join(refDir, "guiBuild_Tool_Step1_Lorem_Ipsum.fodt") + projFile = prjLipsum / "Lorem Ipsum.fodt" + testFile = tstPaths.outDir / "guiBuild_Tool_Step1_Lorem_Ipsum.fodt" + compFile = tstPaths.refDir / "guiBuild_Tool_Step1_Lorem_Ipsum.fodt" copyfile(projFile, testFile) assert cmpFiles(testFile, compFile, [4, 5]) # Change Title Formats and Flip Switches nwBuild.fmtChapter.setText(r"Chapter %chw%: %title%") - qtbot.wait(stepDelay) nwBuild.fmtScene.setText(r"Scene %ch%.%sc%: %title%") - qtbot.wait(stepDelay) nwBuild.fmtSection.setText(r"%ch%.%sc%.1: %title%") - qtbot.wait(stepDelay) qtbot.mouseClick(nwBuild.justifyText, Qt.LeftButton) - qtbot.wait(stepDelay) qtbot.mouseClick(nwBuild.includeSynopsis, Qt.LeftButton) - qtbot.wait(stepDelay) qtbot.mouseClick(nwBuild.includeComments, Qt.LeftButton) - qtbot.wait(stepDelay) qtbot.mouseClick(nwBuild.includeKeywords, Qt.LeftButton) - qtbot.wait(stepDelay) qtbot.mouseClick(nwBuild.replaceUCode, Qt.LeftButton) - qtbot.wait(stepDelay) qtbot.mouseClick(nwBuild.noteFiles, Qt.LeftButton) - qtbot.wait(stepDelay) qtbot.mouseClick(nwBuild.ignoreFlag, Qt.LeftButton) - qtbot.wait(stepDelay) qtbot.mouseClick(nwBuild.buildNovel, Qt.LeftButton) assert nwBuild._saveDocument(nwBuild.FMT_NWD) - projFile = os.path.join(nwLipsum, "Lorem Ipsum.nwd") - testFile = os.path.join(outDir, "guiBuild_Tool_Step2_Lorem_Ipsum.nwd") - compFile = os.path.join(refDir, "guiBuild_Tool_Step2_Lorem_Ipsum.nwd") + projFile = prjLipsum / "Lorem Ipsum.nwd" + testFile = tstPaths.outDir / "guiBuild_Tool_Step2_Lorem_Ipsum.nwd" + compFile = tstPaths.refDir / "guiBuild_Tool_Step2_Lorem_Ipsum.nwd" copyfile(projFile, testFile) assert cmpFiles(testFile, compFile) assert nwBuild._saveDocument(nwBuild.FMT_HTM) - projFile = os.path.join(nwLipsum, "Lorem Ipsum.htm") - testFile = os.path.join(outDir, "guiBuild_Tool_Step2_Lorem_Ipsum.htm") - compFile = os.path.join(refDir, "guiBuild_Tool_Step2_Lorem_Ipsum.htm") + projFile = prjLipsum / "Lorem Ipsum.htm" + testFile = tstPaths.outDir / "guiBuild_Tool_Step2_Lorem_Ipsum.htm" + compFile = tstPaths.refDir / "guiBuild_Tool_Step2_Lorem_Ipsum.htm" copyfile(projFile, testFile) assert cmpFiles(testFile, compFile) assert nwBuild._saveDocument(nwBuild.FMT_MD) - projFile = os.path.join(nwLipsum, "Lorem Ipsum.md") - testFile = os.path.join(outDir, "guiBuild_Tool_Step2_Lorem_Ipsum.md") - compFile = os.path.join(refDir, "guiBuild_Tool_Step2_Lorem_Ipsum.md") + projFile = prjLipsum / "Lorem Ipsum.md" + testFile = tstPaths.outDir / "guiBuild_Tool_Step2_Lorem_Ipsum.md" + compFile = tstPaths.refDir / "guiBuild_Tool_Step2_Lorem_Ipsum.md" copyfile(projFile, testFile) assert cmpFiles(testFile, compFile) assert nwBuild._saveDocument(nwBuild.FMT_FODT) - projFile = os.path.join(nwLipsum, "Lorem Ipsum.fodt") - testFile = os.path.join(outDir, "guiBuild_Tool_Step2_Lorem_Ipsum.fodt") - compFile = os.path.join(refDir, "guiBuild_Tool_Step2_Lorem_Ipsum.fodt") + projFile = prjLipsum / "Lorem Ipsum.fodt" + testFile = tstPaths.outDir / "guiBuild_Tool_Step2_Lorem_Ipsum.fodt" + compFile = tstPaths.refDir / "guiBuild_Tool_Step2_Lorem_Ipsum.fodt" copyfile(projFile, testFile) assert cmpFiles(testFile, compFile, [4, 5]) # Replace Tabs with Spaces qtbot.mouseClick(nwBuild.replaceTabs, Qt.LeftButton) - qtbot.wait(stepDelay) qtbot.mouseClick(nwBuild.buildNovel, Qt.LeftButton) # Save files that can be compared assert nwBuild._saveDocument(nwBuild.FMT_NWD) - projFile = os.path.join(nwLipsum, "Lorem Ipsum.nwd") - testFile = os.path.join(outDir, "guiBuild_Tool_Step3_Lorem_Ipsum.nwd") - compFile = os.path.join(refDir, "guiBuild_Tool_Step3_Lorem_Ipsum.nwd") + projFile = prjLipsum / "Lorem Ipsum.nwd" + testFile = tstPaths.outDir / "guiBuild_Tool_Step3_Lorem_Ipsum.nwd" + compFile = tstPaths.refDir / "guiBuild_Tool_Step3_Lorem_Ipsum.nwd" copyfile(projFile, testFile) assert cmpFiles(testFile, compFile) assert nwBuild._saveDocument(nwBuild.FMT_HTM) - projFile = os.path.join(nwLipsum, "Lorem Ipsum.htm") - testFile = os.path.join(outDir, "guiBuild_Tool_Step3_Lorem_Ipsum.htm") - compFile = os.path.join(refDir, "guiBuild_Tool_Step3_Lorem_Ipsum.htm") + projFile = prjLipsum / "Lorem Ipsum.htm" + testFile = tstPaths.outDir / "guiBuild_Tool_Step3_Lorem_Ipsum.htm" + compFile = tstPaths.refDir / "guiBuild_Tool_Step3_Lorem_Ipsum.htm" copyfile(projFile, testFile) assert cmpFiles(testFile, compFile) assert nwBuild._saveDocument(nwBuild.FMT_MD) - projFile = os.path.join(nwLipsum, "Lorem Ipsum.md") - testFile = os.path.join(outDir, "guiBuild_Tool_Step3_Lorem_Ipsum.md") - compFile = os.path.join(refDir, "guiBuild_Tool_Step3_Lorem_Ipsum.md") + projFile = prjLipsum / "Lorem Ipsum.md" + testFile = tstPaths.outDir / "guiBuild_Tool_Step3_Lorem_Ipsum.md" + compFile = tstPaths.refDir / "guiBuild_Tool_Step3_Lorem_Ipsum.md" copyfile(projFile, testFile) assert cmpFiles(testFile, compFile) assert nwBuild._saveDocument(nwBuild.FMT_FODT) - projFile = os.path.join(nwLipsum, "Lorem Ipsum.fodt") - testFile = os.path.join(outDir, "guiBuild_Tool_Step3_Lorem_Ipsum.fodt") - compFile = os.path.join(refDir, "guiBuild_Tool_Step3_Lorem_Ipsum.fodt") + projFile = prjLipsum / "Lorem Ipsum.fodt" + testFile = tstPaths.outDir / "guiBuild_Tool_Step3_Lorem_Ipsum.fodt" + compFile = tstPaths.refDir / "guiBuild_Tool_Step3_Lorem_Ipsum.fodt" copyfile(projFile, testFile) assert cmpFiles(testFile, compFile, [4, 5]) # Putline Mode nwBuild.fmtChapter.setText(r"Chapter %chw%: %title%") - qtbot.wait(stepDelay) nwBuild.fmtScene.setText(r"Scene %sca%: %title%") - qtbot.wait(stepDelay) nwBuild.fmtSection.setText(r"Section: %title%") - qtbot.wait(stepDelay) qtbot.mouseClick(nwBuild.includeComments, Qt.LeftButton) - qtbot.wait(stepDelay) qtbot.mouseClick(nwBuild.noteFiles, Qt.LeftButton) - qtbot.wait(stepDelay) qtbot.mouseClick(nwBuild.ignoreFlag, Qt.LeftButton) - qtbot.wait(stepDelay) qtbot.mouseClick(nwBuild.includeBody, Qt.LeftButton) - qtbot.wait(stepDelay) qtbot.mouseClick(nwBuild.buildNovel, Qt.LeftButton) # Save files that can be compared assert nwBuild._saveDocument(nwBuild.FMT_NWD) - projFile = os.path.join(nwLipsum, "Lorem Ipsum.nwd") - testFile = os.path.join(outDir, "guiBuild_Tool_Step4_Lorem_Ipsum.nwd") - compFile = os.path.join(refDir, "guiBuild_Tool_Step4_Lorem_Ipsum.nwd") + projFile = prjLipsum / "Lorem Ipsum.nwd" + testFile = tstPaths.outDir / "guiBuild_Tool_Step4_Lorem_Ipsum.nwd" + compFile = tstPaths.refDir / "guiBuild_Tool_Step4_Lorem_Ipsum.nwd" copyfile(projFile, testFile) assert cmpFiles(testFile, compFile) assert nwBuild._saveDocument(nwBuild.FMT_HTM) - projFile = os.path.join(nwLipsum, "Lorem Ipsum.htm") - testFile = os.path.join(outDir, "guiBuild_Tool_Step4_Lorem_Ipsum.htm") - compFile = os.path.join(refDir, "guiBuild_Tool_Step4_Lorem_Ipsum.htm") + projFile = prjLipsum / "Lorem Ipsum.htm" + testFile = tstPaths.outDir / "guiBuild_Tool_Step4_Lorem_Ipsum.htm" + compFile = tstPaths.refDir / "guiBuild_Tool_Step4_Lorem_Ipsum.htm" copyfile(projFile, testFile) assert cmpFiles(testFile, compFile) # Check the JSON files too at this stage assert nwBuild._saveDocument(nwBuild.FMT_JSON_H) - projFile = os.path.join(nwLipsum, "Lorem Ipsum.json") - testFile = os.path.join(outDir, "guiBuild_Tool_Step4H_Lorem_Ipsum.json") - compFile = os.path.join(refDir, "guiBuild_Tool_Step4H_Lorem_Ipsum.json") + projFile = prjLipsum / "Lorem Ipsum.json" + testFile = tstPaths.outDir / "guiBuild_Tool_Step4H_Lorem_Ipsum.json" + compFile = tstPaths.refDir / "guiBuild_Tool_Step4H_Lorem_Ipsum.json" copyfile(projFile, testFile) assert cmpFiles(testFile, compFile, [8]) assert nwBuild._saveDocument(nwBuild.FMT_JSON_M) - projFile = os.path.join(nwLipsum, "Lorem Ipsum.json") - testFile = os.path.join(outDir, "guiBuild_Tool_Step4M_Lorem_Ipsum.json") - compFile = os.path.join(refDir, "guiBuild_Tool_Step4M_Lorem_Ipsum.json") + projFile = prjLipsum / "Lorem Ipsum.json" + testFile = tstPaths.outDir / "guiBuild_Tool_Step4M_Lorem_Ipsum.json" + compFile = tstPaths.refDir / "guiBuild_Tool_Step4M_Lorem_Ipsum.json" copyfile(projFile, testFile) assert cmpFiles(testFile, compFile, [8]) # Since odt and fodt is built by the same code, we don't check the # output. but just that the different format can be written as well assert nwBuild._saveDocument(nwBuild.FMT_ODT) - assert os.path.isfile(os.path.join(nwLipsum, "Lorem Ipsum.odt")) + assert (prjLipsum / "Lorem Ipsum.odt").is_file() # Print to PDF if not nwGUI.mainConf.osDarwin: assert nwBuild._saveDocument(nwBuild.FMT_PDF) - assert os.path.isfile(os.path.join(nwLipsum, "Lorem Ipsum.pdf")) + assert (prjLipsum / "Lorem Ipsum.pdf").is_file() # Close the build tool htmlText = nwBuild.htmlText @@ -287,6 +255,6 @@ def testToolBuild_Main(qtbot, monkeypatch, nwGUI, nwLipsum, refDir, outDir): nwBuild._doClose() - # qtbot.stopForInteraction() + # qtbot.stop() # END Test testToolBuild_Main diff --git a/tests/test_tools/test_tools_lipsum.py b/tests/test_tools/test_tools_lipsum.py index 629deb56..409a168b 100644 --- a/tests/test_tools/test_tools_lipsum.py +++ b/tests/test_tools/test_tools_lipsum.py @@ -21,27 +21,24 @@ along with this program. If not, see . import pytest -from tools import getGuiItem, buildTestProject +from tools import C, getGuiItem, buildTestProject -from PyQt5.QtWidgets import QAction, QMessageBox +from PyQt5.QtWidgets import QAction from novelwriter.tools import GuiLipsum @pytest.mark.gui -def testToolLipsum_Main(qtbot, monkeypatch, nwGUI, fncProj, mockRnd): +def testToolLipsum_Main(qtbot, nwGUI, projPath, mockRnd): """Test the Lorem Ipsum tool. """ - # Block message box - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - # Check that we cannot open when there is no project nwGUI.mainMenu.aLipsumText.activate(QAction.Trigger) assert getGuiItem("GuiLipsum") is None # Create a new project - buildTestProject(nwGUI, fncProj) - assert nwGUI.openDocument("000000000000f") is True + buildTestProject(nwGUI, projPath) + assert nwGUI.openDocument(C.hSceneDoc) is True assert len(nwGUI.docEditor.getText()) == 15 # Open the tool @@ -70,6 +67,6 @@ def testToolLipsum_Main(qtbot, monkeypatch, nwGUI, fncProj, mockRnd): # Close nwLipsum._doClose() - # qtbot.stopForInteraction() + # qtbot.stop() # END Test testToolLipsum_Main diff --git a/tests/test_tools/test_tools_projwizard.py b/tests/test_tools/test_tools_projwizard.py index 8839964e..336e4b1b 100644 --- a/tests/test_tools/test_tools_projwizard.py +++ b/tests/test_tools/test_tools_projwizard.py @@ -19,14 +19,13 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import os import sys import pytest -from tools import getGuiItem +from tools import buildTestProject, getGuiItem from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QFileDialog, QWizard, QMessageBox, QDialog +from PyQt5.QtWidgets import QFileDialog, QWizard, QDialog from novelwriter.enum import nwItemClass from novelwriter.tools.projwizard import ( @@ -34,27 +33,18 @@ from novelwriter.tools.projwizard import ( ProjWizardPopulatePage, ProjWizardCustomPage, ProjWizardFinalPage ) -keyDelay = 2 -typeDelay = 1 -stepDelay = 20 - @pytest.mark.gui @pytest.mark.skipif(sys.platform.startswith("darwin"), reason="Not running on Darwin") -def testToolProjectWizard_Handling(qtbot, monkeypatch, nwGUI, nwMinimal): +def testToolProjectWizard_Handling(qtbot, monkeypatch, nwGUI, projPath): """Test the launch of the project wizard. Disabled for macOS because the test segfaults on QWizard.show() """ - # Block message box - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "critical", lambda *a: QMessageBox.Yes) - - ## - # Test New Project Function - ## + # Test New Project Function + # ======================== # New with a project open should cause an error - assert nwGUI.openProject(nwMinimal) + buildTestProject(nwGUI, projPath) with monkeypatch.context() as mp: mp.setattr(nwGUI, "closeProject", lambda *a: False) assert nwGUI.newProject() is False @@ -70,14 +60,12 @@ def testToolProjectWizard_Handling(qtbot, monkeypatch, nwGUI, nwMinimal): assert nwGUI.newProject() is False # Now, with a non-empty folder - mp.setattr(nwGUI, "showNewProjectDialog", lambda *a: {"projPath": nwMinimal}) + mp.setattr(nwGUI, "showNewProjectDialog", lambda *a: {"projPath": projPath}) assert nwGUI.newProject() is False - ## - # Test the Wizard Launching - ## + # Test the Wizard Launching + # ========================= - nwGUI.mainConf.lastPath = " " monkeypatch.setattr(GuiProjectWizard, "exec_", lambda *a: None) result = nwGUI.showNewProjectDialog() @@ -87,7 +75,6 @@ def testToolProjectWizard_Handling(qtbot, monkeypatch, nwGUI, nwMinimal): assert isinstance(nwWiz, GuiProjectWizard) nwWiz.show() - qtbot.wait(stepDelay) qtbot.mouseClick(nwWiz.button(QWizard.CancelButton), Qt.LeftButton) assert result is None @@ -100,7 +87,7 @@ def testToolProjectWizard_Handling(qtbot, monkeypatch, nwGUI, nwMinimal): nwWiz.reject() nwWiz.close() - # qtbot.stopForInteraction() + # qtbot.stop() # END Test testToolProjectWizard_Handling @@ -108,17 +95,14 @@ def testToolProjectWizard_Handling(qtbot, monkeypatch, nwGUI, nwMinimal): @pytest.mark.gui @pytest.mark.parametrize("prjType", ["minimal", "custom1", "custom2", "sample"]) @pytest.mark.skipif(sys.platform.startswith("darwin"), reason="Not running on Darwin") -def testToolProjectWizard_Run(qtbot, monkeypatch, nwGUI, fncDir, prjType): +def testToolProjectWizard_Run(qtbot, monkeypatch, nwGUI, fncPath, prjType): """Test the new project wizard with a set of selection scenarios. """ - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "critical", lambda *a: QMessageBox.Yes) monkeypatch.setattr(GuiProjectWizard, "exec_", lambda *a: None) - nwGUI.mainConf.lastPath = " " nwWiz = GuiProjectWizard(nwGUI) nwWiz.show() - qtbot.wait(stepDelay) + qtbot.addWidget(nwWiz) # Intro Page # ========== @@ -134,7 +118,6 @@ def testToolProjectWizard_Run(qtbot, monkeypatch, nwGUI, fncDir, prjType): # Setting projName should activate the button assert nwWiz.button(QWizard.NextButton).isEnabled() - qtbot.wait(stepDelay) qtbot.mouseClick(nwWiz.button(QWizard.NextButton), Qt.LeftButton) # Folder Page @@ -146,12 +129,12 @@ def testToolProjectWizard_Run(qtbot, monkeypatch, nwGUI, fncDir, prjType): assert storagePage.errLabel.text() == "" # Set an invalid path - storagePage.projPath.setText(os.path.join(fncDir, "not", "a", "path")) + storagePage.projPath.setText(str(fncPath / "not" / "a" / "path")) assert not nwWiz.button(QWizard.NextButton).isEnabled() assert storagePage.errLabel.text().startswith("Error") # Set an existing path - storagePage.projPath.setText(fncDir) + storagePage.projPath.setText(str(fncPath)) assert not nwWiz.button(QWizard.NextButton).isEnabled() assert storagePage.errLabel.text().startswith("Error") @@ -162,18 +145,17 @@ def testToolProjectWizard_Run(qtbot, monkeypatch, nwGUI, fncDir, prjType): assert storagePage.errLabel.text() == "" # Let the browse feature handle it - projPath = os.path.join(fncDir, "Test Wizard") + projPath = fncPath / "Test Wizard" with monkeypatch.context() as mp: - mp.setattr(QFileDialog, "getExistingDirectory", lambda *a, **k: fncDir) + mp.setattr(QFileDialog, "getExistingDirectory", lambda *a, **k: str(fncPath)) qtbot.mouseClick(storagePage.browseButton, Qt.LeftButton, delay=100) - assert storagePage.projPath.text() == projPath + assert storagePage.projPath.text() == str(projPath) assert storagePage.errLabel.text() == "" # Setting projPath should activate the button assert nwWiz.button(QWizard.NextButton).isEnabled() - qtbot.wait(stepDelay) qtbot.mouseClick(nwWiz.button(QWizard.NextButton), Qt.LeftButton) # Populate Page @@ -183,7 +165,6 @@ def testToolProjectWizard_Run(qtbot, monkeypatch, nwGUI, fncDir, prjType): assert isinstance(popPage, ProjWizardPopulatePage) assert nwWiz.button(QWizard.NextButton).isEnabled() - qtbot.wait(stepDelay) if prjType.startswith("minimal"): popPage.popMinimal.setChecked(True) elif prjType.startswith("custom"): @@ -191,7 +172,6 @@ def testToolProjectWizard_Run(qtbot, monkeypatch, nwGUI, fncDir, prjType): elif prjType.startswith("sample"): popPage.popSample.setChecked(True) - qtbot.wait(stepDelay) qtbot.mouseClick(nwWiz.button(QWizard.NextButton), Qt.LeftButton) # Custom Page @@ -202,6 +182,14 @@ def testToolProjectWizard_Run(qtbot, monkeypatch, nwGUI, fncDir, prjType): assert isinstance(customPage, ProjWizardCustomPage) assert nwWiz.button(QWizard.NextButton).isEnabled() + # Make sure the fourth option is also turned off + customPage.addPlot.setChecked(False) + customPage.addChar.setChecked(False) + customPage.addWorld.setChecked(False) + customPage._syncSwitches() + assert not customPage.addNotes.isChecked() + + # Switch everything back on again customPage.addPlot.setChecked(True) customPage.addChar.setChecked(True) customPage.addWorld.setChecked(True) @@ -211,7 +199,6 @@ def testToolProjectWizard_Run(qtbot, monkeypatch, nwGUI, fncDir, prjType): customPage.numChapters.setValue(0) customPage.numScenes.setValue(10) - qtbot.wait(stepDelay) qtbot.mouseClick(nwWiz.button(QWizard.NextButton), Qt.LeftButton) # Final Page @@ -228,7 +215,7 @@ def testToolProjectWizard_Run(qtbot, monkeypatch, nwGUI, fncDir, prjType): assert projData["projName"] == "Test Wizard" assert projData["projTitle"] == "My Novel" assert projData["projAuthors"] == "Jane Doe" - assert projData["projPath"] == projPath + assert projData["projPath"] == str(projPath) assert projData["popMinimal"] == prjType.startswith("minimal") assert projData["popCustom"] == prjType.startswith("custom") assert projData["popSample"] == prjType.startswith("sample") @@ -256,6 +243,6 @@ def testToolProjectWizard_Run(qtbot, monkeypatch, nwGUI, fncDir, prjType): nwWiz.reject() nwWiz.close() - # qtbot.stopForInteraction() + # qtbot.stop() # END Test testToolProjectWizard_Run diff --git a/tests/test_tools/test_tools_writingstats.py b/tests/test_tools/test_tools_writingstats.py index b7358b20..a02d386a 100644 --- a/tests/test_tools/test_tools_writingstats.py +++ b/tests/test_tools/test_tools_writingstats.py @@ -19,54 +19,41 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import pytest import json -import os +import pytest from mock import causeOSError from tools import getGuiItem, writeFile, buildTestProject from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QAction, QFileDialog, QMessageBox +from PyQt5.QtWidgets import QAction, QFileDialog from novelwriter.tools import GuiWritingStats from novelwriter.constants import nwFiles -keyDelay = 2 -typeDelay = 1 -stepDelay = 20 - @pytest.mark.gui -def testToolWritingStats_Main(qtbot, monkeypatch, nwGUI, fncDir, fncProj): +def testToolWritingStats_Main(qtbot, monkeypatch, nwGUI, fncPath, projPath): """Test the full writing stats tool. """ - # Block message box - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "information", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "warning", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "critical", lambda *a: QMessageBox.Yes) - # Create a project to work on - buildTestProject(nwGUI, fncProj) + buildTestProject(nwGUI, projPath) qtbot.wait(100) assert nwGUI.saveProject() - sessFile = os.path.join(fncProj, "meta", nwFiles.SESS_STATS) + sessFile = projPath / "meta" / nwFiles.SESS_STATS # Open the Writing Stats dialog - nwGUI.mainConf.lastPath = "" nwGUI.mainMenu.aWritingStats.activate(QAction.Trigger) qtbot.waitUntil(lambda: getGuiItem("GuiWritingStats") is not None, timeout=1000) sessLog = getGuiItem("GuiWritingStats") assert isinstance(sessLog, GuiWritingStats) - qtbot.wait(stepDelay) # Test Loading # ============ # No initial logfile - assert not os.path.isfile(sessFile) + assert not sessFile.is_file() assert not sessLog._loadLogFile() # Make a test log file @@ -78,7 +65,7 @@ def testToolWritingStats_Main(qtbot, monkeypatch, nwGUI, fncDir, fncProj): "2020-01-03 21:30:00 2020-01-03 21:30:15 125 5\n" "2020-01-06 21:00:00 2020-01-06 21:00:10 125 5\n" )) - assert os.path.isfile(sessFile) + assert sessFile.is_file() assert sessLog._loadLogFile() assert sessLog.wordOffset == 123 assert len(sessLog.logData) == 4 @@ -122,9 +109,7 @@ def testToolWritingStats_Main(qtbot, monkeypatch, nwGUI, fncDir, fncProj): assert not sessLog._saveData(None) # Make the save succeed - monkeypatch.setattr("os.path.expanduser", lambda *a: fncDir) monkeypatch.setattr(QFileDialog, "getSaveFileName", lambda ss, tt, pp, options: (pp, "")) - sessLog.listBox.sortByColumn(sessLog.C_TIME, 0) assert sessLog.novelWords.text() == "{:n}".format(600) @@ -146,10 +131,8 @@ def testToolWritingStats_Main(qtbot, monkeypatch, nwGUI, fncDir, fncProj): assert sessLog._saveData(sessLog.FMT_JSON) qtbot.wait(100) - assert nwGUI.mainConf.lastPath == fncDir - # Check the exported files - jsonStats = os.path.join(fncDir, "sessionStats.json") + jsonStats = fncPath / "sessionStats.json" with open(jsonStats, mode="r", encoding="utf-8") as inFile: jsonData = json.load(inFile) @@ -186,11 +169,9 @@ def testToolWritingStats_Main(qtbot, monkeypatch, nwGUI, fncDir, fncProj): # No Novel Files qtbot.mouseClick(sessLog.incNovel, Qt.LeftButton) - qtbot.wait(stepDelay) assert sessLog._saveData(sessLog.FMT_JSON) - qtbot.wait(stepDelay) - jsonStats = os.path.join(fncDir, "sessionStats.json") + jsonStats = fncPath / "sessionStats.json" with open(jsonStats, mode="r", encoding="utf-8") as inFile: jsonData = json.loads(inFile.read()) @@ -234,11 +215,9 @@ def testToolWritingStats_Main(qtbot, monkeypatch, nwGUI, fncDir, fncProj): # No Note Files qtbot.mouseClick(sessLog.incNovel, Qt.LeftButton) qtbot.mouseClick(sessLog.incNotes, Qt.LeftButton) - qtbot.wait(stepDelay) assert sessLog._saveData(sessLog.FMT_JSON) - qtbot.wait(stepDelay) - jsonStats = os.path.join(fncDir, "sessionStats.json") + jsonStats = fncPath / "sessionStats.json" with open(jsonStats, mode="r", encoding="utf-8") as inFile: jsonData = json.load(inFile) @@ -282,13 +261,11 @@ def testToolWritingStats_Main(qtbot, monkeypatch, nwGUI, fncDir, fncProj): # No Negative Entries qtbot.mouseClick(sessLog.incNotes, Qt.LeftButton) qtbot.mouseClick(sessLog.hideNegative, Qt.LeftButton) - qtbot.wait(stepDelay) assert sessLog._saveData(sessLog.FMT_JSON) - qtbot.wait(stepDelay) - # qtbot.stopForInteraction() + # qtbot.stop() - jsonStats = os.path.join(fncDir, "sessionStats.json") + jsonStats = fncPath / "sessionStats.json" with open(jsonStats, mode="r", encoding="utf-8") as inFile: jsonData = json.load(inFile) @@ -316,11 +293,9 @@ def testToolWritingStats_Main(qtbot, monkeypatch, nwGUI, fncDir, fncProj): # Un-hide Zero Entries qtbot.mouseClick(sessLog.hideNegative, Qt.LeftButton) qtbot.mouseClick(sessLog.hideZeros, Qt.LeftButton) - qtbot.wait(stepDelay) assert sessLog._saveData(sessLog.FMT_JSON) - qtbot.wait(stepDelay) - jsonStats = os.path.join(fncDir, "sessionStats.json") + jsonStats = fncPath / "sessionStats.json" with open(jsonStats, mode="r", encoding="utf-8") as inFile: jsonData = json.load(inFile) @@ -371,11 +346,9 @@ def testToolWritingStats_Main(qtbot, monkeypatch, nwGUI, fncDir, fncProj): # Group by Day qtbot.mouseClick(sessLog.groupByDay, Qt.LeftButton) - qtbot.wait(stepDelay) assert sessLog._saveData(sessLog.FMT_JSON) - qtbot.wait(stepDelay) - jsonStats = os.path.join(fncDir, "sessionStats.json") + jsonStats = fncPath / "sessionStats.json" with open(jsonStats, mode="r", encoding="utf-8") as inFile: jsonData = json.load(inFile) @@ -422,10 +395,9 @@ def testToolWritingStats_Main(qtbot, monkeypatch, nwGUI, fncDir, fncProj): assert not sessLog._loadLogFile() assert not sessLog._saveData(sessLog.FMT_CSV) - # qtbot.stopForInteraction() + # qtbot.stop() sessLog._doClose() assert nwGUI.closeProject() - qtbot.wait(stepDelay) # END Test testToolWritingStats_Main diff --git a/tests/tools.py b/tests/tools.py index fcdd3817..0883dc14 100644 --- a/tests/tools.py +++ b/tests/tools.py @@ -19,13 +19,41 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import os import time import shutil +from pathlib import Path + from PyQt5.QtWidgets import qApp -XML_IGNORE = ("> By Jane DOe <<\n") + aDoc = theProject.storage.getDocument(xHandle[5]) + aDoc.writeDocument("#! New Novel\n\n>> By Jane Doe <<\n") + theProject.index.reIndexHandle(xHandle[5]) - aDoc = NWDoc(theProject, xHandle[7]) + aDoc = theProject.storage.getDocument(xHandle[7]) aDoc.writeDocument("## %s\n\n" % theProject.tr("New Chapter")) + theProject.index.reIndexHandle(xHandle[7]) - aDoc = NWDoc(theProject, xHandle[8]) + aDoc = theProject.storage.getDocument(xHandle[8]) aDoc.writeDocument("### %s\n\n" % theProject.tr("New Scene")) + theProject.index.reIndexHandle(xHandle[8]) - theProject.projOpened = time.time() + theProject._projOpened = time.time() theProject.setProjectChanged(True) theProject.saveProject(autoSave=True) if theGUI is not None: theGUI.hasProject = True theGUI.rebuildTrees() - theGUI.rebuildIndex(beQuiet=True) return