novelWriter 2.2rc1__py3-none-any.whl → 2.3__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 (162) hide show
  1. {novelWriter-2.2rc1.dist-info → novelWriter-2.3.dist-info}/METADATA +1 -1
  2. {novelWriter-2.2rc1.dist-info → novelWriter-2.3.dist-info}/RECORD +149 -132
  3. {novelWriter-2.2rc1.dist-info → novelWriter-2.3.dist-info}/WHEEL +1 -1
  4. novelWriter-2.3.dist-info/entry_points.txt +2 -0
  5. novelwriter/__init__.py +11 -6
  6. novelwriter/assets/i18n/nw_de_DE.qm +0 -0
  7. novelwriter/assets/i18n/nw_en_US.qm +0 -0
  8. novelwriter/assets/i18n/nw_es_419.qm +0 -0
  9. novelwriter/assets/i18n/nw_fr_FR.qm +0 -0
  10. novelwriter/assets/i18n/nw_it_IT.qm +0 -0
  11. novelwriter/assets/i18n/nw_ja_JP.qm +0 -0
  12. novelwriter/assets/i18n/nw_nb_NO.qm +0 -0
  13. novelwriter/assets/i18n/nw_nl_NL.qm +0 -0
  14. novelwriter/assets/i18n/nw_zh_CN.qm +0 -0
  15. novelwriter/assets/i18n/project_de_DE.json +1 -0
  16. novelwriter/assets/i18n/project_en_US.json +1 -0
  17. novelwriter/assets/i18n/project_es_419.json +11 -0
  18. novelwriter/assets/i18n/project_fr_FR.json +11 -0
  19. novelwriter/assets/i18n/project_it_IT.json +11 -0
  20. novelwriter/assets/i18n/project_ja_JP.json +2 -1
  21. novelwriter/assets/i18n/project_nb_NO.json +1 -0
  22. novelwriter/assets/i18n/project_nl_NL.json +11 -0
  23. novelwriter/assets/i18n/project_pt_BR.json +11 -0
  24. novelwriter/assets/i18n/project_zh_CN.json +11 -0
  25. novelwriter/assets/icons/typicons_dark/icons.conf +11 -2
  26. novelwriter/assets/icons/typicons_dark/mixed_document-new.svg +6 -0
  27. novelwriter/assets/icons/typicons_dark/mixed_import.svg +5 -0
  28. novelwriter/assets/icons/typicons_dark/nw_tb-bold-md.svg +4 -0
  29. novelwriter/assets/icons/typicons_dark/nw_tb-bold.svg +3 -1
  30. novelwriter/assets/icons/typicons_dark/nw_tb-italic-md.svg +4 -0
  31. novelwriter/assets/icons/typicons_dark/nw_tb-italic.svg +3 -1
  32. novelwriter/assets/icons/typicons_dark/nw_tb-strike-md.svg +4 -0
  33. novelwriter/assets/icons/typicons_dark/nw_tb-strike.svg +3 -1
  34. novelwriter/assets/icons/typicons_dark/nw_tb-subscript.svg +4 -2
  35. novelwriter/assets/icons/typicons_dark/nw_tb-superscript.svg +4 -2
  36. novelwriter/assets/icons/typicons_dark/nw_tb-underline.svg +4 -2
  37. novelwriter/assets/icons/typicons_dark/typ_document-add.svg +4 -0
  38. novelwriter/assets/icons/typicons_dark/typ_document.svg +4 -0
  39. novelwriter/assets/icons/typicons_dark/typ_th-dot-more.svg +4 -0
  40. novelwriter/assets/icons/typicons_dark/typ_th-list.svg +9 -0
  41. novelwriter/assets/icons/typicons_light/icons.conf +11 -2
  42. novelwriter/assets/icons/typicons_light/mixed_document-new.svg +6 -0
  43. novelwriter/assets/icons/typicons_light/mixed_import.svg +5 -0
  44. novelwriter/assets/icons/typicons_light/nw_tb-bold-md.svg +4 -0
  45. novelwriter/assets/icons/typicons_light/nw_tb-bold.svg +3 -1
  46. novelwriter/assets/icons/typicons_light/nw_tb-italic-md.svg +4 -0
  47. novelwriter/assets/icons/typicons_light/nw_tb-italic.svg +3 -1
  48. novelwriter/assets/icons/typicons_light/nw_tb-strike-md.svg +4 -0
  49. novelwriter/assets/icons/typicons_light/nw_tb-strike.svg +3 -1
  50. novelwriter/assets/icons/typicons_light/nw_tb-subscript.svg +4 -2
  51. novelwriter/assets/icons/typicons_light/nw_tb-superscript.svg +4 -2
  52. novelwriter/assets/icons/typicons_light/nw_tb-underline.svg +4 -2
  53. novelwriter/assets/icons/typicons_light/typ_document-add.svg +4 -0
  54. novelwriter/assets/icons/typicons_light/typ_document.svg +4 -0
  55. novelwriter/assets/icons/typicons_light/typ_th-dot-more.svg +4 -0
  56. novelwriter/assets/icons/typicons_light/typ_th-list.svg +9 -0
  57. novelwriter/assets/images/novelwriter-text-dark.svg +4 -0
  58. novelwriter/assets/images/novelwriter-text-light.svg +4 -0
  59. novelwriter/assets/images/welcome-dark.jpg +0 -0
  60. novelwriter/assets/images/welcome-light.jpg +0 -0
  61. novelwriter/assets/manual.pdf +0 -0
  62. novelwriter/assets/sample.zip +0 -0
  63. novelwriter/assets/syntax/cyberpunk_night.conf +26 -0
  64. novelwriter/assets/syntax/default_dark.conf +1 -0
  65. novelwriter/assets/syntax/default_light.conf +1 -0
  66. novelwriter/assets/syntax/grey_dark.conf +1 -0
  67. novelwriter/assets/syntax/grey_light.conf +1 -0
  68. novelwriter/assets/syntax/light_owl.conf +1 -0
  69. novelwriter/assets/syntax/night_owl.conf +1 -0
  70. novelwriter/assets/syntax/solarized_dark.conf +1 -0
  71. novelwriter/assets/syntax/solarized_light.conf +1 -0
  72. novelwriter/assets/syntax/tango.conf +23 -0
  73. novelwriter/assets/syntax/tomorrow.conf +1 -0
  74. novelwriter/assets/syntax/tomorrow_night.conf +1 -0
  75. novelwriter/assets/syntax/tomorrow_night_blue.conf +1 -0
  76. novelwriter/assets/syntax/tomorrow_night_bright.conf +1 -0
  77. novelwriter/assets/syntax/tomorrow_night_eighties.conf +1 -0
  78. novelwriter/assets/text/credits_en.htm +4 -2
  79. novelwriter/assets/themes/cyberpunk_night.conf +29 -0
  80. novelwriter/assets/themes/default_dark.conf +2 -2
  81. novelwriter/assets/themes/default_light.conf +2 -2
  82. novelwriter/common.py +64 -66
  83. novelwriter/config.py +39 -44
  84. novelwriter/constants.py +39 -17
  85. novelwriter/core/buildsettings.py +8 -8
  86. novelwriter/core/coretools.py +198 -157
  87. novelwriter/core/docbuild.py +7 -4
  88. novelwriter/core/document.py +7 -7
  89. novelwriter/core/index.py +90 -57
  90. novelwriter/core/item.py +23 -5
  91. novelwriter/core/options.py +11 -10
  92. novelwriter/core/project.py +73 -47
  93. novelwriter/core/projectdata.py +3 -16
  94. novelwriter/core/projectxml.py +14 -42
  95. novelwriter/core/sessions.py +4 -3
  96. novelwriter/core/spellcheck.py +6 -4
  97. novelwriter/core/status.py +5 -4
  98. novelwriter/core/storage.py +183 -141
  99. novelwriter/core/tohtml.py +6 -4
  100. novelwriter/core/tokenizer.py +110 -83
  101. novelwriter/core/tomd.py +2 -2
  102. novelwriter/core/toodt.py +41 -31
  103. novelwriter/core/tree.py +5 -4
  104. novelwriter/dialogs/about.py +88 -179
  105. novelwriter/dialogs/docmerge.py +30 -20
  106. novelwriter/dialogs/docsplit.py +33 -22
  107. novelwriter/dialogs/editlabel.py +20 -8
  108. novelwriter/dialogs/preferences.py +562 -725
  109. novelwriter/dialogs/{projsettings.py → projectsettings.py} +301 -270
  110. novelwriter/dialogs/quotes.py +47 -36
  111. novelwriter/dialogs/wordlist.py +128 -59
  112. novelwriter/enum.py +25 -22
  113. novelwriter/error.py +2 -2
  114. novelwriter/extensions/circularprogress.py +12 -12
  115. novelwriter/extensions/configlayout.py +185 -146
  116. novelwriter/extensions/{wheeleventfilter.py → eventfilters.py} +15 -5
  117. novelwriter/extensions/modified.py +81 -0
  118. novelwriter/extensions/novelselector.py +27 -13
  119. novelwriter/extensions/pagedsidebar.py +15 -20
  120. novelwriter/extensions/simpleprogress.py +8 -9
  121. novelwriter/extensions/statusled.py +9 -9
  122. novelwriter/extensions/switch.py +32 -64
  123. novelwriter/extensions/switchbox.py +2 -7
  124. novelwriter/extensions/versioninfo.py +153 -0
  125. novelwriter/gui/doceditor.py +250 -214
  126. novelwriter/gui/dochighlight.py +66 -94
  127. novelwriter/gui/docviewer.py +71 -98
  128. novelwriter/gui/docviewerpanel.py +140 -47
  129. novelwriter/gui/editordocument.py +3 -3
  130. novelwriter/gui/itemdetails.py +9 -9
  131. novelwriter/gui/mainmenu.py +47 -47
  132. novelwriter/gui/noveltree.py +53 -61
  133. novelwriter/gui/outline.py +100 -76
  134. novelwriter/gui/projtree.py +246 -112
  135. novelwriter/gui/sidebar.py +9 -8
  136. novelwriter/gui/statusbar.py +49 -7
  137. novelwriter/gui/theme.py +74 -76
  138. novelwriter/guimain.py +175 -330
  139. novelwriter/shared.py +68 -30
  140. novelwriter/tools/dictionaries.py +7 -8
  141. novelwriter/tools/lipsum.py +34 -28
  142. novelwriter/tools/manusbuild.py +3 -4
  143. novelwriter/tools/manuscript.py +25 -32
  144. novelwriter/tools/manussettings.py +194 -225
  145. novelwriter/tools/noveldetails.py +525 -0
  146. novelwriter/tools/welcome.py +819 -0
  147. novelwriter/tools/writingstats.py +26 -13
  148. novelWriter-2.2rc1.dist-info/entry_points.txt +0 -5
  149. novelwriter/assets/icons/typicons_dark/nw_tb-markdown.svg +0 -8
  150. novelwriter/assets/icons/typicons_dark/nw_tb-shortcode.svg +0 -8
  151. novelwriter/assets/icons/typicons_light/nw_tb-markdown.svg +0 -8
  152. novelwriter/assets/icons/typicons_light/nw_tb-shortcode.svg +0 -8
  153. novelwriter/assets/images/wizard-back.jpg +0 -0
  154. novelwriter/assets/text/gplv3_en.htm +0 -641
  155. novelwriter/assets/text/release_notes.htm +0 -17
  156. novelwriter/dialogs/projdetails.py +0 -525
  157. novelwriter/dialogs/projload.py +0 -298
  158. novelwriter/dialogs/updates.py +0 -182
  159. novelwriter/extensions/pageddialog.py +0 -130
  160. novelwriter/tools/projwizard.py +0 -478
  161. {novelWriter-2.2rc1.dist-info → novelWriter-2.3.dist-info}/LICENSE.md +0 -0
  162. {novelWriter-2.2rc1.dist-info → novelWriter-2.3.dist-info}/top_level.txt +0 -0
