From 41b7495a067ff5e942d812afa6cf3f7092f15d9f Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Wed, 20 Dec 2023 21:52:57 +0100 Subject: [PATCH] Complete the create new project code path --- novelwriter/core/coretools.py | 208 +++++++++++++-------------- novelwriter/core/storage.py | 17 ++- novelwriter/gui/mainmenu.py | 10 +- novelwriter/guimain.py | 30 ++-- novelwriter/tools/welcome.py | 68 ++++++++- tests/test_core/test_core_storage.py | 8 +- 6 files changed, 203 insertions(+), 138 deletions(-) diff --git a/novelwriter/core/coretools.py b/novelwriter/core/coretools.py index 8829a512..584d4cfd 100644 --- a/novelwriter/core/coretools.py +++ b/novelwriter/core/coretools.py @@ -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." diff --git a/novelwriter/core/storage.py b/novelwriter/core/storage.py index dd707a3b..0d60b0a8 100644 --- a/novelwriter/core/storage.py +++ b/novelwriter/core/storage.py @@ -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.""" diff --git a/novelwriter/gui/mainmenu.py b/novelwriter/gui/mainmenu.py index 3e5812b9..3ba6c5ca 100644 --- a/novelwriter/gui/mainmenu.py +++ b/novelwriter/gui/mainmenu.py @@ -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")) diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index 7be6ced6..2340c799 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -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.""" diff --git a/novelwriter/tools/welcome.py b/novelwriter/tools/welcome.py index b3ed009f..48e706c2 100644 --- a/novelwriter/tools/welcome.py +++ b/novelwriter/tools/welcome.py @@ -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 ## diff --git a/tests/test_core/test_core_storage.py b/tests/test_core/test_core_storage.py index b34d96eb..1407d898 100644 --- a/tests/test_core/test_core_storage.py +++ b/tests/test_core/test_core_storage.py @@ -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()