Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Single file format #1558

Closed
wants to merge 22 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
6b09ed6
Add single file option to project wizard
vkbo Oct 26, 2023
1fa4d1b
Add basic implementation of single file storage
vkbo Oct 26, 2023
4719ee4
Clean up single file mode in storage class
vkbo Oct 26, 2023
58f1336
Fix single file sample project and a few other bits
vkbo Oct 26, 2023
4f088ce
Restructure storage open to separate create and open functions
vkbo Oct 26, 2023
c248944
Fix a few issues with storage open
vkbo Oct 26, 2023
ad62b5d
Remove the windows install scripts and custom registry setup
vkbo Oct 27, 2023
80a4a8b
Add OS support for .nwproj extension
vkbo Oct 27, 2023
50590d1
Fix debian packaging issue and mimetype
vkbo Oct 27, 2023
12a662d
Remove windows bat files from minimal zip package build
vkbo Oct 27, 2023
bbf52e7
Change file extension, fix most of the tests and change the checking …
vkbo Oct 27, 2023
7645c36
Improve how archived projects are handled, and clean up tests
vkbo Oct 28, 2023
524c22d
Redo all icons
vkbo Oct 29, 2023
8913310
Make data folder handling more robust
vkbo Oct 31, 2023
6413ba9
Improve project file handling in single file projects
vkbo Oct 31, 2023
2040ac1
Make some minor fixes and update tests
vkbo Oct 31, 2023
dd6423b
Split the open project function in the storage class
vkbo Nov 1, 2023
06cd866
Restructure how projects are opened
vkbo Nov 2, 2023
1d47f51
Merge branch 'dev' into single_file
vkbo Nov 5, 2023
1c648de
Merge branch 'dev' into single_file
vkbo Nov 8, 2023
791ad4a
Merge branch 'dev' into single_file
vkbo Nov 11, 2023
f947e39
Merge branch 'dev' into single_file
vkbo Nov 18, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 23 additions & 8 deletions novelwriter/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@

import sys
import json
import uuid
import logging

from time import time
Expand All @@ -45,6 +46,15 @@
logger = logging.getLogger(__name__)


def _ensureFolder(path: Path) -> None:
"""Ensure a folder exists and handle name conflicts."""
if not path.is_dir():
if path.exists():
path.rename(path.with_stem(f"{path.stem}-{uuid.uuid4()}"))
path.mkdir()
return


class Config:

LANG_NW = 1
Expand Down Expand Up @@ -379,10 +389,13 @@ def rpxInt(self, value: int) -> int:
"""Un-scale fixed gui sizes by the screen scale factor."""
return int(value/self.guiScale)

def dataPath(self, target: str | None = None) -> Path:
def dataPath(self, target: str | None = None, mkdir: bool = False) -> Path:
"""Return a path in the data folder."""
if isinstance(target, str):
return self._dataPath / target
targetPath = self._dataPath / target
if mkdir:
_ensureFolder(targetPath)
return targetPath
return self._dataPath

