Persist the folio uuid, derived deterministically for legacy folios

Diagram::m_uuid was created in the constructor and never written, so a
folio got a new uuid on every load. Inside a running instance that is
enough (the project database keys on it), but nothing outside it could
tell which folio is which: in the file, folios were only identified by
their position.

Motivation

More and more .qet projects live in version control -- a Git repository
on GitHub or GitLab, reviewed through pull requests, sometimes edited
by several people -- or are synchronised through a cloud or key-value
store. A .qet file is plain XML, so in principle it can be diffed,
merged and split up, but only if the same folio can be recognised in
two versions of the file. Today it cannot:

- Inserting, deleting or reordering a folio shifts every following
  <diagram> element. A line-based diff, and GitHub's review view, then
  pair up unrelated folios and show far more change than was made.
- A three-way merge of two branches that both touched the project has
  no way to match "folio 3" on one side with "folio 3" on the other if
  either side reordered folios.
- Any tool that wants to say "folio X changed in this commit", keep
  per-folio history, lock a single folio, or store folios as separate
  objects has nothing stable to key on. The title and the folio number
  are user-editable and not unique.

Element uuids are already persisted and used for cross-folio links, so
the file format already relies on uuids for identity; the folio itself
was the missing piece. A stable folio uuid is the prerequisite for
later work towards better version control support: per-folio diffs and
locks (check-out / check-in), and possibly storing a project as a
directory with one file per folio.

Change

Write the uuid as an attribute of <diagram> when the whole content is
saved, and restore it first thing when the project is loaded, before any
item is created. Older versions ignore the attribute, so files stay
readable in both directions.

Folios without a uuid: why not a random one

The obvious migration -- keep the random uuid created by the
constructor and save it -- conflicts with #754 / #779: saving an
unmodified project must give the same bytes every time. Every example
project predates the attribute, so each load would invent different
uuids and write them out. Measured on the 24 example projects (resaved
3-4x each from the same original, QT_HASH_SEED=0 so that QDom's
attribute order is stable, isolated HOME per run):

  upstream master              23/24 byte-identical
  persist, random uuid          0/24
  persist, derived uuid (this) 23/24

The remaining project, schema_indus.qet, differs only in element uuids,
the known residual #779 leaves for elements; its folio uuid is stable.

This is the same problem #779 solved for conductors by not writing an
invented uuid back at all. That is not an option here: legacy folios
would never get a persistent uuid, which is the whole point of the
change. Instead, a folio without a uuid gets a name-based (version 5)
uuid, derived only from data read from the file:

  QUuid::createUuidV5(<fixed QET folio namespace>,
                      "legacy" + project title
                               + position of the folio in the file
                               + folio title)

- The same input file always yields the same uuids, so resaving an
  unmodified legacy project stays reproducible.
- The uuid is derived once, at load time, and saved from then on. After
  that it is read, never recomputed: renaming, reordering or editing
  the folio later does not change it. Renaming in the same session as
  the migration does not change it either, since it was derived from
  the title as loaded.
- Two people opening the same legacy file on different branches get
  the same uuid for each folio, even if one of them reorders or renames
  folios before saving. With random uuids the two branches would
  disagree about every folio and a later merge could not match them.
- The folio content is deliberately not part of the name: QDom keeps
  attributes in a hash whose iteration order changes between runs, so
  hashing the content would need a canonical form for no real gain.

Folios are only guaranteed unique within their project. Two unrelated
legacy projects with the same title and the same first folio title get
the same uuid for that folio; anything keying folios globally has to
combine the folio uuid with a project identifier. (The project uuid is
not persisted yet; that is a separate change.)

Duplicated uuids

A hand-edited or merged file can contain the same uuid twice, e.g. a
folio copied by duplicating its XML block. Since the uuid is used as a
key, the second folio gets a derived uuid as well ("duplicate" + the
clashing uuid + the same inputs as above), so this case is
reproducible too. Should a derived uuid ever be taken already, which
takes a hand-crafted file, the name is salted with a counter until it
is free.

The namespace uuid is fixed in the code and must never change, or every
legacy folio would get a different uuid.

Tests (Qt 6.4, offscreen, qelectrotech --resave / --set-titleblock)

- 24 example projects, 3-4 resaves each from the same original: results
  above; all folio uuids identical across runs, no duplicates within
  any project.
- Resaving an already migrated file is byte-identical to the first
  output.
- Renaming a folio in a migrated file keeps its uuid.
- Swapping two <diagram> blocks in a migrated file: each uuid moves
  with its folio.
- Migrating and renaming in the same run (--set-titleblock title=...
  on a legacy file) gives the same uuids as a plain resave.
- A file with a duplicated uuid: the second folio gets a new uuid, the
  same one on every run.
- A migrated file opened with upstream master loads normally; the
  attribute is ignored and dropped on save.