@@ -6,7 +6,7 @@ File History:
6
6
  Created: 2022-11-01 [2.0rc2] NWStorage
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,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,127 @@ 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():
187
+ if inPath.name == nwFiles.PROJ_FILE:
188
+ nwxFile = inPath
189
+ else:
190
+ logger.error("Not a novelWriter project")
191
+ return NWStorageOpen.UNKOWN
192
+ else:
193
+ logger.error("Not found: %s", inPath)
194
+ return NWStorageOpen.NOT_FOUND
195
+
196
+ if not nwxFile.exists():
197
+ # The .nwx file must exist to continue
198
+ logger.error("Not found: %s", nwxFile)
199
+ return NWStorageOpen.NOT_FOUND
200
+
201
+ nwxPath = nwxFile.parent
202
+
203
+ self._storagePath = nwxPath
204
+ self._runtimePath = nwxPath
205
+ self._lockFilePath = nwxPath / nwFiles.PROJ_LOCK
206
+ self._openMode = self.MODE_INPLACE
207
+
208
+ # Check Project Lock
209
+ # ==================
210
+
211
+ if clearLock:
212
+ self._clearLockFile()
213
+
214
+ self._readLockFile()
215
+ if self._lockedBy:
216
+ logger.error("Project is locked, so not opening")
217
+ return NWStorageOpen.LOCKED
218
+ else:
219
+ logger.debug("Project is not locked")
220
+
221
+ # Prepare Folder
222
+ # ==============
223
+
224
+ basePath = self._runtimePath
225
+ metaPath = basePath / "meta"
226
+ contPath = basePath / "content"
227
+ try:
228
+ metaPath.mkdir(exist_ok=True)
229
+ contPath.mkdir(exist_ok=True)
230
+ except Exception as exc:
231
+ logger.error("Failed to create project folders", exc_info=exc)
232
+ self.clear()
233
+ return NWStorageOpen.FAILED
234
+
235
+ # Check for legacy data folders
236
+ legacy = _LegacyStorage(self._project)
237
+ legacy.deprecatedFiles(basePath)
238
+ for child in basePath.iterdir():
239
+ if child.is_dir() and child.name.startswith("data_"):
240
+ legacy.legacyDataFolder(basePath, child)
241
+
242
+ self._writeLockFile()
243
+ self._ready = True
244
+
245
+ return NWStorageOpen.READY
138
246
 
