From b0a0ca089f605ab2a5763b9c768f099ce79b7f52 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Mon, 14 Nov 2022 16:59:51 +0100 Subject: [PATCH] Rewrite index class to use sequential title keys rather than line numbers --- novelwriter/common.py | 4 +- novelwriter/constants.py | 1 - novelwriter/core/index.py | 135 +++++++++++++++++++++-------------- novelwriter/gui/noveltree.py | 18 ++--- novelwriter/gui/outline.py | 33 ++++----- novelwriter/gui/projtree.py | 10 +-- novelwriter/guimain.py | 28 +++++--- 7 files changed, 133 insertions(+), 96 deletions(-) diff --git a/novelwriter/common.py b/novelwriter/common.py index c712015a..5db4752d 100644 --- a/novelwriter/common.py +++ b/novelwriter/common.py @@ -154,11 +154,11 @@ def isHandle(value): def isTitleTag(value): - """Check if a string is a valid title string. + """Check if a string is a valid title tag string. """ if not isinstance(value, str): return False - if len(value) != 7: + if len(value) != 5: return False if not value.startswith("T"): return False diff --git a/novelwriter/constants.py b/novelwriter/constants.py index 87293a52..3d13d1f2 100644 --- a/novelwriter/constants.py +++ b/novelwriter/constants.py @@ -61,7 +61,6 @@ class nwHeaders: H_VALID = ("H0", "H1", "H2", "H3", "H4") H_LEVEL = {"H0": 0, "H1": 1, "H2": 2, "H3": 3, "H4": 4} - TT_NONE = "T000000" # END Class nwHeaders diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py index cf9cc002..e2152ba3 100644 --- a/novelwriter/core/index.py +++ b/novelwriter/core/index.py @@ -41,6 +41,8 @@ from novelwriter.common import ( logger = logging.getLogger(__name__) +TT_NONE = "T0000" + class NWIndex: """This class holds the entire index for a given project. The index @@ -281,50 +283,58 @@ class NWIndex: def _scanActive(self, tHandle, theItem, theText, itemTags): """Scan an active document for meta data. """ - nTitle = 0 - findHeader = True - theLines = theText.splitlines() + nTitle = 0 # Line Number of the previous title + cTitle = TT_NONE # Tag of the current title + pTitle = TT_NONE # Tag of the previous title + firstHeader = True # First header has been seen + theLines = theText.splitlines() for nLine, aLine in enumerate(theLines, start=1): - if len(aLine.strip()) == 0: + if aLine.strip() == "": continue if aLine.startswith("#"): - if findHeader: - hDepth, _ = self._splitHeading(aLine) - if hDepth != "H0": - theItem.setMainHeading(hDepth) - findHeader = False + hDepth, hText = self._splitHeading(aLine) + if hDepth == "H0": + continue - isTitle = self._indexTitle(tHandle, aLine, nLine) - if isTitle and nLine > 0: + if firstHeader: + theItem.setMainHeading(hDepth) + firstHeader = False + + cTitle = self._itemIndex.addItemHeading(tHandle, nLine, hDepth, hText) + if cTitle != TT_NONE: if nTitle > 0: + # We have a new title, so we need to count the words of the previous one lastText = "\n".join(theLines[nTitle-1:nLine-1]) - self._indexWordCounts(tHandle, lastText, nTitle) + self._indexWordCounts(tHandle, lastText, pTitle) nTitle = nLine + pTitle = cTitle elif aLine.startswith("@"): - self._indexKeyword(tHandle, aLine, nTitle, theItem.itemClass, itemTags) + if cTitle != TT_NONE: + self._indexKeyword(tHandle, aLine, cTitle, theItem.itemClass, itemTags) elif aLine.startswith("%"): - if nTitle > 0: + if cTitle != TT_NONE: toCheck = aLine[1:].lstrip() synTag = toCheck[:9].lower() tLen = len(aLine) cLen = len(toCheck) cOff = tLen - cLen if synTag == "synopsis:": - self._indexSynopsis(tHandle, aLine[cOff+9:].strip(), nTitle) + sText = aLine[cOff+9:].strip() + self._itemIndex.setHeadingSynopsis(tHandle, cTitle, sText) # Count words for remaining text after last heading - if nTitle > 0: + if pTitle != TT_NONE: lastText = "\n".join(theLines[nTitle-1:]) - self._indexWordCounts(tHandle, lastText, nTitle) + self._indexWordCounts(tHandle, lastText, pTitle) # Also count words on a page with no titles - if nTitle == 0: - self._indexWordCounts(tHandle, theText, nTitle) + if cTitle == TT_NONE: + self._indexWordCounts(tHandle, theText, cTitle) # Prune no longer used tags for tTag, isActive in itemTags.items(): @@ -362,34 +372,14 @@ class NWIndex: return "H2", aLine[4:].strip() return "H0", "" - def _indexTitle(self, tHandle, aLine, nTitle): - """Save information about the title and its location in the - file to the index. - """ - hDepth, hText = self._splitHeading(aLine) - if hDepth == "H0": - return False - - sTitle = f"T{nTitle:06d}" - self._itemIndex.addItemHeading(tHandle, sTitle, hDepth, hText) - return True - - def _indexWordCounts(self, tHandle, theText, nTitle): + def _indexWordCounts(self, tHandle, theText, sTitle): """Count text stats and save the counts to the index. """ - sTitle = f"T{nTitle:06d}" cC, wC, pC = countWords(theText) - self._itemIndex.setHeadingCounts(tHandle, sTitle, cC, wC, pC) + 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}" - self._itemIndex.setHeadingSynopsis(tHandle, sTitle, theText) - return - - def _indexKeyword(self, tHandle, aLine, nTitle, itemClass, itemTags): + def _indexKeyword(self, tHandle, aLine, sTitle, itemClass, itemTags): """Validate and save the information about a reference to a tag 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 @@ -404,7 +394,6 @@ class NWIndex: logger.warning("Skipping invalid keyword '%s' in '%s'", theBits[0], tHandle) return - sTitle = f"T{nTitle:06d}" if theBits[0] == nwKeyWords.TAG_KEY: tagName = theBits[1] self._tagsIndex.add(tagName, tHandle, sTitle, itemClass) @@ -506,6 +495,14 @@ class NWIndex: """ return self._itemIndex[tHandle] + def getItemHeader(self, tHandle, sTitle): + """Get the header entry for a specific item and heading. + """ + tItem = self._itemIndex[tHandle] + if isinstance(tItem, IndexItem): + return tItem[sTitle] + return None + def getNovelWordCount(self, skipExcl=True): """Count the number of words in the novel project. """ @@ -649,6 +646,8 @@ class TagsIndex: control of the keys. """ + __slots__ = ("_tags") + def __init__(self): self._tags = {} return @@ -695,7 +694,7 @@ class TagsIndex: def tagHeading(self, tagKey): """Get the heading of a given tag. """ - return self._tags.get(tagKey, {}).get("heading", nwHeaders.TT_NONE) + return self._tags.get(tagKey, {}).get("heading", TT_NONE) def tagClass(self, tagKey): """Get the class of a given tag. @@ -754,6 +753,8 @@ class ItemIndex: IndexHeading object for each header of the text. """ + __slots__ = ("_project", "_items") + def __init__(self, project): self._project = project self._items = {} @@ -844,13 +845,15 @@ class ItemIndex: # Setters ## - def addItemHeading(self, tHandle, sTitle, hDepth, hText): - """Set the main heading level of an item. + def addItemHeading(self, tHandle, lineNo, hDepth, hText): + """Add a heading to an item. """ if tHandle in self._items: tItem = self._items[tHandle] - tItem.addHeading(IndexHeading(sTitle, hDepth, hText)) - return + sTitle = tItem.nextHeading() + tItem.addHeading(IndexHeading(sTitle, lineNo, hDepth, hText)) + return sTitle + return TT_NONE def setHeadingCounts(self, tHandle, sTitle, cC, wC, pC): """Set the character, word and paragraph counts of a heading @@ -921,14 +924,16 @@ class IndexItem: must be reset each time the item is re-indexed. """ + __slots__ = ("_handle", "_item", "_headings", "_headings", "_count") + def __init__(self, tHandle, tItem): self._handle = tHandle self._item = tItem self._headings = {} - self._index = 0 + self._count = 0 # Add a placeholder heading - self._headings[nwHeaders.TT_NONE] = IndexHeading(nwHeaders.TT_NONE) + self._headings[TT_NONE] = IndexHeading(TT_NONE) return @@ -951,8 +956,8 @@ class IndexItem: """Add a heading to the item. Also remove the placeholder entry if it exists. """ - if nwHeaders.TT_NONE in self._headings: - self._headings.pop(nwHeaders.TT_NONE) + if TT_NONE in self._headings: + self._headings.pop(TT_NONE) self._headings[tHeading.key] = tHeading return @@ -1011,6 +1016,12 @@ class IndexItem: tags.append(tag) return tags + def nextHeading(self): + """Return the next heading key to be used. + """ + self._count += 1 + return f"T{self._count:04d}" + ## # Pack/Unpack ## @@ -1056,8 +1067,14 @@ class IndexHeading: of all references made under each heading. """ - def __init__(self, key, level="H0", title=""): + __slots__ = ( + "_key", "_line", "_level", "_title", "_charCount", "_wordCount", + "_paraCount", "_synopsis", "_tag", "_refs", + ) + + def __init__(self, key, line=0, level="H0", title=""): self._key = key + self._line = line self._level = level self._title = title @@ -1082,6 +1099,10 @@ class IndexHeading: def key(self): return self._key + @property + def line(self): + return self._line + @property def level(self): return self._level @@ -1125,6 +1146,12 @@ class IndexHeading: self._level = level return + def setLine(self, line): + """Set the line number of a heading. + """ + self._line = max(0, checkInt(line, 0)) + 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. @@ -1166,6 +1193,7 @@ class IndexHeading: return { "level": self._level, "title": self._title, + "line": self._line, "tag": self._tag, "cCount": self._charCount, "wCount": self._wordCount, @@ -1187,6 +1215,7 @@ class IndexHeading: self.setLevel(data.get("level", "H0")) self._title = str(data.get("title", "")) self._tag = str(data.get("tag", "")) + self.setLine(data.get("line", 0)) self.setCounts( data.get("cCount", 0), data.get("wCount", 0), diff --git a/novelwriter/gui/noveltree.py b/novelwriter/gui/noveltree.py index e3134c8c..bd796db7 100644 --- a/novelwriter/gui/noveltree.py +++ b/novelwriter/gui/noveltree.py @@ -40,7 +40,6 @@ from PyQt5.QtWidgets import ( ) from novelwriter.enum import nwDocMode, nwItemClass, nwOutline -from novelwriter.common import checkInt from novelwriter.constants import nwHeaders, nwKeyWords, nwLabels, trConst logger = logging.getLogger(__name__) @@ -60,7 +59,7 @@ class GuiNovelView(QWidget): # Signals for user interaction with the novel tree selectedItemChanged = pyqtSignal(str) - openDocumentRequest = pyqtSignal(str, Enum, int, str) + openDocumentRequest = pyqtSignal(str, Enum, str) def __init__(self, mainGui): super().__init__(parent=mainGui) @@ -543,14 +542,11 @@ class GuiNovelTree(QTreeWidget): selected, return the first. """ selItem = self.selectedItems() - tHandle = None - tLine = 0 if selItem: tHandle = selItem[0].data(self.C_TITLE, self.D_HANDLE) sTitle = selItem[0].data(self.C_TITLE, self.D_TITLE) - tLine = checkInt(sTitle[1:], 1) - 1 - - return tHandle, tLine + return tHandle, sTitle + return None, None def setLastColType(self, colType, doRefresh=True): """Change the content type of the last column and rebuild. @@ -609,11 +605,11 @@ class GuiNovelTree(QTreeWidget): if not isinstance(selItem, QTreeWidgetItem): return - tHandle, _ = self.getSelectedHandle() + tHandle, sTitle = self.getSelectedHandle() if tHandle is None: return - self.novelView.openDocumentRequest.emit(tHandle, nwDocMode.VIEW, -1, "") + self.novelView.openDocumentRequest.emit(tHandle, nwDocMode.VIEW, sTitle or "") return @@ -655,8 +651,8 @@ class GuiNovelTree(QTreeWidget): clicked, and send it to the main gui class for opening in the document editor. """ - tHandle, tLine = self.getSelectedHandle() - self.novelView.openDocumentRequest.emit(tHandle, nwDocMode.EDIT, tLine, "") + tHandle, sTitle = self.getSelectedHandle() + self.novelView.openDocumentRequest.emit(tHandle, nwDocMode.EDIT, sTitle or "") return ## diff --git a/novelwriter/gui/outline.py b/novelwriter/gui/outline.py index 3ccba658..dd0bb3d2 100644 --- a/novelwriter/gui/outline.py +++ b/novelwriter/gui/outline.py @@ -55,6 +55,7 @@ logger = logging.getLogger(__name__) class GuiOutlineView(QWidget): loadDocumentTagRequest = pyqtSignal(str, Enum) + openDocumentRequest = pyqtSignal(str, Enum, str) def __init__(self, mainGui): super().__init__(parent=mainGui) @@ -375,15 +376,16 @@ class GuiOutlineTree(QTreeWidget): hiddenStateChanged = pyqtSignal() activeItemChanged = pyqtSignal(str, str) - def __init__(self, theOutline): - super().__init__(parent=theOutline) + def __init__(self, outlineView): + super().__init__(parent=outlineView) logger.debug("Initialising GuiOutlineTree ...") - self.mainConf = novelwriter.CONFIG - self.mainGui = theOutline.mainGui - self.theProject = theOutline.mainGui.theProject - self.mainTheme = theOutline.mainGui.mainTheme + self.mainConf = novelwriter.CONFIG + self.outlineView = outlineView + self.mainGui = outlineView.mainGui + self.theProject = outlineView.mainGui.theProject + self.mainTheme = outlineView.mainGui.mainTheme self.setUniformRowHeights(True) self.setFrameStyle(QFrame.NoFrame) @@ -524,13 +526,11 @@ class GuiOutlineTree(QTreeWidget): selected, return the first. """ selItem = self.selectedItems() - tHandle = None - tLine = 0 if selItem: tHandle = selItem[0].data(self._colIdx[nwOutline.TITLE], self.D_HANDLE) - tLine = checkInt(selItem[0].text(self._colIdx[nwOutline.LINE]), 1) - 1 - - return tHandle, tLine + sTitle = selItem[0].data(self._colIdx[nwOutline.TITLE], self.D_TITLE) + return tHandle, sTitle + return None, None ## # Slots @@ -542,8 +542,10 @@ class GuiOutlineTree(QTreeWidget): clicked, and send it to the main gui class for opening in the document editor. """ - tHandle, tLine = self.getSelectedHandle() - self.mainGui.openDocument(tHandle, tLine=tLine - 1, doScroll=True) + tHandle, sTitle = self.getSelectedHandle() + if tHandle is None: + return + self.outlineView.openDocumentRequest.emit(tHandle, nwDocMode.EDIT, sTitle or "") return @pyqtSlot() @@ -554,9 +556,8 @@ class GuiOutlineTree(QTreeWidget): selItems = self.selectedItems() if selItems: tHandle = selItems[0].data(self._colIdx[nwOutline.TITLE], self.D_HANDLE) - sTitle = selItems[0].data(self._colIdx[nwOutline.TITLE], self.D_TITLE) + sTitle = selItems[0].data(self._colIdx[nwOutline.TITLE], self.D_TITLE) self.activeItemChanged.emit(tHandle, sTitle) - return @pyqtSlot(int, int, int) @@ -718,7 +719,7 @@ class GuiOutlineTree(QTreeWidget): trItem.setText(self._colIdx[nwOutline.LEVEL], novIdx.level) trItem.setIcon(self._colIdx[nwOutline.LABEL], self._dIcon[nwItem.mainHeading]) trItem.setText(self._colIdx[nwOutline.LABEL], nwItem.itemName) - trItem.setText(self._colIdx[nwOutline.LINE], sTitle[1:].lstrip("0")) + trItem.setText(self._colIdx[nwOutline.LINE], f"{novIdx.line:n}") trItem.setText(self._colIdx[nwOutline.SYNOP], novIdx.synopsis) trItem.setText(self._colIdx[nwOutline.CCOUNT], f"{novIdx.charCount:n}") trItem.setText(self._colIdx[nwOutline.WCOUNT], f"{novIdx.wordCount:n}") diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index 5ddeacba..92bdea27 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -60,7 +60,7 @@ class GuiProjectView(QWidget): # Signals for user interaction with the project tree selectedItemChanged = pyqtSignal(str) - openDocumentRequest = pyqtSignal(str, Enum, int, str) + openDocumentRequest = pyqtSignal(str, Enum, str) # Requests for the main GUI projectSettingsRequest = pyqtSignal(int) @@ -1144,7 +1144,7 @@ class GuiProjectTree(QTreeWidget): return if tItem.isFileType(): - self.projView.openDocumentRequest.emit(tHandle, nwDocMode.EDIT, -1, "") + self.projView.openDocumentRequest.emit(tHandle, nwDocMode.EDIT, "") else: trItem.setExpanded(not trItem.isExpanded()) @@ -1190,11 +1190,11 @@ class GuiProjectTree(QTreeWidget): if isFile: aOpenDoc = ctxMenu.addAction(self.tr("Open Document")) aOpenDoc.triggered.connect( - lambda: self.projView.openDocumentRequest.emit(tHandle, nwDocMode.EDIT, -1, "") + lambda: self.projView.openDocumentRequest.emit(tHandle, nwDocMode.EDIT, "") ) aViewDoc = ctxMenu.addAction(self.tr("View Document")) aViewDoc.triggered.connect( - lambda: self.projView.openDocumentRequest.emit(tHandle, nwDocMode.VIEW, -1, "") + lambda: self.projView.openDocumentRequest.emit(tHandle, nwDocMode.VIEW, "") ) ctxMenu.addSeparator() @@ -1324,7 +1324,7 @@ class GuiProjectTree(QTreeWidget): return if tItem.isFileType(): - self.projView.openDocumentRequest.emit(tHandle, nwDocMode.VIEW, -1, "") + self.projView.openDocumentRequest.emit(tHandle, nwDocMode.VIEW, "") return diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index c4b569e6..b32c3830 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -230,6 +230,7 @@ class GuiMain(QMainWindow): self.docViewer.loadDocumentTagRequest.connect(self._followTag) self.outlineView.loadDocumentTagRequest.connect(self._followTag) + self.outlineView.openDocumentRequest.connect(self._openDocument) # Finalise Initialisation # ======================= @@ -650,7 +651,7 @@ class GuiMain(QMainWindow): return True - def viewDocument(self, tHandle=None, tAnchor=None): + def viewDocument(self, tHandle=None, sTitle=None): """Load a document for viewing in the view panel. """ if not self.hasProject: @@ -689,7 +690,8 @@ class GuiMain(QMainWindow): self.splitDocs.setSizes(vPos) self.viewMeta.setVisible(self.mainConf.showRefPanel) - self.docViewer.navigateTo(tAnchor) + if sTitle: + self.docViewer.navigateTo(f"#{sTitle}") return True @@ -777,17 +779,23 @@ class GuiMain(QMainWindow): return False tHandle = None + sTitle = None tLine = None if self.projView.treeHasFocus(): tHandle = self.projView.getSelectedHandle() elif self.novelView.treeHasFocus(): - tHandle, tLine = self.novelView.getSelectedHandle() + tHandle, sTitle = self.novelView.getSelectedHandle() elif self.outlineView.treeHasFocus(): - tHandle, tLine = self.outlineView.getSelectedHandle() + tHandle, sTitle = self.outlineView.getSelectedHandle() else: logger.warning("No item selected") return False + if tHandle is not None and sTitle is not None: + hItem = self.theProject.index.getItemHeader(tHandle, sTitle) + if hItem is not None: + tLine = hItem.line + if tHandle is not None: self.openDocument(tHandle, tLine=tLine, changeFocus=False, doScroll=False) @@ -1473,18 +1481,22 @@ class GuiMain(QMainWindow): if tMode == nwDocMode.EDIT: self.openDocument(tHandle) elif tMode == nwDocMode.VIEW: - self.viewDocument(tHandle=tHandle, tAnchor=f"#{sTitle}") + self.viewDocument(tHandle=tHandle, sTitle=sTitle) return - @pyqtSlot(str, Enum, int, str) - def _openDocument(self, tHandle, tMode, tLine, tAnchor): + @pyqtSlot(str, Enum, str) + def _openDocument(self, tHandle, tMode, sTitle): """Handle an open document request from one of the tree views. """ if tHandle is not None: if tMode == nwDocMode.EDIT: + tLine = None + hItem = self.theProject.index.getItemHeader(tHandle, sTitle) + if hItem is not None: + tLine = hItem.line self.openDocument(tHandle, tLine=tLine, changeFocus=False) elif tMode == nwDocMode.VIEW: - self.viewDocument(tHandle=tHandle, tAnchor=(tAnchor or None)) + self.viewDocument(tHandle=tHandle, sTitle=sTitle) return @pyqtSlot(nwView)