novelWriter 2.6b1__py3-none-any.whl → 2.6rc1__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 (114) hide show
  1. {novelWriter-2.6b1.dist-info → novelWriter-2.6rc1.dist-info}/METADATA +4 -4
  2. {novelWriter-2.6b1.dist-info → novelWriter-2.6rc1.dist-info}/RECORD +114 -98
  3. {novelWriter-2.6b1.dist-info → novelWriter-2.6rc1.dist-info}/WHEEL +1 -1
  4. novelwriter/__init__.py +50 -11
  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_nl_NL.qm +0 -0
  13. novelwriter/assets/i18n/nw_pl_PL.qm +0 -0
  14. novelwriter/assets/i18n/nw_pt_BR.qm +0 -0
  15. novelwriter/assets/i18n/nw_ru_RU.qm +0 -0
  16. novelwriter/assets/i18n/nw_zh_CN.qm +0 -0
  17. novelwriter/assets/i18n/project_de_DE.json +2 -2
  18. novelwriter/assets/i18n/project_ru_RU.json +11 -0
  19. novelwriter/assets/icons/typicons_dark/icons.conf +7 -0
  20. novelwriter/assets/icons/typicons_dark/mixed_margin-bottom.svg +6 -0
  21. novelwriter/assets/icons/typicons_dark/mixed_margin-left.svg +6 -0
  22. novelwriter/assets/icons/typicons_dark/mixed_margin-right.svg +6 -0
  23. novelwriter/assets/icons/typicons_dark/mixed_margin-top.svg +6 -0
  24. novelwriter/assets/icons/typicons_dark/mixed_size-height.svg +6 -0
  25. novelwriter/assets/icons/typicons_dark/mixed_size-width.svg +6 -0
  26. novelwriter/assets/icons/typicons_dark/nw_toolbar.svg +5 -0
  27. novelwriter/assets/icons/typicons_light/icons.conf +7 -0
  28. novelwriter/assets/icons/typicons_light/mixed_margin-bottom.svg +6 -0
  29. novelwriter/assets/icons/typicons_light/mixed_margin-left.svg +6 -0
  30. novelwriter/assets/icons/typicons_light/mixed_margin-right.svg +6 -0
  31. novelwriter/assets/icons/typicons_light/mixed_margin-top.svg +6 -0
  32. novelwriter/assets/icons/typicons_light/mixed_size-height.svg +6 -0
  33. novelwriter/assets/icons/typicons_light/mixed_size-width.svg +6 -0
  34. novelwriter/assets/icons/typicons_light/nw_toolbar.svg +5 -0
  35. novelwriter/assets/manual.pdf +0 -0
  36. novelwriter/assets/sample.zip +0 -0
  37. novelwriter/assets/text/credits_en.htm +1 -0
  38. novelwriter/common.py +38 -3
  39. novelwriter/config.py +19 -13
  40. novelwriter/constants.py +60 -45
  41. novelwriter/core/buildsettings.py +1 -1
  42. novelwriter/core/coretools.py +112 -126
  43. novelwriter/core/docbuild.py +4 -3
  44. novelwriter/core/document.py +1 -1
  45. novelwriter/core/index.py +10 -20
  46. novelwriter/core/item.py +40 -7
  47. novelwriter/core/itemmodel.py +518 -0
  48. novelwriter/core/options.py +1 -1
  49. novelwriter/core/project.py +68 -90
  50. novelwriter/core/projectdata.py +8 -2
  51. novelwriter/core/projectxml.py +1 -1
  52. novelwriter/core/sessions.py +1 -1
  53. novelwriter/core/spellcheck.py +1 -1
  54. novelwriter/core/status.py +24 -8
  55. novelwriter/core/storage.py +1 -1
  56. novelwriter/core/tree.py +269 -288
  57. novelwriter/dialogs/about.py +1 -1
  58. novelwriter/dialogs/docmerge.py +8 -18
  59. novelwriter/dialogs/docsplit.py +1 -1
  60. novelwriter/dialogs/editlabel.py +1 -1
  61. novelwriter/dialogs/preferences.py +4 -4
  62. novelwriter/dialogs/projectsettings.py +148 -98
  63. novelwriter/dialogs/quotes.py +1 -1
  64. novelwriter/dialogs/wordlist.py +11 -10
  65. novelwriter/enum.py +8 -1
  66. novelwriter/error.py +2 -2
  67. novelwriter/extensions/configlayout.py +7 -5
  68. novelwriter/extensions/eventfilters.py +1 -1
  69. novelwriter/extensions/modified.py +17 -5
  70. novelwriter/extensions/novelselector.py +1 -1
  71. novelwriter/extensions/pagedsidebar.py +4 -4
  72. novelwriter/extensions/progressbars.py +4 -4
  73. novelwriter/extensions/statusled.py +3 -3
  74. novelwriter/extensions/switch.py +3 -3
  75. novelwriter/extensions/switchbox.py +1 -1
  76. novelwriter/extensions/versioninfo.py +1 -1
  77. novelwriter/formats/shared.py +1 -1
  78. novelwriter/formats/todocx.py +35 -39
  79. novelwriter/formats/tohtml.py +15 -16
  80. novelwriter/formats/tokenizer.py +26 -22
  81. novelwriter/formats/tomarkdown.py +1 -1
  82. novelwriter/formats/toodt.py +54 -125
  83. novelwriter/formats/toqdoc.py +93 -45
  84. novelwriter/formats/toraw.py +1 -1
  85. novelwriter/gui/doceditor.py +233 -220
  86. novelwriter/gui/dochighlight.py +1 -1
  87. novelwriter/gui/docviewer.py +39 -10
  88. novelwriter/gui/docviewerpanel.py +15 -23
  89. novelwriter/gui/editordocument.py +1 -1
  90. novelwriter/gui/itemdetails.py +20 -27
  91. novelwriter/gui/mainmenu.py +14 -9
  92. novelwriter/gui/noveltree.py +13 -13
  93. novelwriter/gui/outline.py +18 -20
  94. novelwriter/gui/projtree.py +545 -1201
  95. novelwriter/gui/search.py +11 -19
  96. novelwriter/gui/sidebar.py +1 -1
  97. novelwriter/gui/statusbar.py +20 -3
  98. novelwriter/gui/theme.py +8 -4
  99. novelwriter/guimain.py +60 -48
  100. novelwriter/shared.py +53 -24
  101. novelwriter/text/counting.py +1 -1
  102. novelwriter/text/patterns.py +18 -6
  103. novelwriter/tools/dictionaries.py +1 -1
  104. novelwriter/tools/lipsum.py +1 -1
  105. novelwriter/tools/manusbuild.py +14 -12
  106. novelwriter/tools/manuscript.py +7 -7
  107. novelwriter/tools/manussettings.py +43 -53
  108. novelwriter/tools/noveldetails.py +1 -1
  109. novelwriter/tools/welcome.py +1 -1
  110. novelwriter/tools/writingstats.py +1 -1
  111. novelwriter/types.py +9 -3
  112. {novelWriter-2.6b1.dist-info → novelWriter-2.6rc1.dist-info}/LICENSE.md +0 -0
  113. {novelWriter-2.6b1.dist-info → novelWriter-2.6rc1.dist-info}/entry_points.txt +0 -0
  114. {novelWriter-2.6b1.dist-info → novelWriter-2.6rc1.dist-info}/top_level.txt +0 -0
