From cd3bf1b97adf53486bccf65174106c95edd5e10e Mon Sep 17 00:00:00 2001 From: "B.D. Murphy" <161863146+bdmurphy73@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:40:38 -0800 Subject: [PATCH] feat: add Save Project As (Ctrl+Alt+S) to Project menu - Add Save Project As menu item under Project menu with Ctrl+Alt+S shortcut - SaveProjectAs dialog prompts for destination folder and new project name - Copies all document files, metadata, and project XML to new location - New location added to recent projects as a separate entry - Original project location remains in recent projects unchanged - Generate new UUID for the copied project --- CHANGELOG.md | 7 +++ umbraedit/core/project.py | 127 +++++++++++++++++++++++++++++++++++++- umbraedit/gui/mainmenu.py | 6 ++ umbraedit/guimain.py | 30 +++++++++ umbraedit/shared.py | 7 +++ 5 files changed, 175 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d886aaed0..f359598b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,13 @@ Development release following the 2026.11 release cycle. * Fixed chapter text content to be written as scene documents inside chapter folders during DOCX/DOCM import, instead of being lost. By B.D. Murphy. +**Features** + +* Added "Save Project As" (Ctrl+Alt+S) to the Project menu. Saves the current project + to a new location with an optional new name. All document files, metadata, and the + project XML are copied to the new location, and the new project is added to the + recent projects list as a separate entry. By B.D. Murphy. + --- ## Version 2026.11 [2026-11-05] diff --git a/umbraedit/core/project.py b/umbraedit/core/project.py index 801e0c979..e28be3ef4 100644 --- a/umbraedit/core/project.py +++ b/umbraedit/core/project.py @@ -24,6 +24,7 @@ from __future__ import annotations import json import logging +import shutil from enum import Enum from functools import partial @@ -44,13 +45,13 @@ from umbraedit.common import ( minmax, safeIsFile, ) -from umbraedit.constants import nwLabels, nwStyles, trConst +from umbraedit.constants import nwFiles, nwLabels, nwStyles, trConst from umbraedit.core.index import Index from umbraedit.core.options import OptionState from umbraedit.core.projectdata import ProjectData from umbraedit.core.projectxml import ProjectXMLReader, ProjectXMLWriter, XMLReadState from umbraedit.core.sessions import SessionLog -from umbraedit.core.storage import ProjectStorage, ProjectStorageOpen +from umbraedit.core.storage import ProjectStorage, ProjectStorageCreate, ProjectStorageOpen from umbraedit.core.tree import ProjectTree from umbraedit.enum import nwItemClass, nwItemLayout, nwItemType, nwProjectKind from umbraedit.error import logException @@ -493,6 +494,128 @@ class NWProject: return True + def saveProjectAs(self, newPath: str | Path, newName: str) -> bool: + """Save the current project to a new location with a new name. + + Creates a new project folder at the given path, copies all document + and metadata files from the current project, and writes the project + XML with the new name. The new location is added to the recent + projects list as a separate entry from the original location. + """ + if not self._storage.isOpen(): + SHARED.error(self.tr("There is no project open.")) + return False + + newPath = Path(newPath).resolve() + + # First save all current documents to make sure everything is current + saveTime = time() + self.updateCounts() + self.countStatus() + + for docHandle in self._tree.allDocs(): + doc = self._storage.getDocument(docHandle) + if doc and doc.nwItem: + text = doc.readDocument() + doc.writeDocument(text) + + # Save old paths before creating new storage (createNewProject + # overwrites _storagePath, _runtimePath, etc.) + oldContentPath = self._storage.contentPath + oldRuntimePath = self._storage._runtimePath + oldMetaPath = oldRuntimePath / "meta" if oldRuntimePath else None + + # Create new project storage at the new location + status = self._storage.createNewProject(newPath) + if status != ProjectStorageCreate.READY: + if status == ProjectStorageCreate.NOT_EMPTY: + SHARED.error( + self.tr("The target folder is not empty. Please choose another folder.") + ) + else: + SHARED.error(self.tr("Failed to create project storage.")) + return False + + # Update project data: new UUID and name + self._data.setUuid("") + self._data.setName(newName) + + # Copy all document files from old content to new content + newContentPath = self._storage.contentPath + if oldContentPath and newContentPath: + for mdFile in oldContentPath.glob("*.md"): + destFile = newContentPath / mdFile.name + try: + shutil.copy2(mdFile, destFile) + except Exception as exc: + logger.error( + "Failed to copy document file %s: %s", mdFile.name, exc + ) + SHARED.error( + self.tr("Failed to copy document file: {0}").format(mdFile.name) + ) + return False + + # Copy all meta files from old meta to new meta + newMetaPath = self._storage._runtimePath / "meta" if self._storage._runtimePath else None + if oldMetaPath and newMetaPath: + if oldMetaPath.is_dir(): + newMetaPath.mkdir(exist_ok=True) + for metaFile in oldMetaPath.glob("*"): + if metaFile.is_file(): + destFile = newMetaPath / metaFile.name + try: + shutil.copy2(metaFile, destFile) + except Exception as exc: + logger.error( + "Failed to copy meta file %s: %s", metaFile.name, exc + ) + + # Copy manifest.json if it exists + oldManifestPath = ( + self._storage._runtimePath / nwFiles.MANIFEST_FILE + if oldRuntimePath + else None + ) + newManifestPath = ( + self._storage._runtimePath / nwFiles.MANIFEST_FILE + if self._storage._runtimePath + else None + ) + if oldManifestPath and newManifestPath and oldManifestPath.exists(): + try: + shutil.copy2(oldManifestPath, newManifestPath) + except Exception as exc: + logger.error("Failed to copy manifest file: %s", exc) + + # Write project XML + self._data.setIndexRevision(self._index.indexRevision) + saveTime = time() + editTime = self._data.editTime + max(round(saveTime - self._session.start), 0) + content = self._tree.pack() + xmlWriter = self._storage.getXmlWriter() + if not isinstance(xmlWriter, ProjectXMLWriter): + return False + + if not xmlWriter.write(self._data, content, saveTime, editTime): + self._reportErrors(self.tr("Issues encountered when saving project:")) + return False + + # Save other project data + self._options.saveSettings() + self._index.saveIndex() + + self._storage.runPostSaveTasks(saveTime=saveTime, autoSave=False) + + # Update recent projects with the new location (separate from original) + CONFIG.recentProjects.update(str(newPath), self._data, saveTime) + + self._reportErrors(self.tr("Issues encountered when saving project:")) + SHARED.newStatusMessage(self.tr("Saved Project As: {0}").format(newName)) + self.setProjectChanged(False) + + return True + def closeProject(self, idleTime: float = 0.0) -> None: """Close the project.""" logger.info("Closing project") diff --git a/umbraedit/gui/mainmenu.py b/umbraedit/gui/mainmenu.py index 14b127513..fcbc7c602 100644 --- a/umbraedit/gui/mainmenu.py +++ b/umbraedit/gui/mainmenu.py @@ -210,6 +210,12 @@ class GuiMainMenu(QMenuBar): self.aSaveProject.triggered.connect(qtWeakLambda(self.mainGui.saveProject)) self.mainGui.addAction(self.aSaveProject) + # Project > Save Project As + self.aSaveProjectAs = qtAddAction(self.projMenu, self.tr("Save Project As")) + self.aSaveProjectAs.setShortcut("Ctrl+Alt+S") + self.aSaveProjectAs.triggered.connect(qtWeakLambda(self.mainGui.saveProjectAs)) + self.mainGui.addAction(self.aSaveProjectAs) + # Project > Close Project self.aCloseProject = qtAddAction(self.projMenu, self.tr("Close Project")) self.aCloseProject.setShortcut("Ctrl+Shift+W") diff --git a/umbraedit/guimain.py b/umbraedit/guimain.py index dfc53e032..dd1fac87b 100644 --- a/umbraedit/guimain.py +++ b/umbraedit/guimain.py @@ -39,6 +39,7 @@ from PyQt6.QtGui import QCloseEvent, QCursor, QGuiApplication, QIcon from PyQt6.QtWidgets import ( QApplication, QHBoxLayout, + QInputDialog, QMainWindow, QMessageBox, QSplitter, @@ -467,6 +468,35 @@ class GuiMain(QMainWindow): return SHARED.saveProject(autoSave=autoSave) return False + def saveProjectAs(self) -> None: + """Save the current project to a new location (Save As).""" + if not SHARED.hasProject: + return + + # First save current project to make sure everything is current + if not self.saveProject(autoSave=False): + return + + # Get destination folder + folder = SHARED.getProjectFolder(self, str(CONFIG.homePath())) + if not folder: + return + + # Get new project name + currentName = SHARED.project.data.name + newName, ok = QInputDialog.getText( + self, + self.tr("Save Project As"), + self.tr("Project Name:"), + text=currentName, + ) + if not ok or not newName.strip(): + return + newName = newName.strip() + + # Save the project to the new location + SHARED.saveProjectAs(folder, newName) + ## # Tree Item Actions ## diff --git a/umbraedit/shared.py b/umbraedit/shared.py index 8853938a5..9dea16f18 100644 --- a/umbraedit/shared.py +++ b/umbraedit/shared.py @@ -248,6 +248,13 @@ class SharedData(QObject): return False return self.project.saveProject(autoSave=autoSave) + def saveProjectAs(self, newPath: str | Path, newName: str) -> bool: + """Save the current project to a new location with a new name.""" + if not self.project.isValid: + logger.error("There is no project open") + return False + return self.project.saveProjectAs(newPath, newName) + def closeProject(self) -> None: """Close the current project.""" self._closeToolDialogs()