139
247
  def runPostSaveTasks(self, autoSave: bool = False) -> bool: # pragma: no cover
140
248
  """Run tasks after the project has been saved.
@@ -147,7 +255,7 @@ class NWStorage:
147
255
 
148
256
  def closeSession(self) -> None:
149
257
  """Run tasks related to closing the session."""
150
- self.clearLockFile()
258
+ self._clearLockFile()
151
259
  self.clear()
152
260
  return
153
261
 
@@ -157,26 +265,25 @@ class NWStorage:
157
265
 
158
266
  def getXmlReader(self) -> ProjectXMLReader | None:
159
267
  """Return a properly configured ProjectXMLReader instance."""
160
- if isinstance(self._runtimePath, Path):
161
- projFile = self._runtimePath / nwFiles.PROJ_FILE
162
- return ProjectXMLReader(projFile)
268
+ if isinstance(self._runtimePath, Path) and self._ready:
269
+ return ProjectXMLReader(self._runtimePath / nwFiles.PROJ_FILE)
163
270
  return None
164
271
 
165
272
  def getXmlWriter(self) -> ProjectXMLWriter | None:
166
273
  """Return a properly configured ProjectXMLWriter instance."""
167
- if isinstance(self._runtimePath, Path):
168
- return ProjectXMLWriter(self._runtimePath)
274
+ if isinstance(self._runtimePath, Path) and self._ready:
275
+ return ProjectXMLWriter(self._runtimePath / nwFiles.PROJ_FILE)
169
276
  return None
170
277
 
171
278
  def getDocument(self, tHandle: str | None) -> NWDocument:
172
279
  """Return a document wrapper object."""
173
- if isinstance(self._runtimePath, Path):
280
+ if isinstance(self._runtimePath, Path) and self._ready:
174
281
  return NWDocument(self._project, tHandle)
175
282
  return NWDocument(self._project, None)
176
283
 
177
284
  def getMetaFile(self, fileName: str) -> Path | None:
178
285
  """Return the path to a file in the project meta folder."""
179
- if isinstance(self._runtimePath, Path):
286
+ if isinstance(self._runtimePath, Path) and self._ready:
180
287
  return self._runtimePath / "meta" / fileName
181
288
  return None
182
289
 
@@ -190,59 +297,6 @@ class NWStorage:
190
297
  if item.suffix == ".nwd" and isHandle(item.stem)
191
298
  ] if contentPath else []
192
299
 
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
300
  def zipIt(self, target: str | Path, compression: int | None = None) -> bool:
247
301
  """Zip the content of the project at its runtime location into a