novelwriter/core/tree.py CHANGED
@@ -3,10 +3,11 @@ novelWriter – Project Tree Class
3
3
  ================================
4
4
 
5
5
  File History:
6
- Created: 2020-05-07 [0.4.5] NWTree
6
+ Created: 2020-05-07 [0.4.5] NWTree
7
+ Rewritten: 2024-11-16 [2.6b2] NWTree
7
8
 
8
9
  This file is a part of novelWriter
9
- Copyright 2018–2024, Veronica Berglyd Olsen
10
+ Copyright (C) 2020 Veronica Berglyd Olsen and novelWriter contributors
10
11
 
11
12
  This program is free software: you can redistribute it and/or modify
12
13
  it under the terms of the GNU General Public License as published by
@@ -30,10 +31,13 @@ from collections.abc import Iterable, Iterator
30
31
  from pathlib import Path
31
32
  from typing import TYPE_CHECKING, Literal, overload
32
33
 
33
- from novelwriter.common import isHandle
34
- from novelwriter.constants import nwFiles
34
+ from PyQt5.QtCore import QModelIndex
35
+
36
+ from novelwriter import SHARED
37
+ from novelwriter.constants import nwFiles, nwLabels, trConst
35
38
  from novelwriter.core.item import NWItem
36
- from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType
39
+ from novelwriter.core.itemmodel import ProjectModel, ProjectNode
40
+ from novelwriter.enum import nwChange, nwItemClass, nwItemLayout, nwItemType
37
41
  from novelwriter.error import logException
38
42
 
39
43
  if TYPE_CHECKING: # pragma: no cover
@@ -41,7 +45,7 @@ if TYPE_CHECKING: # pragma: no cover
41
45
 
42
46
  logger = logging.getLogger(__name__)
43
47
 
44
- MAX_DEPTH = 1000 # Cap of tree traversing for loops (recursion limit)
48
+ MAX_DEPTH = 999 # Cap of tree traversing for loops (recursion limit)
45
49
 
46
50
 
47
51
  class NWTree:
@@ -51,30 +55,51 @@ class NWTree:
51
55
  This class holds all the project items of the project as instances
52
56
  of NWItem.
