novelWriter 2.2rc1__py3-none-any.whl → 2.3b1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. {novelWriter-2.2rc1.dist-info → novelWriter-2.3b1.dist-info}/METADATA +1 -1
  2. {novelWriter-2.2rc1.dist-info → novelWriter-2.3b1.dist-info}/RECORD +141 -129
  3. {novelWriter-2.2rc1.dist-info → novelWriter-2.3b1.dist-info}/WHEEL +1 -1
  4. novelwriter/__init__.py +11 -6
  5. novelwriter/assets/i18n/nw_de_DE.qm +0 -0
  6. novelwriter/assets/i18n/nw_en_US.qm +0 -0
  7. novelwriter/assets/i18n/nw_es_419.qm +0 -0
  8. novelwriter/assets/i18n/nw_fr_FR.qm +0 -0
  9. novelwriter/assets/i18n/nw_it_IT.qm +0 -0
  10. novelwriter/assets/i18n/nw_ja_JP.qm +0 -0
  11. novelwriter/assets/i18n/nw_nb_NO.qm +0 -0
  12. novelwriter/assets/i18n/nw_zh_CN.qm +0 -0
  13. novelwriter/assets/i18n/project_de_DE.json +1 -0
  14. novelwriter/assets/i18n/project_en_US.json +1 -0
  15. novelwriter/assets/i18n/project_es_419.json +11 -0
  16. novelwriter/assets/i18n/project_fr_FR.json +11 -0
  17. novelwriter/assets/i18n/project_it_IT.json +11 -0
  18. novelwriter/assets/i18n/project_ja_JP.json +2 -1
  19. novelwriter/assets/i18n/project_nb_NO.json +1 -0
  20. novelwriter/assets/i18n/project_zh_CN.json +11 -0
  21. novelwriter/assets/icons/typicons_dark/icons.conf +9 -2
  22. novelwriter/assets/icons/typicons_dark/mixed_import.svg +5 -0
  23. novelwriter/assets/icons/typicons_dark/nw_tb-bold-md.svg +4 -0
  24. novelwriter/assets/icons/typicons_dark/nw_tb-bold.svg +3 -1
  25. novelwriter/assets/icons/typicons_dark/nw_tb-italic-md.svg +4 -0
  26. novelwriter/assets/icons/typicons_dark/nw_tb-italic.svg +3 -1
  27. novelwriter/assets/icons/typicons_dark/nw_tb-strike-md.svg +4 -0
  28. novelwriter/assets/icons/typicons_dark/nw_tb-strike.svg +3 -1
  29. novelwriter/assets/icons/typicons_dark/nw_tb-subscript.svg +4 -2
  30. novelwriter/assets/icons/typicons_dark/nw_tb-superscript.svg +4 -2
  31. novelwriter/assets/icons/typicons_dark/nw_tb-underline.svg +4 -2
  32. novelwriter/assets/icons/typicons_dark/typ_document-add-col.svg +8 -0
  33. novelwriter/assets/icons/typicons_dark/typ_document-add.svg +4 -0
  34. novelwriter/assets/icons/typicons_dark/typ_document.svg +4 -0
  35. novelwriter/assets/icons/typicons_dark/typ_th-dot-more.svg +4 -0
  36. novelwriter/assets/icons/typicons_light/icons.conf +9 -2
  37. novelwriter/assets/icons/typicons_light/mixed_import.svg +5 -0
  38. novelwriter/assets/icons/typicons_light/nw_tb-bold-md.svg +4 -0
  39. novelwriter/assets/icons/typicons_light/nw_tb-bold.svg +3 -1
  40. novelwriter/assets/icons/typicons_light/nw_tb-italic-md.svg +4 -0
  41. novelwriter/assets/icons/typicons_light/nw_tb-italic.svg +3 -1
  42. novelwriter/assets/icons/typicons_light/nw_tb-strike-md.svg +4 -0
  43. novelwriter/assets/icons/typicons_light/nw_tb-strike.svg +3 -1
  44. novelwriter/assets/icons/typicons_light/nw_tb-subscript.svg +4 -2
  45. novelwriter/assets/icons/typicons_light/nw_tb-superscript.svg +4 -2
  46. novelwriter/assets/icons/typicons_light/nw_tb-underline.svg +4 -2
  47. novelwriter/assets/icons/typicons_light/typ_document-add-col.svg +8 -0
  48. novelwriter/assets/icons/typicons_light/typ_document-add.svg +4 -0
  49. novelwriter/assets/icons/typicons_light/typ_document.svg +4 -0
  50. novelwriter/assets/icons/typicons_light/typ_th-dot-more.svg +4 -0
  51. novelwriter/assets/images/novelwriter-text-dark.svg +4 -0
  52. novelwriter/assets/images/novelwriter-text-light.svg +4 -0
  53. novelwriter/assets/images/welcome-dark.jpg +0 -0
  54. novelwriter/assets/images/welcome-light.jpg +0 -0
  55. novelwriter/assets/manual.pdf +0 -0
  56. novelwriter/assets/sample.zip +0 -0
  57. novelwriter/assets/syntax/default_dark.conf +1 -0
  58. novelwriter/assets/syntax/default_light.conf +1 -0
  59. novelwriter/assets/syntax/grey_dark.conf +1 -0
  60. novelwriter/assets/syntax/grey_light.conf +1 -0
  61. novelwriter/assets/syntax/light_owl.conf +1 -0
  62. novelwriter/assets/syntax/night_owl.conf +1 -0
  63. novelwriter/assets/syntax/solarized_dark.conf +1 -0
  64. novelwriter/assets/syntax/solarized_light.conf +1 -0
  65. novelwriter/assets/syntax/tomorrow.conf +1 -0
  66. novelwriter/assets/syntax/tomorrow_night.conf +1 -0
  67. novelwriter/assets/syntax/tomorrow_night_blue.conf +1 -0
  68. novelwriter/assets/syntax/tomorrow_night_bright.conf +1 -0
  69. novelwriter/assets/syntax/tomorrow_night_eighties.conf +1 -0
  70. novelwriter/assets/text/credits_en.htm +4 -2
  71. novelwriter/assets/themes/default_dark.conf +2 -2
  72. novelwriter/assets/themes/default_light.conf +2 -2
  73. novelwriter/common.py +64 -66
  74. novelwriter/config.py +39 -44
  75. novelwriter/constants.py +39 -17
  76. novelwriter/core/buildsettings.py +8 -8
  77. novelwriter/core/coretools.py +194 -155
  78. novelwriter/core/docbuild.py +7 -4
  79. novelwriter/core/document.py +7 -7
  80. novelwriter/core/index.py +90 -57
  81. novelwriter/core/item.py +23 -5
  82. novelwriter/core/options.py +11 -10
  83. novelwriter/core/project.py +72 -47
  84. novelwriter/core/projectdata.py +3 -16
  85. novelwriter/core/projectxml.py +14 -42
  86. novelwriter/core/sessions.py +4 -3
  87. novelwriter/core/spellcheck.py +6 -4
  88. novelwriter/core/status.py +5 -4
  89. novelwriter/core/storage.py +179 -141
  90. novelwriter/core/tohtml.py +6 -4
  91. novelwriter/core/tokenizer.py +74 -46
  92. novelwriter/core/tomd.py +2 -2
  93. novelwriter/core/toodt.py +41 -31
  94. novelwriter/core/tree.py +5 -4
  95. novelwriter/dialogs/about.py +88 -179
  96. novelwriter/dialogs/docmerge.py +30 -20
  97. novelwriter/dialogs/docsplit.py +33 -22
  98. novelwriter/dialogs/editlabel.py +20 -8
  99. novelwriter/dialogs/preferences.py +562 -725
  100. novelwriter/dialogs/{projsettings.py → projectsettings.py} +301 -270
  101. novelwriter/dialogs/quotes.py +47 -36
  102. novelwriter/dialogs/wordlist.py +128 -59
  103. novelwriter/enum.py +25 -22
  104. novelwriter/error.py +2 -2
  105. novelwriter/extensions/circularprogress.py +12 -12
  106. novelwriter/extensions/configlayout.py +185 -146
  107. novelwriter/extensions/{wheeleventfilter.py → eventfilters.py} +15 -5
  108. novelwriter/extensions/modified.py +81 -0
  109. novelwriter/extensions/novelselector.py +27 -13
  110. novelwriter/extensions/pagedsidebar.py +15 -20
  111. novelwriter/extensions/simpleprogress.py +8 -9
  112. novelwriter/extensions/statusled.py +9 -9
  113. novelwriter/extensions/switch.py +32 -64
  114. novelwriter/extensions/switchbox.py +2 -7
  115. novelwriter/extensions/versioninfo.py +153 -0
  116. novelwriter/gui/doceditor.py +250 -214
  117. novelwriter/gui/dochighlight.py +66 -94
  118. novelwriter/gui/docviewer.py +71 -98
  119. novelwriter/gui/docviewerpanel.py +140 -47
  120. novelwriter/gui/editordocument.py +3 -3
  121. novelwriter/gui/itemdetails.py +9 -9
  122. novelwriter/gui/mainmenu.py +47 -46
  123. novelwriter/gui/noveltree.py +53 -61
  124. novelwriter/gui/outline.py +100 -76
  125. novelwriter/gui/projtree.py +193 -67
  126. novelwriter/gui/sidebar.py +9 -8
  127. novelwriter/gui/statusbar.py +49 -7
  128. novelwriter/gui/theme.py +65 -74
  129. novelwriter/guimain.py +173 -330
  130. novelwriter/shared.py +68 -30
  131. novelwriter/tools/dictionaries.py +7 -8
  132. novelwriter/tools/lipsum.py +34 -28
  133. novelwriter/tools/manusbuild.py +3 -4
  134. novelwriter/tools/manuscript.py +25 -32
  135. novelwriter/tools/manussettings.py +194 -225
  136. novelwriter/tools/noveldetails.py +525 -0
  137. novelwriter/tools/welcome.py +802 -0
  138. novelwriter/tools/writingstats.py +26 -13
  139. novelwriter/assets/icons/typicons_dark/nw_tb-markdown.svg +0 -8
  140. novelwriter/assets/icons/typicons_dark/nw_tb-shortcode.svg +0 -8
  141. novelwriter/assets/icons/typicons_light/nw_tb-markdown.svg +0 -8
  142. novelwriter/assets/icons/typicons_light/nw_tb-shortcode.svg +0 -8
  143. novelwriter/assets/images/wizard-back.jpg +0 -0
  144. novelwriter/assets/text/gplv3_en.htm +0 -641
  145. novelwriter/assets/text/release_notes.htm +0 -17
  146. novelwriter/dialogs/projdetails.py +0 -525
  147. novelwriter/dialogs/projload.py +0 -298
  148. novelwriter/dialogs/updates.py +0 -182
  149. novelwriter/extensions/pageddialog.py +0 -130
  150. novelwriter/tools/projwizard.py +0 -478
  151. {novelWriter-2.2rc1.dist-info → novelWriter-2.3b1.dist-info}/LICENSE.md +0 -0
  152. {novelWriter-2.2rc1.dist-info → novelWriter-2.3b1.dist-info}/entry_points.txt +0 -0
  153. {novelWriter-2.2rc1.dist-info → novelWriter-2.3b1.dist-info}/top_level.txt +0 -0
