novelWriter 2.2.1__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 (110) hide show
  1. {novelWriter-2.2.1.dist-info → novelWriter-2.3b1.dist-info}/METADATA +1 -1
  2. {novelWriter-2.2.1.dist-info → novelWriter-2.3b1.dist-info}/RECORD +102 -92
  3. novelwriter/__init__.py +4 -4
  4. novelwriter/assets/icons/typicons_dark/icons.conf +6 -0
  5. novelwriter/assets/icons/typicons_dark/mixed_import.svg +5 -0
  6. novelwriter/assets/icons/typicons_dark/typ_document-add-col.svg +8 -0
  7. novelwriter/assets/icons/typicons_dark/typ_document-add.svg +4 -0
  8. novelwriter/assets/icons/typicons_dark/typ_document.svg +4 -0
  9. novelwriter/assets/icons/typicons_dark/typ_th-dot-more.svg +4 -0
  10. novelwriter/assets/icons/typicons_light/icons.conf +6 -0
  11. novelwriter/assets/icons/typicons_light/mixed_import.svg +5 -0
  12. novelwriter/assets/icons/typicons_light/typ_document-add-col.svg +8 -0
  13. novelwriter/assets/icons/typicons_light/typ_document-add.svg +4 -0
  14. novelwriter/assets/icons/typicons_light/typ_document.svg +4 -0
  15. novelwriter/assets/icons/typicons_light/typ_th-dot-more.svg +4 -0
  16. novelwriter/assets/images/novelwriter-text-dark.svg +4 -0
  17. novelwriter/assets/images/novelwriter-text-light.svg +4 -0
  18. novelwriter/assets/images/welcome-dark.jpg +0 -0
  19. novelwriter/assets/images/welcome-light.jpg +0 -0
  20. novelwriter/assets/manual.pdf +0 -0
  21. novelwriter/assets/sample.zip +0 -0
  22. novelwriter/assets/syntax/default_dark.conf +1 -0
  23. novelwriter/assets/syntax/default_light.conf +1 -0
  24. novelwriter/assets/syntax/grey_dark.conf +1 -0
  25. novelwriter/assets/syntax/grey_light.conf +1 -0
  26. novelwriter/assets/syntax/light_owl.conf +1 -0
  27. novelwriter/assets/syntax/night_owl.conf +1 -0
  28. novelwriter/assets/syntax/solarized_dark.conf +1 -0
  29. novelwriter/assets/syntax/solarized_light.conf +1 -0
  30. novelwriter/assets/syntax/tomorrow.conf +1 -0
  31. novelwriter/assets/syntax/tomorrow_night.conf +1 -0
  32. novelwriter/assets/syntax/tomorrow_night_blue.conf +1 -0
  33. novelwriter/assets/syntax/tomorrow_night_bright.conf +1 -0
  34. novelwriter/assets/syntax/tomorrow_night_eighties.conf +1 -0
  35. novelwriter/assets/text/credits_en.htm +4 -2
  36. novelwriter/assets/themes/default_dark.conf +2 -2
  37. novelwriter/assets/themes/default_light.conf +2 -2
  38. novelwriter/common.py +48 -37
  39. novelwriter/config.py +36 -41
  40. novelwriter/constants.py +38 -16
  41. novelwriter/core/buildsettings.py +7 -7
  42. novelwriter/core/coretools.py +192 -154
  43. novelwriter/core/docbuild.py +6 -3
  44. novelwriter/core/document.py +6 -6
  45. novelwriter/core/index.py +89 -56
  46. novelwriter/core/item.py +21 -3
  47. novelwriter/core/options.py +8 -7
  48. novelwriter/core/project.py +69 -44
  49. novelwriter/core/projectdata.py +1 -14
  50. novelwriter/core/projectxml.py +13 -41
  51. novelwriter/core/sessions.py +2 -1
  52. novelwriter/core/spellcheck.py +2 -1
  53. novelwriter/core/status.py +2 -1
  54. novelwriter/core/storage.py +178 -140
  55. novelwriter/core/tohtml.py +4 -2
  56. novelwriter/core/tokenizer.py +73 -45
  57. novelwriter/core/toodt.py +40 -30
  58. novelwriter/core/tree.py +3 -2
  59. novelwriter/dialogs/about.py +70 -160
  60. novelwriter/dialogs/docmerge.py +6 -5
  61. novelwriter/dialogs/docsplit.py +6 -6
  62. novelwriter/dialogs/editlabel.py +1 -1
  63. novelwriter/dialogs/preferences.py +553 -703
  64. novelwriter/dialogs/{projsettings.py → projectsettings.py} +288 -262
  65. novelwriter/dialogs/quotes.py +27 -23
  66. novelwriter/dialogs/wordlist.py +96 -40
  67. novelwriter/enum.py +20 -18
  68. novelwriter/error.py +1 -1
  69. novelwriter/extensions/circularprogress.py +11 -11
  70. novelwriter/extensions/configlayout.py +185 -134
  71. novelwriter/extensions/modified.py +81 -0
  72. novelwriter/extensions/novelselector.py +26 -12
  73. novelwriter/extensions/pagedsidebar.py +14 -16
  74. novelwriter/extensions/simpleprogress.py +5 -5
  75. novelwriter/extensions/statusled.py +8 -8
  76. novelwriter/extensions/switch.py +31 -63
  77. novelwriter/extensions/switchbox.py +1 -1
  78. novelwriter/extensions/versioninfo.py +153 -0
  79. novelwriter/gui/doceditor.py +178 -150
  80. novelwriter/gui/dochighlight.py +63 -92
  81. novelwriter/gui/docviewer.py +49 -51
  82. novelwriter/gui/docviewerpanel.py +72 -24
  83. novelwriter/gui/itemdetails.py +7 -7
  84. novelwriter/gui/mainmenu.py +14 -18
  85. novelwriter/gui/noveltree.py +9 -8
  86. novelwriter/gui/outline.py +98 -75
  87. novelwriter/gui/projtree.py +188 -61
  88. novelwriter/gui/sidebar.py +3 -4
  89. novelwriter/gui/statusbar.py +3 -4
  90. novelwriter/gui/theme.py +60 -68
  91. novelwriter/guimain.py +49 -156
  92. novelwriter/shared.py +15 -1
  93. novelwriter/tools/dictionaries.py +5 -6
  94. novelwriter/tools/manuscript.py +6 -6
  95. novelwriter/tools/manussettings.py +192 -221
  96. novelwriter/tools/noveldetails.py +525 -0
  97. novelwriter/tools/welcome.py +802 -0
  98. novelwriter/tools/writingstats.py +9 -9
  99. novelwriter/assets/images/wizard-back.jpg +0 -0
  100. novelwriter/assets/text/gplv3_en.htm +0 -641
  101. novelwriter/assets/text/release_notes.htm +0 -60
  102. novelwriter/dialogs/projdetails.py +0 -518
  103. novelwriter/dialogs/projload.py +0 -294
  104. novelwriter/dialogs/updates.py +0 -172
  105. novelwriter/extensions/pageddialog.py +0 -130
  106. novelwriter/tools/projwizard.py +0 -478
  107. {novelWriter-2.2.1.dist-info → novelWriter-2.3b1.dist-info}/LICENSE.md +0 -0
  108. {novelWriter-2.2.1.dist-info → novelWriter-2.3b1.dist-info}/WHEEL +0 -0
  109. {novelWriter-2.2.1.dist-info → novelWriter-2.3b1.dist-info}/entry_points.txt +0 -0
  110. {novelWriter-2.2.1.dist-info → novelWriter-2.3b1.dist-info}/top_level.txt +0 -0