53
57
 
54
- For historical reasons, the order of the items is saved in a
55
- separate list from the items themselves, which are stored in a
56
- dictionary. This is somewhat redundant with the newer versions of
57
- Python, but is still practical as it's easier to update the item
58
- order as a list.
59
-
60
58
  Each item has a handle, which is a random hex string of length 13.
61
59
  The handle is the name of the item everywhere in novelWriter, and is
62
60
  also used for file names.
63
61
  """
64
62
 
65
- __slots__ = ("_project", "_tree", "_order", "_roots", "_trash", "_changed")
63
+ __slots__ = ("_project", "_model", "_items", "_nodes", "_trash")
66
64
 
67
65
  def __init__(self, project: NWProject) -> None:
68
-
69
66
  self._project = project
67
+ self._model = ProjectModel(self)
68
+ self._items: dict[str, NWItem] = {}
69
+ self._nodes: dict[str, ProjectNode] = {}
70
+ self._trash = None
71
+ logger.debug("Ready: NWTree")
72
+ return
70
73
 
71
- self._tree: dict[str, NWItem] = {} # Holds all the items of the project
72
- self._order: list[str] = [] # The order of the tree items in the tree view
73
- self._roots: dict[str, NWItem] = {} # The root items of the tree
74
+ def __del__(self) -> None: # pragma: no cover
75
+ logger.debug("Delete: NWTree")
76
+ return
77
+
78
+ def __len__(self) -> int:
79
+ """The number of items in the project."""
80
+ return len(self._items)
74
81
 
75
- self._trash = None # The handle of the trash root folder
76
- self._changed = False # True if tree structure has changed
82
+ def __bool__(self) -> bool:
83
+ """True if there are any items in the project."""
84
+ return bool(self._items)
77
85
 
86
+ def __getitem__(self, tHandle: str | None) -> NWItem | None:
87
+ """Return a project item based on its handle. Returns None if
88
+ the handle doesn't exist in the project.
89
+ """
90
+ if tHandle and tHandle in self._items:
91
+ return self._items[tHandle]
92
+ logger.error("No tree item with handle '%s'", str(tHandle))
93
+ return None
94
+
95
+ def __contains__(self, tHandle: str) -> bool:
96
+ """Checks if a handle exists in the tree."""
97
+ return tHandle in self._items
98
+
99
+ def __iter__(self) -> Iterator[NWItem]:
100
+ """Iterate through project items."""
101
+ for node in self._model.root.allChildren():
102
+ yield node.item
78
103
  return
79
104
 
80
105
  ##
@@ -82,9 +107,19 @@ class NWTree:
82
107
  ##
83
108
 
84
109
  @property
85
- def trashRoot(self) -> str | None:
86
- """Return the handle of the trash folder, or None."""
87
- return self._trash
110
+ def trash(self) -> ProjectNode | None:
111
+ """Return trash node, if it exists."""
112
+ if self._trash:
113
+ return self._trash
114
+ return self._getTrashNode()
115
+
116
+ @property
117
+ def model(self) -> ProjectModel:
118
+ return self._model
119
+
120
+ @property
121
+ def nodes(self) -> dict[str, ProjectNode]:
122
+ return self._nodes
88
123
 
89
124
  ##
90
125
  # Class Methods
@@ -92,83 +127,91 @@ class NWTree:
92
127
 
93
128
  def clear(self) -> None:
94
129
  """Clear the item tree entirely."""
95
- self._tree = {}
96
- self._order = []
97
- self._roots = {}
98
- self._trash = None
99
- self._changed = False
130
+ oldModel = self._model
131
+ oldModel.clear()
132
+ self._model = ProjectModel(self)
133
+ self._items.clear()
134
+ self._nodes.clear()
135
+ self._trash = None
136
+ oldModel.deleteLater()
137
+ del oldModel
100
138
  return
101
139
 
102
- def handles(self) -> list[str]:
103
- """Returns a copy of the list of all the active handles."""
104
- return self._order.copy()
140
+ def add(self, item: NWItem, pos: int = -1) -> bool:
141
+ """Add a project item into the project tree."""
142
+ if pHandle := item.itemParent:
143
+ if parent := self._nodes.get(pHandle):
144
+ node = ProjectNode(item)
145
+ index = self._model.indexFromNode(parent)
146
+ self._model.insertChild(node, index, pos)
147
+ self._nodes[item.itemHandle] = node
148
+ self._items[item.itemHandle] = item
149
+ self._itemChange(item, nwChange.CREATE)
150
+ else:
151
+ logger.error("Could not locate parent of '%s'", item.itemHandle)
152
+ return False
153
+ elif item.isRootType():
154
+ node = ProjectNode(item)
155
+ self._model.insertChild(node, QModelIndex(), pos)
156
+ self._nodes[item.itemHandle] = node
157
+ self._items[item.itemHandle] = item
158
+ self._itemChange(item, nwChange.CREATE)
159
+ else:
160
+ logger.error("Invalid project item '%s'", item.itemHandle)
161
+ return False
162
+ return True
163
+
164
+ def remove(self, tHandle: str) -> bool:
165
+ """Remove an item from the project tree."""
166
+ if (node := self._nodes.get(tHandle)) and tHandle in self._items:
167
+ index = self._model.indexFromNode(node)
168
+ if index.isValid() and self._model.removeChild(index.parent(), index.row()):
169
+ self._itemChange(node.item, nwChange.DELETE)
170
+ del self._nodes[tHandle]
171
+ del self._items[tHandle]
172
+ return True
173
+ return False
105
174
 
106
175
  @overload # pragma: no cover
107
- def create(self, label: str, parent: None, itemType: Literal[nwItemType.ROOT],
108
- itemClass: nwItemClass) -> str:
176
+ def create(
177
+ self, label: str, parent: None, itemType: Literal[nwItemType.ROOT],
178
+ itemClass: nwItemClass, pos: int = -1
179
+ ) -> str:
109
180
  pass
110
181
 
111
182
  @overload # pragma: no cover
112
- def create(self, label: str, parent: str | None, itemType: nwItemType,
113
- itemClass: nwItemClass = nwItemClass.NO_CLASS) -> str | None:
183
+ def create(
184
+ self, label: str, parent: str | None, itemType: nwItemType,
185
+ itemClass: nwItemClass = nwItemClass.NO_CLASS, pos: int = -1
186
+ ) -> str | None:
114
187
  pass
115
188
 
116
- def create(self, label, parent, itemType, itemClass=nwItemClass.NO_CLASS):
189
+ def create(
190
+ self, label: str, parent: str | None, itemType: nwItemType,
191
+ itemClass: nwItemClass = nwItemClass.NO_CLASS, pos: int = -1,
192
+ ) -> str | None:
117
193
  """Create a new item in the project tree, and return its handle.
