Save as .qetz by default; a .qet opened is saved beside it (stage D1)

The zipped project (.qetz) is the format QElectroTech saves.

- Save As and the first Save of a new project offer .qetz first
  ("untitled.qetz"); .qet can still be chosen.
- A project read from a .qet is saved as a .qetz beside it, the .qet left
  as it is (QETProject::write(), so Save, autosave, a script's save and the
  assistant's live save all do it). The project then works on the .qetz.
  A path chosen afterwards (Save As, a script naming one) is saved to as it
  is. The editor says so before the first such save, and asks before
  replacing a .qetz already there.
- File > Save a copy for an older QElectroTech writes a .qet copy, for a
  version that cannot open a .qetz; the project keeps its own file.
- The operating systems know the file: a freedesktop MIME type
  application/x-qelectrotech-project+zip (*.qetz, and by its mimetype
  entry when the name says nothing), the desktop file, macOS's document
  types and the Windows installer's association.

Crash backups stay XML, written beside the project as before.

tst_resaveunchanged::qetSavedAsQetzBeside: a .qet saved where it is leaves
the .qet byte for byte and writes a .qetz beside it holding what a plain
save of the .qet holds; it fails when the switch is off. In a real window:
Ctrl+S on perceuse.qet shows the notice, writes perceuse.qetz and leaves
perceuse.qet's md5 unchanged; the copy for an older version (reached by
command search) equals a plain save but for time_spent; a new project's
Save offers untitled.qetz. xdg-mime finds the type by name and by content.
ctest 96/96 with and without QET_LAZY_FOLIOS=1; srclint R-C clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ispyisail
2026-10-11 17:09:31 +13:00
parent 00e200b504
commit ecc7a39131
11 changed files with 175 additions and 5 deletions
+4
View File
@@ -338,6 +338,9 @@ Section ""
WriteRegStr HKEY_CLASSES_ROOT "qet_diagram_file\DefaultIcon" "" "$final_project_ico"
WriteRegStr HKEY_CLASSES_ROOT "qet_diagram_file\shell\open\command" "" '"$final_qet_exe" "%1"'
; File associations - .qetz, the zipped project: the same file type
WriteRegStr HKEY_CLASSES_ROOT ".qetz" "" "qet_diagram_file"
; File associations - .elmt
WriteRegStr HKEY_CLASSES_ROOT ".elmt" "" "qet_element_file"
WriteRegStr HKEY_CLASSES_ROOT "qet_element_file" "" "Element QET"
@@ -486,6 +489,7 @@ Section "Uninstall"
; Remove file associations registry keys
DeleteRegKey HKEY_CLASSES_ROOT "Applications\qelectrotech.exe"
DeleteRegKey HKEY_CLASSES_ROOT ".qet"
DeleteRegKey HKEY_CLASSES_ROOT ".qetz"
DeleteRegKey HKEY_CLASSES_ROOT "qet_diagram_file"
DeleteRegKey HKEY_CLASSES_ROOT ".elmt"
DeleteRegKey HKEY_CLASSES_ROOT "qet_element_file"
+1
View File
@@ -32,6 +32,7 @@
<key>CFBundleTypeExtensions</key>
<array>
<string>qet</string>
<string>qetz</string>
</array>
<key>CFBundleTypeRole</key>
<string>Editor</string>
+1 -1
View File
@@ -6,7 +6,7 @@ Exec=qelectrotech %F
Icon=qelectrotech
Terminal=false
Type=Application
MimeType=application/x-qet-project;application/x-qet-element;application/x-qet-titleblock;
MimeType=application/x-qet-project;application/x-qelectrotech-project+zip;application/x-qet-element;application/x-qet-titleblock;
Categories=Graphics;
Keywords=Graphics;Science;Electricity;Engineering;
Comment=Edit electrical diagrams.
+8
View File
@@ -34,6 +34,14 @@
<comment xml:lang="zh">QElectroTech 项目文件</comment>
<glob pattern="*.qet" />
</mime-type>
<mime-type type="application/x-qelectrotech-project+zip">
<sub-class-of type="application/zip"/>
<comment>QElectroTech zipped project file</comment>
<glob pattern="*.qetz" />
<magic priority="60">
<match type="string" offset="30" value="mimetypeapplication/x-qelectrotech-project+zip"/>
</magic>
</mime-type>
<mime-type type="application/x-qet-element">
<sub-class-of type="text/xml"/>
<comment>QElectroTech element file</comment>
+55 -2
View File
@@ -341,13 +341,15 @@ QString ProjectView::askUserForFilePath(bool assign) {
// ask the user for a filepath in order to save the project
const QString qet_filter = tr("Project QElectroTech (*.qet)", "filetypes allowed when saving a project file");
const QString qetz_filter = tr("Zipped project QElectroTech (*.qetz)", "filetypes allowed when saving a project file");
QString selected_filter = QETProject::isContainerPath(m_project->filePath()) ? qetz_filter : qet_filter;
//A project is saved as a .qetz unless a .qet is chosen
//(stage D1)
QString selected_filter = qetz_filter;
QString filepath = QFileDialog::getSaveFileName(
this,
tr("Save as", "dialog title"),
m_project -> currentDir() + "/" + tr("untitled")
+ (selected_filter == qetz_filter ? ".qetz" : ".qet"),
qet_filter + ";;" + qetz_filter,
qetz_filter + ";;" + qet_filter,
&selected_filter
);
@@ -725,6 +727,32 @@ QETResult ProjectView::saveAs()
return(doSave());
}
/**
@brief ProjectView::saveCopyForOlderVersions
Ask for a .qet path and write the project there, for a version of
QElectroTech that cannot open a .qetz. The project keeps its file.
@return a QETResult object reflecting the situation; a valid one if
the operation was cancelled
*/
QETResult ProjectView::saveCopyForOlderVersions()
{
if (!m_project) return(noProjectResult());
QString base = m_project->filePath().isEmpty()
? m_project->currentDir() + "/" + tr("untitled")
: m_project->filePath();
for (const QString &suffix : {QStringLiteral(".qetz"), QStringLiteral(".qet")})
if (base.endsWith(suffix, Qt::CaseInsensitive)) base.chop(suffix.size());
QString path = QFileDialog::getSaveFileName(
this,
tr("Save a copy for an older QElectroTech", "dialog title"),
base + ".qet",
tr("Project QElectroTech (*.qet)", "filetypes allowed when saving a project file"));
if (path.isEmpty()) return(QETResult());
if (!path.endsWith(".qet", Qt::CaseInsensitive)) path += ".qet";
return(m_project->writeCopyForOlderVersions(path));
}
/**
Save project content, then write the project file. May
call saveAs if no filepath was provided before.
@@ -741,6 +769,31 @@ QETResult ProjectView::doSave()
return(saveAs());
}
//A project read from a .qet is saved as a .qetz beside it: say so,
//and ask before replacing a .qetz already there
if (m_project->savesBesideOpenedFile()) {
const QString beside = m_project->besideContainerPath();
const QString message = tr("This project was opened from %1. It is saved as %2, "
"beside it, in the zipped project format; %1 stays as "
"it is.\n\nTo write a file for a version of "
"QElectroTech that cannot open %2, use File > Save a "
"copy for an older QElectroTech.")
.arg(QFileInfo(m_project->filePath()).fileName(), QFileInfo(beside).fileName());
if (QFileInfo::exists(beside)) {
if (QET::QetMessageBox::question(
this, tr("Save as a zipped project", "message box title"),
message + QStringLiteral("\n\n")
+ tr("%1 already exists. Replace it?").arg(QFileInfo(beside).fileName()),
QMessageBox::Yes | QMessageBox::Cancel, QMessageBox::Cancel)
!= QMessageBox::Yes) {
return(QETResult());
}
} else {
QET::QetMessageBox::information(
this, tr("Save as a zipped project", "message box title"), message);
}
}
// write to file
QETResult result = m_project -> write();
updateWindowTitle();
+1
View File
@@ -131,6 +131,7 @@ class ProjectView : public QWidget
QETResult save();
QETResult saveAs();
QETResult doSave();
QETResult saveCopyForOlderVersions();
int cleanProject();
void updateWindowTitle();
void updateTabTitle(DiagramView *);
+16
View File
@@ -978,6 +978,8 @@ void QETDiagramEditor::setUpActions()
QAction *open_file = m_file_actions_group.addAction(QET::Icons::DocumentOpen, tr("&Open"));
m_save_file = m_file_actions_group.addAction(QET::Icons::DocumentSave, tr("&Save"));
m_save_file_as = m_file_actions_group.addAction(QET::Icons::DocumentSaveAs, tr("Save as"));
m_save_copy_for_older = m_file_actions_group.addAction(QET::Icons::DocumentSaveAs,
tr("Save a copy for an older QElectroTech"));
m_close_file = m_file_actions_group.addAction(QET::Icons::ProjectClose, tr("&Close"));
ShortcutManager::instance().registerAction(new_file, "diagrameditor.new_file", tr("Diagram editors"), QKeySequence::New);
@@ -991,6 +993,19 @@ void QETDiagramEditor::setUpActions()
m_close_file ->setStatusTip( tr("Closes the current project", "status bar tip") );
m_save_file ->setStatusTip( tr("Saves the current project and all its sheets", "status bar tip") );
m_save_file_as ->setStatusTip( tr("Saves the current project with a different file name", "status bar tip") );
m_save_copy_for_older->setStatusTip(tr("Writes a copy of the current project as a .qet file, which "
"versions of QElectroTech that cannot open a .qetz open",
"status bar tip"));
ShortcutManager::instance().registerAction(m_save_copy_for_older, "diagrameditor.save_copy_for_older",
tr("Diagram editors"), QKeySequence());
connect(m_save_copy_for_older, &QAction::triggered, this, [this]() {
if (ProjectView *project_view = currentProjectView()) {
const QETResult result = project_view->saveCopyForOlderVersions();
if (!result.isOk()) {
showError(result);
}
}
});
connect(m_save_file_as, &QAction::triggered, this, &QETDiagramEditor::saveAs);
connect(m_save_file, &QAction::triggered, this, &QETDiagramEditor::save);
@@ -2750,6 +2765,7 @@ void QETDiagramEditor::slot_updateActions()
m_close_file-> setEnabled(opened_project);
m_save_file-> setEnabled(opened_project);
m_save_file_as-> setEnabled(opened_project);
m_save_copy_for_older-> setEnabled(opened_project);
m_rotate_texts-> setEnabled(editable_project);
m_export_to_images-> setEnabled(opened_diagram);
m_print-> setEnabled(opened_diagram);
+1
View File
@@ -301,6 +301,7 @@ class QETDiagramEditor : public QETMainWindow
*m_close_file, ///< Close current project file
*m_save_file, ///< Save current project
*m_save_file_as, ///< Save current project as a specific file
*m_save_copy_for_older, ///< Write a .qet copy for an older QElectroTech
*m_find = nullptr,
*m_jump_to_element = nullptr; ///< Open the "jump to element" quick-open popup
+46 -2
View File
@@ -192,6 +192,8 @@ QETProject::QETProject(KAutoSaveFile *backup, QObject *parent) :
}
//Set the real path, instead of the path of the backup.
setFilePath(backup->managedFile().path());
//A project read from a .qet is saved as a .qetz beside it
m_opened_from_qet = !isContainerPath(m_file_path);
delete backup;
//Set the project to read only mode if the file it.
@@ -437,8 +439,12 @@ QETProject::ProjectState QETProject::openFile(QFile *file)
//Extract the content of the xml. The raw bytes are kept : a project
//file without a persisted uuid derives its uuid from them.
const QByteArray content = file->readAll();
//Each folio's text is kept while it is not built (readDiagramsXml())
m_opened_content = content;
//Each folio's text is kept while it is not built (readDiagramsXml()):
//from a .qet only, a .qetz is a zip of parts
if (!content.startsWith("PK\x03\x04"))
m_opened_content = content;
//A project read from a .qet is saved as a .qetz beside it
m_opened_from_qet = !content.startsWith("PK\x03\x04");
QDomDocument xml_project;
// PreserveSpacingOnlyNodes: without it, a text node that is entirely
// whitespace -- e.g. a title-block custom variable deliberately set to
@@ -1007,6 +1013,8 @@ void QETProject::setFilePath(const QString &filepath)
}
m_next_backup_slot = 0;
m_file_path = filepath;
//A path chosen is saved to as it is
m_opened_from_qet = false;
QFileInfo fi(m_file_path);
if (fi.isWritable()) {
@@ -2472,12 +2480,48 @@ bool QETProject::writeContainer(const QDomDocument &project, const QString &path
return !entries.isEmpty() && QetZip::write(path, entries, error);
}
/**
@brief QETProject::besideContainerPath
@return the .qetz a project read from a .qet is saved to: the same
name, beside it
*/
QString QETProject::besideContainerPath() const
{
QString path = m_file_path;
if (path.endsWith(QStringLiteral(".qet"), Qt::CaseInsensitive)) {
path.chop(4);
}
return path + QStringLiteral(".qetz");
}
/**
@brief QETProject::writeCopyForOlderVersions
Write this project as a .qet at @p path, which a QElectroTech that
reads no .qetz opens. The project keeps its own file and state.
*/
QETResult QETProject::writeCopyForOlderVersions(const QString &path)
{
QHash<int, QByteArray> kept_folios;
QDomDocument xml_project(toXml(&kept_folios));
QString error_message;
if (!QET::writeXmlText(spliceKeptFolios(xml_project.toString(4), kept_folios),
path, &error_message))
return(error_message);
return(QETResult());
}
QETResult QETProject::write()
{
// this operation requires a filepath
if (m_file_path.isEmpty())
return(QString("unable to save project to file: no filepath was specified"));
//A project read from a .qet is saved as a .qetz beside it; the
//.qet stays as it was (stage D1)
if (m_opened_from_qet) {
setFilePath(besideContainerPath());
}
// If the project was opened read-only, only refuse when the target
// really can't be written: an existing file that is not writable, or a
// new file (e.g. "Save As" to another location) whose directory is not
+8
View File
@@ -436,6 +436,12 @@ class QETProject : public QObject
ProjectState openFile(QFile *file);
public:
static bool isContainerPath(const QString &path);
/// Saving writes a .qetz: one read from a .qet is saved as
/// a .qetz beside it, the .qet left as it is
/// (stage D1)
bool savesBesideOpenedFile() const {return m_opened_from_qet;}
QString besideContainerPath() const;
QETResult writeCopyForOlderVersions(const QString &path);
static bool writeContainer(const QDomDocument &project, const QString &path,
QString *error = nullptr);
private:
@@ -520,6 +526,8 @@ class QETProject : public QObject
/// database rows came from it, and nothing project-wide that
/// a folio's saved items depend on has changed since
bool m_kept_folios_trusted = false;
/// Read from a .qet whose path nobody chose since: saved beside it
bool m_opened_from_qet = false;
bool m_kept_folios_stale = false;
/// Something was edited since the project was opened: a table
/// filled from the database may have changed
+34
View File
@@ -479,6 +479,40 @@ private slots:
QCOMPARE(rowsOf(edited), rowsOf(reopened));
}
// A project read from a .qet and saved where it is, is saved as a .qetz
// beside it, and the .qet is left as it was (stage D1).
// What the .qetz holds is what a plain save of the .qet holds.
void qetSavedAsQetzBeside()
{
#ifndef QET_HAS_SCRIPTING
QSKIP("needs --run: this QElectroTech is built without Qt Qml");
#endif
const QString qet = m_dir.filePath(QStringLiteral("beside.qet"));
QFile::remove(qet);
QVERIFY(QFile::copy(QStringLiteral(QET_EXAMPLES_DIR "/industrial.qet"), qet));
const QByteArray before = read(qet);
const QString script = m_dir.filePath(QStringLiteral("beside.js"));
QFile js(script);
QVERIFY(js.open(QIODevice::WriteOnly));
js.write("qet.log('SAVED ' + qet.save(''));\n");
js.close();
const QString log = runChecked({QStringLiteral("--run"), script, qet});
QVERIFY2(log.contains(QStringLiteral("SAVED true")), qPrintable(log.right(400)));
QCOMPARE(read(qet), before);
const QString qetz = m_dir.filePath(QStringLiteral("beside.qetz"));
QVERIFY2(read(qetz).startsWith("PK\x03\x04"), "no .qetz beside the .qet");
auto without_save_stamps = [](QByteArray xml) {
static const QRegularExpression stamp(QStringLiteral(
"\\s*<property [^>]*name=\"saved(filename|filepath|filenamedir|date|date-us|date-eu|time)\"[^>]*>[^<]*</property>"));
return QString::fromUtf8(xml).remove(stamp);
};
const QString straight = resave(qet);
const QString back = resave(qetz);
QVERIFY(!straight.isEmpty() && !back.isEmpty());
QCOMPARE(without_save_stamps(read(back)), without_save_stamps(read(straight)));
}
// A title-block value that is a single space is kept through two saves
// (#973), and a value with accents comes back as it went in.