248
302
  zip file. This process will only grab files that are supposed to
@@ -257,7 +311,6 @@ class NWStorage:
257
311
  baseCont = basePath / "content"
258
312
  files = [
259
313
  (basePath / nwFiles.PROJ_FILE, nwFiles.PROJ_FILE),
260
- (basePath / nwFiles.PROJ_BACKUP, nwFiles.PROJ_BACKUP),
261
314
  (baseMeta / nwFiles.BUILDS_FILE, f"meta/{nwFiles.BUILDS_FILE}"),
262
315
  (baseMeta / nwFiles.INDEX_FILE, f"meta/{nwFiles.INDEX_FILE}"),
263
316
  (baseMeta / nwFiles.OPTS_FILE, f"meta/{nwFiles.OPTS_FILE}"),
@@ -279,7 +332,7 @@ class NWStorage:
279
332
  zipObj.write(srcPath, zipPath)
280
333
  logger.debug("Added: %s", zipPath)
281
334
  except Exception:
282
- logger.error("Failed to create acrhive")
335
+ logger.error("Failed to create archive")
283
336
  logException()
284
337
  return False
285
338
 
@@ -289,59 +342,48 @@ class NWStorage:
289
342
  # Internal Functions
290
343
  ##
291
344
 
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
345
+ def _readLockFile(self) -> None:
346
+ """Read the project lock file."""
347
+ self._lockedBy = None
348
+ path = self._lockFilePath
349
+ if isinstance(path, Path) and path.exists():
350
+ try:
351
+ self._lockedBy = path.read_text(encoding="utf-8").strip().split(";")
352
+ except Exception:
353
+ logger.error("Failed to read project lockfile")
354
+ logException()
355
+ self._lockedBy = ["ERROR", "ERROR", "ERROR", "ERROR"]
356
+ return
357
+ return
299
358
 
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()
359
+ def _writeLockFile(self) -> bool:
360
+ """Write the project lock file."""
361
+ if self._lockFilePath is None:
303
362
  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
363
  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()
364
+ self._lockFilePath.write_text(
365
+ f"{CONFIG.hostName};{CONFIG.osType};{CONFIG.kernelVer};{int(time())}",
366
+ encoding="utf-8"
367
+ )
368
+ except Exception:
369
+ logger.error("Failed to write project lockfile")
370
+ logException()
322
371
  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
372
  return True
341
373
 
342
- ##
343
- # Legacy Project Data Handlers
344
- ##
374
+ def _clearLockFile(self) -> bool:
375
+ """Remove the lock file, if it exists."""
376
+ if self._lockFilePath is None:
377
+ return False
378
+ if self._lockFilePath.exists():
379
+ try:
380
+ self._lockFilePath.unlink()
381
+ except Exception:
382
+ logger.error("Failed to remove project lockfile")
383
+ logException()
384
+ return False
385
+ self._lockedBy = None
386
+ return True
345
387
 
346
388
  # END Class NWStorage
347
389
 
@@ -350,7 +392,7 @@ class _LegacyStorage:
350
392
  """Core: Legacy Storage Converter Utils
351
393
 
352
394
  A class with various functions to convert old file formats and
353
- file/folder layout to the current project format.
395
+ file/folder layouts to the current project format.
354
396
  """
355
397
 
356
398
  def __init__(self, project: NWProject) -> None:
@@ -359,7 +401,8 @@ class _LegacyStorage:
359
401
 
360
402
  def legacyDataFolder(self, path: Path, child: Path) -> None:
361
403
  """Handle the content of a legacy data folder from a version 1.0
362
- project.
404
+ project. This format had 16 data folders where there now is only
405
+ one content folder.
363
406
  """
364
407
  logger.info("Processing legacy data folder: %s", path)
365
408
 
@@ -411,6 +454,7 @@ class _LegacyStorage:
411
454
  path / "meta" / nwFiles.OPTS_FILE
412
455
  )
