The title block variables of a save (saveddate, saveddate-eu,
saveddate-us, savedtime, savedfilename, savedfilepath) were set after
the file was written, and also when a project was opened. A file
therefore held the previous save's date and path: after editing and
saving, the title block showed the date of the save before. And every
save but the first in a session wrote the time of the one before, so a
project saved twice without a change gave two different files, a diff
for nothing in a project kept in git (discussion #1512).
stampSaved() now sets them before writing, when the project has
unsaved changes (the modified flag or the undo stack moved since the
last save, as the window's "[modified]" uses) or is written to another
file. Otherwise they keep what the file holds, and opening a project no
longer sets them.
tst_savedstamps: an unchanged project saved twice in place is
byte-identical; a moved symbol then a save gives today's date and the
file's own path. Both fail without the fix.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XCdg2Er2vYhB8Vwa5G7fSR
A terminal in a symbol's definition saved without its orientation
attribute crashed QElectroTech whenever the project was opened, in the
GUI and with --resave alike: Qet::orientationFromString() read the
first character of an empty string. Hand-edited or generated symbols,
or a damaged file, can hold such a terminal.
An empty string is now an inconsistency like any other, and the terminal
faces north, as the function's comment already said. Every non-empty
string gives the same orientation as before.
tst_terminalnoorientation runs the real binary's --resave on
fixtures/terminal_no_orientation.qet (unlinked_contact_label.qet with one
terminal's orientation removed): it crashes without the fix.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XCdg2Er2vYhB8Vwa5G7fSR
In the realistic styles, some types of terminal get the parts seen on
the real products:
- fuse terminal: black hinged fuse holder on the middle of the terminal,
with its hinge pin, a window on the fuse, a ribbed grip and, for a
fuse terminal with led, a red led
- sectional (disconnect) terminal: orange knife with its hinge and grip
- test terminal: two brass test sockets between the wire entries and
the middle band (in the middle band for a terminal with levels)
The marker tag and the type symbol are drawn on the lever. The lever is
shorter with screw clamps and stays on the middle band for a terminal
with levels.
The realistic styles don't write the xrefs, so each terminal becomes a
clickable zone, like the xref text of the schematic style:
- double-click on a terminal goes to its element on the folio
- the terminal under the mouse is outlined in blue, and a tool tip
shows its label and its xref
- the zones are exported as clickable links in the PDF
A terminal with one level is clickable on its whole body; a terminal
with levels is clickable on the rows of wire entries of each level.
Only the terminals with an xref (element placed on a folio) are
clickable. The zones use the same physical / real indexes as the
schematic style, so the double-click and the PDF export need no
other change.
Add two realistic drawing styles for the terminal strip plan, besides
the existing schematic one:
- Realistic (push-in) and Realistic (screw): the strip seen from above,
at scale (real width of each terminal), like a cabinet mounting
drawing: end stops, group marker carrier with the strip name, one row
of wire entries per level, bridge channels with red bridges, marker
tags with the terminal number, type symbol (fuse, LED, diode,
sectional, ground...), green/yellow PE and terminal color.
The style is chosen for each plan in the "Add terminal strip plan"
dialog and saved with the item (attribute draw_style of
terminal_strip_item). Items saved before keep the schematic style.
Several plans of the same strip can use different styles.
The values used by the realistic styles are in the layout (scale in
px/mm, terminal length in mm, end stops, group marker) and can be set
in the layout editor, whose style combo box only changes the preview.
Separator plates: a new "Separator plate after" check box column in
the terminal strip editor, stored in the terminal element informations
(key terminal_separator). Drawn as a plate at scale in the realistic
styles and as a thick line in the schematic style.
New files: realisticterminalstripdrawer.{h,cpp} (realistic drawing) and
terminalstripdrawerhelpers.h (helpers shared with the schematic drawer).
GeneralConfigurationPage::applyConf() wrote qet-assistant.json before it
applied the collection and title block paths, so the file kept the paths
the dialog had just replaced. The qet MCP server hands the user and
company collections from that file to every headless run it starts: a
company collection chosen in the dialog stayed unknown there, and
company:// paths failed to resolve until QElectroTech was started again.
Write the file last. tst_assistantinfopaths picks a new company and user
collection on the page, applies it and reads the file back; it runs in
QStandardPaths test mode from main() on, since QETApp writes the file to
the standard data folder as its editor opens, whatever --data-dir says.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XCdg2Er2vYhB8Vwa5G7fSR
Every marker tag of a strip has the length of its longest terminal
number (at least two digits), so three-digit numbers are no longer
reduced. The font is still reduced if a number doesn't fit the text
zone of the layout.
- The xref is drawn as plain text, without marker tag.
- Every marker tag of the terminal numbers has the same length
(two digits), the font is reduced when the number is longer.
- The body of every terminal reserves the zone of the longest xref
of the strip, so all terminals of a strip are drawn the same way.
- Draw only the terminal number (part after the last ':'), the strip
name is already in the header; the manager and the tree still show
the full label.
- Clip texts to the width of their terminal and shrink the font in
narrow terminals so they no longer overlap.
- New housing: body with narrower wire entry caps and a slope between
them, grey by default (terminals with a color or a ground type keep
theirs); stripes are clipped to this shape.
- White marker tags behind the number and the cross reference, fitted
to the text and its alignment; black ribbed tags for isolating
(sectional) terminals.
- Clamping parts (screw in a window, castle tab, big center circle, tab,
screw) drawn in the free zone between the two tags, whatever the
layout; fewer parts if the zone is small.
- Remove the wire entry mark that overlapped the cross reference text.
- Draw only the terminal number (the part after the last ':') instead of
the full label (e.g. "1" instead of "XAUZ1:1"); the terminal manager
and tree still show the full label.
- Clip each text to the width of its terminal and shrink the font in
narrow terminals so that texts no longer overlap their neighbours.
- Remove the wire entry mark in the lower part of the terminal body,
which overlapped the cross reference text.
Extend the terminal catalog (terminalcatalog.h) with the widths of
more terminal blocks, from the catalogs of Phoenix Contact (UT),
Weidmuller (WDU), WAGO (TOPJOB S 2002/2006), ABB Entrelec (M series)
and Legrand (Viking 3). For Legrand and ABB Entrelec the value is the
pitch given by the catalog.
Each entry now carries its manufacturer, and the element properties
dialog gets a "Manufacturer" combo box before the width one. The list
of widths is filtered by manufacturer and by the selected section; if
the manufacturer has nothing for that section, all its terminals are
listed. The combo box is created by the code, no change in the .ui.
The section combo boxes of the terminal strip editor and of the
element editor now show the nearest AWG / kcmil size next to the
section in mm2, for example "2.5 mm² (AWG 14)".
The correspondence is approximate (the two systems don't have the
same steps) and is stored in TerminalCatalog::awgForSection().
The stored value is unchanged (terminal_section, in mm2).
Terminal block elements can now define, in the element properties
dialog (Terminal block element group):
- the section in mm2 (combo box with the usual sections, editable)
- the width / manufacturer reference (Weidmuller, Phoenix Contact,
WAGO, ABB Entrelec), filtered by the selected section
- the color, using the palette of the terminal strip (editable,
accepts a name, #rrggbb or #rrggbb/#rrggbb)
Values are stored in the element informations (terminal_section,
terminal_width, terminal_color) with the dot as decimal separator,
the keys already read by RealTerminal. An empty value removes the key.
The sections and the manufacturer widths move from
terminalstripmodel.cpp to the new header-only terminalcatalog.h,
shared by the terminal strip editor and the element editor.
Conflicts: the lock dialog and the recovery dialog both kept in
qetdiagrameditor.cpp; the lock and previous-versions settings both kept.
The spacer in the General settings page moves to row 9, below the lock
checkbox, which the merge had left on the same row.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgPFANhWgD66V3v9KVKV58
Two people opening the same project on a shared drive could both edit it,
and the last save silently wiped the other's work. A project now has a
hidden lock file beside it (.~plant.qetz.lock, small JSON: user, host,
process, version, since, heartbeat). Opening a project another live session
holds shows who has it, since when and how long ago they were last active,
and offers Open read-only (default), Open anyway, or Cancel.
- The lock is made with an exclusive create, refreshed every minute and
deleted when the project closes. It is not a QLockFile: that treats any
lock from another machine as stale after 30 s. Here only a lock made on
this machine by a process that no longer exists is taken silently.
- A folder the lock cannot be made in, or the setting turned off
(Settings > General > Projects), opens as before.
- Save As and .qet saved as .qetz move the lock to the new file, and refuse
a file someone else has open. A project opened read-only for another's
lock does not save over that file.
- --resave and --set-titleblock refuse a file someone has open unless given
--ignore-lock; the read-only verbs never look at locks and headless runs
never take one. A script's qet.save() refuses the same way.
Checked: 17 new unit tests (5 go red when stale detection and the exclusive
create are broken), full ctest 107/107, the dialog and both choices in the
GUI under Xvfb, and --resave refused/allowed from the command line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A report symbol's right-aligned text (the function of its partner's
wire) was saved at another x than with every folio built after deleting
a coil on its folio: moved left by its own width. Cosmetic, nothing lost.
setPlainText() keeps a right-aligned text's right edge, except while the
project is being opened (ProjectParsingRunning) or the symbol is built.
With lazy folios the text is emptied outside that state (the partner's
folio is not built, so no wire is watched) and the edge is kept, then
filled again while the late folios are built, which holds the project in
the opening state: the edge was not kept, and x stayed where emptying
moved it. With every folio built both happen during the opening.
A text emptied with its alignment kept is now filled again the same way,
whatever the project state; a text set while the project is opened and
never emptied that way is untouched, so what a plain opening writes does
not change.
Evidence: forum f1537 (resaved with every folio built), deleting coil
{794d8944} on folio 9 saved x=11.7969 lazily, x=60 eagerly; both 60
now. Resave of the 342 corpus projects, lazy and eager, identical to
before. tst_reporttextlatefolio fails without the fix (x=28.6875 lazily).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Saving replaced the project file whatever had happened to it since it was
opened. If another user, another program or a second QET had saved the same
file in between (a shared drive, a copy synced from another PC), their work
was lost without a word.
QETProject now remembers the project file's size and modification time when
it reads it and after each save of its own. Before writing, write() compares
them with the file on disk and, if they differ, writes nothing and returns
"someone else changed the file on disk at <time> since it was opened".
- Interactive save (Ctrl+S, and the save on closing a project) asks:
"Someone else changed <file> on disk at <time> since you opened it" with
Save as a copy (opens Save As), Overwrite, or Cancel.
- Scripts (--run), the command line, MCP and the autosave timer have no one
to ask, so they keep the refusal (autosave logs it). Setting
QET_OVERWRITE_CHANGED=1 makes a script overwrite on purpose.
- A file that does not exist yet, and a Save As to another name, are not a
conflict; after Save As the remembered state is the new file's.
No change to what is written, so .qet and .qetz stay as they were.
Evidence: new tst_changedondisk (unchanged file saves twice; a file written
behind the open project is refused; the override overwrites; a Save As
target is no conflict) fails on the changed-file case without the source
change and passes with it. Full ctest 107/107. Physical check with
qet-gui-dialog.sh: file modified behind the open project, Ctrl+S shows the
three-button dialog.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgPFANhWgD66V3v9KVKV58
With a .qetz and lazy folios (both the default), changing only the
title, author, date or revision of a folio holding a picture, while that
folio was not displayed, then saving, gave a .qetz that refused to open:
'picture "images/<sha>.png" is missing'.
Such a folio is saved from its items as read, with its new header
(Diagram::deferredXmlWithHeader()). Read from a .qetz, its <image> names
its picture's file (qetz-file) instead of holding the picture, and
QetContainer::split() takes out only pictures held inline: the file was
never written, while the folio still named it. The folio now gets its
pictures written in, from the bytes the project keeps
(QETProject::containerPicture()), as a built folio writes them.
On master, a picture on folio 2 of grafcet, iso_sfc_example, 741,
tremie_vibrante or Projet_vierge is lost this way; found on three forum
projects by comparing lazy saves with saves of every folio built. Saving
as .qet was not affected.
New tst_qetzheaderpictures (grafcet.qet: picture on folio 2, saved as
.qetz; its title changed with folios built when needed; reopened with
its picture and title): fails without this change ("picture ... is
missing"). ctest 107/107.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A project file that could not be read showed "The file ... does not
appear to be a QElectroTech project file", for a .qetz missing one
picture as for a picture file renamed .qet: wrong, and no way out.
Now the message says what is damaged (QETProject::openError()) and
offers, when there are any:
- the newest previous version kept beside it (PreviousVersions);
- for a .qetz, what can be read of it (QetContainer::recover()), then a
list of what was left out.
Either opens as a new project, "<name> (recovered)" beside the file,
numbered if that is taken, not saved yet. The damaged file is never
moved or written.
Checked in the program (scripts/qet-gui-dialog.sh, File > Open on a
.qetz whose picture is missing, a previous version beside it): the
dialog says which picture; "Recover what can be read" opens plant
(recovered).qetz without the picture, saved with Ctrl+S it opens again;
"Open the version saved ..." opens plant (recovered 2).qetz with its
picture; plant.qetz unchanged (same sha256). ctest 107/107.
Stacked on "Keep the previous versions of a project when saving it" and
"Add recovering what can be read of a damaged .qetz".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A .qetz that does not open as it is -- a picture's file missing (as
the title-change fault wrote them), a folio file missing, a row deleted
from project.sqlite by hand (issue #1458), a damaged database -- was
refused whole, and nothing in QElectroTech could get anything back.
QetContainer::recover() reads what it can and says what it left out:
an entry that cannot be unpacked, a missing folio file or picture, a
symbol or wire that does not match the database, and, when the
database cannot be read at all, the information it held (the wires go
too, as it holds their ends). join(), restore() and QetZip::fromBytes()
take an optional list for this; given none they refuse as before.
`qelectrotech --recover damaged.qetz out.qet` writes what was read to a
.qet and lists what was left out.
Measured on industrial (50 folios, 618 symbols, 671 wires): a wire row
deleted -> 670 wires, the rest whole; a folio file missing -> 49
folios; a database page overwritten -> every folio and symbol drawn, no
wires and no symbol information. A zip cut short (no central directory)
still cannot be read.
Tests: recoverLeavesOutOnlyTheDamage (nothing, folio file, wire row,
database page), recoverMissingPicture, recoverNotAZipRefused; they fail
with the database side kept strict. The strict cases pass unchanged.
ctest 106/106.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Saving replaced the project file with nothing kept of what it was: one
bad save -- a fault in QElectroTech, a full disk, a wrong edit saved by
mistake -- and the last good version was gone. AutoCAD keeps a .bak on
every save, KiCad several backups.
Before a project is saved over, the file as it was is now copied to a
folder beside it, "<name>-backups", named by the time that version was
saved ("industrial 2026-10-16 08-30-12.qetz"); only the newest are kept,
5 by default (Settings > General > Projects, 0 for none). A copy is
never a move, so a save that fails leaves the file in place; a copy that
fails only warns, the save goes on. The same bytes as the newest version
kept are not kept twice.
The prompt on opening ("Create a backup copy?"), which writes one dated
copy beside the project and never removes any, is left as it is.
New tst_previousversions (newest kept, same bytes once, same second,
nothing for a new file or a count of 0, a failed copy said); it fails
with the oldest never removed. Checked with the program: two scripted
saves of a .qetz leave the original and the first save in
industrial-backups, the project holds the second. ctest 107/107.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A folio report's label shows where its partner is (updateReportText()).
Changing any other information of the report -- a comment, a function --
ran elementInfoChanged(), which set the text from the label stored in
the report's information instead: the partner's position when the file
was last saved. Move the partner from column 4 to column 9, give the
report a comment, and it showed (and saved) 1-C4 again.
With lazy folios the partner is often built after the change, so the
report was recomputed and the two modes saved different files; that is
how this was found (FINDINGS F116, tools/lazy-folios/edit-diff.py).
New tst_reporttextafterinfo: move the partner, comment the report, save,
check the report says 1-C9, with folios built when needed and with every
folio built. Both rows fail without this change ("1-C4").
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A .qetz's project.sqlite comes from a file, which may be damaged, made
by another tool, edited by hand (issue #1458) or made to harm. SQLite's
advice for such files (https://www.sqlite.org/security.html) was only
partly followed: trusted_schema was off and every name had to be a plain
table. readAsUntrusted() now also turns on the defensive mode, turns off
triggers and views, checks each cell's size as its page is read, limits
a value to 256 MiB, and runs quick_check before anything is read, for
the database read on opening and for the one a save copies kept folios
from.
A damaged database is now refused as "the project database is damaged",
where it was "cannot be read: database disk image is malformed" or a
missing row. Damage on disk was already caught before SQLite: the zip
checks each entry (one flipped bit in project.sqlite: refused, "unexpected
decompressed size"). quick_check does not see a changed value inside a
row (49 of 400 random small changes read silently, quick_check "ok"):
only the zip's checksum does.
New test damagedDatabaseRefused (a page overwritten at three places):
fails without this change on the message. Opening nuri (136 folios):
3.56-3.66 s with and without. ctest: the 7 container, save and lazy
folio suites pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
QSaveFile writes a new file, forces it to disk and renames it over the
old one, but does not force the folder to disk. On ext4 or XFS a power
cut soon after a save can then lose the rename: the previous file comes
back, or none. QET::syncFolderOf() now syncs the folder after every
project, symbol and title block save (QET::writeXmlText()) and every
.qetz save (QetZip::write()). Nothing to do on Windows.
--resave and --set-titleblock wrote a .qet straight over the output file
(QFile, truncate, write): a run stopped part-way left half a project.
They now write through QET::writeXmlText() like the GUI's save. On
Windows their .qet now has LF line ends, as the GUI writes.
Checked with strace on --resave: fdatasync of the new file, linkat into
place, fsync of the folder. The .qet written is byte-identical to before.
ctest 106/106.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Implement ElementTextFormatPattern to capture and reapply dynamic element text formats (font, color, alignment, rotation, frame, positions, groups) across elements.
- When applying a format, missing ElementInfo texts present on the source template are added to the target element with the template's layout and styling, matching other texts in order.
- Register actions under Edit menu, diagram context menu, and ShortcutManager (customizable via Toolbar settings).
- Bind default shortcuts Ctrl+Alt+C (copy text format) and Ctrl+Alt+V (apply text format).
- Provide full undo/redo support via ApplyElementTextFormatCommand.
- Add SVG icons format-copy and format-apply.
- Include English source strings and German/French translations.
The reader of project.sqlite knew two formats at once: every query asked
the database which column names to use (v2() ? "folio_id" : "folio_uuid"
and so on), and each new format would have added another branch to every
query. Asked in discussion #1440.
An older database is now brought up a format at a time when a .qetz opens
(readyToRead()): format 2 to 3 renames its tables' columns, adds the show
columns and puts the symbols' and wires' uuids and the symbols' type back
in the folio files; 3 to 4 has nothing to change for reading. The reader
then knows only the current format. The next format adds one step.
Checked: the format 2 test file (tests/qttest/fixtures/qetz_format2.qetz)
resaved as .qet and as .qetz gives the same files as master. format2Read
now also checks that every symbol and wire gets its uuid and type back:
it failed with the type left out of the step, which the test did not see
before. ctest 106/106.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first pass refused a pair whenever a saved terminal had two live
candidates in reach. That happens when a whole drawing moves by half the
spacing of its terminals (pins 10 apart moved 5), and when two terminals
share a place. When one offset fits every unmatched terminal, as when a
whole drawing moved, that offset now settles all pairs; terminals sharing
a place pair in order.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A project saves, for every placed symbol, where each terminal docked, its
orientation and its number. When the symbol's definition is replaced by one
whose terminal sits a few units elsewhere, wires were matched by exact
position and so were dropped on the next open: legacy wires by Terminal::
fromXml(), uuid wires by the uuid derived from the terminal's new place.
Element::fromXml() now pairs each saved terminal that nothing matched
exactly with the live terminal of the same orientation whose docking point
is within 5 units in x and y, only when each is the other's only candidate.
A pair registers the saved number, and the uuid derived from the old place,
which Diagram::findTerminals() checks after the derived uuid of the current
place. Wire ends reattached this way are counted and logged once per folio.
tst_movedterminals covers a move of 3 and 5 units (number and derived
uuid), 6 units, two candidates, and an unmoved terminal.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#1346 rebuilt the Edit menu from a list of named actions, written before
#1354 added Horizontal mirror and Vertical mirror. The two actions ended up
in no menu and on no toolbar, so their shortcuts (M, F) never fired; only
the right-click menu reached them. Add both to the Edit menu after
"Choose texts orientation".
Also correct two labels left behind by later changes:
- Settings > General: the fine-movement step is now used with Ctrl, not
Alt (#1395).
- Terminal editor tooltip: the checkbox is now called "Function
separation" (#1456).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every other QElectroTech file type is application/x-qet-*; the zipped
project was application/x-qelectrotech-project+zip. No release has
shipped it yet, so it is renamed now.
Files written with the old name still open: the reader accepts both, and
the shared-mime-info file keeps the old name as an alias and matches
either on content.
Review of #1472 (scorpio810, point 2).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A terminal strip saved with a terminal whose symbol is gone left that
terminal out without a word. It still does, and says so in the log.
The same happens with every folio built: the database is filled from
the document, so a symbol in the file is never missing from it.
Review of #1472 (scorpio810, point 8).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
File > Open and Import numbering from a project each had their own
filter for .qet and .qetz, worded differently. Both use
QETProject::openFileFilter() now.
Review of #1472 (scorpio810, point 11).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Saving a project opened from a .qet writes a .qetz beside it. In a
read-only folder that write fails, but the project had already been
switched to the .qetz path: the window then named a file that does not
exist, and the error said only "Permission denied".
The project now stays the .qet it was when the write fails, and the error
names the file: "Cannot save /path/name.qetz: Permission denied". Save as
still saves it elsewhere.
Found answering the review of #1472 (scorpio810, point 12).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>