118
194
  If the item cannot be added to the project because of an invalid
119
195
  parent, None is returned. For root elements, this cannot occur.
120
196
  """
121
197
  parent = None if itemType == nwItemType.ROOT else parent
122
- if parent is None or parent in self._order:
198
+ if parent is None or parent in self._nodes:
123
199
  tHandle = self._makeHandle()
124
- newItem = NWItem(self._project, tHandle)
125
- newItem.setName(label)
126
- newItem.setParent(parent)
127
- newItem.setType(itemType)
128
- newItem.setClass(itemClass)
129
- self.append(newItem)
130
- self.updateItemData(tHandle)
131
- return tHandle
200
+ nwItem = NWItem(self._project, tHandle)
201
+ nwItem.setName(label)
202
+ nwItem.setParent(parent)
203
+ nwItem.setType(itemType)
204
+ nwItem.setClass(itemClass)
205
+ if self.add(nwItem, pos):
206
+ return tHandle
132
207
  return None
133
208
 
134
- def append(self, nwItem: NWItem) -> bool:
135
- """Add a new item to the end of the tree."""
136
- tHandle = nwItem.itemHandle
137
- pHandle = nwItem.itemParent
138
-
139
- if not isHandle(tHandle):
140
- logger.warning("Invalid item handle '%s' detected, skipping", tHandle)
141
- return False
142
-
143
- if tHandle in self._tree:
144
- logger.warning("Duplicate handle '%s' detected, skipping", tHandle)
145
- return False
146
-
147
- logger.debug("Adding item '%s' with parent '%s'", str(tHandle), str(pHandle))
148
-
149
- if nwItem.isRootType():
150
- logger.debug("Item '%s' is a root item", str(tHandle))
151
- self._roots[tHandle] = nwItem
152
- if nwItem.itemClass == nwItemClass.TRASH:
153
- if self._trash is None:
154
- logger.debug("Item '%s' is the trash folder", str(tHandle))
155
- self._trash = tHandle
156
- else:
157
- logger.error("Only one trash folder allowed")
158
- return False
159
-
160
- self._tree[tHandle] = nwItem
161
- self._order.append(tHandle)
162
- self._setTreeChanged(True)
163
-
164
- return True
165
-
166
- def duplicate(self, sHandle: str) -> NWItem | None:
209
+ def duplicate(self, sHandle: str, pHandle: str | None, putAfter: bool) -> NWItem | None:
167
210
  """Duplicate an item and set a new handle."""
168
- sItem = self.__getitem__(sHandle)
169
- if isinstance(sItem, NWItem):
170
- nItem = NWItem.duplicate(sItem, self._makeHandle())
171
- if self.append(nItem):
211
+ if sNode := self._nodes.get(sHandle):
212
+ nItem = NWItem.duplicate(sNode.item, self._makeHandle())
213
+ nItem.setParent(pHandle)
214
+ if self.add(nItem, (sNode.row() + 1) if putAfter else -1):
172
215
  logger.info("Duplicated item '%s' -> '%s'", sHandle, nItem.itemHandle)
173
216
  return nItem
174
217
  return None
@@ -177,23 +220,62 @@ class NWTree:
177
220
  """Pack the content of the tree into a list of dictionaries of
