Some checks failed
Syntax / Linting and Type Checking (push) Has been cancelled
Tests / Linux Python 3.11 (push) Has been cancelled
Tests / Linux Python 3.12 (push) Has been cancelled
Tests / Linux Python 3.13 (push) Has been cancelled
Tests / Linux Python 3.14 (push) Has been cancelled
Tests / Linux Python 3.15 (push) Has been cancelled
Tests / MacOS Python 3.14 (push) Has been cancelled
Tests / Windows Python 3.14 (push) Has been cancelled
Tests / PR Coverage Report (push) Has been cancelled
Tests / Action Coverage Report (push) Has been cancelled
Tests / Ubuntu 24.04 & Sys Packages (push) Has been cancelled
Tests / Fedora 44 & Sys Packages (push) Has been cancelled
- Add Save Project As menu item under Project menu with Ctrl+Alt+S shortcut - SaveProjectAs dialog prompts for destination folder and new project name - Copies all document files, metadata, and project XML to new location - New location added to recent projects as a separate entry - Original project location remains in recent projects unchanged - Generate new UUID for the copied project
802 lines
29 KiB
Python
802 lines
29 KiB
Python
"""
|
|
UmbraEdit - Project Wrapper
|
|
=============================
|
|
|
|
This file is a part of UmbraEdit.
|
|
|
|
Copyright (C) 2018 Veronica Berglyd Olsen and UmbraEdit contributors.
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU General Public License as published by
|
|
the Free Software Foundation, either version 3 of the License, or
|
|
(at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful, but
|
|
WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
|
General Public License for more details.
|
|
|
|
You should have received a copy of the GNU General Public License
|
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
""" # noqa
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import shutil
|
|
|
|
from enum import Enum
|
|
from functools import partial
|
|
from pathlib import Path
|
|
from time import time
|
|
from typing import TYPE_CHECKING
|
|
|
|
from PyQt6.QtCore import QCoreApplication
|
|
|
|
from umbraedit import CONFIG, SHARED, __hexversion__, __version__
|
|
from umbraedit.common import (
|
|
checkStringNone,
|
|
formatInt,
|
|
formatTimeStamp,
|
|
getFileSize,
|
|
hexToInt,
|
|
makeFileNameSafe,
|
|
minmax,
|
|
safeIsFile,
|
|
)
|
|
from umbraedit.constants import nwFiles, nwLabels, nwStyles, trConst
|
|
from umbraedit.core.index import Index
|
|
from umbraedit.core.options import OptionState
|
|
from umbraedit.core.projectdata import ProjectData
|
|
from umbraedit.core.projectxml import ProjectXMLReader, ProjectXMLWriter, XMLReadState
|
|
from umbraedit.core.sessions import SessionLog
|
|
from umbraedit.core.storage import ProjectStorage, ProjectStorageCreate, ProjectStorageOpen
|
|
from umbraedit.core.tree import ProjectTree
|
|
from umbraedit.enum import nwItemClass, nwItemLayout, nwItemType, nwProjectKind
|
|
from umbraedit.error import logException
|
|
|
|
if TYPE_CHECKING:
|
|
from umbraedit.core.status import T_StatusKind, T_UpdateEntry
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class NWProjectState(Enum):
|
|
"""The state of the loaded project."""
|
|
|
|
UNKNOWN = 0
|
|
LOCKED = 1
|
|
RECOVERY = 2
|
|
READY = 3
|
|
|
|
|
|
class NWProject:
|
|
"""Core: UmbraEdit Project Class.
|
|
|
|
This class is the parent class of the project, and holds instances
|
|
of project data, the project tree, and the project index.
|
|
"""
|
|
|
|
__slots__ = (
|
|
"_changed",
|
|
"_countsDirty",
|
|
"_data",
|
|
"_index",
|
|
"_langData",
|
|
"_options",
|
|
"_session",
|
|
"_state",
|
|
"_storage",
|
|
"_tree",
|
|
"_valid",
|
|
"tr",
|
|
)
|
|
|
|
def __init__(self) -> None:
|
|
|
|
# Core Elements
|
|
self._options = OptionState(self) # Project-specific GUI options
|
|
self._storage = ProjectStorage(self) # The project storage handler
|
|
self._data = ProjectData(self) # The project settings
|
|
self._tree = ProjectTree(self) # The project tree
|
|
self._index = Index(self) # The project index
|
|
self._session = SessionLog(self) # The session record
|
|
|
|
# Project Status
|
|
self._langData = {} # Localisation data
|
|
self._changed = False # The project has unsaved changes
|
|
self._valid = False # The project was successfully loaded
|
|
self._state = NWProjectState.UNKNOWN
|
|
self._countsDirty = False # The word counts need to be recalculated
|
|
|
|
# Internal Mapping
|
|
self.tr = partial(QCoreApplication.translate, "NWProject")
|
|
|
|
logger.debug("Ready: NWProject")
|
|
|
|
def __del__(self) -> None: # pragma: no cover
|
|
"""Class destructor."""
|
|
logger.debug("Delete: NWProject")
|
|
|
|
def clear(self) -> None:
|
|
"""Clear the project."""
|
|
self._tree.clear()
|
|
self._index.clear()
|
|
|
|
##
|
|
# Properties
|
|
##
|
|
|
|
@property
|
|
def options(self) -> OptionState:
|
|
return self._options
|
|
|
|
@property
|
|
def storage(self) -> ProjectStorage:
|
|
return self._storage
|
|
|
|
@property
|
|
def data(self) -> ProjectData:
|
|
return self._data
|
|
|
|
@property
|
|
def tree(self) -> ProjectTree:
|
|
return self._tree
|
|
|
|
@property
|
|
def index(self) -> Index:
|
|
return self._index
|
|
|
|
@property
|
|
def session(self) -> SessionLog:
|
|
return self._session
|
|
|
|
@property
|
|
def projOpened(self) -> float:
|
|
return self._session.start
|
|
|
|
@property
|
|
def projChanged(self) -> bool:
|
|
return self._changed
|
|
|
|
@property
|
|
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."""
|
|
return self._storage.lockStatus
|
|
|
|
@property
|
|
def currentTotalCount(self) -> int:
|
|
"""Return the current total word count from the tree."""
|
|
return self._tree.model.root.count
|
|
|
|
@property
|
|
def countsDirty(self) -> bool:
|
|
"""Return whether the word counts need to be recalculated."""
|
|
return self._countsDirty
|
|
|
|
##
|
|
# Item Methods
|
|
##
|
|
|
|
def newRoot(self, itemClass: nwItemClass, pos: int = -1) -> str:
|
|
"""Add a new root folder to the project. If label is not set,
|
|
use the class label.
|
|
"""
|
|
label = trConst(nwLabels.CLASS_NAME[itemClass])
|
|
return self._tree.create(label, None, nwItemType.ROOT, itemClass=itemClass, pos=pos)
|
|
|
|
def newFolder(self, label: str, parent: str, pos: int = -1) -> str | None:
|
|
"""Add a new folder with a given label and parent item."""
|
|
return self._tree.create(label, parent, nwItemType.FOLDER, pos=pos)
|
|
|
|
def newFile(self, label: str, parent: str, pos: int = -1) -> str | None:
|
|
"""Add a new file with a given label and parent item."""
|
|
return self._tree.create(label, parent, nwItemType.FILE, pos=pos)
|
|
|
|
def removeItem(self, tHandle: str) -> bool:
|
|
"""Remove an item from the project. This will delete both the
|
|
project entry and a document file if it exists.
|
|
"""
|
|
if self._tree.checkType(tHandle, nwItemType.FILE):
|
|
SHARED.closeDocument(tHandle)
|
|
doc = self._storage.getDocument(tHandle)
|
|
if not doc.deleteDocument():
|
|
SHARED.error(self.tr("Could not delete document file."), info=doc.error)
|
|
return False
|
|
self._index.deleteHandle(tHandle)
|
|
self._tree.remove(tHandle)
|
|
return True
|
|
|
|
def writeNewFile(
|
|
self,
|
|
tHandle: str,
|
|
hLevel: int,
|
|
isDocument: bool,
|
|
text: str = "",
|
|
*,
|
|
addHeading: bool = True,
|
|
) -> bool:
|
|
"""Write content to a new document after it is created. This
|
|
will not run if the file exists and is not empty.
|
|
"""
|
|
if not ((tItem := self._tree[tHandle]) and tItem.isFileType()):
|
|
return False
|
|
|
|
if self._storage.getDocumentText(tHandle).strip():
|
|
return False
|
|
|
|
if addHeading:
|
|
indent = "#" * minmax(hLevel, 1, 4)
|
|
text = f"{indent} {tItem.itemName}\n\n{text}"
|
|
|
|
if tItem.isNovelLike() and isDocument:
|
|
tItem.setLayout(nwItemLayout.DOCUMENT)
|
|
else:
|
|
tItem.setLayout(nwItemLayout.NOTE)
|
|
|
|
self._storage.getDocument(tHandle).writeDocument(text)
|
|
self._index.scanText(tHandle, text)
|
|
|
|
return True
|
|
|
|
def copyFileContent(self, tHandle: str, sHandle: str, newTitle: str | None = None) -> bool:
|
|
"""Copy content to a new document after it is created. This
|
|
will not run if the file exists and is not empty.
|
|
"""
|
|
if not ((tItem := self._tree[tHandle]) and tItem.isFileType()):
|
|
return False
|
|
|
|
if not ((sItem := self._tree[sHandle]) and sItem.isFileType()):
|
|
return False
|
|
|
|
if self._storage.getDocumentText(tHandle).strip():
|
|
return False
|
|
|
|
logger.debug("Populating '%s' with text from '%s'", tHandle, sHandle)
|
|
text = self._storage.getDocumentText(sHandle)
|
|
if newTitle and (lines := text.split("\n")) and lines and lines[0].startswith(nwStyles.H_MARKERS):
|
|
prefix, _, _ = lines[0].partition(" ")
|
|
lines[0] = f"{prefix} {newTitle}"
|
|
text = "\n".join(lines)
|
|
|
|
self._storage.getDocument(tHandle).writeDocument(text)
|
|
sItem.setLayout(tItem.itemLayout)
|
|
self._index.reIndexHandle(tHandle)
|
|
|
|
return True
|
|
|
|
def createNewNote(self, tag: str, itemClass: nwItemClass) -> None:
|
|
"""Create a new note. This function is used by the document
|
|
editor to create note files for unknown tags.
|
|
"""
|
|
if itemClass != nwItemClass.NO_CLASS:
|
|
if not (rHandle := self._tree.findRoot(itemClass)):
|
|
rHandle = self.newRoot(itemClass)
|
|
if rHandle and (tHandle := self.newFile(tag.title(), rHandle)):
|
|
self.writeNewFile(tHandle, 1, False, f"@tag: {tag}\n\n")
|
|
self._tree.refreshItems([tHandle])
|
|
|
|
##
|
|
# Project Methods
|
|
##
|
|
|
|
def openProject(self, projPath: str | Path, clearLock: bool = False) -> bool:
|
|
"""Open the project file provided. If it doesn't exist, assume
|
|
it is a folder and look for the file within it. If successful,
|
|
parse the XML of the file and populate the project variables and
|
|
build the tree of project items.
|
|
"""
|
|
logger.info("Opening project: %s", projPath)
|
|
|
|
status = self._storage.initProjectStorage(projPath, clearLock)
|
|
if status != ProjectStorageOpen.READY:
|
|
if status == ProjectStorageOpen.UNKOWN:
|
|
SHARED.error(
|
|
self.tr("Not a known project file format."),
|
|
info=self.tr("Path: {0}").format(str(projPath)),
|
|
)
|
|
elif status == ProjectStorageOpen.NOT_FOUND:
|
|
SHARED.error(
|
|
self.tr("Project file not found."),
|
|
info=self.tr("Path: {0}").format(str(projPath)),
|
|
)
|
|
elif status == ProjectStorageOpen.FAILED:
|
|
SHARED.error(
|
|
self.tr("Failed to open project."),
|
|
info=self.tr("Path: {0}").format(str(projPath)),
|
|
exc=self._storage.exc,
|
|
)
|
|
elif status == ProjectStorageOpen.LOCKED:
|
|
self._state = NWProjectState.LOCKED
|
|
else: # pragma: no cover
|
|
pass
|
|
return False
|
|
|
|
# Read Project XML
|
|
# ================
|
|
|
|
xmlReader = self._storage.getXmlReader()
|
|
if not isinstance(xmlReader, ProjectXMLReader):
|
|
return False
|
|
|
|
self._data = ProjectData(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("Project file does not appear to be a UmbraEditXML file."))
|
|
elif xmlReader.state == XMLReadState.UNKNOWN_VERSION:
|
|
SHARED.error(
|
|
self.tr(
|
|
"Unknown or unsupported UmbraEdit project file format. "
|
|
"The project cannot be opened by this version of UmbraEdit. "
|
|
"The file was saved with UmbraEdit version {0}."
|
|
).format(appVersion)
|
|
)
|
|
else:
|
|
SHARED.error(self.tr("Failed to parse project xml."))
|
|
return False
|
|
|
|
# Check Legacy Upgrade
|
|
# ====================
|
|
|
|
if (xmlReader.state == XMLReadState.WAS_LEGACY or self._storage.hasBreakingChanges()) and not SHARED.question(
|
|
self.tr(
|
|
"The file format of your project is about to be updated. "
|
|
"If you proceed, older versions of UmbraEdit will no "
|
|
"longer be able to open this project. Continue?"
|
|
)
|
|
):
|
|
return False
|
|
|
|
# Check UmbraEdit Version
|
|
# =========================
|
|
|
|
if xmlReader.hexVersion > hexToInt(__hexversion__) and not SHARED.question(
|
|
self.tr(
|
|
"This project was saved by a newer version of "
|
|
"UmbraEdit, version {0}. This is version {1}. If you "
|
|
"continue to open the project, some attributes and "
|
|
"settings may not be preserved, but the overall project "
|
|
"should be fine. Continue opening the project?"
|
|
).format(appVersion, __version__),
|
|
warn=True,
|
|
):
|
|
return False
|
|
|
|
# Post XML Loading
|
|
self._storage.runPostXMLTasks()
|
|
|
|
# Extract Data
|
|
# ============
|
|
|
|
self._tree.unpack(projContent)
|
|
self._options.loadSettings()
|
|
self._loadProjectLocalisation()
|
|
|
|
# Check the project tree consistency
|
|
# This also handles any orphaned files found
|
|
orphans, recovered = self._tree.checkConsistency(self.tr("Recovered"))
|
|
if orphans > 0:
|
|
SHARED.warn(
|
|
self.tr("Found {0} orphaned file(s) in the project. {1} file(s) were recovered.").format(
|
|
orphans, recovered
|
|
)
|
|
)
|
|
|
|
self._index.loadIndex()
|
|
if xmlReader.state == XMLReadState.WAS_LEGACY:
|
|
# Often, the index needs to be rebuilt when updating format
|
|
self._index.rebuild()
|
|
|
|
self.updateCounts()
|
|
self._session.startSession()
|
|
self.setProjectChanged(False)
|
|
|
|
# Check for copies and update recent projects
|
|
if storePath := self._storage.storagePath:
|
|
if self._storage.kind == nwProjectKind.BACKUP:
|
|
stamp = self._storage.timeStamp
|
|
if SHARED.question(
|
|
self.tr(
|
|
"This project is a backup copy. Do you want to make it a separate "
|
|
"project so UmbraEdit can tell it apart from the original?"
|
|
),
|
|
info=self.tr("Backup from: {0}").format(CONFIG.localDateTime(stamp)) if stamp else "",
|
|
):
|
|
self._data.setUuid("")
|
|
# The manifest must be saved as a regular project
|
|
self.setProjectChanged(True)
|
|
|
|
elif (other := CONFIG.recentProjects.findCopy(storePath, self._data.uuid)) and SHARED.question(
|
|
self.tr(
|
|
"This project appears to be a copy of another project. "
|
|
"It was either copied outside of UmbraEdit, or is a backup copy. "
|
|
"Do you want to make it a separate project so UmbraEdit can tell them apart?"
|
|
),
|
|
info=self.tr("Path: {0}").format(other),
|
|
):
|
|
self._data.setUuid("")
|
|
self.setProjectChanged(True)
|
|
|
|
CONFIG.recentProjects.update(storePath, self._data, time())
|
|
|
|
self._valid = True
|
|
self._state = NWProjectState.READY
|
|
self._storage.lockSession() # Lock only after a successful open. See issue #1977.
|
|
|
|
SHARED.newStatusMessage(self.tr("Opened Project: {0}").format(self._data.name))
|
|
|
|
return True
|
|
|
|
def saveProject(self, autoSave: bool = False) -> bool:
|
|
"""Save the project main XML file. The saving command itself
|
|
uses a temporary filename, and the file is replaced afterwards
|
|
to make sure if the save fails, we're not left with a truncated
|
|
file.
|
|
"""
|
|
if not self._storage.isOpen():
|
|
SHARED.error(self.tr("There is no project open."))
|
|
return False
|
|
|
|
saveTime = time()
|
|
|
|
logger.info("Saving project: %s", self._storage.storagePath)
|
|
|
|
if autoSave:
|
|
self._data.incAutoCount()
|
|
else:
|
|
self._data.incSaveCount()
|
|
|
|
self.updateCounts()
|
|
self.countStatus()
|
|
|
|
xmlWriter = self._storage.getXmlWriter()
|
|
if not isinstance(xmlWriter, ProjectXMLWriter):
|
|
return False
|
|
|
|
saveTime = time()
|
|
SHARED.clearErrorCache()
|
|
self._data.setIndexRevision(self._index.indexRevision)
|
|
editTime = self._data.editTime + max(round(saveTime - self._session.start), 0)
|
|
content = self._tree.pack()
|
|
if not xmlWriter.write(self._data, content, saveTime, editTime):
|
|
self._reportErrors(self.tr("Issues encountered when saving project:"))
|
|
return False
|
|
|
|
# Save other project data
|
|
if not autoSave:
|
|
self._options.saveSettings()
|
|
self._index.saveIndex()
|
|
|
|
self._storage.runPostSaveTasks(saveTime=saveTime, autoSave=autoSave)
|
|
|
|
# Update recent projects
|
|
if storagePath := self._storage.storagePath:
|
|
CONFIG.recentProjects.update(storagePath, self._data, saveTime)
|
|
|
|
self._reportErrors(self.tr("Issues encountered when saving project:"))
|
|
|
|
SHARED.newStatusMessage(self.tr("Saved Project: {0}").format(self._data.name))
|
|
self.setProjectChanged(False)
|
|
|
|
return True
|
|
|
|
def saveProjectAs(self, newPath: str | Path, newName: str) -> bool:
|
|
"""Save the current project to a new location with a new name.
|
|
|
|
Creates a new project folder at the given path, copies all document
|
|
and metadata files from the current project, and writes the project
|
|
XML with the new name. The new location is added to the recent
|
|
projects list as a separate entry from the original location.
|
|
"""
|
|
if not self._storage.isOpen():
|
|
SHARED.error(self.tr("There is no project open."))
|
|
return False
|
|
|
|
newPath = Path(newPath).resolve()
|
|
|
|
# First save all current documents to make sure everything is current
|
|
saveTime = time()
|
|
self.updateCounts()
|
|
self.countStatus()
|
|
|
|
for docHandle in self._tree.allDocs():
|
|
doc = self._storage.getDocument(docHandle)
|
|
if doc and doc.nwItem:
|
|
text = doc.readDocument()
|
|
doc.writeDocument(text)
|
|
|
|
# Save old paths before creating new storage (createNewProject
|
|
# overwrites _storagePath, _runtimePath, etc.)
|
|
oldContentPath = self._storage.contentPath
|
|
oldRuntimePath = self._storage._runtimePath
|
|
oldMetaPath = oldRuntimePath / "meta" if oldRuntimePath else None
|
|
|
|
# Create new project storage at the new location
|
|
status = self._storage.createNewProject(newPath)
|
|
if status != ProjectStorageCreate.READY:
|
|
if status == ProjectStorageCreate.NOT_EMPTY:
|
|
SHARED.error(
|
|
self.tr("The target folder is not empty. Please choose another folder.")
|
|
)
|
|
else:
|
|
SHARED.error(self.tr("Failed to create project storage."))
|
|
return False
|
|
|
|
# Update project data: new UUID and name
|
|
self._data.setUuid("")
|
|
self._data.setName(newName)
|
|
|
|
# Copy all document files from old content to new content
|
|
newContentPath = self._storage.contentPath
|
|
if oldContentPath and newContentPath:
|
|
for mdFile in oldContentPath.glob("*.md"):
|
|
destFile = newContentPath / mdFile.name
|
|
try:
|
|
shutil.copy2(mdFile, destFile)
|
|
except Exception as exc:
|
|
logger.error(
|
|
"Failed to copy document file %s: %s", mdFile.name, exc
|
|
)
|
|
SHARED.error(
|
|
self.tr("Failed to copy document file: {0}").format(mdFile.name)
|
|
)
|
|
return False
|
|
|
|
# Copy all meta files from old meta to new meta
|
|
newMetaPath = self._storage._runtimePath / "meta" if self._storage._runtimePath else None
|
|
if oldMetaPath and newMetaPath:
|
|
if oldMetaPath.is_dir():
|
|
newMetaPath.mkdir(exist_ok=True)
|
|
for metaFile in oldMetaPath.glob("*"):
|
|
if metaFile.is_file():
|
|
destFile = newMetaPath / metaFile.name
|
|
try:
|
|
shutil.copy2(metaFile, destFile)
|
|
except Exception as exc:
|
|
logger.error(
|
|
"Failed to copy meta file %s: %s", metaFile.name, exc
|
|
)
|
|
|
|
# Copy manifest.json if it exists
|
|
oldManifestPath = (
|
|
self._storage._runtimePath / nwFiles.MANIFEST_FILE
|
|
if oldRuntimePath
|
|
else None
|
|
)
|
|
newManifestPath = (
|
|
self._storage._runtimePath / nwFiles.MANIFEST_FILE
|
|
if self._storage._runtimePath
|
|
else None
|
|
)
|
|
if oldManifestPath and newManifestPath and oldManifestPath.exists():
|
|
try:
|
|
shutil.copy2(oldManifestPath, newManifestPath)
|
|
except Exception as exc:
|
|
logger.error("Failed to copy manifest file: %s", exc)
|
|
|
|
# Write project XML
|
|
self._data.setIndexRevision(self._index.indexRevision)
|
|
saveTime = time()
|
|
editTime = self._data.editTime + max(round(saveTime - self._session.start), 0)
|
|
content = self._tree.pack()
|
|
xmlWriter = self._storage.getXmlWriter()
|
|
if not isinstance(xmlWriter, ProjectXMLWriter):
|
|
return False
|
|
|
|
if not xmlWriter.write(self._data, content, saveTime, editTime):
|
|
self._reportErrors(self.tr("Issues encountered when saving project:"))
|
|
return False
|
|
|
|
# Save other project data
|
|
self._options.saveSettings()
|
|
self._index.saveIndex()
|
|
|
|
self._storage.runPostSaveTasks(saveTime=saveTime, autoSave=False)
|
|
|
|
# Update recent projects with the new location (separate from original)
|
|
CONFIG.recentProjects.update(str(newPath), self._data, saveTime)
|
|
|
|
self._reportErrors(self.tr("Issues encountered when saving project:"))
|
|
SHARED.newStatusMessage(self.tr("Saved Project As: {0}").format(newName))
|
|
self.setProjectChanged(False)
|
|
|
|
return True
|
|
|
|
def closeProject(self, idleTime: float = 0.0) -> None:
|
|
"""Close the project."""
|
|
logger.info("Closing project")
|
|
|
|
SHARED.clearErrorCache()
|
|
self._index.clear() # Triggers clear signal, see #1718
|
|
self._options.saveSettings()
|
|
self._tree.writeToCFile()
|
|
self._session.appendSession(idleTime)
|
|
self._storage.closeSession()
|
|
self._reportErrors(self.tr("Issues encountered when closing project:"))
|
|
|
|
def backupProject(self, doNotify: bool) -> bool:
|
|
"""Create a zip file of the entire project."""
|
|
if not self._storage.isOpen():
|
|
logger.error("No project open")
|
|
return False
|
|
|
|
logger.info("Backing up project")
|
|
SHARED.newStatusMessage(self.tr("Backing up project ..."))
|
|
|
|
if not self._data.name:
|
|
SHARED.error(
|
|
self.tr(
|
|
"Cannot backup project because no project name is set. "
|
|
"Please set a Project Name in Project Settings."
|
|
)
|
|
)
|
|
return False
|
|
|
|
cleanName = makeFileNameSafe(self._data.name)
|
|
backupPath = CONFIG.backupPath()
|
|
baseDir = backupPath / cleanName
|
|
try:
|
|
baseDir.mkdir(exist_ok=True, parents=True)
|
|
except Exception as exc:
|
|
SHARED.error(self.tr("Could not create backup folder."), exc=exc)
|
|
return False
|
|
|
|
saveTime = time()
|
|
match CONFIG.backupInterval:
|
|
case "day":
|
|
timeStamp = formatTimeStamp(saveTime, fileSafe=True, fmt="%Y-%m-%d")
|
|
case "week":
|
|
timeStamp = formatTimeStamp(saveTime, fileSafe=True, fmt="%G-W%V")
|
|
case "month":
|
|
timeStamp = formatTimeStamp(saveTime, fileSafe=True, fmt="%Y-%m")
|
|
case _: # Option "session" falls through here
|
|
timeStamp = formatTimeStamp(saveTime, fileSafe=True)
|
|
|
|
archName = baseDir / f"{cleanName} {timeStamp}.zip"
|
|
if self._storage.zipIt(archName, saveTime, compression=2):
|
|
if doNotify:
|
|
size = formatInt(getFileSize(archName))
|
|
SHARED.info(
|
|
self.tr("Created a backup of your project of size {0}B.").format(size),
|
|
info=self.tr("Path: {0}").format(str(backupPath)),
|
|
)
|
|
else:
|
|
SHARED.error(self.tr("Could not write backup archive."))
|
|
return False
|
|
|
|
SHARED.newStatusMessage(self.tr("Project backed up to '{0}'").format(str(archName)))
|
|
|
|
return True
|
|
|
|
##
|
|
# Setters
|
|
##
|
|
|
|
def setDefaultStatusImport(self) -> None:
|
|
"""Set the default status and importance values."""
|
|
self._data.itemStatus.add(None, self.tr("New"), "faded", "STAR", 0)
|
|
self._data.itemStatus.add(None, self.tr("Note"), "red", "TRIANGLE", 0)
|
|
self._data.itemStatus.add(None, self.tr("Draft"), "yellow", "CIRCLE_T", 0)
|
|
self._data.itemStatus.add(None, self.tr("Finished"), "green", "STAR", 0)
|
|
self._data.itemImport.add(None, self.tr("New"), "purple", "SQUARE", 0)
|
|
self._data.itemImport.add(None, self.tr("Minor"), "purple", "BLOCK_2", 0)
|
|
self._data.itemImport.add(None, self.tr("Major"), "purple", "BLOCK_3", 0)
|
|
self._data.itemImport.add(None, self.tr("Main"), "purple", "BLOCK_4", 0)
|
|
|
|
def setProjectLang(self, language: str | None, rtl: bool) -> None:
|
|
"""Set the project-specific language."""
|
|
language = checkStringNone(language, None)
|
|
reload = self._data.language != language
|
|
self._data.setLanguage(language, rtl)
|
|
if reload:
|
|
self._loadProjectLocalisation()
|
|
|
|
def setProjectChanged(self, status: bool) -> bool:
|
|
"""Toggle the project changed flag, and propagate the
|
|
information to the GUI statusbar.
|
|
"""
|
|
if isinstance(status, bool):
|
|
self._changed = status
|
|
SHARED.setGlobalProjectState(self._changed)
|
|
return self._changed
|
|
|
|
##
|
|
# Class Methods
|
|
##
|
|
|
|
def updateCounts(self) -> None:
|
|
"""Update the total word and character count values."""
|
|
wNovel, wNotes, cNovel, cNotes, wSession, wTarget = self._tree.sumCounts()
|
|
self._data.setCurrCounts(wNovel=wNovel, wNotes=wNotes, cNovel=cNovel, cNotes=cNotes)
|
|
self._data.setDailyProgress(wSession, wTarget)
|
|
self._countsDirty = False
|
|
|
|
def markCountsDirty(self) -> None:
|
|
"""Flag that an item's goal eligibility has changed, so the word
|
|
counts are stale and must be recalculated.
|
|
"""
|
|
self._countsDirty = True
|
|
|
|
def countStatus(self) -> None:
|
|
"""Count how many times the various status flags are used in the
|
|
project tree. The counts themselves are kept in the ItemStatus
|
|
objects. This is essentially a refresh.
|
|
"""
|
|
self._data.itemStatus.resetCounts()
|
|
self._data.itemImport.resetCounts()
|
|
for nwItem in self._tree:
|
|
if nwItem.isNovelLike():
|
|
self._data.itemStatus.increment(nwItem.itemStatus)
|
|
else:
|
|
self._data.itemImport.increment(nwItem.itemImport)
|
|
|
|
def updateStatus(self, kind: T_StatusKind, update: T_UpdateEntry) -> None:
|
|
"""Update status or import entries."""
|
|
if kind == "s":
|
|
self._data.itemStatus.update(update)
|
|
SHARED.emitStatusLabelsChanged(self, kind)
|
|
self._tree.refreshAllItems()
|
|
elif kind == "i":
|
|
self._data.itemImport.update(update)
|
|
SHARED.emitStatusLabelsChanged(self, kind)
|
|
self._tree.refreshAllItems()
|
|
else: # pragma: no cover
|
|
pass
|
|
|
|
def updateTheme(self) -> None:
|
|
"""Update theme elements."""
|
|
logger.debug("Theme Update: NWProject")
|
|
|
|
self._data.itemStatus.refreshIcons()
|
|
self._data.itemImport.refreshIcons()
|
|
|
|
def localLookup(self, word: str | int) -> str:
|
|
"""Look up a word or number in the translation map for the
|
|
project and return it. The variable is cast to a string before
|
|
lookup. If the word does not exist, it returns itself.
|
|
"""
|
|
return self._langData.get(str(word), str(word))
|
|
|
|
##
|
|
# Internal Functions
|
|
##
|
|
def _reportErrors(self, title: str) -> None:
|
|
"""Report any errors from the error cache."""
|
|
if errors := SHARED.errorCache():
|
|
SHARED.error(title, "<br><br>".join(errors))
|
|
|
|
def _loadProjectLocalisation(self) -> bool:
|
|
"""Load the language data for the current project language."""
|
|
if self._data.language is None or CONFIG.nwLangPath is None:
|
|
self._langData = {}
|
|
return False
|
|
|
|
langFile = Path(CONFIG.nwLangPath) / f"project_{self._data.language}.json"
|
|
if not safeIsFile(langFile):
|
|
langFile = Path(CONFIG.nwLangPath) / "project_en_GB.json"
|
|
|
|
try:
|
|
with open(langFile, mode="r", encoding="utf-8") as inFile:
|
|
self._langData = json.load(inFile)
|
|
logger.debug("Loaded project language file: %s", langFile.name)
|
|
except Exception:
|
|
logger.error("Failed to load project language file")
|
|
logException()
|
|
return False
|
|
|
|
return True
|