diff --git a/docs/source/write_projects.rst b/docs/source/write_projects.rst index c25aa13b..a93589cf 100644 --- a/docs/source/write_projects.rst +++ b/docs/source/write_projects.rst @@ -7,9 +7,10 @@ Novel Projects A novelWriter project requires a dedicated folder for storing its files on the local file system. See the :ref:`a_tech` page for further details on how files are organised. -A new project can be created from the :guilabel:`Project` menu by selecting :guilabel:`New Project`. -A list of recently opened projects is maintained, and displayed in the :guilabel:`Open Project` -dialog. A project can be removed from this list by selecting it and pressing the :kbd:`Del` key. +A new project can be created from the :guilabel:`Project` menu by selecting +:guilabel:`New Project`. A list of recently opened projects is maintained, and displayed in the +:guilabel:`Open Project` dialog. A project can be removed from this list by selecting it and +pressing the :kbd:`Del` key. The project specific settings are available in :guilabel:`Project Settings` in the :guilabel:`Project` menu. See further details below in the :ref:`a_proj_settings` section. @@ -23,15 +24,15 @@ Project Roots Projects are structured into a set of top level folders called *root folders*. They are visible in the project tree at the left side of the main window. -The core novel files go into a root folder of type :guilabel:`Novel`. Other supporting files go into -the other root folders. These other root folder types are intended for your notes on the various -elements of your story. Using these is of course entirely optional. +The core novel files go into a root folder of type :guilabel:`Novel`. Other supporting files go +into the other root folders. These other root folder types are intended for your notes on the +various elements of your story. Using these is of course entirely optional. A new project will not have all of the root folders present, but you can add the ones you want from :guilabel:`Create Root Folder` in the :guilabel:`Project` menu. -The root folders are intended for the following use, but aside from the :guilabel:`Novel` folder, no -restrictions are enforced by the application. You can use them however you want. +The root folders are intended for the following use, but aside from the :guilabel:`Novel` folder, +no restrictions are enforced by the application. You can use them however you want. :guilabel:`Novel` This is the root folder of all text that goes into the final novel. This class of files have @@ -40,8 +41,8 @@ restrictions are enforced by the application. You can use them however you want. :guilabel:`Plot` This is the root folder where main plots can be outlined. It is optional, but adding at least - dummy files can be useful in order to tag plot elements for the Outline view. Tags in this folder - can be references using the ``@plot`` keyword. + dummy files can be useful in order to tag plot elements for the Outline view. Tags in this + folder can be references using the ``@plot`` keyword. :guilabel:`Characters` Character files go in this root folder. These are especially important if one wants to use the @@ -86,8 +87,8 @@ Deleted Documents ----------------- Deleted document files will be moved into a special :guilabel:`Trash` root folder. Files in the -trash folder can then be deleted permanently, either individually, or by emptying the trash from the -menu. Files in this folder are removed from the project index and cannot be referenced. +trash folder can then be deleted permanently, either individually, or by emptying the trash from +the menu. Files in this folder are removed from the project index and cannot be referenced. Folders and root folders can only be deleted when they are empty. Recursive deletion is not supported. A document file or a folder can be deleted from the :guilabel:`project` menu, or by @@ -106,25 +107,30 @@ folder, only files. If you need folders in it to organise your files, you can of ones there. You can drag any file to this folder and preserve its settings. The file will always be excluded -from the :guilabel:`Build Novel Project` builds. The file is also removed from the project index, so -the tags and references defined in it will not show up anywhere else. +from the :guilabel:`Build Novel Project` builds. The file is also removed from the project index, +so the tags and references defined in it will not show up anywhere else. .. _a_proj_roots_orph: -Orphaned Documents ------------------- +Recovered Documents +------------------- If novelWriter crashes or otherwise exits without saving the project state, or if you're using a -file synchronisation tool that runs out of sync, there may be files in the project folder that isn't -tracked in the core project file. These files, when discovered, are handled by the Orphaned -Documents routine. +file synchronisation tool that runs out of sync, there may be files in the project folder that +aren't tracked in the core project file. These files, when discovered, are recovered and added back +into the project if possible. -Files that are discovered in the project folder, but not in the project, will be re-added to the -project tree in a special :guilabel:`Orphaned Items` root folder next time the application is -started. These orphaned files will not have most of the meta data preserved, although novelWriter -will try to restore the file label it had in the project tree. Other information will have to be set -again, and the files moved back to the correct location in the project tree. +The discovered files are scanned for meta information that gives clues as to where the file may +previously have been located in the project. The project loading routines will try to put them back +as close as possible to this location if it still exists. Generally, it will be appended to the end +of the folder where it previously was located. If that folder doesn't exist, it will try to add it +to the correct root folder. If it cannot figure out which root folder is correct, the file will be +added to the :guilabel:`Novel` root folder. + +If the title of the file can be recovered, the word "Recovered:" will be added as a prefix. If the +title cannot be determined, the file will be named "Recovered File N" where N is a sequential +number. .. _a_proj_roots_lock: @@ -193,16 +199,16 @@ Word Counts A character, word and paragraph count is maintained for each file, as well as dor each section of a file defined by a header. The word count, and change of words in the current session, is displayed -in the footer of any document open in the editor, and all stats are shown in the details panel below -the project tree for any file selected. +in the footer of any document open in the editor, and all stats are shown in the details panel +below the project tree for any file selected. The word counts are not updated in real time, but runs in the background every five seconds for as long as the document is being actively edited. -A total project word count is displayed in the status bar. The total count depends on the sum of the -values in the project tree, which again depend on an up to date index. If the counts seem wrong, a -full project word recount can be initiated by rebuilding the project's index. Either form the -:guilabel:`Tools` menu, or by pressing :kbd:`F9`. +A total project word count is displayed in the status bar. The total count depends on the sum of +the values in the project tree, which again depend on an up to date index. If the counts seem +wrong, a full project word recount can be initiated by rebuilding the project's index. Either form +the :guilabel:`Tools` menu, or by pressing :kbd:`F9`. .. _a_proj_settings: @@ -242,9 +248,9 @@ many words exist in the entire project. Status and Importance Tabs -------------------------- -Each file of type "Novel" can be given a status level, signified by a coloured icon and each file of -the remaining types can be given an importance level. These are colour coded icons and labels that -can be applied to each file. +Each file of type "Novel" can be given a status level, signified by a coloured icon and each file +of the remaining types can be given an importance level. These are colour coded icons and labels +that can be applied to each file. These are purely there for the user's convenience, and you are not required to use them for any other feature to work. No other part of novelWriter accesses this information. The intention is to @@ -266,8 +272,8 @@ also be applied to exports. .. note:: A keyword cannot contain any spaces. The angle brackets are added by default, and when used in - the text are a part of the keyword to be replaced. This is to ensure that parts of the text isn't - unintentionally replaced by the content of the list. + the text are a part of the keyword to be replaced. This is to ensure that parts of the text + isn't unintentionally replaced by the content of the list. .. _a_proj_backup: @@ -275,22 +281,22 @@ also be applied to exports. Backup ====== -An automatic backup system is built into novelWriter. In order to use it, a backup path to where the -backup files are to be stored must to be provided in :guilabel:`Preferences`. +An automatic backup system is built into novelWriter. In order to use it, a backup path to where +the backup files are to be stored must to be provided in :guilabel:`Preferences`. Backups can be run automatically when a project is closed, which also implies it is run when the application is closed. Backups are date stamped zip files of the entire project folder, and are -stored in a subfolder of the backup path with the same name as the project :guilabel:`Working Title` -set in :ref:`a_proj_settings`. +stored in a subfolder of the backup path with the same name as the project :guilabel:`Working +Title` set in :ref:`a_proj_settings`. The backup feature, when configured, can also be run manually from the :guilabel:`Tools` menu. It is also possible to dissable automated backup for a given project in :guilabel:`Project Settings`. .. note:: - For the backup to be able to run, the :guilabel:`Working Title` must be set in :guilabel:`Project - Settings`. This value is used to generate the folder name for the zip files. Without it, the - backup will not run at all, but produce a warning message. + For the backup to be able to run, the :guilabel:`Working Title` must be set in + :guilabel:`Project Settings`. This value is used to generate the folder name for the zip files. + Without it, the backup will not run at all, but produce a warning message. .. _a_proj_stats: diff --git a/nw/assets/icons/typicons_colour_dark/icons.conf b/nw/assets/icons/typicons_colour_dark/icons.conf index 927da636..d4671820 100644 --- a/nw/assets/icons/typicons_colour_dark/icons.conf +++ b/nw/assets/icons/typicons_colour_dark/icons.conf @@ -29,7 +29,6 @@ cls_archive = delete.svg cls_trash = trash.svg proj_document = document-text.svg proj_folder = folder.svg -proj_orphan = warning.svg doc_h1 = heading1.svg doc_h2 = heading2.svg doc_h3 = heading3.svg diff --git a/nw/assets/icons/typicons_colour_dark/warning.svg b/nw/assets/icons/typicons_colour_dark/warning.svg deleted file mode 100644 index eef5a902..00000000 --- a/nw/assets/icons/typicons_colour_dark/warning.svg +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - diff --git a/nw/assets/icons/typicons_colour_light/icons.conf b/nw/assets/icons/typicons_colour_light/icons.conf index 1a97029f..4f9ffb6c 100644 --- a/nw/assets/icons/typicons_colour_light/icons.conf +++ b/nw/assets/icons/typicons_colour_light/icons.conf @@ -29,7 +29,6 @@ cls_archive = delete.svg cls_trash = trash.svg proj_document = document-text.svg proj_folder = folder.svg -proj_orphan = warning.svg doc_h1 = heading1.svg doc_h2 = heading2.svg doc_h3 = heading3.svg diff --git a/nw/assets/icons/typicons_colour_light/warning.svg b/nw/assets/icons/typicons_colour_light/warning.svg deleted file mode 100644 index ad55f227..00000000 --- a/nw/assets/icons/typicons_colour_light/warning.svg +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - diff --git a/nw/assets/icons/typicons_grey_dark/icons.conf b/nw/assets/icons/typicons_grey_dark/icons.conf index f9c8c49d..7dae46e9 100644 --- a/nw/assets/icons/typicons_grey_dark/icons.conf +++ b/nw/assets/icons/typicons_grey_dark/icons.conf @@ -29,7 +29,6 @@ cls_archive = delete.svg cls_trash = trash.svg proj_document = document-text.svg proj_folder = folder.svg -proj_orphan = warning.svg doc_h1 = heading1.svg doc_h2 = heading2.svg doc_h3 = heading3.svg diff --git a/nw/assets/icons/typicons_grey_dark/warning.svg b/nw/assets/icons/typicons_grey_dark/warning.svg deleted file mode 100644 index 3753c9f4..00000000 --- a/nw/assets/icons/typicons_grey_dark/warning.svg +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - diff --git a/nw/assets/icons/typicons_grey_light/icons.conf b/nw/assets/icons/typicons_grey_light/icons.conf index 732486a5..f21000cb 100644 --- a/nw/assets/icons/typicons_grey_light/icons.conf +++ b/nw/assets/icons/typicons_grey_light/icons.conf @@ -29,7 +29,6 @@ cls_archive = delete.svg cls_trash = trash.svg proj_document = document-text.svg proj_folder = folder.svg -proj_orphan = warning.svg doc_h1 = heading1.svg doc_h2 = heading2.svg doc_h3 = heading3.svg diff --git a/nw/assets/icons/typicons_grey_light/warning.svg b/nw/assets/icons/typicons_grey_light/warning.svg deleted file mode 100644 index 86f67929..00000000 --- a/nw/assets/icons/typicons_grey_light/warning.svg +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - diff --git a/nw/constants/constants.py b/nw/constants/constants.py index a768da9d..8a82f676 100644 --- a/nw/constants/constants.py +++ b/nw/constants/constants.py @@ -25,7 +25,9 @@ along with this program. If not, see . """ -from nw.constants.enum import nwItemClass, nwItemLayout, nwOutline +from nw.constants.enum import ( + nwItemClass, nwItemLayout, nwItemType, nwOutline +) class nwConst(): @@ -43,6 +45,9 @@ class nwConst(): SP_INTERNAL = "internal" SP_ENCHANT = "enchant" + # Check Lists + REG_TYPES = {nwItemType.ROOT, nwItemType.FOLDER, nwItemType.FILE} + # END Class nwConst class nwRegEx(): diff --git a/nw/core/document.py b/nw/core/document.py index 7ae8f4de..67cd1253 100644 --- a/nw/core/document.py +++ b/nw/core/document.py @@ -149,9 +149,9 @@ class NWDoc(): docMeta = "" else: docMeta = ( - f"%%~name: {self._theItem.itemName:s}\n" - f"%%~path: {self._theItem.itemParent:s}/{self._theItem.itemHandle:s}\n" - f"%%~kind: {self._theItem.itemClass.name:s}/{self._theItem.itemLayout.name:s}\n" + f"%%~name: {self._theItem.itemName}\n" + f"%%~path: {self._theItem.itemParent}/{self._theItem.itemHandle}\n" + f"%%~kind: {self._theItem.itemClass.name}/{self._theItem.itemLayout.name}\n" ) try: diff --git a/nw/core/project.py b/nw/core/project.py index 8bfbaa74..1af00534 100644 --- a/nw/core/project.py +++ b/nw/core/project.py @@ -1317,30 +1317,52 @@ class NWProject(): # Handle orphans aDoc = NWDoc(self, self.theParent) nOrph = 0 + noWhere = False for oHandle in orphanFiles: # Look for meta data oName = "" + oParent = None oClass = None oLayout = None if aDoc.openDocument(oHandle, showStatus=False, isOrphan=True) is not None: - oName, _, oClass, oLayout = aDoc.getMeta() + oName, oParent, oClass, oLayout = aDoc.getMeta() - if oName == "": + if oName: + oName = "Recovered: %s" % oName.lstrip("Recovered: ") + else: nOrph += 1 - oName = "Orphaned File %d" % nOrph + oName = "Recovered File %d" % nOrph + # Recover file meta data if oClass is None: - oClass = nwItemClass.NO_CLASS + oClass = nwItemClass.NOVEL + if oLayout is None: - oLayout = nwItemLayout.NO_LAYOUT + oLayout = nwItemLayout.NOTE + + if oParent is None or not self.projTree.handleExists(oParent): + oParent = self.projTree.findRoot(oClass) + if oParent is None: + oParent = self.projTree.findRoot(nwItemClass.NOVEL) + + # If the file still has no parent item, skip it + if oParent is None: + noWhere = True + continue orphItem = NWItem(self) orphItem.setName(oName) orphItem.setType(nwItemType.FILE) orphItem.setClass(oClass) orphItem.setLayout(oLayout) - self.projTree.append(oHandle, None, orphItem) + self.projTree.append(oHandle, oParent, orphItem) + + if noWhere: + self.makeAlert(( + "One or more orphaned files could not be added back into the " + "project. Make sure at least a Novel root folder exists." + ), nwAlert.WARN) return True diff --git a/nw/core/tree.py b/nw/core/tree.py index 43678b73..0c59fd7f 100644 --- a/nw/core/tree.py +++ b/nw/core/tree.py @@ -294,6 +294,11 @@ class NWTree(): tTree.append(tHandle) return tTree + def handleExists(self, tHandle): + """Check if a handle exists in the project. + """ + return tHandle in self._treeOrder + ## # Setters ## diff --git a/nw/gui/build.py b/nw/gui/build.py index 7902996d..c4a8fd8b 100644 --- a/nw/gui/build.py +++ b/nw/gui/build.py @@ -774,14 +774,13 @@ class GuiBuildNovel(QDialog): if not os.path.isdir(saveDir): saveDir = self.mainConf.homePath - if self.mainConf.showGUI: - dlgOpt = QFileDialog.Options() - dlgOpt |= QFileDialog.DontUseNativeDialog - savePath, _ = QFileDialog.getSaveFileName( - self, "Save Document As", savePath, options=dlgOpt - ) - if not savePath: - return False + dlgOpt = QFileDialog.Options() + dlgOpt |= QFileDialog.DontUseNativeDialog + savePath, _ = QFileDialog.getSaveFileName( + self, "Save Document As", savePath, options=dlgOpt + ) + if not savePath: + return False self.mainConf.setLastPath(savePath) @@ -887,19 +886,18 @@ class GuiBuildNovel(QDialog): errMsg = "Unknown format" # Report to user - if self.mainConf.showGUI: - if wSuccess: - self.theParent.makeAlert( - "%s file successfully written to:
%s" % ( - textFmt, savePath - ), nwAlert.INFO - ) - else: - self.theParent.makeAlert( - "Failed to write %s file. %s" % ( - textFmt, errMsg - ), nwAlert.ERROR - ) + if wSuccess: + self.theParent.makeAlert( + "%s file successfully written to:
%s" % ( + textFmt, savePath + ), nwAlert.INFO + ) + else: + self.theParent.makeAlert( + "Failed to write %s file. %s" % ( + textFmt, errMsg + ), nwAlert.ERROR + ) return wSuccess diff --git a/nw/gui/projtree.py b/nw/gui/projtree.py index 2c52c4cb..e1802a8b 100644 --- a/nw/gui/projtree.py +++ b/nw/gui/projtree.py @@ -61,8 +61,7 @@ class GuiProjectTree(QTreeWidget): self.theIndex = theParent.theIndex # Tree Settings - self.theMap = None - self.orphRoot = None + self.theMap = {} self.treeChanged = False self.ctxMenu = GuiProjectTreeMenu(self) @@ -145,11 +144,11 @@ class GuiProjectTree(QTreeWidget): ## def clearTree(self): - """Clear the GUI content and the related maps. + """Clear the GUI content and the related map. """ self.clear() self.theMap = {} - self.orphRoot = None + self.treeChanged = False return def newTreeItem(self, itemType, itemClass): @@ -165,6 +164,11 @@ class GuiProjectTree(QTreeWidget): 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 + # 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. @@ -173,21 +177,18 @@ class GuiProjectTree(QTreeWidget): if pItem is not None: itemClass = pItem.itemClass + # If class is still not set, alert the user and exit if itemClass is None: - if itemType is not None: - if itemType == nwItemType.FILE: - self.makeAlert( - "Please select a valid location in the tree to add a document.", - nwAlert.ERROR - ) - return False - elif itemType == nwItemType.FOLDER: - self.makeAlert( - "Please select a valid location in the tree to add a folder.", - nwAlert.ERROR - ) - return False - self.makeAlert("Failed to add new item.", nwAlert.BUG) + if itemType == nwItemType.FILE: + self.makeAlert( + "Please select a valid location in the tree to add the document.", + nwAlert.ERROR + ) + else: + self.makeAlert( + "Please select a valid location in the tree to add the folder.", + nwAlert.ERROR + ) return False # Everything is fine, we have what we need, so we proceed @@ -260,8 +261,7 @@ class GuiProjectTree(QTreeWidget): # Add the new item to the tree if tHandle is not None: self.revealNewTreeItem(tHandle, nHandle) - if self.mainConf.showGUI: - self.theParent.editItem(tHandle) + self.theParent.editItem(tHandle) return True @@ -270,6 +270,9 @@ class GuiProjectTree(QTreeWidget): """ nwItem = self.theProject.projTree[tHandle] trItem = self._addTreeItem(nwItem, nHandle) + if trItem is None: + return False + pHandle = nwItem.itemParent if pHandle is not None and pHandle in self.theMap: self.theMap[pHandle].setExpanded(True) @@ -285,7 +288,7 @@ class GuiProjectTree(QTreeWidget): logger.error("No project open") return False - if qApp.focusWidget() != self and self.mainConf.showGUI: + if qApp.focusWidget() != self: return False tHandle = self.getSelectedHandle() @@ -326,8 +329,6 @@ class GuiProjectTree(QTreeWidget): """ theList = [] for i in range(self.topLevelItemCount()): - if self.topLevelItem(i) == self.orphRoot: - continue theList = self._scanChildren(theList, self.topLevelItem(i), i) logger.debug("Saving project tree item order") self.theProject.setTreeOrder(theList) @@ -391,14 +392,12 @@ class GuiProjectTree(QTreeWidget): return False msgYes = self.theParent.askQuestion( - "Empty Trash", "Permanently delete %d file%s from Trash?" % ( - nTrash, "s" if nTrash > 1 else "" - ) + "Empty Trash", "Permanently delete %d file(s) from Trash?" % nTrash ) if not msgYes: return False - logger.verbose("Deleting %d files from Trash" % nTrash) + logger.verbose("Deleting %d file(s) from Trash" % nTrash) for tHandle in self.getTreeFromHandle(trashHandle): if tHandle == trashHandle: continue @@ -603,13 +602,11 @@ class GuiProjectTree(QTreeWidget): relevant values in the project and on the status bar. This call is a fast way of getting this number, and depends on the propagateCount function being called when it should to maintain - the correct count. Orphan folder is not included in the total. + the correct count. """ nWords = 0 for n in range(self.topLevelItemCount()): tItem = self.topLevelItem(n) - if tItem == self.orphRoot: - continue nWords += int(tItem.data(self.C_COUNT, Qt.UserRole)) self.theProject.setProjectWordCount(nWords) @@ -640,11 +637,7 @@ class GuiProjectTree(QTreeWidget): selected, return the first. """ selItem = self.selectedItems() - - if len(selItem) == 0: - return None - - if isinstance(selItem[0], QTreeWidgetItem): + if selItem: return selItem[0].data(self.C_NAME, Qt.UserRole) return None @@ -663,18 +656,21 @@ class GuiProjectTree(QTreeWidget): def setSelectedHandle(self, tHandle, doScroll=False): """Set a specific handle as the selected item. """ - if tHandle in self.theMap: - self.clearSelection() - self.theMap[tHandle].setSelected(True) + if tHandle not in self.theMap: + return False - selItems = self.selectedIndexes() - if selItems and doScroll: - self.scrollTo( - selItems[0], QAbstractItemView.PositionAtCenter - ) - return True + tItem = self._getTreeItem(tHandle) + if tItem is None: + return False - return False + self.clearSelection() + self.theMap[tHandle].setSelected(True) + + selItems = self.selectedIndexes() + if selItems and doScroll: + self.scrollTo(selItems[0], QAbstractItemView.PositionAtCenter) + + return True ## # Slots @@ -687,12 +683,12 @@ class GuiProjectTree(QTreeWidget): selItem = self.itemAt(clickPos) if isinstance(selItem, QTreeWidgetItem): tHandle = selItem.data(self.C_NAME, Qt.UserRole) - tItem = self.theProject.projTree[tHandle] self.setSelectedHandle(tHandle) # Just to be safe - - if self.ctxMenu.filterActions(tItem): - # Only open menu if any actions remain after filter - self.ctxMenu.exec_(self.viewport().mapToGlobal(clickPos)) + tItem = self.theProject.projTree[tHandle] + if tItem is not None: + if self.ctxMenu.filterActions(tItem): + # Only open menu if any actions remain after filter + self.ctxMenu.exec_(self.viewport().mapToGlobal(clickPos)) return @@ -764,17 +760,11 @@ class GuiProjectTree(QTreeWidget): logger.debug("Drag'n'drop of item %s accepted" % sHandle) self.propagateCount(sHandle, 0) QTreeWidget.dropEvent(self, theEvent) - - # Handle orphaned files differently than tracked files - if isNone: - self._moveOrphanedItem(sHandle, dHandle) - self._cleanOrphanedRoot() - else: - self._updateItemParent(sHandle) + self._updateItemParent(sHandle) # 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 and not onFree: + if not (isSame or onFree): logger.debug("Item %s class has been changed from %s to %s" % ( sHandle, snItem.itemClass.name, @@ -807,9 +797,7 @@ class GuiProjectTree(QTreeWidget): def _getTreeItem(self, tHandle): """Returns the QTreeWidgetItem of a given item handle. """ - if tHandle in self.theMap.keys(): - return self.theMap[tHandle] - return None + return self.theMap.get(tHandle, None) def _scanChildren(self, theList, theItem, theIndex): """This is a recursive function returning all items in a tree @@ -833,15 +821,15 @@ class GuiProjectTree(QTreeWidget): tClass = nwItem.itemClass newItem = QTreeWidgetItem([""]*4) - newItem.setText(self.C_NAME, "") - newItem.setText(self.C_COUNT, "0") + newItem.setText(self.C_NAME, "") + newItem.setText(self.C_COUNT, "0") newItem.setText(self.C_EXPORT, "") - newItem.setText(self.C_FLAGS, "") + newItem.setText(self.C_FLAGS, "") - newItem.setTextAlignment(self.C_NAME, Qt.AlignLeft | Qt.AlignVCenter) - newItem.setTextAlignment(self.C_COUNT, Qt.AlignRight | Qt.AlignVCenter) - newItem.setTextAlignment(self.C_EXPORT, Qt.AlignLeft | Qt.AlignVCenter) - newItem.setTextAlignment(self.C_FLAGS, Qt.AlignLeft | Qt.AlignVCenter) + newItem.setTextAlignment(self.C_NAME, Qt.AlignLeft) + newItem.setTextAlignment(self.C_COUNT, Qt.AlignRight) + newItem.setTextAlignment(self.C_EXPORT, Qt.AlignLeft) + newItem.setTextAlignment(self.C_FLAGS, Qt.AlignLeft) newItem.setData(self.C_NAME, Qt.UserRole, tHandle) newItem.setData(self.C_COUNT, Qt.UserRole, 0) @@ -854,8 +842,12 @@ class GuiProjectTree(QTreeWidget): elif nwItem.itemType == nwItemType.TRASH: self.addTopLevelItem(newItem) else: - self._addOrphanedRoot() - self.orphRoot.addChild(newItem) + self.makeAlert( + "There is nowhere to add item with name '%s'" % nwItem.itemName, nwAlert.ERROR + ) + del self.theMap[tHandle] + return None + else: byIndex = -1 if nHandle is not None and nHandle in self.theMap: @@ -898,39 +890,12 @@ class GuiProjectTree(QTreeWidget): trItem = self._addTreeItem( self.theProject.projTree[trashHandle] ) - trItem.setExpanded(True) - self._setTreeChanged(True) + if trItem is not None: + trItem.setExpanded(True) + self._setTreeChanged(True) return trItem - def _addOrphanedRoot(self): - """Add the special Orphaned Files root item to hold non-root - items with no parent set. - """ - if self.orphRoot is None: - newItem = QTreeWidgetItem([""]*4) - newItem.setText(self.C_NAME, "Orphaned Files") - newItem.setText(self.C_COUNT, "") - newItem.setText(self.C_EXPORT, "") - newItem.setText(self.C_FLAGS, "") - self.addTopLevelItem(newItem) - self.orphRoot = newItem - newItem.setExpanded(True) - newItem.setData(self.C_NAME, Qt.UserRole, "") - newItem.setData(self.C_COUNT, Qt.UserRole, 0) - newItem.setIcon(self.C_NAME, self.theTheme.getIcon("proj_orphan")) - - return - - def _cleanOrphanedRoot(self): - """Remove the special Orphaned Files root folder if it is empty. - """ - if self.orphRoot is not None: - if self.orphRoot.childCount() == 0: - self.takeTopLevelItem(self.indexOfTopLevelItem(self.orphRoot)) - self.orphRoot = None - return - def _updateItemParent(self, tHandle): """Update the parent handle of an item so that the information in the project is consistent with the treeView. @@ -951,27 +916,6 @@ class GuiProjectTree(QTreeWidget): return True - def _moveOrphanedItem(self, tHandle, dHandle): - """Move an Orphaned Item to a new dHandle parent item. This - function will set all the missing meta data based on the meta - data of the destination item. - """ - trItemS = self._getTreeItem(tHandle) - nwItemS = self.theProject.projTree[tHandle] - nwItemD = self.theProject.projTree[dHandle] - trItemP = trItemS.parent() - nwItemS.setClass(nwItemD.itemClass) - if trItemP is None: - logger.error("Failed to find new parent item of %s" % tHandle) - return False - - pHandle = trItemP.data(self.C_NAME, Qt.UserRole) - nwItemS.setParent(pHandle) - self.setTreeItemValues(tHandle) - self._setTreeChanged(True) - - return True - def _setTreeChanged(self, theState): """Set the tree change flag, and propagate to the project. """ @@ -1047,12 +991,10 @@ class GuiProjectTreeMenu(QMenu): inTrash = theItem.itemParent == trashHandle and trashHandle is not None isTrash = theItem.itemHandle == trashHandle and trashHandle is not None isFile = theItem.itemType == nwItemType.FILE - isOrph = isFile and theItem.itemParent is None - allowEdit = not (isTrash or isOrph) - allowNew = not (isTrash or inTrash or isOrph) + allowNew = not (isTrash or inTrash) - self.editItem.setVisible(allowEdit) + self.editItem.setVisible(not isTrash) self.openItem.setVisible(isFile) self.viewItem.setVisible(isFile) self.toggleExp.setVisible(isFile) diff --git a/nw/gui/theme.py b/nw/gui/theme.py index c2b4b9fd..cd691aa2 100644 --- a/nw/gui/theme.py +++ b/nw/gui/theme.py @@ -524,7 +524,6 @@ class GuiIcons: "cls_trash" : (QStyle.SP_DriveHDIcon, "drive-harddisk"), "proj_document" : (QStyle.SP_FileIcon, "x-office-document"), "proj_folder" : (QStyle.SP_DirIcon, "folder"), - "proj_orphan" : (QStyle.SP_MessageBoxWarning, "dialog-warning"), "proj_nwx" : (None, None), "status_lang" : (None, None), "status_time" : (None, None), diff --git a/nw/guimain.py b/nw/guimain.py index 4361ae8f..c03eceea 100644 --- a/nw/guimain.py +++ b/nw/guimain.py @@ -47,7 +47,7 @@ from nw.gui import ( GuiTheme, GuiWritingStats ) from nw.core import NWProject, NWDoc, NWIndex -from nw.constants import nwItemType, nwItemClass, nwAlert +from nw.constants import nwItemType, nwItemClass, nwAlert, nwConst from nw.common import getGuiItem logger = logging.getLogger(__name__) @@ -722,6 +722,12 @@ class GuiMain(QMainWindow): logger.warning("No item selected") return + tItem = self.theProject.projTree[tHandle] + if tItem is None: + return + if tItem.itemType not in nwConst.REG_TYPES: + return + logger.verbose("Requesting change to item %s" % tHandle) dlgProj = GuiItemEditor(self, self.theProject, tHandle) dlgProj.exec_() @@ -965,17 +971,16 @@ class GuiMain(QMainWindow): logger.error(msgLine) # Popup - if self.mainConf.showGUI: - msgBox = QMessageBox() - if theLevel == nwAlert.INFO: - msgBox.information(self, "Information", popMsg) - elif theLevel == nwAlert.WARN: - msgBox.warning(self, "Warning", popMsg) - elif theLevel == nwAlert.ERROR: - msgBox.critical(self, "Error", popMsg) - elif theLevel == nwAlert.BUG: - popMsg += "
This is a bug!" - msgBox.critical(self, "Internal Error", popMsg) + msgBox = QMessageBox() + if theLevel == nwAlert.INFO: + msgBox.information(self, "Information", popMsg) + elif theLevel == nwAlert.WARN: + msgBox.warning(self, "Warning", popMsg) + elif theLevel == nwAlert.ERROR: + msgBox.critical(self, "Error", popMsg) + elif theLevel == nwAlert.BUG: + popMsg += "
This is a bug!" + msgBox.critical(self, "Internal Error", popMsg) return @@ -1320,6 +1325,7 @@ class GuiMain(QMainWindow): self.openDocument(tHandle, changeFocus=False, doScroll=False) else: logger.verbose("Requested item %s is a folder" % tHandle) + return def _treeKeyPressReturn(self): diff --git a/tests/test_base_init.py b/tests/test_base_init.py index b691bfa1..9a6daedf 100644 --- a/tests/test_base_init.py +++ b/tests/test_base_init.py @@ -61,7 +61,6 @@ def testBaseInit_Options(monkeypatch, tmpDir): nwGUI = nw.main() assert nw.logger.getEffectiveLevel() == logging.WARNING assert nw.CONFIG.debugInfo is False - assert nw.CONFIG.showGUI is False assert nwGUI.closeMain() == "closeMain" # Defaults diff --git a/tests/test_core_project.py b/tests/test_core_project.py index 3abefbbc..57ee8f51 100644 --- a/tests/test_core_project.py +++ b/tests/test_core_project.py @@ -923,9 +923,9 @@ def testCoreProject_OrphanedFiles(dummyGUI, nwLipsum): # First Item with Meta Data oItem = theProject.projTree["636b6aa9b697b"] assert oItem is not None - assert oItem.itemName == "Mars" + assert oItem.itemName == "Recovered: Mars" assert oItem.itemHandle == "636b6aa9b697b" - assert oItem.itemParent is None + assert oItem.itemParent == "60bdf227455cc" assert oItem.itemClass == nwItemClass.WORLD assert oItem.itemType == nwItemType.FILE assert oItem.itemLayout == nwItemLayout.NOTE @@ -933,12 +933,12 @@ def testCoreProject_OrphanedFiles(dummyGUI, nwLipsum): # Second Item without Meta Data oItem = theProject.projTree["736b6aa9b697b"] assert oItem is not None - assert oItem.itemName == "Orphaned File 1" + assert oItem.itemName == "Recovered File 1" assert oItem.itemHandle == "736b6aa9b697b" - assert oItem.itemParent is None - assert oItem.itemClass == nwItemClass.NO_CLASS + assert oItem.itemParent == "b3643d0f92e32" + assert oItem.itemClass == nwItemClass.NOVEL assert oItem.itemType == nwItemType.FILE - assert oItem.itemLayout == nwItemLayout.NO_LAYOUT + assert oItem.itemLayout == nwItemLayout.NOTE assert theProject.saveProject(nwLipsum) assert theProject.closeProject() diff --git a/tests/test_gui_build.py b/tests/test_gui_build.py index 701312f8..fbc887e3 100644 --- a/tests/test_gui_build.py +++ b/tests/test_gui_build.py @@ -9,7 +9,7 @@ from shutil import copyfile from tools import cmpFiles, getGuiItem from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QAction, QMessageBox +from PyQt5.QtWidgets import QAction, QMessageBox, QFileDialog from nw.gui import GuiBuildNovel @@ -23,6 +23,8 @@ def testGuiBuild_Tool(qtbot, monkeypatch, nwGUI, nwLipsum, refDir, outDir): """ # Block message box monkeypatch.setattr(QMessageBox, "question", lambda *args: QMessageBox.Yes) + monkeypatch.setattr(QMessageBox, "information", lambda *args: QMessageBox.Yes) + monkeypatch.setattr(QFileDialog, "getSaveFileName", lambda a, b, c, **kwargs: (c, None)) # Check that we cannot open when there is no project nwGUI.mainMenu.aBuildProject.activate(QAction.Trigger) diff --git a/tests/test_gui_projtree.py b/tests/test_gui_projtree.py index 94726348..d49fa7a6 100644 --- a/tests/test_gui_projtree.py +++ b/tests/test_gui_projtree.py @@ -5,95 +5,133 @@ import pytest import os +from tools import writeFile + from PyQt5.QtCore import QItemSelectionModel from PyQt5.QtWidgets import QAction, QMessageBox from nw.constants import nwItemType, nwItemClass -keyDelay = 2 -typeDelay = 1 -stepDelay = 20 - @pytest.mark.gui -def testGuiProjTree_Main(qtbot, monkeypatch, nwGUI, nwMinimal): - """Test the project tree. +def testGuiProjTree_TreeItems(qtbot, caplog, monkeypatch, nwGUI, nwMinimal): + """Test adding and removing items from the project tree. """ # Block message box monkeypatch.setattr(QMessageBox, "question", lambda *args: QMessageBox.Yes) + monkeypatch.setattr("nw.guimain.GuiMain.editItem", lambda *args: None) nwGUI.theProject.projTree.setSeed(42) - assert nwGUI.openProject(nwMinimal) nwTree = nwGUI.treeView + ## + # Add New Items + ## + + # Try to add and move item with no project + assert not nwTree.newTreeItem(nwItemType.FILE, None) + assert not nwTree.moveTreeItem(1) + + # Open a project + assert nwGUI.openProject(nwMinimal) + # 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) + + # No itemType set or ROOT, but no class + assert not nwTree.newTreeItem(None, None) + assert not nwTree.newTreeItem(nwItemType.ROOT, None) # Select a location chItem = nwTree._getTreeItem("a6d311a93600a") nwTree.setCurrentItem(chItem, QItemSelectionModel.Current) chItem.setExpanded(True) - # Create new item with no class set + # Create new item with no class set (defaults to NOVEL) assert nwTree.newTreeItem(nwItemType.FILE, None) assert nwTree.newTreeItem(nwItemType.FOLDER, None) + # Check that we have the correct tree order + assert nwTree.getTreeFromHandle("a6d311a93600a") == [ + "a6d311a93600a", "f5ab3e30151e1", "8c659a11cd429", "44cb730c42048", "71ee45a3c0db9" + ] + # Add roots - assert not nwTree.newTreeItem(nwItemType.ROOT, None) # Defaults to NOVEL assert not nwTree.newTreeItem(nwItemType.ROOT, nwItemClass.WORLD) # Duplicate assert nwTree.newTreeItem(nwItemType.ROOT, nwItemClass.CUSTOM) # Valid - # Check that we have the correct tree order + # Change max depth and try to add a subfolder that is too deep + monkeypatch.setattr("nw.constants.nwConst.MAX_DEPTH", 2) + chItem = nwTree._getTreeItem("71ee45a3c0db9") + nwTree.setCurrentItem(chItem, QItemSelectionModel.Current) + assert not nwTree.newTreeItem(nwItemType.FOLDER, None) + + ## + # Move Items + ## + + nwTree.setSelectedHandle("8c659a11cd429") + + # Shift focus and try to move item + monkeypatch.setattr("PyQt5.QtWidgets.qApp.focusWidget", lambda: None) + assert not nwTree.moveTreeItem(1) assert nwTree.getTreeFromHandle("a6d311a93600a") == [ - "a6d311a93600a", "f5ab3e30151e1", "8c659a11cd429", "73475cb40a568", "44cb730c42048" + "a6d311a93600a", "f5ab3e30151e1", "8c659a11cd429", "44cb730c42048", "71ee45a3c0db9" ] + monkeypatch.setattr("PyQt5.QtWidgets.qApp.focusWidget", lambda: nwTree) # Move second item up twice (should give same result) - nwTree.setSelectedHandle("8c659a11cd429") nwGUI.mainMenu.aMoveUp.activate(QAction.Trigger) assert nwTree.getTreeFromHandle("a6d311a93600a") == [ - "a6d311a93600a", "8c659a11cd429", "f5ab3e30151e1", "73475cb40a568", "44cb730c42048" + "a6d311a93600a", "8c659a11cd429", "f5ab3e30151e1", "44cb730c42048", "71ee45a3c0db9" ] nwGUI.mainMenu.aMoveUp.activate(QAction.Trigger) assert nwTree.getTreeFromHandle("a6d311a93600a") == [ - "a6d311a93600a", "8c659a11cd429", "f5ab3e30151e1", "73475cb40a568", "44cb730c42048" + "a6d311a93600a", "8c659a11cd429", "f5ab3e30151e1", "44cb730c42048", "71ee45a3c0db9" ] - # Move it back down four times (last to should be the same) + # 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", "73475cb40a568", "44cb730c42048" + "a6d311a93600a", "f5ab3e30151e1", "8c659a11cd429", "44cb730c42048", "71ee45a3c0db9" ] nwGUI.mainMenu.aMoveDown.activate(QAction.Trigger) assert nwTree.getTreeFromHandle("a6d311a93600a") == [ - "a6d311a93600a", "f5ab3e30151e1", "73475cb40a568", "8c659a11cd429", "44cb730c42048" + "a6d311a93600a", "f5ab3e30151e1", "44cb730c42048", "8c659a11cd429", "71ee45a3c0db9" ] nwGUI.mainMenu.aMoveDown.activate(QAction.Trigger) assert nwTree.getTreeFromHandle("a6d311a93600a") == [ - "a6d311a93600a", "f5ab3e30151e1", "73475cb40a568", "44cb730c42048", "8c659a11cd429" + "a6d311a93600a", "f5ab3e30151e1", "44cb730c42048", "71ee45a3c0db9", "8c659a11cd429" ] nwGUI.mainMenu.aMoveDown.activate(QAction.Trigger) assert nwTree.getTreeFromHandle("a6d311a93600a") == [ - "a6d311a93600a", "f5ab3e30151e1", "73475cb40a568", "44cb730c42048", "8c659a11cd429" + "a6d311a93600a", "f5ab3e30151e1", "44cb730c42048", "71ee45a3c0db9", "8c659a11cd429" ] # Move a root item (top level items are different) twice nwTree.flushTreeOrder() - assert nwGUI.theProject.projTree._treeOrder.index("9d5247ab588e0") == 9 + 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") == 10 + assert nwGUI.theProject.projTree._treeOrder.index("9d5247ab588e0") == 11 nwGUI.mainMenu.aMoveDown.activate(QAction.Trigger) nwTree.flushTreeOrder() - assert nwGUI.theProject.projTree._treeOrder.index("9d5247ab588e0") == 10 + 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 @@ -102,11 +140,11 @@ def testGuiProjTree_Main(qtbot, monkeypatch, nwGUI, nwMinimal): assert not nwTree.deleteItem(None) assert not nwTree.deleteItem("1111111111111") assert nwTree.deleteItem("73475cb40a568") # New File - assert nwTree.deleteItem("44cb730c42048") # New Folder - assert nwTree.deleteItem("71ee45a3c0db9") # Custom Root + assert nwTree.deleteItem("71ee45a3c0db9") # New Folder + assert nwTree.deleteItem("811786ad1ae74") # Custom Root assert "73475cb40a568" in nwGUI.theProject.projTree._treeOrder - assert "44cb730c42048" not 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")) @@ -115,24 +153,55 @@ def testGuiProjTree_Main(qtbot, monkeypatch, nwGUI, nwMinimal): 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") - with open(orphFile, mode="w+", encoding="utf8") as outFile: - outFile.write("# Hello World\n") + writeFile(orphFile, "# Hello World\n") # Open the project again nwGUI.openProject(nwMinimal) # Check that the orphaned file was found and added to the tree - assert nwTree.orphRoot is not None nwTree.flushTreeOrder() - assert "1234567890abc" not in nwGUI.theProject.projTree._treeOrder + assert "1234567890abc" in nwGUI.theProject.projTree._treeOrder orItem = nwTree._getTreeItem("1234567890abc") - assert orItem.text(nwTree.C_NAME) == "Orphaned File 1" + 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"].itemParent = 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("nw.core.tree.NWTree.findRoot", lambda *args: None) + + assert not nwTree.newTreeItem(nwItemType.FILE, nwItemClass.NOVEL) + assert not nwTree.newTreeItem(nwItemType.FOLDER, nwItemClass.NOVEL) # qtbot.stopForInteraction() + nwGUI.closeProject() -# END Test testGuiProjTree_Main +# END Test testGuiProjTree_TreeItems