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
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:
@@ -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]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
##
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user