feat: add Save Project As (Ctrl+Alt+S) to Project menu
Some checks failed
Syntax / Linting and Type Checking (push) Has been cancelled
Tests / Linux Python 3.11 (push) Has been cancelled
Tests / Linux Python 3.12 (push) Has been cancelled
Tests / Linux Python 3.13 (push) Has been cancelled
Tests / Linux Python 3.14 (push) Has been cancelled
Tests / Linux Python 3.15 (push) Has been cancelled
Tests / MacOS Python 3.14 (push) Has been cancelled
Tests / Windows Python 3.14 (push) Has been cancelled
Tests / PR Coverage Report (push) Has been cancelled
Tests / Action Coverage Report (push) Has been cancelled
Tests / Ubuntu 24.04 & Sys Packages (push) Has been cancelled
Tests / Fedora 44 & Sys Packages (push) Has been cancelled

- 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
This commit is contained in:
B.D. Murphy
2026-11-05 11:40:38 -08:00
parent e4ac46eb19
commit cd3bf1b97a
5 changed files with 175 additions and 2 deletions

View File

@@ -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]

View File

@@ -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")

View File

@@ -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")

View File

@@ -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
##

View File

@@ -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()