Complete the create new project code path

This commit is contained in:
Veronica Berglyd Olsen
2023-12-20 21:52:57 +01:00
parent a71d0d8cfe
commit 41b7495a06
6 changed files with 203 additions and 138 deletions
+101 -107
View File
@@ -30,6 +30,7 @@ import shutil
import logging
from typing import Iterable
from pathlib import Path
from functools import partial
from PyQt5.QtCore import QCoreApplication
@@ -39,6 +40,7 @@ from novelwriter.common import minmax, simplified
from novelwriter.constants import nwItemClass
from novelwriter.core.item import NWItem
from novelwriter.core.project import NWProject
from novelwriter.core.storage import NWStorageCreate
logger = logging.getLogger(__name__)
@@ -310,9 +312,15 @@ class ProjectBuilder:
"""
def __init__(self) -> None:
self._path = None
self.tr = partial(QCoreApplication.translate, "NWProject")
return
@property
def projPath(self) -> Path | None:
"""The path of the newly created project."""
return self._path
##
# Methods
##
@@ -325,40 +333,56 @@ class ProjectBuilder:
logger.error("Invalid call to newProject function")
return False
popMinimal = data.get("popMinimal", True)
popCustom = data.get("popCustom", False)
popSample = data.get("popSample", False)
# Check if we're extracting the sample project. This is handled
# differently as it isn't actually a new project, so we forward
# this to another function and return here.
if popSample:
return self._extractSampleProject(data)
projPath = data.get("projPath", None)
if projPath is None:
logger.error("No project path set for the new project")
path = data.get("path", None)
if path is None:
SHARED.error("A project path is required.")
return False
if data.get("sample", False):
self._path = path
return self._extractSampleProject(path)
elif data.get("template"):
pass
else:
self._buildAndPopulate(path, data)
return True
##
# Internal Functions
##
def _buildAndPopulate(self, path: Path, data: dict) -> bool:
"""Build a project from a data dictionary of specifications
provided by the wizard.
"""
project = NWProject()
if not project.storage.createNewProject(projPath):
status = project.storage.createNewProject(path)
if status == NWStorageCreate.NOT_EMPTY:
SHARED.error(self.tr(
"A project already exists in that location. "
"Please choose another folder."
))
return False
elif status == NWStorageCreate.OS_ERROR:
SHARED.error(self.tr(
"An error occured while trying to create the project."
), exc=project.storage.exc)
return False
self._path = project.storage.storagePath
lblNewProject = self.tr("New Project")
lblNewChapter = self.tr("New Chapter")
lblNewScene = self.tr("New Scene")
lblTitlePage = self.tr("Title Page")
lblByAuthors = self.tr("By")
# Settings
projName = data.get("projName", lblNewProject)
projTitle = data.get("projTitle", lblNewProject)
projAuthor = data.get("projAuthor", "")
projLang = data.get("projLang", "en_GB")
projName = data.get("name", lblNewProject)
projAuthor = data.get("author", "")
projLang = data.get("language", "en_GB")
project.data.setUuid(None)
project.data.setName(projName)
project.data.setTitle(projTitle)
project.data.setAuthor(projAuthor)
project.data.setLanguage(projLang)
project.setDefaultStatusImport()
@@ -376,110 +400,80 @@ class ProjectBuilder:
aDoc = project.storage.getDocument(hTitlePage)
aDoc.writeDocument(titlePage)
if popMinimal:
# Creating a minimal project with a few root folders and a
# single chapter with a single scene.
hChapter = project.newFile(lblNewChapter, hNovelRoot)
aDoc = project.storage.getDocument(hChapter)
aDoc.writeDocument(f"## {lblNewChapter}\n\n")
# Create a project structure based on selected root folders
# and a number of chapters and scenes selected in the
# wizard's custom page.
if hChapter:
hScene = project.newFile(lblNewScene, hChapter)
aDoc = project.storage.getDocument(hScene)
aDoc.writeDocument(f"### {lblNewScene}\n\n")
# Create chapters and scenes
numChapters = data.get("chapters", 0)
numScenes = data.get("scenes", 0)
project.newRoot(nwItemClass.PLOT)
project.newRoot(nwItemClass.CHARACTER)
project.newRoot(nwItemClass.WORLD)
project.newRoot(nwItemClass.ARCHIVE)
chSynop = self.tr("Summary of the chapter.")
scSynop = self.tr("Summary of the scene.")
bfNote = self.tr("A short description.")
project.saveProject()
project.closeProject()
# Create chapters
if numChapters > 0:
for ch in range(numChapters):
chTitle = self.tr("Chapter {0}").format(f"{ch+1:d}")
cHandle = project.newFile(chTitle, hNovelRoot)
aDoc = project.storage.getDocument(cHandle)
aDoc.writeDocument(f"## {chTitle}\n\n%Synopsis: {chSynop}\n\n")
elif popCustom:
# Create a project structure based on selected root folders
# and a number of chapters and scenes selected in the
# wizard's custom page.
# Create chapter scenes
if numScenes > 0 and cHandle:
for sc in range(numScenes):
scTitle = self.tr("Scene {0}").format(f"{ch+1:d}.{sc+1:d}")
sHandle = project.newFile(scTitle, cHandle)
aDoc = project.storage.getDocument(sHandle)
aDoc.writeDocument(f"### {scTitle}\n\n%Synopsis: {scSynop}\n\n")
# Create chapters and scenes
numChapters = data.get("numChapters", 0)
numScenes = data.get("numScenes", 0)
# Create scenes (no chapters)
elif numScenes > 0:
for sc in range(numScenes):
scTitle = self.tr("Scene {0}").format(f"{sc+1:d}")
sHandle = project.newFile(scTitle, hNovelRoot)
aDoc = project.storage.getDocument(sHandle)
aDoc.writeDocument(f"### {scTitle}\n\n%Synopsis: {scSynop}\n\n")
chSynop = self.tr("Summary of the chapter.")
scSynop = self.tr("Summary of the scene.")
bfNote = self.tr("A short description.")
# Create notes folders
noteTitles = {
nwItemClass.PLOT: self.tr("Main Plot"),
nwItemClass.CHARACTER: self.tr("Protagonist"),
nwItemClass.WORLD: self.tr("Main Location"),
}
# Create chapters
if numChapters > 0:
for ch in range(numChapters):
chTitle = self.tr("Chapter {0}").format(f"{ch+1:d}")
cHandle = project.newFile(chTitle, hNovelRoot)
aDoc = project.storage.getDocument(cHandle)
aDoc.writeDocument(f"## {chTitle}\n\n% Synopsis: {chSynop}\n\n")
addNotes = data.get("notes", False)
for newRoot in data.get("roots", []):
if newRoot in nwItemClass:
rHandle = project.newRoot(newRoot)
if addNotes:
aHandle = project.newFile(noteTitles[newRoot], rHandle)
ntTag = simplified(noteTitles[newRoot]).replace(" ", "")
aDoc = project.storage.getDocument(aHandle)
aDoc.writeDocument(
f"# {noteTitles[newRoot]}\n\n"
f"@tag: {ntTag}\n\n"
f"%Short: {bfNote}\n\n"
)
# Create chapter scenes
if numScenes > 0 and cHandle:
for sc in range(numScenes):
scTitle = self.tr("Scene {0}").format(f"{ch+1:d}.{sc+1:d}")
sHandle = project.newFile(scTitle, cHandle)
aDoc = project.storage.getDocument(sHandle)
aDoc.writeDocument(f"### {scTitle}\n\n% Synopsis: {scSynop}\n\n")
# Also add the archive and trash folders
project.newRoot(nwItemClass.ARCHIVE)
project.trashFolder()
# Create scenes (no chapters)
elif numScenes > 0:
for sc in range(numScenes):
scTitle = self.tr("Scene {0}").format(f"{sc+1:d}")
sHandle = project.newFile(scTitle, hNovelRoot)
aDoc = project.storage.getDocument(sHandle)
aDoc.writeDocument(f"### {scTitle}\n\n% Synopsis: {scSynop}\n\n")
# Create notes folders
noteTitles = {
nwItemClass.PLOT: self.tr("Main Plot"),
nwItemClass.CHARACTER: self.tr("Protagonist"),
nwItemClass.WORLD: self.tr("Main Location"),
}
addNotes = data.get("addNotes", False)
for newRoot in data.get("addRoots", []):
if newRoot in nwItemClass:
rHandle = project.newRoot(newRoot)
if addNotes:
aHandle = project.newFile(noteTitles[newRoot], rHandle)
ntTag = simplified(noteTitles[newRoot]).replace(" ", "")
aDoc = project.storage.getDocument(aHandle)
aDoc.writeDocument(
f"# {noteTitles[newRoot]}\n\n"
f"@tag: {ntTag}\n\n"
f"% Short: {bfNote}\n\n"
)
# Also add the archive and trash folders
project.newRoot(nwItemClass.ARCHIVE)
project.trashFolder()
project.saveProject()
project.closeProject()
project.saveProject()
project.closeProject()
return True
##
# Internal Functions
##
def _extractSampleProject(self, data: dict) -> bool:
def _extractSampleProject(self, path: Path) -> bool:
"""Make a copy of the sample project by extracting the
sample.zip file to the new path.
"""
projPath = data.get("projPath", None)
if projPath is None:
logger.error("No project path set for the example project")
return False
pkgSample = CONFIG.assetPath("sample.zip")
if pkgSample.is_file():
try:
shutil.unpack_archive(pkgSample, projPath)
shutil.unpack_archive(pkgSample, path)
except Exception as exc:
SHARED.error(self.tr(
"Failed to create a new example project."
+13 -4
View File
@@ -57,6 +57,15 @@ class NWStorageOpen(Enum):
# END Enum NWStorageOpen
class NWStorageCreate(Enum):
NOT_EMPTY = 0
OS_ERROR = 1
READY = 2
# END Enum NWStorageCreate
class NWStorage:
"""Core: Project Storage Class
@@ -133,12 +142,12 @@ class NWStorage:
"""Check if the storage location is open."""
return self._ready and self._runtimePath is not None
def createNewProject(self, path: str | Path) -> bool:
def createNewProject(self, path: str | Path) -> NWStorageCreate:
"""Create a new project at the given location."""
inPath = Path(path).resolve()
if inPath.is_dir() and len(list(inPath.iterdir())) > 0:
logger.error("Folder is not empty: %s", inPath)
return False
return NWStorageCreate.NOT_EMPTY
self._storagePath = inPath
self._runtimePath = inPath
@@ -156,11 +165,11 @@ class NWStorage:
self._exception = exc
logger.error("Failed to create project folders", exc_info=exc)
self.clear()
return False
return NWStorageCreate.OS_ERROR
self._ready = True
return True
return NWStorageCreate.READY
def initProjectStorage(self, path: str | Path, clearLock: bool = False) -> NWStorageOpen:
"""Initialise a novelWriter project location."""
+3 -7
View File
@@ -132,14 +132,10 @@ class GuiMainMenu(QMenuBar):
# Project
self.projMenu = self.addMenu(self.tr("&Project"))
# Project > New Project
self.aNewProject = self.projMenu.addAction(self.tr("New Project"))
self.aNewProject.triggered.connect(lambda: self.mainGui.newProject(None))
# Project > Open Project
self.aOpenProject = self.projMenu.addAction(self.tr("Open Project"))
# Project > Create or Open Project
self.aOpenProject = self.projMenu.addAction(self.tr("Create or Open Project"))
self.aOpenProject.setShortcut("Ctrl+Shift+O")
self.aOpenProject.triggered.connect(lambda: self.mainGui.showProjectLoadDialog())
self.aOpenProject.triggered.connect(self.mainGui.showWelcomeDialog)
# Project > Save Project
self.aSaveProject = self.projMenu.addAction(self.tr("Save Project"))
+20 -10
View File
@@ -51,7 +51,7 @@ from novelwriter.gui.itemdetails import GuiItemDetails
from novelwriter.gui.docviewerpanel import GuiDocViewerPanel
from novelwriter.dialogs.about import GuiAbout
from novelwriter.dialogs.updates import GuiUpdates
# from novelwriter.dialogs.projload import GuiProjectLoad
from novelwriter.dialogs.projload import GuiProjectLoad
from novelwriter.dialogs.wordlist import GuiWordList
from novelwriter.dialogs.preferences import GuiPreferences
from novelwriter.dialogs.projdetails import GuiProjectDetails
@@ -360,7 +360,7 @@ class GuiMain(QMainWindow):
self.openProject(cmdOpen)
if not SHARED.hasProject:
self.showProjectLoadDialog()
self.showWelcomeDialog()
# Determine whether release notes need to be shown or not
if hexToInt(CONFIG.lastNotes) < hexToInt(__hexversion__):
@@ -844,18 +844,22 @@ class GuiMain(QMainWindow):
browse button for projects not yet cached. Selecting to create a
new project is forwarded to the new project wizard.
"""
# dlgProj = GuiProjectLoad(self)
# dlgProj.exec_()
dlgProj = GuiProjectLoad(self)
dlgProj.exec_()
# if dlgProj.result() == QDialog.Accepted:
# if dlgProj.openState == GuiProjectLoad.OPEN_STATE:
# self.openProject(dlgProj.openPath)
# elif dlgProj.openState == GuiProjectLoad.NEW_STATE:
# self.newProject()
if dlgProj.result() == QDialog.Accepted:
if dlgProj.openState == GuiProjectLoad.OPEN_STATE:
self.openProject(dlgProj.openPath)
elif dlgProj.openState == GuiProjectLoad.NEW_STATE:
self.newProject()
return
@pyqtSlot()
def showWelcomeDialog(self) -> None:
"""Open the welcome dialog."""
dialog = GuiWelcome(self)
dialog.openProjectRequest.connect(self._openProject)
dialog.exec_()
return
def showNewProjectDialog(self) -> dict | None:
@@ -1208,6 +1212,12 @@ class GuiMain(QMainWindow):
self.viewDocument(tHandle=tHandle, sTitle=sTitle)
return
@pyqtSlot(Path)
def _openProject(self, path: Path) -> None:
"""Handle an open project request."""
self.openProject(path)
return
@pyqtSlot(str, nwDocMode, str, bool)
def _openDocument(self, tHandle: str, mode: nwDocMode, sTitle: str, setFocus: bool) -> None:
"""Handle an open document request."""
+62 -6
View File
@@ -38,8 +38,10 @@ from PyQt5.QtWidgets import (
)
from novelwriter import CONFIG, SHARED, __version__, __date__
from novelwriter.enum import nwItemClass
from novelwriter.common import makeFileNameSafe
from novelwriter.constants import nwUnicode
from novelwriter.core.coretools import ProjectBuilder
from novelwriter.extensions.switch import NSwitch
if TYPE_CHECKING: # pragma: no cover
@@ -50,6 +52,8 @@ logger = logging.getLogger(__name__)
class GuiWelcome(QDialog):
openProjectRequest = pyqtSignal(Path)
def __init__(self, mainGui: GuiMain) -> None:
super().__init__(parent=mainGui)
@@ -84,6 +88,7 @@ class GuiWelcome(QDialog):
self.tabOpen = _OpenProjectPage(self)
self.tabNew = _NewProjectPage(self)
self.tabNew.cancelNewProject.connect(self._showOpenProjectPage)
self.tabNew.openProjectRequest.connect(self._setProjectPath)
self.mainStack = QStackedWidget()
self.mainStack.addWidget(self.tabOpen)
@@ -167,6 +172,15 @@ class GuiWelcome(QDialog):
self.mainStack.setCurrentWidget(self.tabOpen)
return
@pyqtSlot()
@pyqtSlot(Path)
def _setProjectPath(self, path: Path | None = None) -> None:
"""Set the path variable for the project to open."""
if isinstance(path, Path):
self.openProjectRequest.emit(path)
self.close()
return
##
# Internal Functions
##
@@ -207,6 +221,7 @@ class _OpenProjectPage(QWidget):
class _NewProjectPage(QWidget):
cancelNewProject = pyqtSignal()
openProjectRequest = pyqtSignal(Path)
def __init__(self, parent: QWidget) -> None:
super().__init__(parent=parent)
@@ -225,13 +240,14 @@ class _NewProjectPage(QWidget):
# Controls
# ========
self.createButton = QPushButton(self.tr("Create Project"), self)
self.createButton.setIcon(SHARED.theme.getIcon("star"))
self.cancelButton = QPushButton(self.tr("Go Back"), self)
self.cancelButton.setIcon(SHARED.theme.getIcon("backward"))
self.cancelButton.clicked.connect(lambda: self.cancelNewProject.emit())
self.createButton = QPushButton(self.tr("Create Project"), self)
self.createButton.setIcon(SHARED.theme.getIcon("star"))
self.createButton.clicked.connect(self._createNewProject)
self.buttonBox = QHBoxLayout()
self.buttonBox.addStretch(1)
self.buttonBox.addWidget(self.cancelButton, 0)
@@ -258,6 +274,22 @@ class _NewProjectPage(QWidget):
return
##
# Private Slots
##
@pyqtSlot()
def _createNewProject(self) -> None:
"""Create a new project from the data in the form."""
data = self.projectForm.getProjectData()
if not data.get("name"):
SHARED.error(self.tr("A project name is required."))
return
builder = ProjectBuilder()
if builder.buildProject(data) and (path := builder.projPath):
self.openProjectRequest.emit(path)
return
# END Class _NewProjectPage
@@ -422,6 +454,29 @@ class _NewProjectForm(QWidget):
return
def getProjectData(self) -> dict:
"""Collect form data and return it as a dictionary."""
roots = []
if self.addPlot.isChecked():
roots.append(nwItemClass.PLOT)
if self.addChar.isChecked():
roots.append(nwItemClass.CHARACTER)
if self.addWorld.isChecked():
roots.append(nwItemClass.WORLD)
return {
"name": self.projName.text().strip(),
"author": self.projAuthor.text().strip(),
"language": self.projLang.currentData(),
"path": self.projPath.text(),
"blank": self._fillMode == self.FILL_BLANK,
"sample": self._fillMode == self.FILL_SAMPLE,
"template": self._copyPath if self._fillMode == self.FILL_COPY else None,
"chapters": self.numChapters.value(),
"scenes": self.numScenes.value(),
"roots": roots,
"notes": self.addNotes.isChecked(),
}
##
# Private Slots
##
@@ -472,9 +527,10 @@ class _NewProjectForm(QWidget):
@pyqtSlot()
def _setFillCopy(self) -> None:
"""Set fill mode to copy project."""
self._fillMode = self.FILL_COPY
self._copyPath = SHARED.getProjectPath(self, allowZip=True)
self._updateFillInfo()
if copyPath := SHARED.getProjectPath(self, allowZip=True):
self._fillMode = self.FILL_COPY
self._copyPath = copyPath
self._updateFillInfo()
return
##
+4 -4
View File
@@ -32,7 +32,7 @@ from mocked import causeOSError
from novelwriter import CONFIG
from novelwriter.constants import nwFiles
from novelwriter.core.project import NWProject
from novelwriter.core.storage import NWStorage, NWStorageOpen, _LegacyStorage
from novelwriter.core.storage import NWStorage, NWStorageOpen, NWStorageCreate, _LegacyStorage
from novelwriter.core.document import NWDocument
from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter
@@ -67,16 +67,16 @@ def testCoreStorage_CreateNewProject(mockGUI, fncPath):
# Cannot prepare a non-empty folder
(fncPath / "foobar.txt").touch()
assert storage.createNewProject(fncPath) is False
assert storage.createNewProject(fncPath) == NWStorageCreate.NOT_EMPTY
# Try creating in a non-existent subfolder instead
assert storage.createNewProject(fncPath / "project1") is True
assert storage.createNewProject(fncPath / "project1") == NWStorageCreate.READY
assert (fncPath / "project1").is_dir()
assert (fncPath / "project1" / "meta").is_dir()
assert (fncPath / "project1" / "content").is_dir()
# However, the parent folder must exist
assert storage.createNewProject(fncPath / "foobar" / "project1") is False
assert storage.createNewProject(fncPath / "foobar" / "project1") == NWStorageCreate.OS_ERROR
assert isinstance(storage.exc, FileNotFoundError)
project.closeProject()