diff --git a/docs/source/interface.rst b/docs/source/interface.rst index 770b7d59..d0435abf 100644 --- a/docs/source/interface.rst +++ b/docs/source/interface.rst @@ -43,6 +43,27 @@ currently selected item. This panel also includes the latest paragraph and chara addition to the word count. +.. _a_ui_tree_dnd: + +Project Tree Drag and Drop +-------------------------- + +The project tree allows dragging and drop to a certain extent, but it does not allow bulk actions. +This is deliberate to avoid accidentally messing up your project. The project tree has no undo +function. + +Document files and their folders can be rearranged freely within their root folders. Novel files +cannot be moved out of the :guilabel:`Novel` folder, except to :guilabel:`Trash` and the +:guilabel:`Outtakes` folder. Note files can be moved freely anywhere. + +Folders cannot be moved at all outside their root tree. Neither can a folder containing files be +deleted. You must first delete the files. + +Root folders in the project tree cannot be dragged and dropped. However, if you want to reorder +them, you can move them up or down with respect to eachother from the :guilabel:`Tools` menu, or by +pressing :kbd:`Ctrl`:kbd:`Shift` and the :kbd:`Up` or :kbd:`Down` key. + + .. _a_ui_edit: Editing and Viewing Documents diff --git a/docs/source/projects.rst b/docs/source/projects.rst index cc3808ec..199ed904 100644 --- a/docs/source/projects.rst +++ b/docs/source/projects.rst @@ -87,7 +87,7 @@ 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. +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. @@ -96,6 +96,22 @@ A document file or a folder can be deleted from the :guilabel:`project` menu, or :kbd:`Ctrl`:kbd:`Del`. +.. _a_proj_roots_out: + +Archived Documents (Outtakes) +----------------------------- + +If you don't want to delete a file, or put it in the :guilabel:`Trash` folder where it may be +deleted, but still want it out of your main project tree, you can create an :guilabel:`Outtakes` +root folder from the :guilabel:`project` menu. This folder is similar to the :guilabel:`Trash` +folder in that it doesn't allow subfolders and creating new files. + +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, even with the setting to ignore the excluded files +option. 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 diff --git a/nw/assets/icons/typicons_colour_dark/icons.conf b/nw/assets/icons/typicons_colour_dark/icons.conf index c4167b90..8432bf1a 100644 --- a/nw/assets/icons/typicons_colour_dark/icons.conf +++ b/nw/assets/icons/typicons_colour_dark/icons.conf @@ -25,6 +25,7 @@ cls_timeline = calendar.svg cls_object = key.svg cls_entity = flag.svg cls_custom = star.svg +cls_archive = delete.svg cls_trash = trash.svg proj_document = document-text.svg proj_folder = folder.svg diff --git a/nw/assets/icons/typicons_colour_light/icons.conf b/nw/assets/icons/typicons_colour_light/icons.conf index be0b1853..eb0f9b2e 100644 --- a/nw/assets/icons/typicons_colour_light/icons.conf +++ b/nw/assets/icons/typicons_colour_light/icons.conf @@ -25,6 +25,7 @@ cls_timeline = calendar.svg cls_object = key.svg cls_entity = flag.svg cls_custom = star.svg +cls_archive = delete.svg cls_trash = trash.svg proj_document = document-text.svg proj_folder = folder.svg diff --git a/nw/assets/icons/typicons_grey_dark/icons.conf b/nw/assets/icons/typicons_grey_dark/icons.conf index fba00697..5fa6d680 100644 --- a/nw/assets/icons/typicons_grey_dark/icons.conf +++ b/nw/assets/icons/typicons_grey_dark/icons.conf @@ -25,6 +25,7 @@ cls_timeline = calendar.svg cls_object = key.svg cls_entity = flag.svg cls_custom = star.svg +cls_archive = delete.svg cls_trash = trash.svg proj_document = document-text.svg proj_folder = folder.svg diff --git a/nw/assets/icons/typicons_grey_light/icons.conf b/nw/assets/icons/typicons_grey_light/icons.conf index 62aba87f..4363e16f 100644 --- a/nw/assets/icons/typicons_grey_light/icons.conf +++ b/nw/assets/icons/typicons_grey_light/icons.conf @@ -25,6 +25,7 @@ cls_timeline = calendar.svg cls_object = key.svg cls_entity = flag.svg cls_custom = star.svg +cls_archive = delete.svg cls_trash = trash.svg proj_document = document-text.svg proj_folder = folder.svg diff --git a/nw/constants/constants.py b/nw/constants/constants.py index cd7bf350..a8859873 100644 --- a/nw/constants/constants.py +++ b/nw/constants/constants.py @@ -84,6 +84,7 @@ class nwLabels(): nwItemClass.OBJECT : "Objects", nwItemClass.ENTITY : "Entity", nwItemClass.CUSTOM : "Custom", + nwItemClass.ARCHIVE : "Outtakes", nwItemClass.TRASH : "Trash", } CLASS_FLAG = { @@ -96,6 +97,7 @@ class nwLabels(): nwItemClass.OBJECT : "O", nwItemClass.ENTITY : "E", nwItemClass.CUSTOM : "X", + nwItemClass.ARCHIVE : "U", nwItemClass.TRASH : "R", } CLASS_ICON = { @@ -108,6 +110,7 @@ class nwLabels(): nwItemClass.OBJECT : "cls_object", nwItemClass.ENTITY : "cls_entity", nwItemClass.CUSTOM : "cls_custom", + nwItemClass.ARCHIVE : "cls_archive", nwItemClass.TRASH : "cls_trash", } LAYOUT_NAME = { diff --git a/nw/constants/enum.py b/nw/constants/enum.py index ea40c9bf..49d76f5e 100644 --- a/nw/constants/enum.py +++ b/nw/constants/enum.py @@ -48,7 +48,8 @@ class nwItemClass(Enum): OBJECT = 6 ENTITY = 7 CUSTOM = 8 - TRASH = 9 + ARCHIVE = 9 + TRASH = 10 # END Enum nwItemClass diff --git a/nw/core/index.py b/nw/core/index.py index c542a459..6962dbb7 100644 --- a/nw/core/index.py +++ b/nw/core/index.py @@ -34,6 +34,7 @@ from time import time from nw.constants import ( nwFiles, nwKeyWords, nwItemType, nwItemClass, nwItemLayout, nwAlert ) +from nw.core.document import NWDoc from nw.core.tools import countWords logger = logging.getLogger(__name__) @@ -106,6 +107,8 @@ class NWIndex(): def deleteHandle(self, tHandle): """Delete all entries of a given document handle. """ + logger.debug("Removing item %s from the index" % tHandle) + delTags = [] for tTag in self.tagIndex: if self.tagIndex[tTag][1] == tHandle: @@ -121,6 +124,26 @@ class NWIndex(): return + def reIndexHandle(self, tHandle): + """Put a file back into the index. This is used when files are + moved from the archive or trash folders back into the active + project. + """ + logger.debug("Re-indexing item %s" % tHandle) + + tItem = self.theProject.projTree[tHandle] + if tItem is None: + return False + if tItem.itemType != nwItemType.FILE: + return False + + theDoc = NWDoc(self.theProject, self.theParent) + theText = theDoc.openDocument(tHandle, showStatus=False) + if theText: + self.scanText(tHandle, theText) + + return True + ## # Load and Save Index to/from File ## @@ -244,13 +267,24 @@ class NWIndex(): files before we save them, unless we're rebuilding the index. """ theItem = self.theProject.projTree[tHandle] + theRoot = self.theProject.projTree.getRootItem(tHandle) if theItem is None: + logger.error("Not indexing unknown item %s" % tHandle) return False if theItem.itemType != nwItemType.FILE: + logger.error("Not indexing non-file item %s" % tHandle) return False if theItem.parHandle == self.theProject.projTree.trashRoot(): + logger.error("Not indexing trash item %s" % tHandle) return False if theItem.itemLayout == nwItemLayout.NO_LAYOUT: + logger.error("Not indexing no-layout item %s" % tHandle) + return False + if theRoot is None: + logger.error("Not indexing homeless item %s" % tHandle) + return False + if theRoot.itemClass == nwItemClass.ARCHIVE: + logger.error("Not indexing archived item %s" % tHandle) return False itemClass = theItem.itemClass diff --git a/nw/core/tree.py b/nw/core/tree.py index 74080bd3..7d1bef46 100644 --- a/nw/core/tree.py +++ b/nw/core/tree.py @@ -67,6 +67,7 @@ class NWTree(): self._treeOrder = [] self._treeRoots = [] self._trashRoot = None + self._archRoot = None self._theLength = 0 self._theIndex = 0 self._treeChanged = False @@ -96,6 +97,9 @@ class NWTree(): if nwItem.itemType == nwItemType.ROOT: logger.verbose("Entry %s is a root item" % str(tHandle)) self._treeRoots.append(tHandle) + if nwItem.itemClass == nwItemClass.ARCHIVE: + logger.verbose("Entry %s is the archive folder" % str(tHandle)) + self._archRoot = tHandle if nwItem.itemType == nwItemType.TRASH: if self._trashRoot is None: @@ -211,6 +215,14 @@ class NWTree(): return self._trashRoot return None + 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. @@ -245,12 +257,10 @@ class NWTree(): if tItem is not None: for i in range(200): if tItem.parHandle is None: - return tHandle + return tItem else: tHandle = tItem.parHandle - tItem = self.__getitem__(tHandle) - if tItem is None: - return tHandle + tItem = self.__getitem__(tHandle) return None def getItemPath(self, tHandle): @@ -391,7 +401,12 @@ class NWTree(): return False self._treeOrder.remove(tHandle) self._theLength = len(self._treeOrder) + + if tHandle in self._treeRoots: + self._treeRoots.remove(tHandle) + self._setTreeChanged(True) + return True def __contains__(self, tHandle): diff --git a/nw/gui/build.py b/nw/gui/build.py index 9bc09e61..81af3fa4 100644 --- a/nw/gui/build.py +++ b/nw/gui/build.py @@ -478,6 +478,7 @@ class GuiBuildNovel(QDialog): noteRoot = noteFiles noteRoot &= tItem.itemType == nwItemType.ROOT noteRoot &= tItem.itemClass != nwItemClass.NOVEL + noteRoot &= tItem.itemClass != nwItemClass.ARCHIVE try: if noteRoot: @@ -539,7 +540,6 @@ class GuiBuildNovel(QDialog): * Items that appear in the TRASH folder or have parent set to None (orphaned files). """ - if theItem is None: return False @@ -551,6 +551,7 @@ class GuiBuildNovel(QDialog): isNone |= theItem.itemClass == nwItemClass.NO_CLASS isNone |= theItem.itemClass == nwItemClass.TRASH isNone |= theItem.parHandle == self.theProject.projTree.trashRoot() + isNone |= theItem.parHandle == self.theProject.projTree.archiveRoot() isNone |= theItem.parHandle is None isNote = theItem.itemLayout == nwItemLayout.NOTE isNovel = not isNone and not isNote diff --git a/nw/gui/mainmenu.py b/nw/gui/mainmenu.py index 99a4b5c9..c462c748 100644 --- a/nw/gui/mainmenu.py +++ b/nw/gui/mainmenu.py @@ -75,6 +75,9 @@ class GuiMainMenu(QMenuBar): ## 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 @@ -245,6 +248,7 @@ class GuiMainMenu(QMenuBar): self.rootItems[nwItemClass.OBJECT] = QAction("Object Root", self.rootMenu) self.rootItems[nwItemClass.ENTITY] = QAction("Entity Root", self.rootMenu) self.rootItems[nwItemClass.CUSTOM] = QAction("Custom Root", self.rootMenu) + self.rootItems[nwItemClass.ARCHIVE] = QAction("Outtakes Root", self.rootMenu) nCount = 0 for itemClass in self.rootItems.keys(): nCount += 1 # This forces the lambdas to be unique diff --git a/nw/gui/projtree.py b/nw/gui/projtree.py index a4a9b8f7..ab2fe52b 100644 --- a/nw/gui/projtree.py +++ b/nw/gui/projtree.py @@ -201,7 +201,17 @@ class GuiProjectTree(QTreeWidget): if pHandle == self.theProject.projTree.trashRoot(): self.makeAlert( - "Cannot add new files or folders to the trash folder.", nwAlert.ERROR + "Cannot add new files or folders to the %s folder." % ( + nwLabels.CLASS_NAME[nwItemClass.TRASH] + ), nwAlert.ERROR + ) + return False + + if pItem.itemClass == nwItemClass.ARCHIVE: + self.makeAlert( + "Cannot add new files or folders to the %s folder." % ( + nwLabels.CLASS_NAME[nwItemClass.ARCHIVE] + ), nwAlert.ERROR ) return False @@ -459,6 +469,7 @@ class GuiProjectTree(QTreeWidget): tIndex = self.indexOfTopLevelItem(trItemS) if trItemS.childCount() == 0: self.takeTopLevelItem(tIndex) + del self.theProject.projTree[tHandle] self.theParent.mainMenu.setAvailableRoot() self._setTreeChanged(True) else: @@ -669,17 +680,25 @@ class GuiProjectTree(QTreeWidget): isNote = snItem.itemLayout == nwItemLayout.NOTE onFile = dnItem.itemType == nwItemType.FILE isRoot = snItem.itemType == nwItemType.ROOT + onFree = dnItem.itemClass == nwItemClass.ARCHIVE + onFree |= dnItem.itemClass == nwItemClass.TRASH + onFree &= snItem.itemType == nwItemType.FILE isOnTop = self.dropIndicatorPosition() == QAbstractItemView.OnItem - if (isSame or isNone or isNote) and not (onFile and isOnTop) and not isRoot: + if (isSame or isNone or isNote or onFree) and not (onFile and isOnTop) and not isRoot: 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) - if not isSame: + + # 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: logger.debug("Item %s class has been changed from %s to %s" % ( sHandle, snItem.itemClass.name, @@ -687,7 +706,16 @@ class GuiProjectTree(QTreeWidget): )) snItem.setClass(dnItem.itemClass) self.setTreeItemValues(sHandle) + self.propagateCount(sHandle, wCount) + + # The items dropped into archive or trash should be removed + # from the project index + if onFree: + self.theParent.theIndex.deleteHandle(sHandle) + else: + self.theParent.theIndex.reIndexHandle(sHandle) + else: logger.debug("Drag'n'drop of item %s not accepted" % sHandle) self.makeAlert("The item cannot be moved to that location.", nwAlert.ERROR) @@ -928,14 +956,15 @@ class GuiProjectTreeMenu(QMenu): inTrash = theItem.parHandle == trashHandle isTrash = theItem.itemHandle == trashHandle isFile = theItem.itemType == nwItemType.FILE + isArch = theItem.itemClass == nwItemClass.ARCHIVE isOrph = isFile and theItem.parHandle is None showOpen = isFile showView = isFile showEdit = not isTrash and not isOrph showExport = isFile and not inTrash and not isOrph - showNewFile = not isTrash and not inTrash and not isOrph - showNewFolder = not isTrash and not inTrash and not isOrph + showNewFile = not isTrash and not inTrash and not isOrph and not isArch + showNewFolder = not isTrash and not inTrash and not isOrph and not isArch showDelete = not isTrash showEmpty = isTrash diff --git a/nw/gui/theme.py b/nw/gui/theme.py index a368eaf6..fd2826a0 100644 --- a/nw/gui/theme.py +++ b/nw/gui/theme.py @@ -514,6 +514,7 @@ class GuiIcons: "cls_object" : (QStyle.SP_DriveHDIcon, "drive-harddisk"), "cls_entity" : (QStyle.SP_DriveHDIcon, "drive-harddisk"), "cls_custom" : (QStyle.SP_DriveHDIcon, "drive-harddisk"), + "cls_archive" : (QStyle.SP_DriveHDIcon, "drive-harddisk"), "cls_trash" : (QStyle.SP_DriveHDIcon, "drive-harddisk"), "proj_document" : (QStyle.SP_FileIcon, "x-office-document"), "proj_folder" : (QStyle.SP_DirIcon, "folder"), diff --git a/sample/ToC.json b/sample/ToC.json index 4e6ff3d1..c00f8735 100644 --- a/sample/ToC.json +++ b/sample/ToC.json @@ -29,6 +29,11 @@ "NOVEL", "Chapter Two" ], + [ + "content/8a5deb88c0e97.nwd", + "NOVEL", + "Old File" + ], [ "content/96b68994dfa3d.nwd", "NOVEL", diff --git a/sample/ToC.txt b/sample/ToC.txt index da23e664..92237107 100644 --- a/sample/ToC.txt +++ b/sample/ToC.txt @@ -10,6 +10,7 @@ content/636b6aa9b697b.nwd NOVEL Making a Scene content/6a2d6d5f4f401.nwd NOVEL Chapter One content/88706ddc78b1b.nwd NOVEL Chapter Two + content/8a5deb88c0e97.nwd NOVEL Old File content/96b68994dfa3d.nwd NOVEL A Note on Structure content/974e400180a99.nwd NOVEL Page content/ae7339df26ded.nwd NOVEL We Found John! diff --git a/sample/content/8a5deb88c0e97.nwd b/sample/content/8a5deb88c0e97.nwd new file mode 100644 index 00000000..2ed2609f --- /dev/null +++ b/sample/content/8a5deb88c0e97.nwd @@ -0,0 +1,4 @@ +%%~ 8a5deb88c0e97:6827118336ac1:NOVEL:SCENE:Old File +### Discarded Scene + +If you have files you no longer want in your main project, you can move them to the “Outtakes” 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. diff --git a/sample/nwProject.nwx b/sample/nwProject.nwx index a1d6fe7f..05c459a5 100644 --- a/sample/nwProject.nwx +++ b/sample/nwProject.nwx @@ -1,13 +1,13 @@ - + Sample Project Sample Project Jane Smith Jay Doh - 684 - 125 - 33351 + 713 + 138 + 35134 False @@ -15,8 +15,8 @@ True 636b6aa9b697b 636b6aa9b697b - 1022 - 646 + 1071 + 695 376 B @@ -46,7 +46,7 @@ Main - + Novel ROOT @@ -119,7 +119,7 @@ 1811 318 8 - 1880 + 1364 Another Scene @@ -137,13 +137,13 @@ Interlude FILE NOVEL - Finished + New True UNNUMBERED 633 101 3 - 1238 + 0 A Note on Structure @@ -262,7 +262,26 @@ 1 45 - + + Outtakes + ROOT + ARCHIVE + None + True + + + Old File + FILE + NOVEL + 1st Draft + True + SCENE + 315 + 55 + 1 + 322 + + Trash TRASH TRASH @@ -276,9 +295,9 @@ New True SCENE - 30 - 6 - 1 + 0 + 0 + 0 36