Refs #754, #779

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BDyt4txaott5JyPNGQaeVp
This commit is contained in:
Beat Hangartner
2026-09-15 20:27:22 +00:00
committed by Claude
parent 265aa44320
commit 7cd04f2a14
2 changed files with 96 additions and 0 deletions
+93
View File
@@ -691,6 +691,79 @@ QUuid Diagram::uuid()
return m_uuid;
}
/**
@brief Diagram::uuidUsedByOtherDiagram
A hand-edited or merged project file can contain two folios with the same
uuid. The uuid is used as a key (e.g. in the project database), so the
second one must get another one.
@param uuid
@return true if another diagram of the parent project already uses @p uuid
*/
bool Diagram::uuidUsedByOtherDiagram(const QUuid &uuid) const
{
if (!m_project) {
return false;
}
const auto diagrams = m_project->diagrams();
for (const Diagram *diagram : diagrams) {
if (diagram != this && diagram->m_uuid == uuid) {
return true;
}
}
return false;
}
/**
@brief Diagram::derivedUuid
Name-based (version 5) uuid for a folio that has no usable uuid in the
file it is loaded from : either the file predates the uuid attribute, or
the uuid it carries is already taken by another folio of the project.
A random uuid would do as an identity, but it would make saving an
unmodified legacy project non-reproducible : every load would invent a
different one and write it out (see #754). The name is therefore built
only from data read from the file itself -- the project title, the
position of the folio in the file and its title -- so the same input file
always yields the same uuid. It is computed once, when the folio is
loaded, and saved from then on : renaming or moving the folio later does
not change it.
@param root : the <diagram> element being loaded
@param reason : distinguishes the two cases above, so that they can not
produce the same name
@return a uuid not used by any other diagram of the project
*/
QUuid Diagram::derivedUuid(const QDomElement &root, const QString &reason) const
{
//Fixed namespace for QElectroTech folio uuids, never change it :
//doing so would change the uuid given to every legacy folio.
static const QUuid folio_namespace(
QStringLiteral("{d5951240-154d-44d6-8277-0092a31d1920}"));
const int index = m_project
? m_project->diagrams().indexOf(const_cast<Diagram *>(this))
: -1;
const QString project_title = root.ownerDocument()
.documentElement()
.attribute(QStringLiteral("title"));
const QString base = QStringLiteral("qet-folio\n%1\n%2\n%3\n%4")
.arg(reason,
project_title,
QString::number(index),
root.attribute(QStringLiteral("title")));
//A clash is only possible with a hand-crafted file, but the uuid is a
//key : salt the name until it is free. Still deterministic.
QUuid uuid = QUuid::createUuidV5(folio_namespace, base);
for (int salt = 1 ; uuidUsedByOtherDiagram(uuid) ; ++salt) {
uuid = QUuid::createUuidV5(folio_namespace,
base + QStringLiteral("\n")
+ QString::number(salt));
}
return uuid;
}
/**
@brief Diagram::setEventInterface
Set event_interface has current interface.
@@ -906,6 +979,11 @@ QDomDocument Diagram::toXml(bool whole_content, bool is_copy_command) {
// schema properties
// proprietes du schema
if (whole_content) {
//Persist the folio identity, so that a folio keeps the same uuid
//across save/load. Without it every load invents a new one, and
//nothing outside the running instance (version control, a lock,
//a diff tool...) can tell which folio is which.
dom_root.setAttribute(QStringLiteral("uuid"), m_uuid.toString());
border_and_titleblock.titleBlockToXml(dom_root);
border_and_titleblock.borderToXml(dom_root);
@@ -1406,6 +1484,21 @@ bool Diagram::fromXml(QDomElement &document,
// Read attributes of this diagram
if (consider_informations)
{
// Restore the persisted folio uuid. Done first, before any item is
// loaded, so that everything created below sees the final uuid.
// A folio without a usable one (file written before the uuid was
// persisted, or uuid already taken by another folio) gets a
// deterministic one instead, see derivedUuid().
const QUuid persisted_uuid(root.attribute(QStringLiteral("uuid")));
if (persisted_uuid.isNull()) {
m_uuid = derivedUuid(root, QStringLiteral("legacy"));
} else if (uuidUsedByOtherDiagram(persisted_uuid)) {
m_uuid = derivedUuid(root, QStringLiteral("duplicate ")
+ persisted_uuid.toString());
} else {
m_uuid = persisted_uuid;
}
// Load border and titleblock
border_and_titleblock.titleBlockFromXml(root);
border_and_titleblock.borderFromXml(root);
+3
View File
@@ -136,6 +136,9 @@ class Diagram : public QGraphicsScene
bool m_freeze_new_elements;
bool m_freeze_new_conductors_;
QUuid m_uuid = QUuid::createUuid();
bool uuidUsedByOtherDiagram(const QUuid &uuid) const;
QUuid derivedUuid(const QDomElement &root, const QString &reason) const;
// METHODS
protected: