diff --git a/sources/dataBase/projectdatabase.cpp b/sources/dataBase/projectdatabase.cpp index b688865b9..06811c9db 100644 --- a/sources/dataBase/projectdatabase.cpp +++ b/sources/dataBase/projectdatabase.cpp @@ -847,13 +847,15 @@ bool projectDataBase::populateFromDocument(const QDomDocument &document, QString } } - query.prepare(QStringLiteral("INSERT INTO link (element_uuid, linked_uuid, group_index) " - "VALUES (:element_uuid, :linked_uuid, :group_index)")); + query.prepare(QStringLiteral("INSERT INTO link (element_uuid, linked_uuid, group_index, ord) " + "VALUES (:element_uuid, :linked_uuid, :group_index, :ord)")); for (const DocumentElement &element : std::as_const(elements)) { + int ord = 0; for (const auto &link : element.links) { query.bindValue(QStringLiteral(":element_uuid"), element.uuid); query.bindValue(QStringLiteral(":linked_uuid"), link.first); query.bindValue(QStringLiteral(":group_index"), link.second >= 0 ? QVariant(link.second) : QVariant()); + query.bindValue(QStringLiteral(":ord"), ord++); if (!query.exec()) { qDebug() << "projectDataBase::populateFromDocument link insert error : " << query.lastError(); } @@ -1092,7 +1094,7 @@ QList projectDataBase::linkedSymbols(const QUuid &element) const { QList linked; QSqlQuery query(m_data_base); - query.prepare(QStringLiteral("SELECT linked_uuid FROM link WHERE element_uuid = :uuid ORDER BY rowid")); + query.prepare(QStringLiteral("SELECT linked_uuid FROM link WHERE element_uuid = :uuid ORDER BY ord, rowid")); query.bindValue(QStringLiteral(":uuid"), element.toString()); if (query.exec()) { while (query.next()) @@ -2602,12 +2604,14 @@ bool projectDataBase::createDataBase() //Create the link table: one row per element and element it is //linked to -- a coil and its contacts, a pair of folio reports -- - //from each side, with the contact group the element saved for it. + //from each side, with the contact group the element saved for it, + //and its place (ord) in the order the element saves its links. const QString link_table(QStringLiteral( "CREATE TABLE link (" "element_uuid VARCHAR(50) NOT NULL, " "linked_uuid VARCHAR(50) NOT NULL, " "group_index INTEGER, " + "ord INTEGER NOT NULL DEFAULT 0, " "PRIMARY KEY (element_uuid, linked_uuid), " "FOREIGN KEY (element_uuid) REFERENCES element (uuid))")); if (!query_.exec(link_table)) { @@ -3868,10 +3872,11 @@ QString projectDataBase::prefillReport() const void projectDataBase::populateLinkTable() { m_dirty_link_elements.clear(); + const QSet waiting = waitingSymbols(); QSqlQuery query(m_data_base); query.exec(QStringLiteral("DELETE FROM link")); - query.prepare(QStringLiteral("INSERT INTO link (element_uuid, linked_uuid, group_index) " - "VALUES (:element_uuid, :linked_uuid, :group_index)")); + query.prepare(QStringLiteral("INSERT INTO link (element_uuid, linked_uuid, group_index, ord) " + "VALUES (:element_uuid, :linked_uuid, :group_index, :ord)")); for (const auto &diagram : m_project->folios()) { @@ -3885,23 +3890,7 @@ void projectDataBase::populateLinkTable() } const ElementProvider ep(diagram); for (const auto &elmt : ep.find(allElementTypes())) - { - QList> rows; - for (Element *linked : elmt->linkedElements()) - rows.append({linked->uuid().toString(), elmt->groupIndexForElement(linked)}); - //Partners on a folio not built, which it will be linked to - for (const auto &waiting : elmt->waitingLinks()) - rows.append({waiting.first.toString(), waiting.second}); - for (const auto &row : std::as_const(rows)) - { - query.bindValue(QStringLiteral(":element_uuid"), elmt->uuid().toString()); - query.bindValue(QStringLiteral(":linked_uuid"), row.first); - query.bindValue(QStringLiteral(":group_index"), row.second >= 0 ? QVariant(row.second) : QVariant()); - if (!query.exec()) { - qDebug() << "projectDataBase::populateLinkTable insert error : " << query.lastError(); - } - } - } + insertLinkRows(query, elmt, waiting); } } @@ -3927,6 +3916,83 @@ void projectDataBase::queueLinks(Element *element) } } +/** + @brief projectDataBase::linkRowMismatches + A test and debugging aid (QET_CHECK_LINK_ROWS): the placed + symbols whose link rows are not the links they save, in order + (Element::linksToSave(), insertLinkRows()) + @return one entry per such symbol: its uuid +*/ +QStringList projectDataBase::linkRowMismatches() +{ + flushLinks(); + const QSet waiting = waitingSymbols(); + QStringList mismatches; + QSqlQuery query(m_data_base); + query.prepare(QStringLiteral("SELECT linked_uuid, group_index FROM link " + "WHERE element_uuid = :uuid ORDER BY ord, rowid")); + for (auto it = m_placed_elements.cbegin() ; it != m_placed_elements.cend() ; ++it) + { + Element *element = it.value(); + if (!element) { + continue; + } + QList> expected; + for (const Element::SavedLink &link : element->linksToSave(waiting)) + if (!link.built || m_placed_elements.contains(link.uuid, link.built)) + expected.append({link.uuid.toString(), link.group_index}); + QList> rows; + query.bindValue(QStringLiteral(":uuid"), element->uuid().toString()); + if (query.exec()) + while (query.next()) + rows.append({query.value(0).toString(), + query.value(1).isNull() ? -1 : query.value(1).toInt()}); + if (rows != expected) { + mismatches << element->uuid().toString(); + } + } + return mismatches; +} + +/** + @brief projectDataBase::waitingSymbols + @return the symbols on folios not built yet (lazy folios): partners a + symbol keeps its saved links to until they are built +*/ +QSet projectDataBase::waitingSymbols() const +{ + return m_project && m_project->unloadedFolioCount() ? m_project->symbolsOnUnbuiltFolios() + : QSet(); +} + +/** + @brief projectDataBase::insertLinkRows + Write @p element's link rows with @p insert (prepared with :element_uuid, + :linked_uuid, :group_index and :ord): its partners as it saves them + (Element::linksToSave()), in that order, so the rows and the file say + the same: @p waiting is the symbols on folios not built, as a save + takes them (waitingSymbols()). A built partner the project no longer has (on a removed + folio, linked still for the undo) gets no row. +*/ +void projectDataBase::insertLinkRows(QSqlQuery &insert, Element *element, + const QSet &waiting) +{ + int ord = 0; + for (const Element::SavedLink &link : element->linksToSave(waiting)) + { + if (link.built && !m_placed_elements.contains(link.uuid, link.built)) { + continue; + } + insert.bindValue(QStringLiteral(":element_uuid"), element->uuid().toString()); + insert.bindValue(QStringLiteral(":linked_uuid"), link.uuid.toString()); + insert.bindValue(QStringLiteral(":group_index"), link.group_index >= 0 ? QVariant(link.group_index) : QVariant()); + insert.bindValue(QStringLiteral(":ord"), ord++); + if (!insert.exec()) { + qDebug() << "projectDataBase::insertLinkRows insert error : " << insert.lastError(); + } + } +} + /** @brief projectDataBase::flushLinks Write the link rows of every element queued by linksChanged() @@ -3939,12 +4005,13 @@ void projectDataBase::flushLinks() const auto dirty = m_dirty_link_elements; m_dirty_link_elements.clear(); + const QSet waiting = waitingSymbols(); const bool own_transaction = m_data_base.transaction(); QSqlQuery remove(m_data_base); remove.prepare(QStringLiteral("DELETE FROM link WHERE element_uuid = :uuid")); QSqlQuery insert(m_data_base); - insert.prepare(QStringLiteral("INSERT INTO link (element_uuid, linked_uuid, group_index) " - "VALUES (:element_uuid, :linked_uuid, :group_index)")); + insert.prepare(QStringLiteral("INSERT INTO link (element_uuid, linked_uuid, group_index, ord) " + "VALUES (:element_uuid, :linked_uuid, :group_index, :ord)")); for (const QPointer &element : dirty) { if (!element) { @@ -3960,32 +4027,7 @@ void projectDataBase::flushLinks() if (!m_placed_elements.contains(element->uuid(), element)) { continue; } - for (Element *linked : element->linkedElements()) - { - //A symbol on a removed folio stays linked, for the undo, - //but the project no longer has it - if (!m_placed_elements.contains(linked->uuid(), linked)) { - continue; - } - const int group = element->groupIndexForElement(linked); - insert.bindValue(QStringLiteral(":element_uuid"), element->uuid().toString()); - insert.bindValue(QStringLiteral(":linked_uuid"), linked->uuid().toString()); - insert.bindValue(QStringLiteral(":group_index"), group >= 0 ? QVariant(group) : QVariant()); - if (!insert.exec()) { - qDebug() << "projectDataBase::flushLinks insert error : " << insert.lastError(); - } - } - //Partners on a folio not built (lazy folios), which it will - //be linked to once that folio is - for (const auto &waiting : element->waitingLinks()) - { - insert.bindValue(QStringLiteral(":element_uuid"), element->uuid().toString()); - insert.bindValue(QStringLiteral(":linked_uuid"), waiting.first.toString()); - insert.bindValue(QStringLiteral(":group_index"), waiting.second >= 0 ? QVariant(waiting.second) : QVariant()); - if (!insert.exec()) { - qDebug() << "projectDataBase::flushLinks insert error : " << insert.lastError(); - } - } + insertLinkRows(insert, element, waiting); } if (own_transaction) { m_data_base.commit(); diff --git a/sources/dataBase/projectdatabase.h b/sources/dataBase/projectdatabase.h index 6b5fcc38c..a34d52536 100644 --- a/sources/dataBase/projectdatabase.h +++ b/sources/dataBase/projectdatabase.h @@ -154,6 +154,7 @@ class projectDataBase : public QObject void conductorPropertiesStored(Conductor *conductor, const ConductorProperties &properties); void conductorUuidChanged(Conductor *conductor, const QUuid &old_uuid); QStringList conductorPropertiesMismatches() const; + QStringList linkRowMismatches(); //Fill the three stores from a project's document before its //folios are built, then, once @@ -253,6 +254,8 @@ class projectDataBase : public QObject void populateConductorTable(); void populateLinkTable(); void flushLinks(); + void insertLinkRows(QSqlQuery &insert, Element *element, const QSet &waiting); + QSet waitingSymbols() const; void populateDrawingItemTables(); bool populateFromDocument(const QDomDocument &document, QString *why = nullptr); bool writeDrawingItem(QObject *object); diff --git a/sources/diagram.cpp b/sources/diagram.cpp index 5dc44f471..9940ced53 100644 --- a/sources/diagram.cpp +++ b/sources/diagram.cpp @@ -2342,6 +2342,23 @@ bool Diagram::fromXml(QDomElement &document, << "wire end(s) on a terminal that moved since the project was saved," << "matched to it by place"; } + //A copy of a symbol sharing its uuid with another (older versions' + //copies, F100) gets its own, now that its wires are connected to + //it: wires and links then name one symbol each + //(QETProject::renewedSharedUuid(), only while the project opens) + if (consider_informations && m_project) { + const int folio_index = m_project->folioIndex(this); + QHash ordinal; + for (Element *element : std::as_const(added_elements)) { + const QUuid shared = element->uuid(); + const QUuid renewed = m_project->renewedSharedUuid(folio_index, ordinal[shared]++, + shared, uuid()); + if (!renewed.isNull()) { + element->setUuid(renewed); + } + } + } + if (consider_informations && !m_wires_not_reconnected.isEmpty()) { qWarning().noquote() << "Diagram::fromXml():" << m_wires_not_reconnected.size() diff --git a/sources/qetgraphicsitem/element.cpp b/sources/qetgraphicsitem/element.cpp index 91fc96b92..f2d61692f 100644 --- a/sources/qetgraphicsitem/element.cpp +++ b/sources/qetgraphicsitem/element.cpp @@ -1437,32 +1437,11 @@ QDomElement Element::toXml( element.appendChild(inputs); //if this element is linked to other elements, - //save the uuid of each other elements - //A partner on a folio not built yet (lazy folios), which this - //symbol will be linked to, is saved too, in the order the symbol was - //saved with: saving needs no partner built (stage D5a) + //save the uuid of each other elements (linksToSave()) QList> links; - if (keepsSavedLinks() && diagram() && diagram()->project()) { - const QSet waiting = diagram()->project()->waitingLinkTargets(); - QSet written; - for (const auto &link_info : std::as_const(tmp_uuids_link)) { - Element *linked = nullptr; - for (Element *elmt : std::as_const(connected_elements)) - if (elmt->uuid() == link_info.uuid && !written.contains(elmt)) { linked = elmt; break; } - if (linked) { - written.insert(linked); - links.append({linked->uuid(), m_group_index_map.value(linked, -1)}); - } else if (waiting.contains(link_info.uuid)) { - links.append({link_info.uuid, link_info.group_index}); - } - } - for (Element *elmt : std::as_const(connected_elements)) - if (!written.contains(elmt)) - links.append({elmt->uuid(), m_group_index_map.value(elmt, -1)}); - } else { - for (Element *elmt : std::as_const(connected_elements)) - links.append({elmt->uuid(), m_group_index_map.value(elmt, -1)}); - } + for (const SavedLink &link : linksToSave(diagram() && diagram()->project() + ? diagram()->project()->waitingLinkTargets() : QSet())) + links.append({link.uuid, link.group_index}); if (!links.isEmpty()) { QDomElement links_uuids = document.createElement(QStringLiteral("links_uuids")); for (const auto &link : std::as_const(links)) { @@ -1869,6 +1848,45 @@ void Element::initLink(QETProject *prj) } } +/** + @brief Element::linksToSave + The partners this symbol saves, in the order it saves them: the order + it was saved with (tmp_uuids_link), then partners linked since. A + partner on a folio not built yet (lazy folios), which this symbol will + be linked to, is in the list too: saving needs no partner built (stage + D5a). toXml() writes this list, and the project database's link rows + are written from it, so the two are the same (QETZ-DB-FIRST stage 2a). + @param waiting the symbols on folios not built: a saved partner among + them is kept (QETProject::symbolsOnUnbuiltFolios()) + @return each partner's uuid, its contact group (-1 if none), and the + partner itself when it is built (nullptr for one waiting) +*/ +QList Element::linksToSave(const QSet &waiting) const +{ + QList links; + if (keepsSavedLinks() && diagram() && diagram()->project()) { + QSet written; + for (const auto &link_info : std::as_const(tmp_uuids_link)) { + Element *linked = nullptr; + for (Element *elmt : std::as_const(connected_elements)) + if (elmt->uuid() == link_info.uuid && !written.contains(elmt)) { linked = elmt; break; } + if (linked) { + written.insert(linked); + links.append({linked->uuid(), m_group_index_map.value(linked, -1), linked}); + } else if (waiting.contains(link_info.uuid)) { + links.append({link_info.uuid, link_info.group_index, nullptr}); + } + } + for (Element *elmt : std::as_const(connected_elements)) + if (!written.contains(elmt)) + links.append({elmt->uuid(), m_group_index_map.value(elmt, -1), elmt}); + } else { + for (Element *elmt : std::as_const(connected_elements)) + links.append({elmt->uuid(), m_group_index_map.value(elmt, -1), elmt}); + } + return links; +} + /** @brief Element::pendingLinks @return the uuids of the partners this symbol was saved linked to and diff --git a/sources/qetgraphicsitem/element.h b/sources/qetgraphicsitem/element.h index c2bfca599..bf16816ba 100644 --- a/sources/qetgraphicsitem/element.h +++ b/sources/qetgraphicsitem/element.h @@ -227,6 +227,13 @@ class Element : public QetGraphicsItem virtual void unlinkElement(Element *) {} virtual void initLink(QETProject *); QSet pendingLinks() const; + /// A partner as the symbol saves it (linksToSave()) + struct SavedLink { + QUuid uuid; + int group_index = -1; + Element *built = nullptr; ///< nullptr: on a folio not built yet + }; + QList linksToSave(const QSet &waiting) const; bool keepsSavedLinks() const {return !tmp_uuids_link.isEmpty();} QList> waitingLinks() const; /** diff --git a/sources/qetproject.cpp b/sources/qetproject.cpp index b78411f76..9b5510f3c 100644 --- a/sources/qetproject.cpp +++ b/sources/qetproject.cpp @@ -299,6 +299,27 @@ QByteArray QETProject::containerPicture(const QString &file) const return m_container_pictures.value(file); } +/** + @brief QETProject::renewedSharedUuid + While the project opens: the uuid of the @p ordinal -th symbol carrying + @p uuid on the folio at @p folio_index (in the file's order), when + other symbols of the project carry the same uuid and this copy is not + the one keeping it (readDiagramsXml()). Derived from the uuid and the + folio's uuid, so the same file gives the same uuid on every open. + @return a null uuid when this symbol keeps its uuid +*/ +QUuid QETProject::renewedSharedUuid(int folio_index, int ordinal, const QUuid &uuid, + const QUuid &folio_uuid) +{ + const auto keeper = m_shared_uuid_keepers.constFind(uuid); + if (keeper == m_shared_uuid_keepers.constEnd() + || (keeper->first == folio_index && keeper->second == ordinal)) { + return QUuid(); + } + return derivedItemUuid(QStringLiteral("element-copy"), + uuid.toString() + QLatin1Char('\n') + folio_uuid.toString()); +} + /** @brief QETProject::derivedItemUuid A uuid for an item of this project that was saved without one, the same @@ -474,6 +495,8 @@ QETProject::ProjectState QETProject::openFile(QFile *file) } QFileInfo fi(*file); setFilePath(fi.absoluteFilePath()); + //Saved again unchanged, the file keeps its saved date and path + m_stamped_path = m_file_path; //Extract the content of the xml. The raw bytes are kept : a project //file without a persisted uuid derives its uuid from them. @@ -1161,16 +1184,8 @@ void QETProject::setFilePath(const QString &filepath) setReadOnly(false); } - //title block variables should be updated after file save as dialog is confirmed, before file is saved. - m_project_properties.addValue("saveddate", QLocale::system().toString(QDate::currentDate(), QLocale::ShortFormat)); - m_project_properties.addValue("saveddate-eu", QDate::currentDate().toString("dd-MM-yyyy")); - m_project_properties.addValue("saveddate-us", QDate::currentDate().toString("yyyy-MM-dd")); - m_project_properties.addValue("savedtime", QDateTime::currentDateTime().toString("HH:mm")); - m_project_properties.addValue("savedfilename", QFileInfo(filePath()).completeBaseName()); - m_project_properties.addValue("savedfilepath", filePath()); - - - + //The saved date, time and path of the title block are set by + //write(), when the project is saved (stampSaved()) emit projectFilePathChanged(this, m_file_path); emit projectInformationsChanged(this); updateDiagramsFolioData(); @@ -2636,6 +2651,16 @@ QDomDocument QETProject::toXml(QHash *kept_folios, QStringList // Write the elements collection. project_root.appendChild(m_elements_collection->root().cloneNode(true)); + //QET_CHECK_LINK_ROWS=1: say whether the project database's link rows + //are the links each placed symbol saves, in its order, once the + //folios are written. Builds nothing (lazy folios stay as they are): + //a test and debugging aid; it changes nothing. + if (qEnvironmentVariableIntValue("QET_CHECK_LINK_ROWS") == 1) { + const QStringList links = m_data_base.linkRowMismatches(); + qWarning().noquote() << "link rows:" << links.size() << "differ" + << links.mid(0, 5).join(QStringLiteral(", ")); + } + m_waiting_link_targets.clear(); return(xml_doc); } @@ -2801,6 +2826,33 @@ bool QETProject::changedOnDisk(QDateTime *when) const return true; } +/** + @brief QETProject::stampSaved + Set the title block variables of the save (saveddate, saveddate-eu, + saveddate-us, savedtime, savedfilename, savedfilepath) to now and to + the file being written, when there is something new to save: the + project changed since it was opened or last saved, or it is written to + another file. Otherwise they keep what the file holds, so saving an + unchanged project gives an identical file (F124). + @return true if they were set +*/ +bool QETProject::stampSaved() +{ + const bool changed = m_modified || m_undo_stack->index() != m_stamped_undo_index; + if (!changed && m_file_path == m_stamped_path) { + return false; + } + m_project_properties.addValue("saveddate", QLocale::system().toString(QDate::currentDate(), QLocale::ShortFormat)); + m_project_properties.addValue("saveddate-eu", QDate::currentDate().toString("dd-MM-yyyy")); + m_project_properties.addValue("saveddate-us", QDate::currentDate().toString("yyyy-MM-dd")); + m_project_properties.addValue("savedtime", QDateTime::currentDateTime().toString("HH:mm")); + m_project_properties.addValue("savedfilename", QFileInfo(filePath()).completeBaseName()); + m_project_properties.addValue("savedfilepath", filePath()); + emit projectInformationsChanged(this); + updateDiagramsFolioData(); + return true; +} + /** @brief QETProject::write Save the project in a file. @@ -2868,13 +2920,28 @@ QETResult QETProject::write(bool overwrite_changed) if (!PreviousVersions::keep(m_file_path, PreviousVersions::count(), &backup_error)) qWarning().noquote() << "Previous version of" << m_file_path << "not kept:" << backup_error; + //The saved date, time and path go into the file being written, and + //only when there is something new to save: an unchanged project + //saves to an identical file (F124, a project kept in git). A + //failed write puts the previous values back. + const DiagramContext stamped_before = m_project_properties; + const bool stamped = stampSaved(); + const auto failed_stamped = [&](const QString &error) { + if (stamped) { + m_project_properties = stamped_before; + emit projectInformationsChanged(this); + updateDiagramsFolioData(); + } + return failed(error); + }; + QString error_message; if (isContainerPath(m_file_path)) { //A container is written from the whole document: every folio //built (stage D4 writes it from the database) if (!writeContainerFile(m_file_path, &error_message)) - return(failed(error_message)); + return(failed_stamped(error_message)); } else { @@ -2884,7 +2951,7 @@ QETResult QETProject::write(bool overwrite_changed) QDomDocument xml_project(toXml(&kept_folios)); if (!QET::writeXmlText(spliceKeptFolios(xml_project.toString(4), kept_folios), m_file_path, &error_message)) - return(failed(error_message)); + return(failed_stamped(error_message)); } rememberDiskState(); @@ -2905,17 +2972,8 @@ QETResult QETProject::write(bool overwrite_changed) } } - //title block variables should be updated after file save dialog is confirmed, before file is saved. - m_project_properties.addValue("saveddate", QLocale::system().toString(QDate::currentDate(), QLocale::ShortFormat)); - m_project_properties.addValue("saveddate-us", QDate::currentDate().toString("yyyy-MM-dd")); - m_project_properties.addValue("saveddate-eu", QDate::currentDate().toString("dd-MM-yyyy")); - m_project_properties.addValue("savedtime", QDateTime::currentDateTime().toString("HH:mm")); - m_project_properties.addValue("savedfilename", QFileInfo(filePath()).completeBaseName()); - m_project_properties.addValue("savedfilepath", filePath()); - - emit projectInformationsChanged(this); - updateDiagramsFolioData(); - + m_stamped_path = m_file_path; + m_stamped_undo_index = m_undo_stack->index(); setModified(false); return(QETResult()); } @@ -3637,6 +3695,41 @@ void QETProject::readDiagramsXml(QDomDocument &xml_project) } } + //Symbols sharing a uuid (copies made by older versions, F100): the + //project database, links and wires name a symbol by its uuid, so + //each copy but one gets its own (renewedSharedUuid()). The copy + //that keeps it is the first one linked to another symbol (a + //partner's link names the uuid), else the first. The folios with + //a copy to renew are built now, also with lazy folios, so the + //whole project has one uuid per symbol from the start. + m_shared_uuid_keepers.clear(); + QSet build_now; + { + QHash>> copies; + for (int i = 0 ; i < diagram_nodes.length() ; ++i) { + const QDomElement elements = diagram_nodes.at(i).toElement() + .firstChildElement(QStringLiteral("elements")); + QHash ordinal; + for (QDomElement e = elements.firstChildElement(QStringLiteral("element")); + !e.isNull() ; e = e.nextSiblingElement(QStringLiteral("element"))) { + const QUuid uuid(e.attribute(QStringLiteral("uuid"))); + if (uuid.isNull()) continue; + const bool linked = !e.firstChildElement(QStringLiteral("links_uuids")) + .firstChildElement(QStringLiteral("link_uuid")).isNull(); + copies[uuid].append({i, ordinal[uuid]++, linked}); + } + } + for (auto it = copies.cbegin() ; it != copies.cend() ; ++it) { + if (it.value().size() < 2) continue; + auto keeper = it.value().first(); + for (const auto © : it.value()) + if (std::get<2>(copy)) { keeper = copy; break; } + m_shared_uuid_keepers.insert(it.key(), {std::get<0>(keeper), std::get<1>(keeper)}); + for (const auto © : it.value()) + if (copy != keeper) build_now.insert(std::get<0>(copy)); + } + } + if(dlgWaiting) dlgWaiting->setProgressBarRange(0, diagram_nodes.length()*3); @@ -3690,7 +3783,7 @@ void QETProject::readDiagramsXml(QDomDocument &xml_project) connect(diagram, &Diagram::usedTitleBlockTemplateChanged, this, &QETProject::usedTitleBlockTemplateChanged); - m_deferring_folios = m_lazy_open; + m_deferring_folios = m_lazy_open && !build_now.contains(i); diagram->initFromXml(diagram_xml_element); m_deferring_folios = false; if (!diagram->isLoaded() && !line_starts.isEmpty()) @@ -3703,6 +3796,7 @@ void QETProject::readDiagramsXml(QDomDocument &xml_project) } } + m_shared_uuid_keepers.clear(); updateDiagramsFolioData(); //Initialise links between elements in this project diff --git a/sources/qetproject.h b/sources/qetproject.h index 4fbba9047..4b3dc7986 100644 --- a/sources/qetproject.h +++ b/sources/qetproject.h @@ -123,6 +123,8 @@ class QETProject : public QObject projectDataBase *dataBase(); QUuid uuid() const; QUuid derivedItemUuid(const QString &kind, const QString &key); + QUuid renewedSharedUuid(int folio_index, int ordinal, const QUuid &uuid, + const QUuid &folio_uuid); QByteArray containerPicture(const QString &file) const; ProjectState state() const; QString openError() const; @@ -441,6 +443,7 @@ class QETProject : public QObject } private: + bool stampSaved(); void readProjectXml(QDomDocument &xml_project); void readDiagramsXml(QDomDocument &xml_project); void readElementsCollectionXml(QDomDocument &xml_project); @@ -570,6 +573,10 @@ class QETProject : public QObject bool m_kept_folios_trusted = false; /// Read from a .qet whose path nobody chose since: saved beside it bool m_opened_from_qet = false; + /// The file the saved date and path were set for, and the undo + /// index then: an unchanged project keeps them (stampSaved()) + QString m_stamped_path; + int m_stamped_undo_index = 0; /// The project file as this QET last read or wrote it, to see /// whether something else changed it since (changedOnDisk()) QString m_disk_path; @@ -610,7 +617,11 @@ class QETProject : public QObject int m_next_backup_slot = 0; QUuid m_uuid = QUuid::createUuid(); QHash m_derived_uuid_keys; - QSet m_saved_item_uuids; //symbol and wire uuids the file carries, see derivedItemUuid() + QSet m_saved_item_uuids; + /// While opening: per uuid carried by several symbols, the copy + /// keeping it (folio index, ordinal on that folio), see + /// renewedSharedUuid() + QHash> m_shared_uuid_keepers; //symbol and wire uuids the file carries, see derivedItemUuid() projectDataBase m_data_base; QVector m_terminal_strip_vector; QVector m_cables; diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 6965e1b46..840341d3d 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -889,6 +889,18 @@ if(QET_HAS_SCRIPTING) target_compile_definitions(tst_scriptconductoruuid PRIVATE "QET_TEST_BINARY_PATH=\"$\"") + # The saved date, time and path of the title block change only when the + # project changed, and go into the file that save writes (F124): an + # unchanged project saved twice gives an identical file. + add_executable( + tst_savedstamps + tst_savedstamps.cpp) + add_test(NAME tst_savedstamps COMMAND tst_savedstamps) + add_dependencies(tst_savedstamps qelectrotech) + target_link_libraries(tst_savedstamps PRIVATE Qt::Test Qt::Xml) + target_compile_definitions(tst_savedstamps PRIVATE + "QET_TEST_BINARY_PATH=\"$\"") + # Deleting a symbol whose terminal held several wires joins them with a # new wire; its uuid comes from its two ends, so the same deletion saves # the same file every time (F115): examples/affuteuse_250h.qet. diff --git a/tests/qttest/tst_projectlock.cpp b/tests/qttest/tst_projectlock.cpp index 8befad6e2..225d14615 100644 --- a/tests/qttest/tst_projectlock.cpp +++ b/tests/qttest/tst_projectlock.cpp @@ -214,7 +214,9 @@ class tst_projectlock : public QObject void readOnlyFolderDoesNotBlock() { -#ifndef Q_OS_WIN +#ifdef Q_OS_WIN + QSKIP("a read-only folder still takes new files on Windows"); +#else if (geteuid() == 0) QSKIP("root can write anywhere"); #endif diff --git a/tests/qttest/tst_reporttextafterinfo.cpp b/tests/qttest/tst_reporttextafterinfo.cpp index 4f8cc4acc..ecc015a9a 100644 --- a/tests/qttest/tst_reporttextafterinfo.cpp +++ b/tests/qttest/tst_reporttextafterinfo.cpp @@ -69,10 +69,12 @@ private slots: f.close(); // Columns numbered from 1, as QElectroTech's settings page - // shows by default + // shows by default. Written to an INI file QET_SETTINGS_DIR + // points at: on Windows the settings are in the registry + // otherwise, and the columns would count from 0 const QString home = m_dir.filePath(tag + QStringLiteral("-home")); - QVERIFY(QDir().mkpath(home + QStringLiteral("/config/QElectroTech"))); - QFile conf(home + QStringLiteral("/config/QElectroTech/QElectroTech.conf")); + QVERIFY(QDir().mkpath(home + QStringLiteral("/settings/QElectroTech"))); + QFile conf(home + QStringLiteral("/settings/QElectroTech/QElectroTech.ini")); QVERIFY(conf.open(QIODevice::WriteOnly)); conf.write("[General]\nborder-columns_0=false\n"); conf.close(); @@ -80,6 +82,7 @@ private slots: QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); env.insert(QStringLiteral("QT_QPA_PLATFORM"), QStringLiteral("offscreen")); env.insert(QStringLiteral("QET_ENABLE_SCRIPTING"), QStringLiteral("1")); + env.insert(QStringLiteral("QET_SETTINGS_DIR"), home + QStringLiteral("/settings")); env.insert(QStringLiteral("HOME"), home); env.insert(QStringLiteral("XDG_CONFIG_HOME"), home + QStringLiteral("/config")); env.insert(QStringLiteral("XDG_DATA_HOME"), home + QStringLiteral("/data")); diff --git a/tests/qttest/tst_resaveunchanged.cpp b/tests/qttest/tst_resaveunchanged.cpp index 90ebfccd3..9b4fc112d 100644 --- a/tests/qttest/tst_resaveunchanged.cpp +++ b/tests/qttest/tst_resaveunchanged.cpp @@ -411,14 +411,15 @@ private slots: // QElectroTech run in a sandbox of its own with @p args, the store // check on (QET_CHECK_ELEMENT_INFO_STORE); its output - QString runChecked(const QStringList &args, bool eager = false) + QString runChecked(const QStringList &args, bool eager = false, + const char *check = "QET_CHECK_ELEMENT_INFO_STORE") { const QString home = m_dir.filePath(QStringLiteral("home%1").arg(m_run++)); QDir().mkpath(home); QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); env.insert(QStringLiteral("QT_QPA_PLATFORM"), QStringLiteral("offscreen")); env.insert(QStringLiteral("QET_ENABLE_SCRIPTING"), QStringLiteral("1")); - env.insert(QStringLiteral("QET_CHECK_ELEMENT_INFO_STORE"), QStringLiteral("1")); + env.insert(QString::fromLatin1(check), QStringLiteral("1")); if (eager) { env.insert(QStringLiteral("QET_EAGER_FOLIOS"), QStringLiteral("1")); //every folio built on opening } @@ -452,6 +453,31 @@ private slots: QVERIFY2(out.contains(QStringLiteral("wire properties store: 0 differ")), qPrintable(out.right(600))); } + // The link rows say what each symbol saves, in its order, also after + // an edit builds a partner's folio (lazy folios): m_000 as a .qetz + // saved with every folio built, a symbol of its middle folio moved. The rows used to follow the order + // the partners were built in (QETZ-DB-FIRST stage 2a). + void linkRowsAfterLazyEdit() + { + const QString qetz = m_dir.filePath(QStringLiteral("links%1.qetz").arg(m_run)); + QVERIFY(!runChecked({QStringLiteral("--resave"), + QStringLiteral(QET_EXAMPLES_DIR "/m_000.qet"), qetz}, true).isEmpty()); + const QString saved = m_dir.filePath(QStringLiteral("links-moved%1.qetz").arg(m_run)); + const QString js = m_dir.filePath(QStringLiteral("links%1.js").arg(m_run)); + QFile script(js); + QVERIFY(script.open(QIODevice::WriteOnly)); + script.write(QStringLiteral( + "var f = Math.floor(qet.folioCount() / 2);\n" + "var u = qet.elementUuids(f).slice().sort()[0];\n" + "qet.log('MOVED ' + qet.moveElement(f, u, 0, 100));\n" + "qet.save('%1');\n").arg(saved).toUtf8()); + script.close(); + const QString out = runChecked({QStringLiteral("--run"), js, qetz}, false, + "QET_CHECK_LINK_ROWS"); + QVERIFY2(out.contains(QStringLiteral("MOVED true")), qPrintable(out.right(600))); + QVERIFY2(out.contains(QStringLiteral("link rows: 0 differ")), qPrintable(out.right(600))); + } + // The stores filled from the document before the folios are built // hold what building them gives, for a // project saved by this version (every symbol, folio and wire carries diff --git a/tests/qttest/tst_savedstamps.cpp b/tests/qttest/tst_savedstamps.cpp new file mode 100644 index 000000000..3e64c1277 --- /dev/null +++ b/tests/qttest/tst_savedstamps.cpp @@ -0,0 +1,143 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#include +#include +#include +#include +#include + +/** + The title block variables of a save (saveddate, savedtime, + savedfilename, savedfilepath...) are set when the project is saved + with something new in it, and go into the file that save writes. They + used to be set after writing, so each save wrote the time of the one + before: a project saved twice without a change gave two different + files, and a changed project was saved with the previous date. + + Runs the real binary on fixtures/unlinked_contact_label.qet, whose + stamps say 2026-10-02 18:39 in a temporary folder: + - saved twice in its own file without a change: byte-identical; + - one symbol moved, then saved: today's date and the file's own path. +*/ +class tst_savedstamps : public QObject +{ + Q_OBJECT + + QTemporaryDir m_dir; + QString m_base; + + QString run(const QStringList &arguments) + { + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert(QStringLiteral("QT_QPA_PLATFORM"), QStringLiteral("offscreen")); + //Its own settings and single-instance socket: another test's + //QElectroTech running at the same time would otherwise be + //handed this run (SingleApplication) + const QString home = m_dir.filePath(QStringLiteral("home")); + QDir().mkpath(home); + env.insert(QStringLiteral("HOME"), home); + env.insert(QStringLiteral("XDG_CONFIG_HOME"), home + QStringLiteral("/config")); + env.insert(QStringLiteral("XDG_DATA_HOME"), home + QStringLiteral("/data")); + env.insert(QStringLiteral("TMPDIR"), m_dir.path()); + env.insert(QStringLiteral("QET_ENABLE_SCRIPTING"), QStringLiteral("1")); + QProcess proc; + proc.setProcessEnvironment(env); + proc.setProcessChannelMode(QProcess::MergedChannels); + proc.start(QStringLiteral(QET_TEST_BINARY_PATH), arguments); + if (!proc.waitForFinished(120000) || proc.exitStatus() != QProcess::NormalExit + || proc.exitCode() != 0) + return QString(); + return QString::fromUtf8(proc.readAll()) + QStringLiteral("\nEXIT 0"); + } + + QString script(const QString &name, const QString &body) + { + const QString path = m_dir.filePath(name); + QFile file(path); + if (!file.open(QIODevice::WriteOnly)) return QString(); + file.write(body.toUtf8()); + return path; + } + + /// The saved-* properties of @p qetz, read back through --resave + QMap stamps(const QString &qetz) + { + const QString xml = qetz + QStringLiteral(".qet"); + if (run({QStringLiteral("--resave"), qetz, xml}).isEmpty()) return {}; + QFile file(xml); + QDomDocument doc; + if (!file.open(QIODevice::ReadOnly) || !doc.setContent(&file)) return {}; + QMap out; + const QDomNodeList props = doc.documentElement().firstChildElement( + QStringLiteral("properties")).elementsByTagName(QStringLiteral("property")); + for (int i = 0; i < props.size(); ++i) { + const QDomElement p = props.at(i).toElement(); + if (p.attribute(QStringLiteral("name")).startsWith(QLatin1String("saved"))) + out.insert(p.attribute(QStringLiteral("name")), p.text()); + } + return out; + } + + static QByteArray bytes(const QString &path) + { + QFile file(path); + return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray(); + } + +private slots: + void initTestCase() + { + QVERIFY2(QFile::exists(QStringLiteral(QET_TEST_BINARY_PATH)), "qelectrotech binary not found"); + QVERIFY(m_dir.isValid()); + const QString fixture = QFINDTESTDATA("fixtures/unlinked_contact_label.qet"); + QVERIFY2(!fixture.isEmpty(), "fixture project not found"); + //The fixture as a .qetz, its stamps as they were: a copy + //written by --resave is not a save of the project + m_base = m_dir.filePath(QStringLiteral("base.qetz")); + QVERIFY(!run({QStringLiteral("--resave"), fixture, m_base}).isEmpty()); + QCOMPARE(stamps(m_base).value(QStringLiteral("saveddate-us")), QStringLiteral("2026-10-02")); + } + + void unchangedSavesAreIdentical() + { + const QString project = m_dir.filePath(QStringLiteral("unchanged.qetz")); + QVERIFY(QFile::copy(m_base, project)); + const QString js = script(QStringLiteral("unchanged.js"), + QStringLiteral("qet.save('');\nqet.save('');\nqet.log('SAVED twice');\n")); + QVERIFY(run({QStringLiteral("--run"), js, project}).contains(QLatin1String("SAVED twice"))); + QCOMPARE(bytes(project), bytes(m_base)); + } + + void changedSaveIsStampedNow() + { + const QString project = m_dir.filePath(QStringLiteral("changed.qetz")); + QVERIFY(QFile::copy(m_base, project)); + const QString js = script(QStringLiteral("changed.js"), QStringLiteral( + "var u = qet.elementUuids(0)[0];\n" + "qet.log('MOVED ' + qet.moveElement(0, u, 10, 0));\n" + "qet.save('');\n")); + QVERIFY(run({QStringLiteral("--run"), js, project}).contains(QLatin1String("MOVED true"))); + const QMap s = stamps(project); + QCOMPARE(s.value(QStringLiteral("saveddate-us")), QDate::currentDate().toString(QStringLiteral("yyyy-MM-dd"))); + QCOMPARE(s.value(QStringLiteral("savedfilepath")), QFileInfo(project).absoluteFilePath()); + QCOMPARE(s.value(QStringLiteral("savedfilename")), QStringLiteral("changed")); + } +}; + +QTEST_GUILESS_MAIN(tst_savedstamps) +#include "tst_savedstamps.moc" diff --git a/tests/qttest/tst_sharedsymboluuid.cpp b/tests/qttest/tst_sharedsymboluuid.cpp index 85ba9bf95..f2c2671e5 100644 --- a/tests/qttest/tst_sharedsymboluuid.cpp +++ b/tests/qttest/tst_sharedsymboluuid.cpp @@ -1,5 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later #include +#include #include #include @@ -150,13 +151,66 @@ private slots: QVERIFY(!twice.isEmpty()); QCOMPARE(nets(twice), expected); - //No wire names a lamp by the uuid they share - for (const QDomElement &c : childElements(firstDiagram(load(twice)), - QStringLiteral("conductors"), - QStringLiteral("conductor"))) { - QVERIFY(c.attribute(QStringLiteral("element1")) != shared); - QVERIFY(c.attribute(QStringLiteral("element2")) != shared); + //Each lamp has a uuid of its own after a save: one keeps the + //shared uuid, the others get one derived from it + QSet uuids; + for (const QDomElement &e : lamps(load(twice))) + uuids.insert(e.attribute(QStringLiteral("uuid"))); + QCOMPARE(uuids.size(), 3); + QVERIFY(uuids.contains(shared)); + } + + // A linked contact and an unlinked symbol carrying the same uuid (a copy + // made by an older version): the linked one keeps the uuid, the other + // gets its own, and every link and wire stays where it was. + void linkedCopyKeepsUuid() + { + const QString source = QStringLiteral(QET_EXAMPLES_DIR "/affuteuse_250h.qet"); + QDomDocument doc = load(source); + QList all; + const QDomNodeList folios = doc.documentElement().elementsByTagName(QStringLiteral("diagram")); + for (int i = 0; i < folios.size(); ++i) + all << childElements(folios.at(i).toElement(), QStringLiteral("elements"), + QStringLiteral("element")); + auto linked = [](const QDomElement &e) { + return !e.firstChildElement(QStringLiteral("links_uuids")) + .firstChildElement(QStringLiteral("link_uuid")).isNull(); + }; + QDomElement keeper, copy; + for (const QDomElement &e : std::as_const(all)) { + if (keeper.isNull() && linked(e)) keeper = e; + else if (copy.isNull() && !linked(e)) copy = e; } + QVERIFY(!keeper.isNull() && !copy.isNull()); + const QString shared = keeper.attribute(QStringLiteral("uuid")); + copy.setAttribute(QStringLiteral("uuid"), shared); + const int wires = doc.elementsByTagName(QStringLiteral("conductor")).size(); + + const QString saved = resave(write(doc)); + QVERIFY(!saved.isEmpty()); + const QDomDocument out = load(saved); + QHash by_uuid; + QList> links; + const QDomNodeList saved_folios = out.documentElement().elementsByTagName(QStringLiteral("diagram")); + for (int i = 0; i < saved_folios.size(); ++i) + for (const QDomElement &e : childElements(saved_folios.at(i).toElement(), + QStringLiteral("elements"), + QStringLiteral("element"))) { + const QString u = e.attribute(QStringLiteral("uuid")); + QVERIFY2(!by_uuid.contains(u), qPrintable(QStringLiteral("uuid still shared: ") + u)); + by_uuid.insert(u, e); + for (QDomElement l = e.firstChildElement(QStringLiteral("links_uuids")) + .firstChildElement(QStringLiteral("link_uuid")); + !l.isNull(); l = l.nextSiblingElement(QStringLiteral("link_uuid"))) + links.append({u, l.attribute(QStringLiteral("uuid"))}); + } + QVERIFY(by_uuid.contains(shared)); + QVERIFY(linked(by_uuid.value(shared))); + for (const auto &link : std::as_const(links)) { + QVERIFY2(by_uuid.contains(link.second), "a link names a symbol not in the project"); + QVERIFY2(links.contains(std::make_pair(link.second, link.first)), "a link only one side has"); + } + QCOMPARE(out.elementsByTagName(QStringLiteral("conductor")).size(), wires); } void savedByUuidReopensRight()