@@ -38,7 +38,6 @@ from novelwriter.common import (
38
38
  checkBool, checkInt, checkString, checkStringNone, formatTimeStamp,
39
39
  hexToInt, simplified, xmlIndent, yesNo
40
40
  )
41
- from novelwriter.constants import nwFiles
42
41
 
43
42
  if TYPE_CHECKING: # pragma: no cover
44
43
  from novelwriter.core.status import NWStatus
@@ -47,7 +46,7 @@ if TYPE_CHECKING: # pragma: no cover
47
46
  logger = logging.getLogger(__name__)
48
47
 
49
48
  FILE_VERSION = "1.5" # The current project file format version
50
- FILE_REVISION = "1" # The current project file format revision
49
+ FILE_REVISION = "2" # The current project file format revision
51
50
  HEX_VERSION = 0x0105
52
51
 
53
52
  NUM_VERSION = {
@@ -65,12 +64,11 @@ class XMLReadState(Enum):
65
64
 
66
65
  NO_ACTION = 0
67
66
  NO_ERROR = 1
68
- PARSED_BACKUP = 2
69
- CANNOT_PARSE = 3
70
- NOT_NWX_FILE = 4
71
- UNKNOWN_VERSION = 5
72
- PARSED_OK = 6
73
- WAS_LEGACY = 7
67
+ CANNOT_PARSE = 2
68
+ NOT_NWX_FILE = 3
69
+ UNKNOWN_VERSION = 4
70
+ PARSED_OK = 5
71
+ WAS_LEGACY = 6
74
72
 
75
73
  # END Class XMLReadState
76
74
 
@@ -107,7 +105,9 @@ class ProjectXMLReader:
107
105
  the project or the content into their respective section nodes
108
106
  as attributes. The id attribute was also added to the project.
109
107
 
110
- Rev 1: Drops the titleFormat section of settings.
108
+ Rev 1: Drops the titleFormat node from settings. 2.1 Beta 1.
109
+ Rev 2: Drops the title node from project and adds the TEMPLATE
110
+ class for items. 2.3 Beta 1.
111
111
  """
112
112
 
113
113
  def __init__(self, path: str | Path) -> None:
@@ -172,23 +172,9 @@ class ProjectXMLReader:
172
172
  xml = ET.parse(str(self._path))
173
173
  self._state = XMLReadState.NO_ERROR
174
174
  except Exception as exc:
175
- # Trying to open backup file instead
176
175
  logger.error("Failed to parse project XML", exc_info=exc)
177
176
  self._state = XMLReadState.CANNOT_PARSE
178
-
179
- backFile = self._path.with_suffix(".bak")
180
- if backFile.is_file():
181
- try:
182
- xml = ET.parse(str(backFile))
183
- self._state = XMLReadState.PARSED_BACKUP
184
- logger.info("Backup project file parsed")
185
- except Exception as exc:
186
- logger.error("Failed to parse backup project XML", exc_info=exc)
187
- self._state = XMLReadState.CANNOT_PARSE
188
- return False
189
- else:
190
- self._state = XMLReadState.CANNOT_PARSE
191
- return False
177
+ return False
192
178
 
193
179
  xRoot = xml.getroot()
194
180
  self._root = str(xRoot.tag)
@@ -248,8 +234,6 @@ class ProjectXMLReader:
248
234
  for xItem in xSection:
249
235
  if xItem.tag == "name":
250
236
  data.setName(xItem.text)
251
- elif xItem.tag == "title":
252
- data.setTitle(xItem.text)
253
237
  elif xItem.tag == "author":
254
238
  data.setAuthor(xItem.text)
255
239
  else:
@@ -521,7 +505,6 @@ class ProjectXMLWriter:
521
505
 
522
506
  xProject = ET.SubElement(xRoot, "project", attrib=projAttr)
523
507
  self._packSingleValue(xProject, "name", data.name)
524
- self._packSingleValue(xProject, "title", data.title)
525
508
  self._packSingleValue(xProject, "author", data.author)
526
509
 
527
510
  # Save Project Settings
@@ -558,23 +541,12 @@ class ProjectXMLWriter:
558
541
  xName.text = item["name"]
559
542
 
560
543
  # Write the XML tree to file
561
- saveFile = self._path / nwFiles.PROJ_FILE
562
- tempFile = saveFile.with_suffix(".tmp")
563
- backFile = saveFile.with_suffix(".bak")
544
+ tmp = self._path.with_suffix(".tmp")
564
545
  try:
565
546
  xml = ET.ElementTree(xRoot)
566
547
  xmlIndent(xml)
567
- xml.write(tempFile, encoding="utf-8", xml_declaration=True)
568
- except Exception as exc:
569
- self._error = exc
570
- return False
571
-
572
- # If we're here, the file was successfully saved,
573
- # so let's sort out the temps and backups
574
- try:
575
- if saveFile.exists():
576
- saveFile.replace(backFile)
577
- tempFile.replace(saveFile)
548
+ xml.write(tmp, encoding="utf-8", xml_declaration=True)
549
+ tmp.replace(self._path)
578
550
  except Exception as exc:
579
551
  self._error = exc
580
552
  return False
@@ -27,8 +27,9 @@ import json
27
27
  import logging
28
28
 
29
29
  from time import time
30
- from typing import TYPE_CHECKING, Iterator
30
+ from typing import TYPE_CHECKING
31
31
  from pathlib import Path
32
+ from collections.abc import Iterator
32
33
 
33
34
  from novelwriter.error import logException
34
35
  from novelwriter.common import formatTimeStamp
@@ -27,8 +27,9 @@ from __future__ import annotations
27
27
  import json
28
28
  import logging
29
29
 
30
- from typing import TYPE_CHECKING, Iterator
30
+ from typing import TYPE_CHECKING
31
31
  from pathlib import Path
32
+ from collections.abc import Iterator
32
33
 
33
34
  from PyQt5.QtCore import QLocale
34
35
 
@@ -27,7 +27,8 @@ from __future__ import annotations
27
27
  import random
28
28
  import logging
29
29
 
30
- from typing import TYPE_CHECKING, ItemsView, Iterator, KeysView, Literal, ValuesView
30
+ from typing import TYPE_CHECKING, Literal
31
+ from collections.abc import ItemsView, Iterator, KeysView, ValuesView
31
32
 
32
33
  from PyQt5.QtGui import QIcon, QPainter, QPainterPath, QPixmap, QColor
33
34
  from PyQt5.QtCore import QRectF, Qt
@@ -26,6 +26,7 @@ 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
31
  from typing import TYPE_CHECKING
31
32
  from pathlib import Path
@@ -45,6 +46,26 @@ if TYPE_CHECKING: # pragma: no cover
45
46
  logger = logging.getLogger(__name__)
46
47
 
47
48
 
49
+ class NWStorageOpen(Enum):
50
+
51
+ UNKOWN = 0
52
+ NOT_FOUND = 1
53
+ LOCKED = 2
54
+ FAILED = 3
55
+ READY = 4
56
+
57
+ # END Enum NWStorageOpen
58
+
59
+
60
+ class NWStorageCreate(Enum):
61
+
62
+ NOT_EMPTY = 0
63
+ OS_ERROR = 1
64
+ READY = 2
65
+
66
+ # END Enum NWStorageCreate
67
+
68
+
48
69
  class NWStorage:
49
70
  """Core: Project Storage Class
50
71
 
@@ -60,7 +81,10 @@ class NWStorage:
60
81
  self._storagePath = None
61
82
  self._runtimePath = None
62
83
  self._lockFilePath = None
84
+ self._lockedBy = None
63
85
  self._openMode = self.MODE_INACTIVE
86
+ self._ready = False
87
+ self._exception = None
64
88
  return
65
89
 
66
90
  def clear(self) -> None:
@@ -69,6 +93,7 @@ class NWStorage:
69
93
  self._runtimePath = None
70
94
  self._lockFilePath = None
71
95
  self._openMode = self.MODE_INACTIVE
96
+ self._ready = False
72
97
  return
73
98
 
74
99
  ##
@@ -77,12 +102,12 @@ class NWStorage:
77
102
 
78
103
  @property
79
104
  def storagePath(self) -> Path | None:
80
- """Get the path where the project is saved."""
105
+ """Return the path where the project is stored."""
81
106
  return self._storagePath
82
107
 
83
108
  @property
84
109
  def runtimePath(self) -> Path | None:
85
- """Get the path where the project is saved at runtime."""
110
+ """Return the path where the project is stored at runtime."""
86
111
  return self._runtimePath
87
112
 
88
113
  @property
@@ -97,44 +122,123 @@ class NWStorage:
97
122
  logger.error("Content path cannot be resolved")
98
123
  return None
99
124
 
125
+ @property
126
+ def lockStatus(self) -> list | None:
127
+ """Return the project lock information."""
128
+ if isinstance(self._lockedBy, list) and len(self._lockedBy) == 4:
129
+ return self._lockedBy
130
+ return None
131
+
132
+ @property
133
+ def exc(self) -> Exception | None:
134
+ """Return the latest exception of the storage instance."""
135
+ return self._exception
136
+
100
137
  ##
101
138
  # Core Methods
102
139
  ##
103
140
 
104
141
  def isOpen(self) -> bool:
105
142
  """Check if the storage location is open."""
106
- return self._runtimePath is not None
143
+ return self._ready and self._runtimePath is not None
107
144
 
108
- def openProjectInPlace(self, path: str | Path, newProject: bool = False) -> bool:
109
- """Open a novelWriter project in-place. That is, it is opened
110
- directly from a project folder.
111
- """
145
+ def createNewProject(self, path: str | Path) -> NWStorageCreate:
146
+ """Create a new project at the given location."""
112
147
  inPath = Path(path).resolve()
113
- if inPath.is_file():
114
- # The path should not point to an existing file,
115
- # but it can point to a folder containing files
116
- inPath = inPath.parent
117
-
118
- if not (inPath.is_dir() or newProject):
119
- # If the project is not new, the folder must already exist.
120
- return False
148
+ if inPath.is_dir() and len(list(inPath.iterdir())) > 0:
149
+ logger.error("Folder is not empty: %s", inPath)
150
+ return NWStorageCreate.NOT_EMPTY
121
151
 
122
152
  self._storagePath = inPath
123
153
  self._runtimePath = inPath
124
154
  self._lockFilePath = inPath / nwFiles.PROJ_LOCK
125
155
  self._openMode = self.MODE_INPLACE
126
156
 
127
- if not self._prepareStorage(checkLegacy=True, newProject=newProject):
157
+ basePath = self._runtimePath
158
+ metaPath = basePath / "meta"
159
+ contPath = basePath / "content"
160
+ try:
161
+ basePath.mkdir(exist_ok=True)
162
+ metaPath.mkdir(exist_ok=True)
163
+ contPath.mkdir(exist_ok=True)
164
+ except Exception as exc:
165
+ self._exception = exc
166
+ logger.error("Failed to create project folders", exc_info=exc)
128
167
  self.clear()
129
- return False
168
+ return NWStorageCreate.OS_ERROR
130
169
 
131
- return True
170
+ self._ready = True
132
171
 
133
- def openProjectArchive(self, path: str | Path) -> bool: # pragma: no cover
134
- """Open the project from a single file.
135
- Placeholder for later implementation. See #977.
136
- """
137
- return False
172
+ return NWStorageCreate.READY
173
+
174
+ def initProjectStorage(self, path: str | Path, clearLock: bool = False) -> NWStorageOpen:
175
+ """Initialise a novelWriter project location."""
176
+ inPath = Path(path).resolve()
177
+
178
+ # Initialise Storage Instance
179
+ # ===========================
180
+
181
+ # Check what we're opening. Only two options are allowed:
182
+ # 1. A folder with an nwProject.nwx file in it (not home)
183
+ # 2. A full path to an nwProject.nwx file
184
+ if inPath.is_dir() and inPath != Path.home().resolve():
185
+ nwxFile = inPath / nwFiles.PROJ_FILE
186
+ elif inPath.is_file() and inPath.name == nwFiles.PROJ_FILE:
187
+ nwxFile = inPath
188
+ else:
189
+ logger.error("Not a novelWriter project")
190
+ return NWStorageOpen.UNKOWN
191
+
192
+ if not nwxFile.exists():
193
+ # The .nwx file must exist to continue
194
+ logger.error("Not found: %s", nwxFile)
195
+ return NWStorageOpen.NOT_FOUND
196
+
197
+ nwxPath = nwxFile.parent
198
+
199
+ self._storagePath = nwxPath
200
+ self._runtimePath = nwxPath
201
+ self._lockFilePath = nwxPath / nwFiles.PROJ_LOCK
202
+ self._openMode = self.MODE_INPLACE
203
+
204
+ # Check Project Lock
205
+ # ==================
206
+
207
+ if clearLock:
208
+ self._clearLockFile()
209
+
210
+ self._readLockFile()
211
+ if self._lockedBy:
212
+ logger.error("Project is locked, so not opening")
213
+ return NWStorageOpen.LOCKED
214
+ else:
215
+ logger.debug("Project is not locked")
216
+
217
+ # Prepare Folder
218
+ # ==============
219
+
220
+ basePath = self._runtimePath
221
+ metaPath = basePath / "meta"
222
+ contPath = basePath / "content"
223
+ try:
224
+ metaPath.mkdir(exist_ok=True)
225
+ contPath.mkdir(exist_ok=True)
226
+ except Exception as exc:
227
+ logger.error("Failed to create project folders", exc_info=exc)
228
+ self.clear()
229
+ return NWStorageOpen.FAILED
230
+
231
+ # Check for legacy data folders
232
+ legacy = _LegacyStorage(self._project)
233
+ legacy.deprecatedFiles(basePath)
234
+ for child in basePath.iterdir():
235
+ if child.is_dir() and child.name.startswith("data_"):
236
+ legacy.legacyDataFolder(basePath, child)
237
+
238
+ self._writeLockFile()
239
+ self._ready = True
240
+
241
+ return NWStorageOpen.READY
138
242
 
139
243
  def runPostSaveTasks(self, autoSave: bool = False) -> bool: # pragma: no cover
140
244
  """Run tasks after the project has been saved.
@@ -147,7 +251,7 @@ class NWStorage:
147
251
 
148
252
  def closeSession(self) -> None:
149
253
  """Run tasks related to closing the session."""
150
- self.clearLockFile()
254
+ self._clearLockFile()
151
255
  self.clear()
152
256
  return
153
257
 
@@ -157,26 +261,25 @@ class NWStorage:
157
261
 
158
262
  def getXmlReader(self) -> ProjectXMLReader | None:
159
263
  """Return a properly configured ProjectXMLReader instance."""
160
- if isinstance(self._runtimePath, Path):
161
- projFile = self._runtimePath / nwFiles.PROJ_FILE
162
- return ProjectXMLReader(projFile)
264
+ if isinstance(self._runtimePath, Path) and self._ready:
265
+ return ProjectXMLReader(self._runtimePath / nwFiles.PROJ_FILE)
163
266
  return None
164
267
 
165
268
  def getXmlWriter(self) -> ProjectXMLWriter | None:
166
269
  """Return a properly configured ProjectXMLWriter instance."""
167
- if isinstance(self._runtimePath, Path):
168
- return ProjectXMLWriter(self._runtimePath)
270
+ if isinstance(self._runtimePath, Path) and self._ready:
271
+ return ProjectXMLWriter(self._runtimePath / nwFiles.PROJ_FILE)
169
272
  return None
170
273
 
171
274
  def getDocument(self, tHandle: str | None) -> NWDocument:
172
275
  """Return a document wrapper object."""
173
- if isinstance(self._runtimePath, Path):
276
+ if isinstance(self._runtimePath, Path) and self._ready:
174
277
  return NWDocument(self._project, tHandle)
175
278
  return NWDocument(self._project, None)
176
279
 
177
280
  def getMetaFile(self, fileName: str) -> Path | None:
178
281
  """Return the path to a file in the project meta folder."""
179
- if isinstance(self._runtimePath, Path):
282
+ if isinstance(self._runtimePath, Path) and self._ready:
180
283
  return self._runtimePath / "meta" / fileName
181
284
  return None
182
285
 
@@ -190,59 +293,6 @@ class NWStorage:
190
293
  if item.suffix == ".nwd" and isHandle(item.stem)
191
294
  ] if contentPath else []
192
295
 
193
- def readLockFile(self) -> list[str]:
194
- """Read the project lock file."""
195
- if self._lockFilePath is None:
196
- return ["ERROR"]
197
-
198
- if not self._lockFilePath.exists():
199
- return []
200
-
201
- try:
202
- lines = self._lockFilePath.read_text(encoding="utf-8").strip().split(";")
203
- except Exception:
204
- logger.error("Failed to read project lockfile")
205
- logException()
206
- return ["ERROR"]
207
-
208
- if len(lines) != 4:
209
- return ["ERROR"]
210
-
211
- return lines
212
-
213
- def writeLockFile(self) -> bool:
214
- """Write the project lock file."""
215
- if self._lockFilePath is None:
216
- return False
217
-
218
- data = [
219
- CONFIG.hostName, CONFIG.osType,
220
- CONFIG.kernelVer, str(int(time()))
221
- ]
222
- try:
223
- self._lockFilePath.write_text(";".join(data), encoding="utf-8")
224
- except Exception:
225
- logger.error("Failed to write project lockfile")
226
- logException()
227
- return False
228
-
229
- return True
230
-
231
- def clearLockFile(self) -> bool:
232
- """Remove the lock file, if it exists."""
233
- if self._lockFilePath is None:
234
- return False
235
-
236
- if self._lockFilePath.exists():
237
- try:
238
- self._lockFilePath.unlink()
239
- except Exception:
240
- logger.error("Failed to remove project lockfile")
241
- logException()
242
- return False
243
-
244
- return True
245
-
246
296
  def zipIt(self, target: str | Path, compression: int | None = None) -> bool:
247
297
  """Zip the content of the project at its runtime location into a
248
298
  zip file. This process will only grab files that are supposed to
@@ -257,7 +307,6 @@ class NWStorage:
257
307
  baseCont = basePath / "content"
258
308
  files = [
259
309
  (basePath / nwFiles.PROJ_FILE, nwFiles.PROJ_FILE),
260
- (basePath / nwFiles.PROJ_BACKUP, nwFiles.PROJ_BACKUP),
261
310
  (baseMeta / nwFiles.BUILDS_FILE, f"meta/{nwFiles.BUILDS_FILE}"),
262
311
  (baseMeta / nwFiles.INDEX_FILE, f"meta/{nwFiles.INDEX_FILE}"),
263
312
  (baseMeta / nwFiles.OPTS_FILE, f"meta/{nwFiles.OPTS_FILE}"),
@@ -279,7 +328,7 @@ class NWStorage:
279
328
  zipObj.write(srcPath, zipPath)
280
329
  logger.debug("Added: %s", zipPath)
281
330
  except Exception:
282
- logger.error("Failed to create acrhive")
331
+ logger.error("Failed to create archive")
283
332
  logException()
284
333
  return False
285
334
 
@@ -289,59 +338,48 @@ class NWStorage:
289
338
  # Internal Functions
290
339
  ##
291
340
 
292
- def _prepareStorage(self, checkLegacy: bool = True, newProject: bool = False) -> bool:
293
- """Prepare the storage area for the project."""
294
- path = self._runtimePath
295
- if not isinstance(path, Path):
296
- logger.error("No path set")
297
- self.clear()
298
- return False
341
+ def _readLockFile(self) -> None:
342
+ """Read the project lock file."""
343
+ self._lockedBy = None
344
+ path = self._lockFilePath
345
+ if isinstance(path, Path) and path.exists():
346
+ try:
347
+ self._lockedBy = path.read_text(encoding="utf-8").strip().split(";")
348
+ except Exception:
349
+ logger.error("Failed to read project lockfile")
350
+ logException()
351
+ self._lockedBy = ["ERROR", "ERROR", "ERROR", "ERROR"]
352
+ return
353
+ return
299
354
 
300
- if path == Path.home().absolute():
301
- logger.error("Cannot use the user's home path as the root of a project")
302
- self.clear()
355
+ def _writeLockFile(self) -> bool:
356
+ """Write the project lock file."""
357
+ if self._lockFilePath is None:
303
358
  return False
304
-
305
- if newProject:
306
- # If it's a new project, we check that there is no existing
307
- # project in the selected path.
308
- if path.exists() and len(list(path.iterdir())) > 0:
309
- logger.error("The new project folder is not empty")
310
- self.clear()
311
- return False
312
-
313
- # The folder is not required to exist, as it could be a new
314
- # project, so we make sure it does. Then we add subfolders.
315
359
  try:
316
- path.mkdir(exist_ok=True)
317
- (path / "content").mkdir(exist_ok=True)
318
- (path / "meta").mkdir(exist_ok=True)
319
- except Exception as exc:
320
- logger.error("Failed to create required project folders", exc_info=exc)
321
- self.clear()
360
+ self._lockFilePath.write_text(
361
+ f"{CONFIG.hostName};{CONFIG.osType};{CONFIG.kernelVer};{int(time())}",
362
+ encoding="utf-8"
363
+ )
364
+ except Exception:
365
+ logger.error("Failed to write project lockfile")
366
+ logException()
322
367
  return False
323
-
324
- if not checkLegacy:
325
- # The legacy content check is only needed for project folder
326
- # storage, so if it is not expected to be that, there's no
327
- # need for the remaining checks.
328
- return True
329
-
330
- legacy = _LegacyStorage(self._project)
331
-
332
- # Check for legacy data folders
333
- for child in path.iterdir():
334
- if child.is_dir() and child.name.startswith("data_"):
335
- legacy.legacyDataFolder(path, child)
336
-
337
- # Check for no longer used files, and delete them
338
- legacy.deprecatedFiles(path)
339
-
340
368
  return True
341
369
 
342
- ##
343
- # Legacy Project Data Handlers
344
- ##
370
+ def _clearLockFile(self) -> bool:
371
+ """Remove the lock file, if it exists."""
372
+ if self._lockFilePath is None:
373
+ return False
374
+ if self._lockFilePath.exists():
375
+ try:
376
+ self._lockFilePath.unlink()
377
+ except Exception:
378
+ logger.error("Failed to remove project lockfile")
379
+ logException()
380
+ return False
381
+ self._lockedBy = None
382
+ return True
345
383
 
346
384
  # END Class NWStorage
347
385
 
@@ -350,7 +388,7 @@ class _LegacyStorage:
350
388
  """Core: Legacy Storage Converter Utils
351
389
 
352
390
  A class with various functions to convert old file formats and
353
- file/folder layout to the current project format.
391
+ file/folder layouts to the current project format.
354
392
  """
355
393
 
356
394
  def __init__(self, project: NWProject) -> None:
@@ -359,7 +397,8 @@ class _LegacyStorage:
359
397
 
360
398
  def legacyDataFolder(self, path: Path, child: Path) -> None:
361
399
  """Handle the content of a legacy data folder from a version 1.0
362
- project.
400
+ project. This format had 16 data folders where there now is only
401
+ one content folder.
363
402
  """
364
403
  logger.info("Processing legacy data folder: %s", path)
365
404
 
@@ -411,6 +450,7 @@ class _LegacyStorage:
411
450
  path / "meta" / nwFiles.OPTS_FILE
412
451
  )
413
452
 
453
+ # Delete removed files
414
454
  remove = [
415
455
  path / "meta" / "tagsIndex.json", # Renamed in 2.1 Beta 1
416
456
  path / "meta" / "mainOptions.json", # Replaced in 0.5
@@ -422,6 +462,7 @@ class _LegacyStorage:
422
462
  path / "cache" / "prevBuild.json", # Dropped in 2.1 Beta 1
423
463
  path / "cache", # Dropped in 2.1 Beta 1
424
464
  path / "ToC.json", # Dropped in 1.0 RC 1
465
+ path / "nwProject.bak", # Dropped in 2.3 Beta 1
425
466
  ]
426
467
  for item in remove:
427
468
  if item.exists():
@@ -457,7 +498,6 @@ class _LegacyStorage:
457
498
 
458
499
  # Save dictionary and clean up old file
459
500
  userDict.save()
460
- assert wordJson.exists()
461
501
  wordList.unlink()
462
502
 
463
503
  except Exception:
@@ -467,9 +507,7 @@ class _LegacyStorage:
467
507
  return
468
508
 
469
509
  def _convertOldLogFile(self, sessLog: Path, sessJson: Path) -> None:
470
- """Convert the old text log file format to the new JSON Lines
471
- format.
472
- """
510
+ """Convert the old text log file format to JSON Lines."""
473
511
  if sessJson.exists() or not sessLog.exists():
474
512
  # If the new file already exists, we won't overwrite it
475
513
  return
@@ -508,7 +546,7 @@ class _LegacyStorage:
508
546
  return
509
547
 
510
548
  def _convertOldOptionsFile(self, optsOld: Path, optsNew: Path) -> None:
511
- """Convert the old options state file format to the format."""
549
+ """Convert the old options state file format to new format."""
512
550
  if optsNew.exists() or not optsOld.exists():
513
551
  # If the new file already exists, we won't overwrite it
514
552
  return
@@ -338,7 +338,6 @@ class ToHtml(Tokenizer):
338
338
  data = {
339
339
  "meta": {
340
340
  "projectName": self._project.data.name,
341
- "novelTitle": self._project.data.title,
342
341
  "novelAuthor": self._project.data.author,
343
342
  "buildTime": int(timeStamp),
344
343
  "buildTimeStr": formatTimeStamp(timeStamp),
@@ -485,7 +484,10 @@ class ToHtml(Tokenizer):
485
484
  result = f"<span class='tags'>{self._localLookup(nwLabels.KEY_NAME[bits[0]])}:</span> "
486
485
  if len(bits) > 1:
487
486
  if bits[0] == nwKeyWords.TAG_KEY:
488
- result += f"<a name='tag_{bits[1]}'>{bits[1]}</a>"
487
+ one, two = self._project.index.parseValue(bits[1])
488
+ result += f"<a name='tag_{one}'>{one}</a>"
489
+ if two:
490
+ result += f" | <span class='optional'>{two}</a>"
489
491
  else:
490
492
  if self._genMode == self.M_PREVIEW:
491
493
  result += ", ".join(f"<a href='#{bits[0][1:]}={t}'>{t}</a>" for t in bits[1:])