novelwriter/core/index.py CHANGED
@@ -11,7 +11,7 @@ Created: 2022-05-29 [2.0rc1] TagsIndex
11
11
  Created: 2022-05-29 [2.0rc1] ItemIndex
12
12
 
13
13
  This file is a part of novelWriter
14
- Copyright 2018–2023, Veronica Berglyd Olsen
14
+ Copyright 2018–2024, Veronica Berglyd Olsen
15
15
 
16
16
  This program is free software: you can redistribute it and/or modify
17
17
  it under the terms of the GNU General Public License as published by
@@ -32,8 +32,9 @@ import json
32
32
  import logging
33
33
 
34
34
  from time import time
35
- from typing import TYPE_CHECKING, ItemsView, Iterable, Iterator
35
+ from typing import TYPE_CHECKING
36
36
  from pathlib import Path
37
+ from collections.abc import ItemsView, Iterable, Iterator
37
38
 
38
39
  from novelwriter import SHARED
39
40
  from novelwriter.enum import nwComment, nwItemClass, nwItemType, nwItemLayout
@@ -122,8 +123,8 @@ class NWIndex:
122
123
  for nwItem in self._project.tree:
123
124
  if nwItem.isFileType():
124
125
  tHandle = nwItem.itemHandle
125
- theDoc = self._project.storage.getDocument(tHandle)
126
- self.scanText(tHandle, theDoc.readDocument() or "", blockSignal=True)
126
+ doc = self._project.storage.getDocument(tHandle)
127
+ self.scanText(tHandle, doc.readDocument() or "", blockSignal=True)
127
128
  self._indexBroken = False
128
129
  SHARED.indexSignalProxy({"event": "buildIndex"})
129
130
  return
@@ -148,8 +149,8 @@ class NWIndex:
148
149
  """
149
150
  if tHandle and self._project.tree.checkType(tHandle, nwItemType.FILE):
150
151
  logger.debug("Re-indexing item '%s'", tHandle)
151
- theDoc = self._project.storage.getDocument(tHandle)
152
- self.scanText(tHandle, theDoc.readDocument() or "")
152
+ doc = self._project.storage.getDocument(tHandle)
153
+ self.scanText(tHandle, doc.readDocument() or "")
153
154
  return True
154
155
  return False
155
156
 
@@ -420,10 +421,10 @@ class NWIndex:
420
421
  return
421
422
 
422
423
  if tBits[0] == nwKeyWords.TAG_KEY:
423
- tagName = tBits[1]
424
- self._tagsIndex.add(tagName, tHandle, sTitle, itemClass)
425
- self._itemIndex.setHeadingTag(tHandle, sTitle, tagName)
426
- tags[tagName.lower()] = True
424
+ tagKey, displayName = self.parseValue(tBits[1])
425
+ self._tagsIndex.add(tagKey, displayName, tHandle, sTitle, itemClass.name)
426
+ self._itemIndex.setHeadingTag(tHandle, sTitle, tagKey)
427
+ tags[tagKey.lower()] = True
427
428
  else:
428
429
  self._itemIndex.addHeadingRef(tHandle, sTitle, tBits[1:], tBits[0])
429
430
 
@@ -471,10 +472,8 @@ class NWIndex:
471
472
 
472
473
  return True, tBits, tPos
473
474
 
474
- def checkThese(self, tBits: list[str], nwItem: NWItem) -> list[bool]:
475
- """Check the tags against the index to see if they are valid
476
- tags. This is needed for syntax highlighting.
477
- """
475
+ def checkThese(self, tBits: list[str], tHandle: str) -> list[bool]:
476
+ """Check tags against the index to see if they are valid."""
478
477
  nBits = len(tBits)
479
478
  isGood = [False]*nBits
480
479
  if nBits == 0:
@@ -487,20 +486,27 @@ class NWIndex:
487
486
 
488
487
  # For a tag, only the first value is accepted, the rest are ignored
489
488
  if tBits[0] == nwKeyWords.TAG_KEY and nBits > 1:
490
- if tBits[1] in self._tagsIndex:
491
- isGood[1] = self._tagsIndex.tagHandle(tBits[1]) == nwItem.itemHandle
489
+ check, _ = self.parseValue(tBits[1])
490
+ if check in self._tagsIndex:
491
+ isGood[1] = self._tagsIndex.tagHandle(check) == tHandle
492
492
  else:
493
493
  isGood[1] = True
494
494
  return isGood
495
495
 
496
496
  # If we're still here, we check that the references exist
497
+ # Class references cannot have the | symbol in them
497
498
  refKey = nwKeyWords.KEY_CLASS[tBits[0]].name
498
499
  for n in range(1, nBits):
499
- if tBits[n] in self._tagsIndex:
500
- isGood[n] = self._tagsIndex.tagClass(tBits[n]) == refKey
500
+ if (aBit := tBits[n]) in self._tagsIndex:
501
+ isGood[n] = self._tagsIndex.tagClass(aBit) == refKey and "|" not in aBit
501
502
 
502
503
  return isGood
503
504
 
505
+ def parseValue(self, text: str) -> tuple[str, str]:
506
+ """Parse a single value into a name and display part."""
507
+ name, _, display = text.partition("|")
508
+ return name.rstrip(), display.lstrip()
509
+
504
510
  ##
505
511
  # Extract Data
506
512
  ##
@@ -517,28 +523,31 @@ class NWIndex:
517
523
  return None
518
524
 
519
525
  def novelStructure(
520
- self, rootHandle: str | None = None, skipExcl: bool = True
526
+ self, rootHandle: str | None = None, activeOnly: bool = True
521
527
  ) -> Iterator[tuple[str, str, str, IndexHeading]]:
522
528
  """Iterate over all titles in the novel, in the correct order as