178
221
  items. In the order defined by the _treeOrder list.
179
222
  """
180
- tree = []
181
- for tHandle in self._order:
182
- tItem = self.__getitem__(tHandle)
183
- if tItem:
184
- tree.append(tItem.pack())
185
- return tree
223
+ nodes = self._model.root.allChildren()
224
+ if len(nodes) != len(self._nodes):
225
+ logger.warning(
226
+ "Model tree is inconsitent with nodes map, %d != %d",
227
+ len(nodes), len(self._nodes)
228
+ )
229
+ return [node.item.pack() for node in nodes]
186
230
 
187
231
  def unpack(self, data: list[dict]) -> None:
188
232
  """Iterate through all items of a list and add them to the
189
233
  project tree.
190
234
  """
191
235
  self.clear()
236
+ items: dict[str, NWItem] = self._items.copy()
192
237
  for item in data:
193
- nwItem = NWItem(self._project, "") # Handle is set by unpack()
238
+ nwItem = NWItem(self._project, "")
194
239
  if nwItem.unpack(item):
195
- self.append(nwItem)
196
- nwItem.saveInitialCount()
240
+ items[nwItem.itemHandle] = nwItem
241
+
242
+ later = items
243
+ self._model.beginInsertRows(self._model.index(0, 0), 0, 0)
244
+ for _ in range(MAX_DEPTH):
245
+ later = self._addItems(later)
246
+ if len(later) == 0:
247
+ break
248
+ else:
249
+ logger.error("Not all items could be added to project tree")
250
+
251
+ self._trash = self._getTrashNode()
252
+ self._model.endInsertRows()
253
+ self._model.layoutChanged.emit()
254
+
255
+ return
256
+
257
+ def refreshItems(self, items: list[str]) -> None:
258
+ """Refresh these items on the GUI. If they are an ordered range,
259
+ also set the isRange flag to True.
260
+ """
261
+ for tHandle in items:
262
+ if node := self._nodes.get(tHandle):
263
+ node.refresh()
264
+ node.updateCount()
265
+ indexS = self._model.indexFromNode(node, 0)
266
+ indexE = self._model.indexFromNode(node, 3)
267
+ self._model.dataChanged.emit(indexS, indexE)
268
+ self._itemChange(node.item, nwChange.UPDATE)
269
+ return
270
+
271
+ def refreshAllItems(self) -> None:
272
+ """Refresh all items in the tree."""
273
+ for node in reversed(self._model.root.allChildren()):
274
+ node.refresh()
275
+ node.updateCount(propagate=False)
276
+ self._model.root.refresh()
277
+ self._model.root.updateCount(propagate=False)
278
+ self._model.layoutChanged.emit()
197
279
  return
198
280
 
199
281
  def checkConsistency(self, prefix: str) -> tuple[int, int]:
@@ -205,29 +287,21 @@ class NWTree:
205
287
  mark recovered files.
206
288
  """
207
289
  storage = self._project.storage
208
- files = set(storage.scanContent())
209
- for tHandle in self._order:
210
- if self.updateItemData(tHandle):
211
- logger.debug("Checking item '%s' ... OK", tHandle)
212
- files.discard(tHandle) # Remove it from the record
213
- else:
214
- logger.error("Checking item '%s' ... ERROR", tHandle)
215
- self.__delitem__(tHandle) # The file will be re-added as orphaned
216
-
217
- orphans = len(files)
290
+ remains = set(storage.scanContent()).difference(set(self._nodes.keys()))
291
+ orphans = len(remains)
218
292
  if orphans == 0:
219
293
  logger.info("Checked project files: OK")
220
294
  return 0, 0
221
295
 
222
296
  logger.warning("Found %d file(s) not tracked in project", orphans)
223
297
  recovered = 0
224
- for cHandle in files:
298
+ for cHandle in remains:
225
299
  aDoc = storage.getDocument(cHandle)
226
300
  aDoc.readDocument(isOrphan=True)
227
301
  oName, oParent, oClass, oLayout = aDoc.getMeta()
228
302
 
229
303
  oName = oName or cHandle
230
- oParent = oParent if oParent in self._order else None
304
+ oParent = oParent if oParent in self._nodes else None
231
305
  oClass = oClass or nwItemClass.NOVEL
232
306
  oLayout = oLayout or nwItemLayout.NOTE
233
307
 
@@ -248,8 +322,7 @@ class NWTree:
248
322
  newItem.setType(nwItemType.FILE)
249
323
  newItem.setClass(oClass)
250
324
  newItem.setLayout(oLayout)
251
- if self.append(newItem):
252
- self.updateItemData(cHandle)
325
+ if self.add(newItem):
253
326
  recovered += 1
254
327
 
255
328
  return orphans, recovered
@@ -263,38 +336,33 @@ class NWTree:
263
336
  if not (isinstance(contentPath, Path) and isinstance(runtimePath, Path)):
264
337
  return False
265
338
 
266
- tocList = []
267
- tocLen = 0
268
- for tHandle in self._order:
269
- tItem = self.__getitem__(tHandle)
270
- if tItem is None:
271
- continue
272
-
273
- tFile = tHandle+".nwd"
274
- if (contentPath / tFile).is_file():
339
+ entries = []
340
+ maxLen = 0
341
+ for node in self._model.root.allChildren():
342
+ item = node.item
343
+ file = f"{item.itemHandle}.nwd"
344
+ if (contentPath / file).is_file():
275
345
  tocLine = "{0:<25s} {1:<9s} {2:<8s} {3:s}".format(
276
- str(Path("content") / tFile),
277
- tItem.itemClass.name,
278
- tItem.itemLayout.name,
279
- tItem.itemName,
346
+ f"content/{file}",
347
+ item.itemClass.name,
348
+ item.itemLayout.name,
349
+ item.itemName,
280
350
  )
281
- tocList.append(tocLine)
282
- tocLen = max(tocLen, len(tocLine))
351
+ entries.append(tocLine)
352
+ maxLen = max(maxLen, len(tocLine))
283
353
 
284
354
  try:
285
- # Dump the text
286
- tocText = runtimePath / nwFiles.TOC_TXT
287
- with open(tocText, mode="w", encoding="utf-8") as outFile:
288
- outFile.write("\n")
289
- outFile.write("Table of Contents\n")
290
- outFile.write("=================\n")
291
- outFile.write("\n")
292
- outFile.write("{0:<25s} {1:<9s} {2:<8s} {3:s}\n".format(
355
+ with open(runtimePath / nwFiles.TOC_TXT, mode="w", encoding="utf-8") as toc:
356
+ toc.write("\n")
357
+ toc.write("Table of Contents\n")
358
+ toc.write("=================\n")
359
+ toc.write("\n")
360
+ toc.write("{0:<25s} {1:<9s} {2:<8s} {3:s}\n".format(
293
361
  "File Name", "Class", "Layout", "Document Label"
294
362
  ))
295
- outFile.write("-"*max(tocLen, 62) + "\n")
296
- outFile.write("\n".join(tocList))
297
- outFile.write("\n")
363
+ toc.write("-"*max(maxLen, 62) + "\n")
364
+ toc.write("\n".join(entries))
365
+ toc.write("\n")
298
366
 
299
367
  except Exception:
300
368
  logger.error("Could not write ToC file")
@@ -307,74 +375,46 @@ class NWTree:
307
375
  """Loop over all entries and add up the word counts."""
308
376
  noteWords = 0
309
377
  novelWords = 0
310
- for tHandle in self._order:
311
- tItem = self.__getitem__(tHandle)
312
- if tItem is None:
313
- continue
314
- if tItem.itemLayout == nwItemLayout.NO_LAYOUT:
315
- pass
316
- elif tItem.itemLayout == nwItemLayout.NOTE:
317
- noteWords += tItem.wordCount
318
- else:
319
- novelWords += tItem.wordCount
378
+ for item in self._items.values():
379
+ if item.itemLayout == nwItemLayout.NOTE:
380
+ noteWords += item.wordCount
381
+ elif item.itemLayout == nwItemLayout.DOCUMENT:
382
+ novelWords += item.wordCount
320
383
  return novelWords, noteWords
321
384
 
322
385
  ##
323
386
  # Tree Item Methods
324
387
  ##
325
388
 
326
- def updateItemData(self, tHandle: str) -> bool:
327
- """Update the root item handle of a given item. Returns True if
328
- a root was found and data updated, otherwise False.
329
- """
330
- tItem = self.__getitem__(tHandle)
331
- if tItem is None:
332
- return False
333
-
334
- iItem = tItem
335
- for _ in range(MAX_DEPTH):
336
- if iItem.itemParent is None:
337
- tItem.setRoot(iItem.itemHandle)
338
- tItem.setClassDefaults(iItem.itemClass)
339
- return True
340
- else:
341
- iItem = self.__getitem__(iItem.itemParent)
342
- if iItem is None:
343
- return False
344
- else:
345
- raise RecursionError("Critical internal error")
346
-
347
389
  def checkType(self, tHandle: str, itemType: nwItemType) -> bool:
348
390
  """Check if item exists and is of the specified item type."""
349
- tItem = self.__getitem__(tHandle)
350
- if not tItem:
351
- return False
352
- return tItem.itemType == itemType
391
+ if tItem := self.__getitem__(tHandle):
392
+ return tItem.itemType == itemType
393
+ return False
353
394
 
354
- def getItemPath(self, tHandle: str, asName: bool = False) -> list[str]:
395
+ def itemPath(self, tHandle: str, asName: bool = False) -> list[str]:
355
396
  """Iterate upwards in the tree until we find the item with
356
397
  parent None, the root item, and return the list of handles, or
357
398
  alternatively item names. We do this with a for loop with a
358
399
  maximum depth to make infinite loops impossible.
359
400
  """
360
- tTree = []
361
- tItem = self.__getitem__(tHandle)
362
- if tItem is not None:
363
- tTree.append(tItem.itemName if asName else tHandle)
401
+ path = []
402
+ if node := self._nodes.get(tHandle):
364
403
  for _ in range(MAX_DEPTH):
365
- if tItem.itemParent is None:
366
- return tTree
404
+ if parent := node.parent():
405
+ path.append(node.item.itemName if asName else tHandle)
406
+ node = parent
367
407
  else:
368
- tHandle = tItem.itemParent
369
- tItem = self.__getitem__(tHandle)
370
- if tItem is None:
371
- return tTree
372
- else:
373
- tTree.append(tItem.itemName if asName else tHandle)
408
+ return path
374
409
  else:
375
- raise RecursionError("Critical internal error")
410
+ logger.error("Max project tree depth reached")
411
+ return path
376
412
 
377
- return tTree
413
+ def subTree(self, tHandle: str) -> list[str]:
414
+ """Get the subtree from a given handle."""
415
+ if node := self._nodes.get(tHandle):
416
+ return [child.item.itemHandle for child in node.allChildren()]
417
+ return []
378
418
 
379
419
  ##
380
420
  # Tree Root Methods
@@ -383,131 +423,72 @@ class NWTree:
383
423
  def rootClasses(self) -> set[nwItemClass]:
384
424
  """Return a set of all root classes in use by the project."""
385
425
  rootClasses = set()
386
- for nwItem in self._roots.values():
387
- rootClasses.add(nwItem.itemClass)
426
+ for node in self._model.root.children:
427
+ rootClasses.add(node.item.itemClass)
388
428
  return rootClasses
389
429
 
390
430
  def iterRoots(self, itemClass: nwItemClass | None) -> Iterable[tuple[str, NWItem]]:
391
431
  """Iterate over all root items of a given class in order."""
392
- for tHandle in self._order:
393
- nwItem = self.__getitem__(tHandle)
394
- if isinstance(nwItem, NWItem) and nwItem.isRootType():
395
- if itemClass is None or nwItem.itemClass == itemClass:
396
- yield tHandle, nwItem
432
+ for node in self._model.root.children:
433
+ if node.item.isRootType():
434
+ if itemClass is None or node.item.itemClass == itemClass:
435
+ yield node.item.itemHandle, node.item
397
436
  return
398
437
 
399
- def isTrash(self, tHandle: str) -> bool:
400
- """Check if an item is in or is the trash folder."""
401
- tItem = self.__getitem__(tHandle)
402
- if tItem is None:
403
- return True
404
- if tItem.itemClass == nwItemClass.TRASH:
405
- return True
406
- if self._trash is not None:
407
- if tHandle == self._trash:
408
- return True
409
- elif tItem.itemParent == self._trash:
410
- return True
411
- elif tItem.itemRoot == self._trash:
412
- return True
413
- return False
414
-
415
438
  def findRoot(self, itemClass: nwItemClass | None) -> str | None:
416
439
  """Find the first root item for a given class."""
417
- for aRoot in self._roots:
418
- tItem = self.__getitem__(aRoot)
419
- if tItem is None:
420
- continue
421
- if itemClass == tItem.itemClass:
422
- return tItem.itemHandle
440
+ for node in self._model.root.children:
441
+ if node.item.itemClass == itemClass:
442
+ return node.item.itemHandle
423
443
  return None
424
444
 
425
445
  ##
426
- # Setters
446
+ # Internal Functions
427
447
  ##
428
448
 
429
- def setOrder(self, newOrder: list[str]) -> None:
430
- """Reorders the tree based on a list of items."""
431
- tmpOrder = [tHandle for tHandle in newOrder if tHandle in self._tree]
432
- if not (len(tmpOrder) == len(newOrder) == len(self._order)):
433
- # Something is wrong, so let's debug it
434
- for tHandle in newOrder:
435
- if tHandle not in self._tree:
436
- logger.error("Handle '%s' in new tree order is not in old order", tHandle)
437
- for tHandle in self._order:
438
- if tHandle not in tmpOrder:
439
- logger.warning("Handle '%s' in old tree order is not in new order", tHandle)
440
-
441
- # Save the temp list
442
- self._order = tmpOrder
443
- self._setTreeChanged(True)
444
- logger.debug("Project tree order updated")
445
-
449
+ def _itemChange(self, item: NWItem, change: nwChange) -> None:
450
+ """Signal item change and notify project."""
451
+ tHandle = item.itemHandle
452
+ logger.debug("Item change: %s -> %s", tHandle, change.name)
453
+ self._project.setProjectChanged(True)
454
+ SHARED.emitProjectItemChanged(self._project, tHandle, change)
455
+ if item.isRootType():
456
+ SHARED.emitRootFolderChanged(self._project, tHandle, change)
446
457
  return
447
458
 
448
- ##
449
- # Special Methods
450
- ##
451
-
452
- def __len__(self) -> int:
453
- """The number of items in the project."""
454
- return len(self._order)
455
-
456
- def __bool__(self) -> bool:
457
- """True if there are any items in the project."""
458
- return bool(self._order)
459
-
460
- def __getitem__(self, tHandle: str | None) -> NWItem | None:
461
- """Return a project item based on its handle. Returns None if
462
- the handle doesn't exist in the project.
463
- """
464
- if tHandle and tHandle in self._tree:
465
- return self._tree[tHandle]
466
- logger.error("No tree item with handle '%s'", str(tHandle))
459
+ def _getTrashNode(self) -> ProjectNode | None:
460
+ """Get the trash node. If it doesn't exist, create it."""
461
+ for node in self._model.root.children:
462
+ if node.item.itemClass == nwItemClass.TRASH:
463
+ return node
464
+ label = trConst(nwLabels.CLASS_NAME[nwItemClass.TRASH])
465
+ if handle := self.create(label, None, nwItemType.ROOT, nwItemClass.TRASH):
466
+ return self._nodes.get(handle)
467
467
  return None
468
468
 
469
- def __delitem__(self, tHandle: str) -> None:
470
- """Remove an item from the internal lists and dictionaries."""
471
- if tHandle in self._order and tHandle in self._tree:
472
- self._order.remove(tHandle)
473
- del self._tree[tHandle]
474
- else:
475
- logger.warning("Failed to delete item '%s': item not found", tHandle)
476
- return
477
-
478
- if tHandle in self._roots:
479
- del self._roots[tHandle]
480
- if tHandle == self._trash:
481
- self._trash = None
482
-
483
- self._setTreeChanged(True)
484
-
485
- return
486
-
487
- def __contains__(self, tHandle: str) -> bool:
488
- """Checks if a handle exists in the tree."""
489
- return tHandle in self._order
490
-
491
- def __iter__(self) -> Iterator[NWItem]:
492
- """Iterate through project items."""
493
- for tHandle in self._order:
494
- tItem = self._tree.get(tHandle)
495
- if isinstance(tItem, NWItem):
496
- yield tItem
497
- return
498
-
499
- ##
500
- # Internal Functions
501
- ##
502
-
503
- def _setTreeChanged(self, state: bool) -> None:
504
- """Set the changed flag to state, and if being set to True,
505
- propagate that state change to the parent NWProject class.
469
+ def _addItems(self, items: dict[str, NWItem]) -> dict[str, NWItem]:
470
+ """Add a dictionary of items to the project tree. Returns a new
471
+ dictionary of items that could not be added yet, but can be.
506
472
  """
507
- self._changed = state
508
- if state:
509
- self._project.setProjectChanged(True)
510
- return
473
+ remains: dict[str, NWItem] = {}
474
+ for handle, item in items.items():
475
+ if pHandle := item.itemParent:
476
+ if parent := self._nodes.get(pHandle):
477
+ node = ProjectNode(item)
478
+ parent.addChild(node)
479
+ parent.updateCount()
480
+ self._items[handle] = item
481
+ self._nodes[handle] = node
482
+ elif pHandle in items:
483
+ remains[handle] = item
484
+ logger.warning("Item '%s' found before its parent", handle)
485
+ elif item.isRootType():
486
+ node = ProjectNode(item)
487
+ self._model.root.addChild(node)
488
+ self._model.root.updateCount()
489
+ self._items[handle] = item
490
+ self._nodes[handle] = node
491
+ return remains
511
492
 
512
493
  def _makeHandle(self) -> str:
513
494
  """Generate a unique item handle. In the event that the key
@@ -515,7 +496,7 @@ class NWTree:
515
496
  """
516
497
  logger.debug("Generating new handle")
517
498
  handle = f"{random.getrandbits(52):013x}"
518
- if handle in self._tree:
499
+ if handle in self._items:
519
500
  logger.warning("Duplicate handle encountered! Retrying ...")
520
501
  handle = self._makeHandle()
521
502