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