def assetPath(self, target: str | None = None) -> Path:
Expand Down Expand Up @@ -466,15 +479,17 @@ def initConfig(self, confPath: str | Path | None = None,

# If the config and data folders don't exist, create them
# This assumes that the os config and data folders exist
self._confPath.mkdir(exist_ok=True)
self._dataPath.mkdir(exist_ok=True)
_ensureFolder(self._confPath)
_ensureFolder(self._dataPath)

# Also create the syntax, themes and icons folders if possible
if self._dataPath.is_dir():
(self._dataPath / "cache").mkdir(exist_ok=True)
(self._dataPath / "icons").mkdir(exist_ok=True)
(self._dataPath / "syntax").mkdir(exist_ok=True)
(self._dataPath / "themes").mkdir(exist_ok=True)
_ensureFolder(self._dataPath / "temp")
_ensureFolder(self._dataPath / "cache")
_ensureFolder(self._dataPath / "icons")
_ensureFolder(self._dataPath / "syntax")
_ensureFolder(self._dataPath / "themes")
_ensureFolder(self._dataPath / "projects")

# Check if config file exists, and load it. If not, we save defaults
if (self._confPath / nwFiles.CONF_FILE).is_file():
Expand Down
2 changes: 1 addition & 1 deletion novelwriter/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,8 @@ class nwFiles:
RECENT_FILE = "recentProjects.json"

# Project Root Files
PROJ_ARCH = "project.xml"
PROJ_FILE = "nwProject.nwx"
PROJ_BACKUP = "nwProject.bak"
PROJ_LOCK = "nwProject.lock"
TOC_TXT = "ToC.txt"

Expand Down
21 changes: 19 additions & 2 deletions novelwriter/core/coretools.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import logging

from typing import Iterable
from pathlib import Path
from functools import partial

from PyQt5.QtCore import QCoreApplication
Expand Down Expand Up @@ -310,8 +311,14 @@ class ProjectBuilder:

def __init__(self) -> None:
self.tr = partial(QCoreApplication.translate, "NWProject")
self._path = None
return

@property
def projPath(self) -> Path | None:
"""The actual path of the project."""
return self._path

##
# Methods
##
Expand All @@ -327,6 +334,7 @@ def buildProject(self, data: dict) -> bool:
popMinimal = data.get("popMinimal", True)
popCustom = data.get("popCustom", False)
popSample = data.get("popSample", False)
asArchive = data.get("asArchive", False)

# Check if we're extracting the sample project. This is handled
# differently as it isn't actually a new project, so we forward
Expand All @@ -340,9 +348,11 @@ def buildProject(self, data: dict) -> bool:
return False

project = NWProject()
if not project.storage.openProjectInPlace(projPath, newProject=True):
if not project.storage.createNewProject(projPath, asArchive):
return False

self._path = project.storage.storagePath

lblNewProject = self.tr("New Project")
lblNewChapter = self.tr("New Chapter")
lblNewScene = self.tr("New Scene")
Expand Down Expand Up @@ -470,10 +480,17 @@ def _extractSampleProject(self, data: dict) -> bool:
logger.error("No project path set for the example project")
return False

projPath = Path(projPath).resolve()
pkgSample = CONFIG.assetPath("sample.zip")
if pkgSample.is_file():
try:
shutil.unpack_archive(pkgSample, projPath)
if data.get("asArchive", False):
targetPath = projPath.with_suffix(".nwproj")
shutil.copy(pkgSample, targetPath)
self._path = targetPath
else:
shutil.unpack_archive(pkgSample, projPath)
self._path = projPath
except Exception as exc:
SHARED.error(self.tr(
"Failed to create a new example project."
Expand Down
95 changes: 53 additions & 42 deletions novelwriter/core/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import json
import logging

from enum import Enum
from time import time
from typing import TYPE_CHECKING, Iterator
from pathlib import Path
Expand All @@ -40,7 +41,7 @@
from novelwriter.core.tree import NWTree
from novelwriter.core.index import NWIndex
from novelwriter.core.options import OptionState
from novelwriter.core.storage import NWStorage
from novelwriter.core.storage import NWStorage, NWStorageOpen
from novelwriter.core.sessions import NWSessionLog
from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter, XMLReadState
from novelwriter.core.projectdata import NWProjectData
Expand All @@ -55,6 +56,16 @@
logger = logging.getLogger(__name__)


class NWProjectState(Enum):

UNKNOWN = 0
LOCKED = 1
RECOVERY = 2
READY = 3

# END Enum NWProjectState


class NWProject:

def __init__(self) -> None:
Expand All @@ -69,9 +80,9 @@ def __init__(self) -> None:

# Project Status
self._langData = {} # Localisation data
self._lockedBy = None # Data on which computer has the project open
self._changed = False # The project has unsaved changes
self._valid = False # The project was successfully loaded
self._state = NWProjectState.UNKNOWN

# Internal Mapping
self.tr = partial(QCoreApplication.translate, "NWProject")
Expand Down Expand Up @@ -125,12 +136,15 @@ def isValid(self) -> bool:
"""Return True if a project is loaded."""
return self._valid

@property
def state(self) -> NWProjectState:
"""Return the current project state."""
return self._state

@property
def lockStatus(self) -> list | None:
"""Return the project lock information."""
if isinstance(self._lockedBy, list) and len(self._lockedBy) == 4:
return self._lockedBy
return None
return self._storage.lockStatus

@property
def currentEditTime(self) -> int:
Expand Down Expand Up @@ -219,29 +233,24 @@ def openProject(self, projPath: str | Path, clearLock: bool = False) -> bool:
build the tree of project items.
"""
logger.info("Opening project: %s", projPath)
if not self._storage.openProjectInPlace(projPath):
SHARED.error(self.tr("Could not open project with path: {0}").format(projPath))
return False

# Project Lock
# ============
status = self._storage.initProjectLocation(projPath, clearLock)
if status != NWStorageOpen.READY:
if status == NWStorageOpen.UNKOWN:
SHARED.error(self.tr("Not a known project file format."))
elif status == NWStorageOpen.NOT_FOUND:
SHARED.error(self.tr("Project file not found."))
elif status == NWStorageOpen.LOCKED:
self._state = NWProjectState.LOCKED
elif status == NWStorageOpen.RECOVERY:
self._state = NWProjectState.RECOVERY
elif status == NWStorageOpen.FAILED:
SHARED.error(self.tr("Failed to open project."), exc=self._storage.exc)
print("oops", status)
return False

if clearLock:
self._storage.clearLockFile()

lockStatus = self._storage.readLockFile()
if len(lockStatus) > 0:
if lockStatus[0] == "ERROR":
logger.warning("Failed to check lock file")
else:
logger.error("Project is locked, so not opening")
self._lockedBy = lockStatus
return False
else:
logger.debug("Project is not locked")

# Open The Project XML File
# =========================
# Read Project XML
# ================

xmlReader = self._storage.getXmlReader()
if not isinstance(xmlReader, ProjectXMLReader):
Expand All @@ -250,9 +259,7 @@ def openProject(self, projPath: str | Path, clearLock: bool = False) -> bool:
self._data = NWProjectData(self)
projContent = []
xmlParsed = xmlReader.read(self._data, projContent)

appVersion = xmlReader.appVersion or self.tr("Unknown")

if not xmlParsed:
if xmlReader.state == XMLReadState.NOT_NWX_FILE:
SHARED.error(self.tr(
Expand Down Expand Up @@ -323,9 +330,9 @@ def openProject(self, projPath: str | Path, clearLock: bool = False) -> bool:

self.updateWordCounts()
self._session.startSession()
self._storage.writeLockFile()
self.setProjectChanged(False)
self._valid = True
self._state = NWProjectState.READY

SHARED.newStatusMessage(self.tr("Opened Project: {0}").format(self._data.name))

Expand Down Expand Up @@ -367,29 +374,30 @@ def saveProject(self, autoSave: bool = False) -> bool:
# Save other project data
self._options.saveSettings()
self._index.saveIndex()
self._storage.runPostSaveTasks(autoSave=autoSave)
self._storage.runPostSaveTasks()

# Update recent projects
storePath = self._storage.storagePath
if storePath:
if storagePath := self._storage.storagePath:
CONFIG.recentProjects.update(
storePath, self._data.name, sum(self._data.currCounts), saveTime
storagePath, self._data.name, sum(self._data.currCounts), saveTime
)

self._storage.writeLockFile()
SHARED.newStatusMessage(self.tr("Saved Project: {0}").format(self._data.name))
self.setProjectChanged(False)

return True

def closeProject(self, idleTime: float = 0.0) -> None:
"""Close the project."""
logger.info("Closing project")
self._options.saveSettings()
def saveProjectMeta(self, idleTime: float) -> None:
"""Save project meta data. Needed before closing the project."""
self._tree.writeToCFile()
self._options.saveSettings()
self._session.appendSession(idleTime)
return

def closeProject(self) -> None:
"""Close the project."""
logger.info("Closing project")
self._storage.closeSession()
self._lockedBy = None
return

def backupProject(self, doNotify: bool) -> bool:
Expand Down Expand Up @@ -418,10 +426,13 @@ def backupProject(self, doNotify: bool) -> bool:
return False

timeStamp = formatTimeStamp(time(), fileSafe=True)
archName = baseDir / f"{cleanName} {timeStamp}.zip"
if self._storage.zipIt(archName, compression=2):
size = formatInt(archName.stat().st_size)
archName = baseDir / f"{cleanName} {timeStamp}.nwproj"
if self._storage.zipIt(archName, compression=2, isBackup=True):
if doNotify:
try:
size = formatInt(archName.stat().st_size)
except Exception:
size = -1
SHARED.info(
self.tr("Created a backup of your project of size {0}B.").format(size),
info=self.tr("Path: {0}").format(str(backupPath))
Expand Down
12 changes: 5 additions & 7 deletions novelwriter/core/projectxml.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@
checkBool, checkInt, checkString, checkStringNone, formatTimeStamp,
hexToInt, simplified, xmlIndent, yesNo
)
from novelwriter.constants import nwFiles

if TYPE_CHECKING: # pragma: no cover
from novelwriter.core.status import NWStatus
Expand Down Expand Up @@ -558,9 +557,8 @@ def write(self, data: NWProjectData, content: list, saveTime: float, editTime: i
xName.text = item["name"]

# Write the XML tree to file
saveFile = self._path / nwFiles.PROJ_FILE
tempFile = saveFile.with_suffix(".tmp")
backFile = saveFile.with_suffix(".bak")
tempFile = self._path.with_suffix(".tmp")
backFile = self._path.with_suffix(".bak")
try:
xml = ET.ElementTree(xRoot)
xmlIndent(xml)
Expand All @@ -572,9 +570,9 @@ def write(self, data: NWProjectData, content: list, saveTime: float, editTime: i
# If we're here, the file was successfully saved,
# so let's sort out the temps and backups
try:
if saveFile.exists():
saveFile.replace(backFile)
tempFile.replace(saveFile)
if self._path.exists():
self._path.replace(backFile)
tempFile.replace(self._path)
except Exception as exc:
self._error = exc
return False
Expand Down
Loading
Loading