diff --git a/novelwriter/assets/icons/typicons_dark/icons.conf b/novelwriter/assets/icons/typicons_dark/icons.conf index a4f8cf166..b70c1da51 100644 --- a/novelwriter/assets/icons/typicons_dark/icons.conf +++ b/novelwriter/assets/icons/typicons_dark/icons.conf @@ -44,6 +44,7 @@ cls_template = mixed_document-new.svg cls_timeline = typ_calendar.svg cls_trash = typ_trash.svg cls_world = typ_location.svg +copy = mixed_copy.svg cross = typ_times.svg document = typ_document.svg down = typ_chevron-down.svg diff --git a/novelwriter/assets/icons/typicons_dark/mixed_copy.svg b/novelwriter/assets/icons/typicons_dark/mixed_copy.svg new file mode 100644 index 000000000..ff43bce52 --- /dev/null +++ b/novelwriter/assets/icons/typicons_dark/mixed_copy.svg @@ -0,0 +1,4 @@ + + + + diff --git a/novelwriter/assets/icons/typicons_light/icons.conf b/novelwriter/assets/icons/typicons_light/icons.conf index 022f417ea..cf18d9660 100644 --- a/novelwriter/assets/icons/typicons_light/icons.conf +++ b/novelwriter/assets/icons/typicons_light/icons.conf @@ -44,6 +44,7 @@ cls_template = mixed_document-new.svg cls_timeline = typ_calendar.svg cls_trash = typ_trash.svg cls_world = typ_location.svg +copy = mixed_copy.svg cross = typ_times.svg document = typ_document.svg down = typ_chevron-down.svg diff --git a/novelwriter/assets/icons/typicons_light/mixed_copy.svg b/novelwriter/assets/icons/typicons_light/mixed_copy.svg new file mode 100644 index 000000000..17b6efc72 --- /dev/null +++ b/novelwriter/assets/icons/typicons_light/mixed_copy.svg @@ -0,0 +1,4 @@ + + + + diff --git a/novelwriter/core/buildsettings.py b/novelwriter/core/buildsettings.py index f8cc1c401..a187bd20f 100644 --- a/novelwriter/core/buildsettings.py +++ b/novelwriter/core/buildsettings.py @@ -502,6 +502,15 @@ def unpack(self, data: dict) -> None: return + @classmethod + def duplicate(cls, source: BuildSettings) -> BuildSettings: + """Make a copy of another build.""" + cls = BuildSettings() + cls.unpack(source.pack()) + cls._uuid = str(uuid.uuid4()) + cls._name = f"{source.name} 2" + return cls + class BuildCollection: """Core: Build Collection Class diff --git a/novelwriter/gui/theme.py b/novelwriter/gui/theme.py index 4576255a6..340951cfa 100644 --- a/novelwriter/gui/theme.py +++ b/novelwriter/gui/theme.py @@ -503,10 +503,11 @@ class GuiIcons: "fmt_strike-md", "fmt_subscript", "fmt_superscript", "fmt_underline", # General Button Icons - "add", "add_document", "backward", "bookmark", "browse", "checked", "close", "cross", - "document", "down", "edit", "export", "font", "forward", "import", "list", "maximise", - "menu", "minimise", "more", "noncheckable", "open", "panel", "quote", "refresh", "remove", - "revert", "search_replace", "search", "settings", "star", "unchecked", "up", "view", + "add", "add_document", "backward", "bookmark", "browse", "checked", "close", "copy", + "cross", "document", "down", "edit", "export", "font", "forward", "import", "list", + "maximise", "menu", "minimise", "more", "noncheckable", "open", "panel", "quote", + "refresh", "remove", "revert", "search_replace", "search", "settings", "star", "unchecked", + "up", "view", # Switches "sticky-on", "sticky-off", diff --git a/novelwriter/tools/manuscript.py b/novelwriter/tools/manuscript.py index dfcb4d3d0..cad132927 100644 --- a/novelwriter/tools/manuscript.py +++ b/novelwriter/tools/manuscript.py @@ -116,6 +116,11 @@ def __init__(self, parent: GuiMain) -> None: self.tbDel.setStyleSheet(buttonStyle) self.tbDel.clicked.connect(self._deleteSelectedBuild) + self.tbCopy = NIconToolButton(self, iSz, "copy") + self.tbCopy.setToolTip(self.tr("Duplicate Selected Build")) + self.tbCopy.setStyleSheet(buttonStyle) + self.tbCopy.clicked.connect(self._copySelectedBuild) + self.tbEdit = NIconToolButton(self, iSz, "edit") self.tbEdit.setToolTip(self.tr("Edit Selected Build")) self.tbEdit.setStyleSheet(buttonStyle) @@ -128,6 +133,7 @@ def __init__(self, parent: GuiMain) -> None: self.listToolBox.addStretch(1) self.listToolBox.addWidget(self.tbAdd) self.listToolBox.addWidget(self.tbDel) + self.listToolBox.addWidget(self.tbCopy) self.listToolBox.addWidget(self.tbEdit) self.listToolBox.setSpacing(0) @@ -291,6 +297,17 @@ def _editSelectedBuild(self) -> None: self._openSettingsDialog(build) return + @pyqtSlot() + def _copySelectedBuild(self) -> None: + """Copy the currently selected build settings entry.""" + if build := self._getSelectedBuild(): + new = BuildSettings.duplicate(build) + self._builds.setBuild(new) + self._updateBuildsList() + if item := self._buildMap.get(new.buildID): + item.setSelected(True) + return + @pyqtSlot("QListWidgetItem*", "QListWidgetItem*") def _updateBuildDetails(self, current: QListWidgetItem, previous: QListWidgetItem) -> None: """Process change of build selection to update the details.""" @@ -460,9 +477,8 @@ def _updateBuildsList(self) -> None: def _updateBuildItem(self, build: BuildSettings) -> None: """Update the entry of a specific build item.""" - bItem = self._buildMap.get(build.buildID, None) - if isinstance(bItem, QListWidgetItem): - bItem.setText(build.name) + if item := self._buildMap.get(build.buildID): + item.setText(build.name) else: # Probably a new item self._updateBuildsList() return diff --git a/tests/test_core/test_core_buildsettings.py b/tests/test_core/test_core_buildsettings.py index 5d0048345..e41f5f4a0 100644 --- a/tests/test_core/test_core_buildsettings.py +++ b/tests/test_core/test_core_buildsettings.py @@ -465,3 +465,45 @@ def testCoreBuildSettings_Collection(monkeypatch, mockGUI, fncPath: Path, mockRn ] assert another.lastBuild == buildIDOne assert another.defaultBuild == buildIDTwo + + +@pytest.mark.core +def testCoreBuildSettings_Duplicate(monkeypatch, mockGUI, fncPath: Path, mockRnd): + """Test duplicating builds.""" + project = NWProject() + buildTestProject(project, fncPath) + buildsFile = project.storage.getMetaFile(nwFiles.BUILDS_FILE) + assert isinstance(buildsFile, Path) + + # No initial builds in a fresh project + builds = BuildCollection(project) + assert len(builds) == 0 + assert not buildsFile.exists() + assert builds.lastBuild == "" + assert builds.defaultBuild == "" + + # Create a default build + buildOne = BuildSettings() + buildOne.setName("Test Build") + buildOne.setValue("headings.fmtScene", nwHeadFmt.TITLE) + buildOne.setValue("headings.fmtAltScene", nwHeadFmt.TITLE) + buildOne.setValue("headings.fmtSection", nwHeadFmt.TITLE) + + # Copy it + buildTwo = BuildSettings().duplicate(buildOne) + assert buildTwo.name == "Test Build 2" + + # Raw data + dataOne = buildOne.pack() + dataTwo = buildTwo.pack() + + # Name and UUID should be different + assert dataOne["name"] != dataTwo["name"] + assert dataOne["uuid"] != dataTwo["uuid"] + + # The rest should be equal + assert dataOne["path"] == dataTwo["path"] + assert dataOne["build"] == dataTwo["build"] + assert dataOne["format"] == dataTwo["format"] + assert dataOne["settings"] == dataTwo["settings"] + assert dataOne["content"] == dataTwo["content"] diff --git a/tests/test_tools/test_tools_manuscript.py b/tests/test_tools/test_tools_manuscript.py index 67f64c2a2..df5287510 100644 --- a/tests/test_tools/test_tools_manuscript.py +++ b/tests/test_tools/test_tools_manuscript.py @@ -138,6 +138,15 @@ def _testNewSettingsReady(new: BuildSettings): assert build.name == "Test Build" assert manus.buildList.count() == 1 + # Copy the build + manus._buildMap[build.buildID].setSelected(True) + manus._copySelectedBuild() + assert manus.buildList.count() == 2 + + new = manus._getSelectedBuild() + assert new is not None + assert new.name == "Test Build 2" + # Close the dialog should also close the child dialogs manus.btnClose.click() if isinstance(bSettings, GuiBuildSettings):