611 lines
21 KiB
Python
611 lines
21 KiB
Python
"""
|
||
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
|
||
Created: 2023-07-20 [2.1b1] DocDuplicator
|
||
|
||
This file is a part of novelWriter
|
||
Copyright (C) 2022 Veronica Berglyd Olsen and novelWriter 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 logging
|
||
import re
|
||
import shutil
|
||
|
||
from functools import partial
|
||
from pathlib import Path
|
||
from typing import TYPE_CHECKING
|
||
from zipfile import ZipFile, is_zipfile
|
||
|
||
from PyQt6.QtCore import QCoreApplication
|
||
|
||
from novelwriter import CONFIG, SHARED
|
||
from novelwriter.common import isHandle, minmax, simplified
|
||
from novelwriter.constants import nwConst, nwFiles, nwItemClass, nwStats
|
||
from novelwriter.core.project import NWProject
|
||
from novelwriter.core.storage import NWStorageCreate
|
||
|
||
if TYPE_CHECKING:
|
||
from collections.abc import Iterable
|
||
|
||
from novelwriter.core.item import NWItem
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class DocMerger:
|
||
"""Tool: Merge Documents.
|
||
|
||
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._target = None
|
||
self._text = []
|
||
|
||
@property
|
||
def targetHandle(self) -> str | None:
|
||
"""Get the handle of the target document."""
|
||
if self._target:
|
||
return self._target.itemHandle
|
||
return None
|
||
|
||
##
|
||
# 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._target = self._project.tree[tHandle]
|
||
self._text = []
|
||
|
||
def newTargetDoc(self, sHandle: str, label: str) -> None:
|
||
"""Create a brand new target document based on a source handle
|
||
and a new doc label. Calling this function resets the class.
|
||
"""
|
||
sItem = self._project.tree[sHandle]
|
||
if sItem and sItem.itemParent:
|
||
tHandle = self._project.newFile(label, sItem.itemParent)
|
||
if nwItem := self._project.tree[tHandle]:
|
||
nwItem.setLayout(sItem.itemLayout)
|
||
nwItem.setStatus(sItem.itemStatus)
|
||
nwItem.setImport(sItem.itemImport)
|
||
nwItem.notifyToRefresh()
|
||
self._target = nwItem
|
||
self._text = []
|
||
|
||
def appendText(self, sHandle: str, addComment: bool, cmtPrefix: str) -> None:
|
||
"""Append text from an existing document to the text buffer."""
|
||
if item := self._project.tree[sHandle]:
|
||
text = self._project.storage.getDocumentText(sHandle).rstrip("\n")
|
||
if addComment:
|
||
info = item.describeMe()
|
||
status, _ = item.getImportStatus()
|
||
text = f"% {cmtPrefix} {info}: {item.itemName} [{status}]\n\n{text}"
|
||
self._text.append(text)
|
||
|
||
def writeTargetDoc(self) -> bool:
|
||
"""Write the accumulated text into the designated target
|
||
document, appending any existing text.
|
||
"""
|
||
if self._target:
|
||
outDoc = self._project.storage.getDocument(self._target.itemHandle)
|
||
if text := (outDoc.readDocument() or "").rstrip("\n"):
|
||
self._text.insert(0, text)
|
||
|
||
status = outDoc.writeDocument("\n\n".join(self._text) + "\n\n")
|
||
if not status:
|
||
self._error = outDoc.getError()
|
||
|
||
self._project.index.reIndexHandle(self._target.itemHandle)
|
||
self._target.notifyToRefresh()
|
||
|
||
return status
|
||
|
||
return False
|
||
|
||
|
||
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
|
||
|
||
def __len__(self) -> int:
|
||
return len(self._rawData)
|
||
|
||
##
|
||
# 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
|
||
|
||
def newParentFolder(self, pHandle: str, folderLabel: str) -> None:
|
||
"""Create a new folder that will be the top level parent item
|
||
for the new documents.
|
||
"""
|
||
if self._srcItem:
|
||
nHandle = self._project.newFolder(folderLabel, pHandle)
|
||
if nwItem := self._project.tree[nHandle]:
|
||
nwItem.setStatus(self._srcItem.itemStatus)
|
||
nwItem.setImport(self._srcItem.itemImport)
|
||
nwItem.notifyToRefresh()
|
||
self._parHandle = nHandle
|
||
self._inFolder = True
|
||
|
||
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))
|
||
|
||
def writeDocuments(self, docHierarchy: bool) -> Iterable[bool]:
|
||
"""Write each document in the buffer and yield if successful."""
|
||
if self._srcHandle and self._srcItem and self._parHandle:
|
||
pHandle = self._parHandle
|
||
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 (
|
||
(dHandle := self._project.newFile(docLabel, pHandle))
|
||
and (nwItem := self._project.tree[dHandle])
|
||
):
|
||
hHandle[hLevel] = dHandle
|
||
nwItem.setStatus(self._srcItem.itemStatus)
|
||
nwItem.setImport(self._srcItem.itemImport)
|
||
|
||
outDoc = self._project.storage.getDocument(dHandle)
|
||
status = outDoc.writeDocument("\n".join(docText))
|
||
if not status:
|
||
self._error = outDoc.getError()
|
||
|
||
self._project.index.reIndexHandle(dHandle)
|
||
nwItem.notifyToRefresh()
|
||
|
||
yield status
|
||
|
||
hHandle[hLevel] = dHandle
|
||
pLevel = hLevel
|
||
return
|
||
|
||
|
||
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
|
||
|
||
##
|
||
# Methods
|
||
##
|
||
|
||
def duplicate(self, items: list[str]) -> list[str]:
|
||
"""Run through a list of items, duplicate them, and copy the
|
||
text content if they are documents.
|
||
"""
|
||
result = []
|
||
after = True
|
||
if items:
|
||
hMap: dict[str, str | None] = {t: None for t in items}
|
||
SHARED.initMainProgress(len(items))
|
||
for tHandle in items:
|
||
SHARED.incMainProgress()
|
||
if oldItem := self._project.tree[tHandle]:
|
||
pHandle = hMap.get(oldItem.itemParent or "") or oldItem.itemParent
|
||
if newItem := self._project.tree.duplicate(tHandle, pHandle, after):
|
||
hMap[tHandle] = newItem.itemHandle
|
||
if newItem.isFileType():
|
||
self._project.copyFileContent(newItem.itemHandle, tHandle)
|
||
newItem.notifyToRefresh()
|
||
result.append(newItem.itemHandle)
|
||
after = False
|
||
else:
|
||
break
|
||
SHARED.clearMainProgress()
|
||
return result
|
||
|
||
|
||
class DocSearch:
|
||
"""Tool> Search Documents.
|
||
|
||
A global document search class.
|
||
"""
|
||
|
||
def __init__(self) -> None:
|
||
self._regEx = re.compile(r"")
|
||
self._opts = re.IGNORECASE
|
||
self._words = False
|
||
self._escape = True
|
||
|
||
##
|
||
# Methods
|
||
##
|
||
|
||
def setCaseSensitive(self, state: bool) -> None:
|
||
"""Set the case sensitive search flag."""
|
||
self._opts = 0 if state else re.IGNORECASE
|
||
|
||
def setWholeWords(self, state: bool) -> None:
|
||
"""Set the whole words search flag."""
|
||
self._words = state
|
||
|
||
def setUserRegEx(self, state: bool) -> None:
|
||
"""Set the escape flag to the opposite state."""
|
||
self._escape = not state
|
||
|
||
def iterSearch(
|
||
self, project: NWProject, search: str
|
||
) -> Iterable[tuple[NWItem, list[tuple[int, int, str]], bool]]:
|
||
"""Iterate through documents in a project and apply search."""
|
||
self._regEx = re.compile(self._buildPattern(search), self._opts)
|
||
logger.debug("Searching with pattern '%s'", self._regEx.pattern)
|
||
storage = project.storage
|
||
SHARED.initMainProgress(len(project.tree))
|
||
for item in project.tree:
|
||
SHARED.incMainProgress()
|
||
if item.isFileType():
|
||
results, capped = self.searchText(storage.getDocumentText(item.itemHandle))
|
||
yield item, results, capped
|
||
SHARED.clearMainProgress()
|
||
return
|
||
|
||
def searchText(self, text: str) -> tuple[list[tuple[int, int, str]], bool]:
|
||
"""Search a piece of text for RegEx matches."""
|
||
count = 0
|
||
capped = False
|
||
results = []
|
||
for res in self._regEx.finditer(text):
|
||
pos = res.start(0)
|
||
num = len(res.group(0))
|
||
lim = text[:pos].rfind("\n") + 1
|
||
cut = text[lim:pos].rfind(" ") + lim + 1
|
||
context = text[cut:cut+100].partition("\n")[0]
|
||
if context:
|
||
results.append((pos, num, context))
|
||
count += 1
|
||
if count >= nwConst.MAX_SEARCH_RESULT:
|
||
capped = True
|
||
break
|
||
return results, capped
|
||
|
||
##
|
||
# Internal Functions
|
||
##
|
||
|
||
def _buildPattern(self, search: str) -> str:
|
||
"""Build the search pattern string."""
|
||
if self._escape:
|
||
search = re.escape(search)
|
||
if self._words:
|
||
search = f"(?:^|\\b){search}(?:$|\\b)"
|
||
return search
|
||
|
||
|
||
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._path = None
|
||
self.tr = partial(QCoreApplication.translate, "ProjectBuilder")
|
||
|
||
@property
|
||
def projPath(self) -> Path | None:
|
||
"""The path of the newly created project."""
|
||
return self._path
|
||
|
||
##
|
||
# Methods
|
||
##
|
||
|
||
def buildProject(self, data: dict) -> bool:
|
||
"""Build or copy a project from a data dictionary."""
|
||
if isinstance(data, dict):
|
||
path = data.get("path", None) or None
|
||
if author := data.get("author"):
|
||
CONFIG.setLastAuthor(author)
|
||
if isinstance(path, str | Path):
|
||
self._path = Path(path).resolve()
|
||
if data.get("sample"):
|
||
return self._extractSampleProject(self._path, data)
|
||
elif data.get("template"):
|
||
return self._copyProject(self._path, data)
|
||
else:
|
||
return self._buildAndPopulate(self._path, data)
|
||
SHARED.error("A project path is required.")
|
||
return False
|
||
|
||
##
|
||
# Internal Functions
|
||
##
|
||
|
||
def _buildAndPopulate(self, path: Path, data: dict) -> bool:
|
||
"""Build a blank project from a data dictionary."""
|
||
project = NWProject()
|
||
status = project.storage.createNewProject(path)
|
||
if status == NWStorageCreate.NOT_EMPTY:
|
||
SHARED.error(self.tr(
|
||
"The target folder is not empty. "
|
||
"Please choose another folder."
|
||
))
|
||
return False
|
||
elif status == NWStorageCreate.OS_ERROR:
|
||
SHARED.error(self.tr(
|
||
"An error occurred while trying to create the project."
|
||
), exc=project.storage.exc)
|
||
return False
|
||
|
||
self._path = project.storage.storagePath
|
||
|
||
trName = self.tr("New Project")
|
||
trAuthor = self.tr("Author Name")
|
||
trTitlePage = self.tr("Title Page")
|
||
|
||
# Settings
|
||
project.data.setUuid(None)
|
||
project.data.setName(data.get("name", trName))
|
||
project.data.setAuthor(data.get("author", trAuthor))
|
||
project.data.setLanguage(CONFIG.guiLocale)
|
||
project.setDefaultStatusImport()
|
||
project.session.startSession()
|
||
|
||
# Add Root Folders
|
||
hNovelRoot = project.newRoot(nwItemClass.NOVEL)
|
||
hTitlePage = project.newFile(trTitlePage, hNovelRoot)
|
||
|
||
# Generate Title Page
|
||
aDoc = project.storage.getDocument(hTitlePage)
|
||
aDoc.writeDocument((
|
||
"{author}[br]\n"
|
||
"{address} 1[br]\n"
|
||
"{address} 2 <<\n"
|
||
"\n"
|
||
"[vspace:5]\n"
|
||
"\n"
|
||
"#! {title}\n"
|
||
"\n"
|
||
">> **{by} {author}** <<\n"
|
||
"\n"
|
||
">> {count}: [field:{field}] <<\n"
|
||
).format(
|
||
author=project.data.author or trAuthor,
|
||
address=self.tr("Address Line"),
|
||
title=project.data.name or trName,
|
||
by=self.tr("By"),
|
||
count=self.tr("Word Count"),
|
||
field=nwStats.WORDS_TEXT,
|
||
))
|
||
|
||
# Create chapters and scenes
|
||
numChapters = data.get("chapters", 0)
|
||
numScenes = data.get("scenes", 0)
|
||
|
||
trChSynop = self.tr("Summary of the chapter.")
|
||
trScSynop = self.tr("Summary of the scene.")
|
||
trNoteDesc = self.tr("A short description.")
|
||
|
||
# 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: {trChSynop}\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: {trScSynop}\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: {trScSynop}\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("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: {trNoteDesc}\n\n"
|
||
)
|
||
|
||
# Also add the archive and trash folders
|
||
project.newRoot(nwItemClass.ARCHIVE)
|
||
_ = project.tree.trash # Triggers the creation of Trash
|
||
|
||
project.saveProject()
|
||
project.closeProject()
|
||
|
||
return True
|
||
|
||
def _copyProject(self, path: Path, data: dict) -> bool:
|
||
"""Copy an existing project content, but not the meta data, and
|
||
update new settings.
|
||
"""
|
||
source = data.get("template")
|
||
if not (isinstance(source, Path) and source.is_file()
|
||
and (source.name == nwFiles.PROJ_FILE or is_zipfile(source))):
|
||
logger.error("Could not access source project: %s", source)
|
||
return False
|
||
|
||
logger.info("Copying project: %s", source)
|
||
if path.exists():
|
||
SHARED.error(self.tr(
|
||
"The target folder already exists. "
|
||
"Please choose another folder."
|
||
))
|
||
return False
|
||
|
||
# Begin copying
|
||
srcPath = source.parent
|
||
dstPath = path.resolve()
|
||
srcCont = srcPath / "content"
|
||
dstCont = dstPath / "content"
|
||
try:
|
||
dstPath.mkdir(exist_ok=True)
|
||
dstCont.mkdir(exist_ok=True)
|
||
if is_zipfile(source):
|
||
with ZipFile(source) as zipObj:
|
||
for member in zipObj.namelist():
|
||
if member == nwFiles.PROJ_FILE:
|
||
zipObj.extract(member, dstPath)
|
||
elif member.startswith("content") and member.endswith(".nwd"):
|
||
zipObj.extract(member, dstPath)
|
||
else:
|
||
shutil.copy2(srcPath / nwFiles.PROJ_FILE, dstPath)
|
||
for item in srcCont.iterdir():
|
||
if item.is_file() and item.suffix == ".nwd" and isHandle(item.stem):
|
||
shutil.copy2(item, dstCont)
|
||
except Exception as exc:
|
||
SHARED.error(self.tr("Could not copy project files."), exc=exc)
|
||
return False
|
||
|
||
# Open the copied project and update settings
|
||
self._resetProject(dstPath, data)
|
||
|
||
return True
|
||
|
||
def _extractSampleProject(self, path: Path, data: dict) -> bool:
|
||
"""Make a copy of the sample project by extracting the
|
||
sample.zip file to the new path.
|
||
"""
|
||
if path.exists():
|
||
SHARED.error(self.tr(
|
||
"The target folder already exists. "
|
||
"Please choose another folder."
|
||
))
|
||
return False
|
||
|
||
if (sample := CONFIG.assetPath("sample.zip")).is_file():
|
||
try:
|
||
shutil.unpack_archive(sample, path)
|
||
self._resetProject(path, data)
|
||
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
|
||
|
||
def _resetProject(self, path: Path, data: dict) -> None:
|
||
"""Open a project and reset/update its settings."""
|
||
project = NWProject()
|
||
project.openProject(path)
|
||
project.data.setUuid("") # Creates a fresh uuid
|
||
if name := data.get("name", ""):
|
||
project.data.setName(name)
|
||
if author := data.get("author", ""):
|
||
project.data.setAuthor(author)
|
||
project.data.setSpellCheck(True)
|
||
project.data.setSpellLang(None)
|
||
project.data.setDoBackup(True)
|
||
project.data.setSaveCount(0)
|
||
project.data.setAutoCount(0)
|
||
project.data.setEditTime(0)
|
||
project.index.rebuild()
|
||
project.saveProject()
|
||
project.closeProject()
|