Skip to content

Commit

Permalink
Add creating new project from zipped existing project (#1684)
Browse files Browse the repository at this point in the history
  • Loading branch information
vkbo authored Feb 3, 2024
2 parents 8af720c + 4d016c3 commit 321e57a
Show file tree
Hide file tree
Showing 14 changed files with 156 additions and 72 deletions.
36 changes: 23 additions & 13 deletions novelwriter/core/coretools.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from typing import Iterable
from pathlib import Path
from functools import partial
from zipfile import ZipFile, is_zipfile

from PyQt5.QtCore import QCoreApplication

Expand Down Expand Up @@ -459,7 +460,7 @@ def _copyProject(self, path: Path, data: dict) -> bool:
"""
source = data.get("template")
if not (isinstance(source, Path) and source.is_file()
and source.name == nwFiles.PROJ_FILE):
and (source.name == nwFiles.PROJ_FILE or is_zipfile(source))):
logger.error("Could not access source project: %s", source)
return False

Expand All @@ -476,12 +477,24 @@ def _copyProject(self, path: Path, data: dict) -> bool:
dstPath = path.resolve()
srcCont = srcPath / "content"
dstCont = dstPath / "content"
dstPath.mkdir(exist_ok=True)
dstCont.mkdir(exist_ok=True)
shutil.copy2(srcPath / nwFiles.PROJ_FILE, dstPath)
for contFile in srcCont.iterdir():
if contFile.is_file() and contFile.suffix == ".nwd" and isHandle(contFile.stem):
shutil.copy2(contFile, dstCont)
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
project = NWProject()
Expand All @@ -505,14 +518,11 @@ def _extractSampleProject(self, path: Path) -> bool:
"""Make a copy of the sample project by extracting the
sample.zip file to the new path.
"""
pkgSample = CONFIG.assetPath("sample.zip")
if pkgSample.is_file():
if (sample := CONFIG.assetPath("sample.zip")).is_file():
try:
shutil.unpack_archive(pkgSample, path)
shutil.unpack_archive(sample, path)
except Exception as exc:
SHARED.error(self.tr(
"Failed to create a new example project."
), exc=exc)
SHARED.error(self.tr("Failed to create a new example project."), exc=exc)
return False
else:
SHARED.error(self.tr(
Expand Down
17 changes: 8 additions & 9 deletions novelwriter/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,16 +221,15 @@ def runInThreadPool(self, runnable: QRunnable, priority: int = 0) -> None:
def getProjectPath(self, parent: QWidget, path: str | Path | None = None,
allowZip: bool = False) -> Path | None:
"""Open the file dialog and select a novelWriter project file."""
ext = [
self.tr("novelWriter Project File ({0})").format(nwFiles.PROJ_FILE),
self.tr("All files ({0})").format("*"),
]
if allowZip:
ext.insert(1, self.tr("Zip Archives ({0})").format("*.zip"))
projFile, _ = QFileDialog.getOpenFileName(
parent, self.tr("Open Project"), str(path or ""), filter=";;".join(ext)
label = (self.tr("novelWriter Project File or Zip")
if allowZip else self.tr("novelWriter Project File"))
ext = f"{nwFiles.PROJ_FILE} *.zip" if allowZip else nwFiles.PROJ_FILE
selected, _ = QFileDialog.getOpenFileName(
parent, self.tr("Open Project"), str(path or ""), filter=";;".join(
[f"{label} ({ext})", "{0} (*)".format(self.tr("All Files"))]
)
)
return Path(projFile) if projFile else None
return Path(selected) if selected else None

def findTopLevelWidget(self, kind: type[NWWidget]) -> NWWidget | None:
"""Find a top level widget."""
Expand Down
91 changes: 89 additions & 2 deletions tests/test_core/test_core_coretools.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

import uuid
import pytest
import shutil

from shutil import copyfile
from pathlib import Path
Expand Down Expand Up @@ -501,7 +502,7 @@ def testCoreTools_ProjectBuilderB(monkeypatch, fncPath, tstPaths, mockRnd):


@pytest.mark.core
def testCoreTools_ProjectBuilderTemplate(monkeypatch, mockGUI, prjLipsum, fncPath):
def testCoreTools_ProjectBuilderCopyPlain(monkeypatch, caplog, mockGUI, prjLipsum, fncPath):
"""Create a new project copied from existing project."""
srcPath = prjLipsum / nwFiles.PROJ_FILE
dstPath = fncPath / "lipsum"
Expand All @@ -524,6 +525,14 @@ def testCoreTools_ProjectBuilderTemplate(monkeypatch, mockGUI, prjLipsum, fncPat
# Cannot copy to existing folder
assert builder.buildProject({"path": fncPath, "template": srcPath}) is False

# Valid data, but copy fails
caplog.clear()
with monkeypatch.context() as mp:
mp.setattr("pathlib.Path.mkdir", lambda *a, **k: causeOSError)
assert builder.buildProject(data) is False
assert "Could not copy project files." in caplog.text
dstPath.unlink(missing_ok=True) # The failed mkdir leaves an empty file

# Copy project properly
assert builder.buildProject(data) is True

Expand Down Expand Up @@ -556,7 +565,85 @@ def testCoreTools_ProjectBuilderTemplate(monkeypatch, mockGUI, prjLipsum, fncPat
assert dstProject.data.autoCount < 5
assert dstProject.data.editTime < 10

# END Test testCoreTools_ProjectBuilderTemplate
# END Test testCoreTools_ProjectBuilderCopyPlain


@pytest.mark.core
def testCoreTools_ProjectBuilderCopyZipped(monkeypatch, caplog, mockGUI, fncPath, mockRnd):
"""Create a new project copied from existing zipped project."""

# Create a project
origPath = fncPath / "original"
srcProject = NWProject()
buildTestProject(srcProject, origPath)

# Zip it
shutil.make_archive(str(fncPath / "original"), "zip", origPath)

# Make fake zip file
fakeZip = fncPath / "broken.zip"
fakeZip.write_bytes(b"stuff")

# Set up the builder
srcPath = fncPath / "original.zip"
dstPath = fncPath / "copy"
data = {
"name": "Test Project",
"author": "Jane Doe",
"language": "en_US",
"path": dstPath,
"template": srcPath,
}

builder = ProjectBuilder()

# No path set
assert builder.buildProject({"template": srcPath}) is False

# No project at path
assert builder.buildProject({"path": fncPath, "template": fncPath}) is False

# Cannot copy to existing folder
assert builder.buildProject({"path": fncPath, "template": srcPath}) is False

# Cannot copy to existing folder
caplog.clear()
with monkeypatch.context() as mp:
mp.setattr("novelwriter.core.coretools.is_zipfile", lambda *a: True)
assert builder.buildProject({"path": dstPath, "template": fakeZip}) is False
assert "Could not copy project files." in caplog.text
shutil.rmtree(dstPath, ignore_errors=True)

# Copy project properly
assert builder.buildProject(data) is True

# Check Copy
# ==========

dstProject = NWProject()
dstProject.openProject(dstPath)

# UUID should be different
assert srcProject.data.uuid != dstProject.data.uuid

# Name should be different
assert srcProject.data.name == "New Project"
assert dstProject.data.name == "Test Project"

# Author should be different
assert srcProject.data.author == "Jane Doe"
assert dstProject.data.author == "Jane Doe"

# Language should be different
assert srcProject.data.language is None
assert dstProject.data.language == "en_US"

# Counts should be more or less zeroed
assert dstProject.data.saveCount < 5
assert dstProject.data.autoCount < 5
assert dstProject.data.editTime < 10

# END Test testCoreTools_ProjectBuilderCopyZipped


@pytest.mark.core
Expand Down
7 changes: 3 additions & 4 deletions tests/test_dialogs/test_dlg_about.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,9 @@

from pathlib import Path

from tools import getGuiItem

from PyQt5.QtWidgets import QAction, QMessageBox

from novelwriter import SHARED
from novelwriter.dialogs.about import GuiAbout


Expand All @@ -37,8 +36,8 @@ def testDlgAbout_NWDialog(qtbot, monkeypatch, nwGUI):
# NW About
nwGUI.showAboutNWDialog(showNotes=True)

qtbot.waitUntil(lambda: getGuiItem("GuiAbout") is not None, timeout=1000)
msgAbout = getGuiItem("GuiAbout")
qtbot.waitUntil(lambda: SHARED.findTopLevelWidget(GuiAbout) is not None, timeout=1000)
msgAbout = SHARED.findTopLevelWidget(GuiAbout)
assert isinstance(msgAbout, GuiAbout)

assert msgAbout.pageAbout.document().characterCount() > 100
Expand Down
6 changes: 2 additions & 4 deletions tests/test_dialogs/test_dlg_preferences.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,6 @@

import pytest

from tools import getGuiItem

from PyQt5.QtGui import QFontDatabase, QKeyEvent
from PyQt5.QtCore import QEvent, Qt
from PyQt5.QtWidgets import QAction, QDialogButtonBox, QFileDialog, QFontDialog
Expand All @@ -44,8 +42,8 @@ def testDlgPreferences_Main(qtbot, monkeypatch, nwGUI, tstPaths):

# Load GUI with standard values
nwGUI.mainMenu.aPreferences.activate(QAction.ActionEvent.Trigger)
qtbot.waitUntil(lambda: getGuiItem("GuiPreferences") is not None, timeout=1000)
prefs = getGuiItem("GuiPreferences")
qtbot.waitUntil(lambda: SHARED.findTopLevelWidget(GuiPreferences) is not None, timeout=1000)
prefs = SHARED.findTopLevelWidget(GuiPreferences)
assert isinstance(prefs, GuiPreferences)
prefs.show()

Expand Down
10 changes: 6 additions & 4 deletions tests/test_dialogs/test_dlg_projectsettings.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

import pytest

from tools import C, getGuiItem, buildTestProject
from tools import C, buildTestProject

from PyQt5.QtGui import QColor
from PyQt5.QtCore import Qt
Expand All @@ -47,17 +47,19 @@ def testDlgProjSettings_Dialog(qtbot, monkeypatch, nwGUI):

# Check that we cannot open when there is no project
nwGUI.mainMenu.aProjectSettings.activate(QAction.Trigger)
assert getGuiItem("GuiProjectSettings") is None
assert SHARED.findTopLevelWidget(GuiProjectSettings) is None

# Pretend we have a project
SHARED.project._valid = True
SHARED.project.data.setSpellLang("en")

# Get the dialog object
nwGUI.mainMenu.aProjectSettings.activate(QAction.Trigger)
qtbot.waitUntil(lambda: getGuiItem("GuiProjectSettings") is not None, timeout=1000)
qtbot.waitUntil(
lambda: SHARED.findTopLevelWidget(GuiProjectSettings) is not None, timeout=1000
)

projSettings = getGuiItem("GuiProjectSettings")
projSettings = SHARED.findTopLevelWidget(GuiProjectSettings)
assert isinstance(projSettings, GuiProjectSettings)
projSettings.show()
qtbot.addWidget(projSettings)
Expand Down
6 changes: 3 additions & 3 deletions tests/test_dialogs/test_dlg_wordlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QDialog, QAction

from tools import buildTestProject, getGuiItem
from tools import buildTestProject

from novelwriter import SHARED
from novelwriter.core.spellcheck import UserDictionary
Expand All @@ -47,9 +47,9 @@ def testDlgWordList_Dialog(qtbot, monkeypatch, nwGUI, projPath):

# Load the dialog
nwGUI.mainMenu.aEditWordList.activate(QAction.Trigger)
qtbot.waitUntil(lambda: getGuiItem("GuiWordList") is not None, timeout=1000)
qtbot.waitUntil(lambda: SHARED.findTopLevelWidget(GuiWordList) is not None, timeout=1000)

wList = getGuiItem("GuiWordList")
wList = SHARED.findTopLevelWidget(GuiWordList)
assert isinstance(wList, GuiWordList)
wList.show()

Expand Down
10 changes: 5 additions & 5 deletions tests/test_gui/test_gui_guimain.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
from shutil import copyfile

from tools import (
C, NWD_IGNORE, cmpFiles, buildTestProject, XML_IGNORE, getGuiItem
C, NWD_IGNORE, cmpFiles, buildTestProject, XML_IGNORE
)

from PyQt5.QtGui import QPalette
Expand Down Expand Up @@ -93,16 +93,16 @@ def testGuiMain_Launch(qtbot, monkeypatch, nwGUI, projPath):
nwGUI.closeProject()

# Check that release notes opened
qtbot.waitUntil(lambda: getGuiItem("GuiAbout") is not None, timeout=1000)
msgAbout = getGuiItem("GuiAbout")
qtbot.waitUntil(lambda: SHARED.findTopLevelWidget(GuiAbout) is not None, timeout=1000)
msgAbout = SHARED.findTopLevelWidget(GuiAbout)
assert isinstance(msgAbout, GuiAbout)
assert msgAbout.tabBox.currentWidget() == msgAbout.pageNotes
msgAbout.accept()

# Check that project open dialog launches
nwGUI.postLaunchTasks(None)
qtbot.waitUntil(lambda: getGuiItem("GuiWelcome") is not None, timeout=1000)
assert isinstance(welcome := getGuiItem("GuiWelcome"), GuiWelcome)
qtbot.waitUntil(lambda: SHARED.findTopLevelWidget(GuiWelcome) is not None, timeout=1000)
assert isinstance(welcome := SHARED.findTopLevelWidget(GuiWelcome), GuiWelcome)
welcome.show()
welcome.close()

Expand Down
5 changes: 2 additions & 3 deletions tests/test_tools/test_tools_dictionaries.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@

from zipfile import ZipFile

from tools import getGuiItem
from mocked import causeException

from PyQt5.QtGui import QDesktopServices
Expand All @@ -48,9 +47,9 @@ def testToolDictionaries_Main(qtbot, monkeypatch, nwGUI, fncPath):

# Open the tool
nwGUI.showDictionariesDialog()
qtbot.waitUntil(lambda: getGuiItem("GuiDictionaries") is not None, timeout=1000)
qtbot.waitUntil(lambda: SHARED.findTopLevelWidget(GuiDictionaries) is not None, timeout=1000)

nwDicts = getGuiItem("GuiDictionaries")
nwDicts = SHARED.findTopLevelWidget(GuiDictionaries)
assert isinstance(nwDicts, GuiDictionaries)
assert nwDicts.isVisible()
assert nwDicts.inPath.text() == str(fncPath)
Expand Down
5 changes: 3 additions & 2 deletions tests/test_tools/test_tools_lipsum.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,11 @@

import pytest

from tools import C, getGuiItem, buildTestProject
from tools import C, buildTestProject

from PyQt5.QtWidgets import QAction

from novelwriter import SHARED
from novelwriter.tools.lipsum import GuiLipsum


Expand All @@ -34,7 +35,7 @@ def testToolLipsum_Main(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
"""Test the Lorem Ipsum tool."""
# Check that we cannot open when there is no project
nwGUI.mainMenu.aLipsumText.activate(QAction.Trigger)
assert getGuiItem("GuiLipsum") is None
assert SHARED.findTopLevelWidget(GuiLipsum) is None

buildTestProject(nwGUI, projPath)
nwLipsum = GuiLipsum(nwGUI)
Expand Down
Loading

0 comments on commit 321e57a

Please sign in to comment.