diff --git a/nw/core/index.py b/nw/core/index.py index 7a9c88bc..85ec8791 100644 --- a/nw/core/index.py +++ b/nw/core/index.py @@ -59,6 +59,7 @@ class NWIndex(): self._novelIndex = {} self._noteIndex = {} self._textCounts = {} + self._firstTitle = {} # TimeStamps self._timeNovel = 0 @@ -79,6 +80,7 @@ class NWIndex(): self._novelIndex = {} self._noteIndex = {} self._textCounts = {} + self._firstTitle = {} self._timeNovel = 0 self._timeNotes = 0 self._timeIndex = 0 @@ -101,6 +103,7 @@ class NWIndex(): self._novelIndex.pop(tHandle, None) self._noteIndex.pop(tHandle, None) self._textCounts.pop(tHandle, None) + self._firstTitle.pop(tHandle, None) return @@ -169,6 +172,7 @@ class NWIndex(): self._novelIndex = theData.get("novelIndex", {}) self._noteIndex = theData.get("noteIndex", {}) self._textCounts = theData.get("textCounts", {}) + self._firstTitle = theData.get("firstTitle", {}) nowTime = round(time()) self._timeNovel = nowTime @@ -194,6 +198,7 @@ class NWIndex(): "novelIndex" : self._novelIndex, "noteIndex" : self._noteIndex, "textCounts" : self._textCounts, + "firstTitle" : self._firstTitle, }, outFile, indent=2) except Exception: logger.error("Failed to save index file") @@ -215,6 +220,7 @@ class NWIndex(): self._checkNovelNoteIndex("novelIndex") self._checkNovelNoteIndex("noteIndex") self._checkTextCounts() + self._checkFirstTitles() self.indexBroken = False except Exception: @@ -285,6 +291,7 @@ class NWIndex(): "tags" : [], "updated" : round(time()), } + self._firstTitle[tHandle] = ["H0", "T000000"] if itemLayout == nwItemLayout.NOTE: self._novelIndex.pop(tHandle, None) self._noteIndex[tHandle] = {} @@ -312,7 +319,7 @@ class NWIndex(): if nChar == 0: continue - if aLine.startswith(r"#"): + if aLine.startswith("#"): isTitle = self._indexTitle(tHandle, isNovel, aLine, nLine, itemLayout) if isTitle and nLine > 0: if nTitle > 0: @@ -320,11 +327,11 @@ class NWIndex(): self._indexWordCounts(tHandle, isNovel, lastText, nTitle) nTitle = nLine - elif aLine.startswith(r"@"): + elif aLine.startswith("@"): self._indexNoteRef(tHandle, aLine, nLine, nTitle) self._indexTag(tHandle, aLine, nLine, nTitle, itemClass) - elif aLine.startswith(r"%"): + elif aLine.startswith("%"): if nTitle > 0: toCheck = aLine[1:].lstrip() synTag = toCheck[:9].lower() @@ -393,6 +400,9 @@ class NWIndex(): "updated" : round(time()), } + if self._firstTitle[tHandle][0] == "H0": + self._firstTitle[tHandle] = [hDepth, sTitle] + if hText != "": if isNovel: if tHandle in self._novelIndex: @@ -638,6 +648,11 @@ class NWIndex(): return theToC + def getFirstTitle(self, tHandle): + """Return the level and location of the first title of a handle. + """ + return self._firstTitle.get(tHandle, ["H0", "T000000"]) + def getCounts(self, tHandle, sTitle=None): """Returns the counts for a file, or a section of a file starting at title sTitle if it is provided. @@ -879,4 +894,22 @@ class NWIndex(): return + def _checkFirstTitles(self): + """Scan the first titles index for errors. + Waring: This function raises exceptions. + """ + for tHandle in self._firstTitle: + if not isHandle(tHandle): + raise KeyError("firstTitle key is not a handle") + + tEntry = self._firstTitle[tHandle] + if len(tEntry) != 2: + raise IndexError("firstTitle[a] expected 2 values") + if not tEntry[0] in self.H_VALID: + raise ValueError("firstTitle[a][0] is not a header level") + if not isTitleTag(tEntry[1]): + raise ValueError("firstTitle[a][1] is not a title tag") + + return + # END Class NWIndex diff --git a/nw/core/tree.py b/nw/core/tree.py index 94e9b97a..5fdc55ba 100644 --- a/nw/core/tree.py +++ b/nw/core/tree.py @@ -34,10 +34,35 @@ from time import time from nw.core.item import NWItem from nw.common import checkHandle -from nw.constants import nwFiles, nwItemType, nwItemClass, nwItemLayout, nwConst +from nw.constants import ( + nwFiles, nwItemType, nwItemClass, nwItemLayout, nwConst, nwLists +) logger = logging.getLogger(__name__) +# Layout Translation Map +LAYOUT_MAP = { + nwItemLayout.SCENE: { + "H1": nwItemLayout.BOOK, + "H2": nwItemLayout.CHAPTER, + }, + nwItemLayout.CHAPTER: { + "H1": nwItemLayout.BOOK, + "H3": nwItemLayout.SCENE, + "H4": nwItemLayout.SCENE, + }, + nwItemLayout.UNNUMBERED: { + "H1": nwItemLayout.BOOK, + "H3": nwItemLayout.SCENE, + "H4": nwItemLayout.SCENE, + }, + nwItemLayout.PARTITION: { + "H2": nwItemLayout.CHAPTER, + "H3": nwItemLayout.SCENE, + "H4": nwItemLayout.SCENE, + }, +} + class NWTree(): def __init__(self, theProject): @@ -204,6 +229,29 @@ class NWTree(): novelWords += tItem.wordCount return novelWords, noteWords + def updateItemLayout(self, tHandle, hLevel): + """Check if the item layout needs updating based on the header + given level. + """ + tItem = self.__getitem__(tHandle) + if tItem is None: + return False + if tItem.itemClass not in nwLists.CLS_NOVEL: + return False + if hLevel not in ("H1", "H2", "H3", "H4"): + return False + + iLayout = tItem.itemLayout + if iLayout in LAYOUT_MAP: + if hLevel in LAYOUT_MAP[iLayout]: + tItem.itemLayout = LAYOUT_MAP[iLayout][hLevel] + logger.debug("Changed layout for %s from %s to %s" % ( + tHandle, iLayout.name, tItem.itemLayout.name + )) + return True + + return False + ## # Tree Structure Methods ## diff --git a/nw/gui/doceditor.py b/nw/gui/doceditor.py index 627b4ff9..31e77f0d 100644 --- a/nw/gui/doceditor.py +++ b/nw/gui/doceditor.py @@ -398,6 +398,7 @@ class GuiDocEditor(QTextEdit): return False docText = self.getText() + tHandle = theItem.itemHandle cC, wC, pC = countWords(docText) self._updateCounts(cC, wC, pC) @@ -410,7 +411,12 @@ class GuiDocEditor(QTextEdit): self.nwDocument.saveDocument(docText) self.setDocumentChanged(False) - self.theParent.theIndex.scanText(theItem.itemHandle, docText) + self.theParent.theIndex.scanText(tHandle, docText) + + hLevel, _ = self.theParent.theIndex.getFirstTitle(tHandle) + if self.theProject.projTree.updateItemLayout(tHandle, hLevel): + self.theParent.treeView.setTreeItemValues(tHandle) + self.nwDocument.saveDocument(docText) return True diff --git a/tests/reference/coreIndex_LoadSave_tagsIndex.json b/tests/reference/coreIndex_LoadSave_tagsIndex.json index 2aa3df3c..993b4e26 100644 --- a/tests/reference/coreIndex_LoadSave_tagsIndex.json +++ b/tests/reference/coreIndex_LoadSave_tagsIndex.json @@ -567,5 +567,67 @@ 259, 3 ] + }, + "firstTitle": { + "7a992350f3eb6": [ + "H1", + "T000001" + ], + "8c58a65414c23": [ + "H0", + "T000000" + ], + "88d59a277361b": [ + "H2", + "T000001" + ], + "db7e733775d4d": [ + "H1", + "T000001" + ], + "fb609cd8319dc": [ + "H2", + "T000001" + ], + "88243afbe5ed8": [ + "H3", + "T000001" + ], + "f96ec11c6a3da": [ + "H3", + "T000001" + ], + "846352075de7d": [ + "H2", + "T000001" + ], + "441420a886d82": [ + "H2", + "T000001" + ], + "eb103bc70c90c": [ + "H3", + "T000001" + ], + "f8c0562e50f1b": [ + "H3", + "T000001" + ], + "47666c91c7ccf": [ + "H3", + "T000001" + ], + "4c4f28287af27": [ + "H1", + "T000001" + ], + "2426c6f0ca922": [ + "H1", + "T000001" + ], + "04468803b92e1": [ + "H1", + "T000001" + ] } } \ No newline at end of file diff --git a/tests/reference/guiEditor_Main_Final_0e17daca5f3e1.nwd b/tests/reference/guiEditor_Main_Final_0e17daca5f3e1.nwd index 54fef623..c77c3cd6 100644 --- a/tests/reference/guiEditor_Main_Final_0e17daca5f3e1.nwd +++ b/tests/reference/guiEditor_Main_Final_0e17daca5f3e1.nwd @@ -1,6 +1,6 @@ %%~name: New Scene %%~path: 31489056e0916/0e17daca5f3e1 -%%~kind: NOVEL/SCENE +%%~kind: NOVEL/BOOK # Novel ## Chapter diff --git a/tests/reference/guiEditor_Main_Final_nwProject.nwx b/tests/reference/guiEditor_Main_Final_nwProject.nwx index 21011e03..764c4610 100644 --- a/tests/reference/guiEditor_Main_Final_nwProject.nwx +++ b/tests/reference/guiEditor_Main_Final_nwProject.nwx @@ -1,11 +1,11 @@ - + New Project 4 - 1 - 11 + 2 + 8 True @@ -83,7 +83,7 @@ NOVEL New True - SCENE + BOOK 466 83 4 diff --git a/tests/test_core/test_core_index.py b/tests/test_core/test_core_index.py index 5e1e9223..bd2f6b3f 100644 --- a/tests/test_core/test_core_index.py +++ b/tests/test_core/test_core_index.py @@ -492,9 +492,8 @@ def testCoreIndex_ExtractData(nwMinimal, dummyGUI): assert wC == 12 # Words in text and title only assert pC == 2 # Paragraphs in text only - ## - # getReferences - ## + # getReferences + # ============= # Look up an ivalid handle theRefs = theIndex.getReferences("Not a handle") @@ -506,9 +505,8 @@ def testCoreIndex_ExtractData(nwMinimal, dummyGUI): assert theRefs["@pov"] == ["Jane"] assert theRefs["@char"] == ["Jane"] - ## - # getBackReferenceList - ## + # getBackReferenceList + # ==================== # None handle should return an empty dict assert theIndex.getBackReferenceList(None) == {} @@ -517,16 +515,15 @@ def testCoreIndex_ExtractData(nwMinimal, dummyGUI): theRefs = theIndex.getBackReferenceList(cHandle) assert theRefs == {nHandle: "T000001"} - ## - # getTagSource - ## + # getTagSource + # ============ assert theIndex.getTagSource("Jane") == (cHandle, 2, "T000001") assert theIndex.getTagSource("John") == (None, 0, "T000000") - ## - # getCounts for whole text and sections - ## + # getCounts + # ========= + # For whole text and sections # Get section counts for a novel file assert theIndex.scanText(nHandle, ( @@ -555,7 +552,7 @@ def testCoreIndex_ExtractData(nwMinimal, dummyGUI): assert wC == 12 assert pC == 2 - # First part + # Second part cC, wC, pC = theIndex.getCounts(nHandle, "T000011") assert cC == 62 assert wC == 12 @@ -588,15 +585,19 @@ def testCoreIndex_ExtractData(nwMinimal, dummyGUI): assert wC == 12 assert pC == 2 - # First part + # Second part cC, wC, pC = theIndex.getCounts(cHandle, "T000011") assert cC == 62 assert wC == 12 assert pC == 2 - ## - # Novel Stats - ## + # getFirstTitle + # ============= + + assert theIndex.getFirstTitle(cHandle) == ["H1", "T000001"] + + # Novel Stats + # =========== hHandle = theProject.newFile("Chapter", nwItemClass.NOVEL, "a508bb932959c") sHandle = theProject.newFile("Scene One", nwItemClass.NOVEL, "a508bb932959c") @@ -1145,3 +1146,51 @@ def testCoreIndex_CheckTextCounts(dummyGUI): theIndex._checkTextCounts() # END Test testCoreIndex_CheckTextCounts + +@pytest.mark.core +def testCoreIndex_CheckFirstTitle(dummyGUI): + """Test the first title checker. + """ + theProject = NWProject(dummyGUI) + theIndex = NWIndex(theProject, dummyGUI) + + # Valid Index + theIndex._firstTitle = { + "53b69b83cdafc": ["H1", "T000001"], + "974e400180a99": ["H0", "T000000"], + } + assert theIndex._checkFirstTitles() is None + + # Invalid Handle + theIndex._firstTitle = { + "53b69b83cdafc": ["H1", "T000001"], + "h74e400180a99": ["H0", "T000000"], + } + with pytest.raises(KeyError): + theIndex._checkFirstTitles() + + # Wrong Length + theIndex._firstTitle = { + "53b69b83cdafc": ["H1", "T000001"], + "974e400180a99": ["H0", "T000000", "stuff"], + } + with pytest.raises(IndexError): + theIndex._checkFirstTitles() + + # Wrong Header + theIndex._firstTitle = { + "53b69b83cdafc": ["H1", "T000001"], + "974e400180a99": ["XX", "T000000"], + } + with pytest.raises(ValueError): + theIndex._checkFirstTitles() + + # Wrong Title + theIndex._firstTitle = { + "53b69b83cdafc": ["H1", "T000001"], + "974e400180a99": ["H0", "INVALID"], + } + with pytest.raises(ValueError): + theIndex._checkFirstTitles() + +# END Test testCoreIndex_CheckFirstTitle diff --git a/tests/test_core/test_core_tree.py b/tests/test_core/test_core_tree.py index 62803830..d4588b37 100644 --- a/tests/test_core/test_core_tree.py +++ b/tests/test_core/test_core_tree.py @@ -123,9 +123,9 @@ def testCoreTree_BuildTree(dummyGUI, dummyItems): assert not theTree.isTrashRoot("a000000000003") aHandles = [] - for tHandle, pHande, nwItem in dummyItems: + for tHandle, pHandle, nwItem in dummyItems: aHandles.append(tHandle) - assert theTree.append(tHandle, pHande, nwItem) + assert theTree.append(tHandle, pHandle, nwItem) assert theTree._treeChanged @@ -202,13 +202,13 @@ def testCoreTree_BuildTree(dummyGUI, dummyItems): @pytest.mark.core def testCoreTree_Methods(dummyGUI, dummyItems): - """Test building a project tree from a list of items. + """Test bvarious class methods. """ theProject = NWProject(dummyGUI) theTree = NWTree(theProject) - for tHandle, pHande, nwItem in dummyItems: - theTree.append(tHandle, pHande, nwItem) + for tHandle, pHandle, nwItem in dummyItems: + theTree.append(tHandle, pHandle, nwItem) assert len(theTree) == len(dummyItems) @@ -256,6 +256,131 @@ def testCoreTree_Methods(dummyGUI, dummyItems): # END Test testCoreTree_Methods +@pytest.mark.core +def testCoreTree_UpdateItemLayout(dummyGUI, dummyItems): + """Test building a project tree from a list of items. + """ + theProject = NWProject(dummyGUI) + theTree = NWTree(theProject) + + for tHandle, pHandle, nwItem in dummyItems: + theTree.append(tHandle, pHandle, nwItem) + + assert len(theTree) == len(dummyItems) + + # Check rejected items + assert not theTree.updateItemLayout("0000000000000", "H1") # Non-existent handle + assert not theTree.updateItemLayout("a000000000004", "H2") # Character file + assert not theTree.updateItemLayout("c000000000002", "H0") # Wrong header level + + cHandle = "c000000000002" + + # Check layouts we won't change + theTree[cHandle].setLayout(nwItemLayout.NO_LAYOUT) + assert not theTree.updateItemLayout("c000000000002", "H1") + + theTree[cHandle].setLayout(nwItemLayout.TITLE) + assert not theTree.updateItemLayout("c000000000002", "H1") + + theTree[cHandle].setLayout(nwItemLayout.PAGE) + assert not theTree.updateItemLayout("c000000000002", "H1") + + theTree[cHandle].setLayout(nwItemLayout.NOTE) + assert not theTree.updateItemLayout("c000000000002", "H1") + + # BOOK is also a layout we change to, but never from + theTree[cHandle].setLayout(nwItemLayout.BOOK) + assert not theTree.updateItemLayout("c000000000002", "H1") + + # Test SCENE Changes + # ================== + + # H1 -> BOOK + theTree[cHandle].setLayout(nwItemLayout.SCENE) + assert theTree.updateItemLayout("c000000000002", "H1") + assert theTree[cHandle].itemLayout == nwItemLayout.BOOK + + # H2 -> CHAPTER + theTree[cHandle].setLayout(nwItemLayout.SCENE) + assert theTree.updateItemLayout("c000000000002", "H2") + assert theTree[cHandle].itemLayout == nwItemLayout.CHAPTER + + # H3 -> No CHange + theTree[cHandle].setLayout(nwItemLayout.SCENE) + assert not theTree.updateItemLayout("c000000000002", "H3") + + # H4 -> No CHange + theTree[cHandle].setLayout(nwItemLayout.SCENE) + assert not theTree.updateItemLayout("c000000000002", "H4") + + # Test CHAPTER Changes + # ==================== + + # H1 -> BOOK + theTree[cHandle].setLayout(nwItemLayout.CHAPTER) + assert theTree.updateItemLayout("c000000000002", "H1") + assert theTree[cHandle].itemLayout == nwItemLayout.BOOK + + # H2 -> No Change + theTree[cHandle].setLayout(nwItemLayout.CHAPTER) + assert not theTree.updateItemLayout("c000000000002", "H2") + + # H3 -> SCENE + theTree[cHandle].setLayout(nwItemLayout.CHAPTER) + assert theTree.updateItemLayout("c000000000002", "H3") + assert theTree[cHandle].itemLayout == nwItemLayout.SCENE + + # H4 -> SCENE + theTree[cHandle].setLayout(nwItemLayout.CHAPTER) + assert theTree.updateItemLayout("c000000000002", "H4") + assert theTree[cHandle].itemLayout == nwItemLayout.SCENE + + # Test UNNUMBERED Changes + # ======================= + + # H1 -> BOOK + theTree[cHandle].setLayout(nwItemLayout.UNNUMBERED) + assert theTree.updateItemLayout("c000000000002", "H1") + assert theTree[cHandle].itemLayout == nwItemLayout.BOOK + + # H2 -> No Change + theTree[cHandle].setLayout(nwItemLayout.UNNUMBERED) + assert not theTree.updateItemLayout("c000000000002", "H2") + + # H3 -> SCENE + theTree[cHandle].setLayout(nwItemLayout.UNNUMBERED) + assert theTree.updateItemLayout("c000000000002", "H3") + assert theTree[cHandle].itemLayout == nwItemLayout.SCENE + + # H4 -> SCENE + theTree[cHandle].setLayout(nwItemLayout.UNNUMBERED) + assert theTree.updateItemLayout("c000000000002", "H4") + assert theTree[cHandle].itemLayout == nwItemLayout.SCENE + + # Test PARTITION Changes + # ====================== + + # H1 -> BOOK + theTree[cHandle].setLayout(nwItemLayout.PARTITION) + assert not theTree.updateItemLayout("c000000000002", "H1") + + # H2 -> No Change + theTree[cHandle].setLayout(nwItemLayout.PARTITION) + assert theTree.updateItemLayout("c000000000002", "H2") + assert theTree[cHandle].itemLayout == nwItemLayout.CHAPTER + + # H3 -> SCENE + theTree[cHandle].setLayout(nwItemLayout.PARTITION) + assert theTree.updateItemLayout("c000000000002", "H3") + assert theTree[cHandle].itemLayout == nwItemLayout.SCENE + + # H4 -> SCENE + theTree[cHandle].setLayout(nwItemLayout.PARTITION) + assert theTree.updateItemLayout("c000000000002", "H4") + assert theTree[cHandle].itemLayout == nwItemLayout.SCENE + +# END Test testCoreTree_UpdateItemLayout + @pytest.mark.core def testCoreTree_MakeHandles(monkeypatch, dummyGUI): """Test generating item handles. @@ -296,8 +421,8 @@ def testCoreTree_Stats(dummyGUI, dummyItems): theProject = NWProject(dummyGUI) theTree = NWTree(theProject) - for tHandle, pHande, nwItem in dummyItems: - theTree.append(tHandle, pHande, nwItem) + for tHandle, pHandle, nwItem in dummyItems: + theTree.append(tHandle, pHandle, nwItem) assert len(theTree) == len(dummyItems) theTree._treeOrder.append("dummy") @@ -323,9 +448,9 @@ def testCoreTree_Reorder(dummyGUI, dummyItems): theTree = NWTree(theProject) aHandle = [] - for tHandle, pHande, nwItem in dummyItems: + for tHandle, pHandle, nwItem in dummyItems: aHandle.append(tHandle) - theTree.append(tHandle, pHande, nwItem) + theTree.append(tHandle, pHandle, nwItem) assert len(theTree) == len(dummyItems) @@ -348,13 +473,13 @@ def testCoreTree_Reorder(dummyGUI, dummyItems): @pytest.mark.core def testCoreTree_XMLPackUnpack(dummyGUI, dummyItems): - """Test changing tree order. + """Test packing and unpacking the tree to and from XML. """ theProject = NWProject(dummyGUI) theTree = NWTree(theProject) - for tHandle, pHande, nwItem in dummyItems: - theTree.append(tHandle, pHande, nwItem) + for tHandle, pHandle, nwItem in dummyItems: + theTree.append(tHandle, pHandle, nwItem) assert len(theTree) == len(dummyItems) @@ -408,8 +533,8 @@ def testCoreTree_ToCFile(monkeypatch, dummyGUI, dummyItems, tmpDir): theProject = NWProject(dummyGUI) theTree = NWTree(theProject) - for tHandle, pHande, nwItem in dummyItems: - theTree.append(tHandle, pHande, nwItem) + for tHandle, pHandle, nwItem in dummyItems: + theTree.append(tHandle, pHandle, nwItem) assert len(theTree) == len(dummyItems) theTree._treeOrder.append("dummy")