413
456
 
457
+ # Delete removed files
414
458
  remove = [
415
459
  path / "meta" / "tagsIndex.json", # Renamed in 2.1 Beta 1
416
460
  path / "meta" / "mainOptions.json", # Replaced in 0.5
@@ -422,6 +466,7 @@ class _LegacyStorage:
422
466
  path / "cache" / "prevBuild.json", # Dropped in 2.1 Beta 1
423
467
  path / "cache", # Dropped in 2.1 Beta 1
424
468
  path / "ToC.json", # Dropped in 1.0 RC 1
469
+ path / "nwProject.bak", # Dropped in 2.3 Beta 1
425
470
  ]
426
471
  for item in remove:
427
472
  if item.exists():
@@ -457,7 +502,6 @@ class _LegacyStorage:
457
502
 
458
503
  # Save dictionary and clean up old file
459
504
  userDict.save()
460
- assert wordJson.exists()
461
505
  wordList.unlink()
462
506
 
463
507
  except Exception:
@@ -467,9 +511,7 @@ class _LegacyStorage:
467
511
  return
468
512
 
469
513
  def _convertOldLogFile(self, sessLog: Path, sessJson: Path) -> None:
470
- """Convert the old text log file format to the new JSON Lines
471
- format.
472
- """
514
+ """Convert the old text log file format to JSON Lines."""
473
515
  if sessJson.exists() or not sessLog.exists():
474
516
  # If the new file already exists, we won't overwrite it
475
517
  return
@@ -508,7 +550,7 @@ class _LegacyStorage:
508
550
  return
509
551
 
510
552
  def _convertOldOptionsFile(self, optsOld: Path, optsNew: Path) -> None:
511
- """Convert the old options state file format to the format."""
553
+ """Convert the old options state file format to new format."""
512
554
  if optsNew.exists() or not optsOld.exists():
513
555
  # If the new file already exists, we won't overwrite it
514
556
  return
@@ -3,10 +3,10 @@ novelWriter – HTML Text Converter
3
3
  =================================
4
4
 
5
5
  File History:
6
- Created: 2019-05-07 [0.0.1]
6
+ Created: 2019-05-07 [0.0.1] ToHtml
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
@@ -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:])