diff --git a/CHANGELOG.md b/CHANGELOG.md index 00509e4e..b1b08033 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,94 @@ # novelWriter Changelog +## Version 1.7 Beta 1 [2022-05-17] + +### Release Notes + +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 backups. + +Please check the changelog for an overview of changes. The full release notes will be added to the +final release. + +### Detailed Changelog + +**Features** + +* A simple tool to add Lorem Ipsum placeholder text has been added to the Insert menu. PR #1028. +* Status and Importance flags can now be reorganised in Project Settings. Issue #1035. PR #1040. +* It is now possible to create multiple Root Folders of the same kind. This makes it possible to + add multiple Novel root folders in a project, for instance. Issue #967. PR #1031. +* All documents can now be dragged and dropped anywhere in the project tree. The document layout + may be converted in the process. PR #1031. +* Documents in the project tree can now have other documents as child documents. Issue #1002. + PR #1047. +* Folders in the project tree that are not empty, can now be moved to trash. PR #1048. +* Empty folders are deleted on request, and not moved to trash. Issue #1052. PR #1055. + +**User Interface** + +* The tabs under the project tree and to the right of the main window have been replaced with a + toolbar on the left hand side. The toolbar has a set of buttons to change view between Project, + Novel and Outline. The three buttons that were available under the project tree have been moved + to the bottom of the new toolbar. Issue #1056. PR #1057. +* When a document changes from a project document to a note, and back again, the Status flag + setting is preserved. Previously, the Importance setting would overwrite it during the + conversion. PR #1030. +* Item labels, Status labels, and other labels on the GUI are now run through a "simplify" function + before being accepted. This functions strips out all whitespaces and consecutive whitespaces and + replace them with single plain whitespaces. This is a safer format to store in XML, and also + makes sure there aren't invisible characters floating around in the labels. PR #1038. +* Due to the changes to how drag and drop works, there are no longer any restrictions on folders + and documents. Only root folders remain restricted in terms of moving. Root folders can only be + reordered with the Move Up and Move Down commands. PR #1047. +* The label for the highlighting of redundant spaces in the Preferences dialog has been updated to + better reflect what it does. Issue #1043. PR #1046. +* The New Project Wizard will now try to check if the path selected for the new project can + actually be used before letting the user proceed to the next page. Issue #1058. PR #1062. + +**Internationalisation** + +* Dutch translations have been added by Martijn van der Kleijn (@mvdkleijn). PR #1027. + +**Functionality** + +* Documents that are missing in the project index when a project is opened are automatically + re-indexed. This also handles cases where the cached index is missing. PR #1039. + +**Installation and Packaging** + +* Python 3.6 is no longer supported. PR #1004. +* Ubuntu 18.04 packages will no longer be released, due to dropping Python 3.6. Issue #1005. + PR #1014. + +**Project File Format** + +* The item nodes in the content section of the main project XML file have been compacted. It now + consists of a main item node and meta and a name node. All settings have been made attributes of + one of these three nodes, except the item label which is the text value of the name node. The + file format version has been bumped to 1.4. Issue #995. PR #993. +* Both Importance and Status flag values are now saved to the project file. This means if a + document changes layout, the value is no longer lost. PR #1030. + +**Code Improvements** + +* The linting settings have been updated to select between mutually exclusive options in + pycodestyle. PR #1014. +* The Tokenizer class has been converted to an abstract base class. PR #1026. +* The class handling Status and Importance flags has been completely rewritten. The flags are now + handled using a unique random key as reference rather than relying on the text of the label + itself. This makes it a lot easier to rename them as there is no need to update project items. + PR #1034. +* Many of the decisions regarding where items are allowed to belong has been delegated to the + NWItem class that holds the item. Some is also handled by the NWTree class that holds the project + tree. A new maintenance function in the NWTree class will also ensure that the meta data of an + item is correct and up to date. This is especially important after an item has been moved, but is + also checked when items are initially loaded. PRs #1031 and #1054. +* Item handles are now generated using the standard library random number generator. The new + handles have the same format as the old algorithm, so they are compatible. PR #1044. + +---- + ## Version 1.6.2 [2022-03-20] ### Release Notes diff --git a/CREDITS.md b/CREDITS.md index c2d135c9..777b37bb 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -11,13 +11,15 @@ ## Translations +The default language is English (UK) with English (US) as an option. + +* Dutch: Martijn van der Kleijn (@mvdkleijn) * French: Jan Lüdke (@jyhelle) +* German: Myian (@heymyian) +* Latin American Spanish: Tommy Marplatt (@tmarplatt) * Norwegian: Veronica Berglyd Olsen (@vkbo) * Portuguese: Bruno Meneguello (@bkmeneguello) * Simplified Chinese: Qianzhi Long (@longqzh) -* Latin American Spanish: Tommy Marplatt (@tmarplatt) -* Dutch: Martijn van der Kleijn (@mvdkleijn) -* German: Myian (@heymyian) ## Libraries @@ -33,7 +35,7 @@ The following libraries are dependencies of novelWriter: Some of the assets bundled with novelWriter were adapted from the following sources: -* Typicons icons by Stephen Hutchings (CC BY-SA 4.0) +* Typicons icons by [Stephen Hutchings](https://github.com/stephenhutchings/typicons.font) (CC BY-SA 4.0) * Tomorrow syntax themes by Chris Kempson (MIT License) * Owl syntax themes by Sarah Drasner (MIT License) * Solarized themes by Ethan Schoonover, added by @nullbasis (MIT License) diff --git a/README.md b/README.md index 60ca008b..7b0a9965 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ The full documentation is available at The full credits are listed in [CREDITS.md](https://github.com/vkbo/novelWriter/blob/main/CREDITS.md). +You can also follow novelWriter on Mastodon at [fosstodon.org/@novelwriter](https://fosstodon.org/@novelwriter). + ## Implementation The application is written with Python 3 (3.7+) using Qt5 and PyQt5 (5.3+). It is developed on diff --git a/docs/source/index.rst b/docs/source/index.rst index 0191425b..20c39895 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -39,11 +39,13 @@ too. novelWriter can be run directly from the Python source, installed from the * Website: https://novelwriter.io * Documentation: https://novelwriter.readthedocs.io +* Internationalisation: https://crowdin.com/project/novelwriter * Source Code: https://github.com/vkbo/novelWriter * Source Releases: https://github.com/vkbo/novelWriter/releases * Issue Tracker: https://github.com/vkbo/novelWriter/issues * Feature Discussions: https://github.com/vkbo/novelWriter/discussions * PyPi Project: https://pypi.org/project/novelWriter +* Social Media: https://fosstodon.org/@novelwriter .. toctree:: :maxdepth: 1 diff --git a/docs/source/usage_shortcuts.rst b/docs/source/usage_shortcuts.rst index fea65c8c..69c2e773 100644 --- a/docs/source/usage_shortcuts.rst +++ b/docs/source/usage_shortcuts.rst @@ -59,7 +59,6 @@ The main shorcuts are as follows: ":kbd:`Ctrl`:kbd:`Y`", "Redo latest undo." ":kbd:`Ctrl`:kbd:`Z`", "Undo latest changes." ":kbd:`Ctrl`:kbd:`F7`", "Toggle spell checking." - ":kbd:`Ctrl`:kbd:`F10`", "Toggle automatic updating of project outline." ":kbd:`Ctrl`:kbd:`Up`", "Move item one step up in the project tree." ":kbd:`Ctrl`:kbd:`Down`", "Move item one step down in the project tree." ":kbd:`Ctrl`:kbd:`Del`", "Delete next word in editor." @@ -88,7 +87,6 @@ The main shorcuts are as follows: ":kbd:`F7`", "Re-run spell checker." ":kbd:`F8`", "Activate :guilabel:`Focus Mode`, hiding the project tree and document viewer." ":kbd:`F9`", "Re-build the project index." - ":kbd:`F10`", "Re-build the project outline." ":kbd:`F11`", "Activate full screen mode." ":kbd:`Shift`:kbd:`F1`", "Open the local user manual (PDF) if it is available." ":kbd:`Shift`:kbd:`F3`", "Find previous occurrence of search word in current document." diff --git a/i18n/README.md b/i18n/README.md index 27486cf9..b2dd8cc9 100644 --- a/i18n/README.md +++ b/i18n/README.md @@ -2,6 +2,7 @@ The maintenance of translations has been moved to the Crowdin service. The translation strings can be edited there at the [novelWriter project page](https://crowdin.com/project/novelwriter). +However, please read the Translation Guidelines section below. You can still use the manual approach listed below, and then upload the file through the website's interface. The translation strings for that language will then be updated and queued for approval. @@ -11,6 +12,22 @@ To verify a language file translated through the Crowdin tool, download and extr [Generate an Updated Translation File](#generate-an-updated-translation-file) below. +# Translation Guidelines + +When contributing translations, keep the following things in mind. + +* For descriptive labels and dialog boxes, make sure you do _not_ change the meaning of the text + when you translate it from English. The user must receive the same instructions or information + regardless of language. This is improtant, otherwise the documentation will be inconsistent with + the user interface and it will become a lot more difficult to handle user issues and questions. +* If you think a label or description is misleading or incomplete, please file an issue report. The + correct way to handle such changes is to change the text in the code first, which will then be + forwarded to _all_ translators such that the GUI is consistent across all languages. +* For very short labels, like button labels. it may be fine to replace the word with a similar + word, but only as long as the user understands what it is supposed to do. Some buttons and tabs + have limited space. If necessary, it is OK to use abbreviations. + + # Direct Approach Using Qt Linguist Here you will find instructions for translating novelWriter to a new language directly using Qt diff --git a/i18n/nw_base.ts b/i18n/nw_base.ts index 4eaa8252..8d7e2940 100644 --- a/i18n/nw_base.ts +++ b/i18n/nw_base.ts @@ -77,259 +77,259 @@ 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 - + 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 @@ -353,9 +353,9 @@ - - - + + + Licence @@ -411,31 +411,31 @@ - + Theme: {0} - - - - - Author - - + Author + + + + + + Credit - + Icons: {0} - + Syntax: {0} @@ -718,62 +718,62 @@ - + 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 +781,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 +799,32 @@ GuiDocEditFooter - + Status - + Line: {0} ({1}) - + Words: {0} ({1}) - + Document size is {0} bytes - + Words: {0} selected - + Character count: {0} @@ -832,22 +832,22 @@ GuiDocEditHeader - + Edit document meta - + Search document - + Toggle Focus Mode - + Close the document @@ -855,58 +855,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 +914,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. @@ -1067,12 +1067,12 @@ - + Could not save document. - + Element selected in the project tree must be a folder. @@ -1080,83 +1080,78 @@ 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. - + Could not parse source document. - + Failed to open document file. - + No headers found. Nothing to do. - - Cannot add new folder for the document split. Maximum folder depth has been reached. Please move the file to another level in the project tree. - - - - + 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. @@ -1164,42 +1159,42 @@ 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 @@ -1207,22 +1202,22 @@ GuiDocViewHeader - + Go backward - + Go forward - + Reload the document - + Close the document @@ -1283,17 +1278,17 @@ - + Characters - + Words - + Paragraphs @@ -1306,237 +1301,230 @@ - + Include when building project - + Label - + Status - + Layout + + GuiLipsum + + + Insert Placeholder Text + + + + + Insert Lorem Ipsum Text + + + + + Number of paragraphs + + + + + Randomise order + + + + + Insert + + + GuiMain - - Project - - - - - Novel - - - - - Project Details - - - - - Writing Statistics - - - - - Project Settings - - - - - Editor - - - - - Outline - - - - + 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. - + Information - + Warning - + Error - + This is a bug! - + Internal Error - + Exit - + Do you want to exit novelWriter? @@ -1544,677 +1532,682 @@ GuiMainMenu - + &Project - + New Project - + Open Project - + Save Project - + Close Project - + Project Settings - + Project Details - + Create Root Folder - + 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 - + 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 @@ -2294,65 +2287,65 @@ GuiOutlineDetails - - - - + + + + Title - + Chapter - + Scene - + Section - + Document - + Status - + Characters - + Words - + Paragraphs - + Synopsis - + Title Details - + Reference Tags @@ -3046,7 +3039,7 @@ - Highlight multiple spaces + Highlight multiple or trailing spaces @@ -3187,58 +3180,58 @@ GuiProjectEditMain - + Project Settings - + Working title - + 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 @@ -3246,27 +3239,27 @@ GuiProjectEditReplace - + Text Replace List for Preview and Export - + Keyword - + Replace With - + Select item to edit - + Save @@ -3274,67 +3267,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 @@ -3406,27 +3399,27 @@ GuiProjectSettings - + Project Settings - + Settings - + Status - + Importance - + Auto-Replace @@ -3464,100 +3457,78 @@ - - Please select a valid location in the tree to add the document. - - - - - Please select a valid location in the tree to add the folder. - - - - - + Did not find anywhere to add the file or folder! - + Cannot add new files or folders to the Trash folder. - - New File + + New Document - - Cannot add new folder to this item. Maximum folder depth has been reached. + + New Note - + 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? - - - Delete File - - - - - Permanently delete file '{0}'? - - - - - Could not delete document file. - - - - - Move file '{0}' to Trash? - - - - - Cannot delete 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. - - - The item cannot be moved to that location. + + + Delete - + + Permanently delete '{0}'? + + + + + Move '{0}' to Trash? + + + + + Could not delete document file. + + + + There is nowhere to add item with name '{0}'. @@ -3565,52 +3536,52 @@ GuiProjectTreeMenu - + Edit Project Item - + Open Document - + View Document - + Toggle Included Flag - + New File - + New Folder - + Delete Item - + Empty Trash - + Move Item Up - + Move Item Down @@ -3649,6 +3620,39 @@ + + GuiViewsBar + + + Project + + + + + Novel + + + + + Outline + + + + + Details + + + + + Stats + + + + + Settings + + + GuiWordList @@ -3816,7 +3820,7 @@ - + Failed to read session log file. @@ -3824,314 +3828,309 @@ NWProject - - Duplicate root item detected. - - - - - + + New - + 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}. - + 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. - - + + 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. - + 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. - + Failed to create a new example project. Could not find the necessary files. They seem to be missing from this installation. - + Could not create new project folder. - + New project folder is not empty. Each project requires a dedicated project folder. - + You must set a valid backup path in Preferences to use the automatic project backup feature. - + You must set a valid project name in Project Settings to use the automatic project backup feature. - + 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}. @@ -4139,47 +4138,47 @@ 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. - + Additional Root Folders - - - - - - + + + + + + {0} folder - + Populate Novel Folder - + Add chapters - + Scenes (per chapter) - + Add chapter folders @@ -4187,27 +4186,27 @@ ProjWizardFinalPage - + Finished - + All done. - + Press '{0}' to create the new project. - + Done - + Finish @@ -4215,7 +4214,7 @@ ProjWizardFolderPage - + Select Project Folder @@ -4231,10 +4230,20 @@ - + Project Path + + + Error: A project folder cannot be created using this path. + + + + + Error: The selected path already exists. + + ProjWizardIntroPage @@ -4287,27 +4296,27 @@ 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 @@ -4503,17 +4512,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 3fb60626..75aee812 100644 --- a/i18n/nw_en_US.ts +++ b/i18n/nw_en_US.ts @@ -77,259 +77,259 @@ 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 - + 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 @@ -353,9 +353,9 @@ Release - - - + + + Licence License @@ -411,31 +411,31 @@ Translations - + Theme: {0} Theme: {0} - - - - - Author - Author - + Author + Author + + + + + Credit Credit - + Icons: {0} Icons: {0} - + Syntax: {0} Syntax: {0} @@ -718,62 +718,62 @@ 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 +781,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 +799,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 +832,22 @@ GuiDocEditHeader - + Edit document meta Edit document meta - + Search document Search document - + Toggle Focus Mode Toggle Focus Mode - + Close the document Close the document @@ -855,58 +855,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 +914,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. @@ -1067,12 +1067,12 @@ 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. @@ -1080,83 +1080,78 @@ 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. - + Could not parse source document. Could not parse source document. - + Failed to open document file. Failed to open document file. - + No headers found. Nothing to do. No headers found. Nothing to do. - - Cannot add new folder for the document split. Maximum folder depth has been reached. Please move the file to another level in the project tree. - Cannot add new folder for the document split. Maximum folder depth has been reached. Please move the file to another level in the project tree. - - - + 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. @@ -1164,42 +1159,42 @@ 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 @@ -1207,22 +1202,22 @@ GuiDocViewHeader - + Go backward Go backward - + Go forward Go forward - + Reload the document Reload the document - + Close the document Close the document @@ -1283,17 +1278,17 @@ Usage - + Characters Characters - + Words Words - + Paragraphs Paragraphs @@ -1306,237 +1301,230 @@ 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 + + GuiMain - - Project - Project - - - - Novel - Novel - - - - Project Details - Project Details - - - - Writing Statistics - Writing Statistics - - - - Project Settings - Project Settings - - - - Editor - Editor - - - - Outline - Outline - - - + 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. - + 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? @@ -1544,677 +1532,682 @@ 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 - + 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 - + 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 @@ -2294,65 +2287,65 @@ 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 @@ -3046,8 +3039,8 @@ - Highlight multiple spaces - Highlight multiple spaces + Highlight multiple or trailing spaces + Highlight multiple or trailing spaces @@ -3187,58 +3180,58 @@ GuiProjectEditMain - + Project Settings Project Settings - + Working title Working title - + 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 @@ -3246,27 +3239,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 @@ -3274,67 +3267,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 @@ -3406,27 +3399,27 @@ GuiProjectSettings - + Project Settings Project Settings - + Settings Settings - + Status Status - + Importance Importance - + Auto-Replace Auto-Replace @@ -3464,100 +3457,78 @@ Item status - - Please select a valid location in the tree to add the document. - Please select a valid location in the tree to add the document. - - - - Please select a valid location in the tree to add the folder. - Please select a valid location in the tree to add the folder. - - - - + 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 File - New File + + New Document + New Document - - Cannot add new folder to this item. Maximum folder depth has been reached. - Cannot add new folder to this item. Maximum folder depth has been reached. + + New Note + New Note - + 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? - - - Delete File - Delete File - - - - Permanently delete file '{0}'? - Permanently delete file '{0}'? - - - - Could not delete document file. - Could not delete document file. - - - - Move file '{0}' to Trash? - Move file '{0}' to Trash? - - - - Cannot delete folder. It is not empty. Recursive deletion is not supported. Please delete the content first. - Cannot delete 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. Cannot delete root folder. It is not empty. Recursive deletion is not supported. Please delete the content first. - - - The item cannot be moved to that location. - The item cannot be moved to that location. + + + 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. + + + There is nowhere to add item with name '{0}'. There is nowhere to add item with name '{0}'. @@ -3565,52 +3536,52 @@ GuiProjectTreeMenu - + Edit Project Item Edit Project Item - + Open Document Open Document - + View Document View Document - + Toggle Included Flag Toggle Included Flag - + New File New File - + New Folder New Folder - + Delete Item Delete Item - + Empty Trash Empty Trash - + Move Item Up Move Item Up - + Move Item Down Move Item Down @@ -3649,6 +3620,39 @@ Download: {0} + + GuiViewsBar + + + Project + Project + + + + Novel + Novel + + + + Outline + Outline + + + + Details + Details + + + + Stats + Stats + + + + Settings + Settings + + GuiWordList @@ -3816,7 +3820,7 @@ Failed to write {0} file. - + Failed to read session log file. Failed to read session log file. @@ -3824,314 +3828,309 @@ NWProject - - Duplicate root item detected. - Duplicate root item detected. - - - - + + New New - + 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}. - + 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. - - + + 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. - + 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. - + 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. - + Could not create new project folder. Could not create new project folder. - + 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. - + 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. - + 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. - + 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}. @@ -4139,47 +4138,47 @@ 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. - + Additional Root Folders Additional Root Folders - - - - - - + + + + + + {0} folder {0} folder - + Populate Novel Folder Populate Novel Folder - + Add chapters Add chapters - + Scenes (per chapter) Scenes (per chapter) - + Add chapter folders Add chapter folders @@ -4187,27 +4186,27 @@ ProjWizardFinalPage - + Finished Finished - + All done. All done. - + Press '{0}' to create the new project. Press '{0}' to create the new project. - + Done Done - + Finish Finish @@ -4215,7 +4214,7 @@ ProjWizardFolderPage - + Select Project Folder Select Project Folder @@ -4231,10 +4230,20 @@ 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. + ProjWizardIntroPage @@ -4287,27 +4296,27 @@ 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 @@ -4503,17 +4512,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 a4a6c915..17cb5ddb 100644 --- a/i18n/nw_nb_NO.ts +++ b/i18n/nw_nb_NO.ts @@ -77,259 +77,259 @@ 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 - + 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 @@ -353,9 +353,9 @@ Utgivelse - - - + + + Licence Lisens @@ -411,31 +411,31 @@ Oversettelser - + Theme: {0} Tema: {0} - - - - - Author - Ansvarlig - + Author + Ansvarlig + + + + + Credit Kreditert - + Icons: {0} Ikoner: {0} - + Syntax: {0} Syntaks: {0} @@ -718,62 +718,62 @@ 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 +781,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 +799,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 +832,22 @@ GuiDocEditHeader - + Edit document meta Rediger dokumentinstillinger - + Search document Søk i dokumentet - + Toggle Focus Mode Slå av/på "Fokus-modus" - + Close the document Lukk dokumentet @@ -855,58 +855,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 +914,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. @@ -1067,12 +1067,12 @@ 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. @@ -1080,83 +1080,78 @@ 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. - + Could not parse source document. Klarte ikke å lese kilde-dokumentet. - + 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. - - Cannot add new folder for the document split. Maximum folder depth has been reached. Please move the file to another level in the project tree. - Kan ikke legge til ny mappe for å dele opp dokumentet. Dokumentet har allerede maksimal dybde i prosjekttreet. Flytt dokumentet til et annet nivå først. - - - + 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. @@ -1164,42 +1159,42 @@ 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 @@ -1207,22 +1202,22 @@ GuiDocViewHeader - + Go backward Gå bakover - + Go forward Gå fremover - + Reload the document Last dokumentet på nytt - + Close the document Lukk dokumentet @@ -1283,17 +1278,17 @@ Formål - + Characters Tegn - + Words Ord - + Paragraphs Avsnitt @@ -1306,237 +1301,230 @@ 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 + + GuiMain - - Project - Prosjekt - - - - Novel - Roman - - - - Project Details - Prosjektdetaljer - - - - Writing Statistics - Statistikk - - - - Project Settings - Prosjektinnstillinger - - - - Editor - Editor - - - - Outline - Disposisjon - - - + 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. - + 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? @@ -1544,677 +1532,682 @@ 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 - + 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 - + 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 @@ -2294,65 +2287,65 @@ 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 @@ -3046,8 +3039,8 @@ - Highlight multiple spaces - Fremheve repeterte mellomrom + Highlight multiple or trailing spaces + Fremhev flere eller etterfølgende mellomrom @@ -3187,58 +3180,58 @@ GuiProjectEditMain - + Project Settings Prosjektinnstillinger - + Working title Arbeidstittel - + 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 @@ -3246,27 +3239,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 @@ -3274,67 +3267,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 @@ -3406,27 +3399,27 @@ GuiProjectSettings - + Project Settings Prosjektinnstillinger - + Settings Innstillinger - + Status Status - + Importance Viktighet - + Auto-Replace Autoerstatt @@ -3464,100 +3457,78 @@ Filen eller dokumentets status - - Please select a valid location in the tree to add the document. - Du må velge et gyldig sted i prosjekttreet for å legge til dokumentet. - - - - Please select a valid location in the tree to add the folder. - Du må velge et gyldig sted i prosjekttreet for å legge til mappen. - - - - + 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. - - New File - Ny fil + + New Document + Nytt dokument - - Cannot add new folder to this item. Maximum folder depth has been reached. - Kan ikke legge til ny mappe på dette stedet da maksimum mappe-dypde er nådd. + + New Note + Nytt notat - + New Folder Ny mappe - + There is currently no Trash folder in this project. Det er for øyeblikket ingen søppel-mappe i dette prosjektet. - + The Trash folder is already empty. Søppel-mappen er allerede tom. - + Empty Trash Tøm søppel - + Permanently delete {0} file(s) from Trash? Vil du slette {0} filer i søppel-mappen for godt? - - - Delete File - Slett fil - - - - Permanently delete file '{0}'? - Slette filen {0} for godt'? - - - - Could not delete document file. - Kunne ikke slette dokumentets fil. - - - - Move file '{0}' to Trash? - Vil du flytte filen {0} til søpla? - - - - Cannot delete folder. It is not empty. Recursive deletion is not supported. Please delete the content first. - Kan ikke slette mappen da den ikke er tom. Rekursiv sletting er ikke støttet. Du må slette innholdet først. - - - + 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. - - - The item cannot be moved to that location. - Denne enheten kan ikke flyttes til denne lokasjonen. + + + 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. + + + There is nowhere to add item with name '{0}'. Fant ikke noe sted å legge til enheten med navn {0}'. @@ -3565,52 +3536,52 @@ GuiProjectTreeMenu - + Edit Project Item Endre enhet - + Open Document Åpne dokument - + View Document Vis dokument - + Toggle Included Flag Slå av/på inkludering - + New File Ny fil - + New Folder Ny mappe - + Delete Item Slett enhet - + Empty Trash Tøm søppel - + Move Item Up Flytt enhet opp - + Move Item Down Flytt enhet ned @@ -3649,6 +3620,39 @@ Last ned: {0} + + GuiViewsBar + + + Project + Prosjekt + + + + Novel + Roman + + + + Outline + Oversikt + + + + Details + Detaljer + + + + Stats + Statistikk + + + + Settings + Oppsett + + GuiWordList @@ -3816,7 +3820,7 @@ Kunne ikke skrive {0}-filen. - + Failed to read session log file. Kunne ikke lese loggfil med skrive-statistikk. @@ -3824,314 +3828,309 @@ NWProject - - Duplicate root item detected. - Duplikat hovedmappe. - - - - + + New Ny - + 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}. - + 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. - - + + 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. - + 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. - + 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. - + Could not create new project folder. Kunne ikke lage ny prosjekt-mappe. - + New project folder is not empty. Each project requires a dedicated project folder. Ny prosjektmappe er ikke tom. Hvert prosjekt trenger sin egen mappe. - + 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. - + 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. - + 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}. @@ -4139,47 +4138,47 @@ 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. - + Additional Root Folders Hovedmapper - - - - - - + + + + + + {0} folder {0} - + Populate Novel Folder Fyll roman-mappen - + Add chapters Legg til kapitler - + Scenes (per chapter) Scener (per kapittel) - + Add chapter folders Lag kapittel-mapper @@ -4187,27 +4186,27 @@ ProjWizardFinalPage - + Finished Ferdig - + All done. Alt er klart. - + Press '{0}' to create the new project. Trykk '{0}' for å opprette det nye prosjektet. - + Done Ferdig - + Finish Fullfør @@ -4215,7 +4214,7 @@ ProjWizardFolderPage - + Select Project Folder Velg prosjektmappe @@ -4231,10 +4230,20 @@ 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. + ProjWizardIntroPage @@ -4287,27 +4296,27 @@ 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 @@ -4503,17 +4512,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 164a05e7..0ae10566 100644 --- a/novelwriter/__init__.py +++ b/novelwriter/__init__.py @@ -60,9 +60,9 @@ __license__ = "GPLv3" __author__ = "Veronica Berglyd Olsen" __maintainer__ = "Veronica Berglyd Olsen" __email__ = "code@vkbo.net" -__version__ = "1.7-alpha0" -__hexversion__ = "0x010700a0" -__date__ = "2022-02-20" +__version__ = "1.7-beta1" +__hexversion__ = "0x010700b1" +__date__ = "2022-05-17" __status__ = "Stable" __domain__ = "novelwriter.io" __url__ = "https://novelwriter.io" diff --git a/novelwriter/assets/i18n/project_nl_NL.json b/novelwriter/assets/i18n/project_nl_NL.json index 644da56a..65f4eaca 100644 --- a/novelwriter/assets/i18n/project_nl_NL.json +++ b/novelwriter/assets/i18n/project_nl_NL.json @@ -2,104 +2,104 @@ "Synopsis": "Synopsis", "Comment": "Opmerking", "Notes": "Notities", - "0": "Nul", - "1": "Één", - "2": "Twee", - "3": "Drie", - "4": "Vier", - "5": "Vijf", - "6": "Zes", - "7": "Zeven", - "8": "Acht", - "9": "Negen", - "10": "Tien", - "11": "Elf", - "12": "Twaalf", - "13": "Dertien", - "14": "Veertien", - "15": "Vijftien", - "16": "Zestien", - "17": "Zeventien", - "18": "Achttien", - "19": "Negentien", - "20": "Twintig", - "21": "Eenentwintig", - "22": "Tweeentwintig", - "23": "Drieentwintig", - "24": "Vierentwintig", - "25": "Vijfentwintig", - "26": "Zesentwintig", - "27": "Zevenentwintig", - "28": "Achtentwintig", - "29": "Negenentwintig", - "30": "Dertig", - "31": "Eenendertig", - "32": "Tweeendertig", - "33": "Drieendertig", - "34": "Vierendertig", - "35": "Vijfendertig", - "36": "Zesendertig", - "37": "Zevenendertig", - "38": "Achtendertig", - "39": "Negenendertig", - "40": "Veertig", - "41": "Eenenveertig", - "42": "Tweeënveertig", - "43": "Drieenveertig", - "44": "Vierenveertig", - "45": "Vijfenveertig", - "46": "Zesenveertig", - "47": "Zevenenveertig", - "48": "Achtenveertig", - "49": "Negenenveertig", - "50": "Vijftig", - "51": "Eenenvijftig", - "52": "Tweeenvijftig", - "53": "Drieenvijftig", - "54": "Vierenvijftig", - "55": "Vijfenvijftig", - "56": "Zesenvijftig", - "57": "Zevenenvijftig", - "58": "Achtenvijftig", - "59": "Negenenvijftig", - "60": "Zestig", - "61": "Eenenzestig", - "62": "Tweeenzestig", - "63": "Drieenzestig", - "64": "Vierenzestig", - "65": "Vijfenzestig", - "66": "Zesenzestig", - "67": "Zevenenzestig", - "68": "Achtenzestig", - "69": "Negenenzestig", - "70": "Zeventig", - "71": "Eenenzeventig", - "72": "Tweeenzeventig", - "73": "Drieenzeventig", - "74": "Vierenzeventig", - "75": "Vijfenzeventig", - "76": "Zesenzeventig", - "77": "Zevenenzeventig", - "78": "Achtenzeventig", - "79": "Negenenzeventig", - "80": "Tachtig", - "81": "Eenentachtig", - "82": "Tweeentachtig", - "83": "Drieentachtig", - "84": "Vierentachtig", - "85": "Vijfentachtig", - "86": "Zesentachtig", - "87": "Zevenentachtig", - "88": "Achtentachtig", - "89": "Negenentachtig", - "90": "Negentig", - "91": "Eenennegentig", - "92": "Tweeennegentig", - "93": "Drieennegentig", - "94": "Vierennegentig", - "95": "Vijfennegentig", - "96": "Zesennegentig", - "97": "Zevenennegentig", - "98": "Achtennegentig", - "99": "Negenennegentig" + "0": "nul", + "1": "één", + "2": "twee", + "3": "drie", + "4": "vier", + "5": "vijf", + "6": "zes", + "7": "zeven", + "8": "acht", + "9": "negen", + "10": "tien", + "11": "elf", + "12": "twaalf", + "13": "dertien", + "14": "veertien", + "15": "vijftien", + "16": "zestien", + "17": "zeventien", + "18": "achttien", + "19": "negentien", + "20": "twintig", + "21": "eenentwintig", + "22": "tweeëntwintig", + "23": "drieëntwintig", + "24": "vierentwintig", + "25": "vijfentwintig", + "26": "zesentwintig", + "27": "zevenentwintig", + "28": "achtentwintig", + "29": "negenentwintig", + "30": "dertig", + "31": "eenendertig", + "32": "tweeëndertig", + "33": "drieëndertig", + "34": "vierendertig", + "35": "vijfendertig", + "36": "zesendertig", + "37": "zevenendertig", + "38": "achtendertig", + "39": "negenendertig", + "40": "veertig", + "41": "eenenveertig", + "42": "tweeënveertig", + "43": "drieënveertig", + "44": "vierenveertig", + "45": "vijfenveertig", + "46": "zesenveertig", + "47": "zevenenveertig", + "48": "achtenveertig", + "49": "negenenveertig", + "50": "vijftig", + "51": "eenenvijftig", + "52": "tweeënvijftig", + "53": "drieënvijftig", + "54": "vierenvijftig", + "55": "vijfenvijftig", + "56": "zesenvijftig", + "57": "zevenenvijftig", + "58": "achtenvijftig", + "59": "negenenvijftig", + "60": "zestig", + "61": "eenenzestig", + "62": "tweeënzestig", + "63": "drieënzestig", + "64": "vierenzestig", + "65": "vijfenzestig", + "66": "zesenzestig", + "67": "zevenenzestig", + "68": "achtenzestig", + "69": "negenenzestig", + "70": "zeventig", + "71": "eenenzeventig", + "72": "tweeënzeventig", + "73": "drieënzeventig", + "74": "vierenzeventig", + "75": "vijfenzeventig", + "76": "zesenzeventig", + "77": "zevenenzeventig", + "78": "achtenzeventig", + "79": "negenenzeventig", + "80": "tachtig", + "81": "eenentachtig", + "82": "tweeëntachtig", + "83": "drieëntachtig", + "84": "vierentachtig", + "85": "vijfentachtig", + "86": "zesentachtig", + "87": "zevenentachtig", + "88": "achtentachtig", + "89": "negenentachtig", + "90": "negentig", + "91": "eenennegentig", + "92": "tweeënnegentig", + "93": "drieënnegentig", + "94": "vierennegentig", + "95": "vijfennegentig", + "96": "zesennegentig", + "97": "zevenennegentig", + "98": "achtennegentig", + "99": "negenennegentig" } diff --git a/novelwriter/assets/icons/typicons_dark/icons.conf b/novelwriter/assets/icons/typicons_dark/icons.conf index e06aba68..ae157e2c 100644 --- a/novelwriter/assets/icons/typicons_dark/icons.conf +++ b/novelwriter/assets/icons/typicons_dark/icons.conf @@ -42,16 +42,20 @@ 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 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_stats = typ_chart-bar-grey.svg proj_title = mixed_document-title.svg reference = typ_at.svg refresh = typ_refresh.svg @@ -74,3 +78,8 @@ status_stats = typ_chart-bar-grey.svg status_time = typ_stopwatch-grey.svg sticky-off = typ_pin-outline.svg sticky-on = typ_pin.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 diff --git a/novelwriter/assets/icons/typicons_dark/mixed_edit.svg b/novelwriter/assets/icons/typicons_dark/mixed_edit.svg new file mode 100644 index 00000000..fac03f3e --- /dev/null +++ b/novelwriter/assets/icons/typicons_dark/mixed_edit.svg @@ -0,0 +1,52 @@ + + + + + + + image/svg+xml + + + + + + + diff --git a/novelwriter/assets/icons/typicons_dark/typ_book-grey.svg b/novelwriter/assets/icons/typicons_dark/typ_book-grey.svg new file mode 100644 index 00000000..a0f49771 --- /dev/null +++ b/novelwriter/assets/icons/typicons_dark/typ_book-grey.svg @@ -0,0 +1,31 @@ + + + + + + image/svg+xml + + + + + + + + diff --git a/novelwriter/assets/icons/typicons_dark/typ_chevron-down.svg b/novelwriter/assets/icons/typicons_dark/typ_chevron-down.svg new file mode 100644 index 00000000..53389084 --- /dev/null +++ b/novelwriter/assets/icons/typicons_dark/typ_chevron-down.svg @@ -0,0 +1,31 @@ + + + + + + image/svg+xml + + + + + + + + diff --git a/novelwriter/assets/icons/typicons_dark/typ_chevron-up.svg b/novelwriter/assets/icons/typicons_dark/typ_chevron-up.svg new file mode 100644 index 00000000..9ac7e927 --- /dev/null +++ b/novelwriter/assets/icons/typicons_dark/typ_chevron-up.svg @@ -0,0 +1,31 @@ + + + + + + image/svg+xml + + + + + + + + diff --git a/novelwriter/assets/icons/typicons_dark/typ_export.svg b/novelwriter/assets/icons/typicons_dark/typ_export.svg new file mode 100644 index 00000000..6e36ed53 --- /dev/null +++ b/novelwriter/assets/icons/typicons_dark/typ_export.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/novelwriter/assets/icons/typicons_dark/typ_puzzle-outline.svg b/novelwriter/assets/icons/typicons_dark/typ_puzzle-outline.svg new file mode 100644 index 00000000..526feec8 --- /dev/null +++ b/novelwriter/assets/icons/typicons_dark/typ_puzzle-outline.svg @@ -0,0 +1,31 @@ + + + + + + image/svg+xml + + + + + + + + diff --git a/novelwriter/assets/icons/typicons_dark/typ_th-menu.svg b/novelwriter/assets/icons/typicons_dark/typ_th-menu.svg new file mode 100644 index 00000000..89434cdf --- /dev/null +++ b/novelwriter/assets/icons/typicons_dark/typ_th-menu.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/novelwriter/assets/icons/typicons_light/icons.conf b/novelwriter/assets/icons/typicons_light/icons.conf index 56e18b2a..4683bb19 100644 --- a/novelwriter/assets/icons/typicons_light/icons.conf +++ b/novelwriter/assets/icons/typicons_light/icons.conf @@ -42,16 +42,20 @@ 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 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_stats = typ_chart-bar-grey.svg proj_title = mixed_document-title.svg reference = typ_at.svg refresh = typ_refresh.svg @@ -74,3 +78,8 @@ status_stats = typ_chart-bar-grey.svg status_time = typ_stopwatch-grey.svg sticky-off = typ_pin-outline.svg sticky-on = typ_pin.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 diff --git a/novelwriter/assets/icons/typicons_light/mixed_edit.svg b/novelwriter/assets/icons/typicons_light/mixed_edit.svg new file mode 100644 index 00000000..982206ed --- /dev/null +++ b/novelwriter/assets/icons/typicons_light/mixed_edit.svg @@ -0,0 +1,52 @@ + + + + + + + image/svg+xml + + + + + + + diff --git a/novelwriter/assets/icons/typicons_light/typ_book-grey.svg b/novelwriter/assets/icons/typicons_light/typ_book-grey.svg new file mode 100644 index 00000000..06f58ae1 --- /dev/null +++ b/novelwriter/assets/icons/typicons_light/typ_book-grey.svg @@ -0,0 +1,31 @@ + + + + + + image/svg+xml + + + + + + + + diff --git a/novelwriter/assets/icons/typicons_light/typ_chevron-down.svg b/novelwriter/assets/icons/typicons_light/typ_chevron-down.svg new file mode 100644 index 00000000..6ba80643 --- /dev/null +++ b/novelwriter/assets/icons/typicons_light/typ_chevron-down.svg @@ -0,0 +1,31 @@ + + + + + + image/svg+xml + + + + + + + + diff --git a/novelwriter/assets/icons/typicons_light/typ_chevron-up.svg b/novelwriter/assets/icons/typicons_light/typ_chevron-up.svg new file mode 100644 index 00000000..1b9eb901 --- /dev/null +++ b/novelwriter/assets/icons/typicons_light/typ_chevron-up.svg @@ -0,0 +1,31 @@ + + + + + + image/svg+xml + + + + + + + + diff --git a/novelwriter/assets/icons/typicons_light/typ_export.svg b/novelwriter/assets/icons/typicons_light/typ_export.svg new file mode 100644 index 00000000..537f71f9 --- /dev/null +++ b/novelwriter/assets/icons/typicons_light/typ_export.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/novelwriter/assets/icons/typicons_light/typ_puzzle-outline.svg b/novelwriter/assets/icons/typicons_light/typ_puzzle-outline.svg new file mode 100644 index 00000000..e2792d41 --- /dev/null +++ b/novelwriter/assets/icons/typicons_light/typ_puzzle-outline.svg @@ -0,0 +1,31 @@ + + + + + + image/svg+xml + + + + + + + + diff --git a/novelwriter/assets/icons/typicons_light/typ_th-menu.svg b/novelwriter/assets/icons/typicons_light/typ_th-menu.svg new file mode 100644 index 00000000..cfc8a1d9 --- /dev/null +++ b/novelwriter/assets/icons/typicons_light/typ_th-menu.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/novelwriter/assets/text/release_notes.htm b/novelwriter/assets/text/release_notes.htm index 0850e30c..aeb5fbc7 100644 --- a/novelwriter/assets/text/release_notes.htm +++ b/novelwriter/assets/text/release_notes.htm @@ -2,56 +2,16 @@ -

Release Notes for 1.6

-

Released on 20 February 2022

+

Release Notes for 1.7 Beta 1

+

Released on 17 May 2022

-

This release does not introduce any major new features, but is instead a collection of minor -improvements and tweaks based on user requests. There are also a number of changes under the hood -to improve the structure and performance of novelWriter.

-

Some key improvements to the user interface are:

-

✓ The max text width setting in Preferences now also applies to the document viewer, and -the setting itself on the Preference dialog has been simplified a bit.

-

✓ When text is selected in the document editor, the number of words selected is displayed -in the editor's footer area.

-

✓ The search tool in the document editor now shows the number of results in the -document.

-

✓ The Enter and Ctrl+O keyboard shortcuts should now work the same way in all tree -views.

-

✓ It is now possible to set a blank section title format on the Build Novel Project tool -and get empty paragraphs in the output. Previously, a blank format would just remove the section -break entirely. This change allows the user to define hard and soft scene breaks using level three -and four headings. The scene and section titles can be hidden completely with two new switches -added to the user interface.

-

Other feature changes include:

-

✓ The project index is now automatically rebuilt in the event it is empty or incomplete -when the project is opened.

-

✓ The user can now add their own syntax and GUI theme files in the app folder in their -user area on the host operating system. Where the custom files must be added is described in the -documentation.

-

✓ A Windows installer is yet again provided for novelWriter. If you have novelWriter -installed using another method, make sure you uninstall it properly first as the two methods are -not compatible.

-

✓ Release versions for Ubuntu 21.04 have been dropped, and added for the upcoming Ubuntu -22.04.

-

✓ Most translations have been updated. A Dutch translation is in the works.

+

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 +backups.

+

Please check the changelog for an overview of changes. The full release notes will be added to +the final release.

See also the Releases page.

-

Patch Notes

- -

Patch 1.6.1 – 16 March 2022

- -

This is a bugfix and patch release that fixes two recursion/loop issues. One would potentially -cause a crash if the window was resized rapidly, and one would cause a hang with certain search -parameters in the editor's search box. The Latin American Spanish translation has also been -updated.

- -

Patch 1.6.2 – 20 March 2022

- -

This is a bugfix release that fixes a couple of minor issues. Projects containing one or more -empty documents would trigger a rebuild of the index each time the project was opened. This has now -been fixed. Another fix resolves an error message being written to the console logging output when -a new document was created. Both errors were harmless.

- diff --git a/novelwriter/common.py b/novelwriter/common.py index 8e17a7ee..69f6bc05 100644 --- a/novelwriter/common.py +++ b/novelwriter/common.py @@ -184,6 +184,12 @@ def checkIntRange(value, first, last, default): return default +def minmax(value, minVal, maxVal): + """Make sure an integer is between min and max value (inclusive). + """ + return min(maxVal, max(minVal, value)) + + def checkIntTuple(value, valid, default): """Check that an int is an element of a tuple. If it isn't, return the default value. @@ -245,6 +251,13 @@ def formatTime(tS): # String Functions # =============================================================================================== # +def simplified(string): + """Take a string an strip leading and trailing whitespaces, and + replace all occurences of (multiple) whitespaces with a 0x20 space. + """ + return " ".join(str(string).strip().split()) + + def splitVersionNumber(value): """Split a version string on the form aa.bb.cc into major, minor and patch, and computes an integer value aabbcc. diff --git a/novelwriter/config.py b/novelwriter/config.py index c5159493..7fb334c2 100644 --- a/novelwriter/config.py +++ b/novelwriter/config.py @@ -978,12 +978,12 @@ class Config: """ try: import enchant # noqa: F401 - self.hasEnchant = True - logger.debug("Checking package 'pyenchant': OK") - except Exception: + except ImportError: self.hasEnchant = False logger.debug("Checking package 'pyenchant': Missing") - + else: + self.hasEnchant = True + logger.debug("Checking package 'pyenchant': OK") return # END Class Config diff --git a/novelwriter/constants.py b/novelwriter/constants.py index f71453f3..dc62dbd3 100644 --- a/novelwriter/constants.py +++ b/novelwriter/constants.py @@ -25,7 +25,7 @@ along with this program. If not, see . from PyQt5.QtCore import QCoreApplication, QT_TRANSLATE_NOOP -from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType, nwOutline +from novelwriter.enum import nwItemClass, nwItemLayout, nwOutline def trConst(tString): @@ -34,7 +34,7 @@ def trConst(tString): return QCoreApplication.translate("Constant", tString) -class nwConst(): +class nwConst: # Date and Time Formats FMT_TSTAMP = "%Y-%m-%d %H:%M:%S" # Default format @@ -42,32 +42,13 @@ class nwConst(): FMT_DSTAMP = "%Y-%m-%d" # Date only format # Various Hard Limits - MAX_DEPTH = 30 # Maximum folder depth of a project MAX_DOCSIZE = 5000000 # Maxium size of a single document MAX_BUILDSIZE = 10000000 # Maxium size of a project build # END Class nwConst -class nwLists(): - """Lists used for grouping various other constants. - """ - # Regular user-accessible item types - REG_TYPES = {nwItemType.ROOT, nwItemType.FOLDER, nwItemType.FILE} - - # Item classes where the full list of novel layouts are allowed - CLS_NOVEL = {nwItemClass.NOVEL, nwItemClass.ARCHIVE} - - # Item classes which do not require items to have same class - FREE_CLASS = {nwItemClass.ARCHIVE, nwItemClass.TRASH} - - # Deprecated nwItemLayout entries - DEP_LAYOUT = ("TITLE", "PAGE", "BOOK", "PARTITION", "UNNUMBERED", "CHAPTER", "SCENE") - -# END Class nwLists - - -class nwRegEx(): +class nwRegEx: FMT_EI = r"(?. """ from novelwriter.core.document import NWDoc -from novelwriter.core.index import NWIndex, countWords +from novelwriter.core.index import countWords from novelwriter.core.project import NWProject from novelwriter.core.spellcheck import NWSpellEnchant from novelwriter.core.tohtml import ToHtml @@ -30,7 +30,6 @@ from novelwriter.core.tomd import ToMarkdown __all__ = [ "countWords", "NWDoc", - "NWIndex", "NWProject", "NWSpellEnchant", "ToHtml", diff --git a/novelwriter/core/document.py b/novelwriter/core/document.py index 2334c77c..5420d44e 100644 --- a/novelwriter/core/document.py +++ b/novelwriter/core/document.py @@ -52,7 +52,7 @@ class NWDoc(): self._docHandle = theHandle if self._docHandle is not None: - self._theItem = self.theProject.projTree[theHandle] + self._theItem = self.theProject.tree[theHandle] return diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py index b05fa952..f86de8c2 100644 --- a/novelwriter/core/index.py +++ b/novelwriter/core/index.py @@ -4,8 +4,10 @@ novelWriter – Project Index Data class for the project index of tags, headers and references File History: -Created: 2019-04-22 [0.0.1] countWords -Created: 2019-05-27 [0.1.4] NWIndex +Created: 2019-04-22 [0.0.1] countWords +Created: 2019-05-27 [0.1.4] NWIndex +Created: 2022-05-28 [1.7rc1] IndexItem, IndexHeading +Created: 2022-05-29 [1.7rc1] TagsIndex, ItemIndex This file is a part of novelWriter Copyright 2018–2022, Veronica Berglyd Olsen @@ -30,42 +32,59 @@ import logging from time import time -from novelwriter.enum import nwItemType, nwItemClass, nwItemLayout +from novelwriter.enum import nwItemType, nwItemLayout from novelwriter.error import logException from novelwriter.constants import nwFiles, nwKeyWords, nwUnicode from novelwriter.core.document import NWDoc from novelwriter.common import ( - isHandle, isTitleTag, isItemClass, isItemLayout, jsonEncode + checkInt, isHandle, isItemClass, isTitleTag, jsonEncode ) logger = logging.getLogger(__name__) H_VALID = ("H0", "H1", "H2", "H3", "H4") H_LEVEL = {"H0": 0, "H1": 1, "H2": 2, "H3": 3, "H4": 4} +TT_NONE = "T000000" -class NWIndex(): +class NWIndex: + """This class holds the entire index for a given project. The index + contains the data that isn't stored in the project items themselves. + The content of the index is updated every time a file item is saved. + + The primary index data is contained in the ItemIndex class, which + contains an IndexItem representing each NWItem. Each IndexItem holds + an IndexHeading object for each heading of the item's text. + + A reverse index of all tags is contained in the TagsIndex class. + This is duplicate information used for quicker lookups from the tags + and back to items where they are defined. + + The index data is cached in a JSON file between writing sessions. + """ def __init__(self, theProject): self.theProject = theProject - # Internal + # Storage and State + self._tagsIndex = TagsIndex() + self._itemIndex = ItemIndex(theProject) self._indexBroken = False - # Indices - self._tagIndex = {} - self._refIndex = {} - self._fileIndex = {} - self._fileMeta = {} - # TimeStamps - self._timeNovel = 0 - self._timeNotes = 0 - self._timeIndex = 0 + self._indexChange = 0 + self._rootChange = {} return + def __repr__(self): + return f"" + + ## + # Properties + ## + @property def indexBroken(self): return self._indexBroken @@ -77,27 +96,20 @@ class NWIndex(): def clearIndex(self): """Clear the index dictionaries and time stamps. """ - self._tagIndex = {} - self._refIndex = {} - self._fileIndex = {} - self._fileMeta = {} - self._timeNovel = 0 - self._timeNotes = 0 - self._timeIndex = 0 + self._tagsIndex.clear() + self._itemIndex.clear() + self._indexChange = 0 + self._rootChange = {} return def deleteHandle(self, tHandle): """Delete all entries of a given document handle. """ logger.debug("Removing item '%s' from the index", tHandle) + for tTag in self._itemIndex.allItemTags(tHandle): + del self._tagsIndex[tTag] - delTags = list(filter(lambda x: self._tagIndex[x][1] == tHandle, self._tagIndex)) - for tTag in delTags: - self._tagIndex.pop(tTag, None) - - self._refIndex.pop(tHandle, None) - self._fileIndex.pop(tHandle, None) - self._fileMeta.pop(tHandle, None) + del self._itemIndex[tHandle] return @@ -106,30 +118,25 @@ class NWIndex(): moved from the archive or trash folders back into the active project. """ - logger.debug("Re-indexing item '%s'", tHandle) - if not self.theProject.projTree.checkType(tHandle, nwItemType.FILE): + if not self.theProject.tree.checkType(tHandle, nwItemType.FILE): return False + logger.debug("Re-indexing item '%s'", tHandle) theDoc = NWDoc(self.theProject, tHandle) - theText = theDoc.readDocument() - self.scanText(tHandle, theText if theText is not None else "") + self.scanText(tHandle, theDoc.readDocument() or "") return True - def novelChangedSince(self, checkTime): - """Check if the novel index has changed since a given time. - """ - return self._timeNovel > checkTime - - def notesChangedSince(self, checkTime): - """Check if the notes index has changed since a given time. - """ - return self._timeNotes > checkTime - def indexChangedSince(self, checkTime): """Check if the index has changed since a given time. """ - return self._timeIndex > checkTime + return self._indexChange > 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 ## # Load and Save Index to/from File @@ -142,32 +149,39 @@ class NWIndex(): indexFile = os.path.join(self.theProject.projMeta, nwFiles.INDEX_FILE) tStart = time() + self._indexBroken = False if os.path.isfile(indexFile): logger.debug("Loading index file") try: with open(indexFile, mode="r", encoding="utf-8") as inFile: theData = json.load(inFile) - except Exception: logger.error("Failed to load index file") logException() self._indexBroken = True return False - self._tagIndex = theData.get("tagIndex", {}) - self._refIndex = theData.get("refIndex", {}) - self._fileIndex = theData.get("fileIndex", {}) - self._fileMeta = theData.get("fileMeta", {}) + try: + self._tagsIndex.unpackData(theData["tagsIndex"]) + self._itemIndex.unpackData(theData["itemIndex"]) + except Exception: + logger.error("The index content is invalid") + logException() + self._indexBroken = True + return False - nowTime = round(time()) - self._timeNovel = nowTime - self._timeNotes = nowTime - self._timeIndex = nowTime + logger.debug("Checking index") + + # Check that all files are indexed + for fHandle in self.theProject.projFiles: + if fHandle not in self._itemIndex: + logger.warning("Item '%s' is not in the index", fHandle) + self.reIndexHandle(fHandle) + + self._indexChange = round(time()) logger.verbose("Index loaded in %.3f ms", (time() - tStart)*1000) - self._checkIndex() - return True def saveIndex(self): @@ -179,12 +193,12 @@ class NWIndex(): tStart = time() try: + tagsIndex = self._tagsIndex.packData() + itemIndex = self._itemIndex.packData() with open(indexFile, mode="w+", encoding="utf-8") as outFile: outFile.write("{\n") - outFile.write(f' "tagIndex": {jsonEncode(self._tagIndex, n=1, nmax=2)},\n') - outFile.write(f' "refIndex": {jsonEncode(self._refIndex, n=1, nmax=3)},\n') - outFile.write(f' "fileIndex": {jsonEncode(self._fileIndex, n=1, nmax=3)},\n') - outFile.write(f' "fileMeta": {jsonEncode(self._fileMeta, n=1, nmax=2)}\n') + outFile.write(f' "tagsIndex": {jsonEncode(tagsIndex, n=1, nmax=2)},\n') + outFile.write(f' "itemIndex": {jsonEncode(itemIndex, n=1, nmax=4)}\n') outFile.write("}\n") except Exception: @@ -204,12 +218,10 @@ class NWIndex(): """Scan a piece of text associated with a handle. This will update the indices accordingly. This function takes the handle and text as separate inputs as we want to primarily scan the - files before we save them in which case we already have the + files before we save them, in which case we already have the text. """ - theItem = self.theProject.projTree[tHandle] - theRoot = self.theProject.projTree.getRootItem(tHandle) - + theItem = self.theProject.tree[tHandle] if theItem is None: logger.info("Not indexing unknown item '%s'", tHandle) return False @@ -217,9 +229,15 @@ class NWIndex(): logger.info("Not indexing non-file item '%s'", tHandle) return False + # Keep a record of existing tags, and create a new item entry + itemTags = dict.fromkeys(self._itemIndex.allItemTags(tHandle), False) + self._itemIndex.add(tHandle, theItem) + # Run word counter for the whole text cC, wC, pC = countWords(theText) - self._fileMeta[tHandle] = ["H0", cC, wC, pC] + theItem.setCharCount(cC) + theItem.setWordCount(wC) + theItem.setParaCount(pC) # If the file's meta data is missing, or the file is out of the # main project, we don't index the content @@ -229,27 +247,12 @@ class NWIndex(): if theItem.itemParent is None: logger.info("Not indexing orphaned item '%s'", tHandle) return False - if self.theProject.projTree.isTrashRoot(theItem.itemParent): - logger.debug("Not indexing trash item '%s'", tHandle) + if theItem.isInactive(): + logger.debug("Not indexing inactive item '%s'", tHandle) return False - if theRoot.itemClass == nwItemClass.ARCHIVE: - logger.debug("Not indexing archived item '%s'", tHandle) - return False - - itemClass = theItem.itemClass - itemLayout = theItem.itemLayout logger.debug("Indexing item with handle '%s'", tHandle) - # Delete or reset old entries for the file - self._refIndex.pop(tHandle, None) - self._fileIndex[tHandle] = {} - - # Also clear references to the file in the tags index - clearTags = list(filter(lambda x: self._tagIndex[x][1] == tHandle, self._tagIndex)) - for aTag in clearTags: - self._tagIndex.pop(aTag) - # Scan the text content nTitle = 0 theLines = theText.splitlines() @@ -258,7 +261,7 @@ class NWIndex(): continue if aLine.startswith("#"): - isTitle = self._indexTitle(tHandle, aLine, nLine, itemLayout) + isTitle = self._indexTitle(tHandle, aLine, nLine) if isTitle and nLine > 0: if nTitle > 0: lastText = "\n".join(theLines[nTitle-1:nLine-1]) @@ -266,7 +269,7 @@ class NWIndex(): nTitle = nLine elif aLine.startswith("@"): - self._indexKeyword(tHandle, aLine, nLine, nTitle, itemClass) + self._indexKeyword(tHandle, aLine, nTitle, theItem.itemClass, itemTags) elif aLine.startswith("%"): if nTitle > 0: @@ -283,26 +286,28 @@ class NWIndex(): lastText = "\n".join(theLines[nTitle-1:]) self._indexWordCounts(tHandle, lastText, nTitle) - # Index page with no titles and references + # Also count words on a page with no titles if nTitle == 0: - self._indexPage(tHandle, itemLayout) self._indexWordCounts(tHandle, theText, nTitle) + # Prune no longer used tags + for tTag, isActive in itemTags.items(): + if not isActive: + logger.verbose("Deleting removed tag '%s'", tTag) + del self._tagsIndex[tTag] + # Update timestamps for index changes nowTime = round(time()) - self._timeIndex = nowTime - if itemLayout == nwItemLayout.NOTE: - self._timeNotes = nowTime - else: - self._timeNovel = nowTime + self._indexChange = nowTime + self._rootChange[theItem.itemRoot] = nowTime return True ## - # Internal Indexers + # Internal Indexer Helpers ## - def _indexTitle(self, tHandle, aLine, nLine, itemLayout): + def _indexTitle(self, tHandle, aLine, nTitle): """Save information about the title and its location in the file to the index. """ @@ -327,62 +332,31 @@ class NWIndex(): else: return False - sTitle = f"T{nLine:06d}" - self._fileIndex[tHandle][sTitle] = { - "level": hDepth, - "title": hText, - "layout": itemLayout.name, - "cCount": 0, - "wCount": 0, - "pCount": 0, - "synopsis": "", - } - - if self._fileMeta[tHandle][0] == "H0": - # Since this initialises to H0, this ensures that only the - # first header level is recorded in the file meta index - self._fileMeta[tHandle][0] = hDepth + sTitle = f"T{nTitle:06d}" + self._itemIndex.addItemHeading(tHandle, sTitle, hDepth, hText) return True - def _indexPage(self, tHandle, itemLayout): - """Index a page with no title. - """ - self._fileIndex[tHandle]["T000000"] = { - "level": "H0", - "title": "", - "layout": itemLayout.name, - "cCount": 0, - "wCount": 0, - "pCount": 0, - "synopsis": "", - } - return - def _indexWordCounts(self, tHandle, theText, nTitle): """Count text stats and save the counts to the index. """ - cC, wC, pC = countWords(theText) sTitle = f"T{nTitle:06d}" - if tHandle in self._fileIndex: - if sTitle in self._fileIndex[tHandle]: - self._fileIndex[tHandle][sTitle]["cCount"] = cC - self._fileIndex[tHandle][sTitle]["wCount"] = wC - self._fileIndex[tHandle][sTitle]["pCount"] = pC + cC, wC, pC = countWords(theText) + self._itemIndex.setHeadingCounts(tHandle, sTitle, cC, wC, pC) return def _indexSynopsis(self, tHandle, theText, nTitle): """Save the synopsis to the index. """ sTitle = f"T{nTitle:06d}" - if tHandle in self._fileIndex: - if sTitle in self._fileIndex[tHandle]: - self._fileIndex[tHandle][sTitle]["synopsis"] = theText + self._itemIndex.setHeadingSynopsis(tHandle, sTitle, theText) return - def _indexKeyword(self, tHandle, aLine, nLine, nTitle, itemClass): + def _indexKeyword(self, tHandle, aLine, nTitle, itemClass, itemTags): """Validate and save the information about a reference to a tag - in another file. + in another file, or the setting of a tag in the file. A record + of active tags is updated so that no longer used tags can be + pruned later. """ isValid, theBits, _ = self.scanThis(aLine) if not isValid or len(theBits) < 2: @@ -395,15 +369,12 @@ class NWIndex(): sTitle = f"T{nTitle:06d}" if theBits[0] == nwKeyWords.TAG_KEY: - self._tagIndex[theBits[1]] = [nLine, tHandle, itemClass.name, sTitle] - + tagName = theBits[1] + self._tagsIndex.add(tagName, tHandle, sTitle, itemClass) + self._itemIndex.setHeadingTag(tHandle, sTitle, tagName) + itemTags[tagName] = True else: - if tHandle not in self._refIndex: - self._refIndex[tHandle] = {} - if sTitle not in self._refIndex[tHandle]: - self._refIndex[tHandle][sTitle] = [] - for aVal in theBits[1:]: - self._refIndex[tHandle][sTitle].append([nLine, theBits[0], aVal]) + self._itemIndex.addHeadingReferences(tHandle, sTitle, theBits[1:], theBits[0]) return @@ -465,8 +436,8 @@ class NWIndex(): # For a tag, only the first value is accepted, the rest are ignored if theBits[0] == nwKeyWords.TAG_KEY and nBits > 1: - if theBits[1] in self._tagIndex: - isGood[1] = self._tagIndex[theBits[1]][1] == tItem.itemHandle + if theBits[1] in self._tagsIndex: + isGood[1] = self._tagsIndex.tagHandle(theBits[1]) == tItem.itemHandle else: isGood[1] = True return isGood @@ -474,8 +445,8 @@ class NWIndex(): # If we're still here, we check that the references exist theKey = nwKeyWords.KEY_CLASS[theBits[0]].name for n in range(1, nBits): - if theBits[n] in self._tagIndex: - isGood[n] = theKey == self._tagIndex[theBits[n]][2] + if theBits[n] in self._tagsIndex: + isGood[n] = self._tagsIndex.tagClass(theBits[n]) == theKey return isGood @@ -483,77 +454,79 @@ class NWIndex(): # Extract Data ## - def novelStructure(self, skipExcluded=True): + def novelStructure(self, rootHandle=None, skipExcl=True): """Iterate over all titles in the novel, in the correct order as they appear in the tree view and in the respective document files, but skipping all note files. """ - for tHandle in self._listNovelHandles(skipExcluded): - for sTitle in sorted(self._fileIndex[tHandle]): - tKey = f"{tHandle}:{sTitle}" - yield tKey, tHandle, sTitle, self._fileIndex[tHandle][sTitle] + novStruct = self._itemIndex.iterNovelStructure(rootHandle=rootHandle, skipExcl=skipExcl) + for tHandle, sTitle, hItem in novStruct: + yield f"{tHandle}:{sTitle}", tHandle, sTitle, hItem + return - def getNovelWordCount(self, skipExcluded=True): + def getNovelWordCount(self, skipExcl=True): """Count the number of words in the novel project. """ wCount = 0 - for tHandle in self._listNovelHandles(skipExcluded): - for sTitle in self._fileIndex[tHandle]: - wCount += self._fileIndex[tHandle][sTitle]["wCount"] - + for _, _, hItem in self._itemIndex.iterNovelStructure(skipExcl=skipExcl): + wCount += hItem.wordCount return wCount - def getNovelTitleCounts(self, skipExcluded=True): + def getNovelTitleCounts(self, skipExcl=True): """Count the number of titles in the novel project. """ hCount = [0, 0, 0, 0, 0] - for tHandle in self._listNovelHandles(skipExcluded): - for sTitle in self._fileIndex[tHandle]: - iLevel = H_LEVEL.get(self._fileIndex[tHandle][sTitle]["level"], 0) - hCount[iLevel] += 1 - + for _, _, hItem in self._itemIndex.iterNovelStructure(skipExcl=skipExcl): + iLevel = H_LEVEL.get(hItem.level, 0) + hCount[iLevel] += 1 return hCount def getHandleWordCounts(self, tHandle): """Get all header word counts for a specific handle. """ - hRecord = self._fileIndex.get(tHandle, {}) - return [(f"{tHandle}:{sTitle}", sData["wCount"]) for sTitle, sData in hRecord.items()] + return [ + (f"{tHandle}:{sTitle}", hItem.wordCount) + for sTitle, hItem in self._itemIndex.iterItemHeaders(tHandle) + ] def getHandleHeaders(self, tHandle): """Get all headers for a specific handle. """ - hRecord = self._fileIndex.get(tHandle, {}) - return [(sTitle, sData["level"], sData["title"]) for sTitle, sData in hRecord.items()] + return [ + (sTitle, hItem.level, hItem.title) + 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._fileMeta.get(tHandle, ["H0"])[0] + return self._itemIndex.mainItemHeader(tHandle) - def getTableOfContents(self, maxDepth, skipExcluded=True): + def getHandleHeaderIntLevel(self, tHandle): + """Get the integer header level of the first header of a handle. + """ + return H_LEVEL.get(self._itemIndex.mainItemHeader(tHandle), 0) + + def getTableOfContents(self, maxDepth, skipExcl=True): """Generate a table of contents up to a maximum depth. """ tOrder = [] tData = {} pKey = None - for tHandle in self._listNovelHandles(skipExcluded): - for sTitle in sorted(self._fileIndex[tHandle]): - tKey = f"{tHandle}:{sTitle}" - theData = self._fileIndex[tHandle][sTitle] - iLevel = H_LEVEL.get(theData["level"], 0) - if iLevel > maxDepth: - if pKey in tData: - theData["wCount"] - tData[pKey]["words"] += theData["wCount"] - else: - pKey = tKey - tOrder.append(tKey) - tData[tKey] = { - "level": iLevel, - "title": theData["title"], - "words": theData["wCount"], - } + for tHandle, sTitle, hItem in self._itemIndex.iterNovelStructure(skipExcl=skipExcl): + tKey = f"{tHandle}:{sTitle}" + iLevel = H_LEVEL.get(hItem.level, 0) + if iLevel > maxDepth: + if pKey in tData: + tData[pKey]["words"] += hItem.wordCount + else: + pKey = tKey + tOrder.append(tKey) + tData[tKey] = { + "level": iLevel, + "title": hItem.title, + "words": hItem.wordCount, + } theToC = [( tKey, @@ -568,252 +541,670 @@ class NWIndex(): """Return the counts for a file, or a section of a file, starting at title sTitle if it is provided. """ - cC = 0 - wC = 0 - pC = 0 + tItem = self._itemIndex[tHandle] + if tItem is None: + return 0, 0, 0 if sTitle is None: - if tHandle in self._fileMeta: - cC = self._fileMeta[tHandle][1] - wC = self._fileMeta[tHandle][2] - pC = self._fileMeta[tHandle][3] + cItem = tItem.item else: - if tHandle in self._fileIndex: - if sTitle in self._fileIndex[tHandle]: - cC = self._fileIndex[tHandle][sTitle]["cCount"] - wC = self._fileIndex[tHandle][sTitle]["wCount"] - pC = self._fileIndex[tHandle][sTitle]["pCount"] + cItem = tItem[sTitle] - return cC, wC, pC + if cItem is not None: + return cItem.charCount, cItem.wordCount, cItem.paraCount + + return 0, 0, 0 def getReferences(self, tHandle, sTitle=None): """Extract all references made in a file, and optionally title section. """ theRefs = {x: [] for x in nwKeyWords.KEY_CLASS} - if tHandle not in self._refIndex: - return theRefs - - for refTitle in self._refIndex[tHandle]: - for aTag in self._refIndex[tHandle][refTitle]: - if len(aTag) == 3 and (sTitle is None or sTitle == refTitle): - if aTag[1] in theRefs: - theRefs[aTag[1]].append(aTag[2]) + for rTitle, hItem in self._itemIndex.iterItemHeaders(tHandle): + if sTitle is None or sTitle == rTitle: + for aTag, refTypes in hItem.references.items(): + for refType in refTypes: + if refType in theRefs: + theRefs[refType].append(aTag) return theRefs def getNovelData(self, tHandle, sTitle): """Return the novel data of a given handle and title. """ - if tHandle in self._fileIndex: - if sTitle in self._fileIndex[tHandle]: - return self._fileIndex[tHandle][sTitle] + if tHandle in self._itemIndex: + return self._itemIndex[tHandle][sTitle] return None def getBackReferenceList(self, tHandle): """Build a list of files referring back to our file, specified by tHandle. """ - if tHandle is None: + if tHandle is None or tHandle not in self._itemIndex: return {} theRefs = {} - theTags = set(filter(lambda x: self._tagIndex[x][1] == tHandle, self._tagIndex)) - if theTags: - for tHandle in self._refIndex: - for sTitle in self._refIndex[tHandle]: - for _, _, tTag in self._refIndex[tHandle][sTitle]: - if tTag in theTags and tHandle not in theRefs: - theRefs[tHandle] = sTitle + theTags = self._itemIndex.allItemTags(tHandle) + if not theTags: + return theRefs + + for aHandle, sTitle, hItem in self._itemIndex.iterAllHeaders(): + for aTag in hItem.references: + if aTag in theTags and aHandle not in theRefs: + theRefs[aHandle] = sTitle return theRefs def getTagSource(self, theTag): """Return the source location of a given tag. """ - theRef = self._tagIndex.get(theTag, []) - if len(theRef) == 4: - return theRef[1], theRef[0], theRef[3] - return None, 0, "T000000" + tHandle = self._tagsIndex.tagHandle(theTag) + sTitle = self._tagsIndex.tagHeading(theTag) + return tHandle, sTitle + +# END Class NWIndex + + +# =============================================================================================== # +# The Tags Index Object +# =============================================================================================== # + +class TagsIndex: + """A wrapper class that holds the reverse lookup tags index. This is + just a simple wrapper around a single dictionary to keep tighter + control of the keys. + """ + + def __init__(self): + self._tags = {} + return ## - # Internal Functions + # Methods ## - def _listNovelHandles(self, skipExcluded): - """Return a list of all handles that exist in the novel index. + def clear(self): + """Clear the index. """ - theHandles = [] - for tItem in self.theProject.projTree: + self._tags = {} + return + + def __contains__(self, tagKey): + """Check if a tag exists in the index, + """ + return tagKey in self._tags + + def __delitem__(self, tagKey): + """Delete an entry in the index. + """ + self._tags.pop(tagKey, None) + return + + def __getitem__(self, tagKey): + """Return a tag, or return None if it isn't found. + """ + return self._tags.get(tagKey, None) + + def add(self, tagKey, tHandle, sTitle, itemClass): + """Add a key to the index and set all values. + """ + self._tags[tagKey] = { + "handle": tHandle, "heading": sTitle, "class": itemClass.name + } + return + + 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 + + def tagHeading(self, tagKey): + """Get the heading of a given tag. + """ + if tagKey in self._tags: + return self._tags.get(tagKey).get("heading") + return 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 + + ## + # Pack/Unpack + ## + + def packData(self): + """Pack all the data of the tags into a single dictionary. + """ + return self._tags + + def unpackData(self, data): + """Iterate through the tagsIndex loaded from cache and check + that it's valid. + """ + self._tags = {} + if not isinstance(data, dict): + raise ValueError("tagsIndex is not a dict") + + for tagKey, tagData in data.items(): + if not isinstance(tagKey, str): + raise ValueError("tagsIndex keys must be a strings") + if "handle" not in tagData: + raise KeyError("A tagIndex item is missing a handle entry") + if "heading" not in tagData: + raise KeyError("A tagIndex item is missing a heading entry") + if "class" not in tagData: + raise KeyError("A tagIndex item is missing a class entry") + if not isHandle(tagData["handle"]): + raise ValueError("tagsIndex handle must be a handle") + if not isTitleTag(tagData["heading"]): + raise ValueError("tagsIndex heading must be a title tag") + if not isItemClass(tagData["class"]): + raise ValueError("tagsIndex handle must be an nwItemClass") + + self._tags = data + + return + +# END Class TagsIndex + + +# =============================================================================================== # +# The Item Index Objects +# =============================================================================================== # + +class ItemIndex: + """A wrapper object holding the indexed items. This is a warapper + class around a single storage dictionary with a set of utility + functions for setting and accessing the index data. Each indexed + item is stored in an IndexItem object, which again holds an + IndexHeading object for each header of the text. + """ + + def __init__(self, theProject): + self.theProject = theProject + self._items = {} + return + + ## + # Methods + ## + + def clear(self): + """Clear the index. + """ + self._items = {} + return + + def __contains__(self, tHandle): + """Check if an item exists in the index, + """ + return tHandle in self._items + + def __delitem__(self, tHandle): + """Delete an entry in the index. + """ + self._items.pop(tHandle, None) + return + + def __getitem__(self, tHandle): + """Return an item, or return None if it isn't found. + """ + return self._items.get(tHandle, None) + + def add(self, tHandle, tItem): + """Add a new item to the index. This will overwrite the item if + it already exists. + """ + 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. + """ + if tHandle in self._items: + return self._items[tHandle].allTags() + return [] + + def iterItemHeaders(self, tHandle): + """Iterate over all item headers of an item. + """ + if tHandle in self._items: + for sTitle, hItem in self._items[tHandle].items(): + yield sTitle, hItem + return + + def iterAllHeaders(self): + """Iterate through all items and headings in the index. + """ + for tHandle, tItem in self._items.items(): + for sTitle, hItem in tItem.items(): + yield tHandle, sTitle, hItem + return + + def iterNovelStructure(self, rootHandle=None, skipExcl=False): + """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: if tItem is None: continue - if not tItem.isExported and skipExcluded: - continue if tItem.itemLayout == nwItemLayout.NOTE: continue - if tItem.itemHandle in self._fileIndex: - theHandles.append(tItem.itemHandle) + if skipExcl and not tItem.isExported: + continue - return theHandles + tHandle = tItem.itemHandle + if tHandle not in self._items: + continue + + if rootHandle is None: + for sTitle in self._items[tHandle].headings(): + yield tHandle, sTitle, self._items[tHandle][sTitle] + elif tItem.itemRoot == rootHandle: + for sTitle in self._items[tHandle].headings(): + yield tHandle, sTitle, self._items[tHandle][sTitle] + else: + continue + + return ## - # Index Checkers + # Setters ## - def _checkIndex(self): - """Check that the entries in the index are valid and contain the - elements it should. Also check that each file present in the - contents folder when the project was loaded are also present in - the fileMeta index. + def addItemHeading(self, tHandle, sTitle, hDepth, hText): + """Set the main heading level of an item. """ - logger.debug("Checking index") - tStart = time() - - try: - self._checkTagIndex() - self._checkRefIndex() - self._checkFileIndex() - self._checkFileMeta() - self._indexBroken = False - - except Exception: - logger.error("Error while checking index") - logException() - self._indexBroken = True - - # Check that project files are indexed - for fHandle in self.theProject.projFiles: - if fHandle not in self._fileMeta: - self._indexBroken = True - break - - logger.verbose("Index check completed in %.3f ms", (time() - tStart)*1000) - - if self._indexBroken: - self.clearIndex() - + if tHandle in self._items: + tItem = self._items[tHandle] + tItem.updateLevel(hDepth) + tItem.addHeading(IndexHeading(sTitle, hDepth, hText)) return - def _checkTagIndex(self): - """Scan the tag index for errors. - Warning: This function raises exceptions. + def setHeadingCounts(self, tHandle, sTitle, cC, wC, pC): + """Set the character, word and paragraph counts of a heading + on a given item. """ - for tTag in self._tagIndex: - if not isinstance(tTag, str): - raise KeyError("tagIndex key is not a string") - - tEntry = self._tagIndex[tTag] - if len(tEntry) != 4: - raise IndexError("tagIndex[a] expected 4 values") - if not isinstance(tEntry[0], int): - raise ValueError("tagIndex[a][0] is not an integer") - if not isHandle(tEntry[1]): - raise ValueError("tagIndex[a][1] is not a handle") - if not isItemClass(tEntry[2]): - raise ValueError("tagIndex[a][2] is not an nwItemClass") - if not isTitleTag(tEntry[3]): - raise ValueError("tagIndex[a][3] is not a title tag") - + if tHandle in self._items: + self._items[tHandle].setHeadingCounts(sTitle, cC, wC, pC) return - def _checkRefIndex(self): - """Scan the reference index for errors. - Warning: This function raises exceptions. + def setHeadingSynopsis(self, tHandle, sTitle, sText): + """Set the synopsis text for a heading on a given item. """ - for tHandle in self._refIndex: + if tHandle in self._items: + self._items[tHandle].setHeadingSynopsis(sTitle, sText) + return + + def setHeadingTag(self, tHandle, sTitle, tagKey): + """Set the main tag for a heading on a given item. + """ + if tHandle in self._items: + self._items[tHandle].setHeadingTag(sTitle, tagKey) + return + + def addHeadingReferences(self, tHandle, sTitle, tagKeys, refType): + """Set the reference tags for a heading on a given item. + """ + if tHandle in self._items: + self._items[tHandle].addHeadingReferences(sTitle, tagKeys, refType) + return + + ## + # Pack/Unpack + ## + + def packData(self): + """Pack all the data of the index into a single dictionary. + """ + return {handle: item.packData() for handle, item in self._items.items()} + + def unpackData(self, data): + """Iterate through the itemIndex loaded from cache and check + that it's valid. This will raise errors if there is a problem. + """ + self._items = {} + if not isinstance(data, dict): + raise ValueError("itemIndex is not a dict") + + for tHandle, tData in data.items(): if not isHandle(tHandle): - raise KeyError("refIndex key is not a handle") + raise ValueError("itemIndex keys must be handles") - hEntry = self._refIndex[tHandle] - for sTitle in hEntry: - if not isTitleTag(sTitle): - raise KeyError("refIndex[a] key is not a title tag") - - sEntry = hEntry[sTitle] - for tEntry in sEntry: - if len(tEntry) != 3: - raise IndexError("refIndex[a][b][i] expected 3 values") - if not isinstance(tEntry[0], int): - raise ValueError("refIndex[a][b][i][0] is not an integer") - if not tEntry[1] in nwKeyWords.VALID_KEYS: - raise ValueError("refIndex[a][b][i][1] is not a keyword") - if not isinstance(tEntry[2], str): - raise ValueError("refIndex[a][b][i][2] is not a string") + nwItem = self.theProject.tree[tHandle] + if nwItem is not None: + tItem = IndexItem(tHandle, nwItem) + tItem.unpackData(tData) + self._items[tHandle] = tItem return - def _checkFileIndex(self): - """Scan the file index for errors. - Warning: This function raises exceptions. +# END Class ItemIndex + + +class IndexItem: + """This object represents the index data of a project item (NWItem). + It holds a record of all the headings in the text, and the meta data + associated with each heading. It also holds a pointer to the project + item. The main heading level of the item is also held here since it + must be reset each time the item is re-indexed. + """ + + def __init__(self, tHandle, tItem): + self._handle = tHandle + self._item = tItem + self._level = "H0" + self._headings = {} + self._index = 0 + + # Add a placeholder heading + self._headings[TT_NONE] = IndexHeading(TT_NONE) + + return + + def __repr__(self): + return f"" + + ## + # Properties + ## + + @property + 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. """ - for tHandle in self._fileIndex: - if not isHandle(tHandle): - raise KeyError("fileIndex key is not a handle") - - hEntry = self._fileIndex[tHandle] - for sTitle in self._fileIndex[tHandle]: - if not isTitleTag(sTitle): - raise KeyError("fileIndex[a] key is not a title tag") - - sEntry = hEntry[sTitle] - if len(sEntry) != 7: - raise IndexError("fileIndex[a][b] expected 7 values") - - if "level" not in sEntry: - raise KeyError("fileIndex[a][b] has no 'level' key") - if "title" not in sEntry: - raise KeyError("fileIndex[a][b] has no 'title' key") - if "layout" not in sEntry: - raise KeyError("fileIndex[a][b] has no 'layout' key") - if "cCount" not in sEntry: - raise KeyError("fileIndex[a][b] has no 'cCount' key") - if "wCount" not in sEntry: - raise KeyError("fileIndex[a][b] has no 'wCount' key") - if "pCount" not in sEntry: - raise KeyError("fileIndex[a][b] has no 'pCount' key") - if "synopsis" not in sEntry: - raise KeyError("fileIndex[a][b] has no 'synopsis' key") - - if not sEntry["level"] in H_VALID: - raise ValueError("fileIndex[a][b][level] is not a header level") - if not isinstance(sEntry["title"], str): - raise ValueError("fileIndex[a][b][title] is not a string") - if not isItemLayout(sEntry["layout"]): - raise ValueError("fileIndex[a][b][layout] is not an nwItemLayout") - if not isinstance(sEntry["cCount"], int): - raise ValueError("fileIndex[a][b][cCount] is not an integer") - if not isinstance(sEntry["wCount"], int): - raise ValueError("fileIndex[a][b][wCount] is not an integer") - if not isinstance(sEntry["pCount"], int): - raise ValueError("fileIndex[a][b][pCount] is not an integer") - if not isinstance(sEntry["synopsis"], str): - raise ValueError("fileIndex[a][b][synopsis] is not a string") - + if self._level == "H0": + self._level = level return - def _checkFileMeta(self): - """Scan the text counts index for errors. - Warning: This function raises exceptions. + def addHeading(self, tHeading): + """Add a heading to the item. Also remove the placeholder entry + if it exists. """ - for tHandle in self._fileMeta: - if not isHandle(tHandle): - raise KeyError("fileMeta key is not a handle") + if TT_NONE in self._headings: + self._headings.pop(TT_NONE) + self._headings[tHeading.key] = tHeading + return - tEntry = self._fileMeta[tHandle] - if len(tEntry) != 4: - raise IndexError("fileMeta[a] expected 4 values") - if not tEntry[0] in H_VALID: - raise ValueError("fileMeta[a][0] is not a header level") - if not isinstance(tEntry[1], int): - raise ValueError("fileMeta[a][1] is not an integer") - if not isinstance(tEntry[2], int): - raise ValueError("fileMeta[a][2] is not an integer") - if not isinstance(tEntry[3], int): - raise ValueError("fileMeta[a][3] is not an integer") + def setHeadingCounts(self, sTitle, charCount, wordCount, paraCount): + """Set the character, word and paragraph count of a heading. + """ + if sTitle in self._headings: + self._headings[sTitle].setCounts(charCount, wordCount, paraCount) + return + + def setHeadingSynopsis(self, sTitle, synopText): + """Set the synopsis text of a heading. + """ + if sTitle in self._headings: + self._headings[sTitle].setSynopsis(synopText) + return + + def setHeadingTag(self, sTitle, tagKey): + """Set the tag of a heading. + """ + if sTitle in self._headings: + self._headings[sTitle].setTag(tagKey) + return + + def addHeadingReferences(self, sTitle, tagKeys, refType): + """Add a reference key and all its types to a heading. + """ + if sTitle in self._headings: + for tagKey in tagKeys: + self._headings[sTitle].addReference(tagKey, refType) + return + + ## + # Data Methods + ## + + def __getitem__(self, sTitle): + return self._headings.get(sTitle, None) + + def __contains__(self, sTitle): + return sTitle in self._headings + + def items(self): + return self._headings.items() + + def headings(self): + return sorted(self._headings.keys()) + + def allTags(self): + """Return a list of all tags in the current item. + """ + tags = [] + for hItem in self._headings.values(): + tag = hItem.tag + if tag: + tags.append(tag) + return tags + + ## + # Pack/Unpack + ## + + def packData(self): + """Pack the indexed item's data into a dictionary. + """ + heads = {} + refs = {} + for sTitle, hItem in self._headings.items(): + heads[sTitle] = hItem.packData() + hRefs = hItem.packReferences() + if hRefs: + refs[sTitle] = hRefs + + data = {"level": self._level} + data["headings"] = heads + if refs: + data["references"] = refs + + return data + + 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): + raise ValueError("The itemIndex contains an invalid title key") + tHeading = IndexHeading(sTitle) + tHeading.unpackData(hData) + tHeading.unpackReferences(references.get(sTitle, {})) + self.addHeading(tHeading) + return + +# END Class IndexItem + + +class IndexHeading: + """This object represents a section of text in a project item + associated with a single (valid) heading. It holds a separate record + of all references made under each heading. + """ + + def __init__(self, key, level="H0", title=""): + self._key = key + self._level = level + self._title = title + + self._charCount = 0 + self._wordCount = 0 + self._paraCount = 0 + self._synopsis = "" + + self._tag = "" + self._refs = {} return -# END Class NWIndex + def __repr__(self): + return f"" + + ## + # Properties + ## + + @property + def key(self): + return self._key + + @property + def level(self): + return self._level + + @property + def title(self): + return self._title + + @property + def charCount(self): + return self._charCount + + @property + def wordCount(self): + return self._wordCount + + @property + def paraCount(self): + return self._paraCount + + @property + def synopsis(self): + return self._synopsis + + @property + def tag(self): + return self._tag + + @property + def references(self): + return self._refs + + ## + # Setters + ## + + def setLevel(self, level): + """Set the level of the header if it's a valid value. + """ + if level in H_VALID: + self._level = level + return + + def setCounts(self, charCount, wordCount, paraCount): + """Set the character, word and paragraph count. Make sure the + value is an integer and is not smaller than 0. + """ + self._charCount = max(0, checkInt(charCount, 0)) + self._wordCount = max(0, checkInt(wordCount, 0)) + self._paraCount = max(0, checkInt(paraCount, 0)) + return + + def setSynopsis(self, synopText): + """Set the synopsis text and make sure it is a string. + """ + self._synopsis = str(synopText) + return + + def setTag(self, tagKey): + """Set the tag for references, and make sure it is a string. + """ + self._tag = str(tagKey) + return + + def addReference(self, tagKey, refType): + """Add a record of a reference tag, and what keyword types it is + associated with. + """ + if refType in nwKeyWords.VALID_KEYS: + if tagKey not in self._refs: + self._refs[tagKey] = set() + self._refs[tagKey].add(refType) + return + + ## + # Data Methods + ## + + def packData(self): + """Pack the values into a dictionary for saving to cache. + """ + return { + "level": self._level, + "title": self._title, + "tag": self._tag, + "cCount": self._charCount, + "wCount": self._wordCount, + "pCount": self._paraCount, + "synopsis": self._synopsis, + } + + def packReferences(self): + """Pack references into a dictionary for saving to cache. + Multiple types are packed into a sorted, comma separated string. + It is sorted to prevent creating unnecessary diffs as the order + of a set is not guaranteed. + """ + return {key: ",".join(sorted(list(value))) for key, value in self._refs.items()} + + def unpackData(self, data): + """Unpack a heading entry from a dictionary. + """ + self.setLevel(data.get("level", "H0")) + self._title = str(data.get("title", "")) + self._tag = str(data.get("tag", "")) + self.setCounts( + data.get("cCount", 0), + data.get("wCount", 0), + data.get("pCount", 0), + ) + self._synopsis = str(data.get("synopsis", "")) + return + + def unpackReferences(self, data): + """Unpack a set of references from a dictionary. + """ + for tagKey, refTypes in data.items(): + if not isinstance(tagKey, str): + raise ValueError("itemIndex reference key must be a string") + if not isinstance(refTypes, str): + raise ValueError("itemIndex reference type must be a string") + for refType in refTypes.split(","): + if refType in nwKeyWords.VALID_KEYS: + self.addReference(tagKey, refType) + else: + raise ValueError("The itemIndex contains an invalid reference type") + return + +# END Class IndexHeading # =============================================================================================== # diff --git a/novelwriter/core/item.py b/novelwriter/core/item.py index 993b183c..88650475 100644 --- a/novelwriter/core/item.py +++ b/novelwriter/core/item.py @@ -29,9 +29,9 @@ from lxml import etree from novelwriter.enum import nwItemType, nwItemClass, nwItemLayout from novelwriter.common import ( - checkInt, isHandle, isItemClass, isItemLayout, isItemType + checkInt, isHandle, isItemClass, isItemLayout, isItemType, simplified ) -from novelwriter.constants import nwLabels, nwLists, trConst +from novelwriter.constants import nwLabels, trConst logger = logging.getLogger(__name__) @@ -45,6 +45,7 @@ class NWItem(): self._name = "" self._handle = None self._parent = None + self._root = None self._order = 0 self._type = nwItemType.NO_TYPE self._class = nwItemClass.NO_CLASS @@ -85,6 +86,10 @@ class NWItem(): def itemParent(self): return self._parent + @property + def itemRoot(self): + return self._root + @property def itemOrder(self): return self._order @@ -147,6 +152,7 @@ class NWItem(): 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) @@ -154,19 +160,18 @@ class NWItem(): itemAttrib["layout"] = str(self._layout.name) 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) - else: - metaAttrib["expanded"] = str(self._expanded) nameAttrib = {} nameAttrib["status"] = str(self._status) nameAttrib["import"] = str(self._import) if self._type == nwItemType.FILE: - nameAttrib["exported"] = str(self._exported) + nameAttrib["exported"] = str(self._exported) xPack = etree.SubElement(xParent, "item", attrib=itemAttrib) self._subPack(xPack, "meta", attrib=metaAttrib) @@ -188,6 +193,7 @@ class NWItem(): 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)) @@ -233,6 +239,17 @@ class NWItem(): # version of novelWriter that doesn't know the tag logger.error("Unknown tag '%s'", xValue.tag) + # Make some checks to ensure consistency + if self._type == nwItemType.ROOT: + self._root = self._handle # Root items are their own ancestor + 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 + return True @staticmethod @@ -249,7 +266,7 @@ class NWItem(): return ## - # Methods + # Lookup Methods ## def describeMe(self, hLevel=None): @@ -275,168 +292,226 @@ class NWItem(): return trConst(nwLabels.ITEM_DESCRIPTION.get(descKey, "")) + def isNovelLike(self): + """Returns true if the item is of a novel-like class. + """ + return self._class in (nwItemClass.NOVEL, nwItemClass.ARCHIVE) + + def documentAllowed(self): + """Returns true if the item is allowed to be of document layout. + """ + return self._class in (nwItemClass.NOVEL, nwItemClass.ARCHIVE, nwItemClass.TRASH) + + def isInactive(self): + """Returns true if the item is in an inactive class. + """ + 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._class in nwLists.CLS_NOVEL: - stName = self.theProject.statusItems.checkEntry(self._status) - stIcon = self.theProject.statusItems.getIcon(stName) + if self.isNovelLike(): + stName = self.theProject.statusItems.name(self._status) + stIcon = self.theProject.statusItems.icon(self._status) else: - stName = self.theProject.importItems.checkEntry(self._import) - stIcon = self.theProject.importItems.getIcon(stName) + stName = self.theProject.importItems.name(self._import) + stIcon = self.theProject.importItems.icon(self._import) return stName, stIcon - def setImportStatus(self, theLabel): + ## + # Special Setters + ## + + def setImportStatus(self, value): """Update the importance or status value based on class. This is a wrapper setter for setStatus and setImport. """ - if self._class in nwLists.CLS_NOVEL: - self.setStatus(theLabel) + if self.isNovelLike(): + self.setStatus(value) else: - self.setImport(theLabel) + self.setImport(value) + return + + def setClassDefaults(self, itemClass): + """Set the default values based on the item's class and the + project settings. + """ + if self._parent is not None: + # Only update for child items + self.setClass(itemClass) + + if self._layout == nwItemLayout.NO_LAYOUT: + # If no layout is set, pick one + if self.isNovelLike(): + self._layout = nwItemLayout.DOCUMENT + else: + self._layout = nwItemLayout.NOTE + elif not self.documentAllowed(): + # Change layout to note if it is not in an allowed folder + self._layout = nwItemLayout.NOTE + + if self._status is None: + self.setStatus("New") # This forces a default value lookup + + if self._import is None: + self.setImport("New") # This forces a default value lookup + return ## # Set Item Values ## - def setName(self, theName): + def setName(self, name): """Set the item name. """ - if isinstance(theName, str): - self._name = theName.strip() + if isinstance(name, str): + self._name = simplified(name) else: self._name = "" return - def setHandle(self, theHandle): + def setHandle(self, handle): """Set the item handle, and ensure it is valid. """ - if isHandle(theHandle): - self._handle = theHandle + if isHandle(handle): + self._handle = handle else: self._handle = None return - def setParent(self, theParent): + def setParent(self, handle): """Set the parent handle, and ensure it is valid. """ - if theParent is None: + if handle is None: self._parent = None - elif isHandle(theParent): - self._parent = theParent + elif isHandle(handle): + self._parent = handle else: self._parent = None return - def setOrder(self, theOrder): + def setRoot(self, handle): + """Set the root handle, and ensure it is valid. + """ + if handle is None: + self._root = None + elif isHandle(handle): + self._root = handle + else: + self._root = None + return + + def setOrder(self, order): """Set the item order, and ensure that it is valid. This value is purely a meta value, and not actually used by novelWriter at the moment. """ - self._order = checkInt(theOrder, 0) + self._order = checkInt(order, 0) return - def setType(self, theType): + def setType(self, value): """Set the item type from either a proper nwItemType, or set it from a string representing an nwItemType. """ - if isinstance(theType, nwItemType): - self._type = theType - elif isItemType(theType): - self._type = nwItemType[theType] + if isinstance(value, nwItemType): + self._type = value + elif isItemType(value): + self._type = nwItemType[value] + elif value == "TRASH": + self._type = nwItemType.ROOT else: - logger.error("Unrecognised item type '%s'", theType) + logger.error("Unrecognised item type '%s'", value) self._type = nwItemType.NO_TYPE return - def setClass(self, theClass): + def setClass(self, value): """Set the item class from either a proper nwItemClass, or set it from a string representing an nwItemClass. """ - if isinstance(theClass, nwItemClass): - self._class = theClass - elif isItemClass(theClass): - self._class = nwItemClass[theClass] + if isinstance(value, nwItemClass): + self._class = value + elif isItemClass(value): + self._class = nwItemClass[value] else: - logger.error("Unrecognised item class '%s'", theClass) + logger.error("Unrecognised item class '%s'", value) self._class = nwItemClass.NO_CLASS return - def setLayout(self, theLayout): + def setLayout(self, value): """Set the item layout from either a proper nwItemLayout, or set it from a string representing an nwItemLayout. """ - if isinstance(theLayout, nwItemLayout): - self._layout = theLayout - elif isItemLayout(theLayout): - self._layout = nwItemLayout[theLayout] - elif theLayout in nwLists.DEP_LAYOUT: + if isinstance(value, nwItemLayout): + 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'", theLayout) + logger.error("Unrecognised item layout '%s'", value) self._layout = nwItemLayout.NO_LAYOUT return - def setStatus(self, theStatus): + def setStatus(self, value): """Set the item status by looking it up in the valid status items of the current project. """ - self._status = self.theProject.statusItems.checkEntry(theStatus) + self._status = self.theProject.statusItems.check(value) return - def setImport(self, theImport): + 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.checkEntry(theImport) + self._import = self.theProject.importItems.check(value) return - def setExpanded(self, expState): + def setExpanded(self, state): """Set the expanded status of an item in the project tree. """ - if isinstance(expState, str): - self._expanded = (expState == str(True)) + if isinstance(state, str): + self._expanded = (state == str(True)) else: - self._expanded = (expState is True) + self._expanded = (state is True) return - def setExported(self, expState): + def setExported(self, state): """Set the export flag. """ - if isinstance(expState, str): - self._exported = (expState == str(True)) + if isinstance(state, str): + self._exported = (state == str(True)) else: - self._exported = (expState is True) + self._exported = (state is True) return ## # Set Document Meta Data ## - def setCharCount(self, theCount): + def setCharCount(self, count): """Set the character count, and ensure that it is an integer. """ - self._charCount = max(0, checkInt(theCount, 0)) + self._charCount = max(0, checkInt(count, 0)) return - def setWordCount(self, theCount): + def setWordCount(self, count): """Set the word count, and ensure that it is an integer. """ - self._wordCount = max(0, checkInt(theCount, 0)) + self._wordCount = max(0, checkInt(count, 0)) return - def setParaCount(self, theCount): + def setParaCount(self, count): """Set the paragraph count, and ensure that it is an integer. """ - self._paraCount = max(0, checkInt(theCount, 0)) + self._paraCount = max(0, checkInt(count, 0)) return - def setCursorPos(self, thePosition): + def setCursorPos(self, position): """Set the cursor position, and ensure that it is an integer. """ - self._cursorPos = max(0, checkInt(thePosition, 0)) + self._cursorPos = max(0, checkInt(position, 0)) return def saveInitialCount(self): diff --git a/novelwriter/core/project.py b/novelwriter/core/project.py index ff2c2774..460fb91f 100644 --- a/novelwriter/core/project.py +++ b/novelwriter/core/project.py @@ -37,6 +37,7 @@ from PyQt5.QtCore import QCoreApplication 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 @@ -44,16 +45,16 @@ from novelwriter.enum import nwItemType, nwItemClass, nwItemLayout, nwAlert from novelwriter.error import logException from novelwriter.common import ( checkString, checkBool, checkInt, isHandle, formatTimeStamp, - makeFileNameSafe, hexToInt + makeFileNameSafe, hexToInt, simplified ) -from novelwriter.constants import nwLists, trConst, nwFiles, nwLabels +from novelwriter.constants import trConst, nwFiles, nwLabels logger = logging.getLogger(__name__) class NWProject(): - FILE_VERSION = "1.4" + FILE_VERSION = "1.4" # The current project file format version def __init__(self, theParent): @@ -62,9 +63,10 @@ class NWProject(): self.mainConf = novelwriter.CONFIG # Core Elements - self.optState = OptionState(self) # Project-specific GUI options - self.projTree = NWTree(self) # The project tree - self.langData = {} # Localisation data + 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 # Project Status self.projOpened = 0 # The time stamp of when the project file was opened @@ -87,7 +89,7 @@ class NWProject(): self.projFiles = [] # A list of all files in the content folder on load # Project Meta - self.projName = "" # Project name (working title) + self.projName = "" # Project name self.bookTitle = "" # The final title; should only be used for exports self.bookAuthors = [] # A list of book authors @@ -116,63 +118,70 @@ class NWProject(): return + ## + # Properties + ## + + @property + def index(self): + return self._projIndex + + @property + def tree(self): + return self._projTree + + @property + def options(self): + return self._optState + ## # Item Methods ## - def newRoot(self, rootName, rootClass): - """Add a new root item. These items are unique, except for item class - CUSTOM, and always have parent handle set to None. + def newRoot(self, itemClass, label=None): + """Add a new root item. If label is None, use the class label. """ - if not self.projTree.checkRootUnique(rootClass): - self.theParent.makeAlert(self.tr("Duplicate root item detected."), nwAlert.ERROR) - return None - + if label is None: + label = trConst(nwLabels.CLASS_NAME[itemClass]) newItem = NWItem(self) - newItem.setName(rootName) + newItem.setName(label) newItem.setType(nwItemType.ROOT) - newItem.setClass(rootClass) - newItem.setStatus(0) - self.projTree.append(None, None, newItem) + newItem.setClass(itemClass) + self._projTree.append(None, None, newItem) + self._projTree.updateItemData(newItem.itemHandle) return newItem.itemHandle - def newFolder(self, folderName, folderClass, pHandle): - """Add a new folder with a given name and class and parent item. + def newFolder(self, label, pHandle): + """Add a new folder with a given label and parent item. """ newItem = NWItem(self) - newItem.setName(folderName) + newItem.setName(label) newItem.setType(nwItemType.FOLDER) - newItem.setClass(folderClass) - newItem.setStatus(0) - self.projTree.append(None, pHandle, newItem) + self._projTree.append(None, pHandle, newItem) + self._projTree.updateItemData(newItem.itemHandle) return newItem.itemHandle - def newFile(self, fileName, fileClass, pHandle): - """Add a new file with a given name and class, and set a layout - based on the class. DOCUMENT for NOVEL, otherwise NOTE. + def newFile(self, label, pHandle): + """Add a new file with a given label and parent item. """ newItem = NWItem(self) - newItem.setName(fileName) + newItem.setName(label) newItem.setType(nwItemType.FILE) - if fileClass == nwItemClass.NOVEL: - newItem.setLayout(nwItemLayout.DOCUMENT) - else: - newItem.setLayout(nwItemLayout.NOTE) - newItem.setClass(fileClass) - newItem.setStatus(0) - self.projTree.append(None, pHandle, newItem) + self._projTree.append(None, pHandle, newItem) + self._projTree.updateItemData(newItem.itemHandle) return newItem.itemHandle def trashFolder(self): """Add the special trash root folder to the project. """ - trashHandle = self.projTree.trashRoot() + trashHandle = self._projTree.trashRoot() if trashHandle is None: newItem = NWItem(self) newItem.setName(trConst(nwLabels.CLASS_NAME[nwItemClass.TRASH])) - newItem.setType(nwItemType.TRASH) + newItem.setType(nwItemType.ROOT) newItem.setClass(nwItemClass.TRASH) - self.projTree.append(None, None, newItem) + self._projTree.append(None, None, newItem) + self._projTree.updateItemData(newItem.itemHandle) return newItem.itemHandle return trashHandle @@ -193,7 +202,7 @@ class NWProject(): self.autoCount = 0 # Project Tree - self.projTree.clear() + self._projTree.clear() # Project Settings self.projPath = None @@ -218,16 +227,16 @@ class NWProject(): } self.spellCheck = False self.autoOutline = True - self.statusItems = NWStatus() - self.statusItems.addEntry(self.tr("New"), (100, 100, 100)) - self.statusItems.addEntry(self.tr("Note"), (200, 50, 0)) - self.statusItems.addEntry(self.tr("Draft"), (200, 150, 0)) - self.statusItems.addEntry(self.tr("Finished"), (50, 200, 0)) - self.importItems = NWStatus() - self.importItems.addEntry(self.tr("New"), (100, 100, 100)) - self.importItems.addEntry(self.tr("Minor"), (200, 50, 0)) - self.importItems.addEntry(self.tr("Major"), (200, 150, 0)) - self.importItems.addEntry(self.tr("Main"), (50, 200, 0)) + 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 @@ -267,6 +276,7 @@ class NWProject(): logger.error("No project path set for the new project") return False + self.clearProject() if not self.setProjectPath(projPath, newProject=True): return False @@ -274,86 +284,88 @@ class NWProject(): 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 folder with a single file. - xHandle = {} - xHandle[1] = self.newRoot(self.tr("Novel"), nwItemClass.NOVEL) - xHandle[2] = self.newRoot(self.tr("Plot"), nwItemClass.PLOT) - xHandle[3] = self.newRoot(self.tr("Characters"), nwItemClass.CHARACTER) - xHandle[4] = self.newRoot(self.tr("World"), nwItemClass.WORLD) - xHandle[5] = self.newFile(self.tr("Title Page"), nwItemClass.NOVEL, xHandle[1]) - xHandle[6] = self.newFolder(self.tr("New Chapter"), nwItemClass.NOVEL, xHandle[1]) - xHandle[7] = self.newFile(self.tr("New Chapter"), nwItemClass.NOVEL, xHandle[6]) - xHandle[8] = self.newFile(self.tr("New Scene"), nwItemClass.NOVEL, xHandle[6]) - - aDoc = NWDoc(self, xHandle[5]) - aDoc.writeDocument(titlePage) - - aDoc = NWDoc(self, xHandle[7]) + # 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")) - aDoc = NWDoc(self, xHandle[8]) + 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 root folders - nHandle = self.newRoot(self.tr("Novel"), nwItemClass.NOVEL) - for newRoot in projData.get("addRoots", []): - if newRoot in nwItemClass: - self.newRoot(trConst(nwLabels.CLASS_NAME[newRoot]), newRoot) - - # Create a title page - tHandle = self.newFile(self.tr("Title Page"), nwItemClass.NOVEL, nHandle) - self.projTree.setFileItemLayout(tHandle, nwItemLayout.DOCUMENT) - - aDoc = NWDoc(self, tHandle) - aDoc.writeDocument(titlePage) - # Create chapters and scenes numChapters = projData.get("numChapters", 0) numScenes = projData.get("numScenes", 0) - chFolders = projData.get("chFolders", False) + + 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}") - pHandle = nHandle - if chFolders: - pHandle = self.newFolder(chTitle, nwItemClass.NOVEL, nHandle) - - cHandle = self.newFile(chTitle, nwItemClass.NOVEL, pHandle) - self.projTree.setFileItemLayout(cHandle, nwItemLayout.DOCUMENT) - + cHandle = self.newFile(chTitle, hNovelRoot) aDoc = NWDoc(self, cHandle) - aDoc.writeDocument("## %s\n\n" % chTitle) + 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, nwItemClass.NOVEL, pHandle) - + sHandle = self.newFile(scTitle, cHandle) aDoc = NWDoc(self, sHandle) - aDoc.writeDocument("### %s\n\n" % scTitle) + 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, nwItemClass.NOVEL, nHandle) - + sHandle = self.newFile(scTitle, hNovelRoot) aDoc = NWDoc(self, sHandle) - aDoc.writeDocument("### %s\n\n" % scTitle) + 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: @@ -480,8 +492,9 @@ class NWProject(): # 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. Introduced - # in version 1.7. + # 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.theParent.makeAlert(self.tr( @@ -533,14 +546,16 @@ class NWProject(): if xItem.text is None: continue if xItem.tag == "name": - logger.verbose("Working Title: '%s'", xItem.text) - self.projName = xItem.text + self.projName = checkString(simplified(xItem.text), "") + logger.verbose("Working Title: '%s'", self.projName) elif xItem.tag == "title": - logger.verbose("Title is '%s'", xItem.text) - self.bookTitle = xItem.text + self.bookTitle = checkString(simplified(xItem.text), "") + logger.verbose("Title is '%s'", self.bookTitle) elif xItem.tag == "author": - logger.verbose("Author: '%s'", xItem.text) - self.bookAuthors.append(xItem.text) + 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": @@ -591,9 +606,9 @@ class NWProject(): elif xChild.tag == "content": logger.debug("Found project content") - self.projTree.unpackXML(xChild) + self._projTree.unpackXML(xChild) - self.optState.loadSettings() + self._optState.loadSettings() # Sort out old file locations if legacyList: @@ -610,7 +625,13 @@ class NWProject(): self.mainConf.updateRecentCache(self.projPath, self.projName, self.lastWCount, time()) self.mainConf.saveRecentCache() - self.theParent.setStatus(self.tr("Opened Project: {0}").format(self.projName)) + # 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 self._scanProjectFolder() self._loadProjectLocalisation() @@ -621,6 +642,7 @@ class NWProject(): self._writeLockFile() self.setProjectChanged(False) + self.theParent.setStatus(self.tr("Opened Project: {0}").format(self.projName)) return True @@ -687,6 +709,8 @@ class NWProject(): 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") @@ -694,7 +718,7 @@ class NWProject(): # Save Tree Content logger.debug("Writing project content") - self.projTree.packXML(nwXML) + self._projTree.packXML(nwXML) # Write the xml tree to file tempFile = os.path.join(self.projPath, self.projFile+"~") @@ -727,7 +751,7 @@ class NWProject(): return False # Save project GUI options - self.optState.saveSettings() + self._optState.saveSettings() # Update recent projects self.mainConf.updateRecentCache(self.projPath, self.projName, self.currWCount, saveTime) @@ -743,8 +767,8 @@ class NWProject(): """Close the current project and clear all meta data. """ logger.info("Closing project: %s", self.projPath) - self.optState.saveSettings() - self.projTree.writeToCFile() + self._optState.saveSettings() + self._projTree.writeToCFile() self._appendSessionStats(idleTime) self._clearLockFile() self.clearProject() @@ -950,17 +974,17 @@ class NWProject(): return True def setProjectName(self, projName): - """Set the project name (working title), This is the the title - used for backup files etc. + """Set the project name, This is the the name used for backup + files etc. """ - self.projName = projName.strip() + 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 = bookTitle.strip() + self.bookTitle = simplified(bookTitle) self.setProjectChanged(True) return True @@ -972,7 +996,7 @@ class NWProject(): self.bookAuthors = [] for bookAuthor in bookAuthors.splitlines(): - bookAuthor = bookAuthor.strip() + bookAuthor = simplified(bookAuthor) if bookAuthor == "": continue self.bookAuthors.append(bookAuthor) @@ -1018,7 +1042,8 @@ class NWProject(): if self.projSpell != theLang: self.projSpell = theLang self.setProjectChanged(True) - return True + return True + return False def setProjectLang(self, theLang): """Set the project-specific language. @@ -1043,9 +1068,9 @@ class NWProject(): 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._projTree) != len(newOrder): logger.warning("Sizes of new and old tree order do not match") - self.projTree.setOrder(newOrder) + self._projTree.setOrder(newOrder) self.setProjectChanged(True) return True @@ -1065,34 +1090,22 @@ class NWProject(): self.setProjectChanged(True) return True - def setStatusColours(self, newCols): - """Update the list of novel file status flags. Also iterate - through the project and replace keys that have been renamed. + def setStatusColours(self, newCols, delCols): + """Update the list of novel file status flags. """ - replaceMap = self.statusItems.setNewEntries(newCols) - for nwItem in self.projTree: - if nwItem.itemClass in nwLists.CLS_NOVEL: - if nwItem.itemStatus in replaceMap: - nwItem.setStatus(replaceMap[nwItem.itemStatus]) - self.setProjectChanged(True) - return True + return self._setStatusImport(newCols, delCols, self.statusItems) - def setImportColours(self, newCols): - """Update the list of note file importance flags. Also iterate - through the project and replace keys that have been renamed. + def setImportColours(self, newCols, delCols): + """Update the list of note file importance flags. """ - replaceMap = self.importItems.setNewEntries(newCols) - for nwItem in self.projTree: - if nwItem.itemClass not in nwLists.CLS_NOVEL: - if nwItem.itemImport in replaceMap: - nwItem.setImport(replaceMap[nwItem.itemImport]) - self.setProjectChanged(True) - return True + return self._setStatusImport(newCols, delCols, self.importItems) def setAutoReplace(self, autoReplace): """Update the auto-replace dictionary. """ - self.autoReplace = autoReplace + self.autoReplace = {} + for key, entry in autoReplace.items(): + self.autoReplace[key] = simplified(entry) self.setProjectChanged(True) return True @@ -1101,7 +1114,9 @@ class NWProject(): """ for valKey, valEntry in titleFormat.items(): if valKey in self.titleFormat: - self.titleFormat[valKey] = checkString(valEntry, self.titleFormat[valKey]) + self.titleFormat[valKey] = checkString( + simplified(valEntry), self.titleFormat[valKey] + ) return True def setProjectChanged(self, bValue): @@ -1149,16 +1164,16 @@ class NWProject(): capable of handling it. """ sentItems = [] - iterItems = self.projTree.handles() + iterItems = self._projTree.handles() n = 0 nMax = min(len(iterItems), 10000) while n < nMax: tHandle = iterItems[n] - tItem = self.projTree[tHandle] + tItem = self._projTree[tHandle] n += 1 if tItem is None: # Technically a bug since treeOrder is built from the - # same data as projTree + # same data as _projTree continue elif tItem.itemParent is None: # Item is a root, or already been identified as an @@ -1189,7 +1204,7 @@ class NWProject(): def updateWordCounts(self): """Update the total word count values. """ - wcNovel, wcNotes = self.projTree.sumWords() + wcNovel, wcNotes = self._projTree.sumWords() wcTotal = wcNovel + wcNotes if wcTotal != self.currWCount: self.currNovelWC = wcNovel @@ -1205,11 +1220,11 @@ class NWProject(): """ self.statusItems.resetCounts() self.importItems.resetCounts() - for nwItem in self.projTree: - if nwItem.itemClass in nwLists.CLS_NOVEL: - self.statusItems.countEntry(nwItem.itemStatus) + for nwItem in self._projTree: + if nwItem.isNovelLike(): + self.statusItems.increment(nwItem.itemStatus) else: - self.importItems.countEntry(nwItem.itemImport) + self.importItems.increment(nwItem.itemImport) return def localLookup(self, theWord): @@ -1217,17 +1232,39 @@ class NWProject(): return it. The variable is cast to a string before lookup. If the word does not exist, it returns itself. """ - return self.langData.get(str(theWord), str(theWord)) + return self._langData.get(str(theWord), str(theWord)) ## # Internal Functions ## + def _setStatusImport(self, new, delete, target): + """Update the list of novel file status or importance flags, and + delete those that have been requested deleted. + """ + if not (new or delete): + return False + + order = [] + for entry in new: + key = entry.get("key", None) + name = entry.get("name", "") + cols = entry.get("cols", (100, 100, 100)) + if name: + order.append(target.write(key, name, cols)) + + for key in delete: + target.remove(key) + + target.reorder(order) + + return True + def _loadProjectLocalisation(self): """Load the language data for the current project language. """ if self.projLang is None: - self.langData = {} + self._langData = {} return False langFile = os.path.join(self.mainConf.nwLangPath, "project_%s.json" % self.projLang) @@ -1236,7 +1273,7 @@ class NWProject(): try: with open(langFile, mode="r", encoding="utf-8") as inFile: - self.langData = json.load(inFile) + self._langData = json.load(inFile) logger.debug("Loaded project language file: %s", os.path.basename(langFile)) except Exception: @@ -1371,7 +1408,7 @@ class NWProject(): logger.warning("Skipping file: %s", fileItem) continue - if fHandle in self.projTree: + if fHandle in self._projTree: self.projFiles.append(fHandle) logger.debug("Checking file %s, handle '%s': OK", fileItem, fHandle) else: @@ -1418,10 +1455,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._projTree: + oParent = self._projTree.findRoot(oClass) if oParent is None: - oParent = self.projTree.findRoot(nwItemClass.NOVEL) + oParent = self._projTree.findRoot(nwItemClass.NOVEL) # If the file still has no parent item, skip it if oParent is None: @@ -1433,7 +1470,8 @@ class NWProject(): orphItem.setType(nwItemType.FILE) orphItem.setClass(oClass) orphItem.setLayout(oLayout) - self.projTree.append(oHandle, oParent, orphItem) + self._projTree.append(oHandle, oParent, orphItem) + self._projTree.updateItemData(orphItem.itemHandle) if noWhere: self.theParent.makeAlert(self.tr( diff --git a/novelwriter/core/status.py b/novelwriter/core/status.py index 2ca8ed53..4bade5e7 100644 --- a/novelwriter/core/status.py +++ b/novelwriter/core/status.py @@ -4,7 +4,8 @@ novelWriter – Project Item Status Class Data class for the status/importance settings of a project item File History: -Created: 2019-05-19 [0.1.3] +Created: 2019-05-19 [0.1.3] +Rewritten: 2022-04-05 [1.7a0] This file is a part of novelWriter Copyright 2018–2022, Veronica Berglyd Olsen @@ -23,6 +24,7 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ +import random import logging import novelwriter @@ -30,134 +32,208 @@ from lxml import etree from PyQt5.QtGui import QIcon, QPixmap, QColor -from novelwriter.common import checkInt +from novelwriter.common import checkInt, minmax, simplified logger = logging.getLogger(__name__) class NWStatus(): - def __init__(self): + STATUS = 1 + IMPORT = 2 + + def __init__(self, type): + + self._type = type + self._store = {} + self._reverse = {} + self._default = None - self._theLabels = [] - self._theColours = [] - self._theCounts = [] - self._theIcons = [] - self._theMap = {} - self._theLength = 0 - self._theIndex = 0 self._iconSize = novelwriter.CONFIG.pxInt(32) + pixmap = QPixmap(self._iconSize, self._iconSize) + pixmap.fill(QColor(100, 100, 100)) + self._defaultIcon = QIcon(pixmap) + + if self._type == self.STATUS: + self._prefix = "s" + elif self._type == self.IMPORT: + self._prefix = "i" + else: + raise Exception("This is a bug!") return - def addEntry(self, theLabel, theColours): - """Add a status entry to the status object, but ensure it isn't - a duplicate. + def write(self, key, name, cols, count=None): + """Add or update a status entry. If the key is invalid, a new + key is generated. """ - theLabel = theLabel.strip() - if self._getIndex(theLabel) is None: - theIcon = QPixmap(self._iconSize, self._iconSize) - theIcon.fill(QColor(*theColours)) - self._theIcons.append(QIcon(theIcon)) - self._theLabels.append(theLabel) - self._theColours.append(theColours) - self._theCounts.append(0) - self._theMap[theLabel] = self._theLength - self._theLength += 1 + 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)) + + name = simplified(name) + if count is None: + count = self._store[key]["count"] if key in self._store else 0 + + self._store[key] = { + "name": name, + "icon": QIcon(pixmap), + "cols": cols, + "count": count, + } + self._reverse[name] = key + + if self._default is None: + self._default = key + + return key + + def remove(self, key): + """Remove an entry in the list, but not if the count is larger + than 0. + """ + if key not in self._store: + return False + if self._store[key]["count"] > 0: + return False + + del self._reverse[self._store[key]["name"]] + del self._store[key] + + keys = list(self._store.keys()) + if key == self._default: + if len(keys) > 0: + self._default = keys[0] + else: + self._default = None return True - def checkEntry(self, theStatus): - """Check if a status value is valid, and returns the safe - reference to be used internally. + def check(self, value): + """Check the key against the stored status names. """ - if isinstance(theStatus, str): - if self._getIndex(theStatus) is not None: - return theStatus.strip() - return self._theLabels[0] + 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: + return "" - def getIcon(self, theLabel): - """Return the icon for the given status item. + def name(self, key): + """Return the name associated with a given key. """ - theIndex = self._getIndex(theLabel) - if theIndex is not None: - return self._theIcons[theIndex] - return QIcon() + if key in self._store: + return self._store[key]["name"] + elif self._default is not None: + return self._store[self._default]["name"] + else: + return "" - def setNewEntries(self, newList): - """Update the list of entries after they have been modified by - the GUI tool. + def cols(self, key): + """Return the colours associated with a given key. """ - replaceMap = {} + if key in self._store: + return self._store[key]["cols"] + elif self._default is not None: + return self._store[self._default]["cols"] + else: + return (100, 100, 100) - if newList is not None: - self._theLabels = [] - self._theColours = [] - self._theCounts = [] - self._theIcons = [] - self._theMap = {} - self._theLength = 0 - self._theIndex = 0 + def count(self, key): + """Return the count associated with a given key. + """ + if key in self._store: + return self._store[key]["count"] + elif self._default is not None: + return self._store[self._default]["count"] + else: + return 0 - for nName, nR, nG, nB, oName in newList: - self.addEntry(nName, (nR, nG, nB)) - if nName != oName and oName is not None: - replaceMap[oName] = nName + def icon(self, key): + """Return the icon associated with a given key. + """ + if key in self._store: + return self._store[key]["icon"] + elif self._default is not None: + return self._store[self._default]["icon"] + else: + return self._defaultIcon - return replaceMap + def reorder(self, order): + """Reorder the items according to list. + """ + if len(order) != len(self._store): + logger.error("Length mismatch between new and old order") + return False + + if order == list(self._store.keys()): + return False + + store = {} + for key in order: + if key in self._store: + store[key] = self._store[key] + else: + logger.error("Unknown key '%s' in order", key) + return False + + self._store = store + + return True def resetCounts(self): """Clear the counts of references to the status entries. """ - self._theCounts = [0]*self._theLength + for key in self._store: + self._store[key]["count"] = 0 return - def countEntry(self, theLabel): - """Increment the counter for a given label. This should be used - together with resetCounts in a loop over project items. + def increment(self, key): + """Increment the counter for a given entry. """ - theIndex = self._getIndex(theLabel) - if theIndex is not None: - self._theCounts[theIndex] += 1 + if key in self._store: + 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. """ - for n in range(self._theLength): + for key, data in self._store.items(): xSub = etree.SubElement(xParent, "entry", attrib={ - "red": str(self._theColours[n][0]), - "green": str(self._theColours[n][1]), - "blue": str(self._theColours[n][2]), + "key": key, + "count": str(data["count"]), + "red": str(data["cols"][0]), + "green": str(data["cols"][1]), + "blue": str(data["cols"][2]), }) - xSub.text = self._theLabels[n] + xSub.text = data["name"] + return True def unpackXML(self, xParent): """Unpack an XML tree and set the class values. """ - theLabels = [] - theColours = [] + self._store = {} + self._reverse = {} + self._default = None for xChild in xParent: - theLabels.append(xChild.text) - cR = checkInt(xChild.attrib.get("red", 0), 0, False) - cG = checkInt(xChild.attrib.get("green", 0), 0, False) - cB = checkInt(xChild.attrib.get("blue", 0), 0, False) - theColours.append((cR, cG, cB)) - - if len(theLabels) > 0: - self._theLabels = [] - self._theColours = [] - self._theCounts = [] - self._theIcons = [] - self._theMap = {} - self._theLength = 0 - self._theIndex = 0 - - for n in range(len(theLabels)): - self.addEntry(theLabels[n], theColours[n]) + 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) return True @@ -165,39 +241,52 @@ class NWStatus(): # Internal Functions ## - def _getIndex(self, theLabel): - """Look up a status entry in the object lists, and return it if - it exists. + def _newKey(self): + """Generate a new key for a status flag. This method is + recursive, but should only fail if there is an issue with the + random number generator or the user has added a lot of status + flags. The Python recursion limit is given the job to handle + the extreme case and will cause an app crash. """ - if theLabel is None: - return None - return self._theMap.get(theLabel.strip(), None) + key = f"{self._prefix}{random.getrandbits(24):06x}" + if key in self._store: + key = self._newKey() + return key + + def _isKey(self, value): + """Check if a value is a key or not. + """ + if not isinstance(value, str): + return False + if len(value) != 7: + return False + if value[0] != self._prefix: + return False + for c in value[1:]: + if c not in "0123456789abcdef": + return False + return True ## # Iterator Bits ## - def __getitem__(self, n): - """Return an entry by its index. - """ - if n >= 0 and n < self._theLength: - return self._theLabels[n], self._theColours[n], self._theCounts[n], self._theIcons[n] - return None, None, None, QIcon() + def __len__(self): + return len(self._store) + + def __getitem__(self, key): + return self._store[key] def __iter__(self): - """Initialise the iterator. - """ - self._theIndex = 0 - return self + return iter(self._store) - def __next__(self): - """Return the next entry for the iterator. - """ - if self._theIndex < self._theLength: - theLabel, theColour, theCount, theIcon = self.__getitem__(self._theIndex) - self._theIndex += 1 - return theLabel, theColour, theCount, theIcon - else: - raise StopIteration + def keys(self): + return self._store.keys() + + def items(self): + return self._store.items() + + def values(self): + return self._store.values() # END Class NWStatus diff --git a/novelwriter/core/tohtml.py b/novelwriter/core/tohtml.py index baa5165d..86a21786 100644 --- a/novelwriter/core/tohtml.py +++ b/novelwriter/core/tohtml.py @@ -451,7 +451,7 @@ class ToHtml(Tokenizer): def _formatKeywords(self, tText): """Apply HTML formatting to keywords. """ - isValid, theBits, _ = self.theParent.theIndex.scanThis("@"+tText) + isValid, theBits, _ = self.theProject.index.scanThis("@"+tText) if not isValid or not theBits: return "" diff --git a/novelwriter/core/tokenizer.py b/novelwriter/core/tokenizer.py index 271ffd2c..bb5f3d8e 100644 --- a/novelwriter/core/tokenizer.py +++ b/novelwriter/core/tokenizer.py @@ -275,7 +275,7 @@ class Tokenizer(ABC): def addRootHeading(self, theHandle): """Add a heading at the start of a new root folder. """ - if not self.theProject.projTree.checkType(theHandle, nwItemType.ROOT): + if not self.theProject.tree.checkType(theHandle, nwItemType.ROOT): return False if self._isFirst: @@ -284,7 +284,7 @@ class Tokenizer(ABC): else: textAlign = self.A_PBB | self.A_CENTRE - theItem = self.theProject.projTree[theHandle] + theItem = self.theProject.tree[theHandle] locNotes = self._localLookup("Notes") theTitle = f"{locNotes}: {theItem.itemName}" self._theTokens = [] @@ -301,7 +301,7 @@ class Tokenizer(ABC): not set, load it from the file. """ self._theHandle = theHandle - self._theItem = self.theProject.projTree[theHandle] + self._theItem = self.theProject.tree[theHandle] if self._theItem is None: return False diff --git a/novelwriter/core/tomd.py b/novelwriter/core/tomd.py index bd468f55..48d23a35 100644 --- a/novelwriter/core/tomd.py +++ b/novelwriter/core/tomd.py @@ -193,7 +193,7 @@ class ToMarkdown(Tokenizer): def _formatKeywords(self, tText, tStyle): """Apply Markdown formatting to keywords. """ - isValid, theBits, _ = self.theParent.theIndex.scanThis("@"+tText) + isValid, theBits, _ = self.theProject.index.scanThis("@"+tText) if not isValid or not theBits: return "" diff --git a/novelwriter/core/toodt.py b/novelwriter/core/toodt.py index c0b1daee..59eaf30f 100644 --- a/novelwriter/core/toodt.py +++ b/novelwriter/core/toodt.py @@ -550,7 +550,7 @@ class ToOdt(Tokenizer): def _formatKeywords(self, tText): """Apply formatting to keywords. """ - isValid, theBits, _ = self.theParent.theIndex.scanThis("@"+tText) + isValid, theBits, _ = self.theProject.index.scanThis("@"+tText) if not isValid or not theBits: return "" diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py index 7dbef4c6..10a7a78f 100644 --- a/novelwriter/core/tree.py +++ b/novelwriter/core/tree.py @@ -24,16 +24,15 @@ along with this program. If not, see . """ import os +import random import logging -from time import time from lxml import etree -from hashlib import sha256 from novelwriter.enum import nwItemType, nwItemClass, nwItemLayout from novelwriter.error import logException from novelwriter.common import checkHandle -from novelwriter.constants import nwConst, nwFiles +from novelwriter.constants import nwFiles from novelwriter.core.item import NWItem logger = logging.getLogger(__name__) @@ -41,21 +40,20 @@ logger = logging.getLogger(__name__) class NWTree(): + MAX_DEPTH = 1000 # Cap of tree traversing for loops + def __init__(self, theProject): self.theProject = theProject self._projTree = {} # Holds all the items of the project self._treeOrder = [] # The order of the tree items on the tree view - self._treeRoots = [] # The root items of the tree + self._treeRoots = {} # The root items of the tree self._trashRoot = None # The handle of the trash root folder self._archRoot = None # The handle of the archive root folder self._theIndex = 0 # The current iterator index self._treeChanged = False # True if tree structure has changed - self._handleSeed = None # Used for generating handles for testing - self._handleCount = 0 # A counter that is added to the handle generator - return ## @@ -67,7 +65,7 @@ class NWTree(): """ self._projTree = {} self._treeOrder = [] - self._treeRoots = [] + self._treeRoots = {} self._trashRoot = None self._archRoot = None self._theIndex = 0 @@ -98,18 +96,17 @@ class NWTree(): if nwItem.itemType == nwItemType.ROOT: logger.verbose("Item '%s' is a root item", str(tHandle)) - self._treeRoots.append(tHandle) + self._treeRoots[tHandle] = nwItem if nwItem.itemClass == nwItemClass.ARCHIVE: logger.verbose("Item '%s' is the archive folder", str(tHandle)) self._archRoot = tHandle - - if nwItem.itemType == nwItemType.TRASH: - if self._trashRoot is None: - logger.verbose("Item '%s' is the trash folder", str(tHandle)) - self._trashRoot = tHandle - else: - logger.error("Only one trash folder allowed") - return False + elif nwItem.itemClass == nwItemClass.TRASH: + if self._trashRoot is None: + logger.verbose("Item '%s' is the trash folder", str(tHandle)) + self._trashRoot = tHandle + else: + logger.error("Only one trash folder allowed") + return False self._projTree[tHandle] = nwItem self._treeOrder.append(tHandle) @@ -207,9 +204,30 @@ class NWTree(): return novelWords, noteWords ## - # Tree Structure Methods + # Tree Item Methods ## + def updateItemData(self, tHandle): + """Update the root item handle of a given item. Returns True if + a root was found and data updated, otherwise False. + """ + tItem = self.__getitem__(tHandle) + if tItem is None: + return False + + iItem = tItem + for _ in range(self.MAX_DEPTH): + if iItem.itemParent is None: + tItem.setRoot(iItem.itemHandle) + tItem.setClassDefaults(iItem.itemClass) + return True + else: + iItem = self.__getitem__(iItem.itemParent) + if iItem is None: + return False + else: + raise RecursionError("Critical internal error") + def checkType(self, tHandle, itemType): """Return true of item exists and is of the specified item type. """ @@ -218,71 +236,6 @@ class NWTree(): return False return tItem.itemType == itemType - def trashRoot(self): - """Returns the handle of the trash folder, or None if there - isn't one. - """ - if self._trashRoot: - return self._trashRoot - return None - - def isTrashRoot(self, tHandle): - """Check if a handle is the trash folder. - """ - if self._trashRoot is None: - return False - return tHandle == self._trashRoot - - def archiveRoot(self): - """Returns the handle of the archive folder, or None if there - isn't one. - """ - if self._archRoot: - return self._archRoot - return None - - def findRoot(self, theClass): - """Find the root item for a given class. - Note: This returns the first item for class CUSTOM. - """ - for aRoot in self._treeRoots: - tItem = self.__getitem__(aRoot) - if tItem is None: - continue - if theClass == tItem.itemClass: - return tItem.itemHandle - return None - - def checkRootUnique(self, theClass): - """Checks if there already is a root entry of class 'theClass' - in the root of the project tree. CUSTOM class is skipped as it - is not required to be unique. - """ - if theClass == nwItemClass.CUSTOM: - return True - for aRoot in self._treeRoots: - tItem = self.__getitem__(aRoot) - if tItem is None: - continue - if theClass == tItem.itemClass: - return False - return True - - def getRootItem(self, tHandle): - """Iterate upwards in the tree until we find the item with - parent None, the root item. We do this with a for loop with a - maximum depth to make infinite loops impossible. - """ - tItem = self.__getitem__(tHandle) - if tItem is not None: - for i in range(nwConst.MAX_DEPTH + 1): - if tItem.itemParent is None: - return tItem - else: - tHandle = tItem.itemParent - tItem = self.__getitem__(tHandle) - return None - def getItemPath(self, tHandle): """Iterate upwards in the tree until we find the item with parent None, the root item, and return the list of handles. @@ -293,7 +246,7 @@ class NWTree(): tItem = self.__getitem__(tHandle) if tItem is not None: tTree.append(tHandle) - for _ in range(nwConst.MAX_DEPTH + 1): + for _ in range(self.MAX_DEPTH): if tItem.itemParent is None: return tTree else: @@ -303,8 +256,72 @@ class NWTree(): return tTree else: tTree.append(tHandle) + else: + raise RecursionError("Critical internal error") + return tTree + ## + # Tree Root Methods + ## + + def rootClasses(self): + """Return a set of all root classes in use by the project. + """ + rootClasses = set() + for nwItem in self._treeRoots.values(): + rootClasses.add(nwItem.itemClass) + return rootClasses + + def iterRoots(self, itemClass): + """Iterate over all items of a given class. + """ + for tHandle, nwItem in self._treeRoots.items(): + if nwItem.itemClass == itemClass: + yield tHandle, nwItem + return + + def isRoot(self, tHandle): + """Check if a handle is a root item. + """ + return tHandle in self._treeRoots + + def isTrash(self, tHandle): + """Check if an item is in or is the trash folder. + """ + tItem = self.__getitem__(tHandle) + if tItem is None: + return True + if tItem.itemClass == nwItemClass.TRASH: + return True + if self._trashRoot is not None: + if tHandle == self._trashRoot: + return True + elif tItem.itemParent == self._trashRoot: + return True + elif tItem.itemRoot == self._trashRoot: + return True + return False + + def trashRoot(self): + """Returns the handle of the trash folder, or None if there + isn't one. + """ + if self._trashRoot: + return self._trashRoot + return None + + def findRoot(self, theClass): + """Find the first root item for a given class. + """ + for aRoot in self._treeRoots: + tItem = self.__getitem__(aRoot) + if tItem is None: + continue + if theClass == tItem.itemClass: + return tItem.itemHandle + return None + ## # Setters ## @@ -334,14 +351,6 @@ class NWTree(): return - def setSeed(self, theSeed): - """Used for debugging! - Sets a seed for generating handles so that they always come out - in a predictable order. - """ - self._handleSeed = theSeed - return - def setFileItemLayout(self, tHandle, itemLayout): """Set the nwItemLayout for a specific file. """ @@ -358,30 +367,6 @@ class NWTree(): return True - ## - # Getters - ## - - def countTypes(self): - """Count the number of files, folders and roots in the project. - """ - nRoot = 0 - nFolder = 0 - nFile = 0 - - for tHandle in self._treeOrder: - tItem = self.__getitem__(tHandle) - if tItem is None: - continue - elif tItem.itemType == nwItemType.ROOT: - nRoot += 1 - elif tItem.itemType == nwItemType.FOLDER: - nFolder += 1 - elif tItem.itemType == nwItemType.FILE: - nFile += 1 - - return nRoot, nFolder, nFile - ## # Meta Methods ## @@ -420,7 +405,7 @@ class NWTree(): return if tHandle in self._treeRoots: - self._treeRoots.remove(tHandle) + del self._treeRoots[tHandle] if tHandle == self._trashRoot: self._trashRoot = None if tHandle == self._archRoot: @@ -468,29 +453,16 @@ class NWTree(): self.theProject.setProjectChanged(True) return - def _makeHandle(self, addSeed=""): + def _makeHandle(self): """Generate a unique item handle. In the event that the key - already exists, salt the seed and generate a new handle. - A key collision is very unlikely to be caused by the truncation - of the sha256 hash to 13 characters. Assuming it is near-random, - it will on average happen every 4.5^15 times. However, the clock - seed is likely to occasionally generate a collision if the - handle requests come faster than the clock resolution. + already exists, generate a new one. """ - if self._handleSeed is None: - newSeed = "%s_%d_%s" % (str(time()), self._handleCount, addSeed) - self._handleCount += 1 - else: - # This is used for debugging - newSeed = str(self._handleSeed) - self._handleSeed += 1 - - logger.verbose("Generating handle with seed '%s'", newSeed) - itemHandle = sha256(newSeed.encode()).hexdigest()[0:13] - if itemHandle in self._projTree: + logger.verbose("Generating new handle") + handle = f"{random.getrandbits(52):013x}" + if handle in self._projTree: logger.warning("Duplicate handle encountered! Retrying ...") - itemHandle = self._makeHandle(addSeed+"!") + handle = self._makeHandle() - return itemHandle + return handle # END Class NWTree diff --git a/novelwriter/dialogs/about.py b/novelwriter/dialogs/about.py index 68b8f1ef..2bb26b97 100644 --- a/novelwriter/dialogs/about.py +++ b/novelwriter/dialogs/about.py @@ -181,14 +181,14 @@ class GuiAbout(QDialog): aboutMsg += "

{0}

{1}

".format( self.tr("Translations"), self._wrapTable([ + ("Deutsch", "Myian"), ("English", "Veronica Berglyd Olsen"), + ("Español Latinoamericano", "Tommy Marplatt"), ("Français", "Jan Lüdke (jyhelle)"), + ("Nederlands", "Martijn van der Kleijn"), ("Norsk Bokmål", "Veronica Berglyd Olsen"), ("Português", "Bruno Meneguello"), ("简体中文", "Qianzhi Long"), - ("Español Latinoamericano", "Tommy Marplatt"), - ("Nederlands", "Martijn van der Kleijn"), - ("Deutsch", "Myian"), ]) ) diff --git a/novelwriter/dialogs/docmerge.py b/novelwriter/dialogs/docmerge.py index f7695b9a..033f3698 100644 --- a/novelwriter/dialogs/docmerge.py +++ b/novelwriter/dialogs/docmerge.py @@ -125,13 +125,13 @@ class GuiDocMerge(QDialog): ), nwAlert.ERROR) return False - srcItem = self.theProject.projTree[self.sourceItem] + srcItem = self.theProject.tree[self.sourceItem] if srcItem is None: self.theParent.makeAlert(self.tr("Internal error."), nwAlert.ERROR) return False - nHandle = self.theProject.newFile(srcItem.itemName, srcItem.itemClass, srcItem.itemParent) - newItem = self.theProject.projTree[nHandle] + nHandle = self.theProject.newFile(srcItem.itemName, srcItem.itemParent) + newItem = self.theProject.tree[nHandle] newItem.setStatus(srcItem.itemStatus) newItem.setImport(srcItem.itemImport) @@ -170,7 +170,7 @@ class GuiDocMerge(QDialog): if tHandle is None: return False - nwItem = self.theProject.projTree[tHandle] + nwItem = self.theProject.tree[tHandle] if nwItem is None: return False @@ -182,7 +182,7 @@ class GuiDocMerge(QDialog): for sHandle in self.theParent.treeView.getTreeFromHandle(tHandle): newItem = QListWidgetItem() - nwItem = self.theProject.projTree[sHandle] + nwItem = self.theProject.tree[sHandle] if nwItem.itemType is not nwItemType.FILE: continue newItem.setText(nwItem.itemName) diff --git a/novelwriter/dialogs/docsplit.py b/novelwriter/dialogs/docsplit.py index cdf0757a..76e64d5f 100644 --- a/novelwriter/dialogs/docsplit.py +++ b/novelwriter/dialogs/docsplit.py @@ -33,8 +33,7 @@ from PyQt5.QtWidgets import ( ) from novelwriter.core import NWDoc -from novelwriter.enum import nwAlert, nwItemType, nwItemClass, nwItemLayout -from novelwriter.constants import nwConst +from novelwriter.enum import nwAlert, nwItemType from novelwriter.gui.custom import QHelpLabel logger = logging.getLogger(__name__) @@ -51,7 +50,6 @@ class GuiDocSplit(QDialog): self.mainConf = novelwriter.CONFIG self.theParent = theParent self.theProject = theParent.theProject - self.optState = theParent.theProject.optState self.sourceItem = None self.sourceText = [] @@ -76,7 +74,7 @@ class GuiDocSplit(QDialog): 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.optState.getInt("GuiDocSplit", "spLevel", 3) + self.theProject.options.getInt("GuiDocSplit", "spLevel", 3) ) if spIndex != -1: self.splitLevel.setCurrentIndex(spIndex) @@ -122,7 +120,7 @@ class GuiDocSplit(QDialog): ), nwAlert.ERROR) return False - srcItem = self.theProject.projTree[self.sourceItem] + srcItem = self.theProject.tree[self.sourceItem] if srcItem is None: self.theParent.makeAlert(self.tr( "Could not parse source document." @@ -160,16 +158,6 @@ class GuiDocSplit(QDialog): ), nwAlert.ERROR) return False - # Check that another folder can be created - parTree = self.theProject.projTree.getItemPath(srcItem.itemParent) - if len(parTree) >= nwConst.MAX_DEPTH - 1: - self.theParent.makeAlert(self.tr( - "Cannot add new folder for the document split. " - "Maximum folder depth has been reached. " - "Please move the file to another level in the project tree." - ), nwAlert.ERROR) - return False - msgYes = self.theParent.askQuestion( self.tr("Split Document"), "{0}

{1}".format( @@ -186,22 +174,16 @@ class GuiDocSplit(QDialog): return False # Create the folder - fHandle = self.theProject.newFolder( - srcItem.itemName, srcItem.itemClass, srcItem.itemParent - ) + fHandle = self.theProject.newFolder(srcItem.itemName, srcItem.itemParent) self.theParent.treeView.revealNewTreeItem(fHandle) logger.verbose("Creating folder '%s'", fHandle) # Loop through, and create the files for wTitle, iStart, iEnd in finalOrder: - isNovel = srcItem.itemClass == nwItemClass.NOVEL - itemLayout = nwItemLayout.DOCUMENT if isNovel else nwItemLayout.NOTE - wTitle = wTitle.lstrip("#").strip() - nHandle = self.theProject.newFile(wTitle, srcItem.itemClass, fHandle) - newItem = self.theProject.projTree[nHandle] - newItem.setLayout(itemLayout) + nHandle = self.theProject.newFile(wTitle, fHandle) + newItem = self.theProject.tree[nHandle] newItem.setStatus(srcItem.itemStatus) newItem.setImport(srcItem.itemImport) logger.verbose( @@ -228,7 +210,7 @@ class GuiDocSplit(QDialog): def _doClose(self): """Close the dialog window without doing anything. """ - self.optState.saveSettings() + self.theProject.options.saveSettings() self.close() return @@ -249,7 +231,7 @@ class GuiDocSplit(QDialog): if self.sourceItem is None: return False - nwItem = self.theProject.projTree[self.sourceItem] + nwItem = self.theProject.tree[self.sourceItem] if nwItem is None: return False @@ -266,7 +248,7 @@ class GuiDocSplit(QDialog): return False spLevel = self.splitLevel.currentData() - self.optState.setValue("GuiDocSplit", "spLevel", spLevel) + self.theProject.options.setValue("GuiDocSplit", "spLevel", spLevel) logger.debug( "Scanning document '%s' for headings level <= %d", self.sourceItem, spLevel diff --git a/novelwriter/dialogs/itemeditor.py b/novelwriter/dialogs/itemeditor.py index a39d93f4..acf07134 100644 --- a/novelwriter/dialogs/itemeditor.py +++ b/novelwriter/dialogs/itemeditor.py @@ -33,7 +33,7 @@ from PyQt5.QtWidgets import ( ) from novelwriter.enum import nwItemLayout, nwItemType -from novelwriter.constants import trConst, nwLists, nwLabels +from novelwriter.constants import trConst, nwLabels from novelwriter.gui.custom import QSwitch logger = logging.getLogger(__name__) @@ -55,7 +55,7 @@ class GuiItemEditor(QDialog): # Build GUI ## - self.theItem = self.theProject.projTree[tHandle] + self.theItem = self.theProject.tree[tHandle] if self.theItem is None: self.close() return @@ -74,19 +74,28 @@ class GuiItemEditor(QDialog): # Item Status self.editStatus = QComboBox() self.editStatus.setMinimumWidth(mVd) - if self.theItem.itemClass in nwLists.CLS_NOVEL: - for sLabel, _, _, sIcon in self.theProject.statusItems: - self.editStatus.addItem(sIcon, sLabel, sLabel) + if self.theItem.isNovelLike(): + for key, entry in self.theProject.statusItems.items(): + self.editStatus.addItem(entry["icon"], entry["name"], key) + + index = self.editStatus.findData(self.theItem.itemStatus) + if index != -1: + self.editStatus.setCurrentIndex(index) + else: - for sLabel, _, _, sIcon in self.theProject.importItems: - self.editStatus.addItem(sIcon, sLabel, sLabel) + for key, entry in self.theProject.importItems.items(): + self.editStatus.addItem(entry["icon"], entry["name"], key) + + index = self.editStatus.findData(self.theItem.itemImport) + if index != -1: + self.editStatus.setCurrentIndex(index) # Item Layout self.editLayout = QComboBox() self.editLayout.setMinimumWidth(mVd) validLayouts = [] if self.theItem.itemType == nwItemType.FILE: - if self.theItem.itemClass in nwLists.CLS_NOVEL: + if self.theItem.documentAllowed(): validLayouts.append(nwItemLayout.DOCUMENT) validLayouts.append(nwItemLayout.NOTE) else: @@ -97,6 +106,10 @@ class GuiItemEditor(QDialog): if itemLayout in validLayouts: self.editLayout.addItem(trConst(nwLabels.LAYOUT_NAME[itemLayout]), itemLayout) + index = self.editLayout.findData(self.theItem.itemLayout) + if index != -1: + self.editLayout.setCurrentIndex(index) + # Export Switch self.textExport = QLabel(self.tr("Include when building project")) self.editExport = QSwitch() @@ -116,15 +129,6 @@ class GuiItemEditor(QDialog): self.editName.setText(self.theItem.itemName) self.editName.selectAll() - currStatus, _ = self.theItem.getImportStatus() - statusIdx = self.editStatus.findData(currStatus) - if statusIdx != -1: - self.editStatus.setCurrentIndex(statusIdx) - - layoutIdx = self.editLayout.findData(self.theItem.itemLayout) - if layoutIdx != -1: - self.editLayout.setCurrentIndex(layoutIdx) - ## # Assemble ## diff --git a/novelwriter/dialogs/preferences.py b/novelwriter/dialogs/preferences.py index 81bef52a..d38a4ab6 100644 --- a/novelwriter/dialogs/preferences.py +++ b/novelwriter/dialogs/preferences.py @@ -203,6 +203,22 @@ class GuiPreferencesGeneral(QWidget): self.tr("Requires restart.") ) + # Editor Theme + self.guiSyntax = QComboBox() + self.guiSyntax.setMinimumWidth(self.mainConf.pxInt(200)) + self.theSyntaxes = self.theTheme.listSyntax() + for syntaxFile, syntaxName in self.theSyntaxes: + self.guiSyntax.addItem(syntaxName, syntaxFile) + syntaxIdx = self.guiSyntax.findData(self.mainConf.guiSyntax) + if syntaxIdx != -1: + self.guiSyntax.setCurrentIndex(syntaxIdx) + + self.mainForm.addRow( + self.tr("Editor theme"), + self.guiSyntax, + self.tr("Colour theme for the editor and viewer.") + ) + # Font Family self.guiFont = QLineEdit() self.guiFont.setReadOnly(True) @@ -275,6 +291,7 @@ class GuiPreferencesGeneral(QWidget): guiLang = self.guiLang.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() @@ -294,6 +311,7 @@ class GuiPreferencesGeneral(QWidget): self.mainConf.guiLang = guiLang self.mainConf.guiTheme = guiTheme self.mainConf.guiIcons = guiIcons + self.mainConf.guiSyntax = guiSyntax self.mainConf.guiFont = guiFont self.mainConf.guiFontSize = guiFontSize self.mainConf.emphLabels = emphLabels @@ -834,25 +852,6 @@ class GuiPreferencesSyntax(QWidget): self.mainForm.setHelpTextStyle(self.theTheme.helpText) self.setLayout(self.mainForm) - # Highlighting Theme - # ================== - self.mainForm.addGroupLabel(self.tr("Highlighting Theme")) - - self.guiSyntax = QComboBox() - self.guiSyntax.setMinimumWidth(self.mainConf.pxInt(200)) - self.theSyntaxes = self.theTheme.listSyntax() - for syntaxFile, syntaxName in self.theSyntaxes: - self.guiSyntax.addItem(syntaxName, syntaxFile) - syntaxIdx = self.guiSyntax.findData(self.mainConf.guiSyntax) - if syntaxIdx != -1: - self.guiSyntax.setCurrentIndex(syntaxIdx) - - self.mainForm.addRow( - self.tr("Highlighting theme"), - self.guiSyntax, - self.tr("Colour theme for the editor and viewer.") - ) - # Quotes & Dialogue # ================= self.mainForm.addGroupLabel(self.tr("Quotes & Dialogue")) @@ -902,7 +901,7 @@ class GuiPreferencesSyntax(QWidget): self.showMultiSpaces = QSwitch() self.showMultiSpaces.setChecked(self.mainConf.showMultiSpaces) self.mainForm.addRow( - self.tr("Highlight multiple spaces"), + self.tr("Highlight multiple or trailing spaces"), self.showMultiSpaces, self.tr("Applies to the document editor only.") ) @@ -912,9 +911,6 @@ class GuiPreferencesSyntax(QWidget): def saveValues(self): """Save the values set for this tab. """ - # Highlighting Theme - self.mainConf.guiSyntax = self.guiSyntax.currentData() - # Quotes & Dialogue self.mainConf.highlightQuotes = self.highlightQuotes.isChecked() self.mainConf.allowOpenSQuote = self.allowOpenSQuote.isChecked() diff --git a/novelwriter/dialogs/projdetails.py b/novelwriter/dialogs/projdetails.py index 54b6c995..258b82e4 100644 --- a/novelwriter/dialogs/projdetails.py +++ b/novelwriter/dialogs/projdetails.py @@ -52,18 +52,18 @@ class GuiProjectDetails(PagedDialog): self.mainConf = novelwriter.CONFIG self.theParent = theParent self.theProject = theParent.theProject - self.optState = theParent.theProject.optState self.setWindowTitle(self.tr("Project Details")) wW = self.mainConf.pxInt(600) wH = self.mainConf.pxInt(400) + pOptions = self.theProject.options self.setMinimumWidth(wW) self.setMinimumHeight(wH) self.resize( - self.mainConf.pxInt(self.optState.getInt("GuiProjectDetails", "winWidth", wW)), - self.mainConf.pxInt(self.optState.getInt("GuiProjectDetails", "winHeight", wH)) + self.mainConf.pxInt(pOptions.getInt("GuiProjectDetails", "winWidth", wW)), + self.mainConf.pxInt(pOptions.getInt("GuiProjectDetails", "winHeight", wH)) ) self.tabMain = GuiProjectDetailsMain(self.theParent, self.theProject) @@ -120,16 +120,17 @@ class GuiProjectDetails(PagedDialog): countFrom = self.tabContents.poValue.value() clearDouble = self.tabContents.dblValue.isChecked() - self.optState.setValue("GuiProjectDetails", "winWidth", winWidth) - self.optState.setValue("GuiProjectDetails", "winHeight", winHeight) - self.optState.setValue("GuiProjectDetails", "widthCol0", widthCol0) - self.optState.setValue("GuiProjectDetails", "widthCol1", widthCol1) - self.optState.setValue("GuiProjectDetails", "widthCol2", widthCol2) - self.optState.setValue("GuiProjectDetails", "widthCol3", widthCol3) - self.optState.setValue("GuiProjectDetails", "widthCol4", widthCol4) - self.optState.setValue("GuiProjectDetails", "wordsPerPage", wordsPerPage) - self.optState.setValue("GuiProjectDetails", "countFrom", countFrom) - self.optState.setValue("GuiProjectDetails", "clearDouble", clearDouble) + pOptions = self.theProject.options + pOptions.setValue("GuiProjectDetails", "winWidth", winWidth) + pOptions.setValue("GuiProjectDetails", "winHeight", winHeight) + pOptions.setValue("GuiProjectDetails", "widthCol0", widthCol0) + pOptions.setValue("GuiProjectDetails", "widthCol1", widthCol1) + pOptions.setValue("GuiProjectDetails", "widthCol2", widthCol2) + pOptions.setValue("GuiProjectDetails", "widthCol3", widthCol3) + pOptions.setValue("GuiProjectDetails", "widthCol4", widthCol4) + pOptions.setValue("GuiProjectDetails", "wordsPerPage", wordsPerPage) + pOptions.setValue("GuiProjectDetails", "countFrom", countFrom) + pOptions.setValue("GuiProjectDetails", "clearDouble", clearDouble) return @@ -145,7 +146,6 @@ class GuiProjectDetailsMain(QWidget): self.theParent = theParent self.theProject = theProject self.theTheme = theParent.theTheme - self.theIndex = theParent.theIndex fPx = self.theTheme.fontPixelSize fPt = self.theTheme.fontPointSize @@ -245,8 +245,9 @@ class GuiProjectDetailsMain(QWidget): def updateValues(self): """Set all the values. """ - hCounts = self.theIndex.getNovelTitleCounts() - nwCount = self.theIndex.getNovelWordCount() + pIndex = self.theProject.index + hCounts = pIndex.getNovelTitleCounts() + nwCount = pIndex.getNovelWordCount() edTime = self.theProject.getCurrentEditTime() self.wordCountVal.setText(f"{nwCount:n}") @@ -277,8 +278,6 @@ class GuiProjectDetailsContents(QWidget): self.theParent = theParent self.theProject = theProject self.theTheme = theParent.theTheme - self.theIndex = theParent.theIndex - self.optState = theProject.optState # Internal self._theToC = [] @@ -286,6 +285,7 @@ class GuiProjectDetailsContents(QWidget): iPx = self.theTheme.baseIconSize hPx = self.mainConf.pxInt(12) vPx = self.mainConf.pxInt(4) + pOptions = self.theProject.options # Contents Tree # ============= @@ -314,11 +314,11 @@ class GuiProjectDetailsContents(QWidget): treeHeader.setStretchLastSection(True) treeHeader.setMinimumSectionSize(hPx) - wCol0 = self.mainConf.pxInt(self.optState.getInt("GuiProjectDetails", "widthCol0", 200)) - wCol1 = self.mainConf.pxInt(self.optState.getInt("GuiProjectDetails", "widthCol1", 60)) - wCol2 = self.mainConf.pxInt(self.optState.getInt("GuiProjectDetails", "widthCol2", 60)) - wCol3 = self.mainConf.pxInt(self.optState.getInt("GuiProjectDetails", "widthCol3", 60)) - wCol4 = self.mainConf.pxInt(self.optState.getInt("GuiProjectDetails", "widthCol4", 90)) + wCol0 = self.mainConf.pxInt(pOptions.getInt("GuiProjectDetails", "widthCol0", 200)) + wCol1 = self.mainConf.pxInt(pOptions.getInt("GuiProjectDetails", "widthCol1", 60)) + wCol2 = self.mainConf.pxInt(pOptions.getInt("GuiProjectDetails", "widthCol2", 60)) + wCol3 = self.mainConf.pxInt(pOptions.getInt("GuiProjectDetails", "widthCol3", 60)) + wCol4 = self.mainConf.pxInt(pOptions.getInt("GuiProjectDetails", "widthCol4", 90)) self.tocTree.setColumnWidth(0, wCol0) self.tocTree.setColumnWidth(1, wCol1) @@ -330,9 +330,9 @@ class GuiProjectDetailsContents(QWidget): # Options # ======= - wordsPerPage = self.optState.getInt("GuiProjectDetails", "wordsPerPage", 350) - countFrom = self.optState.getInt("GuiProjectDetails", "countFrom", 1) - clearDouble = self.optState.getInt("GuiProjectDetails", "clearDouble", True) + wordsPerPage = pOptions.getInt("GuiProjectDetails", "wordsPerPage", 350) + countFrom = pOptions.getInt("GuiProjectDetails", "countFrom", 1) + clearDouble = pOptions.getInt("GuiProjectDetails", "clearDouble", True) wordsHelp = ( self.tr("Typical word count for a 5 by 8 inch book page with 11 pt font is 350.") @@ -424,7 +424,7 @@ class GuiProjectDetailsContents(QWidget): """Extract the data for the tree. """ self._theToC = [] - self._theToC = self.theIndex.getTableOfContents(2) + self._theToC = self.theProject.index.getTableOfContents(2) self._theToC.append(("", 0, self.tr("END"), 0)) return diff --git a/novelwriter/dialogs/projsettings.py b/novelwriter/dialogs/projsettings.py index f6e4b611..8bbdcee3 100644 --- a/novelwriter/dialogs/projsettings.py +++ b/novelwriter/dialogs/projsettings.py @@ -35,6 +35,7 @@ from PyQt5.QtWidgets import ( ) from novelwriter.enum import nwAlert +from novelwriter.common import simplified from novelwriter.gui.custom import QSwitch, PagedDialog, QConfigLayout logger = logging.getLogger(__name__) @@ -51,19 +52,19 @@ class GuiProjectSettings(PagedDialog): self.mainConf = novelwriter.CONFIG self.theParent = theParent self.theProject = theParent.theProject - self.optState = theParent.theProject.optState self.theProject.countStatus() self.setWindowTitle(self.tr("Project Settings")) wW = self.mainConf.pxInt(570) wH = self.mainConf.pxInt(375) + pOptions = self.theProject.options self.setMinimumWidth(wW) self.setMinimumHeight(wH) self.resize( - self.mainConf.pxInt(self.optState.getInt("GuiProjectSettings", "winWidth", wW)), - self.mainConf.pxInt(self.optState.getInt("GuiProjectSettings", "winHeight", wH)) + self.mainConf.pxInt(pOptions.getInt("GuiProjectSettings", "winWidth", wW)), + self.mainConf.pxInt(pOptions.getInt("GuiProjectSettings", "winHeight", wH)) ) self.tabMain = GuiProjectEditMain(self.theParent, self.theProject) @@ -81,6 +82,9 @@ class GuiProjectSettings(PagedDialog): self.buttonBox.rejected.connect(self._doClose) self.addControls(self.buttonBox) + # Flags + self.spellChanged = False + logger.debug("GuiProjectSettings initialisation complete") return @@ -103,16 +107,18 @@ class GuiProjectSettings(PagedDialog): self.theProject.setProjectName(projName) self.theProject.setBookTitle(bookTitle) self.theProject.setBookAuthors(bookAuthors) - self.theProject.setSpellLang(spellLang) self.theProject.setProjBackup(doBackup) + # Remember this as updating spell dictionary can be expensive + self.spellChanged = self.theProject.setSpellLang(spellLang) + if self.tabStatus.colChanged: - statusCol = self.tabStatus.getNewList() - self.theProject.setStatusColours(statusCol) + newList, delList = self.tabStatus.getNewList() + self.theProject.setStatusColours(newList, delList) if self.tabImport.colChanged: - importCol = self.tabImport.getNewList() - self.theProject.setImportColours(importCol) + newList, delList = self.tabImport.getNewList() + self.theProject.setImportColours(newList, delList) if self.tabStatus.colChanged or self.tabImport.colChanged: self.theParent.rebuildTrees() @@ -146,11 +152,12 @@ class GuiProjectSettings(PagedDialog): statusColW = self.mainConf.rpxInt(self.tabStatus.listBox.columnWidth(0)) importColW = self.mainConf.rpxInt(self.tabImport.listBox.columnWidth(0)) - self.optState.setValue("GuiProjectSettings", "winWidth", winWidth) - self.optState.setValue("GuiProjectSettings", "winHeight", winHeight) - self.optState.setValue("GuiProjectSettings", "replaceColW", replaceColW) - self.optState.setValue("GuiProjectSettings", "statusColW", statusColW) - self.optState.setValue("GuiProjectSettings", "importColW", importColW) + pOptions = self.theProject.options + pOptions.setValue("GuiProjectSettings", "winWidth", winWidth) + pOptions.setValue("GuiProjectSettings", "winHeight", winHeight) + pOptions.setValue("GuiProjectSettings", "replaceColW", replaceColW) + pOptions.setValue("GuiProjectSettings", "statusColW", statusColW) + pOptions.setValue("GuiProjectSettings", "importColW", importColW) return @@ -181,7 +188,7 @@ class GuiProjectEditMain(QWidget): self.editName.setMaximumWidth(xW) self.editName.setText(self.theProject.projName) self.mainForm.addRow( - self.tr("Working title"), + self.tr("Project name"), self.editName, self.tr("Should be set only once.") ) @@ -245,13 +252,16 @@ class GuiProjectEditStatus(QWidget): COL_LABEL = 0 COL_USAGE = 1 + KEY_ROLE = Qt.UserRole + COL_ROLE = Qt.UserRole + 1 + NUM_ROLE = Qt.UserRole + 2 + def __init__(self, theParent, theProject, isStatus): QWidget.__init__(self, theParent) self.mainConf = novelwriter.CONFIG self.theParent = theParent self.theProject = theProject - self.optState = theProject.optState self.theTheme = theParent.theTheme if isStatus: @@ -264,13 +274,12 @@ class GuiProjectEditStatus(QWidget): colSetting = "importColW" wCol0 = self.mainConf.pxInt( - self.optState.getInt("GuiProjectSettings", colSetting, 130) + self.theProject.options.getInt("GuiProjectSettings", colSetting, 130) ) - self.colData = [] - self.colCounts = [] + self.colDeleted = [] self.colChanged = False - self.selColour = None + self.selColour = QColor(100, 100, 100) self.iPx = self.theTheme.baseIconSize @@ -285,8 +294,8 @@ class GuiProjectEditStatus(QWidget): self.listBox.setColumnWidth(self.COL_LABEL, wCol0) self.listBox.setIndentation(0) - for iName, iCol, nUse, _ in self.theStatus: - self._addItem(iName, iCol, iName, nUse) + for key, entry in self.theStatus.items(): + self._addItem(key, entry["name"], entry["cols"], entry["count"]) # List Controls # ============= @@ -297,6 +306,12 @@ class GuiProjectEditStatus(QWidget): self.delButton = QPushButton(self.theTheme.getIcon("remove"), "") self.delButton.clicked.connect(self._delItem) + self.upButton = QPushButton(self.theTheme.getIcon("up"), "") + self.upButton.clicked.connect(lambda: self._moveItem(-1)) + + self.dnButton = QPushButton(self.theTheme.getIcon("down"), "") + self.dnButton.clicked.connect(lambda: self._moveItem(1)) + # Edit Form # ========= @@ -306,7 +321,7 @@ class GuiProjectEditStatus(QWidget): self.editName.setPlaceholderText(self.tr("Select item to edit")) self.colPixmap = QPixmap(self.iPx, self.iPx) - self.colPixmap.fill(QColor(120, 120, 120)) + self.colPixmap.fill(QColor(100, 100, 100)) self.colButton = QPushButton(QIcon(self.colPixmap), self.tr("Colour")) self.colButton.setIconSize(self.colPixmap.rect().size()) self.colButton.clicked.connect(self._selectColour) @@ -320,6 +335,8 @@ class GuiProjectEditStatus(QWidget): self.listControls = QVBoxLayout() self.listControls.addWidget(self.addButton) self.listControls.addWidget(self.delButton) + self.listControls.addWidget(self.upButton) + self.listControls.addWidget(self.dnButton) self.listControls.addStretch(1) self.editBox = QHBoxLayout() @@ -349,12 +366,15 @@ class GuiProjectEditStatus(QWidget): if self.colChanged: newList = [] for n in range(self.listBox.topLevelItemCount()): - nItem = self.listBox.topLevelItem(n) - nIdx = nItem.data(self.COL_LABEL, Qt.UserRole) - newList.append(self.colData[nIdx]) - return newList + 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), + }) + return newList, self.colDeleted - return None + return [], [] ## # User Actions @@ -369,16 +389,16 @@ class GuiProjectEditStatus(QWidget): ) if newCol.isValid(): self.selColour = newCol - colPixmap = QPixmap(self.iPx, self.iPx) - colPixmap.fill(newCol) - self.colButton.setIcon(QIcon(colPixmap)) - self.colButton.setIconSize(colPixmap.rect().size()) + pixmap = QPixmap(self.iPx, self.iPx) + pixmap.fill(newCol) + self.colButton.setIcon(QIcon(pixmap)) + self.colButton.setIconSize(pixmap.rect().size()) return def _newItem(self): """Create a new status item. """ - newItem = self._addItem(self.tr("New Item"), (0, 0, 0), None, 0) + newItem = self._addItem(None, self.tr("New Item"), (100, 100, 100), 0) newItem.setBackground(self.COL_LABEL, QBrush(QColor(0, 255, 0, 70))) newItem.setBackground(self.COL_USAGE, QBrush(QColor(0, 255, 0, 70))) self.colChanged = True @@ -390,14 +410,14 @@ class GuiProjectEditStatus(QWidget): selItem = self._getSelectedItem() if selItem is not None: iRow = self.listBox.indexOfTopLevelItem(selItem) - selIdx = selItem.data(self.COL_LABEL, Qt.UserRole) - if self.colCounts[selIdx] == 0: - self.listBox.takeTopLevelItem(iRow) - self.colChanged = True - else: + if selItem.data(self.COL_LABEL, self.NUM_ROLE) > 0: self.theParent.makeAlert(self.tr( "Cannot delete a status item that is in use." ), nwAlert.ERROR) + else: + self.listBox.takeTopLevelItem(iRow) + self.colDeleted.append(selItem.data(self.COL_LABEL, self.KEY_ROLE)) + self.colChanged = True return def _saveItem(self): @@ -405,53 +425,75 @@ class GuiProjectEditStatus(QWidget): """ selItem = self._getSelectedItem() if selItem is not None: - selIdx = selItem.data(self.COL_LABEL, Qt.UserRole) - self.colData[selIdx] = ( - self.editName.text().strip(), - self.selColour.red(), - self.selColour.green(), - self.selColour.blue(), - self.colData[selIdx][4] - ) - selItem.setText(self.COL_LABEL, self.colData[selIdx][0]) - selItem.setText(self.COL_USAGE, self._usageString(self.colCounts[selIdx])) + selItem.setText(self.COL_LABEL, simplified(self.editName.text())) selItem.setIcon(self.COL_LABEL, self.colButton.icon()) + selItem.setData(self.COL_LABEL, self.COL_ROLE, ( + self.selColour.red(), self.selColour.green(), self.selColour.blue() + )) self.editName.setEnabled(False) self.colChanged = True return - def _addItem(self, iName, iCol, oName, nUse): + def _addItem(self, key, name, cols, count): """Add a status item to the list. """ - newIcon = QPixmap(self.iPx, self.iPx) - newIcon.fill(QColor(*iCol)) - newItem = QTreeWidgetItem() - newItem.setText(self.COL_LABEL, iName) - newItem.setText(self.COL_USAGE, self._usageString(nUse)) - newItem.setIcon(self.COL_LABEL, QIcon(newIcon)) - newItem.setData(self.COL_LABEL, Qt.UserRole, len(self.colData)) - self.listBox.addTopLevelItem(newItem) - self.colData.append((iName, iCol[0], iCol[1], iCol[2], oName)) - self.colCounts.append(nUse) - return newItem + pixmap = QPixmap(self.iPx, self.iPx) + pixmap.fill(QColor(*cols)) + + item = QTreeWidgetItem() + item.setText(self.COL_LABEL, name) + item.setIcon(self.COL_LABEL, QIcon(pixmap)) + item.setData(self.COL_LABEL, self.KEY_ROLE, key) + item.setData(self.COL_LABEL, self.COL_ROLE, cols) + item.setData(self.COL_LABEL, self.NUM_ROLE, count) + item.setText(self.COL_USAGE, self._usageString(count)) + + self.listBox.addTopLevelItem(item) + + return item + + def _moveItem(self, step): + """Move and item up or down step. + """ + selItem = self._getSelectedItem() + if selItem is None: + return + + tIndex = self.listBox.indexOfTopLevelItem(selItem) + nChild = self.listBox.topLevelItemCount() + nIndex = tIndex + step + if nIndex < 0 or nIndex >= nChild: + return + + cItem = self.listBox.takeTopLevelItem(tIndex) + self.listBox.insertTopLevelItem(nIndex, cItem) + self.listBox.clearSelection() + + cItem.setSelected(True) + self.colChanged = True + + return def _selectedItem(self): """Extract the info of a selected item and populate the settings boxes and button. """ selItem = self._getSelectedItem() - if selItem is not None: - selIdx = selItem.data(self.COL_LABEL, Qt.UserRole) - selVal = self.colData[selIdx] - self.selColour = QColor(selVal[1], selVal[2], selVal[3]) - newIcon = QPixmap(self.iPx, self.iPx) - newIcon.fill(self.selColour) - self.editName.setText(selVal[0]) - self.colButton.setIcon(QIcon(newIcon)) - self.editName.setEnabled(True) - self.editName.selectAll() - self.editName.setFocus() + if selItem is None: + return + + cols = selItem.data(self.COL_LABEL, self.COL_ROLE) + name = selItem.text(self.COL_LABEL) + + pixmap = QPixmap(self.iPx, self.iPx) + pixmap.fill(QColor(*cols)) + self.selColour = QColor(*cols) + self.editName.setText(name) + self.colButton.setIcon(QIcon(pixmap)) + self.editName.setEnabled(True) + self.editName.selectAll() + self.editName.setFocus() return @@ -467,12 +509,6 @@ class GuiProjectEditStatus(QWidget): return selItem[0] return None - def _rowsMoved(self): - """A row has been moved, so set the changed flag. - """ - self.colChanged = True - return - def _usageString(self, nUse): """Generate usage string. """ @@ -498,11 +534,10 @@ class GuiProjectEditReplace(QWidget): self.theParent = theParent self.theTheme = theParent.theTheme self.theProject = theProject - self.optState = theProject.optState self.arChanged = False wCol0 = self.mainConf.pxInt( - self.optState.getInt("GuiProjectSettings", "replaceColW", 130) + self.theProject.options.getInt("GuiProjectSettings", "replaceColW", 130) ) pageLabel = self.tr("Text Replace List for Preview and Export") diff --git a/novelwriter/dialogs/wordlist.py b/novelwriter/dialogs/wordlist.py index 7dadf258..77a4fb5a 100644 --- a/novelwriter/dialogs/wordlist.py +++ b/novelwriter/dialogs/wordlist.py @@ -52,19 +52,19 @@ class GuiWordList(QDialog): self.theParent = theParent self.theTheme = theParent.theTheme self.theProject = theParent.theProject - self.optState = theParent.theProject.optState self.setWindowTitle(self.tr("Project Word List")) mS = self.mainConf.pxInt(250) wW = self.mainConf.pxInt(320) wH = self.mainConf.pxInt(340) + pOptions = self.theProject.options self.setMinimumWidth(mS) self.setMinimumHeight(mS) self.resize( - self.mainConf.pxInt(self.optState.getInt("GuiWordList", "winWidth", wW)), - self.mainConf.pxInt(self.optState.getInt("GuiWordList", "winHeight", wH)) + self.mainConf.pxInt(pOptions.getInt("GuiWordList", "winWidth", wW)), + self.mainConf.pxInt(pOptions.getInt("GuiWordList", "winHeight", wH)) ) # Main Widgets @@ -207,8 +207,9 @@ class GuiWordList(QDialog): winWidth = self.mainConf.rpxInt(self.width()) winHeight = self.mainConf.rpxInt(self.height()) - self.optState.setValue("GuiWordList", "winWidth", winWidth) - self.optState.setValue("GuiWordList", "winHeight", winHeight) + pOptions = self.theProject.options + pOptions.setValue("GuiWordList", "winWidth", winWidth) + pOptions.setValue("GuiWordList", "winHeight", winHeight) return diff --git a/novelwriter/enum.py b/novelwriter/enum.py index 3360d8c5..48b894ba 100644 --- a/novelwriter/enum.py +++ b/novelwriter/enum.py @@ -32,7 +32,6 @@ class nwItemType(Enum): ROOT = 1 FOLDER = 2 FILE = 3 - TRASH = 4 # END Enum nwItemType @@ -63,6 +62,14 @@ class nwItemLayout(Enum): # END Enum nwItemLayout +class nwDocMode(Enum): + + VIEW = 0 + EDIT = 1 + +# END Enum nwDocMode + + class nwDocAction(Enum): NO_ACTION = 0 @@ -131,6 +138,16 @@ class nwState(Enum): # END Enum nwState +class nwView(Enum): + + EDITOR = 0 + PROJECT = 1 + NOVEL = 2 + OUTLINE = 3 + +# END Enum nwView + + class nwWidget(Enum): TREE = 1 diff --git a/novelwriter/error.py b/novelwriter/error.py index 0dec56e8..2ceae1e3 100644 --- a/novelwriter/error.py +++ b/novelwriter/error.py @@ -87,6 +87,8 @@ class NWErrorMessage(QDialog): self.mainBox.addWidget(self.btnBox, 2, 0, 1, 2) self.mainBox.setSpacing(16) + # Pick a random window title from a set of error messages by + # Hex, the computer, from Discworld self.setWindowTitle([ "+++ Out of Cheese Error +++", "+++ Divide by Cucumber Error +++", diff --git a/novelwriter/gui/__init__.py b/novelwriter/gui/__init__.py index 405549cb..e3560a99 100644 --- a/novelwriter/gui/__init__.py +++ b/novelwriter/gui/__init__.py @@ -25,10 +25,10 @@ from novelwriter.gui.itemdetails import GuiItemDetails from novelwriter.gui.mainmenu import GuiMainMenu from novelwriter.gui.noveltree import GuiNovelTree from novelwriter.gui.outline import GuiOutline -from novelwriter.gui.outlinedetails import GuiOutlineDetails from novelwriter.gui.projtree import GuiProjectTree from novelwriter.gui.statusbar import GuiMainStatus from novelwriter.gui.theme import GuiTheme +from novelwriter.gui.viewsbar import GuiViewsBar __all__ = [ "GuiDocEditor", @@ -39,7 +39,7 @@ __all__ = [ "GuiMainStatus", "GuiNovelTree", "GuiOutline", - "GuiOutlineDetails", "GuiProjectTree", "GuiTheme", + "GuiViewsBar", ] diff --git a/novelwriter/gui/custom.py b/novelwriter/gui/custom.py index 16d26b40..62dd3c91 100644 --- a/novelwriter/gui/custom.py +++ b/novelwriter/gui/custom.py @@ -409,10 +409,10 @@ class PagedDialog(QDialog): return - def addTab(self, tabWidget, tabLabel): + def addTab(self, widget, label): """Forwards the adding of tabs to the QTabWidget. """ - self._tabBox.addTab(tabWidget, tabLabel) + self._tabBox.addTab(widget, label) return def addControls(self, buttonBar): @@ -431,15 +431,15 @@ class VerticalTabBar(QTabBar): self._mW = novelwriter.CONFIG.pxInt(150) return - def tabSizeHint(self, theIndex): + def tabSizeHint(self, index): """Returns a transposed size hint for the rotated bar. """ - tSize = QTabBar.tabSizeHint(self, theIndex) + tSize = QTabBar.tabSizeHint(self, index) tSize.transpose() tSize.setWidth(min(tSize.width(), self._mW)) return tSize - def paintEvent(self, theEvent): + def paintEvent(self, event): """Custom implementation of the label painter that rotates the label 90 degrees. """ diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index 64c2b73d..5ede17f1 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -33,6 +33,7 @@ import bisect import logging import novelwriter +from enum import Enum from time import time from PyQt5.QtCore import ( @@ -50,7 +51,7 @@ from PyQt5.QtWidgets import ( ) from novelwriter.core import NWDoc, NWSpellEnchant, countWords -from novelwriter.enum import nwAlert, nwDocAction, nwDocInsert +from novelwriter.enum import nwAlert, nwDocAction, nwDocInsert, nwDocMode from novelwriter.common import transferCase from novelwriter.constants import nwConst, nwKeyWords, nwUnicode from novelwriter.gui.dochighlight import GuiDocHighlighter @@ -69,6 +70,7 @@ class GuiDocEditor(QTextEdit): spellDictionaryChanged = pyqtSignal(str, str) docEditedStatusChanged = pyqtSignal(bool) docCountsChanged = pyqtSignal(str, int, int, int) + loadDocumentTagRequest = pyqtSignal(str, Enum) def __init__(self, theParent): QTextEdit.__init__(self, theParent) @@ -79,7 +81,6 @@ class GuiDocEditor(QTextEdit): self.mainConf = novelwriter.CONFIG self.theParent = theParent self.theTheme = theParent.theTheme - self.theIndex = theParent.theIndex self.theProject = theParent.theProject self._nwDocument = None @@ -131,8 +132,9 @@ class GuiDocEditor(QTextEdit): # Editor Settings self.setMinimumWidth(self.mainConf.pxInt(300)) - self.setAutoFillBackground(True) self.setAcceptRichText(False) + self.setAutoFillBackground(True) + self.setFrameStyle(QFrame.NoFrame) # Custom Shortcuts QShortcut( @@ -400,7 +402,7 @@ class GuiDocEditor(QTextEdit): self.document().rootFrame().setFrameFormat(docFrame) self.docFooter.updateLineCount() - self._docHeaders = self.theIndex.getHandleHeaders(self._docHandle) + self._docHeaders = self.theProject.index.getHandleHeaders(self._docHandle) qApp.processEvents() self.document().clearUndoRedoStacks() @@ -505,9 +507,9 @@ class GuiDocEditor(QTextEdit): self.setDocumentChanged(False) - oldHeader = self.theIndex.getHandleHeaderLevel(tHandle) - self.theIndex.scanText(tHandle, docText) - newHeader = self.theIndex.getHandleHeaderLevel(tHandle) + oldHeader = self.theProject.index.getHandleHeaderLevel(tHandle) + self.theProject.index.scanText(tHandle, docText) + newHeader = self.theProject.index.getHandleHeaderLevel(tHandle) if self._updateHeaders(checkLevel=True): self.theParent.requestNovelTreeRefresh() @@ -567,16 +569,6 @@ class GuiDocEditor(QTextEdit): return - def updateDocInfo(self, tHandle): - """Called when an item label is changed to check if the document - title bar needs updating, - """ - if tHandle == self._docHandle: - self.docHeader.setTitleFromHandle(self._docHandle) - self.docFooter.updateInfo() - self.updateDocMargins() - return - ## # Properties ## @@ -1066,7 +1058,22 @@ class GuiDocEditor(QTextEdit): return ## - # Slots + # Public Slots + ## + + @pyqtSlot(str) + def updateDocInfo(self, tHandle): + """Called when an item label is changed to check if the document + title bar needs updating, + """ + if tHandle == self._docHandle: + self.docHeader.setTitleFromHandle(self._docHandle) + self.docFooter.updateInfo() + self.updateDocMargins() + return + + ## + # Private Slots ## @pyqtSlot(int, int, int) @@ -1894,7 +1901,7 @@ class GuiDocEditor(QTextEdit): if loadTag: logger.verbose("Attempting to follow tag '%s'", theWord) - self.theParent.docViewer.loadFromTag(theWord) + self.loadDocumentTagRequest.emit(theWord, nwDocMode.VIEW) else: logger.verbose("Potential tag '%s'", theWord) @@ -2002,7 +2009,7 @@ class GuiDocEditor(QTextEdit): if self._docHandle is None: return False - newHeaders = self.theIndex.getHandleHeaders(self._docHandle) + newHeaders = self.theProject.index.getHandleHeaders(self._docHandle) if checkPos: newPos = [x[0] for x in newHeaders] oldPos = [x[0] for x in self._docHeaders] @@ -2701,15 +2708,15 @@ class GuiDocEditHeader(QWidget): if self.mainConf.showFullPath: tTitle = [] - tTree = self.theProject.projTree.getItemPath(tHandle) + tTree = self.theProject.tree.getItemPath(tHandle) for aHandle in reversed(tTree): - nwItem = self.theProject.projTree[aHandle] + nwItem = self.theProject.tree[aHandle] if nwItem is not None: tTitle.append(nwItem.itemName) sSep = " %s " % nwUnicode.U_RSAQUO self.theTitle.setText(sSep.join(tTitle)) else: - nwItem = self.theProject.projTree[tHandle] + nwItem = self.theProject.tree[tHandle] if nwItem is None: return False self.theTitle.setText(nwItem.itemName) @@ -2795,7 +2802,6 @@ class GuiDocEditFooter(QWidget): self.theParent = docEditor.theParent self.theProject = docEditor.theProject self.theTheme = docEditor.theTheme - self.optState = docEditor.theProject.optState self._theItem = None self._docHandle = None @@ -2918,7 +2924,7 @@ class GuiDocEditFooter(QWidget): logger.verbose("No handle set, so clearing the editor footer") self._theItem = None else: - self._theItem = self.theProject.projTree[self._docHandle] + self._theItem = self.theProject.tree[self._docHandle] self.setHasSelection(False) self.updateInfo() @@ -2942,7 +2948,7 @@ class GuiDocEditFooter(QWidget): else: theStatus, theIcon = self._theItem.getImportStatus() sIcon = theIcon.pixmap(self.sPx, self.sPx) - hLevel = self.theParent.theIndex.getHandleHeaderLevel(self._docHandle) + hLevel = self.theProject.index.getHandleHeaderLevel(self._docHandle) sText = f"{theStatus} / {self._theItem.describeMe(hLevel)}" self.statusIcon.setPixmap(sIcon) diff --git a/novelwriter/gui/dochighlight.py b/novelwriter/gui/dochighlight.py index eddc27c3..bd2d78eb 100644 --- a/novelwriter/gui/dochighlight.py +++ b/novelwriter/gui/dochighlight.py @@ -55,7 +55,7 @@ class GuiDocHighlighter(QSyntaxHighlighter): self.spEnchant = spEnchant self.theParent = theParent self.theTheme = theParent.theTheme - self.theIndex = theParent.theIndex + self.theProject = theParent.theProject self.theHandle = None self.spellCheck = False self.spellRx = None @@ -287,9 +287,10 @@ class GuiDocHighlighter(QSyntaxHighlighter): if theText.startswith("@"): # Keywords and commands self.setCurrentBlockState(self.BLOCK_META) - tItem = self.theParent.theProject.projTree[self.theHandle] - isValid, theBits, thePos = self.theIndex.scanThis(theText) - isGood = self.theIndex.checkThese(theBits, tItem) + pIndex = self.theProject.index + tItem = self.theParent.theProject.tree[self.theHandle] + isValid, theBits, thePos = pIndex.scanThis(theText) + isGood = pIndex.checkThese(theBits, tItem) if isValid: for n, theBit in enumerate(theBits): xPos = thePos[n] diff --git a/novelwriter/gui/docviewer.py b/novelwriter/gui/docviewer.py index 4ec59570..89ae8ea2 100644 --- a/novelwriter/gui/docviewer.py +++ b/novelwriter/gui/docviewer.py @@ -30,17 +30,19 @@ along with this program. If not, see . import logging import novelwriter -from PyQt5.QtCore import Qt, QUrl, QSize, pyqtSlot +from enum import Enum + +from PyQt5.QtCore import Qt, QUrl, QSize, pyqtSlot, pyqtSignal from PyQt5.QtGui import ( QTextOption, QFont, QPalette, QColor, QTextCursor, QIcon, QCursor ) from PyQt5.QtWidgets import ( qApp, QTextBrowser, QWidget, QScrollArea, QLabel, QHBoxLayout, QToolButton, - QAction, QMenu + QAction, QMenu, QFrame ) from novelwriter.core import ToHtml -from novelwriter.enum import nwAlert, nwItemType, nwDocAction +from novelwriter.enum import nwItemType, nwDocAction, nwDocMode from novelwriter.error import logException from novelwriter.constants import nwUnicode @@ -49,6 +51,8 @@ logger = logging.getLogger(__name__) class GuiDocViewer(QTextBrowser): + loadDocumentTagRequest = pyqtSignal(str, Enum) + def __init__(self, theParent): QTextBrowser.__init__(self, theParent) @@ -68,6 +72,7 @@ class GuiDocViewer(QTextBrowser): self.setAutoFillBackground(True) self.setOpenExternalLinks(False) self.setFocusPolicy(Qt.StrongFocus) + self.setFrameStyle(QFrame.NoFrame) # Document Header and Footer self.docHeader = GuiDocViewHeader(self) @@ -159,7 +164,7 @@ class GuiDocViewer(QTextBrowser): def loadText(self, tHandle, updateHistory=True): """Load text into the viewer from an item handle. """ - if not self.theProject.projTree.checkType(tHandle, nwItemType.FILE): + if not self.theProject.tree.checkType(tHandle, nwItemType.FILE): logger.warning("Item not found") return False @@ -238,30 +243,6 @@ class GuiDocViewer(QTextBrowser): self.updateDocMargins() return - def loadFromTag(self, theTag): - """Load text in the document from a reference given by a meta - tag rather than a known handle. This function depends on the - index being up to date. - """ - logger.debug("Loading document from tag '%s'", theTag) - tHandle, _, sTitle = self.theParent.theIndex.getTagSource(theTag) - if tHandle is None: - self.theParent.makeAlert(self.tr( - "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}." - ).format( - theTag, "F9" - ), nwAlert.ERROR) - return False - else: - # Let the parent handle the opening as it also ensures that - # the doc view panel is visible in case this request comes - # from outside this class. - logger.verbose("Tag points to '%s#%s'", tHandle, sTitle) - self.theParent.viewDocument(tHandle, "#%s" % sTitle) - return True - def docAction(self, theAction): """Wrapper function for various document actions on the current document. @@ -341,15 +322,6 @@ class GuiDocViewer(QTextBrowser): return - def updateDocInfo(self, tHandle): - """Called when an item label is changed to check if the document - title bar needs updating, - """ - if tHandle == self._docHandle: - self.docHeader.setTitleFromHandle(self._docHandle) - self.updateDocMargins() - return - ## # Properties ## @@ -408,19 +380,33 @@ class GuiDocViewer(QTextBrowser): return 0 ## - # Slots + # Public Slots + ## + + @pyqtSlot(str) + def updateDocInfo(self, tHandle): + """Called when an item label is changed to check if the document + title bar needs updating, + """ + if tHandle == self._docHandle: + self.docHeader.setTitleFromHandle(self._docHandle) + self.updateDocMargins() + return + + ## + # Private Slots ## @pyqtSlot("QUrl") def _linkClicked(self, theURL): - """Slot for a link in the document being clicked. + """Process a clicked link internally in the document. """ theLink = theURL.url() logger.verbose("Clicked link: '%s'", theLink) if len(theLink) > 0: theBits = theLink.split("=") if len(theBits) == 2: - self.loadFromTag(theBits[1]) + self.loadDocumentTagRequest.emit(theBits[1], nwDocMode.VIEW) return @pyqtSlot("QPoint") @@ -862,15 +848,15 @@ class GuiDocViewHeader(QWidget): if self.mainConf.showFullPath: tTitle = [] - tTree = self.theProject.projTree.getItemPath(tHandle) + tTree = self.theProject.tree.getItemPath(tHandle) for aHandle in reversed(tTree): - nwItem = self.theProject.projTree[aHandle] + nwItem = self.theProject.tree[aHandle] if nwItem is not None: tTitle.append(nwItem.itemName) sSep = " %s " % nwUnicode.U_RSAQUO self.theTitle.setText(sSep.join(tTitle)) else: - nwItem = self.theProject.projTree[tHandle] + nwItem = self.theProject.tree[tHandle] if nwItem is None: return False self.theTitle.setText(nwItem.itemName) @@ -1185,6 +1171,7 @@ class GuiDocViewDetails(QScrollArea): self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) self.setWidgetResizable(True) self.setMinimumHeight(self.mainConf.pxInt(50)) + self.setFrameStyle(QFrame.NoFrame) logger.debug("GuiDocViewDetails initialisation complete") @@ -1197,10 +1184,10 @@ class GuiDocViewDetails(QScrollArea): if self.theParent.docViewer.stickyRef: return - theRefs = self.theParent.theIndex.getBackReferenceList(tHandle) + theRefs = self.theProject.index.getBackReferenceList(tHandle) theList = [] for tHandle in theRefs: - tItem = self.theProject.projTree[tHandle] + tItem = self.theProject.tree[tHandle] if tItem is not None: theList.append("%s" % ( tHandle, theRefs[tHandle], self.linkStyle, tItem.itemName diff --git a/novelwriter/gui/itemdetails.py b/novelwriter/gui/itemdetails.py index 21df9b80..643479a4 100644 --- a/novelwriter/gui/itemdetails.py +++ b/novelwriter/gui/itemdetails.py @@ -115,6 +115,7 @@ class GuiItemDetails(QWidget): self.usageData = QLabel("") self.usageData.setFont(fntValue) self.usageData.setAlignment(Qt.AlignLeft) + self.usageData.setWordWrap(True) # Character Count self.cCountName = QLabel(" "+self.tr("Characters")) @@ -214,6 +215,16 @@ class GuiItemDetails(QWidget): return + def refreshDetails(self): + """Reload the content of the details panel. + """ + self.updateViewBox(self._itemHandle) + + ## + # Public Slots + ## + + @pyqtSlot(str) def updateViewBox(self, tHandle): """Populate the details box from a given handle. """ @@ -221,7 +232,7 @@ class GuiItemDetails(QWidget): self.clearDetails() return - nwItem = self.theProject.projTree[tHandle] + nwItem = self.theProject.tree[tHandle] if nwItem is None: self.clearDetails() return @@ -263,7 +274,7 @@ class GuiItemDetails(QWidget): # Layout # ====== - hLevel = self.theParent.theIndex.getHandleHeaderLevel(tHandle) + hLevel = self.theProject.index.getHandleHeaderLevel(tHandle) usageIcon = self.theTheme.getItemIcon( nwItem.itemType, nwItem.itemClass, nwItem.itemLayout, hLevel ) @@ -284,10 +295,6 @@ class GuiItemDetails(QWidget): return - ## - # Slots - ## - @pyqtSlot(str, int, int, int) def doUpdateCounts(self, tHandle, cC, wC, pC): """Update the counts if the handle is the same as the one we're diff --git a/novelwriter/gui/mainmenu.py b/novelwriter/gui/mainmenu.py index 4bb567d5..bd8d1edd 100644 --- a/novelwriter/gui/mainmenu.py +++ b/novelwriter/gui/mainmenu.py @@ -71,24 +71,6 @@ class GuiMainMenu(QMenuBar): return - ## - # Methods - ## - - def setAvailableRoot(self): - """Update the list of available root folders and set the ones - that are active. - """ - for itemClass in nwItemClass: - if itemClass == nwItemClass.NO_CLASS: - continue - if itemClass == nwItemClass.TRASH: - continue - self.rootItems[itemClass].setVisible( - self.theProject.projTree.checkRootUnique(itemClass) - ) - return - ## # Update Menu on Settings Changed ## @@ -99,12 +81,6 @@ class GuiMainMenu(QMenuBar): self.aSpellCheck.setChecked(theMode) return - def setAutoOutline(self, theMode): - """Forward auto outline check state to its action. - """ - self.aAutoOutline.setChecked(theMode) - return - def setFocusMode(self, theMode): """Forward focus mode check state to its action. """ @@ -123,12 +99,6 @@ class GuiMainMenu(QMenuBar): self.theParent.docEditor.toggleSpellCheck(None) return True - def _toggleAutoOutline(self, theMode): - """Toggle auto outline when the menu entry is checked. - """ - self.theProject.setAutoOutline(theMode) - return True - def _openWebsite(self, theUrl): """Open a URL in the system's default browser. """ @@ -215,7 +185,7 @@ class GuiMainMenu(QMenuBar): # Project > New Folder self.aCreateFolder = QAction(self.tr("Create Folder"), self) self.aCreateFolder.setShortcut("Ctrl+Shift+N") - self.aCreateFolder.triggered.connect(lambda: self._newTreeItem(nwItemType.FOLDER, None)) + self.aCreateFolder.triggered.connect(lambda: self._newTreeItem(nwItemType.FOLDER)) self.projMenu.addAction(self.aCreateFolder) # Project > Separator @@ -277,7 +247,7 @@ class GuiMainMenu(QMenuBar): # Document > New self.aNewDoc = QAction(self.tr("New Document"), self) self.aNewDoc.setShortcut("Ctrl+N") - self.aNewDoc.triggered.connect(lambda: self._newTreeItem(nwItemType.FILE, None)) + self.aNewDoc.triggered.connect(lambda: self._newTreeItem(nwItemType.FILE)) self.docuMenu.addAction(self.aNewDoc) # Document > Open @@ -907,19 +877,6 @@ class GuiMainMenu(QMenuBar): self.aRebuildIndex.triggered.connect(lambda: self.theParent.rebuildIndex()) self.toolsMenu.addAction(self.aRebuildIndex) - # Tools > Rebuild Outline - self.aRebuildOutline = QAction(self.tr("Rebuild Outline"), self) - self.aRebuildOutline.setShortcut("F10") - self.aRebuildOutline.triggered.connect(lambda: self.theParent.rebuildOutline()) - self.toolsMenu.addAction(self.aRebuildOutline) - - # Tools > Toggle Auto Build Outline - self.aAutoOutline = QAction(self.tr("Auto-Update Outline"), self) - self.aAutoOutline.setCheckable(True) - self.aAutoOutline.toggled.connect(self._toggleAutoOutline) - self.aAutoOutline.setShortcut("Ctrl+F10") - self.toolsMenu.addAction(self.aAutoOutline) - # Tools > Separator self.toolsMenu.addSeparator() diff --git a/novelwriter/gui/noveltree.py b/novelwriter/gui/noveltree.py index 0f7ac02f..9ac2ea2b 100644 --- a/novelwriter/gui/noveltree.py +++ b/novelwriter/gui/noveltree.py @@ -29,7 +29,9 @@ import novelwriter from time import time from PyQt5.QtCore import Qt, QSize -from PyQt5.QtWidgets import QTreeWidget, QTreeWidgetItem, QAbstractItemView +from PyQt5.QtWidgets import ( + QTreeWidget, QTreeWidgetItem, QAbstractItemView, QFrame +) from novelwriter.common import checkInt from novelwriter.constants import nwKeyWords @@ -52,7 +54,6 @@ class GuiNovelTree(QTreeWidget): self.theParent = theParent self.theTheme = theParent.theTheme self.theProject = theParent.theProject - self.theIndex = theParent.theIndex # Internal Variables self._treeMap = {} @@ -60,6 +61,7 @@ class GuiNovelTree(QTreeWidget): # Build GUI iPx = self.theTheme.baseIconSize + self.setFrameStyle(QFrame.NoFrame) self.setIconSize(QSize(iPx, iPx)) self.setIndentation(iPx) self.setColumnCount(3) @@ -134,7 +136,7 @@ class GuiNovelTree(QTreeWidget): """ logger.verbose("Requesting refresh of the novel tree") treeChanged = self.theParent.treeView.changedSince(self._lastBuild) - indexChanged = self.theIndex.novelChangedSince(self._lastBuild) + indexChanged = self.theProject.index.indexChangedSince(self._lastBuild) if not (treeChanged or indexChanged or overRide): logger.verbose("No changes have been made to the novel index") return @@ -144,7 +146,6 @@ class GuiNovelTree(QTreeWidget): if selItem: titleKey = selItem[0].data(self.C_TITLE, Qt.UserRole)[2] - self.theParent.treeView.flushTreeOrder() self._populateTree() if titleKey is not None and titleKey in self._treeMap: @@ -155,7 +156,7 @@ class GuiNovelTree(QTreeWidget): def updateWordCounts(self, tHandle): """Update the word count for a given handle. """ - tHeaders = self.theIndex.getHandleWordCounts(tHandle) + tHeaders = self.theProject.index.getHandleWordCounts(tHandle) for titleKey, wCount in tHeaders: if titleKey in self._treeMap: self._treeMap[titleKey].setText(self.C_WORDS, f"{wCount:n}") @@ -249,12 +250,12 @@ class GuiNovelTree(QTreeWidget): currChapter = None currScene = None - for tKey, tHandle, sTitle, novIdx in self.theIndex.novelStructure(skipExcluded=True): + for tKey, tHandle, sTitle, novIdx in self.theProject.index.novelStructure(skipExcl=True): tItem = self._createTreeItem(tHandle, sTitle, tKey, novIdx) self._treeMap[tKey] = tItem - tLevel = novIdx["level"] + tLevel = novIdx.level if tLevel == "H1": self.addTopLevelItem(tItem) currTitle = tItem @@ -301,18 +302,18 @@ class GuiNovelTree(QTreeWidget): """Populate a tree item with all the column values. """ newItem = QTreeWidgetItem() - hIcon = "doc_%s" % novIdx["level"].lower() + hIcon = "doc_%s" % novIdx.level.lower() theData = (tHandle, sTitle[1:].lstrip("0"), titleKey) - wC = int(novIdx["wCount"]) + wC = int(novIdx.wordCount) - newItem.setText(self.C_TITLE, novIdx["title"]) + newItem.setText(self.C_TITLE, novIdx.title) newItem.setData(self.C_TITLE, Qt.UserRole, theData) newItem.setIcon(self.C_TITLE, self.theTheme.getIcon(hIcon)) newItem.setText(self.C_WORDS, f"{wC:n}") newItem.setTextAlignment(self.C_WORDS, Qt.AlignRight) - theRefs = self.theIndex.getReferences(tHandle, sTitle) + theRefs = self.theProject.index.getReferences(tHandle, sTitle) newItem.setText(self.C_POV, ", ".join(theRefs[nwKeyWords.POV_KEY])) return newItem diff --git a/novelwriter/gui/outline.py b/novelwriter/gui/outline.py index 1ac83ff1..34cb35da 100644 --- a/novelwriter/gui/outline.py +++ b/novelwriter/gui/outline.py @@ -4,7 +4,11 @@ novelWriter – GUI Project Outline GUI class for the project outline view File History: -Created: 2019-11-16 [0.4.1] +Created: 2022-05-15 [1.7b1] GuiOutline +Created: 2022-05-22 [1.7b1] GuiOutlineToolBar +Created: 2019-11-16 [0.4.1] GuiOutlineView +Created: 2019-11-16 [0.4.1] GuiOutlineHeaderMenu +Created: 2020-06-02 [0.7.0] GuiOutlineDetails This file is a part of novelWriter Copyright 2018–2022, Veronica Berglyd Olsen @@ -27,20 +31,244 @@ import logging import novelwriter from time import time +from enum import Enum -from PyQt5.QtCore import Qt, QSize, pyqtSlot +from PyQt5.QtCore import ( + Qt, pyqtSignal, pyqtSlot, QSize, QT_TRANSLATE_NOOP +) from PyQt5.QtWidgets import ( - QTreeWidget, QTreeWidgetItem, QMenu, QAction, QAbstractItemView + QAbstractItemView, QAction, QGridLayout, QGroupBox, QHBoxLayout, QLabel, + QMenu, QScrollArea, QSplitter, QTreeWidget, QTreeWidgetItem, QVBoxLayout, + QWidget, QFrame, QToolBar, QSizePolicy, QComboBox, QToolButton ) -from novelwriter.enum import nwItemLayout, nwItemType, nwOutline +from novelwriter.enum import ( + nwDocMode, nwItemClass, nwItemLayout, nwItemType, nwOutline +) from novelwriter.common import checkInt from novelwriter.constants import trConst, nwKeyWords, nwLabels + logger = logging.getLogger(__name__) -class GuiOutline(QTreeWidget): +class GuiOutline(QWidget): + + loadDocumentTagRequest = pyqtSignal(str, Enum) + + def __init__(self, theParent): + QWidget.__init__(self, theParent) + + self.mainConf = novelwriter.CONFIG + self.theParent = theParent + + self.outlineBar = GuiOutlineToolBar(self) + self.outlineView = GuiOutlineView(self) + self.outlineData = GuiOutlineDetails(self) + + self.splitOutline = QSplitter(Qt.Vertical) + self.splitOutline.addWidget(self.outlineView) + self.splitOutline.addWidget(self.outlineData) + self.splitOutline.setSizes(self.mainConf.getOutlinePanePos()) + + # Assemble + self.outerBox = QVBoxLayout() + self.outerBox.setContentsMargins(0, 0, 0, 0) + self.outerBox.addWidget(self.outlineBar) + self.outerBox.addWidget(self.splitOutline) + + self.setLayout(self.outerBox) + + # Connect Signals + self.outlineView.hiddenStateChanged.connect(self._updateMenuColumns) + self.outlineView.activeItemChanged.connect(self.outlineData.showItem) + self.outlineData.itemTagClicked.connect(self._tagClicked) + self.outlineBar.loadNovelRootRequest.connect(self._rootItemChanged) + self.outlineBar.viewColumnToggled.connect(self.outlineView.menuColumnToggled) + + # Function Mappings + self.getSelectedHandle = self.outlineView.getSelectedHandle + + return + + ## + # Methods + ## + + def splitSizes(self): + return self.splitOutline.sizes() + + def clearOutline(self): + self.outlineData.clearDetails() + return + + def initOutline(self): + self.outlineView.initOutline() + self.outlineData.initDetails() + return + + def closeOutline(self): + self.outlineView.closeOutline() + self.outlineData.updateClasses() + return + + def refreshView(self, overRide=False, novelChanged=False): + self.outlineView.refreshTree(overRide=overRide, novelChanged=novelChanged) + return + + def treeFocus(self): + return self.outlineView.hasFocus() + + def setTreeFocus(self): + return self.outlineView.setFocus() + + ## + # Public Slots + ## + + @pyqtSlot(str) + def updateRootItem(self, tHandle): + """Should be called whenever a root folders changes. + """ + self.outlineBar.populateNovelList() + self.outlineData.updateClasses() + return + + ## + # Private Slots + ## + + @pyqtSlot() + def _updateMenuColumns(self): + """Trigger an update of the toggled state of the column menu + checkboxes whenever a signal is received that the hidden state + of columns has changed. + """ + self.outlineBar.setColumnHiddenState(self.outlineView.hiddenColumns) + return + + @pyqtSlot(str) + def _tagClicked(self, link): + """Capture the click of a tag in the details panel. + """ + if link: + self.loadDocumentTagRequest.emit(link, nwDocMode.VIEW) + return + + @pyqtSlot(str) + def _rootItemChanged(self, handle): + """The root novel handle has changed or needs to be refreshed. + """ + self.outlineView.refreshTree(rootHandle=(handle or None), overRide=True) + return + +# END Class GuiOutline + + +class GuiOutlineToolBar(QToolBar): + + loadNovelRootRequest = pyqtSignal(str) + viewColumnToggled = pyqtSignal(bool, Enum) + + def __init__(self, theOutline): + QTreeWidget.__init__(self, theOutline) + + logger.debug("Initialising GuiOutlineToolBar ...") + + self.mainConf = novelwriter.CONFIG + self.theParent = theOutline.theParent + self.theProject = theOutline.theParent.theProject + self.theTheme = theOutline.theParent.theTheme + + iPx = self.mainConf.pxInt(22) + mPx = self.mainConf.pxInt(12) + + 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) + + # Novel Selector + self.novelLabel = QLabel(self.tr("Outline of")) + self.novelLabel.setContentsMargins(0, 0, mPx, 0) + + self.novelValue = QComboBox(self) + self.novelValue.setMinimumWidth(self.mainConf.pxInt(200)) + self.novelValue.currentIndexChanged.connect(self._novelValueChanged) + + # Actions + self.aRefresh = QAction(self.tr("Refresh"), self) + self.aRefresh.setIcon(self.theTheme.getIcon("refresh")) + self.aRefresh.triggered.connect(self._refreshRequested) + + # Column Menu + self.mColumns = GuiOutlineHeaderMenu(self) + self.mColumns.columnToggled.connect( + lambda isChecked, tItem: self.viewColumnToggled.emit(isChecked, tItem) + ) + + self.tbColumns = QToolButton(self) + self.tbColumns.setIcon(self.theTheme.getIcon("menu")) + self.tbColumns.setMenu(self.mColumns) + self.tbColumns.setPopupMode(QToolButton.InstantPopup) + + # Assemble + self.addWidget(self.novelLabel) + self.addWidget(self.novelValue) + self.addSeparator() + self.addAction(self.aRefresh) + self.addWidget(self.tbColumns) + self.addWidget(stretch) + + logger.debug("GuiOutlineToolBar initialisation complete") + + ## + # Methods + ## + + def populateNovelList(self): + """Fill the novel combo box with a list of all novel folders. + """ + self.novelValue.clear() + tIcon = self.theTheme.getIcon(nwLabels.CLASS_ICON[nwItemClass.NOVEL]) + for tHandle, nwItem in self.theProject.tree.iterRoots(nwItemClass.NOVEL): + self.novelValue.addItem(tIcon, nwItem.itemName, tHandle) + self.novelValue.insertSeparator(self.novelValue.count()) + self.novelValue.addItem(tIcon, self.tr("All Novel Folders"), "") + return + + def setColumnHiddenState(self, hiddenState): + """Forward the change of column hidden states to the menu. + """ + self.mColumns.setHiddenState(hiddenState) + return + + ## + # Private Slots + ## + + @pyqtSlot(int) + def _novelValueChanged(self, index): + """Emit a signal containing the handle of the selected item. + """ + if index >= 0: + self.loadNovelRootRequest.emit(self.novelValue.currentData()) + return + + @pyqtSlot() + def _refreshRequested(self): + """Emit a signal containing the handle of the selected item. + """ + self.loadNovelRootRequest.emit(self.novelValue.currentData()) + return + +# END Class GuiOutlineToolBar + + +class GuiOutlineView(QTreeWidget): DEF_WIDTH = { nwOutline.TITLE: 200, @@ -82,19 +310,20 @@ class GuiOutline(QTreeWidget): nwOutline.SYNOP: False, } - def __init__(self, theParent): - QTreeWidget.__init__(self, theParent) + hiddenStateChanged = pyqtSignal() + activeItemChanged = pyqtSignal(str, str) - logger.debug("Initialising GuiOutline ...") + def __init__(self, theOutline): + QTreeWidget.__init__(self, theOutline) + + logger.debug("Initialising GuiOutlineView ...") self.mainConf = novelwriter.CONFIG - self.theParent = theParent - self.theProject = theParent.theProject - self.theTheme = theParent.theTheme - self.theIndex = theParent.theIndex - self.optState = theParent.theProject.optState - self.headerMenu = GuiOutlineHeaderMenu(self) + self.theParent = theOutline.theParent + self.theProject = theOutline.theParent.theProject + self.theTheme = theOutline.theParent.theTheme + self.setFrameStyle(QFrame.NoFrame) self.setSelectionBehavior(QAbstractItemView.SelectRows) self.setSelectionMode(QAbstractItemView.SingleSelection) self.setExpandsOnDoubleClick(False) @@ -107,8 +336,6 @@ class GuiOutline(QTreeWidget): self.setIndentation(iPx) self.treeHead = self.header() - self.treeHead.setContextMenuPolicy(Qt.CustomContextMenu) - self.treeHead.customContextMenuRequested.connect(self._headerRightClick) self.treeHead.sectionMoved.connect(self._columnMoved) # Internals @@ -122,12 +349,25 @@ class GuiOutline(QTreeWidget): self.initOutline() self.clearOutline() - self.headerMenu.setHiddenState(self._colHidden) - logger.debug("GuiOutline initialisation complete") + self.hiddenStateChanged.emit() + + logger.debug("GuiOutlineView initialisation complete") return + ## + # Properties + ## + + @property + def hiddenColumns(self): + return self._colHidden + + ## + # Methods + ## + def initOutline(self): """Set or update outline settings. """ @@ -167,7 +407,7 @@ class GuiOutline(QTreeWidget): return - def refreshTree(self, overRide=False, novelChanged=False): + def refreshTree(self, rootHandle=None, overRide=False, novelChanged=False): """Called whenever the Outline tab is activated and controls what data to load, and if necessary, force a rebuild of the tree. @@ -175,17 +415,17 @@ class GuiOutline(QTreeWidget): # If it's the first time, we always build if self._firstView or self._firstView and overRide: self._loadHeaderState() - self._populateTree() + self._populateTree(rootHandle) self._firstView = False return # 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.theIndex.novelChangedSince(self._lastBuild) + 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() + self._populateTree(rootHandle) return @@ -221,7 +461,7 @@ class GuiOutline(QTreeWidget): document editor. """ tHandle, tLine = self.getSelectedHandle() - self.theParent.openDocument(tHandle, tLine=tLine-1, doScroll=True) + self.theParent.openDocument(tHandle, tLine=tLine - 1, doScroll=True) return @pyqtSlot() @@ -233,18 +473,10 @@ class GuiOutline(QTreeWidget): if selItems: tHandle = selItems[0].data(self._colIdx[nwOutline.TITLE], Qt.UserRole) sTitle = selItems[0].data(self._colIdx[nwOutline.LINE], Qt.UserRole) - self.theParent.projMeta.showItem(tHandle, sTitle) - self.theParent.treeView.setSelectedHandle(tHandle) + self.activeItemChanged.emit(tHandle, sTitle) return - @pyqtSlot("QPoint") - def _headerRightClick(self, clickPos): - """Show the header column menu. - """ - self.headerMenu.exec_(self.mapToGlobal(clickPos)) - return - @pyqtSlot(int, int, int) def _columnMoved(self, logIdx, oldVisualIdx, newVisualIdx): """Make sure the order array is up to date with the actual order @@ -254,9 +486,10 @@ class GuiOutline(QTreeWidget): self._saveHeaderState() return - def _menuColumnToggled(self, isChecked, theItem): + @pyqtSlot(bool, Enum) + def menuColumnToggled(self, isChecked, theItem): """Receive the changes to column visibility forwarded by the - header context menu. + column selection menu. """ logger.verbose("User toggled Outline column '%s'", theItem.name) if theItem in self._colIdx: @@ -273,10 +506,12 @@ class GuiOutline(QTreeWidget): """Load the state of the main tree header, that is, column order and column width. """ + pOptions = self.theProject.options + # Load whatever we saved last time, regardless of wether it # contains the correct names or number of columns. The names # must be valid though. - tempOrder = self.optState.getValue("GuiOutline", "headerOrder", []) + tempOrder = pOptions.getValue("GuiOutline", "headerOrder", []) treeOrder = [] for hName in tempOrder: try: @@ -299,21 +534,21 @@ class GuiOutline(QTreeWidget): # We load whatever column widths and hidden states we find in # the file, and leave the rest in their default state. - tmpWidth = self.optState.getValue("GuiOutline", "columnWidth", {}) + tmpWidth = pOptions.getValue("GuiOutline", "columnWidth", {}) for hName in tmpWidth: try: self._colWidth[nwOutline[hName]] = self.mainConf.pxInt(tmpWidth[hName]) except Exception: logger.warning("Ignored unknown outline column '%s'", str(hName)) - tmpHidden = self.optState.getValue("GuiOutline", "columnHidden", {}) + tmpHidden = pOptions.getValue("GuiOutline", "columnHidden", {}) for hName in tmpHidden: try: self._colHidden[nwOutline[hName]] = tmpHidden[hName] except Exception: logger.warning("Ignored unknown outline column '%s'", str(hName)) - self.headerMenu.setHiddenState(self._colHidden) + self.hiddenStateChanged.emit() return @@ -347,14 +582,15 @@ class GuiOutline(QTreeWidget): if not logHidden and logWidth > 0: colWidth[hName] = logWidth - self.optState.setValue("GuiOutline", "headerOrder", treeOrder) - self.optState.setValue("GuiOutline", "columnWidth", colWidth) - self.optState.setValue("GuiOutline", "columnHidden", colHidden) - self.optState.saveSettings() + pOptions = self.theProject.options + pOptions.setValue("GuiOutline", "headerOrder", treeOrder) + pOptions.setValue("GuiOutline", "columnWidth", colWidth) + pOptions.setValue("GuiOutline", "columnHidden", colHidden) + pOptions.saveSettings() return - def _populateTree(self): + def _populateTree(self, rootHandle): """Build the tree based on the project index, and the header based on the defined constants, default values and user selected width, order and hidden state. All columns are populated, even @@ -387,11 +623,12 @@ class GuiOutline(QTreeWidget): currChapter = None currScene = None - for tKey, tHandle, sTitle, novIdx in self.theIndex.novelStructure(skipExcluded=True): + novStruct = self.theProject.index.novelStructure(rootHandle=rootHandle, skipExcl=True) + for _, tHandle, sTitle, novIdx in novStruct: tItem = self._createTreeItem(tHandle, sTitle, novIdx) - tLevel = novIdx["level"] + tLevel = novIdx.level if tLevel == "H1": self.addTopLevelItem(tItem) currTitle = tItem @@ -437,26 +674,26 @@ class GuiOutline(QTreeWidget): def _createTreeItem(self, tHandle, sTitle, novIdx): """Populate a tree item with all the column values. """ - nwItem = self.theProject.projTree[tHandle] + nwItem = self.theProject.tree[tHandle] newItem = QTreeWidgetItem() - hIcon = "doc_%s" % novIdx["level"].lower() + hIcon = "doc_%s" % novIdx.level.lower() - hLevel = self.theIndex.getHandleHeaderLevel(tHandle) + hLevel = self.theProject.index.getHandleHeaderLevel(tHandle) dIcon = self.theTheme.getItemIcon(nwItemType.FILE, None, nwItemLayout.DOCUMENT, hLevel) - cC = int(novIdx["cCount"]) - wC = int(novIdx["wCount"]) - pC = int(novIdx["pCount"]) + cC = int(novIdx.charCount) + wC = int(novIdx.wordCount) + pC = int(novIdx.paraCount) - newItem.setText(self._colIdx[nwOutline.TITLE], novIdx["title"]) + newItem.setText(self._colIdx[nwOutline.TITLE], novIdx.title) newItem.setData(self._colIdx[nwOutline.TITLE], Qt.UserRole, tHandle) newItem.setIcon(self._colIdx[nwOutline.TITLE], self.theTheme.getIcon(hIcon)) - newItem.setText(self._colIdx[nwOutline.LEVEL], novIdx["level"]) + 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.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}") @@ -464,7 +701,7 @@ class GuiOutline(QTreeWidget): newItem.setTextAlignment(self._colIdx[nwOutline.WCOUNT], Qt.AlignRight) newItem.setTextAlignment(self._colIdx[nwOutline.PCOUNT], Qt.AlignRight) - theRefs = self.theIndex.getReferences(tHandle, sTitle) + 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])) @@ -477,15 +714,16 @@ class GuiOutline(QTreeWidget): return newItem -# END Class GuiOutline +# END Class GuiOutlineView class GuiOutlineHeaderMenu(QMenu): - def __init__(self, theParent): - QMenu.__init__(self, theParent) + columnToggled = pyqtSignal(bool, Enum) + + def __init__(self, theOutline): + QMenu.__init__(self, theOutline) - self.theParent = theParent self.acceptToggle = True mnuHead = QAction(self.tr("Select Columns"), self) @@ -499,7 +737,7 @@ class GuiOutlineHeaderMenu(QMenu): self.actionMap[hItem] = QAction(trConst(nwLabels.OUTLINE_COLS[hItem]), self) self.actionMap[hItem].setCheckable(True) self.actionMap[hItem].toggled.connect( - lambda isChecked, tItem=hItem: self._columnToggled(isChecked, tItem) + lambda isChecked, tItem=hItem: self.columnToggled.emit(isChecked, tItem) ) self.addAction(self.actionMap[hItem]) @@ -520,16 +758,338 @@ class GuiOutlineHeaderMenu(QMenu): return +# END Class GuiOutlineHeaderMenu + + +class GuiOutlineDetails(QScrollArea): + + LVL_MAP = { + "H1": QT_TRANSLATE_NOOP("GuiOutlineDetails", "Title"), + "H2": QT_TRANSLATE_NOOP("GuiOutlineDetails", "Chapter"), + "H3": QT_TRANSLATE_NOOP("GuiOutlineDetails", "Scene"), + "H4": QT_TRANSLATE_NOOP("GuiOutlineDetails", "Section"), + } + + itemTagClicked = pyqtSignal(str) + + def __init__(self, theOutline): + QScrollArea.__init__(self, theOutline) + + logger.debug("Initialising GuiOutlineDetails ...") + + self.mainConf = novelwriter.CONFIG + self.theOutline = theOutline + self.theParent = theOutline.theParent + self.theProject = theOutline.theParent.theProject + self.theTheme = theOutline.theParent.theTheme + + # Sizes + minTitle = 30*self.theTheme.textNWidth + maxTitle = 40*self.theTheme.textNWidth + wCount = self.theTheme.getTextWidth("999,999") + hSpace = int(self.mainConf.pxInt(10)) + vSpace = int(self.mainConf.pxInt(4)) + + # Details Area + self.titleLabel = QLabel("%s" % self.tr("Title")) + self.fileLabel = QLabel("%s" % self.tr("Document")) + self.itemLabel = QLabel("%s" % self.tr("Status")) + self.titleValue = QLabel("") + self.fileValue = QLabel("") + self.itemValue = QLabel("") + + self.titleValue.setMinimumWidth(minTitle) + self.titleValue.setMaximumWidth(maxTitle) + self.fileValue.setMinimumWidth(minTitle) + self.fileValue.setMaximumWidth(maxTitle) + self.itemValue.setMinimumWidth(minTitle) + self.itemValue.setMaximumWidth(maxTitle) + + # Stats Area + self.cCLabel = QLabel("%s" % self.tr("Characters")) + self.wCLabel = QLabel("%s" % self.tr("Words")) + self.pCLabel = QLabel("%s" % self.tr("Paragraphs")) + self.cCValue = QLabel("") + self.wCValue = QLabel("") + self.pCValue = QLabel("") + + self.cCValue.setMinimumWidth(wCount) + self.wCValue.setMinimumWidth(wCount) + self.pCValue.setMinimumWidth(wCount) + self.cCValue.setAlignment(Qt.AlignRight) + self.wCValue.setAlignment(Qt.AlignRight) + self.pCValue.setAlignment(Qt.AlignRight) + + # Synopsis + self.synopLabel = QLabel("%s" % self.tr("Synopsis")) + self.synopValue = QLabel("") + self.synopLWrap = QHBoxLayout() + self.synopValue.setWordWrap(True) + self.synopValue.setAlignment(Qt.AlignTop | Qt.AlignLeft) + self.synopLWrap.addWidget(self.synopValue, 1) + + # Tags + self.povKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.POV_KEY])) + self.focKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.FOCUS_KEY])) + self.chrKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.CHAR_KEY])) + self.pltKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.PLOT_KEY])) + self.timKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.TIME_KEY])) + self.wldKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.WORLD_KEY])) + self.objKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.OBJECT_KEY])) + self.entKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.ENTITY_KEY])) + self.cstKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.CUSTOM_KEY])) + + self.povKeyLWrap = QHBoxLayout() + self.focKeyLWrap = QHBoxLayout() + self.chrKeyLWrap = QHBoxLayout() + self.pltKeyLWrap = QHBoxLayout() + self.timKeyLWrap = QHBoxLayout() + self.wldKeyLWrap = QHBoxLayout() + self.objKeyLWrap = QHBoxLayout() + self.entKeyLWrap = QHBoxLayout() + self.cstKeyLWrap = QHBoxLayout() + + self.povKeyValue = QLabel("") + self.focKeyValue = QLabel("") + self.chrKeyValue = QLabel("") + self.pltKeyValue = QLabel("") + self.timKeyValue = QLabel("") + self.wldKeyValue = QLabel("") + self.objKeyValue = QLabel("") + self.entKeyValue = QLabel("") + self.cstKeyValue = QLabel("") + + self.povKeyValue.setWordWrap(True) + self.focKeyValue.setWordWrap(True) + self.chrKeyValue.setWordWrap(True) + self.pltKeyValue.setWordWrap(True) + self.timKeyValue.setWordWrap(True) + self.wldKeyValue.setWordWrap(True) + self.objKeyValue.setWordWrap(True) + self.entKeyValue.setWordWrap(True) + self.cstKeyValue.setWordWrap(True) + + def tagClicked(link): + self.itemTagClicked.emit(link) + + self.povKeyValue.linkActivated.connect(tagClicked) + self.focKeyValue.linkActivated.connect(tagClicked) + self.chrKeyValue.linkActivated.connect(tagClicked) + self.pltKeyValue.linkActivated.connect(tagClicked) + self.timKeyValue.linkActivated.connect(tagClicked) + self.wldKeyValue.linkActivated.connect(tagClicked) + self.objKeyValue.linkActivated.connect(tagClicked) + self.entKeyValue.linkActivated.connect(tagClicked) + self.cstKeyValue.linkActivated.connect(tagClicked) + + self.povKeyLWrap.addWidget(self.povKeyValue, 1) + self.focKeyLWrap.addWidget(self.focKeyValue, 1) + self.chrKeyLWrap.addWidget(self.chrKeyValue, 1) + self.pltKeyLWrap.addWidget(self.pltKeyValue, 1) + self.timKeyLWrap.addWidget(self.timKeyValue, 1) + self.wldKeyLWrap.addWidget(self.wldKeyValue, 1) + self.objKeyLWrap.addWidget(self.objKeyValue, 1) + self.entKeyLWrap.addWidget(self.entKeyValue, 1) + self.cstKeyLWrap.addWidget(self.cstKeyValue, 1) + + # Selected Item Details + self.mainGroup = QGroupBox(self.tr("Title Details"), self) + self.mainForm = QGridLayout() + self.mainGroup.setLayout(self.mainForm) + + self.mainForm.addWidget(self.titleLabel, 0, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.mainForm.addWidget(self.titleValue, 0, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.mainForm.addWidget(self.cCLabel, 0, 2, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.mainForm.addWidget(self.cCValue, 0, 3, 1, 1, Qt.AlignTop | Qt.AlignRight) + self.mainForm.addWidget(self.fileLabel, 1, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.mainForm.addWidget(self.fileValue, 1, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.mainForm.addWidget(self.wCLabel, 1, 2, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.mainForm.addWidget(self.wCValue, 1, 3, 1, 1, Qt.AlignTop | Qt.AlignRight) + self.mainForm.addWidget(self.itemLabel, 2, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.mainForm.addWidget(self.itemValue, 2, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.mainForm.addWidget(self.pCLabel, 2, 2, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.mainForm.addWidget(self.pCValue, 2, 3, 1, 1, Qt.AlignTop | Qt.AlignRight) + self.mainForm.addWidget(self.synopLabel, 3, 0, 1, 4, Qt.AlignTop | Qt.AlignLeft) + self.mainForm.addLayout(self.synopLWrap, 4, 0, 1, 4, Qt.AlignTop | Qt.AlignLeft) + + self.mainForm.setColumnStretch(1, 1) + self.mainForm.setRowStretch(4, 1) + self.mainForm.setHorizontalSpacing(hSpace) + self.mainForm.setVerticalSpacing(vSpace) + + # Selected Item Tags + self.tagsGroup = QGroupBox(self.tr("Reference Tags"), self) + self.tagsForm = QGridLayout() + self.tagsGroup.setLayout(self.tagsForm) + + self.tagsForm.addWidget(self.povKeyLabel, 0, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.tagsForm.addLayout(self.povKeyLWrap, 0, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.tagsForm.addWidget(self.focKeyLabel, 1, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.tagsForm.addLayout(self.focKeyLWrap, 1, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.tagsForm.addWidget(self.chrKeyLabel, 2, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.tagsForm.addLayout(self.chrKeyLWrap, 2, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.tagsForm.addWidget(self.pltKeyLabel, 3, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.tagsForm.addLayout(self.pltKeyLWrap, 3, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.tagsForm.addWidget(self.timKeyLabel, 4, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.tagsForm.addLayout(self.timKeyLWrap, 4, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.tagsForm.addWidget(self.wldKeyLabel, 5, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.tagsForm.addLayout(self.wldKeyLWrap, 5, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.tagsForm.addWidget(self.objKeyLabel, 6, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.tagsForm.addLayout(self.objKeyLWrap, 6, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.tagsForm.addWidget(self.entKeyLabel, 7, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.tagsForm.addLayout(self.entKeyLWrap, 7, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.tagsForm.addWidget(self.cstKeyLabel, 8, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.tagsForm.addLayout(self.cstKeyLWrap, 8, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) + + self.tagsForm.setColumnStretch(1, 1) + self.tagsForm.setRowStretch(8, 1) + self.tagsForm.setHorizontalSpacing(hSpace) + self.tagsForm.setVerticalSpacing(vSpace) + + # Assemble + self.outerWidget = QWidget() + self.outerBox = QHBoxLayout() + self.outerBox.addWidget(self.mainGroup, 0) + self.outerBox.addWidget(self.tagsGroup, 1) + + self.outerWidget.setLayout(self.outerBox) + self.setWidget(self.outerWidget) + + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.setWidgetResizable(True) + self.setFrameStyle(QFrame.NoFrame) + + self.initDetails() + + logger.debug("GuiOutlineDetails initialisation complete") + + return + + def initDetails(self): + """Set or update outline settings. + """ + # Scroll bars + if self.mainConf.hideVScroll: + self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + else: + self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + + if self.mainConf.hideHScroll: + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + else: + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + + self.updateClasses() + + return + + def clearDetails(self): + """Clear all the data labels. + """ + self.titleLabel.setText("%s" % self.tr("Title")) + self.titleValue.setText("") + self.fileValue.setText("") + self.itemValue.setText("") + self.cCValue.setText("") + self.wCValue.setText("") + self.pCValue.setText("") + self.synopValue.setText("") + self.povKeyValue.setText("") + self.focKeyValue.setText("") + self.chrKeyValue.setText("") + self.pltKeyValue.setText("") + self.timKeyValue.setText("") + self.wldKeyValue.setText("") + self.objKeyValue.setText("") + self.entKeyValue.setText("") + self.cstKeyValue.setText("") + self.updateClasses() + return + ## # Slots ## - def _columnToggled(self, isChecked, theItem): - """The user has toggled the visibility of a column. Forward the - event to the parent class only if we're accepting changes. + @pyqtSlot(str, str) + def showItem(self, tHandle, sTitle): + """Update the content of the tree with the given handle and line + number pointing to a header. """ - if self.acceptToggle: - self.theParent._menuColumnToggled(isChecked, theItem) + pIndex = self.theProject.index + nwItem = self.theProject.tree[tHandle] + novIdx = pIndex.getNovelData(tHandle, sTitle) + theRefs = pIndex.getReferences(tHandle, sTitle) + if nwItem is None or novIdx is None: + return False + + if novIdx.level in self.LVL_MAP: + self.titleLabel.setText("%s" % self.tr(self.LVL_MAP[novIdx.level])) + else: + self.titleLabel.setText("%s" % self.tr("Title")) + self.titleValue.setText(novIdx.title) + + itemStatus, _ = nwItem.getImportStatus() + + self.fileValue.setText(nwItem.itemName) + self.itemValue.setText(itemStatus) + + cC = checkInt(novIdx.charCount, 0) + wC = checkInt(novIdx.wordCount, 0) + pC = checkInt(novIdx.paraCount, 0) + + self.cCValue.setText(f"{cC:n}") + self.wCValue.setText(f"{wC:n}") + self.pCValue.setText(f"{pC:n}") + + self.synopValue.setText(novIdx.synopsis) + + self.povKeyValue.setText(self._formatTags(theRefs, nwKeyWords.POV_KEY)) + self.focKeyValue.setText(self._formatTags(theRefs, nwKeyWords.FOCUS_KEY)) + self.chrKeyValue.setText(self._formatTags(theRefs, nwKeyWords.CHAR_KEY)) + self.pltKeyValue.setText(self._formatTags(theRefs, nwKeyWords.PLOT_KEY)) + self.timKeyValue.setText(self._formatTags(theRefs, nwKeyWords.TIME_KEY)) + self.wldKeyValue.setText(self._formatTags(theRefs, nwKeyWords.WORLD_KEY)) + self.objKeyValue.setText(self._formatTags(theRefs, nwKeyWords.OBJECT_KEY)) + self.entKeyValue.setText(self._formatTags(theRefs, nwKeyWords.ENTITY_KEY)) + self.cstKeyValue.setText(self._formatTags(theRefs, nwKeyWords.CUSTOM_KEY)) + + return True + + @pyqtSlot() + def updateClasses(self): + """Update the visibility status of class details. + """ + usedClasses = self.theProject.tree.rootClasses() + + pltVisible = nwItemClass.PLOT in usedClasses + timVisible = nwItemClass.TIMELINE in usedClasses + wldVisible = nwItemClass.WORLD in usedClasses + objVisible = nwItemClass.OBJECT in usedClasses + entVisible = nwItemClass.ENTITY in usedClasses + cstVisible = nwItemClass.CUSTOM in usedClasses + + self.pltKeyLabel.setVisible(pltVisible) + self.pltKeyValue.setVisible(pltVisible) + self.timKeyLabel.setVisible(timVisible) + self.timKeyValue.setVisible(timVisible) + self.wldKeyLabel.setVisible(wldVisible) + self.wldKeyValue.setVisible(wldVisible) + self.objKeyLabel.setVisible(objVisible) + self.objKeyValue.setVisible(objVisible) + self.entKeyLabel.setVisible(entVisible) + self.entKeyValue.setVisible(entVisible) + self.cstKeyLabel.setVisible(cstVisible) + self.cstKeyValue.setVisible(cstVisible) + return -# END Class GuiOutlineHeaderMenu + @staticmethod + def _formatTags(refs, key): + """Convert a list of tags into a list of clickable tag links. + """ + return ", ".join( + [f"{tag}" for tag in refs.get(key, [])] + ) + +# END Class GuiOutlineDetails diff --git a/novelwriter/gui/outlinedetails.py b/novelwriter/gui/outlinedetails.py deleted file mode 100644 index cc97cdf5..00000000 --- a/novelwriter/gui/outlinedetails.py +++ /dev/null @@ -1,349 +0,0 @@ -""" -novelWriter – GUI Project Outline Details -========================================= -GUI class for the project outline details panel - -File History: -Created: 2020-06-02 [0.7.0] - -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 PyQt5.QtCore import Qt, QT_TRANSLATE_NOOP -from PyQt5.QtWidgets import ( - QScrollArea, QWidget, QGridLayout, QHBoxLayout, QGroupBox, QLabel -) - -from novelwriter.common import checkInt -from novelwriter.constants import trConst, nwKeyWords, nwLabels - -logger = logging.getLogger(__name__) - - -class GuiOutlineDetails(QScrollArea): - - LVL_MAP = { - "H1": QT_TRANSLATE_NOOP("GuiOutlineDetails", "Title"), - "H2": QT_TRANSLATE_NOOP("GuiOutlineDetails", "Chapter"), - "H3": QT_TRANSLATE_NOOP("GuiOutlineDetails", "Scene"), - "H4": QT_TRANSLATE_NOOP("GuiOutlineDetails", "Section"), - } - - def __init__(self, theParent): - QScrollArea.__init__(self, theParent) - - logger.debug("Initialising GuiOutlineDetails ...") - - self.mainConf = novelwriter.CONFIG - self.theParent = theParent - self.theProject = theParent.theProject - self.theTheme = theParent.theTheme - self.theIndex = theParent.theIndex - self.optState = theParent.theProject.optState - - # Sizes - minTitle = 30*self.theTheme.textNWidth - maxTitle = 40*self.theTheme.textNWidth - wCount = self.theTheme.getTextWidth("999,999") - hSpace = int(self.mainConf.pxInt(10)) - vSpace = int(self.mainConf.pxInt(4)) - - # Details Area - self.titleLabel = QLabel("%s" % self.tr("Title")) - self.fileLabel = QLabel("%s" % self.tr("Document")) - self.itemLabel = QLabel("%s" % self.tr("Status")) - self.titleValue = QLabel("") - self.fileValue = QLabel("") - self.itemValue = QLabel("") - - self.titleValue.setMinimumWidth(minTitle) - self.titleValue.setMaximumWidth(maxTitle) - self.fileValue.setMinimumWidth(minTitle) - self.fileValue.setMaximumWidth(maxTitle) - self.itemValue.setMinimumWidth(minTitle) - self.itemValue.setMaximumWidth(maxTitle) - - # Stats Area - self.cCLabel = QLabel("%s" % self.tr("Characters")) - self.wCLabel = QLabel("%s" % self.tr("Words")) - self.pCLabel = QLabel("%s" % self.tr("Paragraphs")) - self.cCValue = QLabel("") - self.wCValue = QLabel("") - self.pCValue = QLabel("") - - self.cCValue.setMinimumWidth(wCount) - self.wCValue.setMinimumWidth(wCount) - self.pCValue.setMinimumWidth(wCount) - self.cCValue.setAlignment(Qt.AlignRight) - self.wCValue.setAlignment(Qt.AlignRight) - self.pCValue.setAlignment(Qt.AlignRight) - - # Synopsis - self.synopLabel = QLabel("%s" % self.tr("Synopsis")) - self.synopValue = QLabel("") - self.synopLWrap = QHBoxLayout() - self.synopValue.setWordWrap(True) - self.synopValue.setAlignment(Qt.AlignTop | Qt.AlignLeft) - self.synopLWrap.addWidget(self.synopValue, 1) - - # Tags - self.povKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.POV_KEY])) - self.focKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.FOCUS_KEY])) - self.chrKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.CHAR_KEY])) - self.pltKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.PLOT_KEY])) - self.timKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.TIME_KEY])) - self.wldKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.WORLD_KEY])) - self.objKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.OBJECT_KEY])) - self.entKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.ENTITY_KEY])) - self.cstKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.CUSTOM_KEY])) - - self.povKeyLWrap = QHBoxLayout() - self.focKeyLWrap = QHBoxLayout() - self.chrKeyLWrap = QHBoxLayout() - self.pltKeyLWrap = QHBoxLayout() - self.timKeyLWrap = QHBoxLayout() - self.wldKeyLWrap = QHBoxLayout() - self.objKeyLWrap = QHBoxLayout() - self.entKeyLWrap = QHBoxLayout() - self.cstKeyLWrap = QHBoxLayout() - - self.povKeyValue = QLabel("") - self.focKeyValue = QLabel("") - self.chrKeyValue = QLabel("") - self.pltKeyValue = QLabel("") - self.timKeyValue = QLabel("") - self.wldKeyValue = QLabel("") - self.objKeyValue = QLabel("") - self.entKeyValue = QLabel("") - self.cstKeyValue = QLabel("") - - self.povKeyValue.setWordWrap(True) - self.focKeyValue.setWordWrap(True) - self.chrKeyValue.setWordWrap(True) - self.pltKeyValue.setWordWrap(True) - self.timKeyValue.setWordWrap(True) - self.wldKeyValue.setWordWrap(True) - self.objKeyValue.setWordWrap(True) - self.entKeyValue.setWordWrap(True) - self.cstKeyValue.setWordWrap(True) - - self.povKeyValue.linkActivated.connect(self._tagClicked) - self.focKeyValue.linkActivated.connect(self._tagClicked) - self.chrKeyValue.linkActivated.connect(self._tagClicked) - self.pltKeyValue.linkActivated.connect(self._tagClicked) - self.timKeyValue.linkActivated.connect(self._tagClicked) - self.wldKeyValue.linkActivated.connect(self._tagClicked) - self.objKeyValue.linkActivated.connect(self._tagClicked) - self.entKeyValue.linkActivated.connect(self._tagClicked) - self.cstKeyValue.linkActivated.connect(self._tagClicked) - - self.povKeyLWrap.addWidget(self.povKeyValue, 1) - self.focKeyLWrap.addWidget(self.focKeyValue, 1) - self.chrKeyLWrap.addWidget(self.chrKeyValue, 1) - self.pltKeyLWrap.addWidget(self.pltKeyValue, 1) - self.timKeyLWrap.addWidget(self.timKeyValue, 1) - self.wldKeyLWrap.addWidget(self.wldKeyValue, 1) - self.objKeyLWrap.addWidget(self.objKeyValue, 1) - self.entKeyLWrap.addWidget(self.entKeyValue, 1) - self.cstKeyLWrap.addWidget(self.cstKeyValue, 1) - - # Selected Item Details - self.mainGroup = QGroupBox(self.tr("Title Details"), self) - self.mainForm = QGridLayout() - self.mainGroup.setLayout(self.mainForm) - - self.mainForm.addWidget(self.titleLabel, 0, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.mainForm.addWidget(self.titleValue, 0, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.mainForm.addWidget(self.cCLabel, 0, 2, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.mainForm.addWidget(self.cCValue, 0, 3, 1, 1, Qt.AlignTop | Qt.AlignRight) - self.mainForm.addWidget(self.fileLabel, 1, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.mainForm.addWidget(self.fileValue, 1, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.mainForm.addWidget(self.wCLabel, 1, 2, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.mainForm.addWidget(self.wCValue, 1, 3, 1, 1, Qt.AlignTop | Qt.AlignRight) - self.mainForm.addWidget(self.itemLabel, 2, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.mainForm.addWidget(self.itemValue, 2, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.mainForm.addWidget(self.pCLabel, 2, 2, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.mainForm.addWidget(self.pCValue, 2, 3, 1, 1, Qt.AlignTop | Qt.AlignRight) - self.mainForm.addWidget(self.synopLabel, 3, 0, 1, 4, Qt.AlignTop | Qt.AlignLeft) - self.mainForm.addLayout(self.synopLWrap, 4, 0, 1, 4, Qt.AlignTop | Qt.AlignLeft) - - self.mainForm.setColumnStretch(1, 1) - self.mainForm.setRowStretch(4, 1) - self.mainForm.setHorizontalSpacing(hSpace) - self.mainForm.setVerticalSpacing(vSpace) - - # Selected Item Tags - self.tagsGroup = QGroupBox(self.tr("Reference Tags"), self) - self.tagsForm = QGridLayout() - self.tagsGroup.setLayout(self.tagsForm) - - self.tagsForm.addWidget(self.povKeyLabel, 0, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.tagsForm.addLayout(self.povKeyLWrap, 0, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.tagsForm.addWidget(self.focKeyLabel, 1, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.tagsForm.addLayout(self.focKeyLWrap, 1, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.tagsForm.addWidget(self.chrKeyLabel, 2, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.tagsForm.addLayout(self.chrKeyLWrap, 2, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.tagsForm.addWidget(self.pltKeyLabel, 3, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.tagsForm.addLayout(self.pltKeyLWrap, 3, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.tagsForm.addWidget(self.timKeyLabel, 4, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.tagsForm.addLayout(self.timKeyLWrap, 4, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.tagsForm.addWidget(self.wldKeyLabel, 5, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.tagsForm.addLayout(self.wldKeyLWrap, 5, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.tagsForm.addWidget(self.objKeyLabel, 6, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.tagsForm.addLayout(self.objKeyLWrap, 6, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.tagsForm.addWidget(self.entKeyLabel, 7, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.tagsForm.addLayout(self.entKeyLWrap, 7, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.tagsForm.addWidget(self.cstKeyLabel, 8, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.tagsForm.addLayout(self.cstKeyLWrap, 8, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) - - self.tagsForm.setColumnStretch(1, 1) - self.tagsForm.setRowStretch(8, 1) - self.tagsForm.setHorizontalSpacing(hSpace) - self.tagsForm.setVerticalSpacing(vSpace) - - # Assemble - self.outerWidget = QWidget() - self.outerBox = QHBoxLayout() - self.outerBox.addWidget(self.mainGroup, 0) - self.outerBox.addWidget(self.tagsGroup, 1) - - self.outerWidget.setLayout(self.outerBox) - self.setWidget(self.outerWidget) - - self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) - self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) - self.setWidgetResizable(True) - - self.initDetails() - - logger.debug("GuiOutlineDetails initialisation complete") - - return - - def initDetails(self): - """Set or update outline settings. - """ - # Scroll bars - if self.mainConf.hideVScroll: - self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - else: - self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) - - if self.mainConf.hideHScroll: - self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - else: - self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) - - return - - def clearDetails(self): - """Clear all the data labels. - """ - self.titleLabel.setText("%s" % self.tr("Title")) - self.titleValue.setText("") - self.fileValue.setText("") - self.itemValue.setText("") - self.cCValue.setText("") - self.wCValue.setText("") - self.pCValue.setText("") - self.synopValue.setText("") - self.povKeyValue.setText("") - self.focKeyValue.setText("") - self.chrKeyValue.setText("") - self.pltKeyValue.setText("") - self.timKeyValue.setText("") - self.wldKeyValue.setText("") - self.objKeyValue.setText("") - self.entKeyValue.setText("") - self.cstKeyValue.setText("") - return - - def showItem(self, tHandle, sTitle): - """Update the content of the tree with the given handle and line - number pointing to a header. - """ - nwItem = self.theProject.projTree[tHandle] - novIdx = self.theIndex.getNovelData(tHandle, sTitle) - theRefs = self.theIndex.getReferences(tHandle, sTitle) - if nwItem is None or novIdx is None: - return False - - if novIdx["level"] in self.LVL_MAP: - self.titleLabel.setText("%s" % self.tr(self.LVL_MAP[novIdx["level"]])) - else: - self.titleLabel.setText("%s" % self.tr("Title")) - self.titleValue.setText(novIdx["title"]) - - self.fileValue.setText(nwItem.itemName) - self.itemValue.setText(nwItem.itemStatus) - - cC = checkInt(novIdx["cCount"], 0) - wC = checkInt(novIdx["wCount"], 0) - pC = checkInt(novIdx["pCount"], 0) - - self.cCValue.setText(f"{cC:n}") - self.wCValue.setText(f"{wC:n}") - self.pCValue.setText(f"{pC:n}") - - self.synopValue.setText(novIdx["synopsis"]) - - self.povKeyValue.setText(self._formatTags(theRefs, nwKeyWords.POV_KEY)) - self.focKeyValue.setText(self._formatTags(theRefs, nwKeyWords.FOCUS_KEY)) - self.chrKeyValue.setText(self._formatTags(theRefs, nwKeyWords.CHAR_KEY)) - self.pltKeyValue.setText(self._formatTags(theRefs, nwKeyWords.PLOT_KEY)) - self.timKeyValue.setText(self._formatTags(theRefs, nwKeyWords.TIME_KEY)) - self.wldKeyValue.setText(self._formatTags(theRefs, nwKeyWords.WORLD_KEY)) - self.objKeyValue.setText(self._formatTags(theRefs, nwKeyWords.OBJECT_KEY)) - self.entKeyValue.setText(self._formatTags(theRefs, nwKeyWords.ENTITY_KEY)) - self.cstKeyValue.setText(self._formatTags(theRefs, nwKeyWords.CUSTOM_KEY)) - - return True - - ## - # Slots - ## - - def _tagClicked(self, theLink): - """Capture the click of a tag in the right-most column. - """ - logger.verbose("Clicked link: '%s'", theLink) - if len(theLink) > 0: - theBits = theLink.split("=") - if len(theBits) == 2: - self.theParent.docViewer.loadFromTag(theBits[1]) - return - - ## - # Internal Functions - ## - - def _formatTags(self, theRefs, theKey): - """Format the tags as clickable links. - """ - if theKey not in theRefs: - return "" - refTags = [] - for tTag in theRefs[theKey]: - refTags.append("%s" % ( - theKey[1:], tTag, tTag - )) - return ", ".join(refTags) - -# END Class GuiOutlineDetails diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index 7c0ec69e..e0a8ef62 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -32,12 +32,14 @@ from time import time from PyQt5.QtCore import Qt, QSize, pyqtSignal, pyqtSlot from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import ( - QTreeWidget, QTreeWidgetItem, QAbstractItemView, QMenu, QAction + QTreeWidget, QTreeWidgetItem, QAbstractItemView, QMenu, QAction, QFrame, + QDialog ) from novelwriter.core import NWDoc from novelwriter.enum import nwItemType, nwItemClass, nwItemLayout, nwAlert -from novelwriter.constants import nwConst, trConst, nwLists, nwLabels +from novelwriter.common import minmax +from novelwriter.dialogs.itemeditor import GuiItemEditor logger = logging.getLogger(__name__) @@ -49,8 +51,9 @@ class GuiProjectTree(QTreeWidget): C_EXPORT = 2 C_STATUS = 3 - novelItemChanged = pyqtSignal() - noteItemChanged = pyqtSignal() + treeItemChanged = pyqtSignal(str) + novelItemChanged = pyqtSignal(str) + rootFolderChanged = pyqtSignal(str) wordCountsChanged = pyqtSignal() def __init__(self, theParent): @@ -62,13 +65,11 @@ class GuiProjectTree(QTreeWidget): self.theParent = theParent self.theTheme = theParent.theTheme self.theProject = theParent.theProject - self.theIndex = theParent.theIndex # Internal Variables - self._treeMap = {} - self._treeChanged = False + self._treeMap = {} + self._lastMove = {} self._timeChanged = 0 - self._lastMove = {} ## # Build GUI @@ -82,7 +83,8 @@ class GuiProjectTree(QTreeWidget): # Tree Settings iPx = self.theTheme.baseIconSize self.setIconSize(QSize(iPx, iPx)) - self.setExpandsOnDoubleClick(True) + self.setFrameStyle(QFrame.NoFrame) + self.setExpandsOnDoubleClick(False) self.setIndentation(iPx) self.setColumnCount(4) self.setHeaderLabels([ @@ -157,147 +159,97 @@ class GuiProjectTree(QTreeWidget): """ self.clear() self._treeMap = {} - self._treeChanged = False + self._lastMove = {} self._timeChanged = 0 return - def newTreeItem(self, itemType, itemClass): - """Add new item to the tree, with a given itemType and - itemClass, and attach it to the selected handle. Also make sure - the item is added in a place it can be added, and that other - meta data is set correctly to ensure a valid project tree. + def newTreeItem(self, itemType, itemClass=None): + """Add new item to the tree, with a given itemType (and + itemClass if Root), and attach it to the selected handle. Also + make sure the item is added in a place it can be added, and that + other meta data is set correctly to ensure a valid project tree. """ - pHandle = self.getSelectedHandle() - nHandle = None - if not self.theParent.hasProject: logger.error("No project open") return False - if not isinstance(itemType, nwItemType): - # This would indicate an internal bug - logger.error("No itemType provided") - return False + nHandle = None + tHandle = None - # The item needs to be assigned an item class, so one must be - # provided, or it must be possible to extract it from the parent - # item of the new item. - if itemClass is None and pHandle is not None: - pItem = self.theProject.projTree[pHandle] - if pItem is not None: - itemClass = pItem.itemClass + if itemType == nwItemType.ROOT and isinstance(itemClass, nwItemClass): - # If class is still not set, alert the user and exit - if itemClass is None: - if itemType == nwItemType.FILE: - self.theParent.makeAlert(self.tr( - "Please select a valid location in the tree to add the document." - ), nwAlert.ERROR) - else: - self.theParent.makeAlert(self.tr( - "Please select a valid location in the tree to add the folder." - ), nwAlert.ERROR) - return False + tHandle = self.theProject.newRoot(itemClass) - # Everything is fine, we have what we need, so we proceed - logger.verbose( - "Adding new item of type '%s' and class '%s' to handle '%s'", - itemType.name, itemClass.name, str(pHandle) - ) + elif itemType in (nwItemType.FILE, nwItemType.FOLDER): - if itemType == nwItemType.ROOT: - tHandle = self.theProject.newRoot( - trConst(nwLabels.CLASS_NAME[itemClass]), itemClass - ) - if tHandle is None: - logger.error("No root item added") - return False - - else: - # If no parent has been selected, make the new file under - # the root NOVEL item. - if pHandle is None: - pHandle = self.theProject.projTree.findRoot(nwItemClass.NOVEL) - - # If still nothing, give up - if pHandle is None: + sHandle = self.getSelectedHandle() + if sHandle is None or sHandle not in self.theProject.tree: self.theParent.makeAlert(self.tr( "Did not find anywhere to add the file or folder!" ), nwAlert.ERROR) return False - # Now check if the selected item is a file, in which case - # the new file will be a sibling - pItem = self.theProject.projTree[pHandle] - if pItem.itemType == nwItemType.FILE: - nHandle = pHandle - pHandle = pItem.itemParent + # If the selected item is a file, the new item will be a + # sibling if the file has no children, otherwise a child + 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 - # If we again have no home, give up - if pHandle is None: - self.theParent.makeAlert(self.tr( - "Did not find anywhere to add the file or folder!" - ), nwAlert.ERROR) - return False - - if self.theProject.projTree.isTrashRoot(pHandle): + if self.theProject.tree.isTrash(sHandle): self.theParent.makeAlert(self.tr( "Cannot add new files or folders to the Trash folder." ), nwAlert.ERROR) return False - parTree = self.theProject.projTree.getItemPath(pHandle) - - # If we're still here, add the file or folder + # Add the file or folder if itemType == nwItemType.FILE: - tHandle = self.theProject.newFile(self.tr("New File"), itemClass, pHandle) - + if pItem.isNovelLike(): + tHandle = self.theProject.newFile(self.tr("New Document"), sHandle) + else: + tHandle = self.theProject.newFile(self.tr("New Note"), sHandle) elif itemType == nwItemType.FOLDER: - if len(parTree) >= nwConst.MAX_DEPTH - 1: - # Folders cannot be deeper than MAX_DEPTH - 1, leaving room - # for one more level of files. - self.theParent.makeAlert(self.tr( - "Cannot add new folder to this item. " - "Maximum folder depth has been reached." - ), nwAlert.ERROR) - return False - tHandle = self.theProject.newFolder(self.tr("New Folder"), itemClass, pHandle) + tHandle = self.theProject.newFolder(self.tr("New Folder"), sHandle) - else: - logger.error("Failed to add new item") - return False + else: + logger.error("Failed to add new item") + return False - # If there is no handle set, return here - if tHandle is None: + # If there is no handle set, return here. This is a bug + if tHandle is None: # pragma: no cover return True # Add the new item to the tree self.revealNewTreeItem(tHandle, nHandle) self.theParent.editItem(tHandle) - nwItem = self.theProject.projTree[tHandle] - # If this is a folder, return here + # Handle new file creation + nwItem = self.theProject.tree[tHandle] if nwItem.itemType != nwItemType.FILE: return True - # This is a new files, so let's add some content + # This is a new file, so let's add some content newDoc = NWDoc(self.theProject, tHandle) - curTxt = newDoc.readDocument() - if curTxt is None: - curTxt = "" - - if curTxt == "": + if not newDoc.readDocument(): if nwItem.itemLayout == nwItemLayout.DOCUMENT: - newText = f"### {nwItem.itemName}\n\n" + iLvl = self.theProject.index.getHandleHeaderIntLevel(sHandle) + hLvl = "#"*minmax(iLvl + 1, 2, 4) + newText = f"{hLvl} {nwItem.itemName}\n\n" else: newText = f"# {nwItem.itemName}\n\n" + pIndex = self.theProject.index + # Save the text and index it newDoc.writeDocument(newText) - self.theIndex.scanText(tHandle, newText) + pIndex.scanText(tHandle, newText) # Get Word Counts - cC, wC, pC = self.theIndex.getCounts(tHandle) + cC, wC, pC = pIndex.getCounts(tHandle) nwItem.setCharCount(cC) nwItem.setWordCount(wC) nwItem.setParaCount(pC) @@ -309,7 +261,10 @@ class GuiProjectTree(QTreeWidget): def revealNewTreeItem(self, tHandle, nHandle=None): """Reveal a newly added project item in the project tree. """ - nwItem = self.theProject.projTree[tHandle] + nwItem = self.theProject.tree[tHandle] + if nwItem is None: + return False + trItem = self._addTreeItem(nwItem, nHandle) if trItem is None: return False @@ -318,7 +273,7 @@ class GuiProjectTree(QTreeWidget): if pHandle is not None and pHandle in self._treeMap: self._treeMap[pHandle].setExpanded(True) - self._emitItemChange(tHandle) + self._alertTreeChange(tHandle=tHandle, flush=True) self.clearSelection() trItem.setSelected(True) @@ -360,10 +315,31 @@ class GuiProjectTree(QTreeWidget): pItem.insertChild(nIndex, cItem) self._recordLastMove(cItem, pItem, tIndex) + self._alertTreeChange(tHandle=tHandle, flush=True) self.clearSelection() cItem.setSelected(True) - self._setTreeChanged(True) - self._emitItemChange(tHandle) + + return True + + def editTreeItem(self, tHandle=None): + """Open the edit item dialog. + """ + if tHandle is None: + logger.warning("No item selected") + return False + + tItem = self.theProject.tree[tHandle] + if tItem is None: + return False + if tItem.itemType == nwItemType.NO_TYPE: + return False + + logger.verbose("Requesting change to item '%s'", tHandle) + dlgProj = GuiItemEditor(self, tHandle) + dlgProj.exec_() + if dlgProj.result() == QDialog.Accepted: + self.setTreeItemValues(tHandle) + self._alertTreeChange(tHandle=tHandle, flush=False) return True @@ -380,16 +356,6 @@ class GuiProjectTree(QTreeWidget): self.theProject.setTreeOrder(theList) return True - def flushTreeOrder(self): - """Calls saveTreeOrder if there are unsaved changes, otherwise - does nothing. - """ - if self._treeChanged: - logger.verbose("Flushing project tree to project class") - self.saveTreeOrder() - self._setTreeChanged(False) - return - def getTreeFromHandle(self, tHandle): """Recursively return all the children items starting from a given item handle. @@ -400,6 +366,14 @@ class GuiProjectTree(QTreeWidget): theList = self._scanChildren(theList, theItem, 0) return theList + def toggleExpanded(self, tHandle): + """Expand an item based on its handle. + """ + trItem = self._getTreeItem(tHandle) + if trItem is not None: + trItem.setExpanded(not trItem.isExpanded()) + return + def getColumnSizes(self): """Return the column widths for the tree columns. """ @@ -419,7 +393,7 @@ class GuiProjectTree(QTreeWidget): logger.error("No project open") return False - trashHandle = self.theProject.projTree.trashRoot() + trashHandle = self.theProject.tree.trashRoot() logger.debug("Emptying Trash folder") if trashHandle is None: @@ -447,13 +421,13 @@ class GuiProjectTree(QTreeWidget): return False logger.verbose("Deleting %d file(s) from Trash", nTrash) - for tHandle in self.getTreeFromHandle(trashHandle): + for tHandle in reversed(self.getTreeFromHandle(trashHandle)): if tHandle == trashHandle: continue self.deleteItem(tHandle, alreadyAsked=True, bulkAction=True) if nTrash > 0: - self._setTreeChanged(True) + self._alertTreeChange(tHandle=trashHandle, flush=True) return True @@ -461,8 +435,8 @@ class GuiProjectTree(QTreeWidget): """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. Folders are deleted if they're empty only, - and the deletion is always permanent. + delete the files on disk. Root folders are deleted if they're empty + only, and the deletion is always permanent. """ if not self.theParent.hasProject: logger.error("No project open") @@ -480,109 +454,22 @@ class GuiProjectTree(QTreeWidget): return False trItemS = self._getTreeItem(tHandle) - nwItemS = self.theProject.projTree[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 - wCount = int(trItemS.data(self.C_COUNT, Qt.UserRole)) - if nwItemS.itemType == nwItemType.FILE: - logger.debug("User requested file '%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 - - pHandle = nwItemS.itemParent - if self.theProject.projTree.isTrashRoot(pHandle): - # 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.theParent.askQuestion( - self.tr("Delete File"), - self.tr("Permanently delete file '{0}'?").format(nwItemS.itemName) - ) - if msgYes: - doPermanent = True - else: - doPermanent = True - - if doPermanent: - logger.debug("Permanently deleting file with handle '%s'", tHandle) - - self.propagateCount(tHandle, 0) - tIndex = trItemP.indexOfChild(trItemS) - trItemC = trItemP.takeChild(tIndex) - - if self.theParent.docEditor.docHandle() == tHandle: - self.theParent.closeDocument() - - delDoc = NWDoc(self.theProject, tHandle) - if not delDoc.deleteDocument(): - self.theParent.makeAlert([ - self.tr("Could not delete document file."), delDoc.getError() - ], nwAlert.ERROR) - return False - - self.theIndex.deleteHandle(tHandle) - self._deleteTreeItem(tHandle) - self._setTreeChanged(True) - self.wordCountsChanged.emit() - - else: - # The file is not already in the trash folder, so we - # move it there. - msgYes = self.theParent.askQuestion( - self.tr("Delete File"), - self.tr("Move file '{0}' to Trash?").format(nwItemS.itemName), - ) - if msgYes: - if pHandle is None: - logger.warning("File has no parent item") - - logger.debug("Moving file '%s' to trash", tHandle) - - self.propagateCount(tHandle, 0) - tIndex = trItemP.indexOfChild(trItemS) - trItemC = trItemP.takeChild(tIndex) - trItemT.addChild(trItemC) - self._updateItemParent(tHandle) - self.propagateCount(tHandle, wCount) - - self.theIndex.deleteHandle(tHandle) - self._recordLastMove(trItemS, trItemP, tIndex) - self._setTreeChanged(True) - - elif nwItemS.itemType == nwItemType.FOLDER: - logger.debug("User requested folder '%s' deleted", tHandle) - trItemP = trItemS.parent() - if trItemP is None: - logger.error("Could not delete folder") - return False - tIndex = trItemP.indexOfChild(trItemS) - if trItemS.childCount() == 0: - trItemP.takeChild(tIndex) - self._deleteTreeItem(tHandle) - self._setTreeChanged(True) - else: - self.theParent.makeAlert(self.tr( - "Cannot delete folder. It is not empty. " - "Recursive deletion is not supported. " - "Please delete the content first." - ), nwAlert.ERROR) - return False - - elif nwItemS.itemType == nwItemType.ROOT: - logger.debug("User requested root folder '%s' deleted", tHandle) + wCount = self._getItemWordCount(tHandle) + autoFlush = not bulkAction + if nwItemS.itemType == nwItemType.ROOT: + # 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.theParent.mainMenu.setAvailableRoot() - self._setTreeChanged(True) + self._alertTreeChange(tHandle=tHandle, flush=True) else: self.theParent.makeAlert(self.tr( "Cannot delete root folder. It is not empty. " @@ -591,6 +478,70 @@ class GuiProjectTree(QTreeWidget): ), 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) + 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 + + 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.theParent.askQuestion( + self.tr("Delete"), + self.tr("Permanently delete '{0}'?").format(nwItemS.itemName) + ) + if msgYes: + doPermanent = True + else: + doPermanent = True + + 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.theParent.docEditor.docHandle() == dHandle: + self.theParent.closeDocument() + self._deleteTreeItem(dHandle) + + self._alertTreeChange(tHandle=tHandle, flush=autoFlush) + self.wordCountsChanged.emit() + + else: + # The item is not already in the trash folder, so we + # move it there. + msgYes = self.theParent.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) + return True def setTreeItemValues(self, tHandle): @@ -599,7 +550,7 @@ class GuiProjectTree(QTreeWidget): already coming from the project tree. """ trItem = self._getTreeItem(tHandle) - nwItem = self.theProject.projTree[tHandle] + nwItem = self.theProject.tree[tHandle] if trItem is None or nwItem is None: return @@ -610,8 +561,8 @@ class GuiProjectTree(QTreeWidget): else: expIcon = self.theTheme.getIcon("cross") - itempStatus, statusIcon = nwItem.getImportStatus() - hLevel = self.theIndex.getHandleHeaderLevel(tHandle) + itemStatus, statusIcon = nwItem.getImportStatus() + hLevel = self.theProject.index.getHandleHeaderLevel(tHandle) itemIcon = self.theTheme.getItemIcon( nwItem.itemType, nwItem.itemClass, nwItem.itemLayout, hLevel ) @@ -620,7 +571,7 @@ class GuiProjectTree(QTreeWidget): trItem.setText(self.C_NAME, nwItem.itemName) trItem.setIcon(self.C_EXPORT, expIcon) trItem.setIcon(self.C_STATUS, statusIcon) - trItem.setToolTip(self.C_STATUS, itempStatus) + trItem.setToolTip(self.C_STATUS, itemStatus) if self.mainConf.emphLabels and nwItem.itemLayout == nwItemLayout.DOCUMENT: trFont = trItem.font(self.C_NAME) @@ -634,7 +585,7 @@ class GuiProjectTree(QTreeWidget): return - def propagateCount(self, tHandle, theCount, nDepth=0): + def propagateCount(self, tHandle, newCount, countChildren=False): """Recursive function setting the word count for a given item, and propagating that count upwards in the tree until reaching a root item. This function is more efficient than recalculating @@ -646,20 +597,30 @@ class GuiProjectTree(QTreeWidget): if tItem is None: return - tItem.setText(self.C_COUNT, f"{theCount:n}") - tItem.setData(self.C_COUNT, Qt.UserRole, int(theCount)) + if countChildren: + for i in range(tItem.childCount()): + newCount += int(tItem.child(i).data(self.C_COUNT, Qt.UserRole)) + + tItem.setText(self.C_COUNT, f"{newCount:n}") + tItem.setData(self.C_COUNT, Qt.UserRole, int(newCount)) pItem = tItem.parent() if pItem is None: return 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) - if not nDepth > nwConst.MAX_DEPTH + 1 and pHandle != "": - self.propagateCount(pHandle, pCount, nDepth+1) + if pHandle: + if self.theProject.tree.checkType(pHandle, nwItemType.FILE): + # A file has an internal word count we need to account + # for, but a folder always has 0 words on its own. + pCount += self.theProject.index.getCounts(pHandle)[1] + + self.propagateCount(pHandle, pCount, countChildren=False) return @@ -703,20 +664,19 @@ class GuiProjectTree(QTreeWidget): return False dstIndex = min(max(0, dstIndex), dstItem.childCount()) - wCount = int(srcItem.data(self.C_COUNT, Qt.UserRole)) sHandle = srcItem.data(self.C_NAME, Qt.UserRole) dHandle = dstItem.data(self.C_NAME, Qt.UserRole) logger.debug("Moving item '%s' back to '%s', index %d", sHandle, dHandle, dstIndex) + wCount = self._getItemWordCount(sHandle) self.propagateCount(sHandle, 0) parItem = srcItem.parent() srcIndex = parItem.indexOfChild(srcItem) movItem = parItem.takeChild(srcIndex) dstItem.insertChild(dstIndex, movItem) - snItem = self.theProject.projTree[sHandle] - dnItem = self.theProject.projTree[dHandle] - self._postItemMove(sHandle, snItem, dnItem, wCount) + self._postItemMove(sHandle, wCount) + self._alertTreeChange(tHandle=sHandle, flush=True) self.clearSelection() movItem.setSelected(True) @@ -771,7 +731,7 @@ class GuiProjectTree(QTreeWidget): if isinstance(selItem, QTreeWidgetItem): tHandle = selItem.data(self.C_NAME, Qt.UserRole) self.setSelectedHandle(tHandle) # Just to be safe - tItem = self.theProject.projTree[tHandle] + tItem = self.theProject.tree[tHandle] if tItem is not None: if self.ctxMenu.filterActions(tItem): # Only open menu if any actions remain after filter @@ -783,7 +743,7 @@ class GuiProjectTree(QTreeWidget): def doUpdateCounts(self, tHandle, cCount, wCount, pCount): """Slot for updating the word count of a specific item. """ - self.propagateCount(tHandle, wCount) + self.propagateCount(tHandle, wCount, countChildren=True) self.wordCountsChanged.emit() return @@ -809,7 +769,7 @@ class GuiProjectTree(QTreeWidget): return tHandle = selItem.data(self.C_NAME, Qt.UserRole) - tItem = self.theProject.projTree[tHandle] + tItem = self.theProject.tree[tHandle] if tItem is None: return @@ -819,61 +779,34 @@ class GuiProjectTree(QTreeWidget): return def dropEvent(self, theEvent): - """Overload the drop of dragged item event to check whether the - drop is allowed or not. Disallowed drops are cancelled. + """Overload the drop item event to ensure relevant data has been + updated. """ sHandle = self.getSelectedHandle() if sHandle is None: - logger.error("No handle selected") + logger.error("Invalid drag and drop event") return - dIndex = self.indexAt(theEvent.pos()) - if not dIndex.isValid(): - logger.error("Invalid drop index") - return + logger.debug("Drag'n'drop of item '%s' accepted", sHandle) sItem = self._getTreeItem(sHandle) - dItem = self.itemFromIndex(dIndex) - dHandle = dItem.data(self.C_NAME, Qt.UserRole) - snItem = self.theProject.projTree[sHandle] - dnItem = self.theProject.projTree[dHandle] - if dnItem is None: - self.theParent.makeAlert(self.tr( - "The item cannot be moved to that location." - ), nwAlert.ERROR) - return + isExpanded = False + if sItem is not None: + isExpanded = sItem.isExpanded() pItem = sItem.parent() pIndex = 0 if pItem is not None: pIndex = pItem.indexOfChild(sItem) - wCount = int(sItem.data(self.C_COUNT, Qt.UserRole)) - isFile = snItem.itemType == nwItemType.FILE - isRoot = snItem.itemType == nwItemType.ROOT - onFile = dnItem.itemType == nwItemType.FILE + wCount = self._getItemWordCount(sHandle) + self.propagateCount(sHandle, 0) - isSame = snItem.itemClass == dnItem.itemClass - isNone = snItem.itemClass == nwItemClass.NO_CLASS - isNote = snItem.itemLayout == nwItemLayout.NOTE - onFree = dnItem.itemClass in nwLists.FREE_CLASS and isFile - - allowDrop = isSame or isNone or isNote or onFree - allowDrop &= not (self.dropIndicatorPosition() == QAbstractItemView.OnItem and onFile) - - if allowDrop and not isRoot: - logger.debug("Drag'n'drop of item '%s' accepted", sHandle) - self.propagateCount(sHandle, 0) - QTreeWidget.dropEvent(self, theEvent) - self._postItemMove(sHandle, snItem, dnItem, wCount) - self._recordLastMove(sItem, pItem, pIndex) - - else: - theEvent.ignore() - logger.debug("Drag'n'drop of item '%s' not accepted", sHandle) - self.theParent.makeAlert(self.tr( - "The item cannot be moved to that location." - ), nwAlert.ERROR) + QTreeWidget.dropEvent(self, theEvent) + self._postItemMove(sHandle, wCount) + self._recordLastMove(sItem, pItem, pIndex) + self._alertTreeChange(tHandle=sHandle, flush=True) + sItem.setExpanded(isExpanded) return @@ -881,64 +814,88 @@ class GuiProjectTree(QTreeWidget): # Internal Functions ## - def _postItemMove(self, sHandle, snItem, dnItem, wCount): + def _postItemMove(self, tHandle, wCount): """Run various maintenance tasks for a moved item. """ - isFile = snItem.itemType == nwItemType.FILE - isSame = snItem.itemClass == dnItem.itemClass - onFree = dnItem.itemClass in nwLists.FREE_CLASS and isFile + trItemS = self._getTreeItem(tHandle) + nwItemS = self.theProject.tree[tHandle] + trItemP = trItemS.parent() + if trItemP is None: + logger.error("Failed to find new parent item of '%s'", tHandle) + return False - self._updateItemParent(sHandle) + # 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) + nwItemS.setParent(pHandle) + trItemP.setExpanded(True) + logger.debug("The parent of item '%s' has been changed to '%s'", tHandle, pHandle) - # If the item does not have the same class as the target, - # and the target is not a free root folder, update its class - if not (isSame or onFree): - logger.debug( - "Item '%s' class has been changed from '%s' to '%s'", - sHandle, snItem.itemClass.name, dnItem.itemClass.name - ) - snItem.setClass(dnItem.itemClass) - self.setTreeItemValues(sHandle) + mHandles = self.getTreeFromHandle(tHandle) + logger.debug("A total of %d item(s) were moved", len(mHandles)) + for mHandle in mHandles: + logger.debug("Updating item '%s'", mHandle) + self.theProject.tree.updateItemData(mHandle) - self.propagateCount(sHandle, wCount) + # Update the index + if nwItemS.isInactive(): + self.theProject.index.deleteHandle(mHandle) + else: + self.theProject.index.reIndexHandle(mHandle) - # The items dropped into archive or trash should be removed - # from the project index, for all other items, we rescan the - # file to ensure the index is up to date. - if onFree: - self.theIndex.deleteHandle(sHandle) - else: - self.theIndex.reIndexHandle(sHandle) + self.setTreeItemValues(mHandle) # Trigger dependent updates - self._setTreeChanged(True) - self._emitItemChange(sHandle) + self.propagateCount(tHandle, wCount) - return + return True + + def _getItemWordCount(self, tHandle): + """Retrun the word count of a given item handle. + """ + tItem = self._getTreeItem(tHandle) + if tItem is None: + return 0 + return int(tItem.data(self.C_COUNT, Qt.UserRole)) def _getTreeItem(self, tHandle): - """Returns the QTreeWidgetItem of a given item handle. + """Return the QTreeWidgetItem of a given item handle. """ return self._treeMap.get(tHandle, None) def _deleteTreeItem(self, tHandle): - """Delete a tree item from the project and the map. + """Permanently delete a tree item from the project and the map. """ - del self.theProject.projTree[tHandle] + if self.theProject.tree.checkType(tHandle, nwItemType.FILE): + delDoc = NWDoc(self.theProject, tHandle) + if not delDoc.deleteDocument(): + self.theParent.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 + + 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) - nwItem = self.theProject.projTree[tHandle] - nwItem.setExpanded(tItem.isExpanded()) + cCount = tItem.childCount() + + # Update tree-related meta data + nwItem = self.theProject.tree[tHandle] + nwItem.setExpanded(tItem.isExpanded() and cCount > 0) nwItem.setOrder(tIndex) + theList.append(tHandle) - for i in range(tItem.childCount()): + for i in range(cCount): self._scanChildren(theList, tItem.child(i), i) + return theList def _addTreeItem(self, nwItem, nHandle=None): @@ -965,9 +922,7 @@ class GuiProjectTree(QTreeWidget): self._treeMap[tHandle] = newItem if pHandle is None: if nwItem.itemType == nwItemType.ROOT: - self.addTopLevelItem(newItem) - self.theParent.mainMenu.setAvailableRoot() - elif nwItem.itemType == nwItemType.TRASH: + newItem.setFlags(newItem.flags() ^ Qt.ItemIsDragEnabled) self.addTopLevelItem(newItem) else: self.theParent.makeAlert(self.tr( @@ -984,16 +939,14 @@ class GuiProjectTree(QTreeWidget): except Exception: logger.error("Failed to get index of item with handle '%s'", nHandle) if byIndex >= 0: - self._treeMap[pHandle].insertChild(byIndex+1, newItem) + self._treeMap[pHandle].insertChild(byIndex + 1, newItem) else: self._treeMap[pHandle].addChild(newItem) - self.propagateCount(tHandle, nwItem.wordCount) + self.propagateCount(tHandle, nwItem.wordCount, countChildren=True) self.setTreeItemValues(tHandle) newItem.setExpanded(nwItem.isExpanded) - self._setTreeChanged(True) - return newItem def _addTrashRoot(self): @@ -1006,52 +959,34 @@ class GuiProjectTree(QTreeWidget): trItem = self._getTreeItem(trashHandle) if trItem is None: - trItem = self._addTreeItem( - self.theProject.projTree[trashHandle] - ) + trItem = self._addTreeItem(self.theProject.tree[trashHandle]) if trItem is not None: trItem.setExpanded(True) - self._setTreeChanged(True) + self._alertTreeChange(tHandle=trashHandle, flush=True) return trItem - def _updateItemParent(self, tHandle): - """Update the parent handle of an item so that the information - in the project is consistent with the treeView. + def _alertTreeChange(self, tHandle=None, flush=True): + """Update information on tree change state, and emit necessary + signals. """ - trItemS = self._getTreeItem(tHandle) - nwItemS = self.theProject.projTree[tHandle] - trItemP = trItemS.parent() - if trItemP is None: - logger.error("Failed to find new parent item of '%s'", tHandle) - return False + self._timeChanged = time() + self.theProject.setProjectChanged(True) + if flush: + self.saveTreeOrder() - pHandle = trItemP.data(self.C_NAME, Qt.UserRole) - nwItemS.setParent(pHandle) - self.setTreeItemValues(tHandle) + tItem = self.theProject.tree[tHandle] + if tItem is None: + return - logger.debug("The parent of item '%s' has been changed to '%s'", tHandle, pHandle) + itemType = tItem.itemType + if itemType == nwItemType.ROOT: + self.rootFolderChanged.emit(tHandle) + elif itemType == nwItemType.FILE and tItem.isNovelLike(): + self.novelItemChanged.emit(tHandle) - return True + self.treeItemChanged.emit(tHandle) - def _setTreeChanged(self, theState): - """Set the tree change flag, and propagate to the project. - """ - self._treeChanged = theState - if theState: - self._timeChanged = time() - self.theProject.setProjectChanged(True) - return - - def _emitItemChange(self, tHandle): - """Emit an item change signal for a given handle. - """ - if self.theProject.projTree.checkType(tHandle, nwItemType.FILE): - nwItem = self.theProject.projTree[tHandle] - if nwItem.itemClass == nwItemClass.NOVEL: - self.novelItemChanged.emit() - else: - self.noteItemChanged.emit() return def _recordLastMove(self, srcItem, parItem, parIndex): @@ -1130,9 +1065,9 @@ class GuiProjectTreeMenu(QMenu): logger.error("Failed to extract information to build tree context menu") return False - trashHandle = self.theTree.theProject.projTree.trashRoot() + trashHandle = self.theTree.theProject.tree.trashRoot() - inTrash = theItem.itemParent == trashHandle and trashHandle is not None + inTrash = self.theTree.theProject.tree.isTrash(theItem.itemHandle) isTrash = theItem.itemHandle == trashHandle and trashHandle is not None isFile = theItem.itemType == nwItemType.FILE @@ -1182,7 +1117,7 @@ class GuiProjectTreeMenu(QMenu): """Forward the new file call to the project tree. """ if self.theItem is not None: - self.theTree.newTreeItem(nwItemType.FILE, None) + self.theTree.newTreeItem(nwItemType.FILE) return @pyqtSlot() @@ -1190,7 +1125,7 @@ class GuiProjectTreeMenu(QMenu): """Forward the new folder call to the project tree. """ if self.theItem is not None: - self.theTree.newTreeItem(nwItemType.FOLDER, None) + self.theTree.newTreeItem(nwItemType.FOLDER) return @pyqtSlot() diff --git a/novelwriter/gui/theme.py b/novelwriter/gui/theme.py index 6cdf72ef..bb93e0b8 100644 --- a/novelwriter/gui/theme.py +++ b/novelwriter/gui/theme.py @@ -456,20 +456,18 @@ class GuiIcons: ICON_KEYS = { # Project and GUI icons - "novelwriter", "proj_nwx", - "cls_none", "cls_novel", "cls_plot", "cls_character", "cls_world", - "cls_timeline", "cls_object", "cls_entity", "cls_custom", "cls_archive", "cls_trash", - "proj_document", "proj_title", "proj_chapter", "proj_scene", "proj_note", "proj_folder", - "status_lang", "status_time", "status_idle", "status_stats", "status_lines", - "doc_h0", "doc_h1", "doc_h2", "doc_h3", "doc_h4", - "search_case", "search_regex", "search_word", "search_loop", "search_project", - "search_cancel", "search_preserve", + "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", # General Button Icons - "delete", "close", "done", "clear", "save", "add", "remove", - "search", "search_replace", "edit", "check", "cross", "hash", - "maximise", "minimise", "refresh", "reference", "backward", - "forward", "settings", + "add", "backward", "check", "clear", "close", "cross", "delete", "done", "down", "edit", + "forward", "hash", "maximise", "menu", "minimise", "reference", "refresh", "remove", + "save", "search_replace", "search", "settings", "up", # Switches "sticky-on", "sticky-off", @@ -639,9 +637,6 @@ class GuiIcons: iconName = "proj_scene" elif tLayout == nwItemLayout.NOTE: iconName = "proj_note" - elif tType == nwItemType.TRASH: - iconName = nwLabels.CLASS_ICON[tClass] - if iconName is None: return QIcon() diff --git a/novelwriter/gui/viewsbar.py b/novelwriter/gui/viewsbar.py new file mode 100644 index 00000000..e5b627b2 --- /dev/null +++ b/novelwriter/gui/viewsbar.py @@ -0,0 +1,139 @@ +""" +novelWriter – GUI Main Window Views ToolBar +=========================================== +GUI class for the main window "Views" toolbar + +File History: +Created: 2022-05-10 [1.7b1] + +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 PyQt5.QtCore import Qt, QSize, pyqtSignal +from PyQt5.QtWidgets import ( + QToolBar, QWidget, QSizePolicy, QAction, QMenu, QToolButton +) + +from novelwriter.enum import nwView + +logger = logging.getLogger(__name__) + + +class GuiViewsBar(QToolBar): + + viewChangeRequested = pyqtSignal(nwView) + + def __init__(self, theParent): + QToolBar.__init__(self, theParent) + + logger.debug("Initialising GuiViewsBar ...") + + self.mainConf = novelwriter.CONFIG + self.theParent = theParent + self.theTheme = theParent.theTheme + + # Style + iPx = self.mainConf.pxInt(22) + mPx = self.mainConf.pxInt(60) + + lblFont = self.theTheme.guiFont + lblFont.setPointSizeF(0.65*self.theTheme.fontPointSize) + + self.setMovable(False) + self.setToolButtonStyle(Qt.ToolButtonTextUnderIcon) + 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.setFont(lblFont) + self.aProject.setToolTip(self.tr("Show project tree and editor")) + self.aProject.setIcon(self.theTheme.getIcon("view_editor")) + self.aProject.triggered.connect(lambda: self.viewChangeRequested.emit(nwView.PROJECT)) + + self.aNovel = QAction(self.tr("Novel")) + self.aNovel.setFont(lblFont) + self.aNovel.setToolTip(self.tr("Show novel tree and editor")) + self.aNovel.setIcon(self.theTheme.getIcon("view_novel")) + self.aNovel.triggered.connect(lambda: self.viewChangeRequested.emit(nwView.NOVEL)) + + self.aOutline = QAction(self.tr("Outline")) + self.aOutline.setFont(lblFont) + self.aOutline.setToolTip(self.tr("Show novel outline")) + self.aOutline.setIcon(self.theTheme.getIcon("view_outline")) + self.aOutline.triggered.connect(lambda: self.viewChangeRequested.emit(nwView.OUTLINE)) + + self.aBuild = QAction(self.tr("Build")) + self.aBuild.setFont(lblFont) + self.aBuild.setToolTip(self.tr("Build novel project")) + self.aBuild.setIcon(self.theTheme.getIcon("view_build")) + self.aBuild.triggered.connect(lambda: self.theParent.showBuildProjectDialog()) + + self.aDetails = QAction(self.tr("Details")) + self.aDetails.setFont(lblFont) + self.aDetails.setToolTip(self.tr("Show project details")) + self.aDetails.setIcon(self.theTheme.getIcon("proj_details")) + self.aDetails.triggered.connect(lambda: self.theParent.showProjectDetailsDialog()) + + self.aStats = QAction(self.tr("Stats")) + self.aStats.setFont(lblFont) + self.aStats.setToolTip(self.tr("Show project statistics")) + self.aStats.setIcon(self.theTheme.getIcon("proj_stats")) + self.aStats.triggered.connect(lambda: self.theParent.showWritingStatsDialog()) + + # Settings Menu + self.mSettings = QMenu() + + self.aPrjSettings = QAction(self.tr("Project Settings")) + self.aPrjSettings.triggered.connect(lambda: self.theParent.showProjectSettingsDialog()) + self.mSettings.addAction(self.aPrjSettings) + + self.aPreferences = QAction(self.tr("Preferences")) + self.aPreferences.triggered.connect(lambda: self.theParent.showPreferencesDialog()) + self.mSettings.addAction(self.aPreferences) + + self.tbSettings = QToolButton(self) + self.tbSettings.setFont(lblFont) + self.tbSettings.setText(self.tr("Settings")) + self.tbSettings.setIcon(self.theTheme.getIcon("settings")) + self.tbSettings.setMenu(self.mSettings) + self.tbSettings.setToolButtonStyle(Qt.ToolButtonTextUnderIcon) + self.tbSettings.setPopupMode(QToolButton.InstantPopup) + + # Assemble + self.addAction(self.aProject) + self.addAction(self.aNovel) + self.addAction(self.aOutline) + self.addAction(self.aBuild) + self.addWidget(stretch) + self.addAction(self.aDetails) + self.addAction(self.aStats) + self.addWidget(self.tbSettings) + + logger.debug("GuiViewsBar initialisation complete") + + return + +# END Class GuiViewsBar diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index 9db27659..6a056dca 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -27,35 +27,34 @@ import os import logging import novelwriter +from enum import Enum from time import time from datetime import datetime -from PyQt5.QtCore import Qt, QTimer, QSize, QThreadPool, pyqtSlot +from PyQt5.QtCore import Qt, QTimer, QThreadPool, pyqtSlot from PyQt5.QtGui import QIcon, QKeySequence, QCursor from PyQt5.QtWidgets import ( qApp, QMainWindow, QVBoxLayout, QWidget, QSplitter, QFileDialog, QShortcut, - QMessageBox, QDialog, QTabWidget, QToolBar, QAction + QMessageBox, QDialog, QStackedWidget ) from novelwriter.gui import ( GuiDocEditor, GuiDocViewDetails, GuiDocViewer, GuiItemDetails, GuiMainMenu, - GuiMainStatus, GuiNovelTree, GuiOutline, GuiOutlineDetails, GuiProjectTree, - GuiTheme + GuiMainStatus, GuiNovelTree, GuiOutline, GuiProjectTree, GuiTheme, + GuiViewsBar ) from novelwriter.dialogs import ( - GuiAbout, GuiDocMerge, GuiDocSplit, GuiItemEditor, GuiPreferences, - GuiProjectDetails, GuiProjectLoad, GuiProjectSettings, GuiUpdates, - GuiWordList + GuiAbout, GuiDocMerge, GuiDocSplit, GuiPreferences, GuiProjectDetails, + GuiProjectLoad, GuiProjectSettings, GuiUpdates, GuiWordList ) from novelwriter.tools import ( GuiBuildNovel, GuiLipsum, GuiProjectWizard, GuiWritingStats ) -from novelwriter.core import NWProject, NWIndex +from novelwriter.core import NWProject from novelwriter.enum import ( - nwItemType, nwItemClass, nwAlert, nwWidget, nwState + nwDocMode, nwItemType, nwItemClass, nwAlert, nwWidget, nwState, nwView ) from novelwriter.common import getGuiItem, hexToInt -from novelwriter.constants import nwLists logger = logging.getLogger(__name__) @@ -87,7 +86,6 @@ class GuiMain(QMainWindow): # Core Classes and Settings self.theTheme = GuiTheme() self.theProject = NWProject(self) - self.theIndex = NWIndex(self.theProject) self.hasProject = False self.isFocusMode = False self.idleRefTime = time() @@ -103,8 +101,7 @@ class GuiMain(QMainWindow): # Sizes mPx = self.mainConf.pxInt(4) - fPx = self.theTheme.fontPixelSize - fPt = self.theTheme.fontPointSize + hWd = self.mainConf.pxInt(4) # Main GUI Elements self.statusBar = GuiMainStatus(self) @@ -115,62 +112,43 @@ class GuiMain(QMainWindow): self.docViewer = GuiDocViewer(self) self.treeMeta = GuiItemDetails(self) self.projView = GuiOutline(self) - self.projMeta = GuiOutlineDetails(self) self.mainMenu = GuiMainMenu(self) + self.viewsBar = GuiViewsBar(self) # Connect Signals Between Main Elements - self.docEditor.spellDictionaryChanged.connect(self.statusBar.setLanguage) - self.docEditor.docEditedStatusChanged.connect(self.statusBar.doUpdateDocumentStatus) - self.docEditor.docCountsChanged.connect(self.treeMeta.doUpdateCounts) - self.docEditor.docCountsChanged.connect(self.treeView.doUpdateCounts) + self.viewsBar.viewChangeRequested.connect(self._changeView) self.treeView.itemSelectionChanged.connect(self._treeSingleClick) self.treeView.itemDoubleClicked.connect(self._treeDoubleClick) self.treeView.novelItemChanged.connect(self._treeNovelItemChanged) self.treeView.wordCountsChanged.connect(self._updateStatusWordCount) + self.treeView.treeItemChanged.connect(self.docEditor.updateDocInfo) + self.treeView.treeItemChanged.connect(self.docViewer.updateDocInfo) + self.treeView.treeItemChanged.connect(self.treeMeta.updateViewBox) + self.treeView.rootFolderChanged.connect(self.projView.updateRootItem) - # Project Tree Tabs - self.projTabs = QTabWidget() - self.projTabs.setTabPosition(QTabWidget.South) - self.projTabs.setStyleSheet("QTabWidget::pane {border: 0;};") - self.projTabs.addTab(self.treeView, self.tr("Project")) - self.projTabs.addTab(self.novelView, self.tr("Novel")) - self.projTabs.currentChanged.connect(self._projTabsChanged) + self.docEditor.spellDictionaryChanged.connect(self.statusBar.setLanguage) + self.docEditor.docEditedStatusChanged.connect(self.statusBar.doUpdateDocumentStatus) + self.docEditor.docCountsChanged.connect(self.treeMeta.doUpdateCounts) + self.docEditor.docCountsChanged.connect(self.treeView.doUpdateCounts) + self.docEditor.loadDocumentTagRequest.connect(self._followTag) - tabFont = self.projTabs.tabBar().font() - tabFont.setPointSizeF(0.9*fPt) - self.projTabs.tabBar().setFont(tabFont) + self.docViewer.loadDocumentTagRequest.connect(self._followTag) - # Project Tree Action Buttons - btnSize = int(round(0.7*fPx)) - self.treeButtons = QToolBar() - self.treeButtons.setToolButtonStyle(Qt.ToolButtonIconOnly) - self.treeButtons.setIconSize(QSize(btnSize, btnSize)) - self.treeButtons.setContentsMargins(0, 0, 0, 0) - self.treeButtons.setStyleSheet("QToolBar {padding: 0;}") - self.projTabs.setCornerWidget(self.treeButtons, Qt.BottomRightCorner) + self.projView.loadDocumentTagRequest.connect(self._followTag) - self.projDetailsBtn = QAction(self.tr("Project Details")) - self.projDetailsBtn.setIcon(self.theTheme.getIcon("status_lines")) - self.projDetailsBtn.triggered.connect(lambda: self.showProjectDetailsDialog()) - self.treeButtons.addAction(self.projDetailsBtn) - - self.projStatsBtn = QAction(self.tr("Writing Statistics")) - self.projStatsBtn.setIcon(self.theTheme.getIcon("status_stats")) - self.projStatsBtn.triggered.connect(lambda: self.showWritingStatsDialog()) - self.treeButtons.addAction(self.projStatsBtn) - - self.projSettingsBtn = QAction(self.tr("Project Settings")) - self.projSettingsBtn.setIcon(self.theTheme.getIcon("settings")) - self.projSettingsBtn.triggered.connect(lambda: self.showProjectSettingsDialog()) - self.treeButtons.addAction(self.projSettingsBtn) + # Project Tree Stack + self.projStack = QStackedWidget() + self.projStack.addWidget(self.treeView) + self.projStack.addWidget(self.novelView) + self.projStack.currentChanged.connect(self._projStackChanged) # Project Tree View self.treePane = QWidget() self.treeBox = QVBoxLayout() self.treeBox.setContentsMargins(0, 0, 0, 0) self.treeBox.setSpacing(mPx) - self.treeBox.addWidget(self.projTabs) + self.treeBox.addWidget(self.projStack) self.treeBox.addWidget(self.treeMeta) self.treePane.setLayout(self.treeBox) @@ -178,47 +156,42 @@ class GuiMain(QMainWindow): self.splitView = QSplitter(Qt.Vertical) self.splitView.addWidget(self.docViewer) self.splitView.addWidget(self.viewMeta) + self.splitView.setHandleWidth(hWd) self.splitView.setSizes(self.mainConf.getViewPanePos()) # Splitter : Document Editor / Document Viewer self.splitDocs = QSplitter(Qt.Horizontal) self.splitDocs.addWidget(self.docEditor) self.splitDocs.addWidget(self.splitView) - - # Splitter : Project Outlie / Outline Details - self.splitOutline = QSplitter(Qt.Vertical) - self.splitOutline.addWidget(self.projView) - self.splitOutline.addWidget(self.projMeta) - self.splitOutline.setSizes(self.mainConf.getOutlinePanePos()) - - # Main Tabs : Editor / Outline - self.mainTabs = QTabWidget() - self.mainTabs.setTabPosition(QTabWidget.East) - self.mainTabs.setStyleSheet("QTabWidget::pane {border: 0;}") - self.mainTabs.addTab(self.splitDocs, self.tr("Editor")) - self.mainTabs.addTab(self.splitOutline, self.tr("Outline")) - self.mainTabs.currentChanged.connect(self._mainTabChanged) + self.splitDocs.setHandleWidth(hWd) # Splitter : Project Tree / Main Tabs self.splitMain = QSplitter(Qt.Horizontal) - self.splitMain.setContentsMargins(mPx, mPx, mPx, mPx) + self.splitMain.setContentsMargins(0, 0, 0, 0) self.splitMain.addWidget(self.treePane) - self.splitMain.addWidget(self.mainTabs) + self.splitMain.addWidget(self.splitDocs) + self.splitMain.setHandleWidth(hWd) self.splitMain.setSizes(self.mainConf.getMainPanePos()) + # Main Stack : Editor / Outline + self.mainStack = QStackedWidget() + self.mainStack.addWidget(self.splitMain) + self.mainStack.addWidget(self.projView) + self.mainStack.currentChanged.connect(self._mainStackChanged) + # Indices of Splitter Widgets self.idxTree = self.splitMain.indexOf(self.treePane) - self.idxMain = self.splitMain.indexOf(self.mainTabs) + self.idxMain = self.splitMain.indexOf(self.splitDocs) self.idxEditor = self.splitDocs.indexOf(self.docEditor) self.idxViewer = self.splitDocs.indexOf(self.splitView) self.idxViewDoc = self.splitView.indexOf(self.docViewer) self.idxViewMeta = self.splitView.indexOf(self.viewMeta) # Indices of Tab Widgets - self.idxTabEdit = self.mainTabs.indexOf(self.splitDocs) - self.idxTabProj = self.mainTabs.indexOf(self.splitOutline) - self.idxTreeView = self.projTabs.indexOf(self.treeView) - self.idxNovelView = self.projTabs.indexOf(self.novelView) + self.idxEditorView = self.mainStack.indexOf(self.splitMain) + self.idxOutlineView = self.mainStack.indexOf(self.projView) + self.idxTreeView = self.projStack.indexOf(self.treeView) + self.idxNovelView = self.projStack.indexOf(self.novelView) # Splitter Behaviour self.splitMain.setCollapsible(self.idxTree, False) @@ -237,8 +210,9 @@ class GuiMain(QMainWindow): # Set Main Window Elements self.setMenuBar(self.mainMenu) - self.setCentralWidget(self.splitMain) + self.setCentralWidget(self.mainStack) self.setStatusBar(self.statusBar) + self.addToolBar(Qt.LeftToolBarArea, self.viewsBar) # Finalise Initialisation # ======================= @@ -323,7 +297,7 @@ class GuiMain(QMainWindow): self.docEditor.clearEditor() self.docEditor.setDictionaries() self.closeDocViewer() - self.projMeta.clearDetails() + self.projView.clearOutline() # General self.statusBar.clearStatus() @@ -385,6 +359,7 @@ class GuiMain(QMainWindow): self.rebuildTrees() self.saveProject() self.docEditor.setDictionaries() + self.projView.updateRootItem(None) self.rebuildIndex(beQuiet=True) self.statusBar.setRefTime(self.theProject.projOpened) self.statusBar.setProjectStatus(nwState.GOOD) @@ -446,10 +421,10 @@ class GuiMain(QMainWindow): self.idleRefTime = time() self.idleTime = 0.0 - self.theIndex.clearIndex() + self.theProject.index.clearIndex() self.clearGUI() self.hasProject = False - self.mainTabs.setCurrentWidget(self.splitDocs) + self._changeView(nwView.PROJECT) return saveOK @@ -465,7 +440,7 @@ class GuiMain(QMainWindow): return False # Switch main tab to editor view - self.mainTabs.setCurrentWidget(self.splitDocs) + self._changeView(nwView.PROJECT) # Try to open the project if not self.theProject.openProject(projFile): @@ -523,15 +498,15 @@ class GuiMain(QMainWindow): self.idleTime = 0.0 # Load the tag index - self.theIndex.loadIndex() + self.theProject.index.loadIndex() # Update GUI self._updateWindowTitle(self.theProject.projName) self.rebuildTrees() self.docEditor.setDictionaries() self.docEditor.toggleSpellCheck(self.theProject.spellCheck) - self.mainMenu.setAutoOutline(self.theProject.autoOutline) self.statusBar.setRefTime(self.theProject.projOpened) + self.projView.updateRootItem(None) self._updateStatusWordCount() # Restore previously open documents, if any @@ -542,10 +517,10 @@ class GuiMain(QMainWindow): self.viewDocument(self.theProject.lastViewed) # Check if we need to rebuild the index - if self.theIndex.indexBroken: + if self.theProject.index.indexBroken: self.makeAlert(self.tr( "The project index is outdated or broken. Rebuilding index." - ), nwAlert.WARN) + ), nwAlert.INFO) self.rebuildIndex() # Make sure the changed status is set to false on things opened @@ -566,7 +541,7 @@ class GuiMain(QMainWindow): self.treeView.saveTreeOrder() if self.theProject.saveProject(autoSave=autoSave): - self.theIndex.saveIndex() + self.theProject.index.saveIndex() return True @@ -599,12 +574,12 @@ class GuiMain(QMainWindow): logger.error("No project open") return False - if not self.theProject.projTree.checkType(tHandle, nwItemType.FILE): + if not self.theProject.tree.checkType(tHandle, nwItemType.FILE): logger.debug("Requested item '%s' is not a document", tHandle) return False self.closeDocument() - self.mainTabs.setCurrentWidget(self.splitDocs) + self._changeView(nwView.EDITOR) if self.docEditor.loadText(tHandle, tLine): if changeFocus: self.docEditor.setFocus() @@ -623,12 +598,11 @@ class GuiMain(QMainWindow): logger.error("No project open") return False - self.treeView.flushTreeOrder() nHandle = None # The next handle after tHandle fHandle = None # The first file handle we encounter foundIt = False # We've found tHandle, pick the next we see - for tItem in self.theProject.projTree: - if not self.theProject.projTree.checkType(tItem.itemHandle, nwItemType.FILE): + for tItem in self.theProject.tree: + if not self.theProject.tree.checkType(tItem.itemHandle, nwItemType.FILE): continue if fHandle is None: fHandle = tItem.itemHandle @@ -687,7 +661,7 @@ class GuiMain(QMainWindow): return False # Make sure main tab is in Editor view - self.mainTabs.setCurrentWidget(self.splitDocs) + self._changeView(nwView.EDITOR) logger.debug("Viewing document with handle '%s'", tHandle) if self.docViewer.loadText(tHandle): @@ -817,7 +791,7 @@ class GuiMain(QMainWindow): tHandle = self.treeView.getSelectedHandle() elif self.novelView.hasFocus(): tHandle, tLine = self.novelView.getSelectedHandle() - elif self.projView.hasFocus(): + elif self.projView.treeFocus(): tHandle, tLine = self.projView.getSelectedHandle() else: logger.warning("No item selected") @@ -840,27 +814,10 @@ class GuiMain(QMainWindow): tHandle = self.docEditor.docHandle() else: tHandle = self.treeView.getSelectedHandle() + if tHandle: + return self.treeView.editTreeItem(tHandle) - if tHandle is None: - logger.warning("No item selected") - return False - - tItem = self.theProject.projTree[tHandle] - if tItem is None: - return False - if tItem.itemType not in nwLists.REG_TYPES: - return False - - logger.verbose("Requesting change to item '%s'", tHandle) - dlgProj = GuiItemEditor(self, tHandle) - dlgProj.exec_() - if dlgProj.result() == QDialog.Accepted: - self.treeView.setTreeItemValues(tHandle) - self.treeMeta.updateViewBox(tHandle) - self.docEditor.updateDocInfo(tHandle) - self.docViewer.updateDocInfo(tHandle) - - return True + return False def rebuildTrees(self): """Rebuild the project tree. @@ -872,7 +829,7 @@ class GuiMain(QMainWindow): def requestNovelTreeRefresh(self): """Update the novel tree, but only if it is visible. """ - if self.projTabs.currentIndex() == self.idxNovelView and self.hasProject: + if self.projStack.currentIndex() == self.idxNovelView and self.hasProject: self.novelView.refreshTree() return True return False @@ -889,25 +846,16 @@ class GuiMain(QMainWindow): tStart = time() self.treeView.saveTreeOrder() - self.theIndex.clearIndex() + self.theProject.index.clearIndex() - for tItem in self.theProject.projTree: + for tItem in self.theProject.tree: + if tItem is None: # pragma: no cover + continue # This is a bug trap - if tItem is not None: - self.setStatus(self.tr("Indexing: '{0}'").format(tItem.itemName)) - else: - self.setStatus(self.tr("Indexing: '{0}'").format(self.tr("Unknown item"))) - - if tItem is not None and tItem.itemType == nwItemType.FILE: - logger.verbose("Scanning '%s'", tItem.itemName) - self.theIndex.reIndexHandle(tItem.itemHandle) - - # Get Word Counts - cC, wC, pC = self.theIndex.getCounts(tItem.itemHandle) - tItem.setCharCount(cC) - tItem.setWordCount(wC) - tItem.setParaCount(pC) - self.treeView.propagateCount(tItem.itemHandle, wC) + logger.verbose("Indexing '%s'", tItem.itemName) + if self.theProject.index.reIndexHandle(tItem.itemHandle): + # Update Word Counts + self.treeView.propagateCount(tItem.itemHandle, tItem.wordCount, countChildren=True) self.treeView.setTreeItemValues(tItem.itemHandle) tEnd = time() @@ -925,19 +873,6 @@ class GuiMain(QMainWindow): return True - def rebuildOutline(self): - """Force a rebuild of the Outline view. - """ - if not self.hasProject: - logger.error("No project open") - return False - - logger.verbose("Forcing a rebuild of the Project Outline") - self.mainTabs.setCurrentWidget(self.splitOutline) - self.projView.refreshTree(overRide=True) - - return True - ## # Main Dialogs ## @@ -985,7 +920,6 @@ class GuiMain(QMainWindow): self.treeView.initTree() self.novelView.initTree() self.projView.initOutline() - self.projMeta.initDetails() self._updateStatusWordCount() return @@ -1002,7 +936,9 @@ class GuiMain(QMainWindow): if dlgProj.result() == QDialog.Accepted: logger.debug("Applying new project settings") - self.docEditor.setDictionaries() + if dlgProj.spellChanged: + self.docEditor.setDictionaries() + self.treeMeta.refreshDetails() self._updateWindowTitle(self.theProject.projName) return True @@ -1014,8 +950,6 @@ class GuiMain(QMainWindow): logger.error("No project open") return False - self.treeView.flushTreeOrder() - dlgDetails = getGuiItem("GuiProjectDetails") if dlgDetails is None: dlgDetails = GuiProjectDetails(self) @@ -1220,7 +1154,7 @@ class GuiMain(QMainWindow): if not self.isFocusMode: self.mainConf.setMainPanePos(self.splitMain.sizes()) self.mainConf.setDocPanePos(self.splitDocs.sizes()) - self.mainConf.setOutlinePanePos(self.splitOutline.sizes()) + self.mainConf.setOutlinePanePos(self.projView.splitSizes()) if self.viewMeta.isVisible(): self.mainConf.setViewPanePos(self.splitView.sizes()) @@ -1244,20 +1178,20 @@ class GuiMain(QMainWindow): """Switch focus between main GUI views. """ if paneNo == nwWidget.TREE: - tabIdx = self.projTabs.currentIndex() + tabIdx = self.projStack.currentIndex() if tabIdx == self.idxTreeView: self.treeView.setFocus() elif tabIdx == self.idxNovelView: self.novelView.setFocus() elif paneNo == nwWidget.EDITOR: - self.mainTabs.setCurrentWidget(self.splitDocs) + self._changeView(nwView.EDITOR) self.docEditor.setFocus() elif paneNo == nwWidget.VIEWER: - self.mainTabs.setCurrentWidget(self.splitDocs) + self._changeView(nwView.EDITOR) self.docViewer.setFocus() elif paneNo == nwWidget.OUTLINE: - self.mainTabs.setCurrentWidget(self.splitOutline) - self.projView.setFocus() + self._changeView(nwView.OUTLINE) + self.projView.setTreeFocus() return def closeDocEditor(self): @@ -1289,7 +1223,6 @@ class GuiMain(QMainWindow): self.mainMenu.setFocusMode(self.isFocusMode) if self.isFocusMode: logger.debug("Activating Focus Mode") - self.mainTabs.setCurrentWidget(self.splitDocs) self.switchFocus(nwWidget.EDITOR) else: logger.debug("Deactivating Focus Mode") @@ -1298,7 +1231,7 @@ class GuiMain(QMainWindow): self.treePane.setVisible(isVisible) self.statusBar.setVisible(isVisible) self.mainMenu.setVisible(isVisible) - self.mainTabs.tabBar().setVisible(isVisible) + self.viewsBar.setVisible(isVisible) hideDocFooter = self.isFocusMode and self.mainConf.hideFocusFooter self.docEditor.docFooter.setVisible(not hideDocFooter) @@ -1426,7 +1359,7 @@ class GuiMain(QMainWindow): return True def _updateWindowTitle(self, projName=None): - """Set the window title and add the project's working title. + """Set the window title and add the project's name. """ winTitle = self.mainConf.appName if projName is not None: @@ -1469,9 +1402,9 @@ class GuiMain(QMainWindow): "popMinimal": newProj.field("popMinimal"), "popCustom": newProj.field("popCustom"), "addRoots": [], + "addNotes": False, "numChapters": 0, "numScenes": 0, - "chFolders": False, } if newProj.field("popCustom"): addRoots = [] @@ -1481,19 +1414,30 @@ class GuiMain(QMainWindow): addRoots.append(nwItemClass.CHARACTER) if newProj.field("addWorld"): addRoots.append(nwItemClass.WORLD) - if newProj.field("addTime"): - addRoots.append(nwItemClass.TIMELINE) - if newProj.field("addObject"): - addRoots.append(nwItemClass.OBJECT) - if newProj.field("addEntity"): - addRoots.append(nwItemClass.ENTITY) projData["addRoots"] = addRoots + projData["addNotes"] = newProj.field("addNotes") projData["numChapters"] = newProj.field("numChapters") projData["numScenes"] = newProj.field("numScenes") - projData["chFolders"] = newProj.field("chFolders") return projData + def _getTagSource(self, tTag): + """A wrapper function for the index lookup of a tag that will + display an alert if the tag cannot be found. + """ + tHandle, sTitle = self.theProject.index.getTagSource(tTag) + if tHandle is None: + self.makeAlert(self.tr( + "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}." + ).format( + tTag, "F9" + ), nwAlert.ERROR) + return None, None + + return tHandle, sTitle + ## # Events ## @@ -1509,9 +1453,42 @@ class GuiMain(QMainWindow): return ## - # Slots + # Private Slots ## + @pyqtSlot(str, Enum) + def _followTag(self, tTag, tMode): + """Follow a tag after user interaction with a link. + """ + tHandle, sTitle = self._getTagSource(tTag) + if tHandle is not None: + if tMode == nwDocMode.EDIT: + self.openDocument(tHandle) + elif tMode == nwDocMode.VIEW: + self.viewDocument(tHandle=tHandle, tAnchor=f"#{sTitle}") + return + + @pyqtSlot(nwView) + def _changeView(self, view): + """Handle the requested change of view from the GuiViewBar. + """ + if view == nwView.EDITOR: + # Only change the main stack, but not the project stack + self.mainStack.setCurrentWidget(self.splitMain) + + elif view == nwView.PROJECT: + self.mainStack.setCurrentWidget(self.splitMain) + self.projStack.setCurrentWidget(self.treeView) + + elif view == nwView.NOVEL: + self.mainStack.setCurrentWidget(self.splitMain) + self.projStack.setCurrentWidget(self.novelView) + + elif view == nwView.OUTLINE: + self.mainStack.setCurrentWidget(self.projView) + + return + @pyqtSlot() def _timeTick(self): """Triggered on every tick of the main timer. @@ -1567,11 +1544,17 @@ class GuiMain(QMainWindow): @pyqtSlot("QTreeWidgetItem*", int) def _treeDoubleClick(self, tItem, colNo): """The user double-clicked an item in the tree. If it is a file, - we open it. Otherwise, we do nothing. + we open it. Otherwise, we toggle the expanded status. """ tHandle = self.treeView.getSelectedHandle() if tHandle is not None: - self.openDocument(tHandle, changeFocus=False, doScroll=False) + tItem = self.theProject.tree[tHandle] + if tItem is None: + return + if tItem.itemType == nwItemType.FILE: + self.openDocument(tHandle, changeFocus=False, doScroll=False) + else: + self.treeView.toggleExpanded(tHandle) return @pyqtSlot() @@ -1579,11 +1562,10 @@ class GuiMain(QMainWindow): """Triggered when there is a change to a novel item in the project tree. """ - if self.mainTabs.currentIndex() == self.idxTabProj: + if self.mainStack.currentIndex() == self.idxOutlineView: logger.verbose("Novel tree changed while Outline tab active") if self.hasProject: - self.treeView.flushTreeOrder() - self.projView.refreshTree(novelChanged=True) + self.projView.refreshView(novelChanged=True) return @@ -1608,20 +1590,20 @@ class GuiMain(QMainWindow): return @pyqtSlot(int) - def _mainTabChanged(self, tabIndex): + def _mainStackChanged(self, tabIndex): """Activated when the main window tab is changed. """ - if tabIndex == self.idxTabEdit: + if tabIndex == self.idxEditorView: logger.verbose("Editor tab activated") - elif tabIndex == self.idxTabProj: + elif tabIndex == self.idxOutlineView: logger.verbose("Project outline tab activated") if self.hasProject: - self.projView.refreshTree() + self.projView.refreshView() return @pyqtSlot(int) - def _projTabsChanged(self, tabIndex): + def _projStackChanged(self, tabIndex): """Activated when the project view tab is changed. """ sHandle = None diff --git a/novelwriter/tools/build.py b/novelwriter/tools/build.py index 7ebed572..b27a98a4 100644 --- a/novelwriter/tools/build.py +++ b/novelwriter/tools/build.py @@ -75,7 +75,6 @@ class GuiBuildNovel(QDialog): self.theParent = theParent self.theTheme = theParent.theTheme self.theProject = theParent.theProject - self.optState = theParent.theProject.optState self.htmlText = [] # List of html documents self.htmlStyle = [] # List of html styles @@ -86,9 +85,10 @@ class GuiBuildNovel(QDialog): self.setMinimumWidth(self.mainConf.pxInt(700)) self.setMinimumHeight(self.mainConf.pxInt(600)) + pOptions = self.theProject.options self.resize( - self.mainConf.pxInt(self.optState.getInt("GuiBuildNovel", "winWidth", 900)), - self.mainConf.pxInt(self.optState.getInt("GuiBuildNovel", "winHeight", 800)) + self.mainConf.pxInt(pOptions.getInt("GuiBuildNovel", "winWidth", 900)), + self.mainConf.pxInt(pOptions.getInt("GuiBuildNovel", "winHeight", 800)) ) self.docView = GuiBuildNovelDocView(self, self.theProject) @@ -174,12 +174,12 @@ class GuiBuildNovel(QDialog): self.hideScene = QSwitch(width=wS, height=hS) self.hideScene.setChecked( - self.optState.getBool("GuiBuildNovel", "hideScene", False) + pOptions.getBool("GuiBuildNovel", "hideScene", False) ) self.hideSection = QSwitch(width=wS, height=hS) self.hideSection.setChecked( - self.optState.getBool("GuiBuildNovel", "hideSection", True) + pOptions.getBool("GuiBuildNovel", "hideSection", True) ) # Wrapper boxes due to QGridView and QLineEdit expand bug @@ -235,7 +235,7 @@ class GuiBuildNovel(QDialog): self.textFont.setReadOnly(True) self.textFont.setMinimumWidth(xFmt) self.textFont.setText( - self.optState.getString("GuiBuildNovel", "textFont", self.mainConf.textFont) + pOptions.getString("GuiBuildNovel", "textFont", self.mainConf.textFont) ) self.fontButton = QPushButton("...") self.fontButton.setMaximumWidth(int(2.5*self.theTheme.getTextWidth("..."))) @@ -247,7 +247,7 @@ class GuiBuildNovel(QDialog): self.textSize.setMaximum(72) self.textSize.setSingleStep(1) self.textSize.setValue( - self.optState.getInt("GuiBuildNovel", "textSize", self.mainConf.textSize) + pOptions.getInt("GuiBuildNovel", "textSize", self.mainConf.textSize) ) self.lineHeight = QDoubleSpinBox(self) @@ -257,7 +257,7 @@ class GuiBuildNovel(QDialog): self.lineHeight.setSingleStep(0.05) self.lineHeight.setDecimals(2) self.lineHeight.setValue( - self.optState.getFloat("GuiBuildNovel", "lineHeight", 1.15) + pOptions.getFloat("GuiBuildNovel", "lineHeight", 1.15) ) # Wrapper box due to QGridView and QLineEdit expand bug @@ -291,12 +291,12 @@ class GuiBuildNovel(QDialog): self.justifyText = QSwitch(width=wS, height=hS) self.justifyText.setChecked( - self.optState.getBool("GuiBuildNovel", "justifyText", False) + pOptions.getBool("GuiBuildNovel", "justifyText", False) ) self.noStyling = QSwitch(width=wS, height=hS) self.noStyling.setChecked( - self.optState.getBool("GuiBuildNovel", "noStyling", False) + pOptions.getBool("GuiBuildNovel", "noStyling", False) ) self.styleForm.addWidget(justifyLabel, 1, 0, 1, 1, Qt.AlignLeft) @@ -316,22 +316,22 @@ class GuiBuildNovel(QDialog): self.includeSynopsis = QSwitch(width=wS, height=hS) self.includeSynopsis.setChecked( - self.optState.getBool("GuiBuildNovel", "incSynopsis", False) + pOptions.getBool("GuiBuildNovel", "incSynopsis", False) ) self.includeComments = QSwitch(width=wS, height=hS) self.includeComments.setChecked( - self.optState.getBool("GuiBuildNovel", "incComments", False) + pOptions.getBool("GuiBuildNovel", "incComments", False) ) self.includeKeywords = QSwitch(width=wS, height=hS) self.includeKeywords.setChecked( - self.optState.getBool("GuiBuildNovel", "incKeywords", False) + pOptions.getBool("GuiBuildNovel", "incKeywords", False) ) self.includeBody = QSwitch(width=wS, height=hS) self.includeBody.setChecked( - self.optState.getBool("GuiBuildNovel", "incBodyText", True) + pOptions.getBool("GuiBuildNovel", "incBodyText", True) ) synopsisLabel = QLabel(self.tr("Include synopsis")) @@ -360,17 +360,17 @@ class GuiBuildNovel(QDialog): self.novelFiles = QSwitch(width=wS, height=hS) self.novelFiles.setChecked( - self.optState.getBool("GuiBuildNovel", "addNovel", True) + pOptions.getBool("GuiBuildNovel", "addNovel", True) ) self.noteFiles = QSwitch(width=wS, height=hS) self.noteFiles.setChecked( - self.optState.getBool("GuiBuildNovel", "addNotes", False) + pOptions.getBool("GuiBuildNovel", "addNotes", False) ) self.ignoreFlag = QSwitch(width=wS, height=hS) self.ignoreFlag.setChecked( - self.optState.getBool("GuiBuildNovel", "ignoreFlag", False) + pOptions.getBool("GuiBuildNovel", "ignoreFlag", False) ) novelLabel = QLabel(self.tr("Include novel files")) @@ -396,12 +396,12 @@ class GuiBuildNovel(QDialog): self.replaceTabs = QSwitch(width=wS, height=hS) self.replaceTabs.setChecked( - self.optState.getBool("GuiBuildNovel", "replaceTabs", False) + pOptions.getBool("GuiBuildNovel", "replaceTabs", False) ) self.replaceUCode = QSwitch(width=wS, height=hS) self.replaceUCode.setChecked( - self.optState.getBool("GuiBuildNovel", "replaceUCode", False) + pOptions.getBool("GuiBuildNovel", "replaceUCode", False) ) tabsLabel = QLabel(self.tr("Replace tabs with spaces")) @@ -493,9 +493,9 @@ class GuiBuildNovel(QDialog): # Splitter Position boxWidth = self.mainConf.pxInt(350) - boxWidth = self.optState.getInt("GuiBuildNovel", "boxWidth", boxWidth) + boxWidth = pOptions.getInt("GuiBuildNovel", "boxWidth", boxWidth) docWidth = max(self.width() - boxWidth, 100) - docWidth = self.optState.getInt("GuiBuildNovel", "docWidth", docWidth) + docWidth = pOptions.getInt("GuiBuildNovel", "docWidth", docWidth) # The Tool Box self.toolsBox = QVBoxLayout() @@ -659,6 +659,7 @@ class GuiBuildNovel(QDialog): fmtUnnumbered = self.fmtUnnumbered.text() fmtScene = self.fmtScene.text() fmtSection = self.fmtSection.text() + buildLang = self.buildLang.currentData() hideScene = self.hideScene.isChecked() hideSection = self.hideSection.isChecked() textFont = self.textFont.text() @@ -676,7 +677,7 @@ class GuiBuildNovel(QDialog): replaceUCode = self.replaceUCode.isChecked() # The language lookup dict is reloaded if needed - self.theProject.setProjectLang(self.buildLang.currentData()) + self.theProject.setProjectLang(buildLang) # Get font information fontInfo = QFontInfo(QFont(textFont, textSize)) @@ -706,16 +707,16 @@ class GuiBuildNovel(QDialog): if isOdt: bldObj.setColourHeaders(not noStyling) + bldObj.setLanguage(buildLang) bldObj.initDocument() # Make sure the project and document is up to date - self.theParent.treeView.flushTreeOrder() self.theParent.saveDocument() - self.buildProgress.setMaximum(len(self.theProject.projTree)) + self.buildProgress.setMaximum(len(self.theProject.tree)) self.buildProgress.setValue(0) - for nItt, tItem in enumerate(self.theProject.projTree): + for nItt, tItem in enumerate(self.theProject.tree): noteRoot = noteFiles noteRoot &= tItem.itemType == nwItemType.ROOT @@ -780,14 +781,12 @@ class GuiBuildNovel(QDialog): if theItem is None: return False - if not theItem.isExported and not ignoreFlag: + if not (theItem.isExported or ignoreFlag): return False isNone = theItem.itemType != nwItemType.FILE isNone |= theItem.itemLayout == nwItemLayout.NO_LAYOUT - isNone |= theItem.itemClass == nwItemClass.NO_CLASS - isNone |= theItem.itemClass == nwItemClass.TRASH - isNone |= theItem.itemParent == self.theProject.projTree.trashRoot() + isNone |= theItem.isInactive() isNone |= theItem.itemParent is None isNote = theItem.itemLayout == nwItemLayout.NOTE isNovel = not isNone and not isNote @@ -799,10 +798,6 @@ class GuiBuildNovel(QDialog): if isNovel and not novelFiles: return False - rootItem = self.theProject.projTree.getRootItem(theItem.itemHandle) - if rootItem.itemClass == nwItemClass.ARCHIVE: - return False - return True def _saveDocument(self, theFmt): @@ -1159,28 +1154,28 @@ class GuiBuildNovel(QDialog): self.theProject.setProjectLang(buildLang) # GUI Settings - self.optState.setValue("GuiBuildNovel", "hideScene", hideScene) - self.optState.setValue("GuiBuildNovel", "hideSection", hideSection) - self.optState.setValue("GuiBuildNovel", "winWidth", winWidth) - self.optState.setValue("GuiBuildNovel", "winHeight", winHeight) - self.optState.setValue("GuiBuildNovel", "boxWidth", boxWidth) - self.optState.setValue("GuiBuildNovel", "docWidth", docWidth) - self.optState.setValue("GuiBuildNovel", "justifyText", justifyText) - self.optState.setValue("GuiBuildNovel", "noStyling", noStyling) - self.optState.setValue("GuiBuildNovel", "textFont", textFont) - self.optState.setValue("GuiBuildNovel", "textSize", textSize) - self.optState.setValue("GuiBuildNovel", "lineHeight", lineHeight) - self.optState.setValue("GuiBuildNovel", "addNovel", novelFiles) - self.optState.setValue("GuiBuildNovel", "addNotes", noteFiles) - self.optState.setValue("GuiBuildNovel", "ignoreFlag", ignoreFlag) - self.optState.setValue("GuiBuildNovel", "incSynopsis", incSynopsis) - self.optState.setValue("GuiBuildNovel", "incComments", incComments) - self.optState.setValue("GuiBuildNovel", "incKeywords", incKeywords) - self.optState.setValue("GuiBuildNovel", "incBodyText", incBodyText) - self.optState.setValue("GuiBuildNovel", "replaceTabs", replaceTabs) - self.optState.setValue("GuiBuildNovel", "replaceUCode", replaceUCode) - - self.optState.saveSettings() + pOptions = self.theProject.options + pOptions.setValue("GuiBuildNovel", "hideScene", hideScene) + pOptions.setValue("GuiBuildNovel", "hideSection", hideSection) + pOptions.setValue("GuiBuildNovel", "winWidth", winWidth) + pOptions.setValue("GuiBuildNovel", "winHeight", winHeight) + pOptions.setValue("GuiBuildNovel", "boxWidth", boxWidth) + pOptions.setValue("GuiBuildNovel", "docWidth", docWidth) + pOptions.setValue("GuiBuildNovel", "justifyText", justifyText) + pOptions.setValue("GuiBuildNovel", "noStyling", noStyling) + pOptions.setValue("GuiBuildNovel", "textFont", textFont) + pOptions.setValue("GuiBuildNovel", "textSize", textSize) + pOptions.setValue("GuiBuildNovel", "lineHeight", lineHeight) + pOptions.setValue("GuiBuildNovel", "addNovel", novelFiles) + pOptions.setValue("GuiBuildNovel", "addNotes", noteFiles) + pOptions.setValue("GuiBuildNovel", "ignoreFlag", ignoreFlag) + pOptions.setValue("GuiBuildNovel", "incSynopsis", incSynopsis) + pOptions.setValue("GuiBuildNovel", "incComments", incComments) + pOptions.setValue("GuiBuildNovel", "incKeywords", incKeywords) + pOptions.setValue("GuiBuildNovel", "incBodyText", incBodyText) + pOptions.setValue("GuiBuildNovel", "replaceTabs", replaceTabs) + pOptions.setValue("GuiBuildNovel", "replaceUCode", replaceUCode) + pOptions.saveSettings() return diff --git a/novelwriter/tools/lipsum.py b/novelwriter/tools/lipsum.py index a6ac572d..4ce21643 100644 --- a/novelwriter/tools/lipsum.py +++ b/novelwriter/tools/lipsum.py @@ -72,7 +72,7 @@ class GuiLipsum(QDialog): # Form self.headLabel = QLabel("{0}".format(self.tr("Insert Lorem Ipsum Text"))) - self.paraLabel = QLabel(self.tr("Number of pragraphs")) + self.paraLabel = QLabel(self.tr("Number of paragraphs")) self.paraCount = QSpinBox() self.paraCount.setMinimum(1) self.paraCount.setMaximum(100) diff --git a/novelwriter/tools/projwizard.py b/novelwriter/tools/projwizard.py index 8f746ca6..765445b9 100644 --- a/novelwriter/tools/projwizard.py +++ b/novelwriter/tools/projwizard.py @@ -31,12 +31,10 @@ from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( QWizard, QWizardPage, QLabel, QVBoxLayout, QLineEdit, QPlainTextEdit, QPushButton, QFileDialog, QHBoxLayout, QRadioButton, QFormLayout, - QGroupBox, QGridLayout, QSpinBox + QGridLayout, QSpinBox ) -from novelwriter.enum import nwItemClass from novelwriter.common import makeFileNameSafe -from novelwriter.constants import trConst, nwLabels from novelwriter.gui.custom import QSwitch logger = logging.getLogger(__name__) @@ -98,10 +96,10 @@ class ProjWizardIntroPage(QWizardPage): self.setTitle(self.tr("Create New Project")) self.theText = QLabel(self.tr( - "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." )) self.theText.setWordWrap(True) @@ -134,7 +132,7 @@ class ProjWizardIntroPage(QWizardPage): self.projAuthors.setPlaceholderText(self.tr("Optional. One name per line.")) self.mainForm = QFormLayout() - self.mainForm.addRow(self.tr("Working Title"), self.projName) + self.mainForm.addRow(self.tr("Project Name"), self.projName) self.mainForm.addRow(self.tr("Novel Title"), self.projTitle) self.mainForm.addRow(self.tr("Author(s)"), self.projAuthors) self.mainForm.setVerticalSpacing(fS) @@ -185,6 +183,9 @@ class ProjWizardFolderPage(QWizardPage): self.browseButton.setMaximumWidth(int(2.5*self.theTheme.getTextWidth("..."))) self.browseButton.clicked.connect(self._doBrowse) + self.errLabel = QLabel("") + self.errLabel.setWordWrap(True) + self.mainForm = QHBoxLayout() self.mainForm.addWidget(QLabel(self.tr("Project Path")), 0) self.mainForm.addWidget(self.projPath, 1) @@ -198,11 +199,36 @@ class ProjWizardFolderPage(QWizardPage): self.outerBox.setSpacing(vS) self.outerBox.addWidget(self.theText) self.outerBox.addLayout(self.mainForm) + self.outerBox.addWidget(self.errLabel) self.outerBox.addStretch(1) self.setLayout(self.outerBox) return + def isComplete(self): + """Check that the selected path isn't already being used. + """ + self.errLabel.setText("") + if not QWizardPage.isComplete(self): + return False + + setPath = os.path.abspath(os.path.expanduser(self.projPath.text())) + parPath = os.path.dirname(setPath) + logger.verbose("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." + )) + return False + + if os.path.exists(setPath): + self.errLabel.setText(self.tr( + "Error: The selected path already exists." + )) + return False + + return True + ## # Slots ## @@ -296,68 +322,28 @@ class ProjWizardCustomPage(QWizardPage): self.setTitle(self.tr("Custom Project Options")) self.theText = QLabel(self.tr( - "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." )) self.theText.setWordWrap(True) - vS = self.mainConf.pxInt(12) + cM = self.mainConf.pxInt(12) + mH = self.mainConf.pxInt(26) + fS = self.mainConf.pxInt(4) # Root Folders - self.rootGroup = QGroupBox(self.tr("Additional Root Folders")) - self.rootForm = QGridLayout() - self.rootGroup.setLayout(self.rootForm) - - self.lblPlot = QLabel(self.tr("{0} folder").format( - trConst(nwLabels.CLASS_NAME[nwItemClass.PLOT])) - ) - self.lblChar = QLabel(self.tr("{0} folder").format( - trConst(nwLabels.CLASS_NAME[nwItemClass.CHARACTER])) - ) - self.lblWorld = QLabel(self.tr("{0} folder").format( - trConst(nwLabels.CLASS_NAME[nwItemClass.WORLD])) - ) - self.lblTime = QLabel(self.tr("{0} folder").format( - trConst(nwLabels.CLASS_NAME[nwItemClass.TIMELINE])) - ) - self.lblObject = QLabel(self.tr("{0} folder").format( - trConst(nwLabels.CLASS_NAME[nwItemClass.OBJECT])) - ) - self.lblEntity = QLabel(self.tr("{0} folder").format( - trConst(nwLabels.CLASS_NAME[nwItemClass.ENTITY])) - ) - - self.addPlot = QSwitch() - self.addChar = QSwitch() - self.addWorld = QSwitch() - self.addTime = QSwitch() - self.addObject = QSwitch() - self.addEntity = QSwitch() + self.addPlot = QSwitch() + self.addChar = QSwitch() + self.addWorld = QSwitch() + self.addNotes = QSwitch() self.addPlot.setChecked(True) self.addChar.setChecked(True) - self.addWorld.setChecked(True) - - self.rootForm.addWidget(self.lblPlot, 0, 0) - self.rootForm.addWidget(self.lblChar, 1, 0) - self.rootForm.addWidget(self.lblWorld, 2, 0) - self.rootForm.addWidget(self.lblTime, 3, 0) - self.rootForm.addWidget(self.lblObject, 4, 0) - self.rootForm.addWidget(self.lblEntity, 5, 0) - self.rootForm.addWidget(self.addPlot, 0, 1, 1, 1, Qt.AlignRight) - self.rootForm.addWidget(self.addChar, 1, 1, 1, 1, Qt.AlignRight) - self.rootForm.addWidget(self.addWorld, 2, 1, 1, 1, Qt.AlignRight) - self.rootForm.addWidget(self.addTime, 3, 1, 1, 1, Qt.AlignRight) - self.rootForm.addWidget(self.addObject, 4, 1, 1, 1, Qt.AlignRight) - self.rootForm.addWidget(self.addEntity, 5, 1, 1, 1, Qt.AlignRight) - self.rootForm.setRowStretch(6, 1) - - # Novel Options - self.novelGroup = QGroupBox(self.tr("Populate Novel Folder")) - self.novelForm = QGridLayout() - self.novelGroup.setLayout(self.novelForm) + self.addWorld.setChecked(False) + self.addNotes.setChecked(False) + # Generate Content self.numChapters = QSpinBox() self.numChapters.setRange(0, 100) self.numChapters.setValue(5) @@ -366,37 +352,40 @@ class ProjWizardCustomPage(QWizardPage): self.numScenes.setRange(0, 200) self.numScenes.setValue(5) - self.chFolders = QSwitch() - self.chFolders.setChecked(True) - - self.novelForm.addWidget(QLabel(self.tr("Add chapters")), 0, 0) - self.novelForm.addWidget(QLabel(self.tr("Scenes (per chapter)")), 1, 0) - self.novelForm.addWidget(QLabel(self.tr("Add chapter folders")), 2, 0) - self.novelForm.addWidget(self.numChapters, 0, 1, 1, 1, Qt.AlignRight) - self.novelForm.addWidget(self.numScenes, 1, 1, 1, 1, Qt.AlignRight) - self.novelForm.addWidget(self.chFolders, 2, 1, 1, 1, Qt.AlignRight) - self.novelForm.setRowStretch(3, 1) + # Grid Form + self.addBox = QGridLayout() + self.addBox.addWidget(QLabel(self.tr("Add a folder for plot notes")), 0, 0) + self.addBox.addWidget(QLabel(self.tr("Add a folder for character notes")), 1, 0) + self.addBox.addWidget(QLabel(self.tr("Add a folder for location notes")), 2, 0) + self.addBox.addWidget(QLabel(self.tr("Add example notes to the above")), 3, 0) + self.addBox.addWidget(QLabel(self.tr("Add chapters to the novel folder")), 4, 0) + self.addBox.addWidget(QLabel(self.tr("Add scenes to each chapter")), 5, 0) + self.addBox.addWidget(self.addPlot, 0, 1, 1, 1, Qt.AlignRight) + self.addBox.addWidget(self.addChar, 1, 1, 1, 1, Qt.AlignRight) + self.addBox.addWidget(self.addWorld, 2, 1, 1, 1, Qt.AlignRight) + self.addBox.addWidget(self.addNotes, 3, 1, 1, 1, Qt.AlignRight) + self.addBox.addWidget(self.numChapters, 4, 1, 1, 1, Qt.AlignRight) + self.addBox.addWidget(self.numScenes, 5, 1, 1, 1, Qt.AlignRight) + self.addBox.setVerticalSpacing(fS) + self.addBox.setHorizontalSpacing(cM) + self.addBox.setContentsMargins(cM, 0, cM, 0) + self.addBox.setColumnStretch(2, 1) + for i in range(6): + self.addBox.setRowMinimumHeight(i, mH) # Wizard Fields self.registerField("addPlot", self.addPlot) self.registerField("addChar", self.addChar) self.registerField("addWorld", self.addWorld) - self.registerField("addTime", self.addTime) - self.registerField("addObject", self.addObject) - self.registerField("addEntity", self.addEntity) + self.registerField("addNotes", self.addNotes) self.registerField("numChapters", self.numChapters) self.registerField("numScenes", self.numScenes) - self.registerField("chFolders", self.chFolders) # Assemble - self.innerBox = QHBoxLayout() - self.innerBox.addWidget(self.rootGroup) - self.innerBox.addWidget(self.novelGroup) - self.outerBox = QVBoxLayout() - self.outerBox.setSpacing(vS) + self.outerBox.setSpacing(cM) self.outerBox.addWidget(self.theText) - self.outerBox.addLayout(self.innerBox) + self.outerBox.addLayout(self.addBox) self.outerBox.addStretch(1) self.setLayout(self.outerBox) @@ -413,15 +402,8 @@ class ProjWizardFinalPage(QWizardPage): self.mainConf = novelwriter.CONFIG self.theWizard = theWizard - self.setTitle(self.tr("Finished")) - self.theText = QLabel( - "

%s

%s

" % ( - self.tr("All done."), - self.tr("Press '{0}' to create the new project.").format( - self.tr("Done") if self.mainConf.osDarwin else self.tr("Finish") - ) - ) - ) + self.setTitle(self.tr("Summary")) + self.theText = QLabel("") self.theText.setWordWrap(True) # Assemble @@ -433,4 +415,51 @@ class ProjWizardFinalPage(QWizardPage): return + def initializePage(self): + """Update the summary information on the final page. + """ + QWizardPage.initializePage(self) + + sumList = [] + sumList.append(self.tr("Project Name: {0}").format(self.field("projName"))) + sumList.append(self.tr("Project Path: {0}").format(self.field("projPath"))) + + if self.field("popMinimal"): + sumList.append(self.tr("Fill the project with a minimal set of items")) + elif self.field("popSample"): + sumList.append(self.tr("Fill the project with example files")) + elif self.field("popCustom"): + if self.field("addPlot"): + sumList.append(self.tr("Add a folder for plot notes")) + if self.field("addChar"): + sumList.append(self.tr("Add a folder for character notes")) + if self.field("addWorld"): + sumList.append(self.tr("Add a folder for location notes")) + if self.field("addNotes"): + sumList.append(self.tr("Add example notes to the above")) + if self.field("numChapters") > 0: + sumList.append(self.tr("Add {0} chapters to the novel folder").format( + self.field("numChapters") + )) + if self.field("numScenes") > 0: + sumList.append(self.tr("Add {0} scenes to each chapter").format( + self.field("numScenes") + )) + else: + if self.field("numScenes") > 0: + sumList.append(self.tr("Add {0} scenes").format( + self.field("numScenes") + )) + + self.theText.setText( + "

%s

 • %s

%s

" % ( + self.tr("You have selected the following:"), + "
 • ".join(sumList), + self.tr("Press '{0}' to create the new project.").format( + self.tr("Done") if self.mainConf.osDarwin else self.tr("Finish") + ) + ) + ) + return + # END Class ProjWizardFinalPage diff --git a/novelwriter/tools/writingstats.py b/novelwriter/tools/writingstats.py index 4ce83b66..aff7bc3d 100644 --- a/novelwriter/tools/writingstats.py +++ b/novelwriter/tools/writingstats.py @@ -67,33 +67,34 @@ class GuiWritingStats(QDialog): self.theParent = theParent self.theTheme = theParent.theTheme self.theProject = theParent.theProject - self.optState = theParent.theProject.optState self.logData = [] self.filterData = [] self.timeFilter = 0.0 self.wordOffset = 0 + pOptions = self.theProject.options + self.setWindowTitle(self.tr("Writing Statistics")) self.setMinimumWidth(self.mainConf.pxInt(420)) self.setMinimumHeight(self.mainConf.pxInt(400)) self.resize( - self.mainConf.pxInt(self.optState.getInt("GuiWritingStats", "winWidth", 550)), - self.mainConf.pxInt(self.optState.getInt("GuiWritingStats", "winHeight", 500)) + self.mainConf.pxInt(pOptions.getInt("GuiWritingStats", "winWidth", 550)), + self.mainConf.pxInt(pOptions.getInt("GuiWritingStats", "winHeight", 500)) ) # List Box wCol0 = self.mainConf.pxInt( - self.optState.getInt("GuiWritingStats", "widthCol0", 180) + pOptions.getInt("GuiWritingStats", "widthCol0", 180) ) wCol1 = self.mainConf.pxInt( - self.optState.getInt("GuiWritingStats", "widthCol1", 80) + pOptions.getInt("GuiWritingStats", "widthCol1", 80) ) wCol2 = self.mainConf.pxInt( - self.optState.getInt("GuiWritingStats", "widthCol2", 80) + pOptions.getInt("GuiWritingStats", "widthCol2", 80) ) wCol3 = self.mainConf.pxInt( - self.optState.getInt("GuiWritingStats", "widthCol3", 80) + pOptions.getInt("GuiWritingStats", "widthCol3", 80) ) self.listBox = QTreeWidget() @@ -115,9 +116,9 @@ class GuiWritingStats(QDialog): hHeader.setTextAlignment(self.C_IDLE, Qt.AlignRight) hHeader.setTextAlignment(self.C_COUNT, Qt.AlignRight) - sortCol = checkIntRange(self.optState.getInt("GuiWritingStats", "sortCol", 0), 0, 2, 0) + sortCol = checkIntRange(pOptions.getInt("GuiWritingStats", "sortCol", 0), 0, 2, 0) sortOrder = checkIntTuple( - self.optState.getInt("GuiWritingStats", "sortOrder", Qt.DescendingOrder), + pOptions.getInt("GuiWritingStats", "sortOrder", Qt.DescendingOrder), (Qt.AscendingOrder, Qt.DescendingOrder), Qt.DescendingOrder ) self.listBox.sortByColumn(sortCol, sortOrder) @@ -190,37 +191,37 @@ class GuiWritingStats(QDialog): self.incNovel = QSwitch(width=2*sPx, height=sPx) self.incNovel.setChecked( - self.optState.getBool("GuiWritingStats", "incNovel", True) + pOptions.getBool("GuiWritingStats", "incNovel", True) ) self.incNovel.clicked.connect(self._updateListBox) self.incNotes = QSwitch(width=2*sPx, height=sPx) self.incNotes.setChecked( - self.optState.getBool("GuiWritingStats", "incNotes", True) + pOptions.getBool("GuiWritingStats", "incNotes", True) ) self.incNotes.clicked.connect(self._updateListBox) self.hideZeros = QSwitch(width=2*sPx, height=sPx) self.hideZeros.setChecked( - self.optState.getBool("GuiWritingStats", "hideZeros", True) + pOptions.getBool("GuiWritingStats", "hideZeros", True) ) self.hideZeros.clicked.connect(self._updateListBox) self.hideNegative = QSwitch(width=2*sPx, height=sPx) self.hideNegative.setChecked( - self.optState.getBool("GuiWritingStats", "hideNegative", False) + pOptions.getBool("GuiWritingStats", "hideNegative", False) ) self.hideNegative.clicked.connect(self._updateListBox) self.groupByDay = QSwitch(width=2*sPx, height=sPx) self.groupByDay.setChecked( - self.optState.getBool("GuiWritingStats", "groupByDay", False) + pOptions.getBool("GuiWritingStats", "groupByDay", False) ) self.groupByDay.clicked.connect(self._updateListBox) self.showIdleTime = QSwitch(width=2*sPx, height=sPx) self.showIdleTime.setChecked( - self.optState.getBool("GuiWritingStats", "showIdleTime", False) + pOptions.getBool("GuiWritingStats", "showIdleTime", False) ) self.showIdleTime.clicked.connect(self._updateListBox) @@ -244,7 +245,7 @@ class GuiWritingStats(QDialog): self.histMax.setMaximum(100000) self.histMax.setSingleStep(100) self.histMax.setValue( - self.optState.getInt("GuiWritingStats", "histMax", 2000) + pOptions.getInt("GuiWritingStats", "histMax", 2000) ) self.histMax.valueChanged.connect(self._updateListBox) @@ -323,23 +324,23 @@ class GuiWritingStats(QDialog): showIdleTime = self.showIdleTime.isChecked() histMax = self.histMax.value() - self.optState.setValue("GuiWritingStats", "winWidth", winWidth) - self.optState.setValue("GuiWritingStats", "winHeight", winHeight) - self.optState.setValue("GuiWritingStats", "widthCol0", widthCol0) - self.optState.setValue("GuiWritingStats", "widthCol1", widthCol1) - self.optState.setValue("GuiWritingStats", "widthCol2", widthCol2) - self.optState.setValue("GuiWritingStats", "widthCol3", widthCol3) - self.optState.setValue("GuiWritingStats", "sortCol", sortCol) - self.optState.setValue("GuiWritingStats", "sortOrder", sortOrder) - self.optState.setValue("GuiWritingStats", "incNovel", incNovel) - self.optState.setValue("GuiWritingStats", "incNotes", incNotes) - self.optState.setValue("GuiWritingStats", "hideZeros", hideZeros) - self.optState.setValue("GuiWritingStats", "hideNegative", hideNegative) - self.optState.setValue("GuiWritingStats", "groupByDay", groupByDay) - self.optState.setValue("GuiWritingStats", "showIdleTime", showIdleTime) - self.optState.setValue("GuiWritingStats", "histMax", histMax) - - self.optState.saveSettings() + pOptions = self.theProject.options + pOptions.setValue("GuiWritingStats", "winWidth", winWidth) + pOptions.setValue("GuiWritingStats", "winHeight", winHeight) + pOptions.setValue("GuiWritingStats", "widthCol0", widthCol0) + pOptions.setValue("GuiWritingStats", "widthCol1", widthCol1) + pOptions.setValue("GuiWritingStats", "widthCol2", widthCol2) + pOptions.setValue("GuiWritingStats", "widthCol3", widthCol3) + pOptions.setValue("GuiWritingStats", "sortCol", sortCol) + pOptions.setValue("GuiWritingStats", "sortOrder", sortOrder) + pOptions.setValue("GuiWritingStats", "incNovel", incNovel) + pOptions.setValue("GuiWritingStats", "incNotes", incNotes) + pOptions.setValue("GuiWritingStats", "hideZeros", hideZeros) + pOptions.setValue("GuiWritingStats", "hideNegative", hideNegative) + pOptions.setValue("GuiWritingStats", "groupByDay", groupByDay) + pOptions.setValue("GuiWritingStats", "showIdleTime", showIdleTime) + pOptions.setValue("GuiWritingStats", "histMax", histMax) + pOptions.saveSettings() self.close() return diff --git a/sample/content/5eaea4e8cdee8.nwd b/sample/content/5eaea4e8cdee8.nwd index 1a7f3c79..0f8ecc26 100644 --- a/sample/content/5eaea4e8cdee8.nwd +++ b/sample/content/5eaea4e8cdee8.nwd @@ -4,5 +4,6 @@ # Mars @tag: Mars +@location: Space It’s red. Dusty and red. diff --git a/sample/content/636b6aa9b697b.nwd b/sample/content/636b6aa9b697b.nwd index 8fe96042..a334e8ce 100644 --- a/sample/content/636b6aa9b697b.nwd +++ b/sample/content/636b6aa9b697b.nwd @@ -1,19 +1,19 @@ %%~name: Making a Scene -%%~path: e7ded148d6e4a/636b6aa9b697b +%%~path: 6a2d6d5f4f401/636b6aa9b697b %%~kind: NOVEL/DOCUMENT ### Making a Scene @pov: Jane -@char: John +@char: John, Jane @location: Earth -A scene is defined by a level three heading, like the one at the top of this page. The scene will be assigned to the chapter preceding it in the project tree. +A scene is defined by a level three heading, like the one at the top of this page. The scene will be assigned to the chapter preceding it in the project tree. The scene document can be sorted after the chapter document, or as a child of the chapter. Both result in the same output in the end, so it is a matter of preference. Each paragraph in the scene is separated by a blank line. The text supports minimal formatting, like **bold**, _italic_ and **_bold italic_**. You can also ~~strike through~~ text. There is **some support for _nested_ emphasis**, but there are some known limitations. If the syntax highlighter doesn’t show it correctly, the export tool will not either. -In addition, the editor supports automatic formatting of “quotes”, both double and ‘single’. Depending on the syntax highlighter, these can be in different colours. “You can of course use **bold** and _italic_ text inside of quotes too.” +In addition, the editor supports automatic formatting of “quotes”, both double and ‘single’. Depending on the syntax highlighter settings and colour theme, these can be in different colours. “You can of course use **bold** and _italic_ text inside of quotes too.” -If you have the need for it, you can also add text that can be automatically replaced by other text when you generate a preview or export the project. Now, let’s auto-replace this A with , and this C with . While is just . Press Ctrl+R to see what this looks like in the view pane. +If you have the need for it, you can also add text that can be automatically replaced by other text when you generate a preview or export the project. Now, let’s auto-replace this A with , and this C with . While is just . Press Ctrl+R to see what this looks like in the view pane. The list of auto-replaced text is sett in Project Settings. The editor also supports non breaking spaces, and the spell checker accepts long dashes—like this—as valid word separators. Regular dashes are also supported – and can be automatically inserted when typing two hyphens. @@ -25,7 +25,7 @@ If you need to split a scene file up into further pieces, you can do so with the Both scene and section titles can be left out of the final exported document. The formatting of titles can be selected from the Build Novel Project dialog. You can also have them replaced with scene separators like “* * *”. -#### Text Alignment +#### Text Alignment and Indentation The text by default will have the left or justified alignment in the main text files in your project. You can also specify alignment for a specific paragraph by “pushing” it away from an edge with a set of ‘>>’ or ‘<<’ symbols, like so: @@ -35,8 +35,6 @@ This text is left-aligned. << >> This text is centred. << -#### Text Indent - You can indent a paragraph from both the left and right margin with ‘>’ and ‘<’ symbols. > This paragraph is indented from both the left margin and the right margin. This is useful for when you want to quote a large chunk of text for instance. < diff --git a/sample/content/6a2d6d5f4f401.nwd b/sample/content/6a2d6d5f4f401.nwd index 60d8d2a0..2a536e9f 100644 --- a/sample/content/6a2d6d5f4f401.nwd +++ b/sample/content/6a2d6d5f4f401.nwd @@ -1,11 +1,11 @@ %%~name: Chapter One -%%~path: e7ded148d6e4a/6a2d6d5f4f401 +%%~path: 7031beac91f75/6a2d6d5f4f401 %%~kind: NOVEL/DOCUMENT ## So it Begins @pov: Jane @location: Earth -% Synopsis: We can add a chapter file, but keep the scene files separate. In the chapter file we can set the meta data that applies to the whole chapter if we wish to. +% Synopsis: We can add a chapter document, but keep the scene files separate. In the chapter document we can set the meta data that applies to the whole chapter if we wish to. You can add the scenes as child documents directly under the chapter. -A chapter can also contain leading text before the first scene. +A chapter can contain leading text before the first scene, like this piece of text. diff --git a/sample/content/88706ddc78b1b.nwd b/sample/content/88706ddc78b1b.nwd index ce4d2123..ceaddd29 100644 --- a/sample/content/88706ddc78b1b.nwd +++ b/sample/content/88706ddc78b1b.nwd @@ -1,5 +1,5 @@ %%~name: Chapter Two -%%~path: e7ded148d6e4a/88706ddc78b1b +%%~path: 7031beac91f75/88706ddc78b1b %%~kind: NOVEL/DOCUMENT ## Where has John Gone? @@ -11,6 +11,7 @@ ### Jane Cannot Find John @pov: Jane +@focus: John @location: Space Jane has been looking all over for John. He’s nowhere to be found on Earth, so Jane goes to space. diff --git a/sample/content/8a5deb88c0e97.nwd b/sample/content/8a5deb88c0e97.nwd index c5b61a9f..31a99aeb 100644 --- a/sample/content/8a5deb88c0e97.nwd +++ b/sample/content/8a5deb88c0e97.nwd @@ -1,6 +1,6 @@ %%~name: Old File %%~path: ae9bf3c3ea159/8a5deb88c0e97 -%%~kind: NOVEL/DOCUMENT +%%~kind: ARCHIVE/DOCUMENT ### Discarded Scene -If you have files you no longer want in your main project, you can move them to the “Archive” folder. This is equivalent to turning off the “Include when building project” switch, just that you also put the file away, although the switch can be ignored when building the project, this folder cannot. +If you have files you no longer want in your main project, you can move them to the “Archive” folder. This is equivalent to turning off the “Include when building project” switch, just that you also put the file away. diff --git a/sample/content/96b68994dfa3d.nwd b/sample/content/96b68994dfa3d.nwd index 849cd41d..05ae7770 100644 --- a/sample/content/96b68994dfa3d.nwd +++ b/sample/content/96b68994dfa3d.nwd @@ -1,11 +1,11 @@ %%~name: A Note on Structure -%%~path: e7ded148d6e4a/96b68994dfa3d +%%~path: 7031beac91f75/96b68994dfa3d %%~kind: NOVEL/NOTE # A Note on Structure This file is just a note. You can save notes anywhere you like in the project tree. Notes can be filtered out when you export the project. -In root folders that isn’t the Novel root folder, you can _only_ add notes. In the Novel root folder, you can choose between a number of layouts. Some of them are just to let yourself know what each file is for. +In root folders that aren’t the Novel or Archive root folders, you can _only_ add notes. In the Novel and Archive folder you can also add Project Documents, which are the documents that make up your actual story. ## Headers in Notes @@ -15,8 +15,10 @@ Unlike in novel files, headers in notes have no particular meaning other than vi The folders in the tree view have no structural meaning other than they’re a way for you to organise your files into groups in whatever way suits you. They are not intended to represent chapters, but you can of course use them for that. If you do, know that you still need to define chapter headers in your structure so novelWriter knows where you want them. +If you do have separate chapter documents, you can always add scene documents as child document of the chapter instead of using folders. + ## Linking Files and Notes -You can link files and notes together by assigning tags to them, and then reference them from other files. The file class of a file determines which reference keywords apply to each file. For instance a file in the Characters root folder can be referenced using either the @char keyword or the @pov keyword. +You can link project documents and notes together by assigning tags to the notes with the @tag keyword, and then reference them from other files using one of the many reference keywords. The file class of a file determines which reference keywords apply to each file. For instance a file in the Characters root folder can be referenced using either the @char keyword or the @pov keyword. If you want to see the content of the file the reference points to, you can click Ctrl+Enter with the cursor on top of the reference, and the view pane will show you the file. In the view pane, all references are clickable, so you can navigate further. At the bottom of the view pane, a list of files referencing the one your viewing will appear. This panel updates when you navigate, unless you make it sticky by clicking the sticky checkbox. \ No newline at end of file diff --git a/sample/content/974e400180a99.nwd b/sample/content/974e400180a99.nwd index ff9b71d5..99919687 100644 --- a/sample/content/974e400180a99.nwd +++ b/sample/content/974e400180a99.nwd @@ -6,4 +6,4 @@ This is a plain page with some text on it. -If you want the text to start on a fresh page, add the [NEW PAGE] code above the text. You can also add empty paragraphs with the [VSPACE] code. +If you want the text to start on a fresh page, add the [NEW PAGE] code above the text. You can also add empty paragraphs with the [VSPACE] code. The above code adds two empty paragraphs before the text starts. diff --git a/sample/content/a520879ca0b45.nwd b/sample/content/a520879ca0b45.nwd new file mode 100644 index 00000000..12d7a62a --- /dev/null +++ b/sample/content/a520879ca0b45.nwd @@ -0,0 +1,17 @@ +%%~name: Chapter One +%%~path: e5e47ebf63b1c/a520879ca0b45 +%%~kind: NOVEL/DOCUMENT +### Chapter One + +@pov: Jane + +% Synopsis: Remember Jane and John? + +### Scene One + +@pov: Jane +@focus: John + +A project can have multiple novel root folders for multiple novels. This is the first scene of a sequel to the first novel. + +In this way, the writer can keep the same notes for multiple novels. This can be especially useful if the writer is planning a multi-novel story in advance. diff --git a/sample/content/ae7339df26ded.nwd b/sample/content/ae7339df26ded.nwd index 9b135713..8b53f816 100644 --- a/sample/content/ae7339df26ded.nwd +++ b/sample/content/ae7339df26ded.nwd @@ -1,9 +1,10 @@ %%~name: We Found John! -%%~path: e7ded148d6e4a/ae7339df26ded +%%~path: 88706ddc78b1b/ae7339df26ded %%~kind: NOVEL/DOCUMENT ### We Found John! @pov: John +@focus: John @location: Mars Jane has been searching for a while, and she finally found John on Mars. He was indeed in space! What was he doing on Mars anyway? Well, it turns out, he was farming potatoes. diff --git a/sample/content/b3e74dbc1f584.nwd b/sample/content/b3e74dbc1f584.nwd index 6931a299..bb88600b 100644 --- a/sample/content/b3e74dbc1f584.nwd +++ b/sample/content/b3e74dbc1f584.nwd @@ -4,5 +4,6 @@ # Earth @tag: Earth +@location: Space -Third planet from the sun, fairly dense, and with lots of people on it. \ No newline at end of file +Third planet from the sun, fairly dense, and with lots of people on it. diff --git a/sample/content/b8136a5a774a0.nwd b/sample/content/b8136a5a774a0.nwd index 3c2c1854..636c7227 100644 --- a/sample/content/b8136a5a774a0.nwd +++ b/sample/content/b8136a5a774a0.nwd @@ -1,6 +1,6 @@ %%~name: Delete Me! %%~path: 98acd8c76c93a/b8136a5a774a0 -%%~kind: NOVEL/DOCUMENT +%%~kind: TRASH/DOCUMENT ### Delete Me! This scene is trash. \ No newline at end of file diff --git a/sample/content/ba8a28a246524.nwd b/sample/content/ba8a28a246524.nwd index cd684e19..c2acfb26 100644 --- a/sample/content/ba8a28a246524.nwd +++ b/sample/content/ba8a28a246524.nwd @@ -1,9 +1,9 @@ %%~name: Interlude -%%~path: e7ded148d6e4a/ba8a28a246524 +%%~path: 7031beac91f75/ba8a28a246524 %%~kind: NOVEL/DOCUMENT ##! Interlude -% Notice that this is a file with the flag ‘N.Un’. The ‘N’ means it’s a novel file, and the ‘Un’ means it’s an unnumbered chapter. Unnumbered chapters can be treated separately from numbered chapters during export. Perfect for when you want to add an interlude, or for a prologue or epilogue. +% Notice that this document has a title with a ‘!’ in it in addition to the two hash symbols. This means it is an unnumbered chapter. Unnumbered chapters can be treated separately from numbered chapters during export. Perfect for when you want to add an interlude, or for a prologue or epilogue. I am the very model of a modern Major-General I've information vegetable, animal, and mineral diff --git a/sample/content/bacb7059e3083.nwd b/sample/content/bacb7059e3083.nwd new file mode 100644 index 00000000..b6be7a07 --- /dev/null +++ b/sample/content/bacb7059e3083.nwd @@ -0,0 +1,8 @@ +%%~name: Title Page +%%~path: e5e47ebf63b1c/bacb7059e3083 +%%~kind: NOVEL/DOCUMENT +#! Sequel Novel + +>> **By Jane Doh** << + +% Synopsis: Jane and John are back in a sequel to My Novel! diff --git a/sample/content/bc0cbd2a407f3.nwd b/sample/content/bc0cbd2a407f3.nwd index 3c95e8ff..fd920d0d 100644 --- a/sample/content/bc0cbd2a407f3.nwd +++ b/sample/content/bc0cbd2a407f3.nwd @@ -1,5 +1,5 @@ %%~name: Another Scene -%%~path: e7ded148d6e4a/bc0cbd2a407f3 +%%~path: 6a2d6d5f4f401/bc0cbd2a407f3 %%~kind: NOVEL/DOCUMENT ### Another Scene @@ -7,9 +7,9 @@ @focus: Jane @location: Earth -Adding more scenes to a chapter is as easy as adding more scene files, with a level three heading, or just adding another level three heading in the same file if that works for the way you want to structure your files. +Adding more scenes to a chapter is as easy as adding more scene files with a level three heading. You can of course also just add another level three heading in the same file if that works for the way you want to structure your files. -In fact, if you wish, you can add all the scenes in the chapter file too. All novelWriter cares about is the level of the headings. +In fact, if you wish, you can add all the scenes in the chapter file too. All novelWriter cares about is the level of the headings and the order in which they appear. ### More Scenes @@ -17,4 +17,4 @@ In fact, if you wish, you can add all the scenes in the chapter file too. All no @focus: John @location: Earth -This is a second scene in the same file as the previous scene. You can always split the files up later. +This is a second scene in the same file as the previous scene. You can always split the files up later using the split tool. diff --git a/sample/nwProject.nwx b/sample/nwProject.nwx index 68265de5..9f3a6587 100644 --- a/sample/nwProject.nwx +++ b/sample/nwProject.nwx @@ -1,13 +1,13 @@ - + Sample Project Sample Project Jane Smith Jay Doh - 1303 - 199 - 65005 + 1334 + 225 + 67746 False @@ -15,11 +15,11 @@ True None True - 636b6aa9b697b + a520879ca0b45 636b6aa9b697b - 1206 - 830 - 376 + 1363 + 954 + 409 B E @@ -33,121 +33,129 @@
- New - Notes - Started - 1st Draft - 2nd Draft - 3rd Draft - Finished + New + Notes + Started + 1st Draft + 2nd Draft + 3rd Draft + Finished - None - Minor - Major - Main + None + Minor + Major + Main
- - + + - Novel + Novel - - - Title Page + + + Title Page - - - Page + + + Page - - - Part One + + + Part One - + + + Chapter One + + + + Making a Scene + + + + Another Scene + + + + Interlude + + + + A Note on Structure + + + + Chapter Two + + + + We Found John! + + - A Folder + Sequel - - - Chapter One + + + Title Page - - - Making a Scene + + + Chapter One - - - Another Scene - - - - Interlude - - - - A Note on Structure - - - - Chapter Two - - - - We Found John! - - + - Characters + Characters - + - Main Characters + Main Characters - - - John Smith + + + John Smith - - - Jane Smith + + + Jane Smith - + - Locations + Locations - - - Earth + + + Earth - - - Space + + + Space - - - Mars + + + Mars - + - Archive + Archive - + - Scenes + Scenes - - - Old File + + + Old File - + - Trash + Trash - - - Delete Me! + + + Delete Me!
diff --git a/setup/data/x-novelwriter-project.xml b/setup/data/x-novelwriter-project.xml index 789c08fd..db045446 100644 --- a/setup/data/x-novelwriter-project.xml +++ b/setup/data/x-novelwriter-project.xml @@ -4,5 +4,6 @@ novelWriter Project + diff --git a/tests/conftest.py b/tests/conftest.py index 4d8d7183..769f855c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -180,6 +180,26 @@ def nwGUI(qtbot, monkeypatch, fncDir, fncConf): return +## +# Python Objects +## + +@pytest.fixture(scope="function") +def mockRnd(monkeypatch): + """Create a mock random number generator that just counts upwards + 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 + + gen = rnd(1000) + monkeypatch.setattr("random.getrandbits", lambda *a: next(gen)) + + return + + ## # Temp Project Folders ## @@ -248,10 +268,6 @@ def nwOldProj(tmpDir): return -## -# Useful Fixtures -## - @pytest.fixture(scope="session") def ipsumText(): """Return five paragraphs of Lorem Ipsum text. diff --git a/tests/lipsum/nwProject.nwx b/tests/lipsum/nwProject.nwx index 55a582ad..02e3df49 100644 --- a/tests/lipsum/nwProject.nwx +++ b/tests/lipsum/nwProject.nwx @@ -1,12 +1,12 @@ - + Lorem Ipsum Lorem Ipsum lipsum.com - 23 + 26 24 - 1854 + 1863 False @@ -31,102 +31,102 @@
- New - Note - Draft - Finished + New + Note + Draft + Finished - New - Minor - Major - Main + New + Minor + Major + Main
- + - Novel + Novel - - - Lorem Ipsum + + + Lorem Ipsum - - - Front Matter + + + Front Matter - - - Prologue + + + Prologue - - - Act One + + + Act One - + - Chapter One + Chapter One - - - Chapter One + + + Chapter One - - - Scene One + + + Scene One - - - Scene Two + + + Scene Two - - - Interlude + + + Interlude - + - Chapter Two + Chapter Two - - - Chapter Two + + + Chapter Two - - - Scene Three + + + Scene Three - - - Scene Four + + + Scene Four - - - Scene Five + + + Scene Five - + - Characters + Characters - - - Mr. Nobody + + + Mr. Nobody - + - Plot + Plot - - - Main + + + Main - + - World + World - - - Ancient Europe + + + Ancient Europe
diff --git a/tests/minimal/nwProject.nwx b/tests/minimal/nwProject.nwx index 6f45815d..af7595a4 100644 --- a/tests/minimal/nwProject.nwx +++ b/tests/minimal/nwProject.nwx @@ -1,13 +1,13 @@ - + Test Minimal Minimal Jane Doe John Doh - 14 + 17 2 - 135 + 150 True @@ -29,50 +29,50 @@
- New - Note - Draft - Finished + New + Note + Draft + Finished - New - Minor - Major - Main + New + Minor + Major + Main
- + - Novel + Novel - - - Title Page + + + Title Page - + - New Chapter + New Chapter - - - New Chapter + + + New Chapter - - - New Scene + + + New Scene - + - Plot + Plot - + - Characters + Characters - + - World + World
diff --git a/tests/mock.py b/tests/mock.py index 4b272a17..23938d41 100644 --- a/tests/mock.py +++ b/tests/mock.py @@ -29,7 +29,6 @@ class MockGuiMain(): def __init__(self): self.mainConf = None self.hasProject = True - self.theIndex = None self.theProject = None self.statusBar = MockStatusBar() diff --git a/tests/reference/coreIndex_LoadSave_tagsIndex.json b/tests/reference/coreIndex_LoadSave_tagsIndex.json index fb4d9acd..60c59d86 100644 --- a/tests/reference/coreIndex_LoadSave_tagsIndex.json +++ b/tests/reference/coreIndex_LoadSave_tagsIndex.json @@ -1,99 +1,125 @@ { -"tagIndex": { - "Bod": [3, "4c4f28287af27", "CHARACTER", "T000001"], - "Main": [3, "2426c6f0ca922", "PLOT", "T000001"], - "Europe": [3, "04468803b92e1", "WORLD", "T000001"] -}, -"refIndex": { - "fb609cd8319dc": { - "T000001": [[3, "@pov", "Bod"], [4, "@plot", "Main"], [5, "@location", "Europe"]] + "tagsIndex": { + "Bod": {"handle": "4c4f28287af27", "heading": "T000001", "class": "CHARACTER"}, + "Main": {"handle": "2426c6f0ca922", "heading": "T000001", "class": "PLOT"}, + "Europe": {"handle": "04468803b92e1", "heading": "T000001", "class": "WORLD"} }, - "88243afbe5ed8": { - "T000001": [[3, "@pov", "Bod"], [4, "@plot", "Main"], [5, "@location", "Europe"]] - }, - "f96ec11c6a3da": { - "T000001": [[3, "@pov", "Bod"], [4, "@plot", "Main"], [5, "@location", "Europe"]] - }, - "441420a886d82": { - "T000001": [[3, "@pov", "Bod"], [4, "@plot", "Main"], [5, "@location", "Europe"]] - }, - "eb103bc70c90c": { - "T000001": [[3, "@pov", "Bod"], [4, "@plot", "Main"], [5, "@location", "Europe"]] - }, - "f8c0562e50f1b": { - "T000001": [[3, "@pov", "Bod"], [4, "@plot", "Main"], [5, "@location", "Europe"]] - }, - "47666c91c7ccf": { - "T000001": [[3, "@pov", "Bod"], [4, "@plot", "Main"], [5, "@location", "Europe"]] - }, - "4c4f28287af27": { - "T000001": [[4, "@plot", "Main"]] + "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."} + }, + "references": { + "T000001": {"Bod": "@pov", "Main": "@plot", "Europe": "@location"} + } + }, + "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": ""} + }, + "references": { + "T000001": {"Bod": "@pov", "Main": "@plot", "Europe": "@location"} + } + }, + "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": ""} + }, + "references": { + "T000001": {"Bod": "@pov", "Main": "@plot", "Europe": "@location"} + } + }, + "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."} + }, + "references": { + "T000001": {"Bod": "@pov", "Main": "@plot", "Europe": "@location"} + } + }, + "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."} + }, + "references": { + "T000001": {"Bod": "@pov", "Main": "@plot", "Europe": "@location"} + } + }, + "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."} + }, + "references": { + "T000001": {"Bod": "@pov", "Main": "@plot", "Europe": "@location"} + } + }, + "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."} + }, + "references": { + "T000001": {"Bod": "@pov", "Main": "@plot", "Europe": "@location"} + } + }, + "4c4f28287af27": { + "level": "H1", + "headings": { + "T000001": {"level": "H1", "title": "Nobody Owens", "tag": "Bod", "cCount": 1864, "wCount": 284, "pCount": 3, "synopsis": ""} + }, + "references": { + "T000001": {"Main": "@plot"} + } + }, + "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": ""} + } + } } -}, -"fileIndex": { - "7a992350f3eb6": { - "T000001": {"level": "H1", "title": "Lorem Ipsum", "layout": "DOCUMENT", "cCount": 230, "wCount": 40, "pCount": 3, "synopsis": ""} - }, - "8c58a65414c23": { - "T000000": {"level": "H0", "title": "", "layout": "DOCUMENT", "cCount": 1058, "wCount": 176, "pCount": 2, "synopsis": ""} - }, - "88d59a277361b": { - "T000001": {"level": "H2", "title": "Prologue", "layout": "DOCUMENT", "cCount": 584, "wCount": 92, "pCount": 1, "synopsis": "Explanation from the lipsum.com website."} - }, - "db7e733775d4d": { - "T000001": {"level": "H1", "title": "Act One", "layout": "DOCUMENT", "cCount": 35, "wCount": 6, "pCount": 1, "synopsis": ""} - }, - "fb609cd8319dc": { - "T000001": {"level": "H2", "title": "Chapter One", "layout": "DOCUMENT", "cCount": 419, "wCount": 67, "pCount": 1, "synopsis": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque at aliquam quam."} - }, - "88243afbe5ed8": { - "T000001": {"level": "H3", "title": "Scene One", "layout": "DOCUMENT", "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", "layout": "DOCUMENT", "cCount": 1561, "wCount": 230, "pCount": 2, "synopsis": ""} - }, - "f96ec11c6a3da": { - "T000001": {"level": "H3", "title": "Scene Two", "layout": "DOCUMENT", "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", "layout": "DOCUMENT", "cCount": 2009, "wCount": 301, "pCount": 3, "synopsis": ""} - }, - "846352075de7d": { - "T000001": {"level": "H2", "title": "Why do we use it?", "layout": "DOCUMENT", "cCount": 631, "wCount": 109, "pCount": 3, "synopsis": ""} - }, - "441420a886d82": { - "T000001": {"level": "H2", "title": "Chapter Two", "layout": "DOCUMENT", "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."} - }, - "eb103bc70c90c": { - "T000001": {"level": "H3", "title": "Scene Three", "layout": "DOCUMENT", "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."} - }, - "f8c0562e50f1b": { - "T000001": {"level": "H3", "title": "Scene Four", "layout": "DOCUMENT", "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."} - }, - "47666c91c7ccf": { - "T000001": {"level": "H3", "title": "Scene Five", "layout": "DOCUMENT", "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."} - }, - "4c4f28287af27": { - "T000001": {"level": "H1", "title": "Nobody Owens", "layout": "NOTE", "cCount": 1864, "wCount": 284, "pCount": 3, "synopsis": ""} - }, - "2426c6f0ca922": { - "T000001": {"level": "H1", "title": "Main Plot", "layout": "NOTE", "cCount": 1369, "wCount": 195, "pCount": 2, "synopsis": ""} - }, - "04468803b92e1": { - "T000001": {"level": "H1", "title": "Ancient Europe", "layout": "NOTE", "cCount": 1770, "wCount": 259, "pCount": 3, "synopsis": ""} - } -}, -"fileMeta": { - "7a992350f3eb6": ["H1", 230, 40, 3], - "8c58a65414c23": ["H0", 1058, 176, 2], - "88d59a277361b": ["H2", 584, 92, 1], - "db7e733775d4d": ["H1", 35, 6, 1], - "fb609cd8319dc": ["H2", 419, 67, 1], - "88243afbe5ed8": ["H3", 2758, 404, 4], - "f96ec11c6a3da": ["H3", 4043, 600, 6], - "846352075de7d": ["H2", 631, 109, 3], - "441420a886d82": ["H2", 477, 70, 1], - "eb103bc70c90c": ["H3", 3006, 439, 4], - "f8c0562e50f1b": ["H3", 3839, 563, 6], - "47666c91c7ccf": ["H3", 3644, 543, 5], - "4c4f28287af27": ["H1", 1864, 284, 3], - "2426c6f0ca922": ["H1", 1369, 195, 2], - "04468803b92e1": ["H1", 1770, 259, 3] -} } diff --git a/tests/reference/coreProject_NewCustomA_nwProject.nwx b/tests/reference/coreProject_NewCustomA_nwProject.nwx index 10398c21..be4845bf 100644 --- a/tests/reference/coreProject_NewCustomA_nwProject.nwx +++ b/tests/reference/coreProject_NewCustomA_nwProject.nwx @@ -1,5 +1,5 @@ - + Test Custom Test Novel @@ -29,110 +29,106 @@
- New - Note - Draft - Finished + New + Note + Draft + Finished - New - Minor - Major - Main + New + Minor + Major + Main - - + + - Novel + 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 + Plot - + + + Main Plot + + - Characters + Characters - + + + Protagonist + + - Locations + Locations - + + + Main Location + + - Timeline + Archive - + - Objects - - - - Entities - - - - Title Page - - - - Chapter 1 - - - - Chapter 1 - - - - Scene 1.1 - - - - Scene 1.2 - - - - Scene 1.3 - - - - Chapter 2 - - - - Chapter 2 - - - - Scene 2.1 - - - - Scene 2.2 - - - - Scene 2.3 - - - - Chapter 3 - - - - Chapter 3 - - - - Scene 3.1 - - - - Scene 3.2 - - - - Scene 3.3 + Trash
diff --git a/tests/reference/coreProject_NewCustomB_nwProject.nwx b/tests/reference/coreProject_NewCustomB_nwProject.nwx index 2bb2df31..12f911cd 100644 --- a/tests/reference/coreProject_NewCustomB_nwProject.nwx +++ b/tests/reference/coreProject_NewCustomB_nwProject.nwx @@ -1,5 +1,5 @@ - + Test Custom Test Novel @@ -29,74 +29,82 @@
- New - Note - Draft - Finished + New + Note + Draft + Finished - New - Minor - Major - Main + New + Minor + Major + Main - - + + - Novel + Novel - + + + Title Page + + + + Scene 1 + + + + Scene 2 + + + + Scene 3 + + + + Scene 4 + + + + Scene 5 + + + + Scene 6 + + - Plot + Plot - + + + Main Plot + + - Characters + Characters - + + + Protagonist + + - Locations + Locations - + + + Main Location + + - Timeline + Archive - + - Objects - - - - Entities - - - - Title Page - - - - Scene 1 - - - - Scene 2 - - - - Scene 3 - - - - Scene 4 - - - - Scene 5 - - - - Scene 6 + Trash
diff --git a/tests/reference/coreProject_NewFile_nwProject.nwx b/tests/reference/coreProject_NewFile_nwProject.nwx index d4ffb126..19a2f4bf 100644 --- a/tests/reference/coreProject_NewFile_nwProject.nwx +++ b/tests/reference/coreProject_NewFile_nwProject.nwx @@ -1,8 +1,9 @@ - + New Project - + New Novel + Jane Doe 2 1 0 @@ -27,58 +28,58 @@
- 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 - - - Hello + + + Hello - - - Jane + + + Jane
diff --git a/tests/reference/coreProject_NewMinimal_nwProject.nwx b/tests/reference/coreProject_NewMinimal_nwProject.nwx index 20fa4d71..422bb6b2 100644 --- a/tests/reference/coreProject_NewMinimal_nwProject.nwx +++ b/tests/reference/coreProject_NewMinimal_nwProject.nwx @@ -1,9 +1,9 @@ - + New Project - 1 + 2 1 0 @@ -27,50 +27,50 @@
- New - Note - Draft - Finished + New + Note + Draft + Finished - New - Minor - Major - Main + New + Minor + Major + Main - + - Novel + Novel - + + + Title Page + + + + New Chapter + + + + New Scene + + - Plot + Plot - + - Characters + Characters - + - World + Locations - - - Title Page - - + - New Chapter - - - - New Chapter - - - - New Scene + Archive
diff --git a/tests/reference/coreProject_NewRoot_nwProject.nwx b/tests/reference/coreProject_NewRoot_nwProject.nwx index 22d51fd7..cd25c1cf 100644 --- a/tests/reference/coreProject_NewRoot_nwProject.nwx +++ b/tests/reference/coreProject_NewRoot_nwProject.nwx @@ -1,8 +1,9 @@ - + New Project - + New Novel + Jane Doe 2 1 0 @@ -27,66 +28,82 @@
- 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 - + - Timeline + Novel - + - Object + Plot - + - Custom1 + Characters - + - Custom2 + Locations + + + + Timeline + + + + Objects + + + + Custom + + + + Custom
diff --git a/tests/reference/guiEditor_Main_Final_0e17daca5f3e1.nwd b/tests/reference/guiEditor_Main_Final_000000000000f.nwd similarity index 95% rename from tests/reference/guiEditor_Main_Final_0e17daca5f3e1.nwd rename to tests/reference/guiEditor_Main_Final_000000000000f.nwd index 67886bc1..fcab1110 100644 --- a/tests/reference/guiEditor_Main_Final_0e17daca5f3e1.nwd +++ b/tests/reference/guiEditor_Main_Final_000000000000f.nwd @@ -1,5 +1,5 @@ %%~name: New Scene -%%~path: 31489056e0916/0e17daca5f3e1 +%%~path: 000000000000d/000000000000f %%~kind: NOVEL/DOCUMENT # Novel diff --git a/tests/reference/guiEditor_Main_Final_1a6562590ef19.nwd b/tests/reference/guiEditor_Main_Final_0000000000020.nwd similarity index 57% rename from tests/reference/guiEditor_Main_Final_1a6562590ef19.nwd rename to tests/reference/guiEditor_Main_Final_0000000000020.nwd index 9a3ca0a9..c0316819 100644 --- a/tests/reference/guiEditor_Main_Final_1a6562590ef19.nwd +++ b/tests/reference/guiEditor_Main_Final_0000000000020.nwd @@ -1,5 +1,5 @@ -%%~name: New File -%%~path: 71ee45a3c0db9/1a6562590ef19 +%%~name: New Note +%%~path: 000000000000a/0000000000020 %%~kind: CHARACTER/NOTE # Jane Doe diff --git a/tests/reference/guiEditor_Main_Final_031b4af5197ec.nwd b/tests/reference/guiEditor_Main_Final_0000000000021.nwd similarity index 61% rename from tests/reference/guiEditor_Main_Final_031b4af5197ec.nwd rename to tests/reference/guiEditor_Main_Final_0000000000021.nwd index acb36501..5dddd23b 100644 --- a/tests/reference/guiEditor_Main_Final_031b4af5197ec.nwd +++ b/tests/reference/guiEditor_Main_Final_0000000000021.nwd @@ -1,5 +1,5 @@ -%%~name: New File -%%~path: 44cb730c42048/031b4af5197ec +%%~name: New Note +%%~path: 0000000000009/0000000000021 %%~kind: PLOT/NOTE # Main Plot diff --git a/tests/reference/guiEditor_Main_Final_41cfc0d1f2d12.nwd b/tests/reference/guiEditor_Main_Final_0000000000022.nwd similarity index 62% rename from tests/reference/guiEditor_Main_Final_41cfc0d1f2d12.nwd rename to tests/reference/guiEditor_Main_Final_0000000000022.nwd index 8e8cb037..092f832a 100644 --- a/tests/reference/guiEditor_Main_Final_41cfc0d1f2d12.nwd +++ b/tests/reference/guiEditor_Main_Final_0000000000022.nwd @@ -1,5 +1,5 @@ -%%~name: New File -%%~path: 811786ad1ae74/41cfc0d1f2d12 +%%~name: New Note +%%~path: 000000000000b/0000000000022 %%~kind: WORLD/NOTE # Main Location diff --git a/tests/reference/guiEditor_Main_Final_nwProject.nwx b/tests/reference/guiEditor_Main_Final_nwProject.nwx index 685095bb..a47d454d 100644 --- a/tests/reference/guiEditor_Main_Final_nwProject.nwx +++ b/tests/reference/guiEditor_Main_Final_nwProject.nwx @@ -1,9 +1,10 @@ - + New Project - - 5 + New Novel + Jane Doe + 4 2 3 @@ -13,10 +14,10 @@ True None True - 0e17daca5f3e1 + 000000000000f None - 126 - 99 + 129 + 102 27 @@ -27,66 +28,66 @@
- New - Note - Draft - Finished + New + Note + Draft + Finished - New - Minor - Major - Main + New + Minor + Major + Main - + - Novel + Novel - - - Title Page + + + Title Page - + - New Chapter + New Chapter - - - New Chapter + + + New Chapter - - - New Scene + + + New Scene - + - Plot + Plot - - - New File + + + New Note - + - Characters + Characters - - - New File + + + New Note - + - World + World - - - New File + + + New Note - - - Trash + + + Trash
diff --git a/tests/reference/guiEditor_Main_Initial_nwProject.nwx b/tests/reference/guiEditor_Main_Initial_nwProject.nwx index a42552cf..03313570 100644 --- a/tests/reference/guiEditor_Main_Initial_nwProject.nwx +++ b/tests/reference/guiEditor_Main_Initial_nwProject.nwx @@ -1,9 +1,10 @@ - + New Project - - 3 + New Novel + Jane Doe + 2 1 0 @@ -15,8 +16,8 @@ True None None - 6 - 6 + 9 + 9 0 @@ -27,50 +28,50 @@
- New - Note - Draft - Finished + New + Note + Draft + Finished - New - Minor - Major - Main + New + Minor + Major + Main - + - Novel + Novel - - - Title Page + + + Title Page - + - New Chapter + New Chapter - - - New Chapter + + + New Chapter - - - New Scene + + + New Scene - + - Plot + Plot - + - Characters + Characters - + - World + World
diff --git a/tests/reference/guiProjSettings_Dialog_nwProject.nwx b/tests/reference/guiProjSettings_Dialog_nwProject.nwx index c6652f72..f12c1dc2 100644 --- a/tests/reference/guiProjSettings_Dialog_nwProject.nwx +++ b/tests/reference/guiProjSettings_Dialog_nwProject.nwx @@ -1,11 +1,11 @@ - + Project Name Project Title Jane Doe John Doh - 2 + 1 1 0 @@ -17,13 +17,13 @@ True None None - 6 - 6 + 9 + 9 0 B D - With This Stuff + With This Stuff %title% @@ -33,50 +33,50 @@
- New - Note - Finished - Final + New + Note + Finished + Final - New - Minor - Major - Final + New + Minor + Major + Final - + - Novel + Novel - - - Title Page + + + Title Page - + - New Chapter + New Chapter - - - New Chapter + + + New Chapter - - - New Scene + + + New Scene - + - Plot + Plot - + - Characters + Characters - + - World + World
diff --git a/tests/test_base/test_base_common.py b/tests/test_base/test_base_common.py index f5eef8fe..0ce153b4 100644 --- a/tests/test_base/test_base_common.py +++ b/tests/test_base/test_base_common.py @@ -31,9 +31,9 @@ from novelwriter.guimain import GuiMain from novelwriter.common import ( checkString, checkInt, checkFloat, checkBool, checkHandle, isHandle, isTitleTag, isItemClass, isItemType, isItemLayout, hexToInt, checkIntRange, - checkIntTuple, formatInt, formatTimeStamp, formatTime, splitVersionNumber, - transferCase, fuzzyTime, numberToRoman, jsonEncode, readTextFile, - makeFileNameSafe, sha256sum, getGuiItem, NWConfigParser + minmax, checkIntTuple, formatInt, formatTimeStamp, formatTime, simplified, + splitVersionNumber, transferCase, fuzzyTime, numberToRoman, jsonEncode, + readTextFile, makeFileNameSafe, sha256sum, getGuiItem, NWConfigParser ) @@ -161,6 +161,7 @@ def testBaseCommon_IsItemClass(): assert isItemClass("ARCHIVE") is True assert isItemClass("TRASH") is True + # Invalid assert isItemClass("None") is False assert isItemClass(None) is False assert isItemClass("STUFF") is False @@ -176,8 +177,11 @@ def testBaseCommon_IsItemType(): assert isItemType("ROOT") is True assert isItemType("FOLDER") is True assert isItemType("FILE") is True - assert isItemType("TRASH") is True + # Deprecated Type + assert isItemType("TRASH") is False + + # Invalid assert isItemType("None") is False assert isItemType(None) is False assert isItemType("STUFF") is False @@ -193,6 +197,16 @@ def testBaseCommon_IsItemLayout(): assert isItemLayout("DOCUMENT") is True assert isItemLayout("NOTE") is True + # Deprecated Layouts + assert isItemLayout("TITLE") is False + assert isItemLayout("PAGE") is False + assert isItemLayout("BOOK") is False + assert isItemLayout("PARTITION") is False + assert isItemLayout("UNNUMBERED") is False + assert isItemLayout("CHAPTER") is False + assert isItemLayout("SCENE") is False + + # Invalid assert isItemLayout("None") is False assert isItemLayout(None) is False assert isItemLayout("STUFF") is False @@ -226,6 +240,16 @@ def testBaseCommon_CheckIntRange(): # END Test testBaseCommon_CheckIntRange +@pytest.mark.base +def testBaseCommon_MinMax(): + """Test the minmax function. + """ + for i in range(-5, 15): + assert 0 <= minmax(i, 0, 10) <= 10 + +# END Test testBaseCommon_MinMax + + @pytest.mark.base def testBaseCommon_CheckIntTuple(): """Test the checkIntTuple function. @@ -270,6 +294,17 @@ def testBaseCommon_FormatTime(): # END Test testBaseCommon_FormatTime +@pytest.mark.base +def testBaseCommon_Simplified(): + """Test the simplified function. + """ + assert simplified("Hello World") == "Hello World" + assert simplified(" Hello World ") == "Hello World" + assert simplified("\tHello\n\r\tWorld") == "Hello World" + +# END Test testBaseCommon_Simplified + + @pytest.mark.base def testBaseCommon_SplitVersionNumber(): """Test the splitVersionNumber function. diff --git a/tests/test_core/test_core_document.py b/tests/test_core/test_core_document.py index eb7794b5..2f80e45a 100644 --- a/tests/test_core/test_core_document.py +++ b/tests/test_core/test_core_document.py @@ -64,9 +64,9 @@ def testCoreDocument_LoadSave(monkeypatch, mockGUI, nwMinimal): assert theDoc.readDocument() == "### New Scene\n\n" # Try to open a new (non-existent) file - nHandle = theProject.projTree.findRoot(nwItemClass.NOVEL) + nHandle = theProject.tree.findRoot(nwItemClass.NOVEL) assert nHandle is not None - xHandle = theProject.newFile("New File", nwItemClass.NOVEL, nHandle) + xHandle = theProject.newFile("New File", nHandle) theDoc = NWDoc(theProject, xHandle) assert bool(theDoc) is True assert repr(theDoc) == f"" diff --git a/tests/test_core/test_core_index.py b/tests/test_core/test_core_index.py index 9a7f7b88..3d40a9df 100644 --- a/tests/test_core/test_core_index.py +++ b/tests/test_core/test_core_index.py @@ -19,17 +19,17 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import pytest import os import json +import pytest from shutil import copyfile from mock import causeException -from tools import cmpFiles +from tools import buildTestProject, cmpFiles, writeFile from novelwriter.core.project import NWProject -from novelwriter.core.index import NWIndex, countWords +from novelwriter.core.index import NWIndex, countWords, TagsIndex from novelwriter.enum import nwItemClass, nwItemLayout @@ -43,10 +43,11 @@ def testCoreIndex_LoadSave(monkeypatch, nwLipsum, mockGUI, outDir, refDir): compFile = os.path.join(refDir, "coreIndex_LoadSave_tagsIndex.json") theProject = NWProject(mockGUI) - theProject.projTree.setSeed(42) assert theProject.openProject(nwLipsum) theIndex = NWIndex(theProject) + assert repr(theIndex) == "" + notIndexable = { "b3643d0f92e32": False, # Novel ROOT "45e6b01ca35c1": False, # Chapter One FOLDER @@ -55,7 +56,7 @@ def testCoreIndex_LoadSave(monkeypatch, nwLipsum, mockGUI, outDir, refDir): "6c6afb1247750": False, # Plot ROOT "60bdf227455cc": False, # World ROOT } - for tItem in theProject.projTree: + for tItem in theProject.tree: assert theIndex.reIndexHandle(tItem.itemHandle) is notIndexable.get(tItem.itemHandle, True) assert theIndex.reIndexHandle(None) is False @@ -69,66 +70,77 @@ def testCoreIndex_LoadSave(monkeypatch, nwLipsum, mockGUI, outDir, refDir): assert theIndex.saveIndex() is True # Take a copy of the index - tagIndex = str(theIndex._tagIndex) - refIndex = str(theIndex._refIndex) - fileIndex = str(theIndex._fileIndex) - textCounts = str(theIndex._fileMeta) + tagIndex = str(theIndex._tagsIndex.packData()) + itemsIndex = str(theIndex._itemIndex.packData()) # Delete a handle - assert theIndex._tagIndex.get("Bod", None) is not None - assert theIndex._refIndex.get("4c4f28287af27", None) is not None - assert theIndex._fileIndex.get("4c4f28287af27", None) is not None - assert theIndex._fileMeta.get("4c4f28287af27", None) is not None + assert theIndex._tagsIndex["Bod"] is not None + assert theIndex._itemIndex["4c4f28287af27"] is not None theIndex.deleteHandle("4c4f28287af27") - assert theIndex._tagIndex.get("Bod", None) is None - assert theIndex._refIndex.get("4c4f28287af27", None) is None - assert theIndex._fileIndex.get("4c4f28287af27", None) is None - assert theIndex._fileMeta.get("4c4f28287af27", None) is None + assert theIndex._tagsIndex["Bod"] is None + assert theIndex._itemIndex["4c4f28287af27"] is None # Clear the index theIndex.clearIndex() - assert theIndex._tagIndex == {} - assert theIndex._refIndex == {} - assert theIndex._fileIndex == {} - assert theIndex._fileMeta == {} + assert theIndex._tagsIndex._tags == {} + assert theIndex._itemIndex._items == {} # Make the load fail with monkeypatch.context() as mp: mp.setattr(json, "load", causeException) assert theIndex.loadIndex() is False + assert theIndex.indexBroken is True # Make the load pass assert theIndex.loadIndex() is True - - assert str(theIndex._tagIndex) == tagIndex - assert str(theIndex._refIndex) == refIndex - assert str(theIndex._fileIndex) == fileIndex - assert str(theIndex._fileMeta) == textCounts - - # Break the index and check that we notice assert theIndex.indexBroken is False - theIndex._tagIndex["Bod"].append("Stuff") - theIndex._checkIndex() + + assert str(theIndex._tagsIndex.packData()) == tagIndex + assert str(theIndex._itemIndex.packData()) == itemsIndex + + # Check File + copyfile(projFile, testFile) + assert cmpFiles(testFile, compFile) + + # Write an emtpy index file and load it + writeFile(projFile, "{}") + assert theIndex.loadIndex() is False assert theIndex.indexBroken is True + # Write an index file that passes loading, but is still empty + writeFile(projFile, '{"tagsIndex": {}, "itemIndex": {}}') + assert theIndex.loadIndex() is True + assert theIndex.indexBroken is False + + # Check that the index is re-populated + assert "04468803b92e1" in theIndex._itemIndex + assert "2426c6f0ca922" in theIndex._itemIndex + assert "441420a886d82" in theIndex._itemIndex + assert "47666c91c7ccf" in theIndex._itemIndex + assert "4c4f28287af27" in theIndex._itemIndex + assert "846352075de7d" in theIndex._itemIndex + assert "88243afbe5ed8" in theIndex._itemIndex + assert "88d59a277361b" in theIndex._itemIndex + assert "8c58a65414c23" in theIndex._itemIndex + assert "db7e733775d4d" in theIndex._itemIndex + assert "eb103bc70c90c" in theIndex._itemIndex + assert "f8c0562e50f1b" in theIndex._itemIndex + assert "f96ec11c6a3da" in theIndex._itemIndex + assert "fb609cd8319dc" in theIndex._itemIndex + assert "7a992350f3eb6" in theIndex._itemIndex + # Finalise assert theProject.closeProject() is True - copyfile(projFile, testFile) - assert cmpFiles(testFile, compFile) - # END Test testCoreIndex_LoadSave @pytest.mark.core -def testCoreIndex_ScanThis(nwMinimal, mockGUI): +def testCoreIndex_ScanThis(mockGUI): """Test the tag scanner function scanThis. """ theProject = NWProject(mockGUI) - theProject.projTree.setSeed(42) - assert theProject.openProject(nwMinimal) is True - - theIndex = NWIndex(theProject) + theIndex = theProject.index isValid, theBits, thePos = theIndex.scanThis("tag: this, and this") assert isValid is False @@ -173,21 +185,19 @@ def testCoreIndex_ScanThis(nwMinimal, mockGUI): @pytest.mark.core -def testCoreIndex_CheckThese(nwMinimal, mockGUI): +def testCoreIndex_CheckThese(mockGUI, fncDir, mockRnd): """Test the tag checker function checkThese. """ theProject = NWProject(mockGUI) - theProject.projTree.setSeed(42) - assert theProject.openProject(nwMinimal) is True + buildTestProject(theProject, fncDir) + theIndex = theProject.index - theIndex = NWIndex(theProject) - nHandle = theProject.newFile("Hello", nwItemClass.NOVEL, "a508bb932959c") - cHandle = theProject.newFile("Jane", nwItemClass.CHARACTER, "afb3043c7b2b3") - nItem = theProject.projTree[nHandle] - cItem = theProject.projTree[cHandle] + nHandle = theProject.newFile("Hello", "0000000000010") + cHandle = theProject.newFile("Jane", "0000000000012") + nItem = theProject.tree[nHandle] + cItem = theProject.tree[cHandle] - assert theIndex.novelChangedSince(0) is False - assert theIndex.notesChangedSince(0) is False + assert theIndex.rootChangedSince("0000000000010", 0) is False assert theIndex.indexChangedSince(0) is False assert theIndex.scanText(cHandle, ( @@ -201,8 +211,10 @@ def testCoreIndex_CheckThese(nwMinimal, mockGUI): "@pov: Jane\n" "@invalid: John\n" # Checks for issue #688 )) - assert theIndex._tagIndex == {"Jane": [2, cHandle, "CHARACTER", "T000001"]} - assert theIndex.getNovelData(nHandle, "T000001")["title"] == "Hello World!" + assert theIndex._tagsIndex.tagHandle("Jane") == cHandle + assert theIndex._tagsIndex.tagHeading("Jane") == "T000001" + assert theIndex._tagsIndex.tagClass("Jane") == "CHARACTER" + assert theIndex.getNovelData(nHandle, "T000001").title == "Hello World!" assert theIndex.getReferences(nHandle, "T000001") == { "@char": [], "@custom": [], @@ -215,12 +227,14 @@ def testCoreIndex_CheckThese(nwMinimal, mockGUI): "@time": [] } - assert theIndex.novelChangedSince(0) is True - assert theIndex.notesChangedSince(0) is True + assert theIndex.rootChangedSince("0000000000010", 0) is True assert theIndex.indexChangedSince(0) is True assert theIndex.getHandleHeaderLevel(cHandle) == "H1" assert theIndex.getHandleHeaderLevel(nHandle) == "H1" + assert theIndex.getHandleHeaderIntLevel(cHandle) == 1 + assert theIndex.getHandleHeaderIntLevel(nHandle) == 1 + assert theIndex.getHandleHeaderIntLevel("stuff") == 0 # Zero Items assert theIndex.checkThese([], cItem) == [] @@ -250,19 +264,17 @@ def testCoreIndex_CheckThese(nwMinimal, mockGUI): @pytest.mark.core -def testCoreIndex_ScanText(nwMinimal, mockGUI): +def testCoreIndex_ScanText(mockGUI, fncDir, mockRnd): """Check the index text scanner. """ theProject = NWProject(mockGUI) - theProject.projTree.setSeed(42) - assert theProject.openProject(nwMinimal) is True - - theIndex = NWIndex(theProject) + buildTestProject(theProject, fncDir) + theIndex = theProject.index # Some items for fail to scan tests - dHandle = theProject.newFolder("Folder", nwItemClass.NOVEL, "a508bb932959c") - xHandle = theProject.newFile("No Layout", nwItemClass.NOVEL, "a508bb932959c") - xItem = theProject.projTree[xHandle] + dHandle = theProject.newFolder("Folder", "0000000000010") + xHandle = theProject.newFile("No Layout", "0000000000010") + xItem = theProject.tree[xHandle] xItem.setLayout(nwItemLayout.NO_LAYOUT) # Check invalid data @@ -276,22 +288,26 @@ def testCoreIndex_ScanText(nwMinimal, mockGUI): # Create the trash folder tHandle = theProject.trashFolder() - assert theProject.projTree[tHandle] is not None + assert theProject.tree[tHandle] is not None xItem.setParent(tHandle) + theProject.tree.updateItemData(xItem.itemHandle) + assert xItem.itemRoot == tHandle + assert xItem.itemClass == nwItemClass.TRASH assert theIndex.scanText(xHandle, "Hello World!") is False # Create the archive root - aHandle = theProject.newRoot("Archive", nwItemClass.ARCHIVE) - assert theProject.projTree[aHandle] is not None + 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 # Make some usable items - tHandle = theProject.newFile("Title", nwItemClass.NOVEL, "a508bb932959c") - pHandle = theProject.newFile("Page", nwItemClass.NOVEL, "a508bb932959c") - nHandle = theProject.newFile("Hello", nwItemClass.NOVEL, "a508bb932959c") - cHandle = theProject.newFile("Jane", nwItemClass.CHARACTER, "afb3043c7b2b3") - sHandle = theProject.newFile("Scene", nwItemClass.NOVEL, "a508bb932959c") + 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") # Text Indexing # ============= @@ -309,8 +325,10 @@ def testCoreIndex_ScanText(nwMinimal, mockGUI): "This is a story about Jane Smith.\n\n" "Well, not really.\n" )) - assert theIndex._tagIndex == {"Jane": [2, cHandle, "CHARACTER", "T000001"]} - assert theIndex.getNovelData(nHandle, "T000001")["title"] == "Hello World!" + assert theIndex._tagsIndex.tagHandle("Jane") == cHandle + assert theIndex._tagsIndex.tagHeading("Jane") == "T000001" + assert theIndex._tagsIndex.tagClass("Jane") == "CHARACTER" + assert theIndex.getNovelData(nHandle, "T000001").title == "Hello World!" # Title Indexing # ============== @@ -332,42 +350,40 @@ def testCoreIndex_ScanText(nwMinimal, mockGUI): "##### Title Five\n\n" # Not interpreted as a title, the hashes are counted as a word "Paragraph Five.\n\n" )) - assert nHandle not in theIndex._refIndex + assert theIndex._itemIndex[nHandle]["T000001"].references == {} + assert theIndex._itemIndex[nHandle]["T000007"].references == {} + assert theIndex._itemIndex[nHandle]["T000013"].references == {} + assert theIndex._itemIndex[nHandle]["T000019"].references == {} - assert theIndex._fileIndex[nHandle]["T000001"]["level"] == "H1" - assert theIndex._fileIndex[nHandle]["T000007"]["level"] == "H2" - assert theIndex._fileIndex[nHandle]["T000013"]["level"] == "H3" - assert theIndex._fileIndex[nHandle]["T000019"]["level"] == "H4" + assert theIndex._itemIndex[nHandle]["T000001"].level == "H1" + assert theIndex._itemIndex[nHandle]["T000007"].level == "H2" + assert theIndex._itemIndex[nHandle]["T000013"].level == "H3" + assert theIndex._itemIndex[nHandle]["T000019"].level == "H4" - assert theIndex._fileIndex[nHandle]["T000001"]["title"] == "Title One" - assert theIndex._fileIndex[nHandle]["T000007"]["title"] == "Title Two" - assert theIndex._fileIndex[nHandle]["T000013"]["title"] == "Title Three" - assert theIndex._fileIndex[nHandle]["T000019"]["title"] == "Title Four" + assert theIndex._itemIndex[nHandle]["T000001"].title == "Title One" + assert theIndex._itemIndex[nHandle]["T000007"].title == "Title Two" + assert theIndex._itemIndex[nHandle]["T000013"].title == "Title Three" + assert theIndex._itemIndex[nHandle]["T000019"].title == "Title Four" - assert theIndex._fileIndex[nHandle]["T000001"]["layout"] == "DOCUMENT" - assert theIndex._fileIndex[nHandle]["T000007"]["layout"] == "DOCUMENT" - assert theIndex._fileIndex[nHandle]["T000013"]["layout"] == "DOCUMENT" - assert theIndex._fileIndex[nHandle]["T000019"]["layout"] == "DOCUMENT" + assert theIndex._itemIndex[nHandle]["T000001"].charCount == 23 + assert theIndex._itemIndex[nHandle]["T000007"].charCount == 23 + assert theIndex._itemIndex[nHandle]["T000013"].charCount == 27 + assert theIndex._itemIndex[nHandle]["T000019"].charCount == 56 - assert theIndex._fileIndex[nHandle]["T000001"]["cCount"] == 23 - assert theIndex._fileIndex[nHandle]["T000007"]["cCount"] == 23 - assert theIndex._fileIndex[nHandle]["T000013"]["cCount"] == 27 - assert theIndex._fileIndex[nHandle]["T000019"]["cCount"] == 56 + assert theIndex._itemIndex[nHandle]["T000001"].wordCount == 4 + assert theIndex._itemIndex[nHandle]["T000007"].wordCount == 4 + assert theIndex._itemIndex[nHandle]["T000013"].wordCount == 4 + assert theIndex._itemIndex[nHandle]["T000019"].wordCount == 9 - assert theIndex._fileIndex[nHandle]["T000001"]["wCount"] == 4 - assert theIndex._fileIndex[nHandle]["T000007"]["wCount"] == 4 - assert theIndex._fileIndex[nHandle]["T000013"]["wCount"] == 4 - assert theIndex._fileIndex[nHandle]["T000019"]["wCount"] == 9 + assert theIndex._itemIndex[nHandle]["T000001"].paraCount == 1 + assert theIndex._itemIndex[nHandle]["T000007"].paraCount == 1 + assert theIndex._itemIndex[nHandle]["T000013"].paraCount == 1 + assert theIndex._itemIndex[nHandle]["T000019"].paraCount == 3 - assert theIndex._fileIndex[nHandle]["T000001"]["pCount"] == 1 - assert theIndex._fileIndex[nHandle]["T000007"]["pCount"] == 1 - assert theIndex._fileIndex[nHandle]["T000013"]["pCount"] == 1 - assert theIndex._fileIndex[nHandle]["T000019"]["pCount"] == 3 - - assert theIndex._fileIndex[nHandle]["T000001"]["synopsis"] == "Synopsis One." - assert theIndex._fileIndex[nHandle]["T000007"]["synopsis"] == "Synopsis Two." - assert theIndex._fileIndex[nHandle]["T000013"]["synopsis"] == "Synopsis Three." - assert theIndex._fileIndex[nHandle]["T000019"]["synopsis"] == "Synopsis Four." + assert theIndex._itemIndex[nHandle]["T000001"].synopsis == "Synopsis One." + assert theIndex._itemIndex[nHandle]["T000007"].synopsis == "Synopsis Two." + assert theIndex._itemIndex[nHandle]["T000013"].synopsis == "Synopsis Three." + assert theIndex._itemIndex[nHandle]["T000019"].synopsis == "Synopsis Four." # Note File assert theIndex.scanText(cHandle, ( @@ -376,15 +392,13 @@ def testCoreIndex_ScanText(nwMinimal, mockGUI): "% synopsis: Synopsis One.\n\n" "Paragraph One.\n\n" )) - assert cHandle not in theIndex._refIndex - - assert theIndex._fileIndex[cHandle]["T000001"]["level"] == "H1" - assert theIndex._fileIndex[cHandle]["T000001"]["title"] == "Title One" - assert theIndex._fileIndex[cHandle]["T000001"]["layout"] == "NOTE" - assert theIndex._fileIndex[cHandle]["T000001"]["cCount"] == 23 - assert theIndex._fileIndex[cHandle]["T000001"]["wCount"] == 4 - assert theIndex._fileIndex[cHandle]["T000001"]["pCount"] == 1 - assert theIndex._fileIndex[cHandle]["T000001"]["synopsis"] == "Synopsis One." + assert theIndex._itemIndex[cHandle]["T000001"].references == {} + assert theIndex._itemIndex[cHandle]["T000001"].level == "H1" + assert theIndex._itemIndex[cHandle]["T000001"].title == "Title One" + assert theIndex._itemIndex[cHandle]["T000001"].charCount == 23 + assert theIndex._itemIndex[cHandle]["T000001"].wordCount == 4 + assert theIndex._itemIndex[cHandle]["T000001"].paraCount == 1 + assert theIndex._itemIndex[cHandle]["T000001"].synopsis == "Synopsis One." # Valid and Invalid References assert theIndex.scanText(sHandle, ( @@ -395,9 +409,9 @@ def testCoreIndex_ScanText(nwMinimal, mockGUI): "% synopsis: Synopsis One.\n\n" "Paragraph One.\n\n" )) - assert theIndex._refIndex[sHandle]["T000001"] == ( - [[3, "@pov", "One"], [5, "@char", "Two"]] - ) + assert theIndex._itemIndex[sHandle]["T000001"].references == { + "One": {"@pov"}, "Two": {"@char"} + } # Special Titles # ============== @@ -406,58 +420,52 @@ def testCoreIndex_ScanText(nwMinimal, mockGUI): "#! My Project\n\n" ">> By Jane Doe <<\n\n" )) - assert tHandle not in theIndex._refIndex - - assert theIndex._fileIndex[tHandle]["T000001"]["level"] == "H1" - assert theIndex._fileIndex[tHandle]["T000001"]["title"] == "My Project" - assert theIndex._fileIndex[tHandle]["T000001"]["layout"] == "DOCUMENT" - assert theIndex._fileIndex[tHandle]["T000001"]["cCount"] == 21 - assert theIndex._fileIndex[tHandle]["T000001"]["wCount"] == 5 - assert theIndex._fileIndex[tHandle]["T000001"]["pCount"] == 1 - assert theIndex._fileIndex[tHandle]["T000001"]["synopsis"] == "" + assert theIndex._itemIndex[cHandle]["T000001"].references == {} + assert theIndex._itemIndex[tHandle]["T000001"].level == "H1" + assert theIndex._itemIndex[tHandle]["T000001"].title == "My Project" + assert theIndex._itemIndex[tHandle]["T000001"].charCount == 21 + assert theIndex._itemIndex[tHandle]["T000001"].wordCount == 5 + assert theIndex._itemIndex[tHandle]["T000001"].paraCount == 1 + assert theIndex._itemIndex[tHandle]["T000001"].synopsis == "" assert theIndex.scanText(tHandle, ( "##! Prologue\n\n" "In the beginning there was time ...\n\n" )) - assert tHandle not in theIndex._refIndex - - assert theIndex._fileIndex[tHandle]["T000001"]["level"] == "H2" - assert theIndex._fileIndex[tHandle]["T000001"]["title"] == "Prologue" - assert theIndex._fileIndex[tHandle]["T000001"]["layout"] == "DOCUMENT" - assert theIndex._fileIndex[tHandle]["T000001"]["cCount"] == 43 - assert theIndex._fileIndex[tHandle]["T000001"]["wCount"] == 8 - assert theIndex._fileIndex[tHandle]["T000001"]["pCount"] == 1 - assert theIndex._fileIndex[tHandle]["T000001"]["synopsis"] == "" + assert theIndex._itemIndex[cHandle]["T000001"].references == {} + assert theIndex._itemIndex[tHandle]["T000001"].level == "H2" + assert theIndex._itemIndex[tHandle]["T000001"].title == "Prologue" + assert theIndex._itemIndex[tHandle]["T000001"].charCount == 43 + assert theIndex._itemIndex[tHandle]["T000001"].wordCount == 8 + assert theIndex._itemIndex[tHandle]["T000001"].paraCount == 1 + assert theIndex._itemIndex[tHandle]["T000001"].synopsis == "" # Page wo/Title # ============= - theProject.projTree[pHandle]._layout = nwItemLayout.DOCUMENT + theProject.tree[pHandle]._layout = nwItemLayout.DOCUMENT assert theIndex.scanText(pHandle, ( "This is a page with some text on it.\n\n" )) - assert pHandle in theIndex._fileIndex - assert theIndex._fileIndex[pHandle]["T000000"]["level"] == "H0" - assert theIndex._fileIndex[pHandle]["T000000"]["title"] == "" - assert theIndex._fileIndex[pHandle]["T000000"]["layout"] == "DOCUMENT" - assert theIndex._fileIndex[pHandle]["T000000"]["cCount"] == 36 - assert theIndex._fileIndex[pHandle]["T000000"]["wCount"] == 9 - assert theIndex._fileIndex[pHandle]["T000000"]["pCount"] == 1 - assert theIndex._fileIndex[pHandle]["T000000"]["synopsis"] == "" + assert theIndex._itemIndex[pHandle]["T000000"].references == {} + assert theIndex._itemIndex[pHandle]["T000000"].level == "H0" + assert theIndex._itemIndex[pHandle]["T000000"].title == "" + assert theIndex._itemIndex[pHandle]["T000000"].charCount == 36 + assert theIndex._itemIndex[pHandle]["T000000"].wordCount == 9 + assert theIndex._itemIndex[pHandle]["T000000"].paraCount == 1 + assert theIndex._itemIndex[pHandle]["T000000"].synopsis == "" - theProject.projTree[pHandle]._layout = nwItemLayout.NOTE + theProject.tree[pHandle]._layout = nwItemLayout.NOTE assert theIndex.scanText(pHandle, ( "This is a page with some text on it.\n\n" )) - assert pHandle in theIndex._fileIndex - assert theIndex._fileIndex[pHandle]["T000000"]["level"] == "H0" - assert theIndex._fileIndex[pHandle]["T000000"]["title"] == "" - assert theIndex._fileIndex[pHandle]["T000000"]["layout"] == "NOTE" - assert theIndex._fileIndex[pHandle]["T000000"]["cCount"] == 36 - assert theIndex._fileIndex[pHandle]["T000000"]["wCount"] == 9 - assert theIndex._fileIndex[pHandle]["T000000"]["pCount"] == 1 - assert theIndex._fileIndex[pHandle]["T000000"]["synopsis"] == "" + assert theIndex._itemIndex[pHandle]["T000000"].references == {} + assert theIndex._itemIndex[pHandle]["T000000"].level == "H0" + assert theIndex._itemIndex[pHandle]["T000000"].title == "" + assert theIndex._itemIndex[pHandle]["T000000"].charCount == 36 + assert theIndex._itemIndex[pHandle]["T000000"].wordCount == 9 + assert theIndex._itemIndex[pHandle]["T000000"].paraCount == 1 + assert theIndex._itemIndex[pHandle]["T000000"].synopsis == "" assert theProject.closeProject() is True @@ -465,19 +473,27 @@ def testCoreIndex_ScanText(nwMinimal, mockGUI): @pytest.mark.core -def testCoreIndex_ExtractData(nwMinimal, mockGUI): +def testCoreIndex_ExtractData(mockGUI, fncDir, mockRnd): """Check the index data extraction functions. """ theProject = NWProject(mockGUI) - theProject.projTree.setSeed(42) - assert theProject.openProject(nwMinimal) is True + buildTestProject(theProject, fncDir) - theIndex = NWIndex(theProject) - nHandle = theProject.newFile("Hello", nwItemClass.NOVEL, "a508bb932959c") - cHandle = theProject.newFile("Jane", nwItemClass.CHARACTER, "afb3043c7b2b3") + 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") + + nHandle = theProject.newFile("Hello", "0000000000010") + cHandle = theProject.newFile("Jane", "0000000000012") assert theIndex.getNovelData("", "") is None - assert theIndex.getNovelData("a508bb932959c", "") is None + assert theIndex.getNovelData("0000000000010", "") is None assert theIndex.scanText(cHandle, ( "# Jane Smith\n" @@ -497,28 +513,36 @@ def testCoreIndex_ExtractData(nwMinimal, mockGUI): for aKey, _, _, _ in theIndex.novelStructure(): theKeys.append(aKey) - assert theKeys == ["%s:T000001" % nHandle] + assert theKeys == [ + "0000000000014:T000001", + "0000000000016:T000001", + "0000000000017:T000001", + "%s:T000001" % nHandle, + ] # Check that excluded files can be skipped - theProject.projTree[nHandle].setExported(False) + theProject.tree[nHandle].setExported(False) theKeys = [] - for aKey, _, _, _ in theIndex.novelStructure(skipExcluded=False): + for aKey, _, _, _ in theIndex.novelStructure(skipExcl=False): theKeys.append(aKey) - assert theKeys == ["%s:T000001" % nHandle] + assert theKeys == [ + "0000000000014:T000001", + "0000000000016:T000001", + "0000000000017:T000001", + "%s:T000001" % nHandle, + ] theKeys = [] - for aKey, _, _, _ in theIndex.novelStructure(skipExcluded=True): + for aKey, _, _, _ in theIndex.novelStructure(skipExcl=True): theKeys.append(aKey) - assert theKeys == [] - - theKeys = [] - for aKey, _, _, _ in theIndex.novelStructure(): - theKeys.append(aKey) - - assert theKeys == [] + assert theKeys == [ + "0000000000014:T000001", + "0000000000016:T000001", + "0000000000017:T000001", + ] # The novel file should have the correct counts cC, wC, pC = theIndex.getCounts(nHandle) @@ -545,6 +569,9 @@ def testCoreIndex_ExtractData(nwMinimal, mockGUI): # None handle should return an empty dict assert theIndex.getBackReferenceList(None) == {} + # The Title Page file should have no references as it has no tag + assert theIndex.getBackReferenceList("0000000000014") == {} + # The character file should have a record of the reference from the novel file theRefs = theIndex.getBackReferenceList(cHandle) assert theRefs == {nHandle: "T000001"} @@ -552,13 +579,17 @@ def testCoreIndex_ExtractData(nwMinimal, mockGUI): # getTagSource # ============ - assert theIndex.getTagSource("Jane") == (cHandle, 2, "T000001") - assert theIndex.getTagSource("John") == (None, 0, "T000000") + assert theIndex.getTagSource("Jane") == (cHandle, "T000001") + assert theIndex.getTagSource("John") == (None, "T000000") # getCounts # ========= # For whole text and sections + # Invalid handle or title should return 0s + assert theIndex.getCounts("stuff") == (0, 0, 0) + assert theIndex.getCounts(nHandle, "stuff") == (0, 0, 0) + # Get section counts for a novel file assert theIndex.scanText(nHandle, ( "# Hello World!\n" @@ -628,46 +659,80 @@ def testCoreIndex_ExtractData(nwMinimal, mockGUI): # Novel Stats # =========== - hHandle = theProject.newFile("Chapter", nwItemClass.NOVEL, "a508bb932959c") - sHandle = theProject.newFile("Scene One", nwItemClass.NOVEL, "a508bb932959c") - tHandle = theProject.newFile("Scene Two", nwItemClass.NOVEL, "a508bb932959c") + hHandle = theProject.newFile("Chapter", "0000000000010") + sHandle = theProject.newFile("Scene One", "0000000000010") + tHandle = theProject.newFile("Scene Two", "0000000000010") - theProject.projTree[hHandle].itemLayout == nwItemLayout.DOCUMENT - theProject.projTree[sHandle].itemLayout == nwItemLayout.DOCUMENT - theProject.projTree[tHandle].itemLayout == nwItemLayout.DOCUMENT + theProject.tree[hHandle].itemLayout == nwItemLayout.DOCUMENT + theProject.tree[sHandle].itemLayout == nwItemLayout.DOCUMENT + theProject.tree[tHandle].itemLayout == nwItemLayout.DOCUMENT assert theIndex.scanText(hHandle, "## Chapter One\n\n") assert theIndex.scanText(sHandle, "### Scene One\n\n") assert theIndex.scanText(tHandle, "### Scene Two\n\n") - assert theIndex._listNovelHandles(False) == [nHandle, hHandle, sHandle, tHandle] - assert theIndex._listNovelHandles(True) == [hHandle, sHandle, tHandle] + assert [(h, t) for h, t, _ in theIndex._itemIndex.iterNovelStructure(skipExcl=False)] == [ + ("0000000000014", "T000001"), + ("0000000000016", "T000001"), + ("0000000000017", "T000001"), + (nHandle, "T000001"), + (nHandle, "T000011"), + (hHandle, "T000001"), + (sHandle, "T000001"), + (tHandle, "T000001"), + ] + + assert [(h, t) for h, t, _ in theIndex._itemIndex.iterNovelStructure(skipExcl=True)] == [ + ("0000000000014", "T000001"), + ("0000000000016", "T000001"), + ("0000000000017", "T000001"), + (hHandle, "T000001"), + (sHandle, "T000001"), + (tHandle, "T000001"), + ] # Add a fake handle to the tree and check that it's ignored - theProject.projTree._treeOrder.append("0000000000000") - assert theIndex._listNovelHandles(False) == [nHandle, hHandle, sHandle, tHandle] - theProject.projTree._treeOrder.remove("0000000000000") + theProject.tree._treeOrder.append("0000000000000") + assert [(h, t) for h, t, _ in theIndex._itemIndex.iterNovelStructure(skipExcl=False)] == [ + ("0000000000014", "T000001"), + ("0000000000016", "T000001"), + ("0000000000017", "T000001"), + (nHandle, "T000001"), + (nHandle, "T000011"), + (hHandle, "T000001"), + (sHandle, "T000001"), + (tHandle, "T000001"), + ] + theProject.tree._treeOrder.remove("0000000000000") # Extract stats - assert theIndex.getNovelWordCount(False) == 34 - assert theIndex.getNovelWordCount(True) == 6 - assert theIndex.getNovelTitleCounts(False) == [0, 2, 1, 2, 0] - assert theIndex.getNovelTitleCounts(True) == [0, 0, 1, 2, 0] + assert theIndex.getNovelWordCount(skipExcl=False) == 43 + assert theIndex.getNovelWordCount(skipExcl=True) == 15 + assert theIndex.getNovelTitleCounts(skipExcl=False) == [0, 3, 2, 3, 0] + assert theIndex.getNovelTitleCounts(skipExcl=True) == [0, 1, 2, 3, 0] # Table of Contents - assert theIndex.getTableOfContents(0, True) == [] - assert theIndex.getTableOfContents(1, True) == [] - assert theIndex.getTableOfContents(2, True) == [ + assert theIndex.getTableOfContents(0, skipExcl=True) == [] + assert theIndex.getTableOfContents(1, skipExcl=True) == [ + ("0000000000014: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(3, True) == [ + 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(0, False) == [] - assert theIndex.getTableOfContents(1, False) == [ + 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), ] @@ -682,7 +747,9 @@ def testCoreIndex_ExtractData(nwMinimal, mockGUI): ("%s:T000001" % nHandle, 12), ("%s:T000011" % nHandle, 16) ] - assert theProject.closeProject() + assert theIndex.saveIndex() is True + assert theProject.saveProject() is True + assert theProject.closeProject() is True # Header Record bHandle = "0000000000000" @@ -698,535 +765,400 @@ def testCoreIndex_ExtractData(nwMinimal, mockGUI): @pytest.mark.core -def testCoreIndex_CheckTagIndex(mockGUI): - """Test the tag index checker. +def testCoreIndex_TagsIndex(): + """Check the TagsIndex class. """ - theProject = NWProject(mockGUI) - theIndex = NWIndex(theProject) + tagsIndex = TagsIndex() + assert tagsIndex._tags == {} - # Valid Index - theIndex._tagIndex = { - "John": [3, "14298de4d9524", "CHARACTER", "T000001"], - "Jane": [3, "bb2c23b3c42cc", "CHARACTER", "T000001"], + # Expected data + content = { + "Tag1": { + "handle": "0000000000001", + "heading": "T000001", + "class": nwItemClass.NOVEL.name, + }, + "Tag2": { + "handle": "0000000000002", + "heading": "T000002", + "class": nwItemClass.CHARACTER.name, + }, + "Tag3": { + "handle": "0000000000003", + "heading": "T000003", + "class": nwItemClass.PLOT.name, + }, } - assert theIndex._checkTagIndex() is None - # Wrong Key Type - theIndex._tagIndex = { - "John": [3, "14298de4d9524", "CHARACTER", "T000001"], - 123456: [3, "bb2c23b3c42cc", "CHARACTER", "T000001"], - } + # Add data + tagsIndex.add("Tag1", "0000000000001", "T000001", nwItemClass.NOVEL) + tagsIndex.add("Tag2", "0000000000002", "T000002", nwItemClass.CHARACTER) + tagsIndex.add("Tag3", "0000000000003", "T000003", nwItemClass.PLOT) + assert tagsIndex._tags == content + + # Get items + assert tagsIndex["Tag1"] == content["Tag1"] + assert tagsIndex["Tag2"] == content["Tag2"] + assert tagsIndex["Tag3"] == content["Tag3"] + assert tagsIndex["Tag4"] is None + + # Contains + assert "Tag1" in tagsIndex + assert "Tag2" in tagsIndex + assert "Tag3" in tagsIndex + assert "Tag4" not in tagsIndex + + # Read back handles + assert tagsIndex.tagHandle("Tag1") == "0000000000001" + assert tagsIndex.tagHandle("Tag2") == "0000000000002" + assert tagsIndex.tagHandle("Tag3") == "0000000000003" + assert tagsIndex.tagHandle("Tag4") is None + + # Read back headings + assert tagsIndex.tagHeading("Tag1") == "T000001" + assert tagsIndex.tagHeading("Tag2") == "T000002" + assert tagsIndex.tagHeading("Tag3") == "T000003" + assert tagsIndex.tagHeading("Tag4") == "T000000" + + # Read back classes + assert tagsIndex.tagClass("Tag1") == nwItemClass.NOVEL.name + assert tagsIndex.tagClass("Tag2") == nwItemClass.CHARACTER.name + assert tagsIndex.tagClass("Tag3") == nwItemClass.PLOT.name + assert tagsIndex.tagClass("Tag4") is None + + # Pack Data + assert tagsIndex.packData() == content + + # Delete the second key and a nomn-existant key + del tagsIndex["Tag2"] + del tagsIndex["Tag4"] + assert "Tag1" in tagsIndex + assert "Tag2" not in tagsIndex + assert "Tag3" in tagsIndex + assert "Tag4" not in tagsIndex + + # Clear and reload + tagsIndex.clear() + assert tagsIndex._tags == {} + assert tagsIndex.packData() == {} + + tagsIndex.unpackData(content) + assert tagsIndex._tags == content + assert tagsIndex.packData() == content + + # Unpack Errors + # ============= + tagsIndex.clear() + + # Invalid data type + with pytest.raises(ValueError): + tagsIndex.unpackData([]) + + # Invalid key + with pytest.raises(ValueError): + tagsIndex.unpackData({ + 1234: { + "handle": "0000000000001", + "heading": "T000001", + "class": "NOVEL", + } + }) + + # Missing handle with pytest.raises(KeyError): - theIndex._checkTagIndex() + tagsIndex.unpackData({ + "Tag1": { + "heading": "T000001", + "class": "NOVEL", + } + }) - # Wrong Length - theIndex._tagIndex = { - "John": [3, "14298de4d9524", "CHARACTER", "T000001"], - "Jane": [3, "bb2c23b3c42cc", "CHARACTER", "T000001", "Stuff"], - } - with pytest.raises(IndexError): - theIndex._checkTagIndex() + # Missing heading + with pytest.raises(KeyError): + tagsIndex.unpackData({ + "Tag1": { + "handle": "0000000000001", + "class": "NOVEL", + } + }) - # Wrong Type of Entry 0 - theIndex._tagIndex = { - "John": [3, "14298de4d9524", "CHARACTER", "T000001"], - "Jane": ["3", "bb2c23b3c42cc", "CHARACTER", "T000001"], - } + # Missing class + with pytest.raises(KeyError): + tagsIndex.unpackData({ + "Tag1": { + "handle": "0000000000001", + "heading": "T000001", + } + }) + + # Invalid handle with pytest.raises(ValueError): - theIndex._checkTagIndex() + tagsIndex.unpackData({ + "Tag1": { + "handle": "blablabla", + "heading": "T000001", + "class": "NOVEL", + } + }) - # Wrong Type of Entry 1 - theIndex._tagIndex = { - "John": [3, "14298de4d9524", "CHARACTER", "T000001"], - "Jane": [3, 0xbb2c23b3c42cc, "CHARACTER", "T000001"], - } + # Invalid heading with pytest.raises(ValueError): - theIndex._checkTagIndex() + tagsIndex.unpackData({ + "Tag1": { + "handle": "0000000000001", + "heading": "blabla", + "class": "NOVEL", + } + }) - # Wrong Type of Entry 2 - theIndex._tagIndex = { - "John": [3, "14298de4d9524", "CHARACTER", "T000001"], - "Jane": [3, "bb2c23b3c42cc", "INVALID_CLASS", "T000001"], - } + # Invalid class with pytest.raises(ValueError): - theIndex._checkTagIndex() + tagsIndex.unpackData({ + "Tag1": { + "handle": "0000000000001", + "heading": "T000001", + "class": "blabla", + } + }) - # Wrong Type of Entry 3 - theIndex._tagIndex = { - "John": [3, "14298de4d9524", "CHARACTER", "T000001"], - "Jane": [3, "bb2c23b3c42cc", "CHARACTER", "INVALID"], - } - with pytest.raises(ValueError): - theIndex._checkTagIndex() - -# END Test testCoreIndex_CheckTagIndex +# END Test testCoreIndex_TagsIndex @pytest.mark.core -def testCoreIndex_CheckRefIndex(mockGUI): - """Test the reference index checker. +def testCoreIndex_ItemIndex(mockGUI, fncDir, mockRnd): + """Check the ItemIndex class. """ theProject = NWProject(mockGUI) - theIndex = NWIndex(theProject) + buildTestProject(theProject, fncDir) - # Valid Index - theIndex._refIndex = { - "6a2d6d5f4f401": { - "T000000": [], - "T000001": [[3, "@pov", "Jane"], [4, "@location", "Earth"]], - } - } - assert theIndex._checkRefIndex() is None + nHandle = "0000000000014" + cHandle = "0000000000016" + sHandle = "0000000000017" - # Invalid Handle - theIndex._refIndex = { - "Ha2d6d5f4f401": { - "T000000": [], - "T000001": [[3, "@pov", "Jane"], [4, "@location", "Earth"]], - } - } - with pytest.raises(KeyError): - theIndex._checkRefIndex() + assert theProject.index.saveIndex() is True + itemIndex = theProject.index._itemIndex - # Invalid Title - theIndex._refIndex = { - "6a2d6d5f4f401": { - "T000000": [], - "INVALID": [[3, "@pov", "Jane"], [4, "@location", "Earth"]], - } - } - with pytest.raises(KeyError): - theIndex._checkRefIndex() + # The index should be empty + assert nHandle not in itemIndex + assert cHandle not in itemIndex + assert sHandle not in itemIndex - # Wrong Length - theIndex._refIndex = { - "6a2d6d5f4f401": { - "T000000": [], - "T000001": [[3, "@pov", "Jane"], [4, "@location", "Earth", "Stuff"]], - } - } - with pytest.raises(IndexError): - theIndex._checkRefIndex() + # Add Items + # ========= + assert cHandle not in itemIndex - # Wrong Type of Entry 0 - theIndex._refIndex = { - "6a2d6d5f4f401": { - "T000000": [], - "T000001": [[3, "@pov", "Jane"], ["4", "@location", "Earth"]], - } + # Add the novel chapter file + 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 + itemIndex.setHeadingCounts(cHandle, "T000001", 60, 10, 2) + itemIndex.setHeadingSynopsis(cHandle, "T000001", "In the beginning ...") + itemIndex.setHeadingTag(cHandle, "T000001", "One") + itemIndex.addHeadingReferences(cHandle, "T000001", ["Jane"], "@pov") + itemIndex.addHeadingReferences(cHandle, "T000001", ["Jane"], "@focus") + 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 ...", } + assert "@pov" in idxData[cHandle]["references"]["T000001"]["Jane"] + assert "@focus" in idxData[cHandle]["references"]["T000001"]["Jane"] + assert "@char" in idxData[cHandle]["references"]["T000001"]["Jane"] + assert "@char" in idxData[cHandle]["references"]["T000001"]["John"] + + # Add the other two files + itemIndex.add(nHandle, theProject.tree[nHandle]) + itemIndex.add(sHandle, theProject.tree[sHandle]) + itemIndex.addItemHeading(nHandle, "T000001", "H1", "Novel") + itemIndex.addItemHeading(sHandle, "T000001", "H3", "Scene One") + + # Check Item and Heading Direct Access + # ==================================== + + # Check repr strings + assert repr(itemIndex[nHandle]) == f"" + assert repr(itemIndex[nHandle]["T000001"]) == "" + + # Check content of a single item + assert "T000001" in itemIndex[nHandle] + assert itemIndex[cHandle].allTags() == ["One"] + + # Check the content of a single heading + assert itemIndex[cHandle]["T000001"].key == "T000001" + assert itemIndex[cHandle]["T000001"].level == "H2" + assert itemIndex[cHandle]["T000001"].title == "Chapter One" + assert itemIndex[cHandle]["T000001"].tag == "One" + assert itemIndex[cHandle]["T000001"].charCount == 60 + assert itemIndex[cHandle]["T000001"].wordCount == 10 + assert itemIndex[cHandle]["T000001"].paraCount == 2 + assert itemIndex[cHandle]["T000001"].synopsis == "In the beginning ..." + assert "Jane" in itemIndex[cHandle]["T000001"].references + assert "John" in itemIndex[cHandle]["T000001"].references + + # Check heading level setter + itemIndex[cHandle]["T000001"].setLevel("H3") # Change it + assert itemIndex[cHandle]["T000001"].level == "H3" + itemIndex[cHandle]["T000001"].setLevel("H2") # Set it back + assert itemIndex[cHandle]["T000001"].level == "H2" + itemIndex[cHandle]["T000001"].setLevel("H5") # Invalid level + assert itemIndex[cHandle]["T000001"].level == "H2" + + # Data Extraction + # =============== + + # Get headers + allHeads = list(itemIndex.iterAllHeaders()) + assert allHeads[0][0] == cHandle + assert allHeads[1][0] == nHandle + assert allHeads[2][0] == sHandle + assert allHeads[0][1] == "T000001" + assert allHeads[1][1] == "T000001" + assert allHeads[2][1] == "T000001" + + # Ask for stuff that doesn't exist + assert itemIndex.mainItemHeader("blablabla") == "H0" + assert itemIndex.allItemTags("blablabla") == [] + + # Novel Structure + # =============== + + # Add a second novel + mHandle = theProject.newRoot(nwItemClass.NOVEL) + uHandle = theProject.newFile("Title Page", mHandle) + itemIndex.add(uHandle, theProject.tree[uHandle]) + itemIndex.addItemHeading(uHandle, "T000001", "H1", "Novel 2") + assert uHandle in itemIndex + + # Structure of all novels + nStruct = list(itemIndex.iterNovelStructure()) + assert len(nStruct) == 4 + assert nStruct[0][0] == nHandle + assert nStruct[1][0] == cHandle + assert nStruct[2][0] == sHandle + assert nStruct[3][0] == uHandle + + # Novel structure with root handle set + nStruct = list(itemIndex.iterNovelStructure(rootHandle="0000000000010")) + assert len(nStruct) == 3 + assert nStruct[0][0] == nHandle + assert nStruct[1][0] == cHandle + assert nStruct[2][0] == sHandle + + nStruct = list(itemIndex.iterNovelStructure(rootHandle=mHandle)) + assert len(nStruct) == 1 + assert nStruct[0][0] == uHandle + + # Inject garbage into tree + theProject.tree._treeOrder.append("stuff") + nStruct = list(itemIndex.iterNovelStructure()) + assert len(nStruct) == 4 + assert nStruct[0][0] == nHandle + assert nStruct[1][0] == cHandle + assert nStruct[2][0] == sHandle + assert nStruct[3][0] == uHandle + + # Skip excluded + theProject.tree[sHandle].setExported(False) + nStruct = list(itemIndex.iterNovelStructure(skipExcl=True)) + assert len(nStruct) == 3 + assert nStruct[0][0] == nHandle + assert nStruct[1][0] == cHandle + assert nStruct[2][0] == uHandle + + # Delete new item + del itemIndex[uHandle] + assert uHandle not in itemIndex + + # Unpack Error Handling + # ===================== + + # Pack/unpack should restore state + content = itemIndex.packData() + itemIndex.clear() + itemIndex.unpackData(content) + assert itemIndex.packData() == content + itemIndex.clear() + + # Data must be dictionary with pytest.raises(ValueError): - theIndex._checkRefIndex() + itemIndex.unpackData("stuff") - # Wrong Type of Entry 1 - theIndex._refIndex = { - "6a2d6d5f4f401": { - "T000000": [], - "T000001": [[3, "@pov", "Jane"], [4, "@stuff", "Earth"]], - } - } + # Keys must be valid handles with pytest.raises(ValueError): - theIndex._checkRefIndex() + itemIndex.unpackData({"stuff": "more stuff"}) - # Wrong Type of Entry 2 - theIndex._refIndex = { - "6a2d6d5f4f401": { - "T000000": [], - "T000001": [[3, "@pov", "Jane"], [4, "@location", 123456]], - } - } + # Unknown keys should be skipped + itemIndex.unpackData({"0000000000000": {}}) + assert itemIndex._items == {} + + # Known keys can be added, even witout data + itemIndex.unpackData({nHandle: {}}) + assert nHandle in itemIndex + + # Title tags must be valid with pytest.raises(ValueError): - theIndex._checkRefIndex() + itemIndex.unpackData({cHandle: {"headings": {"TTTTTTT": {}}}}) -# END Test testCoreIndex_CheckRefIndex - - -@pytest.mark.core -def testCoreIndex_CheckFileIndex(mockGUI): - """Test the file index checker. - """ - theProject = NWProject(mockGUI) - theIndex = NWIndex(theProject) - - # Valid Index - theIndex._fileIndex = { - "53b69b83cdafc": { - "T000001": { - "level": "H1", - "title": "My Novel", - "layout": "DOCUMENT", - "cCount": 72, - "wCount": 15, - "pCount": 2, - "synopsis": "text", - } + # Reference without a heading should be rejected + itemIndex.unpackData({ + cHandle: { + "headings": {"T000001": {}}, + "references": {"T000001": {}, "T000002": {}}, } - } - theIndex._fileIndex = theIndex._fileIndex.copy() - assert theIndex._checkFileIndex() is None + }) + assert "T000001" in itemIndex[cHandle] + assert "T000002" not in itemIndex[cHandle] + itemIndex.clear() - # Invalid Handle - theIndex._fileIndex = { - "H3b69b83cdafc": { - "T000001": { - "level": "H1", - "title": "My Novel", - "layout": "DOCUMENT", - "cCount": 72, - "wCount": 15, - "pCount": 2, - "synopsis": "text", - } - } - } - with pytest.raises(KeyError): - theIndex._checkFileIndex() - - # Invalid Title - theIndex._fileIndex = { - "53b69b83cdafc": { - "INVALID": { - "level": "H1", - "title": "My Novel", - "layout": "DOCUMENT", - "cCount": 72, - "wCount": 15, - "pCount": 2, - "synopsis": "text", - } - } - } - with pytest.raises(KeyError): - theIndex._checkFileIndex() - - # Wrong Length - theIndex._fileIndex = { - "53b69b83cdafc": { - "T000001": { - "level": "H1", - "title": "My Novel", - "layout": "DOCUMENT", - "cCount": 72, - "wCount": 15, - "pCount": 2, - "synopsis": "text", - "stuff": None - } - } - } - with pytest.raises(IndexError): - theIndex._checkFileIndex() - - # Missing Keys - # ============ - - # Missing 'level' - theIndex._fileIndex = { - "53b69b83cdafc": { - "T000001": { - "stuff": "H1", - "title": "My Novel", - "layout": "DOCUMENT", - "cCount": 72, - "wCount": 15, - "pCount": 2, - "synopsis": "text", - } - } - } - with pytest.raises(KeyError): - theIndex._checkFileIndex() - - # Missing 'title' - theIndex._fileIndex = { - "53b69b83cdafc": { - "T000001": { - "level": "H1", - "stuff": "My Novel", - "layout": "DOCUMENT", - "cCount": 72, - "wCount": 15, - "pCount": 2, - "synopsis": "text", - } - } - } - with pytest.raises(KeyError): - theIndex._checkFileIndex() - - # Missing 'layout' - theIndex._fileIndex = { - "53b69b83cdafc": { - "T000001": { - "level": "H1", - "title": "My Novel", - "stuff": "DOCUMENT", - "cCount": 72, - "wCount": 15, - "pCount": 2, - "synopsis": "text", - } - } - } - with pytest.raises(KeyError): - theIndex._checkFileIndex() - - # Missing 'cCount' - theIndex._fileIndex = { - "53b69b83cdafc": { - "T000001": { - "level": "H1", - "title": "My Novel", - "layout": "DOCUMENT", - "stuff": 72, - "wCount": 15, - "pCount": 2, - "synopsis": "text", - } - } - } - with pytest.raises(KeyError): - theIndex._checkFileIndex() - - # Missing 'wCount' - theIndex._fileIndex = { - "53b69b83cdafc": { - "T000001": { - "level": "H1", - "title": "My Novel", - "layout": "DOCUMENT", - "cCount": 72, - "stuff": 15, - "pCount": 2, - "synopsis": "text", - } - } - } - with pytest.raises(KeyError): - theIndex._checkFileIndex() - - # Missing 'pCount' - theIndex._fileIndex = { - "53b69b83cdafc": { - "T000001": { - "level": "H1", - "title": "My Novel", - "layout": "DOCUMENT", - "cCount": 72, - "wCount": 15, - "stuff": 2, - "synopsis": "text", - } - } - } - with pytest.raises(KeyError): - theIndex._checkFileIndex() - - # Missing 'synopsis' - theIndex._fileIndex = { - "53b69b83cdafc": { - "T000001": { - "level": "H1", - "title": "My Novel", - "layout": "DOCUMENT", - "cCount": 72, - "wCount": 15, - "pCount": 2, - "stuff": "text", - } - } - } - with pytest.raises(KeyError): - theIndex._checkFileIndex() - - # Wrong Types - # =========== - - # Wrong Type for 'level' - theIndex._fileIndex = { - "53b69b83cdafc": { - "T000001": { - "level": "XX", - "title": "My Novel", - "layout": "DOCUMENT", - "cCount": 72, - "wCount": 15, - "pCount": 2, - "synopsis": "text", - } - } - } + # Tag keys must be strings with pytest.raises(ValueError): - theIndex._checkFileIndex() - - # Wrong Type for 'title' - theIndex._fileIndex = { - "53b69b83cdafc": { - "T000001": { - "level": "H1", - "title": 12345678, - "layout": "DOCUMENT", - "cCount": 72, - "wCount": 15, - "pCount": 2, - "synopsis": "text", + itemIndex.unpackData({ + cHandle: { + "headings": {"T000001": {}}, + "references": {"T000001": {1234: "@pov"}}, } - } - } - with pytest.raises(ValueError): - theIndex._checkFileIndex() + }) - # Wrong Type for 'layout' - theIndex._fileIndex = { - "53b69b83cdafc": { - "T000001": { - "level": "H1", - "title": "My Novel", - "layout": "INVALID", - "cCount": 72, - "wCount": 15, - "pCount": 2, - "synopsis": "text", + # Type must be strings + with pytest.raises(ValueError): + itemIndex.unpackData({ + cHandle: { + "headings": {"T000001": {}}, + "references": {"T000001": {"John": []}}, } - } - } - with pytest.raises(ValueError): - theIndex._checkFileIndex() + }) - # Wrong Type for 'cCount' - theIndex._fileIndex = { - "53b69b83cdafc": { - "T000001": { - "level": "H1", - "title": "My Novel", - "layout": "DOCUMENT", - "cCount": "72", - "wCount": 15, - "pCount": 2, - "synopsis": "text", + # Types must be valid + with pytest.raises(ValueError): + itemIndex.unpackData({ + cHandle: { + "headings": {"T000001": {}}, + "references": {"T000001": {"John": "@pov,@char,@stuff"}}, } + }) + + # This should pass + itemIndex.unpackData({ + cHandle: { + "headings": {"T000001": {}}, + "references": {"T000001": {"John": "@pov,@char"}}, } - } - with pytest.raises(ValueError): - theIndex._checkFileIndex() + }) - # Wrong Type for 'wCount' - theIndex._fileIndex = { - "53b69b83cdafc": { - "T000001": { - "level": "H1", - "title": "My Novel", - "layout": "DOCUMENT", - "cCount": 72, - "wCount": "15", - "pCount": 2, - "synopsis": "text", - } - } - } - with pytest.raises(ValueError): - theIndex._checkFileIndex() - - # Wrong Type for 'pCount' - theIndex._fileIndex = { - "53b69b83cdafc": { - "T000001": { - "level": "H1", - "title": "My Novel", - "layout": "DOCUMENT", - "cCount": 72, - "wCount": 15, - "pCount": "2", - "synopsis": "text", - } - } - } - with pytest.raises(ValueError): - theIndex._checkFileIndex() - - # Wrong Type for 'synopsis' - theIndex._fileIndex = { - "53b69b83cdafc": { - "T000001": { - "level": "H1", - "title": "My Novel", - "layout": "DOCUMENT", - "cCount": 72, - "wCount": 15, - "pCount": 2, - "synopsis": 123456, - } - } - } - with pytest.raises(ValueError): - theIndex._checkFileIndex() - -# END Test testCoreIndex_CheckFileIndex - - -@pytest.mark.core -def testCoreIndex_CheckFileMeta(mockGUI): - """Test the file meta checker. - """ - theProject = NWProject(mockGUI) - theIndex = NWIndex(theProject) - - # Valid Index - theIndex._fileMeta = { - "53b69b83cdafc": ["H0", 72, 15, 2], - "974e400180a99": ["H0", 210, 40, 2], - } - assert theIndex._checkFileMeta() is None - - # Invalid Handle - theIndex._fileMeta = { - "53b69b83cdafc": ["H0", 72, 15, 2], - "h74e400180a99": ["H0", 210, 40, 2], - } - with pytest.raises(KeyError): - theIndex._checkFileMeta() - - # Wrong Length - theIndex._fileMeta = { - "53b69b83cdafc": ["H0", 72, 15, 2], - "974e400180a99": ["H0", 210, 40, 2, 8], - } - with pytest.raises(IndexError): - theIndex._checkFileMeta() - - # Content of Entry 0 - theIndex._fileMeta = { - "53b69b83cdafc": ["H0", 72, 15, 2], - "974e400180a99": ["XXX", 210, 40, 2], - } - with pytest.raises(ValueError): - theIndex._checkFileMeta() - - # Type of Entry 1 - theIndex._fileMeta = { - "53b69b83cdafc": ["H0", 72, 15, 2], - "974e400180a99": ["H0", "210", 40, 2], - } - with pytest.raises(ValueError): - theIndex._checkFileMeta() - - # Type of Entry 2 - theIndex._fileMeta = { - "53b69b83cdafc": ["H0", 72, 15, 2], - "974e400180a99": ["H0", 210, "40", 2], - } - with pytest.raises(ValueError): - theIndex._checkFileMeta() - - # Type of Entry 3 - theIndex._fileMeta = { - "53b69b83cdafc": ["H0", 72, 15, 2], - "974e400180a99": ["H0", 210, 40, "2"], - } - with pytest.raises(ValueError): - theIndex._checkFileMeta() - -# END Test testCoreIndex_CheckTextCounts +# END Test testCoreIndex_ItemIndex @pytest.mark.core diff --git a/tests/test_core/test_core_item.py b/tests/test_core/test_core_item.py index 61c67987..fbc0ded3 100644 --- a/tests/test_core/test_core_item.py +++ b/tests/test_core/test_core_item.py @@ -31,17 +31,22 @@ from novelwriter.enum import nwItemClass, nwItemType, nwItemLayout @pytest.mark.core -def testCoreItem_Setters(mockGUI): +def testCoreItem_Setters(mockGUI, mockRnd): """Test all the simple setters for the NWItem class. """ theProject = NWProject(mockGUI) theItem = NWItem(theProject) + statusKeys = ["s000000", "s000001", "s000002", "s000003"] + importKeys = ["i000004", "i000005", "i000006", "i000007"] + # Name theItem.setName("A Name") assert theItem.itemName == "A Name" theItem.setName("\t A Name ") assert theItem.itemName == "A Name" + theItem.setName("\t A\t\u2009\u202f\u2002\u2003\u2028\u2029Name ") + assert theItem.itemName == "A Name" theItem.setName(123) assert theItem.itemName == "" @@ -67,6 +72,18 @@ def testCoreItem_Setters(mockGUI): theItem.setParent("0123456789abc") assert theItem.itemParent == "0123456789abc" + # Root + theItem.setRoot(None) + assert theItem.itemRoot is None + theItem.setRoot(123) + assert theItem.itemRoot is None + theItem.setRoot("0123456789abcdef") + assert theItem.itemRoot is None + theItem.setRoot("0123456789abg") + assert theItem.itemRoot is None + theItem.setRoot("0123456789abc") + assert theItem.itemRoot == "0123456789abc" + # Order theItem.setOrder(None) assert theItem.itemOrder == 0 @@ -77,50 +94,32 @@ def testCoreItem_Setters(mockGUI): # Importance theItem._class = nwItemClass.CHARACTER - theItem.setImport("Nonsense") - assert theItem.itemImport == "New" - theItem.setImport("New") - assert theItem.itemImport == "New" - theItem.setImport("Minor") - assert theItem.itemImport == "Minor" - theItem.setImport("Major") - assert theItem.itemImport == "Major" - theItem.setImport("Main") - assert theItem.itemImport == "Main" + theItem.setImport("Word") + assert theItem.itemImport == importKeys[0] # Default + for key in importKeys: + theItem.setImport(key) + assert theItem.itemImport == key # Status theItem._class = nwItemClass.NOVEL - theItem.setStatus("Nonsense") - assert theItem.itemStatus == "New" - theItem.setStatus("New") - assert theItem.itemStatus == "New" - theItem.setStatus("Note") - assert theItem.itemStatus == "Note" - theItem.setStatus("Draft") - assert theItem.itemStatus == "Draft" - theItem.setStatus("Finished") - assert theItem.itemStatus == "Finished" + theItem.setStatus("Word") + assert theItem.itemStatus == statusKeys[0] # Default + for key in statusKeys: + theItem.setStatus(key) + assert theItem.itemStatus == key # Status/Importance Wrapper theItem._class = nwItemClass.CHARACTER - theItem.setImportStatus("New") - assert theItem.itemImport == "New" - theItem.setImportStatus("Minor") - assert theItem.itemImport == "Minor" - theItem.setImportStatus("Note") - assert theItem.itemImport == "New" - theItem.setImportStatus("Draft") - assert theItem.itemImport == "New" + for key in importKeys: + theItem.setImport(key) + assert theItem.itemImport == key + assert theItem.itemStatus == statusKeys[3] # Should not change theItem._class = nwItemClass.NOVEL - theItem.setImportStatus("New") - assert theItem.itemStatus == "New" - theItem.setImportStatus("Minor") - assert theItem.itemStatus == "New" - theItem.setImportStatus("Note") - assert theItem.itemStatus == "Note" - theItem.setImportStatus("Draft") - assert theItem.itemStatus == "Draft" + for key in statusKeys: + theItem.setStatus(key) + assert theItem.itemImport == importKeys[3] # Should not change + assert theItem.itemStatus == key # Expanded theItem.setExpanded(8) @@ -222,6 +221,7 @@ def testCoreItem_Methods(mockGUI): # Status + Icon # ============= + theItem.setType("FILE") theItem.setStatus("Note") theItem.setImport("Minor") @@ -231,11 +231,19 @@ def testCoreItem_Methods(mockGUI): assert stT == "Note" assert isinstance(stI, QIcon) + theItem.setImportStatus("Draft") + stT, stI = theItem.getImportStatus() + assert stT == "Draft" + theItem.setClass("CHARACTER") stT, stI = theItem.getImportStatus() assert stT == "Minor" assert isinstance(stI, QIcon) + theItem.setImportStatus("Major") + stT, stI = theItem.getImportStatus() + assert stT == "Major" + # Representation # ============== @@ -275,8 +283,8 @@ def testCoreItem_TypeSetter(mockGUI): assert theItem.itemType == nwItemType.FOLDER theItem.setType("FILE") assert theItem.itemType == nwItemType.FILE - theItem.setType("TRASH") - assert theItem.itemType == nwItemType.TRASH + + # Alternative theItem.setType(nwItemType.ROOT) assert theItem.itemType == nwItemType.ROOT @@ -296,28 +304,74 @@ def testCoreItem_ClassSetter(mockGUI): assert theItem.itemClass == nwItemClass.NO_CLASS theItem.setClass("NONSENSE") assert theItem.itemClass == nwItemClass.NO_CLASS + theItem.setClass("NO_CLASS") assert theItem.itemClass == nwItemClass.NO_CLASS + assert theItem.isNovelLike() is False + assert theItem.documentAllowed() is False + assert theItem.isInactive() is True + theItem.setClass("NOVEL") assert theItem.itemClass == nwItemClass.NOVEL + assert theItem.isNovelLike() is True + assert theItem.documentAllowed() is True + assert theItem.isInactive() is False + theItem.setClass("PLOT") assert theItem.itemClass == nwItemClass.PLOT + assert theItem.isNovelLike() is False + assert theItem.documentAllowed() is False + assert theItem.isInactive() is False + theItem.setClass("CHARACTER") assert theItem.itemClass == nwItemClass.CHARACTER + assert theItem.isNovelLike() is False + assert theItem.documentAllowed() is False + assert theItem.isInactive() is False + theItem.setClass("WORLD") assert theItem.itemClass == nwItemClass.WORLD + assert theItem.isNovelLike() is False + assert theItem.documentAllowed() is False + assert theItem.isInactive() is False + theItem.setClass("TIMELINE") assert theItem.itemClass == nwItemClass.TIMELINE + assert theItem.isNovelLike() is False + assert theItem.documentAllowed() is False + assert theItem.isInactive() is False + theItem.setClass("OBJECT") assert theItem.itemClass == nwItemClass.OBJECT + assert theItem.isNovelLike() is False + assert theItem.documentAllowed() is False + assert theItem.isInactive() is False + theItem.setClass("ENTITY") assert theItem.itemClass == nwItemClass.ENTITY + assert theItem.isNovelLike() is False + assert theItem.documentAllowed() is False + assert theItem.isInactive() is False + theItem.setClass("CUSTOM") assert theItem.itemClass == nwItemClass.CUSTOM + assert theItem.isNovelLike() is False + assert theItem.documentAllowed() is False + assert theItem.isInactive() is False + theItem.setClass("ARCHIVE") assert theItem.itemClass == nwItemClass.ARCHIVE + assert theItem.isNovelLike() is True + assert theItem.documentAllowed() is True + assert theItem.isInactive() is True + theItem.setClass("TRASH") assert theItem.itemClass == nwItemClass.TRASH + assert theItem.isNovelLike() is False + assert theItem.documentAllowed() is True + assert theItem.isInactive() is True + + # Alternative theItem.setClass(nwItemClass.NOVEL) assert theItem.itemClass == nwItemClass.NOVEL @@ -346,7 +400,7 @@ def testCoreItem_LayoutSetter(mockGUI): theItem.setLayout("NOTE") assert theItem.itemLayout == nwItemLayout.NOTE - # Alternatives + # Alternative theItem.setLayout(nwItemLayout.NOTE) assert theItem.itemLayout == nwItemLayout.NOTE @@ -354,23 +408,83 @@ def testCoreItem_LayoutSetter(mockGUI): @pytest.mark.core -def testCoreItem_XMLPackUnpack(mockGUI, caplog): +def testCoreItem_ClassDefaults(mockGUI): + """Test the setter for the default values. + """ + theProject = NWProject(mockGUI) + theItem = NWItem(theProject) + + # Root items should not have their class updated + theItem.setParent(None) + theItem.setClass(nwItemClass.NO_CLASS) + assert theItem.itemClass == nwItemClass.NO_CLASS + + theItem.setClassDefaults(nwItemClass.NOVEL) + assert theItem.itemClass == nwItemClass.NO_CLASS + + # Non-root items should have their class updated + theItem.setParent("0123456789abc") + theItem.setClass(nwItemClass.NO_CLASS) + assert theItem.itemClass == nwItemClass.NO_CLASS + + theItem.setClassDefaults(nwItemClass.NOVEL) + assert theItem.itemClass == nwItemClass.NOVEL + + # Non-layout items should have their layout set based on class + theItem.setParent("0123456789abc") + theItem.setClass(nwItemClass.NO_CLASS) + theItem.setLayout(nwItemLayout.NO_LAYOUT) + assert theItem.itemLayout == nwItemLayout.NO_LAYOUT + + theItem.setClassDefaults(nwItemClass.NOVEL) + assert theItem.itemLayout == nwItemLayout.DOCUMENT + + theItem.setParent("0123456789abc") + theItem.setClass(nwItemClass.NO_CLASS) + theItem.setLayout(nwItemLayout.NO_LAYOUT) + assert theItem.itemLayout == nwItemLayout.NO_LAYOUT + + theItem.setClassDefaults(nwItemClass.PLOT) + assert theItem.itemLayout == nwItemLayout.NOTE + + # If documents are not allowed in that class, the layout should be changed + theItem.setParent("0123456789abc") + theItem.setClass(nwItemClass.NO_CLASS) + theItem.setLayout(nwItemLayout.DOCUMENT) + assert theItem.itemLayout == nwItemLayout.DOCUMENT + + theItem.setClassDefaults(nwItemClass.PLOT) + assert theItem.itemLayout == nwItemLayout.NOTE + + # In all cases, status and importance should no longer be None + assert theItem.itemStatus is not None + assert theItem.itemImport is not None + +# END Test testCoreItem_ClassDefaults + + +@pytest.mark.core +def testCoreItem_XMLPackUnpack(mockGUI, caplog, mockRnd): """Test packing and unpacking XML objects for the NWItem class. """ theProject = NWProject(mockGUI) nwXML = etree.Element("novelWriterXML") + statusKeys = ["s000000", "s000001", "s000002", "s000003"] + importKeys = ["i000004", "i000005", "i000006", "i000007"] + # 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.setStatus("Main") + theItem.setImport(importKeys[3]) theItem.setLayout("NOTE") theItem.setExported(False) theItem.setParaCount(3) @@ -383,17 +497,19 @@ def testCoreItem_XMLPackUnpack(mockGUI, caplog): theItem.packXML(xContent) assert etree.tostring(xContent, pretty_print=False, encoding="utf-8") == ( b'' - b'' - b'A Name' + 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.itemOrder == 1 assert theItem.isExported is False assert theItem.paraCount == 3 @@ -403,6 +519,8 @@ def testCoreItem_XMLPackUnpack(mockGUI, caplog): 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 # ====== @@ -410,11 +528,12 @@ def testCoreItem_XMLPackUnpack(mockGUI, caplog): 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("Main") + theItem.setStatus(statusKeys[1]) theItem.setLayout("NOTE") theItem.setExpanded(True) theItem.setExported(False) @@ -428,17 +547,18 @@ def testCoreItem_XMLPackUnpack(mockGUI, caplog): theItem.packXML(xContent) assert etree.tostring(xContent, pretty_print=False, encoding="utf-8") == ( b'' - b'A Name' - 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 @@ -449,6 +569,8 @@ def testCoreItem_XMLPackUnpack(mockGUI, caplog): 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 # ====== @@ -588,4 +710,8 @@ def testCoreItem_ConvertFromFmt13(mockGUI): assert theItem.itemType == nwItemType.FILE assert theItem.itemLayout == nwItemLayout.DOCUMENT + # Deprecated Type + theItem.setType("TRASH") + assert theItem.itemType == nwItemType.ROOT + # END Test testCoreItem_ConvertFromFmt13 diff --git a/tests/test_core/test_core_project.py b/tests/test_core/test_core_project.py index 311fca8c..005277c6 100644 --- a/tests/test_core/test_core_project.py +++ b/tests/test_core/test_core_project.py @@ -19,14 +19,14 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import pytest import os +import pytest from shutil import copyfile from zipfile import ZipFile from lxml import etree -from tools import cmpFiles, writeFile, readFile +from tools import cmpFiles, writeFile, readFile, buildTestProject, XML_IGNORE from mock import causeOSError from novelwriter.core.project import NWProject @@ -36,7 +36,7 @@ from novelwriter.constants import nwFiles @pytest.mark.core -def testCoreProject_NewMinimal(fncDir, outDir, refDir, mockGUI): +def testCoreProject_NewMinimal(fncDir, outDir, refDir, mockGUI, mockRnd): """Create a new project from a project wizard dictionary. With default setting, creating a Minimal project. """ @@ -45,7 +45,6 @@ def testCoreProject_NewMinimal(fncDir, outDir, refDir, mockGUI): compFile = os.path.join(refDir, "coreProject_NewMinimal_nwProject.nwx") theProject = NWProject(mockGUI) - theProject.projTree.setSeed(42) # Setting no data should fail assert theProject.newProject({}) is False @@ -68,7 +67,7 @@ def testCoreProject_NewMinimal(fncDir, outDir, refDir, mockGUI): assert theProject.saveProject() is True assert theProject.closeProject() is True copyfile(projFile, testFile) - assert cmpFiles(testFile, compFile, [2, 6, 7, 8]) + assert cmpFiles(testFile, compFile, ignoreStart=XML_IGNORE) assert theProject.projChanged is False # Open a second time @@ -78,13 +77,13 @@ def testCoreProject_NewMinimal(fncDir, outDir, refDir, mockGUI): assert theProject.saveProject() is True assert theProject.closeProject() is True copyfile(projFile, testFile) - assert cmpFiles(testFile, compFile, [2, 6, 7, 8]) + assert cmpFiles(testFile, compFile, ignoreStart=XML_IGNORE) # END Test testCoreProject_NewMinimal @pytest.mark.core -def testCoreProject_NewCustomA(fncDir, outDir, refDir, mockGUI): +def testCoreProject_NewCustomA(fncDir, outDir, refDir, mockGUI, mockRnd): """Create a new project from a project wizard dictionary. Custom type with chapters and scenes. """ @@ -104,29 +103,25 @@ def testCoreProject_NewCustomA(fncDir, outDir, refDir, mockGUI): nwItemClass.PLOT, nwItemClass.CHARACTER, nwItemClass.WORLD, - nwItemClass.TIMELINE, - nwItemClass.OBJECT, - nwItemClass.ENTITY, ], + "addNotes": True, "numChapters": 3, "numScenes": 3, - "chFolders": True, } theProject = NWProject(mockGUI) - theProject.projTree.setSeed(42) assert theProject.newProject(projData) is True assert theProject.saveProject() is True assert theProject.closeProject() is True copyfile(projFile, testFile) - assert cmpFiles(testFile, compFile, [2, 6, 7, 8]) + assert cmpFiles(testFile, compFile, ignoreStart=XML_IGNORE) # END Test testCoreProject_NewCustomA @pytest.mark.core -def testCoreProject_NewCustomB(fncDir, outDir, refDir, mockGUI): +def testCoreProject_NewCustomB(fncDir, outDir, refDir, mockGUI, mockRnd): """Create a new project from a project wizard dictionary. Custom type without chapters, but with scenes. """ @@ -146,23 +141,19 @@ def testCoreProject_NewCustomB(fncDir, outDir, refDir, mockGUI): nwItemClass.PLOT, nwItemClass.CHARACTER, nwItemClass.WORLD, - nwItemClass.TIMELINE, - nwItemClass.OBJECT, - nwItemClass.ENTITY, ], + "addNotes": True, "numChapters": 0, "numScenes": 6, - "chFolders": True, } theProject = NWProject(mockGUI) - theProject.projTree.setSeed(42) assert theProject.newProject(projData) is True assert theProject.saveProject() is True assert theProject.closeProject() is True copyfile(projFile, testFile) - assert cmpFiles(testFile, compFile, [2, 6, 7, 8]) + assert cmpFiles(testFile, compFile, ignoreStart=XML_IGNORE) # END Test testCoreProject_NewCustomB @@ -182,7 +173,6 @@ def testCoreProject_NewSampleA(fncDir, tmpConf, mockGUI, tmpDir): "popCustom": False, } theProject = NWProject(mockGUI) - theProject.projTree.setSeed(42) # Sample set, but no path assert not theProject.newProject({"popSample": True}) @@ -231,7 +221,6 @@ def testCoreProject_NewSampleB(monkeypatch, fncDir, tmpConf, mockGUI, tmpDir): "popCustom": False, } theProject = NWProject(mockGUI) - theProject.projTree.setSeed(42) # Make sure we do not pick up the novelwriter/assets/sample.zip file tmpConf.assetPath = tmpDir @@ -255,7 +244,7 @@ def testCoreProject_NewSampleB(monkeypatch, fncDir, tmpConf, mockGUI, tmpDir): @pytest.mark.core -def testCoreProject_NewRoot(fncDir, outDir, refDir, mockGUI): +def testCoreProject_NewRoot(fncDir, outDir, refDir, mockGUI, mockRnd): """Check that new root folders can be added to the project. """ projFile = os.path.join(fncDir, "nwProject.nwx") @@ -263,36 +252,35 @@ def testCoreProject_NewRoot(fncDir, outDir, refDir, mockGUI): compFile = os.path.join(refDir, "coreProject_NewRoot_nwProject.nwx") theProject = NWProject(mockGUI) - theProject.projTree.setSeed(42) + buildTestProject(theProject, fncDir) - assert theProject.newProject({"projPath": fncDir}) is True 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("Novel", nwItemClass.NOVEL), type(None)) - assert isinstance(theProject.newRoot("Plot", nwItemClass.PLOT), type(None)) - assert isinstance(theProject.newRoot("Character", nwItemClass.CHARACTER), type(None)) - assert isinstance(theProject.newRoot("World", nwItemClass.WORLD), type(None)) - assert isinstance(theProject.newRoot("Timeline", nwItemClass.TIMELINE), str) - assert isinstance(theProject.newRoot("Object", nwItemClass.OBJECT), str) - assert isinstance(theProject.newRoot("Custom1", nwItemClass.CUSTOM), str) - assert isinstance(theProject.newRoot("Custom2", nwItemClass.CUSTOM), str) + 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.projChanged is True assert theProject.saveProject() is True assert theProject.closeProject() is True copyfile(projFile, testFile) - assert cmpFiles(testFile, compFile, [2, 6, 7, 8]) + assert cmpFiles(testFile, compFile, ignoreStart=XML_IGNORE) assert theProject.projChanged is False # END Test testCoreProject_NewRoot @pytest.mark.core -def testCoreProject_NewFile(fncDir, outDir, refDir, mockGUI): +def testCoreProject_NewFile(fncDir, outDir, refDir, mockGUI, mockRnd): """Check that new files can be added to the project. """ projFile = os.path.join(fncDir, "nwProject.nwx") @@ -300,22 +288,21 @@ def testCoreProject_NewFile(fncDir, outDir, refDir, mockGUI): compFile = os.path.join(refDir, "coreProject_NewFile_nwProject.nwx") theProject = NWProject(mockGUI) - theProject.projTree.setSeed(42) + buildTestProject(theProject, fncDir) - assert theProject.newProject({"projPath": fncDir}) is True 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.newFile("Hello", nwItemClass.NOVEL, "31489056e0916"), str) - assert isinstance(theProject.newFile("Jane", nwItemClass.CHARACTER, "71ee45a3c0db9"), str) + assert isinstance(theProject.newFile("Hello", "31489056e0916"), str) + assert isinstance(theProject.newFile("Jane", "71ee45a3c0db9"), str) assert theProject.projChanged assert theProject.saveProject() is True assert theProject.closeProject() is True copyfile(projFile, testFile) - assert cmpFiles(testFile, compFile, [2, 6, 7, 8]) + assert cmpFiles(testFile, compFile, ignoreStart=XML_IGNORE) assert theProject.projChanged is False # END Test testCoreProject_NewFile @@ -496,7 +483,7 @@ def testCoreProject_Save(monkeypatch, nwMinimal, mockGUI, refDir): assert theProject.saveProject() is True assert theProject.saveCount == saveCount + 1 assert theProject.autoCount == autoCount - assert cmpFiles(testFile, compFile, [2, 6, 7, 8, 9]) + assert cmpFiles(testFile, compFile, ignoreStart=XML_IGNORE) # Check that a second save creates a .bak file assert os.path.isfile(backFile) is True @@ -507,7 +494,7 @@ def testCoreProject_Save(monkeypatch, nwMinimal, mockGUI, refDir): assert theProject.saveProject(autoSave=True) is True assert theProject.saveCount == saveCount assert theProject.autoCount == autoCount + 1 - assert cmpFiles(testFile, compFile, [2, 6, 7, 8, 9]) + assert cmpFiles(testFile, compFile, ignoreStart=XML_IGNORE) # Close test project assert theProject.closeProject() @@ -647,17 +634,17 @@ def testCoreProject_AccessItems(nwMinimal, mockGUI): "afb3043c7b2b3", # ROOT: Characters "9d5247ab588e0", # ROOT: World ] - assert theProject.projTree.handles() == oldOrder + assert theProject.tree.handles() == oldOrder assert theProject.setTreeOrder(newOrder) - assert theProject.projTree.handles() == newOrder + assert theProject.tree.handles() == newOrder # Add a non-existing item - theProject.projTree._treeOrder.append("01234567789abc") + theProject.tree._treeOrder.append("01234567789abc") # Add an item with a non-existent parent - nHandle = theProject.newFile("Test File", nwItemClass.NOVEL, "a6d311a93600a") - theProject.projTree[nHandle].setParent("cba9876543210") - assert theProject.projTree[nHandle].itemParent == "cba9876543210" + nHandle = theProject.newFile("Test File", "a6d311a93600a") + theProject.tree[nHandle].setParent("cba9876543210") + assert theProject.tree[nHandle].itemParent == "cba9876543210" retOrder = [] for tItem in theProject.getProjectItems(): @@ -674,19 +661,138 @@ def testCoreProject_AccessItems(nwMinimal, mockGUI): "f5ab3e30151e1", # FILE: New Chapter "8c659a11cd429", # FILE: New Scene ] - assert theProject.projTree[nHandle].itemParent is None + assert theProject.tree[nHandle].itemParent is None # END Test testCoreProject_AccessItems @pytest.mark.core -def testCoreProject_Methods(monkeypatch, nwMinimal, mockGUI, tmpDir): +def testCoreProject_StatusImport(mockGUI, fncDir, mockRnd): + """Test the status and importance flag handling. + """ + theProject = NWProject(mockGUI) + buildTestProject(theProject, fncDir) + + statusKeys = ["s000008", "s000009", "s00000a", "s00000b"] + importKeys = ["i00000c", "i00000d", "i00000e", "i00000f"] + + # Change Status + # ============= + + theProject.tree["0000000000014"].setStatus("Finished") + theProject.tree["0000000000015"].setStatus("Draft") + theProject.tree["0000000000016"].setStatus("Note") + theProject.tree["0000000000017"].setStatus("Finished") + + 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] + + newList = [ + {"key": statusKeys[0], "name": "New", "cols": (1, 1, 1)}, + {"key": statusKeys[1], "name": "Draft", "cols": (2, 2, 2)}, # These are swapped + {"key": statusKeys[2], "name": "Note", "cols": (3, 3, 3)}, # These are swapped + {"key": statusKeys[3], "name": "Edited", "cols": (4, 4, 4)}, # Renamed + {"key": None, "name": "Finished", "cols": (5, 5, 5)}, # New, reused name + ] + assert theProject.setStatusColours(None, None) is False + 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) + + # 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) + + # Delete last entry + assert theProject.setStatusColours([], [lastKey]) is True + assert theProject.statusItems.name(lastKey) == "New" + + # Change Importance + # ================= + + fHandle = theProject.newFile("Jane Doe", "8b9d2e465e150") + theProject.tree[fHandle].setImport("Main") + + assert theProject.tree[fHandle].itemImport == importKeys[3] + newList = [ + {"key": importKeys[0], "name": "New", "cols": (1, 1, 1)}, + {"key": importKeys[1], "name": "Minor", "cols": (2, 2, 2)}, + {"key": importKeys[2], "name": "Major", "cols": (3, 3, 3)}, + {"key": importKeys[3], "name": "Min", "cols": (4, 4, 4)}, + {"key": None, "name": "Max", "cols": (5, 5, 5)}, + ] + assert theProject.setImportColours(None, None) is False + 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) + + # 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) + + # Delete last entry + assert theProject.setImportColours([], [lastKey]) is True + assert theProject.importItems.name(lastKey) == "New" + + # Delete Status/Import + # ==================== + + theProject.statusItems.resetCounts() + for key in list(theProject.statusItems.keys()): + assert theProject.statusItems.remove(key) is True + + theProject.importItems.resetCounts() + for key in list(theProject.importItems.keys()): + assert theProject.importItems.remove(key) is True + + assert len(theProject.statusItems) == 0 + assert len(theProject.importItems) == 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): """Test other project class methods and functions. """ theProject = NWProject(mockGUI) - theProject.projTree.setSeed(42) - assert theProject.openProject(nwMinimal) - assert theProject.projPath == nwMinimal + buildTestProject(theProject, fncDir) # Setting project path assert theProject.setProjectPath(None) @@ -697,16 +803,16 @@ def testCoreProject_Methods(monkeypatch, nwMinimal, mockGUI, tmpDir): assert theProject.projPath == os.path.expanduser("~") # Create a new folder and populate it - projPath = os.path.join(nwMinimal, "mock1") + 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(nwMinimal, "mock2") + projPath = os.path.join(fncDir, "mock2") assert not theProject.setProjectPath(projPath, newProject=True) # Set back - assert theProject.setProjectPath(nwMinimal) + assert theProject.setProjectPath(fncDir) # Project Name assert theProject.setProjectName(" A Name ") @@ -744,9 +850,10 @@ def testCoreProject_Methods(monkeypatch, nwMinimal, mockGUI, tmpDir): # Trash folder # Should create on first call, and just returned on later calls - assert theProject.projTree["73475cb40a568"] is None - assert theProject.trashFolder() == "73475cb40a568" - assert theProject.trashFolder() == "73475cb40a568" + hTrash = "0000000000018" + assert theProject.tree[hTrash] is None + assert theProject.trashFolder() == hTrash + assert theProject.trashFolder() == hTrash # Project backup assert theProject.doBackup is True @@ -770,9 +877,10 @@ def testCoreProject_Methods(monkeypatch, nwMinimal, mockGUI, tmpDir): # Spell language theProject.projChanged = False - assert theProject.setSpellLang(None) assert theProject.projSpell is None - assert theProject.setSpellLang("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" @@ -812,70 +920,20 @@ def testCoreProject_Methods(monkeypatch, nwMinimal, mockGUI, tmpDir): # Change project tree order oldOrder = [ - "a508bb932959c", "a35baf2e93843", "a6d311a93600a", - "f5ab3e30151e1", "8c659a11cd429", "7695ce551d265", - "afb3043c7b2b3", "9d5247ab588e0", "73475cb40a568", + "0000000000010", "0000000000011", "0000000000012", + "0000000000013", "0000000000014", "0000000000015", + "0000000000016", "0000000000017", "0000000000018", ] newOrder = [ - "f5ab3e30151e1", "8c659a11cd429", "7695ce551d265", - "a508bb932959c", "a35baf2e93843", "a6d311a93600a", - "afb3043c7b2b3", "9d5247ab588e0", + "0000000000013", "0000000000014", "0000000000015", + "0000000000010", "0000000000011", "0000000000012", + "0000000000016", "0000000000017", ] - assert theProject.projTree.handles() == oldOrder + assert theProject.tree.handles() == oldOrder assert theProject.setTreeOrder(newOrder) - assert theProject.projTree.handles() == newOrder + assert theProject.tree.handles() == newOrder assert theProject.setTreeOrder(oldOrder) - assert theProject.projTree.handles() == oldOrder - - # Change status - theProject.projTree["a35baf2e93843"].setStatus("Finished") - theProject.projTree["a6d311a93600a"].setStatus("Draft") - theProject.projTree["f5ab3e30151e1"].setStatus("Note") - theProject.projTree["8c659a11cd429"].setStatus("Finished") - newList = [ - ("New", 1, 1, 1, "New"), - ("Draft", 2, 2, 2, "Note"), # These are swapped - ("Note", 3, 3, 3, "Draft"), # These are swapped - ("Edited", 4, 4, 4, "Finished"), # Renamed - ("Finished", 5, 5, 5, None), # New, with reused name - ] - assert theProject.setStatusColours(newList) - assert theProject.statusItems._theLabels == [ - "New", "Draft", "Note", "Edited", "Finished" - ] - assert theProject.statusItems._theColours == [ - (1, 1, 1), (2, 2, 2), (3, 3, 3), (4, 4, 4), (5, 5, 5) - ] - assert theProject.projTree["a35baf2e93843"].itemStatus == "Edited" # Renamed - assert theProject.projTree["a6d311a93600a"].itemStatus == "Note" # Swapped - assert theProject.projTree["f5ab3e30151e1"].itemStatus == "Draft" # Swapped - assert theProject.projTree["8c659a11cd429"].itemStatus == "Edited" # Renamed - - # Change importance - fHandle = theProject.newFile("Jane Doe", nwItemClass.CHARACTER, "afb3043c7b2b3") - theProject.projTree[fHandle].setImport("Main") - newList = [ - ("New", 1, 1, 1, "New"), - ("Minor", 2, 2, 2, "Minor"), - ("Major", 3, 3, 3, "Major"), - ("Min", 4, 4, 4, "Main"), - ("Max", 5, 5, 5, None), - ] - assert theProject.setImportColours(newList) - assert theProject.importItems._theLabels == [ - "New", "Minor", "Major", "Min", "Max" - ] - assert theProject.importItems._theColours == [ - (1, 1, 1), (2, 2, 2), (3, 3, 3), (4, 4, 4), (5, 5, 5) - ] - assert theProject.projTree[fHandle].itemImport == "Min" - - # Check status counts - assert theProject.statusItems._theCounts == [0, 0, 0, 0, 0] - assert theProject.importItems._theCounts == [0, 0, 0, 0, 0] - theProject.countStatus() - assert theProject.statusItems._theCounts == [1, 1, 1, 2, 0] - assert theProject.importItems._theCounts == [3, 0, 0, 1, 0] + assert theProject.tree.handles() == oldOrder # Session stats theProject.currWCount = 200 @@ -890,7 +948,7 @@ def testCoreProject_Methods(monkeypatch, nwMinimal, mockGUI, tmpDir): assert not theProject._appendSessionStats(idleTime=0) # Write entry - assert theProject.projMeta == os.path.join(nwMinimal, "meta") + assert theProject.projMeta == os.path.join(fncDir, "meta") statsFile = os.path.join(theProject.projMeta, nwFiles.SESS_STATS) theProject.projOpened = 1600002000 @@ -944,9 +1002,16 @@ def testCoreProject_OrphanedFiles(mockGUI, nwLipsum): """ theProject = NWProject(mockGUI) - assert theProject.openProject(nwLipsum) - assert theProject.projTree["636b6aa9b697b"] is None - assert theProject.closeProject() + assert theProject.openProject(nwLipsum) is True + assert theProject.tree["636b6aa9b697b"] is None + + # Add a file with non-existent parent + # This file will be renoved from the project on open + assert theProject.newFile("Oops", "0000000000000") + + # Save and close + assert theProject.saveProject() is True + assert theProject.closeProject() is True # First Item with Meta Data orphPath = os.path.join(nwLipsum, "content", "636b6aa9b697b.nwd") @@ -976,11 +1041,11 @@ def testCoreProject_OrphanedFiles(mockGUI, nwLipsum): assert theProject.openProject(nwLipsum) assert theProject.projPath is not None - assert theProject.projTree["636b6aa9b697bb"] is None - assert theProject.projTree["abcdefghijklm"] is None + assert theProject.tree["636b6aa9b697bb"] is None + assert theProject.tree["abcdefghijklm"] is None # First Item with Meta Data - oItem = theProject.projTree["636b6aa9b697b"] + oItem = theProject.tree["636b6aa9b697b"] assert oItem is not None assert oItem.itemName == "[Recovered] Mars" assert oItem.itemHandle == "636b6aa9b697b" @@ -990,7 +1055,7 @@ def testCoreProject_OrphanedFiles(mockGUI, nwLipsum): assert oItem.itemLayout == nwItemLayout.NOTE # Second Item without Meta Data - oItem = theProject.projTree["736b6aa9b697b"] + oItem = theProject.tree["736b6aa9b697b"] assert oItem is not None assert oItem.itemName == "Recovered File 1" assert oItem.itemHandle == "736b6aa9b697b" @@ -1107,10 +1172,6 @@ def testCoreProject_LegacyData(monkeypatch, mockGUI, fncDir): theProject = NWProject(mockGUI) theProject.setProjectPath(fncDir) - # assert theProject.newProject({"projPath": fncDir}) - # assert theProject.saveProject() - # assert theProject.closeProject() - # Check behaviour of deprecated files function on OSError tstFile = os.path.join(fncDir, "ToC.json") writeFile(tstFile, "stuff") diff --git a/tests/test_core/test_core_status.py b/tests/test_core/test_core_status.py index a7a2d55e..868a3034 100644 --- a/tests/test_core/test_core_status.py +++ b/tests/test_core/test_core_status.py @@ -20,6 +20,7 @@ along with this program. If not, see . """ import pytest +import random from lxml import etree @@ -27,105 +28,306 @@ from PyQt5.QtGui import QIcon from novelwriter.core.status import NWStatus +statusKeys = ["sa3b179", "s1c8031", "s06671a", "sbdd640"] +importKeys = ["i466852", "i3eb13b", "i392456", "i23b8c1"] + + +@pytest.mark.core +def testCoreStatus_Internal(): + """Test all the internal functions of the NWStatus class. + """ + random.seed(42) + theStatus = NWStatus(NWStatus.STATUS) + theImport = NWStatus(NWStatus.IMPORT) + + with pytest.raises(Exception): + NWStatus(999) + + # Generate Key + # ============ + + assert theStatus._newKey() == statusKeys[0] + assert theStatus._newKey() == statusKeys[1] + + # Key collision, should move to key 3 + theStatus.write(statusKeys[2], "Crash", (0, 0, 0)) + assert theStatus._newKey() == statusKeys[3] + + assert theImport._newKey() == importKeys[0] + assert theImport._newKey() == importKeys[1] + + # Key collision, should move to key 3 + theImport.write(importKeys[2], "Crash", (0, 0, 0)) + assert theImport._newKey() == importKeys[3] + + # Check Key + # ========= + + assert theStatus._isKey(None) is False # Not a string + assert theStatus._isKey("s00000") is False # Too short + assert theStatus._isKey("s000000") is True # Correct length + assert theStatus._isKey("s0000000") is False # Too long + assert theStatus._isKey("i000000") is False # Wrong type + assert theStatus._isKey("q000000") is False # Wrong type + assert theStatus._isKey("s12345H") is False # Not a hex value + assert theStatus._isKey("s12345F") is False # Not a lower case hex value + assert theStatus._isKey("s12345f") is True # Valid hex value + + assert theImport._isKey(None) is False # Not a string + assert theImport._isKey("i00000") is False # Too short + assert theImport._isKey("i000000") is True # Correct length + assert theImport._isKey("i0000000") is False # Too long + assert theImport._isKey("s000000") is False # Wrong type + assert theImport._isKey("q000000") is False # Wrong type + assert theImport._isKey("i12345H") is False # Not a hex value + assert theImport._isKey("i12345F") is False # Not a lower case hex value + assert theImport._isKey("i12345f") is True # Valid hex value + +# END Test testCoreStatus_Internal + + +@pytest.mark.core +def testCoreStatus_Iterator(): + """Test the iterator functions 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)) + theStatus.write(None, "Draft", (200, 150, 0)) + theStatus.write(None, "Finished", (50, 200, 0)) + + # Direct access + entry = theStatus[statusKeys[0]] + assert entry["cols"] == (100, 100, 100) + assert entry["name"] == "New" + assert entry["count"] == 0 + assert isinstance(entry["icon"], QIcon) + + # Iterate + entries = list(theStatus) + assert len(entries) == 4 + assert len(theStatus) == 4 + + # Keys + assert list(theStatus.keys()) == statusKeys + + # Items + for index, (key, entry) in enumerate(theStatus.items()): + assert key == statusKeys[index] + assert "cols" in entry + assert "name" in entry + assert "count" in entry + assert "icon" in entry + + # Valuse + for entry in theStatus.values(): + assert "cols" in entry + assert "name" in entry + assert "count" in entry + assert "icon" in entry + +# END Test testCoreStatus_Iterator + @pytest.mark.core def testCoreStatus_Entries(): - """Test all the simple setters for the NWItem class. + """Test all the simple setters for the NWStatus class. """ - theStatus = NWStatus() + random.seed(42) + theStatus = NWStatus(NWStatus.STATUS) - # Add entries - theStatus.addEntry("New", (100, 100, 100)) - theStatus.addEntry("Minor", (200, 50, 0)) - theStatus.addEntry("Major", (200, 150, 0)) - theStatus.addEntry("Main", (50, 200, 0)) + # Write + # ===== - assert theStatus._theLabels == ["New", "Minor", "Major", "Main"] - assert theStatus._theColours == [(100, 100, 100), (200, 50, 0), (200, 150, 0), (50, 200, 0)] - assert theStatus._theCounts == [0, 0, 0, 0] - assert theStatus._theMap["New"] == 0 - assert theStatus._theMap["Minor"] == 1 - assert theStatus._theMap["Major"] == 2 - assert theStatus._theMap["Main"] == 3 - assert theStatus._theLength == 4 + # Have a key + theStatus.write(statusKeys[0], "Entry 1", (200, 100, 50)) + assert theStatus[statusKeys[0]]["name"] == "Entry 1" + assert theStatus[statusKeys[0]]["cols"] == (200, 100, 50) - # Lookups - assert theStatus._getIndex(None) is None - assert theStatus._getIndex("stuff") is None - assert theStatus._getIndex("Main") == 3 + # Don't have a key + theStatus.write(None, "Entry 2", (210, 110, 60)) + assert theStatus[statusKeys[1]]["name"] == "Entry 2" + assert theStatus[statusKeys[1]]["cols"] == (210, 110, 60) - # Checks - assert theStatus.checkEntry(123) == "New" - assert theStatus.checkEntry("Stuff") == "New" - assert theStatus.checkEntry("New ") == "New" - assert theStatus.checkEntry(" Main ") == "Main" + # Wrong colour spec + theStatus.write(None, "Entry 3", "what?") + assert theStatus[statusKeys[2]]["name"] == "Entry 3" + assert theStatus[statusKeys[2]]["cols"] == (100, 100, 100) - # Icons - assert isinstance(theStatus.getIcon("Stuff"), QIcon) - assert isinstance(theStatus.getIcon("New"), QIcon) + # Wrong colour count + theStatus.write(None, "Entry 4", (10, 20)) + assert theStatus[statusKeys[3]]["name"] == "Entry 4" + assert theStatus[statusKeys[3]]["cols"] == (100, 100, 100) - # Set new list - newList = [ - ("New", 1, 1, 1, "New"), - ("Minor", 2, 2, 2, "Minor"), - ("Major", 3, 3, 3, "Major"), - ("Min", 4, 4, 4, "Main"), - ("Max", 5, 5, 5, None), - ] - assert theStatus.setNewEntries(None) == {} - assert theStatus.setNewEntries(newList) == {"Main": "Min"} + # Check reverse map + assert theStatus._reverse == { + "Entry 1": statusKeys[0], + "Entry 2": statusKeys[1], + "Entry 3": statusKeys[2], + "Entry 4": statusKeys[3], + } - assert theStatus._theLabels == ["New", "Minor", "Major", "Min", "Max"] - assert theStatus._theColours == [(1, 1, 1), (2, 2, 2), (3, 3, 3), (4, 4, 4), (5, 5, 5)] - assert theStatus._theCounts == [0, 0, 0, 0, 0] - assert theStatus._theMap["New"] == 0 - assert theStatus._theMap["Minor"] == 1 - assert theStatus._theMap["Major"] == 2 - assert theStatus._theMap["Min"] == 3 - assert theStatus._theMap["Max"] == 4 - assert theStatus._theLength == 5 + # Check + # ===== - # Add counts - countTo = [3, 5, 7, 9, 11] + # Normal lookup + 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] + + # Name Access + # =========== + + assert theStatus.name(statusKeys[0]) == "Entry 1" + assert theStatus.name(statusKeys[1]) == "Entry 2" + assert theStatus.name(statusKeys[2]) == "Entry 3" + assert theStatus.name(statusKeys[3]) == "Entry 4" + assert theStatus.name("blablabla") == "Entry 1" + + # Colour Access + # ============= + + assert theStatus.cols(statusKeys[0]) == (200, 100, 50) + assert theStatus.cols(statusKeys[1]) == (210, 110, 60) + assert theStatus.cols(statusKeys[2]) == (100, 100, 100) + assert theStatus.cols(statusKeys[3]) == (100, 100, 100) + assert theStatus.cols("blablabla") == (200, 100, 50) + + # Icon Access + # =========== + + assert isinstance(theStatus.icon(statusKeys[0]), QIcon) + assert isinstance(theStatus.icon(statusKeys[1]), QIcon) + assert isinstance(theStatus.icon(statusKeys[2]), QIcon) + assert isinstance(theStatus.icon(statusKeys[3]), QIcon) + assert isinstance(theStatus.icon("blablabla"), QIcon) + + # Increment and Count Access + # ========================== + + countTo = [3, 5, 7, 9] for i, n in enumerate(countTo): for _ in range(n): - theStatus.countEntry(theStatus._theLabels[i]) - assert theStatus._theCounts == countTo + theStatus.increment(statusKeys[i]) - # Iterate - for i, (sA, sB, sC, sD) in enumerate(theStatus): - assert sA == theStatus._theLabels[i] - assert sB == theStatus._theColours[i] - assert sC == theStatus._theCounts[i] - assert sD == theStatus._theIcons[i] + assert theStatus.count(statusKeys[0]) == countTo[0] + assert theStatus.count(statusKeys[1]) == countTo[1] + assert theStatus.count(statusKeys[2]) == countTo[2] + assert theStatus.count(statusKeys[3]) == countTo[3] + assert theStatus.count("blablabla") == countTo[0] - sA, sB, sC, sD = theStatus[9] - assert sA is None - assert sB is None - assert sC is None - assert isinstance(sD, QIcon) - - # Clear counts theStatus.resetCounts() - assert theStatus._theCounts == [0, 0, 0, 0, 0] + + assert theStatus.count(statusKeys[0]) == 0 + assert theStatus.count(statusKeys[1]) == 0 + assert theStatus.count(statusKeys[2]) == 0 + assert theStatus.count(statusKeys[3]) == 0 + + # Reorder + # ======= + + cOrder = list(theStatus.keys()) + assert cOrder == statusKeys + + # Wrong length + assert theStatus.reorder([]) is False + + # No change + assert theStatus.reorder(cOrder) is False + + # Actual reaorder + nOrder = [ + statusKeys[0], + statusKeys[2], + statusKeys[1], + statusKeys[3], + ] + assert theStatus.reorder(nOrder) is True + assert list(theStatus.keys()) == nOrder + + # Add an unknown key + wOrder = nOrder.copy() + wOrder[3] = theStatus._newKey() + assert theStatus.reorder(wOrder) is False + assert list(theStatus.keys()) == nOrder + + # Put it back + assert theStatus.reorder(cOrder) is True + assert list(theStatus.keys()) == cOrder + + # Default + # ======= + + default = theStatus._default + theStatus._default = None + + assert theStatus.check("Entry 5") == "" + assert theStatus.name("blablabla") == "" + assert theStatus.cols("blablabla") == (100, 100, 100) + assert theStatus.count("blablabla") == 0 + assert isinstance(theStatus.icon("blablabla"), QIcon) + + theStatus._default = default + + # Remove + # ====== + + # Non-existing entry + assert theStatus.remove("blablabla") is False + + # Non-zero entry + theStatus.increment(statusKeys[3]) + assert theStatus.remove(statusKeys[3]) is False + + # Delete last entry + theStatus.resetCounts() + lastName = theStatus.name(statusKeys[3]) + assert lastName == "Entry 4" + assert theStatus.remove(statusKeys[3]) is True + assert theStatus.check(statusKeys[3]) == theStatus._default + assert theStatus.check(lastName) == theStatus._default + + # Delete default entry, Entry 2 is new default + firstName = theStatus.name(theStatus._default) + assert firstName == "Entry 1" + assert theStatus.remove(theStatus._default) is True + assert theStatus.name(firstName) == "Entry 2" + + # Remove remaining entries + assert theStatus.remove(statusKeys[1]) is True + assert theStatus.remove(statusKeys[2]) is True + + assert len(theStatus) == 0 + assert theStatus._default is None # END Test testCoreStatus_Entries @pytest.mark.core def testCoreStatus_XMLPackUnpack(): - """Test all the simple setters for the NWItem class. + """Test all the XML pack/unpack of the NWStatus class. """ - theStatus = NWStatus() - theStatus.addEntry("New", (100, 100, 100)) - theStatus.addEntry("Minor", (200, 50, 0)) - theStatus.addEntry("Major", (200, 150, 0)) - theStatus.addEntry("Main", (50, 200, 0)) + random.seed(42) + theStatus = NWStatus(NWStatus.STATUS) + theStatus.write(None, "New", (100, 100, 100)) + theStatus.write(None, "Note", (200, 50, 0)) + theStatus.write(None, "Draft", (200, 150, 0)) + theStatus.write(None, "Finished", (50, 200, 0)) countTo = [3, 5, 7, 9] for i, n in enumerate(countTo): for _ in range(n): - theStatus.countEntry(theStatus._theLabels[i]) + theStatus.increment(statusKeys[i]) nwXML = etree.Element("novelWriterXML") @@ -134,23 +336,29 @@ def testCoreStatus_XMLPackUnpack(): theStatus.packXML(xStatus) assert etree.tostring(xStatus, pretty_print=False, encoding="utf-8") == ( b'' - b'New' - b'Minor' - b'Major' - b'Main' + b'New' + b'Note' + b'Draft' + b'Finished' b'' ) # Unpack - theStatus = NWStatus() + theStatus = NWStatus(NWStatus.STATUS) assert theStatus.unpackXML(xStatus) - assert theStatus._theLabels == ["New", "Minor", "Major", "Main"] - assert theStatus._theColours == [(100, 100, 100), (200, 50, 0), (200, 150, 0), (50, 200, 0)] - assert theStatus._theCounts == [0, 0, 0, 0] - assert theStatus._theMap["New"] == 0 - assert theStatus._theMap["Minor"] == 1 - assert theStatus._theMap["Major"] == 2 - assert theStatus._theMap["Main"] == 3 - assert theStatus._theLength == 4 + 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]]["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[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 diff --git a/tests/test_core/test_core_tohtml.py b/tests/test_core/test_core_tohtml.py index 11d89572..f21d5d78 100644 --- a/tests/test_core/test_core_tohtml.py +++ b/tests/test_core/test_core_tohtml.py @@ -24,7 +24,7 @@ import pytest from tools import readFile -from novelwriter.core import NWProject, NWIndex, ToHtml +from novelwriter.core import NWProject, ToHtml @pytest.mark.core @@ -32,7 +32,6 @@ def testCoreToHtml_ConvertFormat(mockGUI): """Test the tokenizer and converter chain using the ToHtml class. """ theProject = NWProject(mockGUI) - mockGUI.theIndex = NWIndex(theProject) theHtml = ToHtml(theProject) # Novel Files Headers @@ -235,7 +234,6 @@ def testCoreToHtml_ConvertDirect(mockGUI): """Test the converter directly using the ToHtml class. """ theProject = NWProject(mockGUI) - mockGUI.theIndex = NWIndex(theProject) theHtml = ToHtml(theProject) theHtml._isNovel = True @@ -606,7 +604,6 @@ def testCoreToHtml_Format(mockGUI): """Test all the formatters for the ToHtml class. """ theProject = NWProject(mockGUI) - mockGUI.theIndex = NWIndex(theProject) theHtml = ToHtml(theProject) # Export Mode diff --git a/tests/test_core/test_core_tokenizer.py b/tests/test_core/test_core_tokenizer.py index 088de786..97023201 100644 --- a/tests/test_core/test_core_tokenizer.py +++ b/tests/test_core/test_core_tokenizer.py @@ -136,7 +136,6 @@ def testCoreToken_TextOps(monkeypatch, nwMinimal, mockGUI): """Test handling files and text in the Tokenizer class. """ theProject = NWProject(mockGUI) - theProject.projTree.setSeed(42) theProject.projLang = "en" theProject._loadProjectLocalisation() diff --git a/tests/test_core/test_core_tomd.py b/tests/test_core/test_core_tomd.py index 51eea72b..2e49d16b 100644 --- a/tests/test_core/test_core_tomd.py +++ b/tests/test_core/test_core_tomd.py @@ -24,7 +24,7 @@ import pytest from tools import readFile -from novelwriter.core import NWProject, NWIndex, ToMarkdown +from novelwriter.core import NWProject, ToMarkdown @pytest.mark.core @@ -32,7 +32,6 @@ def testCoreToMarkdown_ConvertFormat(mockGUI): """Test the tokenizer and converter chain using the ToMarkdown class. """ theProject = NWProject(mockGUI) - mockGUI.theIndex = NWIndex(theProject) theMD = ToMarkdown(theProject) # Headers @@ -161,7 +160,6 @@ def testCoreToMarkdown_ConvertDirect(mockGUI): """Test the converter directly using the ToMarkdown class. """ theProject = NWProject(mockGUI) - mockGUI.theIndex = NWIndex(theProject) theMD = ToMarkdown(theProject) theMD._isNovel = True @@ -266,7 +264,6 @@ def testCoreToMarkdown_Format(mockGUI): """Test all the formatters for the ToMarkdown class. """ theProject = NWProject(mockGUI) - mockGUI.theIndex = NWIndex(theProject) theMD = ToMarkdown(theProject) assert theMD._formatKeywords("", theMD.A_NONE) == "" diff --git a/tests/test_core/test_core_toodt.py b/tests/test_core/test_core_toodt.py index e2ccb4a5..c714b5f5 100644 --- a/tests/test_core/test_core_toodt.py +++ b/tests/test_core/test_core_toodt.py @@ -28,7 +28,7 @@ from shutil import copyfile from tools import cmpFiles -from novelwriter.core import NWProject, NWIndex, ToOdt +from novelwriter.core import NWProject, ToOdt from novelwriter.core.toodt import ODTParagraphStyle, ODTTextStyle, XMLParagraph, _mkTag XML_NS = [ @@ -55,7 +55,6 @@ def testCoreToOdt_Init(mockGUI): """Test initialisation of the ODT document. """ theProject = NWProject(mockGUI) - mockGUI.theIndex = NWIndex(theProject) # Flat Doc # ======== @@ -111,7 +110,6 @@ def testCoreToOdt_TextFormatting(mockGUI): """Test formatting of paragraphs. """ theProject = NWProject(mockGUI) - mockGUI.theIndex = NWIndex(theProject) theDoc = ToOdt(theProject, isFlat=True) theDoc.initDocument() @@ -233,7 +231,6 @@ def testCoreToOdt_Convert(mockGUI): """Test the converter of the ToOdt class. """ theProject = NWProject(mockGUI) - mockGUI.theIndex = NWIndex(theProject) theDoc = ToOdt(theProject, isFlat=True) theDoc._isNovel = True @@ -565,7 +562,6 @@ def testCoreToOdt_ConvertDirect(mockGUI): otherwise hard to reach conditions. """ theProject = NWProject(mockGUI) - mockGUI.theIndex = NWIndex(theProject) theDoc = ToOdt(theProject, isFlat=True) theDoc._isNovel = True @@ -620,7 +616,6 @@ def testCoreToOdt_SaveFlat(mockGUI, fncDir, outDir, refDir): """Test the document save functions. """ theProject = NWProject(mockGUI) - mockGUI.theIndex = NWIndex(theProject) theDoc = ToOdt(theProject, isFlat=True) theDoc._isNovel = True @@ -657,7 +652,6 @@ def testCoreToOdt_SaveFull(mockGUI, fncDir, outDir, refDir): """Test the document save functions. """ theProject = NWProject(mockGUI) - mockGUI.theIndex = NWIndex(theProject) theDoc = ToOdt(theProject, isFlat=False) theDoc._isNovel = True @@ -737,7 +731,6 @@ def testCoreToOdt_Format(mockGUI): """Test the formatters for the ToOdt class. """ theProject = NWProject(mockGUI) - mockGUI.theIndex = NWIndex(theProject) theDoc = ToOdt(theProject, isFlat=True) assert theDoc._formatSynopsis("synopsis text") == ( diff --git a/tests/test_core/test_core_tree.py b/tests/test_core/test_core_tree.py index b9ad0473..23eb7f73 100644 --- a/tests/test_core/test_core_tree.py +++ b/tests/test_core/test_core_tree.py @@ -21,9 +21,9 @@ along with this program. If not, see . import os import pytest +import random from lxml import etree -from hashlib import sha256 from tools import readFile @@ -33,7 +33,7 @@ from novelwriter.constants import nwFiles @pytest.fixture(scope="function") -def mockItems(mockGUI): +def mockItems(mockGUI, mockRnd): """Create a list of mock items. """ theProject = NWProject(mockGUI) @@ -76,7 +76,7 @@ def mockItems(mockGUI): itemF = NWItem(theProject) itemF._name = "Trash" - itemF._type = nwItemType.TRASH + itemF._type = nwItemType.ROOT itemF._class = nwItemClass.TRASH itemF._expanded = False @@ -103,7 +103,7 @@ def mockItems(mockGUI): ("a000000000002", None, itemE), ("a000000000003", None, itemF), ("a000000000004", None, itemG), - ("b000000000002", "a000000000002", itemH), + ("b000000000002", "a000000000004", itemH), ] return theItems @@ -116,26 +116,22 @@ def testCoreTree_BuildTree(mockGUI, mockItems): theProject = NWProject(mockGUI) theTree = NWTree(theProject) - theTree.setSeed(42) - assert theTree._handleSeed == 42 - # Check that tree is empty (calls NWTree.__bool__) - assert not theTree + assert bool(theTree) is False # Check for archive and trash folders assert theTree.trashRoot() is None - assert theTree.archiveRoot() is None - assert not theTree.isTrashRoot("a000000000003") aHandles = [] for tHandle, pHandle, nwItem in mockItems: aHandles.append(tHandle) - assert theTree.append(tHandle, pHandle, nwItem) + assert theTree.append(tHandle, pHandle, nwItem) is True + assert theTree.updateItemData(tHandle) is True - assert theTree._treeChanged + assert theTree._treeChanged is True # Check that tree is not empty (calls __bool__) - assert theTree + assert bool(theTree) is True # Check the number of elements (calls __len__) assert len(theTree) == len(mockItems) @@ -149,17 +145,43 @@ def testCoreTree_BuildTree(mockGUI, mockItems): # Check that we have the correct archive and trash folders assert theTree.trashRoot() == "a000000000003" - assert theTree.archiveRoot() == "a000000000002" - assert theTree.isTrashRoot("a000000000003") + assert theTree.findRoot(nwItemClass.ARCHIVE) == "a000000000002" + assert theTree.isTrash("a000000000003") is True + assert theTree.isRoot("a000000000002") is True + + # Check that we have the root classes + assert theTree.rootClasses() == { + nwItemClass.NOVEL, nwItemClass.CHARACTER, nwItemClass.ARCHIVE, nwItemClass.TRASH + } + + # Check the isTrash function + assert theTree.isTrash("0000000000000") is True # Doesn't exist + assert theTree.isTrash("a000000000003") is True # This the trash folder + + theTree["a000000000003"].setClass(nwItemClass.NO_CLASS) + assert theTree.isTrash("a000000000003") is True # This is still trash + theTree["a000000000003"].setClass(nwItemClass.TRASH) + + assert theTree.isTrash("b000000000002") is False # This is not trash + + value = theTree["b000000000002"].itemParent + theTree["b000000000002"].setParent("a000000000003") + assert theTree.isTrash("b000000000002") is True # This is in trash + theTree["b000000000002"].setParent(value) + + value = theTree["b000000000002"].itemRoot + theTree["b000000000002"].setRoot("a000000000003") + assert theTree.isTrash("b000000000002") is True # This is in trash + theTree["b000000000002"].setRoot(value) # Try to add another trash folder itemT = NWItem(theProject) itemT._name = "Trash" - itemT._type = nwItemType.TRASH + itemT._type = nwItemType.ROOT itemT._class = nwItemClass.TRASH itemT._expanded = False - assert not theTree.append("1234567890abc", None, itemT) + assert theTree.append("1234567890abc", None, itemT) is False assert len(theTree) == len(mockItems) # Generate handle automatically @@ -169,14 +191,16 @@ def testCoreTree_BuildTree(mockGUI, mockItems): itemT._class = nwItemClass.NOVEL itemT._layout = nwItemLayout.DOCUMENT - assert theTree.append(None, None, itemT) + assert theTree.append(None, None, itemT) is True + assert theTree.updateItemData(itemT.itemHandle) is True assert len(theTree) == len(mockItems) + 1 theList = theTree.handles() - assert theList[-1] == "73475cb40a568" + nHandle = "0000000000010" + assert theList[-1] == nHandle # Try to add existing handle - assert not theTree.append("73475cb40a568", None, itemT) + assert theTree.append(nHandle, None, itemT) is False assert len(theTree) == len(mockItems) + 1 # Delete a non-existing item @@ -184,9 +208,9 @@ def testCoreTree_BuildTree(mockGUI, mockItems): assert len(theTree) == len(mockItems) + 1 # Delete the last item - del theTree["73475cb40a568"] + del theTree[nHandle] assert len(theTree) == len(mockItems) - assert "73475cb40a568" not in theTree + assert nHandle not in theTree # Delete the Novel, Archive and Trash folders del theTree["a000000000001"] @@ -196,7 +220,6 @@ def testCoreTree_BuildTree(mockGUI, mockItems): del theTree["a000000000002"] assert len(theTree) == len(mockItems) - 2 assert "a000000000002" not in theTree - assert theTree.archiveRoot() is None del theTree["a000000000003"] assert len(theTree) == len(mockItems) - 3 @@ -215,31 +238,43 @@ def testCoreTree_Methods(mockGUI, mockItems): for tHandle, pHandle, nwItem in mockItems: theTree.append(tHandle, pHandle, nwItem) + theTree.updateItemData(tHandle) assert len(theTree) == len(mockItems) + # Update item data, nonsense handle + assert theTree.updateItemData("stuff") is False + + # Update item data, invalid item parent + corrParent = theTree["b000000000001"].itemParent + theTree["b000000000001"].setParent("0000000000000") + assert theTree.updateItemData("b000000000001") is False + + # Update item data, valid item parent + theTree["b000000000001"].setParent(corrParent) + assert theTree.updateItemData("b000000000001") is True + + # Update item data, root is unreachable + maxDepth = theTree.MAX_DEPTH + theTree.MAX_DEPTH = 0 + with pytest.raises(RecursionError): + theTree.updateItemData("b000000000001") + theTree.MAX_DEPTH = maxDepth + # Chech type assert theTree.checkType("blabla", nwItemType.FILE) is False assert theTree.checkType("b000000000001", nwItemType.FILE) is False assert theTree.checkType("c000000000001", nwItemType.FILE) is True # Root item lookup - theTree._treeRoots.append("stuff") assert theTree.findRoot(nwItemClass.WORLD) is None assert theTree.findRoot(nwItemClass.NOVEL) == "a000000000001" assert theTree.findRoot(nwItemClass.CHARACTER) == "a000000000004" - # Check for root uniqueness - assert theTree.checkRootUnique(nwItemClass.CUSTOM) - assert theTree.checkRootUnique(nwItemClass.WORLD) - assert not theTree.checkRootUnique(nwItemClass.NOVEL) - assert not theTree.checkRootUnique(nwItemClass.CHARACTER) - - # Find root item of child item - assert theTree.getRootItem("b000000000001").itemHandle == "a000000000001" - assert theTree.getRootItem("c000000000001").itemHandle == "a000000000001" - assert theTree.getRootItem("c000000000002").itemHandle == "a000000000001" - assert theTree.getRootItem("stuff") is None + # 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 + del theTree._treeRoots["0000000000000"] # Get item path assert theTree.getItemPath("stuff") == [] @@ -247,6 +282,13 @@ def testCoreTree_Methods(mockGUI, mockItems): "c000000000001", "b000000000001", "a000000000001" ] + # Cause recursion error + maxDepth = theTree.MAX_DEPTH + theTree.MAX_DEPTH = 0 + with pytest.raises(RecursionError): + theTree.getItemPath("c000000000001") + theTree.MAX_DEPTH = maxDepth + # Break the folder parent handle theTree["b000000000001"]._parent = "stuff" assert theTree.getItemPath("c000000000001") == [ @@ -269,44 +311,31 @@ def testCoreTree_Methods(mockGUI, mockItems): @pytest.mark.core -def testCoreTree_MakeHandles(monkeypatch, mockGUI): +def testCoreTree_MakeHandles(mockGUI): """Test generating item handles. """ + random.seed(42) theProject = NWProject(mockGUI) theTree = NWTree(theProject) - theTree.setSeed(42) + handles = ["1c803a3b1799d", "bdd6406671ad1", "3eb1346685257", "23b8c392456de"] + random.seed(42) tHandle = theTree._makeHandle() - assert tHandle == "73475cb40a568" + assert tHandle == handles[0] + theTree._projTree[handles[0]] = None # Add the next in line to the project to force duplicate - theTree._projTree["44cb730c42048"] = None + theTree._projTree[handles[1]] = None tHandle = theTree._makeHandle() - assert tHandle == "71ee45a3c0db9" - - # Fix the time() function and force a handle collission - theTree.setSeed(None) - theTree._handleCount = 0 - monkeypatch.setattr("novelwriter.core.tree.time", lambda: 123.4) + assert tHandle == handles[2] + theTree._projTree[handles[2]] = None + # Reset the seed to force collissions, which should still end up + # returning the next handle in the sequence + random.seed(42) tHandle = theTree._makeHandle() - theTree._projTree[tHandle] = None - newSeed = "123.4_0_" - assert tHandle == sha256(newSeed.encode()).hexdigest()[0:13] - - tHandle = theTree._makeHandle() - theTree._projTree[tHandle] = None - newSeed = "123.4_1_" - assert tHandle == sha256(newSeed.encode()).hexdigest()[0:13] - - # Reset the count and the handle for 0 and 1 should be duplicates - # which forces the function to add the '!' - theTree._handleCount = 0 - tHandle = theTree._makeHandle() - theTree._projTree[tHandle] = None - newSeed = "123.4_1_!" - assert tHandle == sha256(newSeed.encode()).hexdigest()[0:13] + assert tHandle == handles[3] # END Test testCoreTree_MakeHandles @@ -329,12 +358,6 @@ def testCoreTree_Stats(mockGUI, mockItems): assert novelWords == 550 assert noteWords == 400 - # Count types - nRoot, nFolder, nFile = theTree.countTypes() - assert nRoot == 3 - assert nFolder == 1 - assert nFile == 3 - # END Test testCoreTree_Stats @@ -379,6 +402,7 @@ def testCoreTree_XMLPackUnpack(mockGUI, mockItems): for tHandle, pHandle, nwItem in mockItems: theTree.append(tHandle, pHandle, nwItem) + theTree.updateItemData(tHandle) assert len(theTree) == len(mockItems) @@ -387,26 +411,33 @@ def testCoreTree_XMLPackUnpack(mockGUI, mockItems): assert etree.tostring(nwXML, pretty_print=False, encoding="utf-8") == ( b'' b'' - b'Novel' - b'Act One' - b'' - b'' - b'Chapter One' - b'' - b'Scene One' - b'Outtakes' - b'Trash' - b'' - b'Characters' - b'Jane Doe' + b'Novel' + b'Act One' + b'Chapter One' + b'Scene One' + b'Outtakes' + b'Trash' + b'Characters' + b'Jane Doe' b'' b'' ) @@ -429,6 +460,7 @@ def testCoreTree_ToCFile(monkeypatch, mockGUI, mockItems, tmpDir): for tHandle, pHandle, nwItem in mockItems: theTree.append(tHandle, pHandle, nwItem) + theTree.updateItemData(tHandle) assert len(theTree) == len(mockItems) theTree._treeOrder.append("stuff") diff --git a/tests/test_dialogs/test_dlg_docmerge.py b/tests/test_dialogs/test_dlg_docmerge.py index 6a3825b8..a82c77bd 100644 --- a/tests/test_dialogs/test_dlg_docmerge.py +++ b/tests/test_dialogs/test_dlg_docmerge.py @@ -19,21 +19,21 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import pytest import os +import pytest -from tools import getGuiItem, readFile, writeFile from mock import causeOSError +from tools import getGuiItem, readFile, writeFile, buildTestProject from PyQt5.QtWidgets import QAction, QMessageBox, QDialog -from novelwriter.dialogs import GuiDocMerge, GuiItemEditor from novelwriter.enum import nwItemType, nwWidget +from novelwriter.dialogs import GuiDocMerge, GuiItemEditor from novelwriter.core.tree import NWTree @pytest.mark.gui -def testDlgMerge_Main(qtbot, monkeypatch, nwGUI, fncProj): +def testDlgMerge_Main(qtbot, monkeypatch, nwGUI, fncProj, mockRnd): """Test the merge documents tool. """ # Block message box @@ -41,26 +41,26 @@ def testDlgMerge_Main(qtbot, monkeypatch, nwGUI, fncProj): monkeypatch.setattr(QMessageBox, "critical", lambda *a: QMessageBox.Ok) # Create a new project - nwGUI.theProject.projTree.setSeed(42) - assert nwGUI.newProject({"projPath": fncProj}) + buildTestProject(nwGUI, fncProj) # Handles for new objects - hChapterDir = "31489056e0916" - hChapterOne = "98010bd9270f9" - hSceneOne = "0e17daca5f3e1" - hSceneTwo = "1a6562590ef19" - hSceneThree = "031b4af5197ec" - hSceneFour = "41cfc0d1f2d12" - hMergedDoc = "2858dcd1057d3" + hNovelRoot = "0000000000008" + hChapterDir = "000000000000d" + hChapterOne = "000000000000e" + hSceneOne = "000000000000f" + hSceneTwo = "0000000000010" + hSceneThree = "0000000000011" + hSceneFour = "0000000000012" + hMergedDoc = "0000000000023" # Add Project Content monkeypatch.setattr(GuiItemEditor, "exec_", lambda *a: QDialog.Accepted) nwGUI.switchFocus(nwWidget.TREE) nwGUI.treeView.clearSelection() nwGUI.treeView._getTreeItem(hChapterDir).setSelected(True) - nwGUI.treeView.newTreeItem(nwItemType.FILE, None) - nwGUI.treeView.newTreeItem(nwItemType.FILE, None) - nwGUI.treeView.newTreeItem(nwItemType.FILE, None) + nwGUI.treeView.newTreeItem(nwItemType.FILE) + nwGUI.treeView.newTreeItem(nwItemType.FILE) + nwGUI.treeView.newTreeItem(nwItemType.FILE) assert nwGUI.saveProject() is True assert nwGUI.closeProject() is True @@ -137,7 +137,7 @@ def testDlgMerge_Main(qtbot, monkeypatch, nwGUI, fncProj): assert os.path.isfile(mergedFile) assert readFile(mergedFile) == ( "%%%%~name: New Chapter\n" - "%%%%~path: 73475cb40a568/2858dcd1057d3\n" + "%%%%~path: %s/%s\n" "%%%%~kind: NOVEL/DOCUMENT\n" "%s\n\n" "%s\n\n" @@ -145,6 +145,8 @@ def testDlgMerge_Main(qtbot, monkeypatch, nwGUI, fncProj): "%s\n\n" "%s\n\n" ) % ( + hNovelRoot, + hMergedDoc, tChapterOne.strip(), tSceneOne.strip(), tSceneTwo.strip(), diff --git a/tests/test_dialogs/test_dlg_docsplit.py b/tests/test_dialogs/test_dlg_docsplit.py index d8d4bbac..90e3375d 100644 --- a/tests/test_dialogs/test_dlg_docsplit.py +++ b/tests/test_dialogs/test_dlg_docsplit.py @@ -19,22 +19,22 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import pytest import os +import pytest -from tools import getGuiItem, readFile, writeFile from mock import causeOSError +from tools import getGuiItem, readFile, writeFile, buildTestProject from PyQt5.QtWidgets import QAction, QMessageBox, QDialog -from novelwriter.dialogs import GuiDocSplit, GuiItemEditor from novelwriter.enum import nwItemType, nwWidget -from novelwriter.core.document import NWDoc +from novelwriter.dialogs import GuiDocSplit, GuiItemEditor from novelwriter.core.tree import NWTree +from novelwriter.core.document import NWDoc @pytest.mark.gui -def testDlgSplit_Main(qtbot, monkeypatch, nwGUI, fncProj): +def testDlgSplit_Main(qtbot, monkeypatch, nwGUI, fncProj, mockRnd): """Test the split document tool. """ # Block message box @@ -42,27 +42,27 @@ def testDlgSplit_Main(qtbot, monkeypatch, nwGUI, fncProj): monkeypatch.setattr(QMessageBox, "critical", lambda *a: QMessageBox.Ok) # Create a new project - nwGUI.theProject.projTree.setSeed(42) - assert nwGUI.newProject({"projPath": fncProj}) is True + buildTestProject(nwGUI, fncProj) # Handles for new objects - hNovelRoot = "73475cb40a568" - hChapterDir = "31489056e0916" - hToSplit = "1a6562590ef19" - hPartition = "41cfc0d1f2d12" - hChapterOne = "2858dcd1057d3" - hSceneOne = "2fca346db6561" - hSceneTwo = "02d20bbd7e394" - hSceneThree = "7688b6ef52555" - hSceneFour = "c837649cce43f" - hSceneFive = "6208ef0f7750c" + hNovelRoot = "0000000000008" + hChapterDir = "000000000000d" + hToSplit = "0000000000010" + hNewFolder = "0000000000021" + hPartition = "0000000000022" + hChapterOne = "0000000000023" + hSceneOne = "0000000000024" + hSceneTwo = "0000000000025" + hSceneThree = "0000000000026" + hSceneFour = "0000000000027" + hSceneFive = "0000000000028" # Add Project Content monkeypatch.setattr(GuiItemEditor, "exec_", lambda *a: QDialog.Accepted) nwGUI.switchFocus(nwWidget.TREE) nwGUI.treeView.clearSelection() nwGUI.treeView._getTreeItem(hNovelRoot).setSelected(True) - nwGUI.treeView.newTreeItem(nwItemType.FILE, None) + nwGUI.treeView.newTreeItem(nwItemType.FILE) assert nwGUI.saveProject() is True assert nwGUI.closeProject() is True @@ -173,52 +173,52 @@ def testDlgSplit_Main(qtbot, monkeypatch, nwGUI, fncProj): assert readFile(os.path.join(contentDir, hPartition+".nwd")) == ( "%%%%~name: Nantucket\n" - "%%%%~path: 031b4af5197ec/%s\n" + "%%%%~path: %s/%s\n" "%%%%~kind: NOVEL/DOCUMENT\n" "%s\n\n" - ) % (hPartition, tPartition) + ) % (hNewFolder, hPartition, tPartition) assert readFile(os.path.join(contentDir, hChapterOne+".nwd")) == ( "%%%%~name: Chapter One\n" - "%%%%~path: 031b4af5197ec/%s\n" + "%%%%~path: %s/%s\n" "%%%%~kind: NOVEL/DOCUMENT\n" "%s\n\n" - ) % (hChapterOne, tChapterOne) + ) % (hNewFolder, hChapterOne, tChapterOne) assert readFile(os.path.join(contentDir, hSceneOne+".nwd")) == ( "%%%%~name: Scene One\n" - "%%%%~path: 031b4af5197ec/%s\n" + "%%%%~path: %s/%s\n" "%%%%~kind: NOVEL/DOCUMENT\n" "%s\n\n" - ) % (hSceneOne, tSceneOne) + ) % (hNewFolder, hSceneOne, tSceneOne) assert readFile(os.path.join(contentDir, hSceneTwo+".nwd")) == ( "%%%%~name: Scene Two\n" - "%%%%~path: 031b4af5197ec/%s\n" + "%%%%~path: %s/%s\n" "%%%%~kind: NOVEL/DOCUMENT\n" "%s\n\n" - ) % (hSceneTwo, tSceneTwo) + ) % (hNewFolder, hSceneTwo, tSceneTwo) assert readFile(os.path.join(contentDir, hSceneThree+".nwd")) == ( "%%%%~name: Scene Three\n" - "%%%%~path: 031b4af5197ec/%s\n" + "%%%%~path: %s/%s\n" "%%%%~kind: NOVEL/DOCUMENT\n" "%s\n\n" - ) % (hSceneThree, tSceneThree) + ) % (hNewFolder, hSceneThree, tSceneThree) assert readFile(os.path.join(contentDir, hSceneFour+".nwd")) == ( "%%%%~name: Scene Four\n" - "%%%%~path: 031b4af5197ec/%s\n" + "%%%%~path: %s/%s\n" "%%%%~kind: NOVEL/DOCUMENT\n" "%s\n\n" - ) % (hSceneFour, tSceneFour) + ) % (hNewFolder, hSceneFour, tSceneFour) assert readFile(os.path.join(contentDir, hSceneFive+".nwd")) == ( "%%%%~name: The End\n" - "%%%%~path: 031b4af5197ec/%s\n" + "%%%%~path: %s/%s\n" "%%%%~kind: NOVEL/DOCUMENT\n" "%s\n\n" - ) % (hSceneFive, tSceneFive) + ) % (hNewFolder, hSceneFive, tSceneFive) # OS error with monkeypatch.context() as mp: @@ -230,12 +230,6 @@ def testDlgSplit_Main(qtbot, monkeypatch, nwGUI, fncProj): mp.setattr(QMessageBox, "question", lambda *a: QMessageBox.No) assert nwSplit._doSplit() is False - # Block folder creation by returning that the folder has a depth - # of 50 items in the tree - with monkeypatch.context() as mp: - mp.setattr(NWTree, "getItemPath", lambda *a: [""]*50) - assert nwSplit._doSplit() is False - # Clear the list nwSplit.listBox.clear() assert nwSplit._doSplit() is False diff --git a/tests/test_dialogs/test_dlg_itemeditor.py b/tests/test_dialogs/test_dlg_itemeditor.py index 79038345..95b1ee71 100644 --- a/tests/test_dialogs/test_dlg_itemeditor.py +++ b/tests/test_dialogs/test_dlg_itemeditor.py @@ -21,7 +21,7 @@ along with this program. If not, see . import pytest -from tools import getGuiItem +from tools import getGuiItem, buildTestProject from PyQt5.QtWidgets import QAction, QDialog, QMessageBox @@ -30,9 +30,12 @@ from novelwriter.enum import nwItemLayout, nwItemType from novelwriter.dialogs import GuiItemEditor from novelwriter.core.tree import NWTree +statusKeys = ["s000000", "s000001", "s000002", "s000003"] +importKeys = ["i000004", "i000005", "i000006", "i000007"] + @pytest.mark.gui -def testDlgItemEditor_Dialog(qtbot, monkeypatch, nwGUI, fncProj): +def testDlgItemEditor_Dialog(qtbot, monkeypatch, nwGUI, fncProj, mockRnd): """Test launching the item editor dialog from GuiMain. """ # Block message box @@ -45,15 +48,15 @@ def testDlgItemEditor_Dialog(qtbot, monkeypatch, nwGUI, fncProj): assert nwGUI.editItem() is False # Create and Open Project - nwGUI.theProject.projTree.setSeed(42) - assert nwGUI.newProject({"projPath": fncProj}) + buildTestProject(nwGUI, fncProj) + tHandle = "000000000000f" # No Selection nwGUI.treeView.clearSelection() assert nwGUI.editItem() is False # Force opening from editor - assert nwGUI.openDocument("0e17daca5f3e1") + assert nwGUI.openDocument(tHandle) nwGUI.isFocusMode = True # Block Tree Lookup @@ -62,9 +65,9 @@ def testDlgItemEditor_Dialog(qtbot, monkeypatch, nwGUI, fncProj): assert nwGUI.editItem() is False # Invalid Type - nwGUI.theProject.projTree["0e17daca5f3e1"]._type = nwItemType.NO_TYPE + nwGUI.theProject.tree[tHandle]._type = nwItemType.NO_TYPE assert nwGUI.editItem() is False - nwGUI.theProject.projTree["0e17daca5f3e1"]._type = nwItemType.FILE + nwGUI.theProject.tree[tHandle]._type = nwItemType.FILE # Open Properly assert nwGUI.editItem() is True @@ -88,7 +91,7 @@ def testDlgItemEditor_Dialog(qtbot, monkeypatch, nwGUI, fncProj): @pytest.mark.gui -def testDlgItemEditor_Novel(qtbot, monkeypatch, nwGUI, fncProj): +def testDlgItemEditor_Novel(qtbot, monkeypatch, nwGUI, fncProj, mockRnd): """Test the item editor dialog for a novel document. """ # Block message box @@ -96,9 +99,13 @@ def testDlgItemEditor_Novel(qtbot, monkeypatch, nwGUI, fncProj): monkeypatch.setattr(GuiProjectTree, "hasFocus", lambda *a: True) # Create Project and Open Document - nwGUI.theProject.projTree.setSeed(42) - assert nwGUI.newProject({"projPath": fncProj}) - assert nwGUI.openDocument("0e17daca5f3e1") + buildTestProject(nwGUI, fncProj) + tHandle = "000000000000f" + + assert nwGUI.theProject.statusItems.name(statusKeys[0]) == "New" + assert nwGUI.theProject.statusItems.name(statusKeys[1]) == "Note" + + assert nwGUI.openDocument(tHandle) is True # Check that an invalid handle is managed itemEdit = GuiItemEditor(nwGUI, "whatever") @@ -106,12 +113,12 @@ def testDlgItemEditor_Novel(qtbot, monkeypatch, nwGUI, fncProj): itemEdit._doClose() # Edit a Document - itemEdit = GuiItemEditor(nwGUI, "0e17daca5f3e1") + itemEdit = GuiItemEditor(nwGUI, tHandle) itemEdit.show() # Check Existing Settings assert itemEdit.editName.text() == "New Scene" - assert itemEdit.editStatus.currentData() == "New" + assert itemEdit.editStatus.currentData() == statusKeys[0] assert itemEdit.editLayout.currentData() == nwItemLayout.DOCUMENT assert itemEdit.editExport.isChecked() is True @@ -125,12 +132,12 @@ def testDlgItemEditor_Novel(qtbot, monkeypatch, nwGUI, fncProj): # Check New Settings itemEdit._doSave() assert itemEdit.theItem.itemName == "Great Scene" - assert itemEdit.theItem.itemStatus == "Note" + assert itemEdit.theItem.itemStatus == statusKeys[1] assert itemEdit.theItem.itemLayout == nwItemLayout.NOTE assert itemEdit.theItem.isExported is False # Check that the editor header is updated - nwGUI.docEditor.updateDocInfo("0e17daca5f3e1") + nwGUI.docEditor.updateDocInfo(tHandle) assert nwGUI.docEditor.docHeader.theTitle.text() == "Novel › New Chapter › Great Scene" itemEdit.close() @@ -141,7 +148,7 @@ def testDlgItemEditor_Novel(qtbot, monkeypatch, nwGUI, fncProj): @pytest.mark.gui -def testDlgItemEditor_Note(qtbot, monkeypatch, nwGUI, fncProj): +def testDlgItemEditor_Note(qtbot, monkeypatch, nwGUI, fncProj, mockRnd): """Test the item editor dialog for a project note. """ # Block message box @@ -149,24 +156,27 @@ def testDlgItemEditor_Note(qtbot, monkeypatch, nwGUI, fncProj): monkeypatch.setattr(GuiProjectTree, "hasFocus", lambda *a: True) # Create Project and Open Document - nwGUI.theProject.projTree.setSeed(42) - assert nwGUI.newProject({"projPath": fncProj}) + buildTestProject(nwGUI, fncProj) + assert nwGUI.theProject.statusItems.name(statusKeys[0]) == "New" + assert nwGUI.theProject.statusItems.name(statusKeys[1]) == "Note" + assert nwGUI.theProject.importItems.name(importKeys[0]) == "New" + assert nwGUI.theProject.importItems.name(importKeys[1]) == "Minor" # Create Note nwGUI.treeView.clearSelection() - nwGUI.treeView._getTreeItem("71ee45a3c0db9").setSelected(True) + nwGUI.treeView._getTreeItem("000000000000a").setSelected(True) nwGUI.treeView.newTreeItem(nwItemType.FILE, None) # Open Note - assert nwGUI.openDocument("1a6562590ef19") + assert nwGUI.openDocument("0000000000010") # Edit a Document - itemEdit = GuiItemEditor(nwGUI, "1a6562590ef19") + itemEdit = GuiItemEditor(nwGUI, "0000000000010") itemEdit.show() # Check Existing Settings - assert itemEdit.editName.text() == "New File" - assert itemEdit.editStatus.currentData() == "New" + assert itemEdit.editName.text() == "New Note" + assert itemEdit.editStatus.currentData() == importKeys[0] assert itemEdit.editLayout.currentData() == nwItemLayout.NOTE assert itemEdit.editExport.isChecked() is True @@ -178,8 +188,8 @@ def testDlgItemEditor_Note(qtbot, monkeypatch, nwGUI, fncProj): # Check New Settings assert itemEdit.theItem.itemName == "New Character" - assert itemEdit.theItem.itemStatus == "New" - assert itemEdit.theItem.itemImport == "Minor" + assert itemEdit.theItem.itemStatus == statusKeys[0] + assert itemEdit.theItem.itemImport == importKeys[1] assert itemEdit.theItem.itemLayout == nwItemLayout.NOTE assert itemEdit.theItem.isExported is False @@ -191,7 +201,7 @@ def testDlgItemEditor_Note(qtbot, monkeypatch, nwGUI, fncProj): @pytest.mark.gui -def testDlgItemEditor_Folder(qtbot, monkeypatch, nwGUI, fncProj): +def testDlgItemEditor_Folder(qtbot, monkeypatch, nwGUI, fncProj, mockRnd): """Test the item editor dialog for a folder. """ # Block message box @@ -199,16 +209,18 @@ def testDlgItemEditor_Folder(qtbot, monkeypatch, nwGUI, fncProj): monkeypatch.setattr(GuiProjectTree, "hasFocus", lambda *a: True) # Create Project and Open Document - nwGUI.theProject.projTree.setSeed(42) - assert nwGUI.newProject({"projPath": fncProj}) + buildTestProject(nwGUI, fncProj) # Edit a Folder - itemEdit = GuiItemEditor(nwGUI, "31489056e0916") + itemEdit = GuiItemEditor(nwGUI, "000000000000d") itemEdit.show() + assert nwGUI.theProject.statusItems.name(statusKeys[0]) == "New" + assert nwGUI.theProject.statusItems.name(statusKeys[1]) == "Note" + # Check Existing Settings assert itemEdit.editName.text() == "New Chapter" - assert itemEdit.editStatus.currentData() == "New" + assert itemEdit.editStatus.currentData() == statusKeys[0] assert itemEdit.editLayout.currentData() == nwItemLayout.NO_LAYOUT assert itemEdit.editExport.isChecked() is False @@ -222,7 +234,7 @@ def testDlgItemEditor_Folder(qtbot, monkeypatch, nwGUI, fncProj): # Check New Settings itemEdit._doSave() assert itemEdit.theItem.itemName == "Chapter One" - assert itemEdit.theItem.itemStatus == "Note" + assert itemEdit.theItem.itemStatus == statusKeys[1] assert itemEdit.theItem.itemLayout == nwItemLayout.NO_LAYOUT assert itemEdit.theItem.isExported is False diff --git a/tests/test_dialogs/test_dlg_projload.py b/tests/test_dialogs/test_dlg_projload.py index 195ca26b..6edb7a0d 100644 --- a/tests/test_dialogs/test_dlg_projload.py +++ b/tests/test_dialogs/test_dlg_projload.py @@ -42,7 +42,8 @@ def testDlgLoadProject_Main(qtbot, monkeypatch, nwGUI, nwMinimal): """Test the load project wizard. """ # Block message box - monkeypatch.setattr(QMessageBox, "question", lambda *args: QMessageBox.Yes) + monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) + monkeypatch.setattr(QMessageBox, "information", lambda *a: QMessageBox.Yes) assert nwGUI.openProject(nwMinimal) assert nwGUI.closeProject() diff --git a/tests/test_dialogs/test_dlg_projsettings.py b/tests/test_dialogs/test_dlg_projsettings.py index ec316bea..f193194a 100644 --- a/tests/test_dialogs/test_dlg_projsettings.py +++ b/tests/test_dialogs/test_dlg_projsettings.py @@ -19,27 +19,29 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import pytest import os +import pytest from shutil import copyfile -from tools import cmpFiles, getGuiItem +from tools import cmpFiles, getGuiItem, buildTestProject from PyQt5.QtGui import QColor from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import ( - QDialog, QAction, QMessageBox, QColorDialog, QTreeWidgetItem -) +from PyQt5.QtWidgets import QDialog, QAction, QMessageBox, QColorDialog from novelwriter.dialogs import GuiProjectSettings keyDelay = 2 typeDelay = 1 stepDelay = 20 +statusKeys = ["s000000", "s000001", "s000002", "s000003"] +importKeys = ["i000004", "i000005", "i000006", "i000007"] @pytest.mark.gui -def testDlgProjSettings_Dialog(qtbot, monkeypatch, nwGUI, fncDir, fncProj, outDir, refDir): +def testDlgProjSettings_Dialog( + qtbot, monkeypatch, nwGUI, fncDir, fncProj, outDir, refDir, mockRnd +): """Test the full project settings dialog. """ projFile = os.path.join(fncProj, "nwProject.nwx") @@ -55,8 +57,7 @@ def testDlgProjSettings_Dialog(qtbot, monkeypatch, nwGUI, fncDir, fncProj, outDi assert getGuiItem("GuiProjectSettings") is None # Create new project - nwGUI.theProject.projTree.setSeed(42) - assert nwGUI.newProject({"projPath": fncProj}) + buildTestProject(nwGUI, fncProj) nwGUI.mainConf.backupPath = fncDir nwGUI.theProject.setSpellLang("en") @@ -80,7 +81,7 @@ def testDlgProjSettings_Dialog(qtbot, monkeypatch, nwGUI, fncDir, fncProj, outDi # ============ assert projEdit.tabMain.editName.text() == "New Project" - assert projEdit.tabMain.editTitle.text() == "" + 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 @@ -89,6 +90,7 @@ def testDlgProjSettings_Dialog(qtbot, monkeypatch, nwGUI, fncDir, fncProj, outDi projEdit.tabMain.editName.setText("") for c in "Project Name": qtbot.keyClick(projEdit.tabMain.editName, c, delay=typeDelay) + projEdit.tabMain.editTitle.setText("") for c in "Project Title": qtbot.keyClick(projEdit.tabMain.editTitle, c, delay=typeDelay) @@ -111,19 +113,9 @@ def testDlgProjSettings_Dialog(qtbot, monkeypatch, nwGUI, fncDir, fncProj, outDi projEdit._tabBox.setCurrentWidget(projEdit.tabStatus) assert projEdit.tabStatus.colChanged is False - assert projEdit.tabStatus.getNewList() is None + assert projEdit.tabStatus.getNewList() == ([], []) assert projEdit.tabStatus.listBox.topLevelItemCount() == 4 - # Fake drag'n'drop should change changed status - projEdit.tabStatus._rowsMoved() - assert projEdit.tabStatus.colChanged is True - projEdit.tabStatus.colChanged = False - - projEdit.tabStatus.listBox.clearSelection() - assert projEdit.tabStatus._getSelectedItem() is None - projEdit.tabStatus.listBox.topLevelItem(0).setSelected(True) - assert isinstance(projEdit.tabStatus._getSelectedItem(), QTreeWidgetItem) - # Can't delete the first item (it's in use) projEdit.tabStatus.listBox.clearSelection() projEdit.tabStatus.listBox.topLevelItem(0).setSelected(True) @@ -150,11 +142,53 @@ def testDlgProjSettings_Dialog(qtbot, monkeypatch, nwGUI, fncDir, fncProj, outDi qtbot.wait(stepDelay) assert projEdit.tabStatus.colChanged is True - assert projEdit.tabStatus.getNewList() == [ - ("New", 100, 100, 100, "New"), - ("Note", 200, 50, 0, "Note"), - ("Finished", 50, 200, 0, "Finished"), - ("Final", 20, 30, 40, None) + assert projEdit.tabStatus.getNewList() == ( + [ + { + "key": statusKeys[0], + "name": "New", + "cols": (100, 100, 100) + }, { + "key": statusKeys[1], + "name": "Note", + "cols": (200, 50, 0) + }, { + "key": statusKeys[3], + "name": "Finished", + "cols": (50, 200, 0) + }, { + "key": None, + "name": "Final", + "cols": (20, 30, 40) + } + ], [ + statusKeys[2] # 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 + ] + + 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 + ] + + 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] + ] + projEdit.tabStatus._moveItem(1) + assert [x["key"] for x in projEdit.tabStatus.getNewList()[0]] == [ + statusKeys[0], statusKeys[1], statusKeys[3], None ] # Importance Tab diff --git a/tests/test_dialogs/test_dlg_wordlist.py b/tests/test_dialogs/test_dlg_wordlist.py index 3ca5a0ee..d9993d4d 100644 --- a/tests/test_dialogs/test_dlg_wordlist.py +++ b/tests/test_dialogs/test_dlg_wordlist.py @@ -40,6 +40,7 @@ stepDelay = 20 def testDlgWordList_Dialog(qtbot, monkeypatch, nwGUI, nwMinimal): """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) monkeypatch.setattr(GuiWordList, "exec_", lambda *a: None) diff --git a/tests/test_gui/test_gui_doceditor.py b/tests/test_gui/test_gui_doceditor.py index 8057ca41..1727b69f 100644 --- a/tests/test_gui/test_gui_doceditor.py +++ b/tests/test_gui/test_gui_doceditor.py @@ -29,7 +29,7 @@ from PyQt5.QtWidgets import QAction, QMessageBox, qApp from novelwriter.gui.doceditor import GuiDocEditor from novelwriter.core import countWords -from novelwriter.enum import nwDocAction, nwDocInsert, nwItemClass, nwItemLayout +from novelwriter.enum import nwDocAction, nwDocInsert, nwItemLayout from novelwriter.constants import nwKeyWords, nwUnicode keyDelay = 2 @@ -43,6 +43,7 @@ def testGuiEditor_Init(qtbot, monkeypatch, nwGUI, nwMinimal, ipsumText): """ # 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) @@ -184,10 +185,10 @@ def testGuiEditor_SaveText(qtbot, monkeypatch, caplog, nwGUI, nwMinimal, ipsumTe assert "Could not save document." in caplog.text # Change header level - assert nwGUI.theProject.projTree[sHandle].itemLayout == nwItemLayout.DOCUMENT + assert nwGUI.theProject.tree[sHandle].itemLayout == nwItemLayout.DOCUMENT nwGUI.docEditor.replaceText(longText[1:]) assert nwGUI.docEditor.saveText() is True - assert nwGUI.theProject.projTree[sHandle].itemLayout == nwItemLayout.DOCUMENT + assert nwGUI.theProject.tree[sHandle].itemLayout == nwItemLayout.DOCUMENT # Regular save assert nwGUI.docEditor.saveText() is True @@ -235,9 +236,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.projTree[sHandle].cursorPos != 10 + assert nwGUI.theProject.tree[sHandle].cursorPos != 10 nwGUI.docEditor.saveCursorPosition() - assert nwGUI.theProject.projTree[sHandle].cursorPos == 10 + assert nwGUI.theProject.tree[sHandle].cursorPos == 10 assert nwGUI.docEditor.setCursorLine(None) is False assert nwGUI.docEditor.setCursorLine(2) is True @@ -1142,7 +1143,7 @@ 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", nwItemClass.CHARACTER, "afb3043c7b2b3") + cHandle = nwGUI.theProject.newFile("Jane Doe", "afb3043c7b2b3") assert nwGUI.openDocument(cHandle) is True assert nwGUI.docEditor.replaceText(theText) is True assert nwGUI.saveDocument() is True @@ -1225,8 +1226,8 @@ def testGuiEditor_WordCounters(qtbot, monkeypatch, caplog, nwGUI, nwMinimal, ips # Open a document and populate it sHandle = "8c659a11cd429" - nwGUI.theProject.projTree[sHandle]._initCount = 0 # Clear item's count - nwGUI.theProject.projTree[sHandle]._wordCount = 0 # Clear item's count + 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) @@ -1252,9 +1253,9 @@ 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.projTree[sHandle]._charCount == cC - assert nwGUI.theProject.projTree[sHandle]._wordCount == wC - assert nwGUI.theProject.projTree[sHandle]._paraCount == pC + assert nwGUI.theProject.tree[sHandle]._charCount == cC + assert nwGUI.theProject.tree[sHandle]._wordCount == wC + assert nwGUI.theProject.tree[sHandle]._paraCount == pC assert nwGUI.docEditor.docFooter.wordsText.text() == f"Words: {wC} (+{wC})" # Select all text @@ -1285,7 +1286,6 @@ def testGuiEditor_Search(qtbot, monkeypatch, nwGUI, nwLipsum): monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) monkeypatch.setattr(GuiDocEditor, "hasFocus", lambda *a: True) - nwGUI.theProject.projTree.setSeed(42) assert nwGUI.openProject(nwLipsum) is True assert nwGUI.openDocument("4c4f28287af27") is True origText = nwGUI.docEditor.getText() diff --git a/tests/test_gui/test_gui_docviewer.py b/tests/test_gui/test_gui_docviewer.py index 4d22b62d..1fa6b2c7 100644 --- a/tests/test_gui/test_gui_docviewer.py +++ b/tests/test_gui/test_gui_docviewer.py @@ -43,13 +43,12 @@ def testGuiViewer_Main(qtbot, monkeypatch, nwGUI, nwLipsum): monkeypatch.setattr(QMessageBox, "information", lambda *a: QMessageBox.Yes) # Open project - nwGUI.theProject.projTree.setSeed(42) assert nwGUI.openProject(nwLipsum) # Rebuild the index nwGUI.mainMenu.aRebuildIndex.activate(QAction.Trigger) - assert nwGUI.theIndex._tagIndex != {} - assert nwGUI.theIndex._refIndex != {} + assert nwGUI.theProject.index._tagsIndex._tags != {} + assert nwGUI.theProject.index._itemIndex._items != {} # Select a document in the project tree nwGUI.treeView.setSelectedHandle("88243afbe5ed8") @@ -141,7 +140,7 @@ def testGuiViewer_Main(qtbot, monkeypatch, nwGUI, nwLipsum): nwGUI.docViewer.reloadText() # Change document title - nwItem = nwGUI.theProject.projTree["4c4f28287af27"] + nwItem = nwGUI.theProject.tree["4c4f28287af27"] nwItem.setName("Test Title") assert nwItem.itemName == "Test Title" nwGUI.docViewer.updateDocInfo("4c4f28287af27") diff --git a/tests/test_gui/test_gui_guimain.py b/tests/test_gui/test_gui_guimain.py index 34cfaffa..5f49d51d 100644 --- a/tests/test_gui/test_gui_guimain.py +++ b/tests/test_gui/test_gui_guimain.py @@ -19,11 +19,11 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import pytest import os +import pytest from shutil import copyfile -from tools import cmpFiles +from tools import cmpFiles, buildTestProject, XML_IGNORE, writeFile from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QMessageBox, QDialog @@ -32,6 +32,7 @@ from novelwriter.gui import ( GuiDocEditor, GuiProjectTree, GuiNovelTree, GuiOutline ) from novelwriter.enum import nwItemType, nwWidget +from novelwriter.tools import GuiProjectWizard from novelwriter.dialogs.itemeditor import GuiItemEditor keyDelay = 2 @@ -60,7 +61,6 @@ def testGuiMain_ProjectBlocker(monkeypatch, nwGUI): assert nwGUI.editItem() is False assert nwGUI.requestNovelTreeRefresh() is False assert nwGUI.rebuildIndex() is False - assert nwGUI.rebuildOutline() is False assert nwGUI.showProjectSettingsDialog() is False assert nwGUI.showProjectDetailsDialog() is False assert nwGUI.showBuildProjectDialog() is False @@ -71,21 +71,58 @@ def testGuiMain_ProjectBlocker(monkeypatch, nwGUI): @pytest.mark.gui -def testGuiMain_ProjectTreeItems(qtbot, monkeypatch, nwGUI, fncProj): +def testGuiMain_NewProject(monkeypatch, nwGUI, fncProj): + """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) + assert nwGUI.newProject(projData=None) is False + + # Close project + with monkeypatch.context() as mp: + nwGUI.hasProject = True + mp.setattr(QMessageBox, "question", lambda *a: QMessageBox.No) + assert nwGUI.newProject(projData={"projPath": fncProj}) is False + + # No project path + assert nwGUI.newProject(projData={}) is False + + # Project file already exists + projFile = os.path.join(fncProj, nwGUI.theProject.projFile) + writeFile(projFile, "Stuff") + assert nwGUI.newProject(projData={"projPath": fncProj}) is False + os.unlink(projFile) + + # An unreachable path should also fail + projPath = os.path.join(fncProj, "stuff", "stuff", "stuff") + assert nwGUI.newProject(projData={"projPath": projPath}) 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")) + +# END Test testGuiMain_NewProject + + +@pytest.mark.gui +def testGuiMain_ProjectTreeItems(qtbot, monkeypatch, nwGUI, fncProj, mockRnd): """Test handling of project tree items based on GUI focus states. """ monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - nwGUI.theProject.projTree.setSeed(42) - assert nwGUI.newProject({"projPath": fncProj}) is True - assert nwGUI.saveProject() is True + buildTestProject(nwGUI, fncProj) - sHandle = "0e17daca5f3e1" + sHandle = "000000000000f" assert nwGUI.openSelectedItem() is False # Project Tree has focus nwGUI.switchFocus(nwWidget.TREE) - nwGUI.projTabs.setCurrentIndex(0) + nwGUI.projStack.setCurrentIndex(0) with monkeypatch.context() as mp: mp.setattr(GuiProjectTree, "hasFocus", lambda *a: True) assert nwGUI.docEditor.docHandle() is None @@ -95,7 +132,7 @@ def testGuiMain_ProjectTreeItems(qtbot, monkeypatch, nwGUI, fncProj): assert nwGUI.closeDocument() is True # Novel Tree has focus - nwGUI.projTabs.setCurrentIndex(1) + nwGUI.projStack.setCurrentIndex(1) nwGUI.novelView.refreshTree(True) with monkeypatch.context() as mp: mp.setattr(GuiNovelTree, "hasFocus", lambda *a: True) @@ -111,12 +148,12 @@ def testGuiMain_ProjectTreeItems(qtbot, monkeypatch, nwGUI, fncProj): # Project Outline has focus nwGUI.switchFocus(nwWidget.OUTLINE) with monkeypatch.context() as mp: - mp.setattr(GuiOutline, "hasFocus", lambda *a: True) + mp.setattr(GuiOutline, "treeFocus", lambda *a: True) assert nwGUI.docEditor.docHandle() is None - actItem = nwGUI.projView.topLevelItem(0) + actItem = nwGUI.projView.outlineView.topLevelItem(0) chpItem = actItem.child(0) selItem = chpItem.child(0) - nwGUI.projView.setCurrentItem(selItem) + nwGUI.projView.outlineView.setCurrentItem(selItem) nwGUI._keyPressReturn() assert nwGUI.docEditor.docHandle() == sHandle assert nwGUI.closeDocument() is True @@ -127,7 +164,7 @@ def testGuiMain_ProjectTreeItems(qtbot, monkeypatch, nwGUI, fncProj): @pytest.mark.gui -def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, fncProj, refDir, outDir): +def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, fncProj, refDir, outDir, mockRnd): """Test the document editor. """ # Block message box @@ -139,15 +176,14 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, fncProj, refDir, outDir): monkeypatch.setattr(GuiDocEditor, "hasFocus", lambda *a: True) # Create new, save, close project - nwGUI.theProject.projTree.setSeed(42) - assert nwGUI.newProject({"projPath": fncProj}) + buildTestProject(nwGUI, fncProj) assert nwGUI.saveProject() assert nwGUI.closeProject() - assert len(nwGUI.theProject.projTree) == 0 - assert len(nwGUI.theProject.projTree._treeOrder) == 0 - assert len(nwGUI.theProject.projTree._treeRoots) == 0 - assert nwGUI.theProject.projTree.trashRoot() is None + assert len(nwGUI.theProject.tree) == 0 + 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" @@ -161,7 +197,7 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, fncProj, refDir, outDir): testFile = os.path.join(outDir, "guiEditor_Main_Initial_nwProject.nwx") compFile = os.path.join(refDir, "guiEditor_Main_Initial_nwProject.nwx") copyfile(projFile, testFile) - assert cmpFiles(testFile, compFile, [2, 6, 7, 8]) + assert cmpFiles(testFile, compFile, ignoreStart=XML_IGNORE) qtbot.wait(stepDelay) # qtbot.stopForInteraction() @@ -171,27 +207,27 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, fncProj, refDir, outDir): qtbot.wait(stepDelay) # Check that we loaded the data - assert len(nwGUI.theProject.projTree) == 8 - assert len(nwGUI.theProject.projTree._treeOrder) == 8 - assert len(nwGUI.theProject.projTree._treeRoots) == 4 - assert nwGUI.theProject.projTree.trashRoot() is None + 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 == "" - assert len(nwGUI.theProject.bookAuthors) == 0 - assert not nwGUI.theProject.spellCheck + assert nwGUI.theProject.bookTitle == "New Novel" + assert len(nwGUI.theProject.bookAuthors) == 1 + assert nwGUI.theProject.spellCheck is False # Check that tree items have been created - assert nwGUI.treeView._getTreeItem("73475cb40a568") is not None - assert nwGUI.treeView._getTreeItem("25fc0e7096fc6") is not None - assert nwGUI.treeView._getTreeItem("31489056e0916") is not None - assert nwGUI.treeView._getTreeItem("98010bd9270f9") is not None - assert nwGUI.treeView._getTreeItem("0e17daca5f3e1") is not None - assert nwGUI.treeView._getTreeItem("44cb730c42048") is not None - assert nwGUI.treeView._getTreeItem("71ee45a3c0db9") is not None - assert nwGUI.treeView._getTreeItem("811786ad1ae74") is not None + assert nwGUI.treeView._getTreeItem("0000000000008") is not None + assert nwGUI.treeView._getTreeItem("0000000000009") is not None + assert nwGUI.treeView._getTreeItem("000000000000a") is not None + assert nwGUI.treeView._getTreeItem("000000000000b") is not None + assert nwGUI.treeView._getTreeItem("000000000000c") is not None + assert nwGUI.treeView._getTreeItem("000000000000d") is not None + assert nwGUI.treeView._getTreeItem("000000000000e") is not None + assert nwGUI.treeView._getTreeItem("000000000000f") is not None nwGUI.mainMenu.aSpellCheck.setChecked(True) assert nwGUI.mainMenu._toggleSpellCheck() @@ -205,7 +241,7 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, fncProj, refDir, outDir): # Add a Character File nwGUI.switchFocus(nwWidget.TREE) nwGUI.treeView.clearSelection() - nwGUI.treeView._getTreeItem("71ee45a3c0db9").setSelected(True) + nwGUI.treeView._getTreeItem("000000000000a").setSelected(True) nwGUI.treeView.newTreeItem(nwItemType.FILE, None) assert nwGUI.openSelectedItem() @@ -227,7 +263,7 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, fncProj, refDir, outDir): # Add a Plot File nwGUI.switchFocus(nwWidget.TREE) nwGUI.treeView.clearSelection() - nwGUI.treeView._getTreeItem("44cb730c42048").setSelected(True) + nwGUI.treeView._getTreeItem("0000000000009").setSelected(True) nwGUI.treeView.newTreeItem(nwItemType.FILE, None) assert nwGUI.openSelectedItem() @@ -249,7 +285,7 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, fncProj, refDir, outDir): # Add a World File nwGUI.switchFocus(nwWidget.TREE) nwGUI.treeView.clearSelection() - nwGUI.treeView._getTreeItem("811786ad1ae74").setSelected(True) + nwGUI.treeView._getTreeItem("000000000000b").setSelected(True) nwGUI.treeView.newTreeItem(nwItemType.FILE, None) assert nwGUI.openSelectedItem() @@ -280,9 +316,9 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, fncProj, refDir, outDir): # Select the 'New Scene' file nwGUI.switchFocus(nwWidget.TREE) nwGUI.treeView.clearSelection() - nwGUI.treeView._getTreeItem("73475cb40a568").setExpanded(True) - nwGUI.treeView._getTreeItem("31489056e0916").setExpanded(True) - nwGUI.treeView._getTreeItem("0e17daca5f3e1").setSelected(True) + nwGUI.treeView._getTreeItem("0000000000008").setExpanded(True) + nwGUI.treeView._getTreeItem("000000000000d").setExpanded(True) + nwGUI.treeView._getTreeItem("000000000000f").setSelected(True) assert nwGUI.openSelectedItem() # Type something into the document @@ -417,8 +453,8 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, fncProj, refDir, outDir): # Open and view the edited document nwGUI.switchFocus(nwWidget.VIEWER) - assert nwGUI.openDocument("0e17daca5f3e1") - assert nwGUI.viewDocument("0e17daca5f3e1") + assert nwGUI.openDocument("000000000000f") + assert nwGUI.viewDocument("000000000000f") qtbot.wait(stepDelay) assert nwGUI.saveProject() assert nwGUI.closeDocViewer() @@ -427,11 +463,11 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, fncProj, refDir, outDir): # Check a Quick Create and Delete assert nwGUI.treeView.newTreeItem(nwItemType.FILE, None) newHandle = nwGUI.treeView.getSelectedHandle() - assert nwGUI.theProject.projTree["2858dcd1057d3"] is not None + assert nwGUI.theProject.tree["0000000000020"] is not None assert nwGUI.treeView.deleteItem() assert nwGUI.treeView.setSelectedHandle(newHandle) assert nwGUI.treeView.deleteItem() - assert nwGUI.theProject.projTree["2fca346db6561"] is not None # Trash + assert nwGUI.theProject.tree["0000000000024"] is not None # Trash assert nwGUI.saveProject() # Check the files @@ -439,32 +475,81 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, fncProj, refDir, outDir): testFile = os.path.join(outDir, "guiEditor_Main_Final_nwProject.nwx") compFile = os.path.join(refDir, "guiEditor_Main_Final_nwProject.nwx") copyfile(projFile, testFile) - assert cmpFiles(testFile, compFile, [2, 6, 7, 8]) + assert cmpFiles(testFile, compFile, ignoreStart=XML_IGNORE) - projFile = os.path.join(fncProj, "content", "031b4af5197ec.nwd") - testFile = os.path.join(outDir, "guiEditor_Main_Final_031b4af5197ec.nwd") - compFile = os.path.join(refDir, "guiEditor_Main_Final_031b4af5197ec.nwd") + projFile = os.path.join(fncProj, "content", "000000000000f.nwd") + testFile = os.path.join(outDir, "guiEditor_Main_Final_000000000000f.nwd") + compFile = os.path.join(refDir, "guiEditor_Main_Final_000000000000f.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, compFile) - projFile = os.path.join(fncProj, "content", "1a6562590ef19.nwd") - testFile = os.path.join(outDir, "guiEditor_Main_Final_1a6562590ef19.nwd") - compFile = os.path.join(refDir, "guiEditor_Main_Final_1a6562590ef19.nwd") + projFile = os.path.join(fncProj, "content", "0000000000020.nwd") + testFile = os.path.join(outDir, "guiEditor_Main_Final_0000000000020.nwd") + compFile = os.path.join(refDir, "guiEditor_Main_Final_0000000000020.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, compFile) - projFile = os.path.join(fncProj, "content", "0e17daca5f3e1.nwd") - testFile = os.path.join(outDir, "guiEditor_Main_Final_0e17daca5f3e1.nwd") - compFile = os.path.join(refDir, "guiEditor_Main_Final_0e17daca5f3e1.nwd") + projFile = os.path.join(fncProj, "content", "0000000000021.nwd") + testFile = os.path.join(outDir, "guiEditor_Main_Final_0000000000021.nwd") + compFile = os.path.join(refDir, "guiEditor_Main_Final_0000000000021.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, compFile) - projFile = os.path.join(fncProj, "content", "41cfc0d1f2d12.nwd") - testFile = os.path.join(outDir, "guiEditor_Main_Final_41cfc0d1f2d12.nwd") - compFile = os.path.join(refDir, "guiEditor_Main_Final_41cfc0d1f2d12.nwd") + projFile = os.path.join(fncProj, "content", "0000000000022.nwd") + testFile = os.path.join(outDir, "guiEditor_Main_Final_0000000000022.nwd") + compFile = os.path.join(refDir, "guiEditor_Main_Final_0000000000022.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, compFile) # qtbot.stopForInteraction() # END Test testGuiMain_Editing + + +@pytest.mark.gui +def testGuiMain_FocusFullMode(qtbot, monkeypatch, nwGUI, fncProj, mockRnd): + """Test toggling focus mode in main window. + """ + monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) + + buildTestProject(nwGUI, fncProj) + assert nwGUI.isFocusMode is False + + # Focus Mode + # ========== + + # No document open, so not allowing focus mode + assert nwGUI.toggleFocusMode() is False + + # Open a file in editor and viewer + assert nwGUI.openDocument("000000000000f") + assert nwGUI.viewDocument("000000000000f") + + # Enable focus mode + assert nwGUI.toggleFocusMode() is True + assert nwGUI.treePane.isVisible() is False + assert nwGUI.statusBar.isVisible() is False + assert nwGUI.mainMenu.isVisible() is False + assert nwGUI.viewsBar.isVisible() is False + assert nwGUI.splitView.isVisible() is False + + # Disable focus mode + assert nwGUI.toggleFocusMode() is True + assert nwGUI.treePane.isVisible() is True + assert nwGUI.statusBar.isVisible() is True + assert nwGUI.mainMenu.isVisible() is True + assert nwGUI.viewsBar.isVisible() is True + assert nwGUI.splitView.isVisible() is True + + # Full Screen Mode + # ================ + + assert nwGUI.mainConf.isFullScreen is False + nwGUI.toggleFullScreenMode() + assert nwGUI.mainConf.isFullScreen is True + nwGUI.toggleFullScreenMode() + assert nwGUI.mainConf.isFullScreen is False + + # qtbot.stopForInteraction() + +# END Test testGuiMain_FocusFullMode diff --git a/tests/test_gui/test_gui_mainmenu.py b/tests/test_gui/test_gui_mainmenu.py index 15282fda..801abd68 100644 --- a/tests/test_gui/test_gui_mainmenu.py +++ b/tests/test_gui/test_gui_mainmenu.py @@ -26,7 +26,7 @@ from PyQt5.QtCore import Qt from PyQt5.QtGui import QTextCursor, QTextBlock from PyQt5.QtWidgets import QAction, QFileDialog, QMessageBox -from tools import writeFile +from tools import writeFile, buildTestProject from novelwriter.gui.doceditor import GuiDocEditor from novelwriter.enum import nwDocAction, nwDocInsert @@ -48,7 +48,6 @@ def testGuiMenu_EditFormat(qtbot, monkeypatch, nwGUI, nwLipsum): # Test Document Action with No Project assert nwGUI.docEditor.docAction(nwDocAction.COPY) is False - nwGUI.theProject.projTree.setSeed(42) assert nwGUI.openProject(nwLipsum) is True qtbot.wait(stepDelay) @@ -381,7 +380,6 @@ def testGuiMenu_ContextMenus(qtbot, monkeypatch, nwGUI, nwLipsum): # Block message box monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - nwGUI.theProject.projTree.setSeed(42) assert nwGUI.openProject(nwLipsum) assert nwGUI.openDocument("4c4f28287af27") qtbot.wait(stepDelay) @@ -460,18 +458,17 @@ def testGuiMenu_ContextMenus(qtbot, monkeypatch, nwGUI, nwLipsum): @pytest.mark.gui -def testGuiMenu_Insert(qtbot, monkeypatch, nwGUI, fncDir, fncProj): +def testGuiMenu_Insert(qtbot, monkeypatch, nwGUI, fncDir, fncProj, mockRnd): """Test the Insert menu. """ # Block message box monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) monkeypatch.setattr(QMessageBox, "critical", lambda *a: QMessageBox.Yes) - nwGUI.theProject.projTree.setSeed(42) - assert nwGUI.newProject({"projPath": fncProj}) + buildTestProject(nwGUI, fncProj) - assert nwGUI.treeView._getTreeItem("0e17daca5f3e1") is not None - assert nwGUI.openDocument("0e17daca5f3e1") is True + assert nwGUI.treeView._getTreeItem("000000000000f") is not None + assert nwGUI.openDocument("000000000000f") is True nwGUI.docEditor.clear() # Test Faulty Inserts @@ -680,7 +677,7 @@ def testGuiMenu_Insert(qtbot, monkeypatch, nwGUI, fncDir, fncProj): assert not nwGUI.importDocument() # Open the document from before, and add some text to it - nwGUI.openDocument("0e17daca5f3e1") + nwGUI.openDocument("000000000000f") nwGUI.docEditor.setText("Bar") assert nwGUI.docEditor.getText() == "Bar" @@ -712,7 +709,7 @@ def testGuiMenu_Insert(qtbot, monkeypatch, nwGUI, fncDir, fncProj): 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", "0e17daca5f3e1.nwd") + assert theBits[1] == os.path.join(fncProj, "content", "000000000000f.nwd") # qtbot.stopForInteraction() diff --git a/tests/test_gui/test_gui_noveltree.py b/tests/test_gui/test_gui_noveltree.py index 57e6acec..2bf2df8a 100644 --- a/tests/test_gui/test_gui_noveltree.py +++ b/tests/test_gui/test_gui_noveltree.py @@ -37,7 +37,6 @@ def testGuiNovelTree_TreeItems(qtbot, monkeypatch, nwGUI, nwMinimal): monkeypatch.setattr(QMessageBox, "information", lambda *a: QMessageBox.Yes) nwGUI.openProject(nwMinimal) - nwGUI.theProject.projTree.setSeed(42) nwTree = nwGUI.novelView ## @@ -60,7 +59,7 @@ def testGuiNovelTree_TreeItems(qtbot, monkeypatch, nwGUI, nwMinimal): # Populate Tree ## - nwGUI.projTabs.setCurrentIndex(nwGUI.idxNovelView) + nwGUI.projStack.setCurrentIndex(nwGUI.idxNovelView) nwGUI.rebuildIndex() nwTree._populateTree() assert nwTree.topLevelItemCount() == 1 diff --git a/tests/test_gui/test_gui_outline.py b/tests/test_gui/test_gui_outline.py index 972e7456..49a86b16 100644 --- a/tests/test_gui/test_gui_outline.py +++ b/tests/test_gui/test_gui_outline.py @@ -19,20 +19,144 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ +import os +import time import pytest -from PyQt5.QtCore import Qt, QPoint -from PyQt5.QtWidgets import QAction, QTreeWidgetItem, QMessageBox +from tools import buildTestProject, writeFile -from novelwriter.enum import nwOutline +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import QWidget, QMessageBox, QAction -keyDelay = 2 -typeDelay = 1 -stepDelay = 20 +from novelwriter.enum import nwItemClass, nwOutline, nwView @pytest.mark.gui -def testGuiOutline_Main(qtbot, monkeypatch, nwGUI, nwLipsum): +def testGuiOutline_Main(qtbot, monkeypatch, nwGUI, fncDir): + """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) + + nwGUI.rebuildIndex() + nwGUI._changeView(nwView.OUTLINE) + + outlineMain = nwGUI.projView + outlineView = outlineMain.outlineView + outlineData = outlineMain.outlineData + outlineMenu = outlineMain.outlineBar.mColumns + + # Toggle scrollbars + nwGUI.mainConf.hideVScroll = True + nwGUI.mainConf.hideHScroll = True + nwGUI.projView.initOutline() + assert outlineView.verticalScrollBarPolicy() == Qt.ScrollBarAlwaysOff + assert outlineView.horizontalScrollBarPolicy() == Qt.ScrollBarAlwaysOff + assert outlineData.verticalScrollBarPolicy() == Qt.ScrollBarAlwaysOff + assert outlineData.horizontalScrollBarPolicy() == Qt.ScrollBarAlwaysOff + + nwGUI.mainConf.hideVScroll = False + nwGUI.mainConf.hideHScroll = False + nwGUI.projView.initOutline() + assert outlineView.verticalScrollBarPolicy() == Qt.ScrollBarAsNeeded + assert outlineView.horizontalScrollBarPolicy() == Qt.ScrollBarAsNeeded + assert outlineData.verticalScrollBarPolicy() == Qt.ScrollBarAsNeeded + assert outlineData.horizontalScrollBarPolicy() == Qt.ScrollBarAsNeeded + + # Check focus + with monkeypatch.context() as mp: + mp.setattr(QWidget, "hasFocus", lambda *a: True) + assert outlineMain.treeFocus() is True + + outlineMain.setTreeFocus() # Can't check. just ensures that it doesn't error + + # Option State + # ============ + pOptions = nwGUI.theProject.options + colNames = [h.name for h in nwOutline] + colItems = [h for h in nwOutline] + colWidth = {h: outlineView.DEF_WIDTH[h] for h in nwOutline} + colHidden = {h: outlineView.DEF_HIDDEN[h] for h in nwOutline} + + assert outlineView.topLevelItemCount() > 0 + + # Save header state not allowed + outlineView._lastBuild = 0 + outlineView._saveHeaderState() + assert pOptions.getValue("GuiOutline", "headerOrder", []) == [] + + # Allow saving header state + outlineView._lastBuild = time.time() + outlineView._saveHeaderState() + assert pOptions.getValue("GuiOutline", "headerOrder", []) == colNames + assert outlineView._treeOrder == colItems + assert outlineView._colWidth == colWidth + assert outlineView._colHidden == colHidden + + # Get default values + optItems = pOptions.getValue("GuiOutline", "headerOrder", []) + optWidth = pOptions.getValue("GuiOutline", "columnWidth", {}) + optHidden = pOptions.getValue("GuiOutline", "columnHidden", {}) + + # Add invalid column name + pOptions.setValue("GuiOutline", "headerOrder", optItems + ["blabla"]) + outlineView._loadHeaderState() + assert outlineView._treeOrder == colItems + assert outlineView._colHidden == colHidden + + # Add duplicate column name + pOptions.setValue("GuiOutline", "headerOrder", optItems + [optItems[-1]]) + outlineView._loadHeaderState() + assert outlineView._treeOrder == colItems + assert outlineView._colHidden == colHidden + + # Invalid column width data + pOptions.setValue("GuiOutline", "headerOrder", optItems) + pOptions.setValue("GuiOutline", "columnWidth", {"blabla": None}) + outlineView._loadHeaderState() + assert outlineView._treeOrder == colItems + assert outlineView._colHidden == colHidden + + # Invalid column width data + pOptions.setValue("GuiOutline", "headerOrder", optItems) + pOptions.setValue("GuiOutline", "columnWidth", optWidth) + pOptions.setValue("GuiOutline", "columnHidden", {"bloabla": None}) + outlineView._loadHeaderState() + assert outlineView._treeOrder == colItems + assert outlineView._colHidden == colHidden + + # Valid settings + pOptions.setValue("GuiOutline", "headerOrder", optItems) + pOptions.setValue("GuiOutline", "columnWidth", optWidth) + pOptions.setValue("GuiOutline", "columnHidden", optHidden) + outlineView._loadHeaderState() + assert outlineView._treeOrder == colItems + assert outlineView._colHidden == colHidden + + # Header Menu + # =========== + + # Trigger the menu entry for all hidden columns + for hItem in nwOutline: + if outlineView.DEF_HIDDEN[hItem]: + outlineMenu.actionMap[hItem].activate(QAction.Trigger) + + # Now no columns should be hidden + outlineView._saveHeaderState() + assert not any(pOptions.getValue("GuiOutline", "columnHidden", None).values()) + + # qtbot.stop() + +# END Test testGuiOutline_Main + + +@pytest.mark.gui +def testGuiOutline_Content(qtbot, monkeypatch, nwGUI, nwLipsum): """Test the outline view. """ # Block message box @@ -43,73 +167,109 @@ def testGuiOutline_Main(qtbot, monkeypatch, nwGUI, nwLipsum): nwGUI.mainConf.lastPath = nwLipsum nwGUI.rebuildIndex() - nwGUI.mainTabs.setCurrentIndex(nwGUI.idxTabProj) + nwGUI._changeView(nwView.OUTLINE) - assert nwGUI.projView.topLevelItemCount() > 0 + outlineMain = nwGUI.projView + outlineBar = outlineMain.outlineBar + outlineView = outlineMain.outlineView + outlineData = outlineMain.outlineData - # Context Menu - nwGUI.projView._headerRightClick(QPoint(1, 1)) - nwGUI.projView.headerMenu.actionMap[nwOutline.CCOUNT].activate(QAction.Trigger) - nwGUI.projView.headerMenu.close() - qtbot.mouseClick(nwGUI.projView, Qt.LeftButton) + lipHandle = "b3643d0f92e32" - nwGUI.projView._loadHeaderState() - assert not nwGUI.projView._colHidden[nwOutline.CCOUNT] + # Check defaults in dropdown list + assert outlineBar.novelValue.itemData(0) == lipHandle + assert outlineBar.novelValue.itemData(1) is None # Separator + assert outlineBar.novelValue.itemData(2) == "" # All novels + + # Add a second novel folder + newHandle = nwGUI.theProject.newRoot(nwItemClass.NOVEL) + nwGUI.treeView.revealNewTreeItem(newHandle) + + # Check new values in dropdown list + assert outlineBar.novelValue.itemData(0) == lipHandle + assert outlineBar.novelValue.itemData(1) == newHandle + assert outlineBar.novelValue.itemData(2) is None # Separator + assert outlineBar.novelValue.itemData(3) == "" # All novels + + # Add a bunch of files in a header order that hits all tree combos + docList = [ + ("Section 1", 4), ("Scene 1", 3), ("Chapter 1", 2), ("Part 1", 1), + ("Section 2", 4), ("Scene 2", 3), ("Chapter 2", 2), + ("Section 3", 4), ("Scene 3", 3), + ("Section 4", 4), + ] + for dTitle, hLevel in docList: + aHandle = nwGUI.theProject.newFile(dTitle, newHandle) + hHash = "#"*hLevel + writeFile(os.path.join(nwLipsum, "content", f"{aHandle}.nwd"), f"{hHash} {dTitle}\n\n") + nwGUI.treeView.revealNewTreeItem(aHandle) + + nwGUI.rebuildIndex() + + # Build the second novel + outlineBar.novelValue.setCurrentIndex(1) + outlineBar._refreshRequested() + + # Go back to Lipsum + outlineBar.novelValue.setCurrentIndex(0) + outlineBar._refreshRequested() + + # Check Details + # ============= # First Item - nwGUI.rebuildOutline() - selItem = nwGUI.projView.topLevelItem(0) - assert isinstance(selItem, QTreeWidgetItem) + outlineView.refreshTree() + selItem = outlineView.topLevelItem(0) - nwGUI.projView.setCurrentItem(selItem) - assert nwGUI.projMeta.titleLabel.text() == "Title" - assert nwGUI.projMeta.titleValue.text() == "Lorem Ipsum" - assert nwGUI.projMeta.fileValue.text() == "Lorem Ipsum" - assert nwGUI.projMeta.itemValue.text() == "Finished" + outlineView.setCurrentItem(selItem) + assert outlineData.titleLabel.text() == "Title" + assert outlineData.titleValue.text() == "Lorem Ipsum" + assert outlineData.fileValue.text() == "Lorem Ipsum" + assert outlineData.itemValue.text() == "Finished" - assert nwGUI.projMeta.cCValue.text() == "230" - assert nwGUI.projMeta.wCValue.text() == "40" - assert nwGUI.projMeta.pCValue.text() == "3" + assert outlineData.cCValue.text() == "230" + assert outlineData.wCValue.text() == "40" + assert outlineData.pCValue.text() == "3" # Scene One - actItem = nwGUI.projView.topLevelItem(1) + actItem = outlineView.topLevelItem(1) chpItem = actItem.child(0) selItem = chpItem.child(0) - nwGUI.projView.setCurrentItem(selItem) - tHandle, tLine = nwGUI.projView.getSelectedHandle() + outlineView.setCurrentItem(selItem) + tHandle, tLine = outlineView.getSelectedHandle() assert tHandle == "88243afbe5ed8" assert tLine == 0 - assert nwGUI.projMeta.titleLabel.text() == "Scene" - assert nwGUI.projMeta.titleValue.text() == "Scene One" - assert nwGUI.projMeta.fileValue.text() == "Scene One" - assert nwGUI.projMeta.itemValue.text() == "Finished" + assert outlineData.titleLabel.text() == "Scene" + assert outlineData.titleValue.text() == "Scene One" + assert outlineData.fileValue.text() == "Scene One" + assert outlineData.itemValue.text() == "Finished" # Click POV Link - assert nwGUI.projMeta.povKeyValue.text() == "
Bod" - nwGUI.projMeta._tagClicked("#pov=Bod") + assert outlineData.povKeyValue.text() == "Bod" + nwGUI.projView._tagClicked("Bod") assert nwGUI.docViewer.docHandle() == "4c4f28287af27" # Scene One, Section Two - actItem = nwGUI.projView.topLevelItem(1) + actItem = outlineView.topLevelItem(1) chpItem = actItem.child(0) scnItem = chpItem.child(0) selItem = scnItem.child(0) - nwGUI.projView.setCurrentItem(selItem) - tHandle, tLine = nwGUI.projView.getSelectedHandle() + outlineView.setCurrentItem(selItem) + tHandle, tLine = outlineView.getSelectedHandle() assert tHandle == "88243afbe5ed8" assert tLine == 12 - assert nwGUI.projMeta.titleLabel.text() == "Section" - assert nwGUI.projMeta.titleValue.text() == "Scene One, Section Two" - assert nwGUI.projMeta.fileValue.text() == "Scene One" - assert nwGUI.projMeta.itemValue.text() == "Finished" + assert outlineData.titleLabel.text() == "Section" + assert outlineData.titleValue.text() == "Scene One, Section Two" + assert outlineData.fileValue.text() == "Scene One" + assert outlineData.itemValue.text() == "Finished" - nwGUI.projView._treeDoubleClick(selItem, 0) + outlineView._treeDoubleClick(selItem, 0) assert nwGUI.docEditor.docHandle() == "88243afbe5ed8" - # qtbot.stopForInteraction() + # qtbot.stop() -# END Test testGuiOutline_Main +# END Test testGuiOutline_Content diff --git a/tests/test_gui/test_gui_projtree.py b/tests/test_gui/test_gui_projtree.py index 445bfb9a..258ad150 100644 --- a/tests/test_gui/test_gui_projtree.py +++ b/tests/test_gui/test_gui_projtree.py @@ -22,9 +22,8 @@ along with this program. If not, see . import pytest import os -from tools import writeFile +from tools import buildTestProject -from PyQt5.QtCore import QItemSelectionModel from PyQt5.QtWidgets import QAction, QMessageBox from novelwriter.guimain import GuiMain @@ -33,207 +32,420 @@ from novelwriter.enum import nwItemType, nwItemClass @pytest.mark.gui -def testGuiProjTree_TreeItems(qtbot, caplog, monkeypatch, nwGUI, nwMinimal): +def testGuiProjTree_NewItems(qtbot, caplog, monkeypatch, nwGUI, fncDir, mockRnd): """Test adding and removing items from the project tree. """ # Block message box - monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - monkeypatch.setattr(QMessageBox, "critical", lambda *a: QMessageBox.Yes) 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(GuiMain, "editItem", lambda *a: None) - nwGUI.theProject.projTree.setSeed(42) nwTree = nwGUI.treeView - ## - # Add New Items - ## + # Try to add item with no project + assert nwTree.newTreeItem(nwItemType.FILE) is False - # Try to add and move item with no project - assert not nwTree.newTreeItem(nwItemType.FILE, None) - assert not nwTree.moveTreeItem(1) + # Create a project + prjDir = os.path.join(fncDir, "project") + buildTestProject(nwGUI, prjDir) - # Open a project - assert nwGUI.openProject(nwMinimal) + # No itemType set + nwTree.clearSelection() + assert nwTree.newTreeItem(None) is False + + # Root Items + # ========== + + # No class set + assert nwTree.newTreeItem(nwItemType.ROOT) is False + + # Create root item + assert nwTree.newTreeItem(nwItemType.ROOT, nwItemClass.WORLD) is True + assert "0000000000010" in nwGUI.theProject.tree + + # File/Folder Items + # ================= # No location selected for new item nwTree.clearSelection() - assert not nwTree.newTreeItem(nwItemType.FILE, None) - assert not nwTree.newTreeItem(nwItemType.FOLDER, None) - assert nwTree.newTreeItem(nwItemType.FILE, nwItemClass.NOVEL) + caplog.clear() + assert nwTree.newTreeItem(nwItemType.FILE) is False + assert nwTree.newTreeItem(nwItemType.FOLDER) is False + assert "Did not find anywhere" in caplog.text - # No itemType set or ROOT, but no class - assert not nwTree.newTreeItem(None, None) - assert not nwTree.newTreeItem(nwItemType.ROOT, None) + # Create new folder as child of Novel folder + nwTree.setSelectedHandle("0000000000008") + assert nwTree.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 - # Select a location - chItem = nwTree._getTreeItem("a6d311a93600a") - nwTree.setCurrentItem(chItem, QItemSelectionModel.Current) - chItem.setExpanded(True) + # Add a new file in the new folder + nwTree.setSelectedHandle("0000000000011") + assert nwTree.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 - # Create new item with no class set (defaults to NOVEL) - assert nwTree.newTreeItem(nwItemType.FILE, None) - assert nwTree.newTreeItem(nwItemType.FOLDER, None) + # Add a new file next to the other new file + nwTree.setSelectedHandle("0000000000012") + assert nwTree.newTreeItem(nwItemType.FILE) is True + assert nwGUI.theProject.tree["0000000000013"].itemParent == "0000000000011" + assert nwGUI.theProject.tree["0000000000013"].itemRoot == "0000000000008" + assert nwGUI.theProject.tree["0000000000013"].itemClass == nwItemClass.NOVEL + assert nwGUI.openDocument("0000000000013") + assert nwGUI.docEditor.getText() == "## New Document\n\n" - # Check that we have the correct tree order - assert nwTree.getTreeFromHandle("a6d311a93600a") == [ - "a6d311a93600a", "f5ab3e30151e1", "8c659a11cd429", "44cb730c42048", "71ee45a3c0db9" - ] + # Add a new file to the characters folder + nwTree.setSelectedHandle("000000000000a") + assert nwTree.newTreeItem(nwItemType.FILE) is True + assert nwGUI.theProject.tree["0000000000014"].itemParent == "000000000000a" + assert nwGUI.theProject.tree["0000000000014"].itemRoot == "000000000000a" + assert nwGUI.theProject.tree["0000000000014"].itemClass == nwItemClass.CHARACTER + assert nwGUI.openDocument("0000000000014") + assert nwGUI.docEditor.getText() == "# New Note\n\n" - # Add roots - assert not nwTree.newTreeItem(nwItemType.ROOT, nwItemClass.WORLD) # Duplicate - assert nwTree.newTreeItem(nwItemType.ROOT, nwItemClass.CUSTOM) # Valid + # Make sure the sibling folder bug trap works + nwTree.setSelectedHandle("0000000000013") + nwGUI.theProject.tree["0000000000013"].setParent(None) # This should not happen + caplog.clear() + assert nwTree.newTreeItem(nwItemType.FILE) is False + assert "Internal error" in caplog.text + nwGUI.theProject.tree["0000000000013"].setParent("0000000000011") - # Change max depth and try to add a subfolder that is too deep - monkeypatch.setattr("novelwriter.constants.nwConst.MAX_DEPTH", 2) - chItem = nwTree._getTreeItem("71ee45a3c0db9") - nwTree.setCurrentItem(chItem, QItemSelectionModel.Current) - assert not nwTree.newTreeItem(nwItemType.FOLDER, None) + # Get the trash folder + nwTree._addTrashRoot() + trashHandle = nwGUI.theProject.trashFolder() + nwTree.setSelectedHandle(trashHandle) + assert nwTree.newTreeItem(nwItemType.FILE) is False + assert "Cannot add new files or folders to the Trash folder" in caplog.text - ## - # Move Items - ## + # Other Checks + # ============ - nwTree.setSelectedHandle("8c659a11cd429") + # Also check error handling in reveal function + assert nwTree.revealNewTreeItem("abc") is False - # Shift focus and try to move item - monkeypatch.setattr(GuiProjectTree, "hasFocus", lambda *a: False) - assert not nwTree.moveTreeItem(1) - assert nwTree.getTreeFromHandle("a6d311a93600a") == [ - "a6d311a93600a", "f5ab3e30151e1", "8c659a11cd429", "44cb730c42048", "71ee45a3c0db9" - ] - monkeypatch.setattr(GuiProjectTree, "hasFocus", lambda *a: True) - - # Move second item up twice (should give same result) - nwGUI.mainMenu.aMoveUp.activate(QAction.Trigger) - assert nwTree.getTreeFromHandle("a6d311a93600a") == [ - "a6d311a93600a", "8c659a11cd429", "f5ab3e30151e1", "44cb730c42048", "71ee45a3c0db9" - ] - nwGUI.mainMenu.aMoveUp.activate(QAction.Trigger) - assert nwTree.getTreeFromHandle("a6d311a93600a") == [ - "a6d311a93600a", "8c659a11cd429", "f5ab3e30151e1", "44cb730c42048", "71ee45a3c0db9" - ] - - # Move it back down four times (last two should be the same) - nwGUI.mainMenu.aMoveDown.activate(QAction.Trigger) - assert nwTree.getTreeFromHandle("a6d311a93600a") == [ - "a6d311a93600a", "f5ab3e30151e1", "8c659a11cd429", "44cb730c42048", "71ee45a3c0db9" - ] - nwGUI.mainMenu.aMoveDown.activate(QAction.Trigger) - assert nwTree.getTreeFromHandle("a6d311a93600a") == [ - "a6d311a93600a", "f5ab3e30151e1", "44cb730c42048", "8c659a11cd429", "71ee45a3c0db9" - ] - nwGUI.mainMenu.aMoveDown.activate(QAction.Trigger) - assert nwTree.getTreeFromHandle("a6d311a93600a") == [ - "a6d311a93600a", "f5ab3e30151e1", "44cb730c42048", "71ee45a3c0db9", "8c659a11cd429" - ] - nwGUI.mainMenu.aMoveDown.activate(QAction.Trigger) - assert nwTree.getTreeFromHandle("a6d311a93600a") == [ - "a6d311a93600a", "f5ab3e30151e1", "44cb730c42048", "71ee45a3c0db9", "8c659a11cd429" - ] - - # Move up twice, and undo - nwTree._lastMove = {} - nwGUI.mainMenu.aMoveUp.activate(QAction.Trigger) - nwGUI.mainMenu.aMoveUp.activate(QAction.Trigger) - nwGUI.mainMenu.aMoveUndo.activate(QAction.Trigger) - assert nwTree.getTreeFromHandle("a6d311a93600a") == [ - "a6d311a93600a", "f5ab3e30151e1", "44cb730c42048", "71ee45a3c0db9", "8c659a11cd429" - ] - - # Move a root item (top level items are different) twice - nwTree.flushTreeOrder() - assert nwGUI.theProject.projTree._treeOrder.index("9d5247ab588e0") == 10 - nwTree.setSelectedHandle("9d5247ab588e0") - - nwGUI.mainMenu.aMoveDown.activate(QAction.Trigger) - nwTree.flushTreeOrder() - assert nwGUI.theProject.projTree._treeOrder.index("9d5247ab588e0") == 11 - - nwGUI.mainMenu.aMoveDown.activate(QAction.Trigger) - nwTree.flushTreeOrder() - assert nwGUI.theProject.projTree._treeOrder.index("9d5247ab588e0") == 11 - - ## - # Delete and Trash - ## - - # Add some content to the new file - nwGUI.openDocument("73475cb40a568") - nwGUI.docEditor.setText("# Hello World\n") - nwGUI.saveDocument() - nwGUI.saveProject() - assert os.path.isfile(os.path.join(nwMinimal, "content", "73475cb40a568.nwd")) - - # Delete the items we added earlier - nwTree.clearSelection() - assert not nwTree.emptyTrash() # No folder yet - assert not nwTree.deleteItem(None) - assert not nwTree.deleteItem("1111111111111") - assert nwTree.deleteItem("73475cb40a568") # New File - assert nwTree.deleteItem("71ee45a3c0db9") # New Folder - assert nwTree.deleteItem("811786ad1ae74") # Custom Root - assert "73475cb40a568" in nwGUI.theProject.projTree._treeOrder - assert "71ee45a3c0db9" not in nwGUI.theProject.projTree._treeOrder - assert "811786ad1ae74" not in nwGUI.theProject.projTree._treeOrder - - # The file is in trash, empty it - assert os.path.isfile(os.path.join(nwMinimal, "content", "73475cb40a568.nwd")) - assert nwTree.emptyTrash() - assert not nwTree.emptyTrash() # Already empty - assert not os.path.isfile(os.path.join(nwMinimal, "content", "73475cb40a568.nwd")) - assert "73475cb40a568" not in nwGUI.theProject.projTree._treeOrder - - # Should not be allowed to add files and folders to Trash - trashHandle = nwGUI.theProject.projTree.trashRoot() - chItem = nwTree._getTreeItem(trashHandle) - nwTree.setCurrentItem(chItem, QItemSelectionModel.Current) - assert not nwTree.newTreeItem(nwItemType.FILE, None) - assert not nwTree.newTreeItem(nwItemType.FOLDER, None) - - # Close the project - nwGUI.closeProject() - - ## - # Orphaned Files - ## - - # Add an orphaned file - orphFile = os.path.join(nwMinimal, "content", "1234567890abc.nwd") - writeFile(orphFile, "# Hello World\n") - - # Open the project again - nwGUI.openProject(nwMinimal) - - # Check that the orphaned file was found and added to the tree - nwTree.flushTreeOrder() - assert "1234567890abc" in nwGUI.theProject.projTree._treeOrder - orItem = nwTree._getTreeItem("1234567890abc") - assert orItem.text(nwTree.C_NAME) == "Recovered File 1" - - ## - # Unexpected Error Handling - ## - - # Add an item with an invalid type - assert not nwTree.newTreeItem(nwItemType.NO_TYPE, nwItemClass.NOVEL) - assert "Failed to add new item" in caplog.messages[-1] - - # Add new file after one that has no parent handle - chItem = nwTree._getTreeItem("44cb730c42048") - nwTree.setCurrentItem(chItem, QItemSelectionModel.Current) - nwTree.theProject.projTree["44cb730c42048"]._parent = None - assert not nwTree.newTreeItem(nwItemType.FILE, nwItemClass.NOVEL) - nwTree.clearSelection() - - # Add a file with no parent, and fail to find a suitable parent item - monkeypatch.setattr("novelwriter.core.tree.NWTree.findRoot", lambda *a: None) - - assert not nwTree.newTreeItem(nwItemType.FILE, nwItemClass.NOVEL) - assert not nwTree.newTreeItem(nwItemType.FOLDER, nwItemClass.NOVEL) + # Add an item that cannot be displayed in the tree + nHandle = nwGUI.theProject.newFile("Test", None) + assert nwTree.revealNewTreeItem(nHandle) is False + # Clean up # qtbot.stopForInteraction() nwGUI.closeProject() -# END Test testGuiProjTree_TreeItems +# END Test testGuiProjTree_NewItems + + +@pytest.mark.gui +def testGuiProjTree_MoveItems(qtbot, monkeypatch, nwGUI, fncDir, 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(GuiMain, "editItem", lambda *a: None) + + nwTree = nwGUI.treeView + + # Try to move item with no project + assert nwTree.moveTreeItem(1) is False + + # Create a project + prjDir = os.path.join(fncDir, "project") + buildTestProject(nwGUI, prjDir) + + # Move Documents + # ============== + + # Add some files + nwTree.setSelectedHandle("000000000000d") + assert nwTree.newTreeItem(nwItemType.FILE) is True + assert nwTree.newTreeItem(nwItemType.FILE) is True + assert nwTree.newTreeItem(nwItemType.FILE) is True + assert nwTree.getTreeFromHandle("000000000000d") == [ + "000000000000d", "000000000000e", "000000000000f", + "0000000000010", "0000000000011", "0000000000012", + ] + + # Move item without focus + monkeypatch.setattr(GuiProjectTree, "hasFocus", lambda *a: False) + assert nwTree.moveTreeItem(1) is False + assert nwTree.getTreeFromHandle("000000000000d") == [ + "000000000000d", "000000000000e", "000000000000f", + "0000000000010", "0000000000011", "0000000000012", + ] + monkeypatch.setattr(GuiProjectTree, "hasFocus", lambda *a: True) + + # Move with no selections + nwTree.clearSelection() + assert nwTree.moveTreeItem(1) is False + + # Move second item up twice (should give same result) + nwTree.setSelectedHandle("000000000000f") + assert nwTree.moveTreeItem(-1) is True + assert nwTree.getTreeFromHandle("000000000000d") == [ + "000000000000d", "000000000000f", "000000000000e", + "0000000000010", "0000000000011", "0000000000012", + ] + assert nwTree.moveTreeItem(-1) is False + assert nwTree.getTreeFromHandle("000000000000d") == [ + "000000000000d", "000000000000f", "000000000000e", + "0000000000010", "0000000000011", "0000000000012", + ] + + # Restore via menu entry + nwGUI.mainMenu.aMoveDown.activate(QAction.Trigger) + assert nwTree.getTreeFromHandle("000000000000d") == [ + "000000000000d", "000000000000e", "000000000000f", + "0000000000010", "0000000000011", "0000000000012", + ] + + # Move fifth item down twice (should give same result) + nwTree.setSelectedHandle("0000000000011") + assert nwTree.moveTreeItem(1) is True + assert nwTree.getTreeFromHandle("000000000000d") == [ + "000000000000d", "000000000000e", "000000000000f", + "0000000000010", "0000000000012", "0000000000011", + ] + assert nwTree.moveTreeItem(1) is False + assert nwTree.getTreeFromHandle("000000000000d") == [ + "000000000000d", "000000000000e", "000000000000f", + "0000000000010", "0000000000012", "0000000000011", + ] + + # Restore via menu entry + nwGUI.mainMenu.aMoveUp.activate(QAction.Trigger) + assert nwTree.getTreeFromHandle("000000000000d") == [ + "000000000000d", "000000000000e", "000000000000f", + "0000000000010", "0000000000011", "0000000000012", + ] + + # Move down again, and restore via undo + nwTree.setSelectedHandle("0000000000011") + assert nwTree.moveTreeItem(1) is True + assert nwTree.getTreeFromHandle("000000000000d") == [ + "000000000000d", "000000000000e", "000000000000f", + "0000000000010", "0000000000012", "0000000000011", + ] + nwGUI.mainMenu.aMoveUndo.activate(QAction.Trigger) + assert nwTree.getTreeFromHandle("000000000000d") == [ + "000000000000d", "000000000000e", "000000000000f", + "0000000000010", "0000000000011", "0000000000012", + ] + + # Root Folder + # =========== + + nwTree.setSelectedHandle("0000000000008") + assert nwGUI.theProject.tree._treeOrder.index("0000000000008") == 0 + + # Move novel folder up + assert nwTree.moveTreeItem(-1) is False + assert nwGUI.theProject.tree._treeOrder.index("0000000000008") == 0 + + # Move novel folder down + assert nwTree.moveTreeItem(1) is True + assert nwGUI.theProject.tree._treeOrder.index("0000000000008") == 1 + + # Move novel folder up again + assert nwTree.moveTreeItem(-1) is True + assert nwGUI.theProject.tree._treeOrder.index("0000000000008") == 0 + + # Clean up + # qtbot.stopForInteraction() + 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. + """ + # 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(GuiMain, "editItem", lambda *a: None) + + nwTree = nwGUI.treeView + + # Try to run with no project + assert nwTree.emptyTrash() is False + assert nwTree.deleteItem() is False + + # Create a project + prjDir = os.path.join(fncDir, "project") + buildTestProject(nwGUI, prjDir) + + # Try emptying the trash already now, when there is no trash folder + assert nwTree.emptyTrash() is False + + # Add some files + nwTree.setSelectedHandle("000000000000d") + assert nwTree.newTreeItem(nwItemType.FILE) is True + assert nwTree.newTreeItem(nwItemType.FILE) is True + assert nwTree.newTreeItem(nwItemType.FILE) is True + assert nwTree.getTreeFromHandle("000000000000d") == [ + "000000000000d", "000000000000e", "000000000000f", + "0000000000010", "0000000000011", "0000000000012", + ] + + # Delete item without focus -> blocked + monkeypatch.setattr(GuiProjectTree, "hasFocus", lambda *a: False) + nwTree.setSelectedHandle("0000000000012") + assert nwTree.deleteItem() is False + monkeypatch.setattr(GuiProjectTree, "hasFocus", lambda *a: True) + + # No selection made + nwTree.clearSelection() + caplog.clear() + assert nwTree.deleteItem() is False + assert "no item to delete" in caplog.text + + # Not a valid handle + nwTree.clearSelection() + caplog.clear() + assert nwTree.deleteItem("0000000000000") is False + assert "Could not find tree item" in caplog.text + + # Delete Folder/Root + # ================== + + # Deleting non-empty folders is blocked + assert nwTree.deleteItem("0000000000008") is False # Novel Root + assert nwTree.deleteItem("000000000000a") is True # Character Root + + # Delete File + # =========== + + # Block adding trash folder + funcPointer = nwTree._addTrashRoot + nwTree._addTrashRoot = lambda *a: None + assert nwTree.deleteItem("0000000000012") is False + nwTree._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", + "0000000000010" + ] + trashHandle = nwGUI.theProject.tree.trashRoot() + assert nwTree.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" + ] + + # 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.newTreeItem(nwItemType.FOLDER) is True + nwTree.setSelectedHandle("0000000000014") + assert nwTree.newTreeItem(nwItemType.FILE) is True + assert nwTree.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.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._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 diff --git a/tests/test_gui/test_gui_statusbar.py b/tests/test_gui/test_gui_statusbar.py index 9d7c4efc..51e9c70b 100644 --- a/tests/test_gui/test_gui_statusbar.py +++ b/tests/test_gui/test_gui_statusbar.py @@ -22,21 +22,22 @@ along with this program. If not, see . import time import pytest +from tools import buildTestProject + from PyQt5.QtWidgets import QMessageBox from novelwriter.core import NWDoc -from novelwriter.enum import nwItemClass, nwState +from novelwriter.enum import nwState @pytest.mark.gui -def testGuiStatusBar_Main(qtbot, monkeypatch, nwGUI, fncProj): +def testGuiStatusBar_Main(qtbot, monkeypatch, nwGUI, fncProj, mockRnd): """Test the the various features of the status bar. """ monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) - nwGUI.theProject.projTree.setSeed(42) - assert nwGUI.newProject({"projPath": fncProj}) is True - cHandle = nwGUI.theProject.newFile("A Note", nwItemClass.CHARACTER, "71ee45a3c0db9") + buildTestProject(nwGUI, fncProj) + cHandle = nwGUI.theProject.newFile("A Note", "000000000000a") newDoc = NWDoc(nwGUI.theProject, cHandle) newDoc.writeDocument("# A Note\n\n") nwGUI.treeView.revealNewTreeItem(cHandle) @@ -90,10 +91,10 @@ def testGuiStatusBar_Main(qtbot, monkeypatch, nwGUI, fncProj): # Project Stats nwGUI.statusBar.mainConf.incNotesWCount = False nwGUI._updateStatusWordCount() - assert nwGUI.statusBar.statsText.text() == "Words: 6 (+6)" + assert nwGUI.statusBar.statsText.text() == "Words: 9 (+9)" nwGUI.statusBar.mainConf.incNotesWCount = True nwGUI._updateStatusWordCount() - assert nwGUI.statusBar.statsText.text() == "Words: 8 (+8)" + assert nwGUI.statusBar.statsText.text() == "Words: 11 (+11)" # qtbot.stopForInteraction() diff --git a/tests/test_gui/test_gui_theme.py b/tests/test_gui/test_gui_theme.py index 6adac08e..da7fc414 100644 --- a/tests/test_gui/test_gui_theme.py +++ b/tests/test_gui/test_gui_theme.py @@ -35,6 +35,7 @@ def testGuiTheme_Main(qtbot, monkeypatch, nwMinimal, tmpDir): """Test the theme and icon classes. """ # 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) diff --git a/tests/test_tools/test_tools_lipsum.py b/tests/test_tools/test_tools_lipsum.py index d4596546..629deb56 100644 --- a/tests/test_tools/test_tools_lipsum.py +++ b/tests/test_tools/test_tools_lipsum.py @@ -21,7 +21,7 @@ along with this program. If not, see . import pytest -from tools import getGuiItem +from tools import getGuiItem, buildTestProject from PyQt5.QtWidgets import QAction, QMessageBox @@ -29,7 +29,7 @@ from novelwriter.tools import GuiLipsum @pytest.mark.gui -def testToolLipsum_Main(qtbot, monkeypatch, nwGUI, fncProj): +def testToolLipsum_Main(qtbot, monkeypatch, nwGUI, fncProj, mockRnd): """Test the Lorem Ipsum tool. """ # Block message box @@ -40,9 +40,8 @@ def testToolLipsum_Main(qtbot, monkeypatch, nwGUI, fncProj): assert getGuiItem("GuiLipsum") is None # Create a new project - nwGUI.theProject.projTree.setSeed(42) - assert nwGUI.newProject({"projPath": fncProj}) is True - assert nwGUI.openDocument("0e17daca5f3e1") is True + buildTestProject(nwGUI, fncProj) + assert nwGUI.openDocument("000000000000f") is True assert len(nwGUI.docEditor.getText()) == 15 # Open the tool diff --git a/tests/test_tools/test_tools_projwizard.py b/tests/test_tools/test_tools_projwizard.py index 53f1cc79..8839964e 100644 --- a/tests/test_tools/test_tools_projwizard.py +++ b/tests/test_tools/test_tools_projwizard.py @@ -19,14 +19,14 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import pytest import os import sys +import pytest from tools import getGuiItem from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QFileDialog, QWizard, QMessageBox +from PyQt5.QtWidgets import QFileDialog, QWizard, QMessageBox, QDialog from novelwriter.enum import nwItemClass from novelwriter.tools.projwizard import ( @@ -41,8 +41,8 @@ stepDelay = 20 @pytest.mark.gui @pytest.mark.skipif(sys.platform.startswith("darwin"), reason="Not running on Darwin") -def testToolProjectWizard_Main(qtbot, monkeypatch, nwGUI, nwMinimal): - """Test the new project wizard. +def testToolProjectWizard_Handling(qtbot, monkeypatch, nwGUI, nwMinimal): + """Test the launch of the project wizard. Disabled for macOS because the test segfaults on QWizard.show() """ # Block message box @@ -55,173 +55,207 @@ def testToolProjectWizard_Main(qtbot, monkeypatch, nwGUI, nwMinimal): # New with a project open should cause an error assert nwGUI.openProject(nwMinimal) - assert not nwGUI.newProject() + with monkeypatch.context() as mp: + mp.setattr(nwGUI, "closeProject", lambda *a: False) + assert nwGUI.newProject() is False # Close project, but call with invalid path assert nwGUI.closeProject() with monkeypatch.context() as mp: mp.setattr(nwGUI, "showNewProjectDialog", lambda *a: None) - assert not nwGUI.newProject() + assert nwGUI.newProject() is False # Now, with an empty dictionary mp.setattr(nwGUI, "showNewProjectDialog", lambda *a: {}) - assert not nwGUI.newProject() + assert nwGUI.newProject() is False # Now, with a non-empty folder mp.setattr(nwGUI, "showNewProjectDialog", lambda *a: {"projPath": nwMinimal}) - assert not nwGUI.newProject() + assert nwGUI.newProject() is False ## - # Test the Wizard + # Test the Wizard Launching ## - monkeypatch.setattr(GuiProjectWizard, "exec_", lambda *a: None) nwGUI.mainConf.lastPath = " " + monkeypatch.setattr(GuiProjectWizard, "exec_", lambda *a: None) - nwGUI.closeProject() - nwGUI.showNewProjectDialog() + result = nwGUI.showNewProjectDialog() qtbot.waitUntil(lambda: getGuiItem("GuiProjectWizard") is not None, timeout=1000) nwWiz = getGuiItem("GuiProjectWizard") assert isinstance(nwWiz, GuiProjectWizard) nwWiz.show() + qtbot.wait(stepDelay) + qtbot.mouseClick(nwWiz.button(QWizard.CancelButton), Qt.LeftButton) + assert result is None - for wStep in range(4): - # This does not actually create the project, it just generates the - # dictionary that defines it. - - # Intro Page - introPage = nwWiz.currentPage() - assert isinstance(introPage, ProjWizardIntroPage) - assert not nwWiz.button(QWizard.NextButton).isEnabled() - - qtbot.wait(stepDelay) - for c in ("Test Minimal %d" % wStep): - qtbot.keyClick(introPage.projName, c, delay=typeDelay) - - qtbot.wait(stepDelay) - for c in "Minimal Novel": - qtbot.keyClick(introPage.projTitle, c, delay=typeDelay) - - qtbot.wait(stepDelay) - for c in "Jane Doe": - qtbot.keyClick(introPage.projAuthors, c, delay=typeDelay) - - # 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 - storagePage = nwWiz.currentPage() - assert isinstance(storagePage, ProjWizardFolderPage) - assert not nwWiz.button(QWizard.NextButton).isEnabled() - - if wStep == 0: - # Check invalid path first, the first time we reach here - monkeypatch.setattr(QFileDialog, "getExistingDirectory", lambda *a, **kw: "") - qtbot.wait(stepDelay) - qtbot.mouseClick(storagePage.browseButton, Qt.LeftButton, delay=100) - assert storagePage.projPath.text() == "" - - # Then, we always return nwMinimal as path - monkeypatch.setattr(QFileDialog, "getExistingDirectory", lambda *a, **kw: nwMinimal) - - qtbot.wait(stepDelay) - qtbot.mouseClick(storagePage.browseButton, Qt.LeftButton, delay=100) - projPath = os.path.join(nwMinimal, "Test Minimal %d" % wStep) - assert storagePage.projPath.text() == projPath - - # 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 - popPage = nwWiz.currentPage() - assert isinstance(popPage, ProjWizardPopulatePage) - assert nwWiz.button(QWizard.NextButton).isEnabled() - - qtbot.wait(stepDelay) - if wStep == 0: - popPage.popMinimal.setChecked(True) - elif wStep == 1: - popPage.popCustom.setChecked(True) - elif wStep == 2: - popPage.popCustom.setChecked(True) - elif wStep == 3: - popPage.popSample.setChecked(True) - - qtbot.wait(stepDelay) - qtbot.mouseClick(nwWiz.button(QWizard.NextButton), Qt.LeftButton) - - # Custom Page - if wStep == 1 or wStep == 2: - customPage = nwWiz.currentPage() - assert isinstance(customPage, ProjWizardCustomPage) - assert nwWiz.button(QWizard.NextButton).isEnabled() - - customPage.addPlot.setChecked(True) - customPage.addChar.setChecked(True) - customPage.addWorld.setChecked(True) - customPage.addTime.setChecked(True) - customPage.addObject.setChecked(True) - customPage.addEntity.setChecked(True) - - if wStep == 2: - customPage.numChapters.setValue(0) - customPage.numScenes.setValue(10) - customPage.chFolders.setChecked(False) - - qtbot.wait(stepDelay) - qtbot.mouseClick(nwWiz.button(QWizard.NextButton), Qt.LeftButton) - - # Final Page - finalPage = nwWiz.currentPage() - assert isinstance(finalPage, ProjWizardFinalPage) - assert nwWiz.button(QWizard.FinishButton).isEnabled() # But we don't click it - - # Check Data - projData = nwGUI._assembleProjectWizardData(nwWiz) - assert projData["projName"] == "Test Minimal %d" % wStep - assert projData["projTitle"] == "Minimal Novel" - assert projData["projAuthors"] == "Jane Doe" - assert projData["projPath"] == projPath - assert projData["popMinimal"] == (wStep == 0) - assert projData["popCustom"] == (wStep == 1 or wStep == 2) - assert projData["popSample"] == (wStep == 3) - if wStep == 1 or wStep == 2: - assert projData["addRoots"] == [ - nwItemClass.PLOT, - nwItemClass.CHARACTER, - nwItemClass.WORLD, - nwItemClass.TIMELINE, - nwItemClass.OBJECT, - nwItemClass.ENTITY, - ] - if wStep == 1: - assert projData["numChapters"] == 5 - assert projData["numScenes"] == 5 - assert projData["chFolders"] - else: - assert projData["numChapters"] == 0 - assert projData["numScenes"] == 10 - assert not projData["chFolders"] - else: - assert projData["addRoots"] == [] - assert projData["numChapters"] == 0 - assert projData["numScenes"] == 0 - assert not projData["chFolders"] - - # Restart the wizard for next iteration - nwWiz.restart() + with monkeypatch.context() as mp: + mp.setattr(GuiProjectWizard, "result", lambda *a: QDialog.Accepted) + result = nwGUI.showNewProjectDialog() + nwWiz.button(QWizard.CancelButton).click() + assert isinstance(result, dict) nwWiz.reject() nwWiz.close() # qtbot.stopForInteraction() -# END Test testToolProjectWizard_Main +# END Test testToolProjectWizard_Handling + + +@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): + """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) + + # Intro Page + # ========== + + introPage = nwWiz.currentPage() + assert isinstance(introPage, ProjWizardIntroPage) + assert not nwWiz.button(QWizard.NextButton).isEnabled() + + introPage.projName.setText("Test Wizard") + introPage.projTitle.setText("My Novel") + introPage.projAuthors.setPlainText("Jane Doe") + + # 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 + # =========== + + storagePage = nwWiz.currentPage() + assert isinstance(storagePage, ProjWizardFolderPage) + assert not nwWiz.button(QWizard.NextButton).isEnabled() + assert storagePage.errLabel.text() == "" + + # Set an invalid path + storagePage.projPath.setText(os.path.join(fncDir, "not", "a", "path")) + assert not nwWiz.button(QWizard.NextButton).isEnabled() + assert storagePage.errLabel.text().startswith("Error") + + # Set an existing path + storagePage.projPath.setText(fncDir) + assert not nwWiz.button(QWizard.NextButton).isEnabled() + assert storagePage.errLabel.text().startswith("Error") + + # Return a non-result from browse + with monkeypatch.context() as mp: + mp.setattr(QFileDialog, "getExistingDirectory", lambda *a, **k: "") + qtbot.mouseClick(storagePage.browseButton, Qt.LeftButton, delay=100) + assert storagePage.errLabel.text() == "" + + # Let the browse feature handle it + projPath = os.path.join(fncDir, "Test Wizard") + with monkeypatch.context() as mp: + mp.setattr(QFileDialog, "getExistingDirectory", lambda *a, **k: fncDir) + qtbot.mouseClick(storagePage.browseButton, Qt.LeftButton, delay=100) + + assert storagePage.projPath.text() == 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 + # ============= + + popPage = nwWiz.currentPage() + 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"): + popPage.popCustom.setChecked(True) + elif prjType.startswith("sample"): + popPage.popSample.setChecked(True) + + qtbot.wait(stepDelay) + qtbot.mouseClick(nwWiz.button(QWizard.NextButton), Qt.LeftButton) + + # Custom Page + # =========== + if prjType.startswith("custom"): + + customPage = nwWiz.currentPage() + assert isinstance(customPage, ProjWizardCustomPage) + assert nwWiz.button(QWizard.NextButton).isEnabled() + + customPage.addPlot.setChecked(True) + customPage.addChar.setChecked(True) + customPage.addWorld.setChecked(True) + customPage.addNotes.setChecked(True) + + if prjType == "custom2": + customPage.numChapters.setValue(0) + customPage.numScenes.setValue(10) + + qtbot.wait(stepDelay) + qtbot.mouseClick(nwWiz.button(QWizard.NextButton), Qt.LeftButton) + + # Final Page + # ========== + + finalPage = nwWiz.currentPage() + assert isinstance(finalPage, ProjWizardFinalPage) + assert nwWiz.button(QWizard.FinishButton).isEnabled() # But we don't click it + + # Check Data + # ========== + + projData = nwGUI._assembleProjectWizardData(nwWiz) + assert projData["projName"] == "Test Wizard" + assert projData["projTitle"] == "My Novel" + assert projData["projAuthors"] == "Jane Doe" + assert projData["projPath"] == projPath + assert projData["popMinimal"] == prjType.startswith("minimal") + assert projData["popCustom"] == prjType.startswith("custom") + assert projData["popSample"] == prjType.startswith("sample") + if prjType.startswith("custom"): + assert projData["addRoots"] == [ + nwItemClass.PLOT, + nwItemClass.CHARACTER, + nwItemClass.WORLD, + ] + if prjType == "custom1": + assert projData["numChapters"] == 5 + assert projData["numScenes"] == 5 + assert projData["addNotes"] is True + else: + assert projData["numChapters"] == 0 + assert projData["numScenes"] == 10 + assert projData["addNotes"] is True + else: + assert projData["addRoots"] == [] + assert projData["numChapters"] == 0 + assert projData["numScenes"] == 0 + assert projData["addNotes"] is False + + # Cleanup + nwWiz.reject() + nwWiz.close() + + # qtbot.stopForInteraction() + +# END Test testToolProjectWizard_Run diff --git a/tests/test_tools/test_tools_writingstats.py b/tests/test_tools/test_tools_writingstats.py index cffef9e6..b7358b20 100644 --- a/tests/test_tools/test_tools_writingstats.py +++ b/tests/test_tools/test_tools_writingstats.py @@ -23,8 +23,8 @@ import pytest import json import os -from tools import getGuiItem, writeFile from mock import causeOSError +from tools import getGuiItem, writeFile, buildTestProject from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QAction, QFileDialog, QMessageBox @@ -48,7 +48,7 @@ def testToolWritingStats_Main(qtbot, monkeypatch, nwGUI, fncDir, fncProj): monkeypatch.setattr(QMessageBox, "critical", lambda *a: QMessageBox.Yes) # Create a project to work on - assert nwGUI.newProject({"projPath": fncProj}) + buildTestProject(nwGUI, fncProj) qtbot.wait(100) assert nwGUI.saveProject() sessFile = os.path.join(fncProj, "meta", nwFiles.SESS_STATS) diff --git a/tests/tools.py b/tests/tools.py index bd417aae..fcdd3817 100644 --- a/tests/tools.py +++ b/tests/tools.py @@ -20,10 +20,13 @@ along with this program. If not, see . """ import os +import time import shutil from PyQt5.QtWidgets import qApp +XML_IGNORE = ("> By Jane DOe <<\n") + + aDoc = NWDoc(theProject, xHandle[7]) + aDoc.writeDocument("## %s\n\n" % theProject.tr("New Chapter")) + + aDoc = NWDoc(theProject, xHandle[8]) + aDoc.writeDocument("### %s\n\n" % theProject.tr("New Scene")) + + 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