523
529
  they appear in the tree view and in the respective document
524
530
  files, but skipping all note files.
525
531
  """
526
- structure = self._itemIndex.iterNovelStructure(rHandle=rootHandle, skipExcl=skipExcl)
532
+ structure = self._itemIndex.iterNovelStructure(rHandle=rootHandle, activeOnly=activeOnly)
527
533
  for tHandle, sTitle, hItem in structure:
528
534
  yield f"{tHandle}:{sTitle}", tHandle, sTitle, hItem
529
535
  return
530
536
 
531
- def getNovelWordCount(self, skipExcl: bool = True) -> int:
532
- """Count the number of words in the novel project."""
533
- wCount = 0
534
- for _, _, hItem in self._itemIndex.iterNovelStructure(skipExcl=skipExcl):
535
- wCount += hItem.wordCount
536
- return wCount
537
+ def getNovelWordCount(self, rootHandle: str | None = None, activeOnly: bool = True) -> int:
538
+ """Count the number of words in one or all novel roots."""
539
+ return sum(hItem.wordCount for _, _, hItem in self._itemIndex.iterNovelStructure(
540
+ rHandle=rootHandle, activeOnly=activeOnly
541
+ ))
537
542
 
538
- def getNovelTitleCounts(self, skipExcl: bool = True) -> list[int]:
539
- """Count the number of titles in the novel project."""
543
+ def getNovelTitleCounts(
544
+ self, rootHandle: str | None = None, activeOnly: bool = True
545
+ ) -> list[int]:
546
+ """Count the number of titles in one or all novel roots."""
540
547
  hCount = [0, 0, 0, 0, 0]
541
- for _, _, hItem in self._itemIndex.iterNovelStructure(skipExcl=skipExcl):
548
+ for _, _, hItem in self._itemIndex.iterNovelStructure(
549
+ rHandle=rootHandle, activeOnly=activeOnly
550
+ ):
542
551
  iLevel = nwHeaders.H_LEVEL.get(hItem.level, 0)
543
552
  hCount[iLevel] += 1
544
553
  return hCount
@@ -551,14 +560,14 @@ class NWIndex:
551
560
  return 0
552
561
 
553
562
  def getTableOfContents(
554
- self, rHandle: str, maxDepth: int, skipExcl: bool = True
563
+ self, rHandle: str | None, maxDepth: int, activeOnly: bool = True
555
564
  ) -> list[tuple[str, int, str, int]]:
556
565
  """Generate a table of contents up to a maximum depth."""
557
566
  tOrder = []
558
567
  tData = {}
559
568
  pKey = None
560
569
  for tHandle, sTitle, hItem in self._itemIndex.iterNovelStructure(
561
- rHandle=rHandle, skipExcl=skipExcl
570
+ rHandle=rHandle, activeOnly=activeOnly
562
571
  ):
563
572
  tKey = f"{tHandle}:{sTitle}"
564
573
  iLevel = nwHeaders.H_LEVEL.get(hItem.level, 0)
@@ -612,9 +621,18 @@ class NWIndex:
612
621
  for refType in refTypes:
613
622
  if refType in tRefs:
614
623
  tRefs[refType].append(self._tagsIndex.tagName(aTag))
615
-
616
624
  return tRefs
617
625
 
626
+ def getReferenceForHeader(self, tHandle: str, nHead: int, keyClass: str) -> list[str]:
627
+ """Get the display names for a tags class for insertion into a
628
+ heading by one of the build classes.
629
+ """
630
+ if iItem := self._itemIndex[tHandle]:
631
+ if hItem := iItem[f"T{nHead:04d}"]:
632
+ hRefs = [k for k, v in hItem.references.items() if keyClass in v]
633
+ return [self._tagsIndex.tagDisplay(k) for k in hRefs]
634
+ return []
635
+
618
636
  def getBackReferenceList(self, tHandle: str) -> dict[str, tuple[str, IndexHeading]]:
619
637
  """Build a dict of files referring back to our file."""
620
638
  if tHandle is None or tHandle not in self._itemIndex:
@@ -646,12 +664,15 @@ class NWIndex:
646
664
  """Return all tags based on itemClass."""
647
665
  return self._tagsIndex.filterTagNames(itemClass.name)
648
666
 
649
- def getTagsData(self) -> Iterator[tuple[str, str, str, IndexItem | None, IndexHeading | None]]:
667
+ def getTagsData(
668
+ self, activeOnly: bool = True
669
+ ) -> Iterator[tuple[str, str, str, IndexItem | None, IndexHeading | None]]:
650
670
  """Return all known tags."""
651
671
  for tag, data in self._tagsIndex.items():
652
672
  iItem = self._itemIndex[data.get("handle")]
653
673
  hItem = None if iItem is None else iItem[data.get("heading")]
654
- yield tag, data.get("name", ""), data.get("class", ""), iItem, hItem
674
+ if not activeOnly or (iItem and iItem.item.isActive):
675
+ yield tag, data.get("name", ""), data.get("class", ""), iItem, hItem
655
676
  return
656
677
 
657
678
  def getSingleTag(self, tagKey: str) -> tuple[str, str, IndexItem | None, IndexHeading | None]:
@@ -709,17 +730,26 @@ class TagsIndex:
709
730
  """Return a dictionary view of all tags."""
710
731
  return self._tags.items()
711
732
 
712
- def add(self, tagKey: str, tHandle: str, sTitle: str, itemClass: nwItemClass) -> None:
733
+ def add(self, tagKey: str, displayName: str, tHandle: str,
734
+ sTitle: str, className: str) -> None:
713
735
  """Add a key to the index and set all values."""
714
736
  self._tags[tagKey.lower()] = {
715
- "name": tagKey, "handle": tHandle, "heading": sTitle, "class": itemClass.name
737
+ "name": tagKey,
738
+ "display": displayName or tagKey,
739
+ "handle": tHandle,
740
+ "heading": sTitle,
741
+ "class": className,
716
742
  }
717
743
  return
718
744
 
719
745
  def tagName(self, tagKey: str) -> str:
720
- """Get the display name of a given tag."""
746
+ """Get the name of a given tag."""
721
747
  return self._tags.get(tagKey.lower(), {}).get("name", "")
722
748
 
749
+ def tagDisplay(self, tagKey: str) -> str:
750
+ """Get the display name of a given tag."""
751
+ return self._tags.get(tagKey.lower(), {}).get("display", "")
752
+
723
753
  def tagHandle(self, tagKey: str) -> str | None:
724
754
  """Get the handle of a given tag."""
725
755
  return self._tags.get(tagKey.lower(), {}).get("handle", None)
@@ -754,27 +784,30 @@ class TagsIndex:
754
784
  if not isinstance(data, dict):
755
785
  raise ValueError("tagsIndex is not a dict")
756
786
 
757
- for tagKey, tagData in data.items():
758
- if not isinstance(tagKey, str):
759
- raise ValueError("tagsIndex keys must be a strings")
760
- if "name" not in tagData:
761
- raise KeyError("A tagIndex item is missing a name entry")
762
- if "handle" not in tagData:
763
- raise KeyError("A tagIndex item is missing a handle entry")
764
- if "heading" not in tagData:
765
- raise KeyError("A tagIndex item is missing a heading entry")
766
- if "class" not in tagData:
767
- raise KeyError("A tagIndex item is missing a class entry")
768
- if tagData["name"].lower() != tagKey:
769
- raise ValueError("tagsIndex name must match key")
770
- if not isHandle(tagData["handle"]):
787
+ for key, entry in data.items():
788
+ if not isinstance(key, str):
789
+ raise ValueError("tagsIndex keys must be a string")
790
+ if not isinstance(entry, dict):
791
+ raise ValueError("tagsIndex entry is not a dict")
792
+
793
+ name = entry.get("name")
794
+ display = entry.get("display")
795
+ handle = entry.get("handle")
796
+ heading = entry.get("heading")
797
+ className = entry.get("class")
798
+
799
+ if not isinstance(name, str):
800
+ raise ValueError("tagsIndex name is not a string")
801
+ if not isinstance(display, str):
802
+ raise ValueError("tagsIndex display is not a string")
803
+ if not isHandle(handle):
771
804
  raise ValueError("tagsIndex handle must be a handle")
772
- if not isTitleTag(tagData["heading"]):
805
+ if not isTitleTag(heading):
773
806
  raise ValueError("tagsIndex heading must be a title tag")
774
- if not isItemClass(tagData["class"]):
807
+ if not isItemClass(className):
775
808
  raise ValueError("tagsIndex handle must be an nwItemClass")
776
809
 
777
- self._tags = data
810
+ self.add(name, display, handle, heading, className)
778
811
 
779
812
  return
780
813
 
@@ -848,7 +881,7 @@ class ItemIndex:
848
881
  return
849
882
 
850
883
  def iterNovelStructure(
851
- self, rHandle: str | None = None, skipExcl: bool = False
884
+ self, rHandle: str | None = None, activeOnly: bool = False
852
885
  ) -> Iterable[tuple[str, str, IndexHeading]]:
853
886
  """Iterate over all items and headers in the novel structure for
