""" novelWriter – Project Document Tools ==================================== File History: Created: 2022-10-02 [2.0rc1] DocMerger Created: 2022-10-11 [2.0rc1] DocSplitter Created: 2022-11-03 [2.0rc2] ProjectBuilder This file is a part of novelWriter Copyright 2018–2023, Veronica Berglyd Olsen 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 . """ from __future__ import annotations import shutil import logging from typing import Iterable from functools import partial from PyQt5.QtCore import QCoreApplication from novelwriter import CONFIG, SHARED from novelwriter.common import minmax, simplified from novelwriter.constants import nwItemClass from novelwriter.core.item import NWItem from novelwriter.core.project import NWProject logger = logging.getLogger(__name__) class DocMerger: """Document tool for merging a set of documents into a single new document. The parameters are defined by the user using the GuiDocMerge dialog. """ def __init__(self, project: NWProject) -> None: self._project = project self._error = "" self._targetDoc = None self._targetText = [] return ## # Methods ## def getError(self) -> str: """Return any collected errors.""" return self._error def setTargetDoc(self, tHandle: str) -> None: """Set the target document for the merging. Calling this function resets the class. """ self._targetDoc = tHandle self._targetText = [] return def newTargetDoc(self, srcHandle: str, docLabel: str) -> str | None: """Create a brand new target document based on a source handle and a new doc label. Calling this function resets the class. """ srcItem = self._project.tree[srcHandle] if srcItem is None or srcItem.itemParent is None: return None newHandle = self._project.newFile(docLabel, srcItem.itemParent) newItem = self._project.tree[newHandle] if isinstance(newItem, NWItem): newItem.setLayout(srcItem.itemLayout) newItem.setStatus(srcItem.itemStatus) newItem.setImport(srcItem.itemImport) self._targetDoc = newHandle self._targetText = [] return newHandle def appendText(self, srcHandle: str, addComment: bool, cmtPrefix: str) -> bool: """Append text from an existing document to the text buffer.""" srcItem = self._project.tree[srcHandle] if srcItem is None: return False inDoc = self._project.storage.getDocument(srcHandle) docText = (inDoc.readDocument() or "").rstrip("\n") if addComment: docInfo = srcItem.describeMe() docSt, _ = srcItem.getImportStatus(incIcon=False) cmtLine = f"% {cmtPrefix} {docInfo}: {srcItem.itemName} [{docSt}]\n\n" docText = cmtLine + docText self._targetText.append(docText) return True def writeTargetDoc(self) -> bool: """Write the accumulated text into the designated target document, appending any existing text. """ if self._targetDoc is None: return False outDoc = self._project.storage.getDocument(self._targetDoc) docText = (outDoc.readDocument() or "").rstrip("\n") if docText: self._targetText.insert(0, docText) status = outDoc.writeDocument("\n\n".join(self._targetText) + "\n\n") if not status: self._error = outDoc.getError() return status # END Class DocMerger class DocSplitter: """Document tool for splitting a document into a set of new documents. The parameters are defined by the user using the GuiDocSplit dialog. """ def __init__(self, project: NWProject, sHandle: str) -> None: self._project = project self._error = "" self._parHandle = None self._srcHandle = None self._srcItem = None self._inFolder = False self._rawData = [] srcItem = self._project.tree[sHandle] if srcItem is not None and srcItem.isFileType(): self._srcHandle = sHandle self._srcItem = srcItem return ## # Methods ## def getError(self) -> str: """Return any collected errors.""" return self._error def setParentItem(self, pHandle: str) -> None: """Set the item that will be the top level parent item for the new documents. """ self._parHandle = pHandle self._inFolder = False return def newParentFolder(self, pHandle: str, folderLabel: str) -> str | None: """Create a new folder that will be the top level parent item for the new documents. """ if self._srcItem is None: return None newHandle = self._project.newFolder(folderLabel, pHandle) newItem = self._project.tree[newHandle] if isinstance(newItem, NWItem): newItem.setStatus(self._srcItem.itemStatus) newItem.setImport(self._srcItem.itemImport) self._parHandle = newHandle self._inFolder = True return newHandle def splitDocument(self, splitData: list, splitText: list[str]) -> None: """Loop through the split data record and perform the split job on a list of text lines. """ self._rawData = [] buffer = splitText.copy() for lineNo, hLevel, hLabel in reversed(splitData): chunk = buffer[lineNo:] buffer = buffer[:lineNo] self._rawData.insert(0, (chunk, hLevel, hLabel)) return def writeDocuments(self, docHierarchy: bool) -> Iterable[tuple[bool, str | None, str | None]]: """An iterator that will write each document in the buffer, and return its new handle, parent handle, and sibling handle. """ if self._srcHandle is None or self._srcItem is None or self._parHandle is None: return pHandle = self._parHandle nHandle = self._parHandle if self._inFolder else self._srcHandle hHandle = [self._parHandle, None, None, None, None] pLevel = 0 for docText, hLevel, docLabel in self._rawData: hLevel = minmax(hLevel, 1, 4) if pLevel == 0: pLevel = hLevel if docHierarchy: if hLevel == 1: pHandle = self._parHandle elif hLevel == 2: pHandle = hHandle[1] or hHandle[0] elif hLevel == 3: pHandle = hHandle[2] or hHandle[1] or hHandle[0] elif hLevel == 4: pHandle = hHandle[3] or hHandle[2] or hHandle[1] or hHandle[0] if hLevel < pLevel: nHandle = hHandle[hLevel] or hHandle[0] elif hLevel > pLevel: nHandle = pHandle dHandle = self._project.newFile(docLabel, pHandle) hHandle[hLevel] = dHandle newItem = self._project.tree[dHandle] if isinstance(newItem, NWItem): newItem.setStatus(self._srcItem.itemStatus) newItem.setImport(self._srcItem.itemImport) outDoc = self._project.storage.getDocument(dHandle) status = outDoc.writeDocument("\n".join(docText)) if not status: self._error = outDoc.getError() yield status, dHandle, nHandle hHandle[hLevel] = dHandle nHandle = dHandle pLevel = hLevel return # END Class DocSplitter class DocDuplicator: """A class that will duplicate all documents and folders starting from a given handle. """ def __init__(self, project: NWProject) -> None: self._project = project return ## # Methods ## def duplicate(self, items: list[str]) -> Iterable[tuple[str, str | None]]: """Run through a list of items, duplicate them, and copy the text content if they are documents. """ if not items: return nHandle = items[0] hMap: dict[str, str | None] = {t: None for t in items} for tHandle in items: newItem = self._project.tree.duplicate(tHandle) if newItem is None: return hMap[tHandle] = newItem.itemHandle if newItem.itemParent in hMap: newItem.setParent(hMap[newItem.itemParent]) self._project.tree.updateItemData(newItem.itemHandle) if newItem.isFileType(): oldDoc = self._project.storage.getDocument(tHandle) newDoc = self._project.storage.getDocument(newItem.itemHandle) if newDoc.fileExists(): return newDoc.writeDocument(oldDoc.readDocument() or "") yield newItem.itemHandle, nHandle nHandle = None return # END Class DocDuplicator class ProjectBuilder: """A class to build a new project from a set of user-defined parameter provided by the New Project Wizard. """ def __init__(self) -> None: self.tr = partial(QCoreApplication.translate, "NWProject") return ## # Methods ## def buildProject(self, data: dict) -> bool: """Build a project from a data dictionary of specifications provided by the wizard. """ if not isinstance(data, dict): 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") return False project = NWProject() if not project.storage.openProjectInPlace(projPath, newProject=True): return False 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", "") project.data.setUuid(None) project.data.setName(projName) project.data.setTitle(projTitle) project.data.setAuthor(projAuthor) project.setDefaultStatusImport() project.session.startSession() # Add Root Folders hNovelRoot = project.newRoot(nwItemClass.NOVEL) hTitlePage = project.newFile(lblTitlePage, hNovelRoot) novelTitle = project.data.title if project.data.title else project.data.name titlePage = f"#! {novelTitle}\n\n" if project.data.author: titlePage += f">> {lblByAuthors} {project.data.author} <<\n\n" 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") if hChapter: hScene = project.newFile(lblNewScene, hChapter) aDoc = project.storage.getDocument(hScene) aDoc.writeDocument(f"### {lblNewScene}\n\n") project.newRoot(nwItemClass.PLOT) project.newRoot(nwItemClass.CHARACTER) project.newRoot(nwItemClass.WORLD) project.newRoot(nwItemClass.ARCHIVE) project.saveProject() project.closeProject() 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 chapters and scenes numChapters = data.get("numChapters", 0) numScenes = data.get("numScenes", 0) chSynop = self.tr("Summary of the chapter.") scSynop = self.tr("Summary of the scene.") # 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") # 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 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@tag: {ntTag}\n\n") # Also add the archive and trash folders project.newRoot(nwItemClass.ARCHIVE) project.trashFolder() project.saveProject() project.closeProject() return True ## # Internal Functions ## def _extractSampleProject(self, data: dict) -> 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) except Exception as exc: SHARED.error(self.tr( "Failed to create a new example project." ), exc=exc) return False else: SHARED.error(self.tr( "Failed to create a new example project. " "Could not find the necessary files. " "They seem to be missing from this installation." )) return False return True # END Class ProjectBuilder