diff --git a/build-aux/windows/QET64.nsi b/build-aux/windows/QET64.nsi index fc79b780a..529efce82 100644 --- a/build-aux/windows/QET64.nsi +++ b/build-aux/windows/QET64.nsi @@ -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" diff --git a/misc/Info.plist b/misc/Info.plist index 3d138a781..c41a2f03f 100644 --- a/misc/Info.plist +++ b/misc/Info.plist @@ -32,6 +32,7 @@ CFBundleTypeExtensions qet + qetz CFBundleTypeRole Editor diff --git a/misc/org.qelectrotech.qelectrotech.desktop b/misc/org.qelectrotech.qelectrotech.desktop index d76e23964..86876992c 100644 --- a/misc/org.qelectrotech.qelectrotech.desktop +++ b/misc/org.qelectrotech.qelectrotech.desktop @@ -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. diff --git a/misc/qelectrotech.xml b/misc/qelectrotech.xml index 9c59b116b..cefe25eb5 100644 --- a/misc/qelectrotech.xml +++ b/misc/qelectrotech.xml @@ -34,6 +34,14 @@ QElectroTech 项目文件 + + + QElectroTech zipped project file + + + + + QElectroTech element file diff --git a/sources/projectview.cpp b/sources/projectview.cpp index 47bdc0719..ab62a3591 100644 --- a/sources/projectview.cpp +++ b/sources/projectview.cpp @@ -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(); diff --git a/sources/projectview.h b/sources/projectview.h index dfbd03987..eec92dcc7 100644 --- a/sources/projectview.h +++ b/sources/projectview.h @@ -131,6 +131,7 @@ class ProjectView : public QWidget QETResult save(); QETResult saveAs(); QETResult doSave(); + QETResult saveCopyForOlderVersions(); int cleanProject(); void updateWindowTitle(); void updateTabTitle(DiagramView *); diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index 8c9924606..ab25c619a 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -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); diff --git a/sources/qetdiagrameditor.h b/sources/qetdiagrameditor.h index 2ff471b3f..f9287cf07 100644 --- a/sources/qetdiagrameditor.h +++ b/sources/qetdiagrameditor.h @@ -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 diff --git a/sources/qetproject.cpp b/sources/qetproject.cpp index 7cdc79970..92aeefc6c 100644 --- a/sources/qetproject.cpp +++ b/sources/qetproject.cpp @@ -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 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 diff --git a/sources/qetproject.h b/sources/qetproject.h index 97987d1a9..07d4abd51 100644 --- a/sources/qetproject.h +++ b/sources/qetproject.h @@ -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 diff --git a/tests/qttest/tst_resaveunchanged.cpp b/tests/qttest/tst_resaveunchanged.cpp index c36ac8127..c7d7bc409 100644 --- a/tests/qttest/tst_resaveunchanged.cpp +++ b/tests/qttest/tst_resaveunchanged.cpp @@ -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*]*name=\"saved(filename|filepath|filenamedir|date|date-us|date-eu|time)\"[^>]*>[^<]*")); + 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.