854
887
  a given root handle, or for all if root handle is None.
@@ -856,7 +889,7 @@ class ItemIndex:
856
889
  for tItem in self._project.tree:
857
890
  if tItem.isNoteLayout():
858
891
  continue
859
- if skipExcl and not tItem.isActive:
892
+ if activeOnly and not tItem.isActive:
860
893
  continue
861
894
 
862
895
  tHandle = tItem.itemHandle
@@ -1159,7 +1192,7 @@ class IndexHeading:
1159
1192
  return self._tag
1160
1193
 
1161
1194
  @property
1162
- def references(self) -> dict:
1195
+ def references(self) -> dict[str, set[str]]:
1163
1196
  return self._refs
1164
1197
 
1165
1198
  ##
novelwriter/core/item.py CHANGED
@@ -3,10 +3,10 @@ novelWriter – Project Item Class
3
3
  ================================
4
4
 
5
5
  File History:
6
- Created: 2018-10-27 [0.0.1]
6
+ Created: 2018-10-27 [0.0.1] NWItem
7
7
 
8
8
  This file is a part of novelWriter
9
- Copyright 2018–2023, Veronica Berglyd Olsen
9
+ Copyright 2018–2024, Veronica Berglyd Olsen
10
10
 
11
11
  This program is free software: you can redistribute it and/or modify
