From bc6da100d3a30e974ce1616aebbc5b98511388b3 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Sat, 17 Oct 2026 22:22:10 +1300 Subject: [PATCH] Fix a table losing its query when a folio is removed with lazy folios With lazy folios, removing a folio left the tables on folios not built yet as the file had them. A table split over several folios keeps its query in its first part; when the folio holding that part was removed, the next part, built later from the file, named a part that was gone and was saved with no query. A parts list that listed the removed folio's symbols also kept its old size. With every folio built neither happens. Removing a folio that holds a table or a symbol now builds the folios holding tables first, while the removed folio is still in the project. A folio with neither builds nothing, as tst_lazyfolios checks. buildFolios() now holds the project database's update signal back and sends it once at the end, as pasting does: without that every item built made every table lay itself out again, and the save after a removal on a 50-folio project took about 270 s instead of 4.5 s. The rows are still written as each item is built, so no full refill of the database is needed (one per build made a 136-folio project's edit 20 % slower). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XCdg2Er2vYhB8Vwa5G7fSR --- sources/qetproject.cpp | 44 +++++++++++++++++++++ sources/qetproject.h | 1 + tests/qttest/tst_resaveunchanged.cpp | 59 ++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+) diff --git a/sources/qetproject.cpp b/sources/qetproject.cpp index b7463fe81..b78411f76 100644 --- a/sources/qetproject.cpp +++ b/sources/qetproject.cpp @@ -752,11 +752,24 @@ void QETProject::buildFolios(const QList &folios) const auto *self = const_cast(this); const ProjectState state = m_state; self->m_state = ProjectParsingRunning; + //Batch the database's signals as a paste does + //(DiagramEventAddPaste): every symbol and wire built would emit + //dataBaseUpdated(), and every table already built would measure all + //its rows again each time -- minutes for a save building 40 folios + //with 10 parts-list folios built. The rows are still written as each + //item is built: only the signal waits, and is sent once at the end + //(updateDB() would fill the whole database again, every time). + const bool signals_were_blocked = self->m_data_base.signalsBlocked(); + self->m_data_base.blockSignals(true); QList built; for (Diagram *d : folios) if (d->buildDeferred()) built << d; for (Diagram *d : std::as_const(built)) self->folioLoaded(d); + self->m_data_base.blockSignals(signals_were_blocked); + if (!signals_were_blocked && !built.isEmpty()) { + emit self->m_data_base.dataBaseUpdated(); + } //A symbol built before still keeping the links it was saved with //(a partner on a folio not built): its initLink() links those just //built, puts its partners back in their saved order and drops what @@ -3175,6 +3188,23 @@ void QETProject::buildFoliosHoldingTables() buildFolios(to_build); } +/** + @brief QETProject::folioHoldsTablesOrSymbols + @return true if @p folio, built or not, has a drawn table or a symbol +*/ +bool QETProject::folioHoldsTablesOrSymbols(Diagram *folio) const +{ + if (!folio->isLoaded()) { + const QDomElement xml = folio->deferredXml(); + return !xml.elementsByTagName(QetGraphicsTableItem::xmlTagName()).isEmpty() + || !xml.elementsByTagName(QStringLiteral("element")).isEmpty(); + } + for (QGraphicsItem *item : folio->items()) + if (item->type() == QetGraphicsTableItem::Type || item->type() == Element::Type) + return true; + return false; +} + /** @brief QETProject::unbuiltFolioHasTables @return true if @p folio, not built yet (lazy folios), has a drawn @@ -4274,6 +4304,20 @@ void QETProject::detachDiagram(Diagram *diagram) disconnect(diagram, &Diagram::usedTitleBlockTemplateChanged, this, &QETProject::usedTitleBlockTemplateChanged); + //A folio that holds a table or a symbol is removed: tables on folios + //not built yet (lazy folios) are built while it is still in the + //project, as they are with every folio built (F121). A table split + //over several folios keeps its query in its first part, and the next + //part takes it over only if that part was built + //(QetGraphicsTableItem::toXml()); built later, from the file, it + //names a part that is gone and saves no query. A built table also + //follows the project's database, so a parts list that showed this + //folio's symbols is resized like every other. A folio with neither + //builds no other. + if (folioHoldsTablesOrSymbols(diagram)) { + buildFoliosHoldingTables(); + } + m_diagrams_list.removeAll(diagram); updateDiagramsFolioData(); emit diagramRemoved(this, diagram); diff --git a/sources/qetproject.h b/sources/qetproject.h index 8b0dbe26f..4fbba9047 100644 --- a/sources/qetproject.h +++ b/sources/qetproject.h @@ -354,6 +354,7 @@ class QETProject : public QObject QList unusedElements() const; QList unbuiltFolioElements() const; bool unbuiltFolioHasTables(Diagram *folio) const; + bool folioHoldsTablesOrSymbols(Diagram *folio) const; bool usesTitleBlockTemplate(const TitleBlockTemplateLocation &); bool projectWasModified(); bool projectOptionsWereModified(); diff --git a/tests/qttest/tst_resaveunchanged.cpp b/tests/qttest/tst_resaveunchanged.cpp index 72a8186a7..90ebfccd3 100644 --- a/tests/qttest/tst_resaveunchanged.cpp +++ b/tests/qttest/tst_resaveunchanged.cpp @@ -234,6 +234,65 @@ private slots: QVERIFY(references > 0); } + // Deleting the folio that holds the first part of a split table, with + // folios opened lazily (the default), then saving: the next part keeps + // the table's query (F121). The first part holds it; the next part takes + // it over only if it was built when the folio went, and it used to be + // built later, from the file, naming a part that was gone and saving no + // query. industrial.qet's folio 41 (index 40) holds the first part of its + // 10-part parts list. It is resaved first: its folios have no saved uuid, + // and such a project opens with every folio built. Every table in the + // file must name a table that is in it, or have a query. + void deletedFirstPartKeepsTableQuery() + { +#ifndef QET_HAS_SCRIPTING + QSKIP("needs --run: this QElectroTech is built without Qt Qml"); +#endif + const QString in = resave(QStringLiteral(QET_EXAMPLES_DIR "/industrial.qet")); + QVERIFY(!in.isEmpty()); + const QString out = m_dir.filePath(QStringLiteral("deleted_first_part.qet")); + const QString script = m_dir.filePath(QStringLiteral("deleted_first_part.js")); + QFile js(script); + QVERIFY(js.open(QIODevice::WriteOnly)); + js.write(QStringLiteral("qet.log('REMOVED ' + qet.removeFolio(40));\n" + "qet.log('SAVED ' + qet.save('%1'));\n").arg(out).toUtf8()); + js.close(); + const QString home = m_dir.filePath(QStringLiteral("home%1").arg(m_run++)); + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert(QStringLiteral("QT_QPA_PLATFORM"), QStringLiteral("offscreen")); + env.insert(QStringLiteral("QET_ENABLE_SCRIPTING"), QStringLiteral("1")); + env.remove(QStringLiteral("QET_EAGER_FOLIOS")); + env.insert(QStringLiteral("HOME"), home); + env.insert(QStringLiteral("XDG_CONFIG_HOME"), home + QStringLiteral("/config")); + env.insert(QStringLiteral("XDG_DATA_HOME"), home + QStringLiteral("/data")); + QProcess proc; + proc.setProcessEnvironment(env); + proc.start(QStringLiteral(QET_TEST_BINARY_PATH), + {QStringLiteral("--run"), script, in}); + QVERIFY(proc.waitForFinished(180000)); + const QString log = QString::fromUtf8(proc.readAllStandardOutput() + proc.readAllStandardError()); + QVERIFY2(log.contains(QStringLiteral("REMOVED true")) && log.contains(QStringLiteral("SAVED true")), + qPrintable(log.right(400))); + + const QString saved = QString::fromUtf8(read(out)); + const QRegularExpression table_re( + QStringLiteral("]*uuid=\"([^\"]+)\"[^>]*>(.*?)"), + QRegularExpression::DotMatchesEverythingOption); + QHash tables; // uuid -> what is inside the table + for (const auto &m : table_re.globalMatch(saved)) + tables.insert(m.captured(1), m.captured(2)); + QVERIFY(tables.size() >= 9); + const QRegularExpression previous_re(QStringLiteral("\\s*\\S")); + for (auto it = tables.cbegin() ; it != tables.cend() ; ++it) { + const auto previous = previous_re.match(it.value()); + QVERIFY2(query_re.match(it.value()).hasMatch() + || (previous.hasMatch() && tables.contains(previous.captured(1))), + qPrintable(QStringLiteral("table %1 has no query and names no table in the file") + .arg(it.key()))); + } + } + // A folio report arrow linked to one that has no terminal -- such as // ref_voyant_2_h.elmt from the collection -- crashed QElectroTech on // opening: the arrow's text looked for a wire on the other arrow's