12
12
  it under the terms of the GNU General Public License as published by
@@ -334,15 +334,33 @@ class NWItem:
334
334
 
335
335
  def isNovelLike(self) -> bool:
336
336
  """Check if the item is of a novel-like class."""
337
- return self._class in (nwItemClass.NOVEL, nwItemClass.ARCHIVE)
337
+ return self._class in (
338
+ nwItemClass.NOVEL,
339
+ nwItemClass.ARCHIVE,
340
+ nwItemClass.TEMPLATE,
341
+ )
342
+
343
+ def isTemplateFile(self) -> bool:
344
+ """Check if the item is a template file."""
345
+ return self._type == nwItemType.FILE and self._class == nwItemClass.TEMPLATE
338
346
 
339
347
  def documentAllowed(self) -> bool:
340
348
  """Check if the item is allowed to be of document layout."""
341
- return self._class in (nwItemClass.NOVEL, nwItemClass.ARCHIVE, nwItemClass.TRASH)
349
+ return self._class in (
350
+ nwItemClass.NOVEL,
351
+ nwItemClass.ARCHIVE,
352
+ nwItemClass.TEMPLATE,
353
+ nwItemClass.TRASH,
354
+ )
342
355
 
343
356
  def isInactiveClass(self) -> bool:
344
357
  """Check if the item is in an inactive class."""
345
- return self._class in (nwItemClass.NO_CLASS, nwItemClass.ARCHIVE, nwItemClass.TRASH)
358
+ return self._class in (
359
+ nwItemClass.NO_CLASS,
360
+ nwItemClass.ARCHIVE,
361
+ nwItemClass.TEMPLATE,
362
+ nwItemClass.TRASH,
363
+ )
346
364
 
347
365
  def isRootType(self) -> bool:
348
366
  """Check if item is a root item."""
@@ -3,11 +3,11 @@ novelWriter – Project Options Cache
3
3
  ===================================
4
4
 
5
5
  File History:
6
- Created: 2019-10-21 [0.3.1]
7
- Rewritten: 2020-02-19 [0.4.5]
6
+ Created: 2019-10-21 [0.3.1] OptionState
7
+ Rewritten: 2020-02-19 [0.4.5] OptionState
8
8
 
9
9
  This file is a part of novelWriter
10
- Copyright 2018–2023, Veronica Berglyd Olsen
10
+ Copyright 2018–2024, Veronica Berglyd Olsen
11
11
 
12
12
  This program is free software: you can redistribute it and/or modify
13
13
  it under the terms of the GNU General Public License as published by
@@ -42,7 +42,7 @@ logger = logging.getLogger(__name__)
42
42
 
43
43
  NWEnum = TypeVar("NWEnum", bound=Enum)
44
44
 
45
- VALID_MAP = {
45
+ VALID_MAP: dict[str, set[str]] = {
46
46
  "GuiWritingStats": {
47
47
  "winWidth", "winHeight", "widthCol0", "widthCol1", "widthCol2",
48
48
  "widthCol3", "sortCol", "sortOrder", "incNovel", "incNotes",
@@ -53,10 +53,6 @@ VALID_MAP = {
53
53
  "GuiProjectSettings": {
54
54
  "winWidth", "winHeight", "replaceColW", "statusColW", "importColW",
55
55
  },
56
- "GuiProjectDetails": {
57
- "winWidth", "winHeight", "widthCol0", "widthCol1", "widthCol2",
58
- "widthCol3", "widthCol4", "wordsPerPage", "countFrom", "clearDouble",
59
- },
60
56
  "GuiWordList": {"winWidth", "winHeight"},
61
57
  "GuiNovelView": {"lastCol", "lastColSize"},
62
58
  "GuiBuildSettings": {
@@ -70,8 +66,13 @@ VALID_MAP = {
70
66
  "winWidth", "winHeight", "fmtWidth", "sumWidth",
71
67
  },
72
68
  "GuiDocViewerPanel": {
73
- "colWidths",
74
- }
69
+ "colWidths", "hideInactive",
70
+ },
71
+ "GuiNovelDetails": {
72
+ "winWidth", "winHeight", "widthCol0", "widthCol1", "widthCol2",
73
+ "widthCol3", "widthCol4", "wordsPerPage", "countFrom", "clearDouble",
74
+ "novelRoot",
75
+ },
75
76
  }
76
77
 
77
78
 
@@ -3,10 +3,10 @@ novelWriter – Project Wrapper
3
3
  =============================
4
4
 
5
5
  File History:
6
- Created: 2018-09-29 [0.0.1]
6
+ Created: 2018-09-29 [0.0.1] NWProject
7
7
 
8
8
  This file is a part of novelWriter
9
- Copyright 2018–2023, Veronica Berglyd Olsen
9
+ Copyright 2018–2024, Veronica Berglyd Olsen
10
10
 
11
11
  This program is free software: you can redistribute it and/or modify
12
12
  it under the terms of the GNU General Public License as published by
@@ -26,10 +26,12 @@ from __future__ import annotations
26
26
  import json
27
27
  import logging
28
28
 
29
+ from enum import Enum
29
30
  from time import time
30
- from typing import TYPE_CHECKING, Iterator
31
+ from typing import TYPE_CHECKING
31
32
  from pathlib import Path
32
33
  from functools import partial
34
+ from collections.abc import Iterator
33
35
 
34
36
  from PyQt5.QtCore import QCoreApplication
35
37
 
@@ -40,7 +42,7 @@ from novelwriter.constants import trConst, nwLabels
40
42
  from novelwriter.core.tree import NWTree
41
43
  from novelwriter.core.index import NWIndex
42
44
  from novelwriter.core.options import OptionState
43
- from novelwriter.core.storage import NWStorage
45
+ from novelwriter.core.storage import NWStorage, NWStorageOpen
44
46
  from novelwriter.core.sessions import NWSessionLog
45
47
  from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter, XMLReadState
46
48
  from novelwriter.core.projectdata import NWProjectData
@@ -55,6 +57,16 @@ if TYPE_CHECKING: # pragma: no cover
55
57
  logger = logging.getLogger(__name__)
56
58
 
57
59
 
60
+ class NWProjectState(Enum):
61
+
62
+ UNKNOWN = 0
63
+ LOCKED = 1
64
+ RECOVERY = 2
65
+ READY = 3
66
+
67
+ # END Enum NWProjectState
68
+
69
+
58
70
  class NWProject:
59
71
 
60
72
  def __init__(self) -> None:
@@ -69,9 +81,9 @@ class NWProject:
69
81
 
70
82
  # Project Status
71
83
  self._langData = {} # Localisation data
72
- self._lockedBy = None # Data on which computer has the project open
73
84
  self._changed = False # The project has unsaved changes
74
85
  self._valid = False # The project was successfully loaded
86
+ self._state = NWProjectState.UNKNOWN
75
87
 
76
88
  # Internal Mapping
77
89
  self.tr = partial(QCoreApplication.translate, "NWProject")
@@ -80,7 +92,7 @@ class NWProject:
80
92
 
81
93
  return
82
94
 
83
- def __del__(self): # pragma: no cover
95
+ def __del__(self) -> None: # pragma: no cover
84
96
  logger.debug("Delete: NWProject")
85
97
  return
86
98
 
@@ -125,12 +137,15 @@ class NWProject:
125
137
  """Return True if a project is loaded."""
126
138
  return self._valid
127
139
 
140
+ @property
141
+ def state(self) -> NWProjectState:
142
+ """Return the current project state."""
143
+ return self._state
144
+
128
145
  @property
129
146
  def lockStatus(self) -> list | None:
130
147
  """Return the project lock information."""
131
- if isinstance(self._lockedBy, list) and len(self._lockedBy) == 4:
132
- return self._lockedBy
133
- return None
148
+ return self._storage.lockStatus
134
149
 
135
150
  @property
136
151
  def currentEditTime(self) -> int:
@@ -161,24 +176,47 @@ class NWProject:
161
176
  will not run if the file exists and is not empty.
162
177
  """
163
178
  tItem = self._tree[tHandle]
164
- if tItem is None:
165
- return False
166
- if not tItem.isFileType():
179
+ if not (tItem and tItem.isFileType()):
167
180
  return False
168
181
 
169
182
  newDoc = self._storage.getDocument(tHandle)
170
183
  if (newDoc.readDocument() or "").strip():
171
184
  return False
172
185
 
173
- hshText = "#"*minmax(hLevel, 1, 4)
174
- newText = f"{hshText} {tItem.itemName}\n\n{text}"
186
+ indent = "#"*minmax(hLevel, 1, 4)
187
+ text = f"{indent} {tItem.itemName}\n\n{text}"
188
+
175
189
  if tItem.isNovelLike() and isDocument:
176
190
  tItem.setLayout(nwItemLayout.DOCUMENT)
177
191
  else:
178
192
  tItem.setLayout(nwItemLayout.NOTE)
179
193
 
180
- newDoc.writeDocument(newText)
181
- self._index.scanText(tHandle, newText)
194
+ newDoc.writeDocument(text)
195
+ self._index.scanText(tHandle, text)
196
+
197
+ return True
198
+
199
+ def copyFileContent(self, tHandle: str, sHandle: str) -> bool:
200
+ """Copy content to a new document after it is created. This
201
+ will not run if the file exists and is not empty.
202
+ """
203
+ tItem = self._tree[tHandle]
204
+ if not (tItem and tItem.isFileType()):
205
+ return False
206
+
207
+ sItem = self._tree[sHandle]
208
+ if not (sItem and sItem.isFileType()):
209
+ return False
210
+
211
+ newDoc = self._storage.getDocument(tHandle)
212
+ if (newDoc.readDocument() or "").strip():
213
+ return False
214
+
215
+ logger.debug("Populating '%s' with text from '%s'", tHandle, sHandle)
216
+ text = self._storage.getDocument(sHandle).readDocument() or ""
217
+ newDoc.writeDocument(text)
218
+ sItem.setLayout(tItem.itemLayout)
219
+ self._index.scanText(tHandle, text)
182
220
 
183
221
  return True
184
222
 
@@ -219,29 +257,21 @@ class NWProject:
219
257
  build the tree of project items.
220
258
  """
221
259
  logger.info("Opening project: %s", projPath)
222
- if not self._storage.openProjectInPlace(projPath):
223
- SHARED.error(self.tr("Could not open project with path: {0}").format(projPath))
224
- return False
225
-
226
- # Project Lock
227
- # ============
228
260
 
229
- if clearLock:
230
- self._storage.clearLockFile()
231
-
232
- lockStatus = self._storage.readLockFile()
233
- if len(lockStatus) > 0:
234
- if lockStatus[0] == "ERROR":
235
- logger.warning("Failed to check lock file")
236
- else:
237
- logger.error("Project is locked, so not opening")
238
- self._lockedBy = lockStatus
239
- return False
240
- else:
241
- logger.debug("Project is not locked")
261
+ status = self._storage.initProjectStorage(projPath, clearLock)
262
+ if status != NWStorageOpen.READY:
263
+ if status == NWStorageOpen.UNKOWN:
264
+ SHARED.error(self.tr("Not a known project file format."))
265
+ elif status == NWStorageOpen.NOT_FOUND:
266
+ SHARED.error(self.tr("Project file not found."))
267
+ elif status == NWStorageOpen.LOCKED:
268
+ self._state = NWProjectState.LOCKED
269
+ elif status == NWStorageOpen.FAILED:
270
+ SHARED.error(self.tr("Failed to open project."), exc=self._storage.exc)
271
+ return False
242
272
 
243
- # Open The Project XML File
244
- # =========================
273
+ # Read Project XML
274
+ # ================
245
275
 
246
276
  xmlReader = self._storage.getXmlReader()
247
277
  if not isinstance(xmlReader, ProjectXMLReader):
@@ -250,9 +280,7 @@ class NWProject:
250
280
  self._data = NWProjectData(self)
251
281
  projContent = []
252
282
  xmlParsed = xmlReader.read(self._data, projContent)
253
-
254
283
  appVersion = xmlReader.appVersion or self.tr("Unknown")
255
-
256
284
  if not xmlParsed:
257
285
  if xmlReader.state == XMLReadState.NOT_NWX_FILE:
258
286
  SHARED.error(self.tr(
@@ -302,8 +330,7 @@ class NWProject:
302
330
  self._loadProjectLocalisation()
303
331
 
304
332
  # Update recent projects
305
- storePath = self._storage.storagePath
306
- if storePath:
333
+ if storePath := self._storage.storagePath:
307
334
  CONFIG.recentProjects.update(
308
335
  storePath, self._data.name, sum(self._data.initCounts), time()
309
336
  )
@@ -323,9 +350,9 @@ class NWProject:
323
350
 
324
351
  self.updateWordCounts()
325
352
  self._session.startSession()
326
- self._storage.writeLockFile()
327
353
  self.setProjectChanged(False)
328
354
  self._valid = True
355
+ self._state = NWProjectState.READY
329
356
 
330
357
  SHARED.newStatusMessage(self.tr("Opened Project: {0}").format(self._data.name))
331
358
 
@@ -370,13 +397,11 @@ class NWProject:
370
397
  self._storage.runPostSaveTasks(autoSave=autoSave)
371
398
 
372
399
  # Update recent projects
373
- storePath = self._storage.storagePath
374
- if storePath:
400
+ if storagePath := self._storage.storagePath:
375
401
  CONFIG.recentProjects.update(
376
- storePath, self._data.name, sum(self._data.currCounts), saveTime
402
+ storagePath, self._data.name, sum(self._data.currCounts), saveTime
377
403
  )
378
404
 
379
- self._storage.writeLockFile()
380
405
  SHARED.newStatusMessage(self.tr("Saved Project: {0}").format(self._data.name))
381
406
  self.setProjectChanged(False)
382
407
 
@@ -420,8 +445,8 @@ class NWProject:
420
445
  timeStamp = formatTimeStamp(time(), fileSafe=True)
421
446
  archName = baseDir / f"{cleanName} {timeStamp}.zip"
422
447
  if self._storage.zipIt(archName, compression=2):
423
- size = formatInt(getFileSize(archName))
424
448
  if doNotify:
449
+ size = formatInt(getFileSize(archName))
425
450
  SHARED.info(
426
451
  self.tr("Created a backup of your project of size {0}B.").format(size),
427
452
  info=self.tr("Path: {0}").format(str(backupPath))