Commit Graph

9468 Commits

Author SHA1 Message Date
Laurent Trinques d889a384aa Revert "fix: harden project database export" 2026-09-23 13:50:49 +02:00
Laurent Trinques ed0f800ab5 Update linux-build.yml
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 2m20s
2026-09-23 06:40:53 +02:00
Laurent Trinques 6d8459d647 Merge pull request #983 from ispyisail/fix-sql-readonly-cte-bypass
Enforce read-only SQL with SQLite, not a first-word check
2026-09-23 03:22:00 +02:00
ispyisail 28acbadaf0 Merge remote-tracking branch 'upstream/master' into pr983-rebase-check
# Conflicts:
#	sources/dataBase/projectdatabase.cpp
#	tests/qttest/CMakeLists.txt
2026-09-23 13:16:48 +12:00
Laurent Trinques 4456d08b91 Merge pull request #988 from elevatormind/chore/remove-obsolete-google-tests
Chore/remove obsolete google tests
2026-09-23 02:55:16 +02:00
Laurent Trinques aae7a8bde3 Merge pull request #980 from ispyisail/scripting-api-feature-complete
Scripting API: make it feature-complete, sync misc/qet-mcp
2026-09-23 02:47:25 +02:00
Laurent Trinques 4bcf5834e4 Merge pull request #984 from ispyisail/feature/scripting-opt-in
Scripting off by default, with a prompt and a setting to turn it on
2026-09-23 02:40:18 +02:00
Laurent Trinques f6889af99c Merge pull request #987 from elevatormind/fix/tests-pugixml-link
fix(tests): link pugixml to crash dump test
2026-09-23 02:34:54 +02:00
Laurent Trinques 9e5af6440b Merge pull request #989 from ispyisail/fix/titleblock-unset-variable-bug973
Fix bugtracker #973: unset title-block custom variable shows its own name
2026-09-23 02:33:56 +02:00
Laurent Trinques b97bccecd3 Merge pull request #990 from ispyisail/fix/diagramcontext-trims-whitespace-value
Preserve an all-whitespace context value through save and reload
2026-09-23 02:32:09 +02:00
Laurent Trinques 14cf3e403f Merge pull request #982 from Kellermorph/fix-copy
Fix copy
2026-09-23 02:22:11 +02:00
Laurent Trinques 1f0ed43006 Merge pull request #985 from ispyisail/fix/query-row-cap
A query stored in a project file can hang QElectroTech for ever
2026-09-23 02:19:28 +02:00
Laurent Trinques fc2a200ca2 Merge pull request #993 from ispyisail/feature/duplicate-offset-dialog-991
Add Ctrl+D: duplicate the selection, offset by a configured grid step
2026-09-23 02:17:36 +02:00
Laurent Trinques ccf96e0537 Merge pull request #986 from elevatormind/no-sqlite
fix: harden project database export
2026-09-23 02:15:29 +02:00
ispyisail fa213d90d9 Add Ctrl+D: duplicate the selection, offset by a configured grid step (#991)
Split from #913's second suggestion. There was no shortcut for the common
"duplicate with offset" convention; the nearest existing feature,
"Collage multiple", is a different workflow (a dialog for repeating a
paste in a grid pattern, not a one-shot duplicate).

Ctrl+D copies the selection and places it immediately, offset by a
configured spacing and direction -- no interactive follow-the-cursor
step, unlike Ctrl+V. The first press (or after the setting is explicitly
reopened) shows DuplicateOffsetDialog: spacing in grid steps, direction
up/down/left/right. Every later press reuses whatever was confirmed then,
silently, so a row of copies is one key held down and tapped, not a
dialog every time -- unattended, repeatable stamping is the actual point
of a duplicate shortcut, which a dialog or an interactive placement step
on every press would defeat. A separate "Configurer la duplication..."
entry reopens the dialog on demand to change the setting later. Cancel
leaves the diagram untouched -- verified, not assumed: qet_diff against
the saved file shows 0 added.

Chaining ("keep tapping to lay out a row") needs no special handling:
QET already reselects whatever a paste just added
(PasteDiagramCommand::redo()), so the next Ctrl+D naturally continues
from the copy just placed rather than the original.

The offset is applied by hand rather than by asking paste()/fromXml() to
place the copy at a target position. Both of those feed the position
through Diagram::snapToGrid(), which reads
QApplication::keyboardModifiers() and rounds to the nearest PIXEL instead
of the grid whenever Ctrl is held -- and Ctrl is always held here, this
action's own shortcut being Ctrl+D. Measured before settling on this:
routing the offset through paste() first produced copies off-grid on both
axes, by an amount that tracked the selection's own bounding-box geometry
rather than being a fixed error -- caught by qet-mcp's qet_elements
against the saved file, not by eye. fromXml() is instead called with no
position argument at all (leaves every item at its source coordinates,
landing the copy on top of the originals -- (0,0) is not a position, this
is "keep the source coordinates"), and the offset is added directly with
setPos(). A plain addition cannot be off by a rounding rule that never
runs.

Conductors are not in the hand-translated set: fromXml() itself does not
reposition them either -- they load after elements are already in their
final place and take their geometry from their terminals, which have
already moved with the elements that own them. Verified this holds: drew
a conductor by hand between two elements (drag, not click-click),
selected both, Ctrl+D, and the new conductor correctly joins the two new
elements via qet_conductors -- not the originals, not a mix.

Verified end-to-end on a built binary via qet-mcp, not by eye:

  before          L2 (303,207)  L9 (512,196)     -- deliberately off-grid
  spacing=2, down    (303,227)     (512,216)      -- +0,+20 exactly
  same again, 2nd    (303,247)     (512,236)      -- +0,+20 again, chained

Both elements land exactly the configured offset from their immediate
source regardless of the selection's own alignment. Qt 6.10.2, ctest
11/11.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 11:30:30 +12:00
ispyisail 84add7b3ef Preserve an all-whitespace context value through save and reload
A property value that is entirely whitespace -- reported in #973 as a
workaround (setting a title-block custom variable to a single space, the
only way to give it a value other than blank before that bug was fixed in
#989) -- did not survive a save/reload cycle. Two independent causes, both
needed for the round trip to actually work:

1. DiagramContext::toXml() called .trimmed() on every stored value before
   writing it, unconditionally. For ordinary content this only strips
   accidental leading/trailing whitespace, but for a value that IS
   whitespace it collapses the entire thing to "", indistinguishable from
   a value that was never set.

2. QDomDocument::setContent(), used to parse the project file, discards a
   text node that is entirely whitespace by default. Confirmed in
   isolation, outside any QET code: parsing "<a> </a>" with the default
   ParseOptions gives QDomElement::text() == ""; adding
   ParseOption::PreserveSpacingOnlyNodes gives " ". So even once (1) stops
   destroying the value on save, the very next load throws it away again.

Fix (1) only trims when the trimmed result isn't empty, i.e. leaves an
all-whitespace value untouched. Fix (2) adds PreserveSpacingOnlyNodes to
the one setContent() call that parses a project file
(QETProject::readProjectXml()) -- not the other ~19 call sites in the
codebase (clipboard paste, element/macro loading, translations, autonum
context), which read different, narrower documents and are not implicated
in this report.

Blast radius of (2): every place that walks a QDomNode's children already
filters on isElement() (see QET::findInDomElement()), so the extra
whitespace-only text-node siblings this keeps around are inert wherever
current code already expected only elements. The one place it isn't inert
is exactly the bug -- calling .text() on an element whose entire content
is whitespace.

Verified end-to-end, not just at one stage: a single-space title-block
variable now survives two successive --resave cycles unchanged (confirmed
byte-for-byte in the saved XML), and renders as blank space rather than
literal placeholder text or a vanished value. Re-saved all 24 shipped
examples with and without this change and diffed: 23 byte-identical, the
one that differs (schema_indus.qet) differs only in element uuids -- and
resaving it twice with the SAME unpatched binary produces that same kind
of diff, confirming it is pre-existing non-determinism in files that
predate persisted uuids, unrelated to this change. Qt 6.10.2, ctest 11/11.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 09:20:31 +12:00
ispyisail 36fb048a80 Fix bugtracker #973: unset title-block custom variable shows its own name
BorderTitleBlock::updateDiagramContextForTitleBlock() skips merging a
page-level custom variable into the title block's render context whenever
its value is empty -- added by PR #572 to fix #531, where an empty
page-level value was shadowing a real project-level one of the same name.

But skipping the merge removes the key from the context entirely, and
TitleBlockTemplate::interpreteVariables() only replaces "%name"/"%{name}"
when "name" is an actual key in that context -- anything absent is left as
its own literal placeholder text. Folio Properties auto-adds every one of
a template's custom variables to the Custom tab with an empty value (#271/
#495) precisely so the user only has to fill in what's missing; until they
do, that variable now renders as e.g. "%label1" instead of blank.

Reproduced two ways: a synthetic fixture, and examples/2612_ats_singlephase.qet
itself, which already carries three such auto-added-but-unset properties
("label1", "label2", "label3") and renders all three literally on current
master.

Fix: skip the empty page-level value only when a project-level one already
exists to show through (preserving #531's guarantee); otherwise still merge
it in empty, so the placeholder resolves to blank rather than falling out
of the context altogether.

Verified against the shipped example (--export-png, before/after crop of
the rendered title block): "%label1"/"%label2"/"%label3" now blank. A
variable never added to the Custom tab at all ("%client", also present in
the same example) is unaffected -- nothing was ever configured for it, and
that is a separate, narrower case. Qt 6.10.2, ctest 11/11.

A related but distinct issue -- DiagramContext::toXml() trims a stored
value before saving, so an all-whitespace value is written as empty --
explains a second symptom from the same report (a single-space "workaround"
value vanishing after the project is reopened) but touches every
context-backed property, not just title blocks, and is left for a separate
fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 09:08:21 +12:00
Magnus Hellströmer a7ae17a1b4 chore: remove obsolete Google test projects 2026-09-22 20:34:40 +02:00
Magnus Hellströmer a1675d7dea fix(tests): link pugixml to crash dump test 2026-09-22 20:02:01 +02:00
Magnus Hellströmer 13a37e1f59 fix: harden project database export
Use a bound VACUUM INTO path and remove the stale SQLite
handle declaration. Fix shell continuations in Windows CI and
Debian installation instructions.
2026-09-22 19:05:05 +02:00
Laurent Trinques 6fc7e4a090 Merge pull request #965 from elevatormind/no-sqlite
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 1m54s
Remove SQLite library dependency while retaining project db export
2026-09-22 18:51:00 +02:00
ispyisail 08b83f23e3 qet-mcp: say what to do when QElectroTech refuses to run scripts
#984 switches JavaScript scripting off by default, and five tools here
drive QElectroTech through --run: qet_query, qet_continuity, qet_check,
qet_project_new, qet_edit. Against such a build they all stop working, and
what came back was exit code 3 and a paragraph of French naming a settings
dialog nobody driving an MCP server is looking at.

Nothing needed building to make them work again -- _run_qet() inherits its
environment, so QET_ENABLE_SCRIPTING=1 in the "env" block of the client's
own configuration already reaches QElectroTech. Verified both ways against
a #984 binary: without it qet_query returns ok=false exit=3, with it
ok=true and the rows.

So this is about saying so. The refusal is now recognised and answered with
an instruction the caller can act on, keyed on QElectroTech naming the
variable with exit 3 as a fallback for a future build that words it
differently. Two older hints fitted the same symptom and were overwriting
it -- "the binary never ran the script ... is it a build with --run
support?" sends the reader to check the one thing that is fine -- so both
now yield to whatever the launch already reported. qet_check builds its
answer fresh rather than layering onto the launch result, so it carries the
reason across explicitly; without that every check read "no result came
back", which is true and tells nobody why.

The server does not set the variable itself, on purpose. A switch a program
turns on for itself is not a switch: whoever configured this server and
pointed it at a QElectroTech binary made that choice, and their interactive
QElectroTech keeps whatever its own setting says. README says this, and the
registration example now shows the env block with both variables in it.

Six tests, faking subprocess.run so they cost no launch. Two are structural
rather than behavioural: one fails if either older hint goes back to
assigning over the specific one, the other reads which tools actually pass
script= to _run_qet and fails if the hint's list of them drifts. Both were
mutation-checked by reintroducing exactly those mistakes.

176 tests pass with QET_BINARY, QET_ELEMENTS, QET_EXAMPLES and
QET_ENABLE_SCRIPTING set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 04:35:46 +12:00
ispyisail 3fa5e0a475 Stop a query in a project file from hanging QElectroTech for ever
A <graphics_table>'s <query> is stored in the .qet and executed when the
project loads. SQLite produces rows lazily, so the cost of that query is
not bounded by anything the project contains -- it is bounded by how long
the loop reading the rows is willing to run. A recursive CTE takes one line
to make that forever:

  WITH RECURSIVE c(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM c) SELECT n ...

Put that in the <query> of any project's summary table and opening the file
pins a core at 100% and grows ProjectDBModel::m_record until memory runs
out. Measured on examples/industrial.qet with the query swapped, built from
master:

  clean     --export-bom   3.6 s, 396 rows, exit 0
  poisoned  --export-bom   killed at 90 s, still going, no output

No scripting, no MCP, no flag beyond an ordinary export. Opening the file in
the editor is the same code path.

QetScriptApi::query() has the identical loop, and the script engine's own
30 s interrupt does not reach it: that aborts JavaScript, and this is C++
inside a single call. Left alone it hung a --run for 45 s until the harness
killed it.

Both loops now stop at projectDataBase::MaxResultRows (100000) and say so.
That is a backstop, not a page size: the largest table in the shipped
examples is 396 rows, and a caller that reaches 100000 has been handed
something it should not run to completion. It is not silent either way --
the model logs the offending query text, and qet.query() sets queryError(),
so a truncated result is never mistaken for a complete one.

  clean     --export-bom   3.6 s, 396 rows, exit 0   (unchanged)
  poisoned  --export-bom   20.2 s, 396 rows, exit 0, warning names the query
  qet.query(recursive CTE) 3.8 s, 100000 rows, queryError() set

Reverting each cap restores the hang, so both checks discriminate.

Related to #983, which fixes a different flaw reachable through the same
stored query. Neither depends on the other.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 04:16:21 +12:00
ispyisail 8334a9a27f Scripting is off until asked for, and says how to turn it on
A script reaches the whole project and, through the export calls, the
filesystem. That is a capability most people installing an electrical CAD
program never asked for, and leaving it on by default hands it to them
anyway. So QET_HAS_SCRIPTING builds now ship with it switched off.

QetSettings::scriptingEnabled() is the single answer, read by all three
places that need it, with QET_ENABLE_SCRIPTING=1 overriding the stored
value. The override is not decoration: a CI job or a batch run has no
dialog to tick, and a machine whose HOME is created fresh for each run has
nowhere to keep the setting either. It beats a stored "false" on purpose,
so a box unticked once cannot lock a build server out of --run for good.
Only the exact value "1" counts.

  --run          refuses with exit 3 and a message naming both ways in.
  Projet > Exécuter un script...  asks once, and turns the setting on if
                 the answer is yes. Asking beats grey: a disabled menu
                 entry says something exists and nothing about how to have
                 it, and this is the pattern people already know from
                 macro security in office software.
  Configurer QElectroTech > Général > Projets  has the checkbox, for
                 turning it back off. While the environment forces
                 scripting on, the box is disabled and says why, and
                 applyConf() then leaves the stored value alone rather
                 than quietly overwriting it.

runOnProject() checks as well, after both callers have. It is the one
function that actually evaluates JavaScript, so it is the one place a
future caller cannot forget to ask; the callers check first only to give a
better answer than it can.

Verified on the built binary, all four states, with an isolated HOME:

  stored   env  result
  absent    -   refused, exit 3
  true      -   script runs, exit 0
  false     -   refused, exit 3
  false     1   script runs, exit 0

tst_scriptingsetting covers the same matrix hermetically, in its own
QSettings scope, and was mutation-checked: flipping the default to true
turns defaultsToOff() red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 04:06:33 +12:00
ispyisail 4a636b14e3 qet-mcp: confine tool paths to a workspace, and never clobber silently
Two security reviews of #980 landed on the same gap: every path in a tool
call is chosen by the model, and nothing checked where those paths pointed.
That made the server a read/write primitive for anything the process could
reach -- read any project on the disk, export one somewhere else, overwrite
an unrelated file, embed an arbitrary local image or PDF. The sandboxed HOME
each QElectroTech launch gets isolates settings, not the filesystem.

Data paths are now confined to a workspace: QET_MCP_WORKSPACE (os.pathsep
separated), defaulting to the directory the server was started in, which is
what an MCP host normally sets anyway. QET_MCP_ALLOW_ANY_PATH=1 turns the
check off; it exists so that is a visible choice rather than the default.
Paths are resolved before comparison, so a symlink planted inside the
workspace is judged by where it points -- the case a string-prefix check
gets wrong.

Two arguments are deliberately exempt: "binary" and "elements_dir". Those
are configuration, chosen once by whoever runs the server, and both normally
live in /usr or a build tree. Confining them would reject the ordinary case
while stopping nothing -- they are not where a model gets to point the
server at /etc.

Enforcement sits at the dispatcher, where model-supplied arguments enter,
not inside each tool. Importing the module and calling tool_export() from
Python stays unconfined and is meant to: that is the caller's own code with
the caller's own paths.

Separately, an existing "output" is now refused unless the call passes
"overwrite": true. qet_project_new already worked this way; qet_export,
qet_edit and qet_element_build now match it. Replacing a file is the one
step this server cannot undo.

17 tests cover it, including the symlink escape, the traversal, the
overwrite gate and the operation-level file paths that add_image and
add_pdf_page carry one level down. Two of them compare the policy table
against the tool schemas, because a write tool missing from either list
fails silently in opposite directions. Two more drive a real server process
over stdio, which is the only thing that shows a call is gated rather than
merely gate-able. Mutation-checked: removing the confinement fails 8, and
desynchronising the two lists fails the drift pair.

170 tests pass, with QET_BINARY, QET_ELEMENTS and QET_EXAMPLES set so none
are skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 03:36:29 +12:00
ispyisail f777be05b4 Enforce read-only SQL with SQLite, not with a first-word check
projectDataBase::isReadOnlySelect() decides whether a query only reads
by looking at its first keyword and rejecting internal semicolons.
SQLite has allowed a CTE prefix in front of a data-modifying statement
since 3.8.3, so

    WITH x AS (SELECT 1) DELETE FROM element

begins with WITH, contains no semicolon, passes the check, and deletes
every row. UPDATE and INSERT go through the same way.

This is not only reachable from the custom-query box. ProjectDBModel::
fromXml() reads a <graphics_table>'s saved <query> straight out of the
.qet and fillValue() executes it, so a project file can carry the
statement. Reproduced against a build of this branch's parent, with no
scripting and no CLI flag beyond the export itself: a project whose
stored table query was replaced with the DELETE above exported a bill
of materials of 0 rows instead of 14, exit code 0, nothing logged. A
silently empty or -- with UPDATE -- silently altered BOM is the kind of
output someone orders parts from.

Fixed by asking SQLite about the statement it actually compiled.
sqlite3_prepare_v2() compiles without running, sqlite3_stmt_readonly()
reports on the compiled statement rather than on how it was spelled,
and the prepare tail catches a second statement structurally. The same
project now exports its 14 rows again and logs a reason for the
refusal, while an ordinary WITH ... SELECT in a project file still runs
untouched -- the fix is not "ban CTEs".

isReadOnlySelect() stays in front of it rather than being replaced:
SQLite considers ATTACH, BEGIN and several PRAGMAs read-only too, since
none of them change the contents of the database, so dropping the
statement-type allowlist would have widened what is accepted while
fixing what is executed.

The check lives in its own translation unit depending on nothing but
QString and SQLite, so tests/qttest/tst_sqlreadonly.cpp can link it
alone and exercise the security property without standing up a
QETProject: 18 assertions covering the three CTE-prefixed writes named
in the review, bare writes, trailing statements, comment-only input
(which compiles to a null statement sqlite3_stmt_readonly() must not be
handed) and a null connection (refused, not waved through). Confirmed
the suite discriminates by deliberately disabling the new check and
watching exactly the nine write-refusal assertions go red while the
accept cases stayed green.

ctest 13/13, qet-coherence-check and qet-pdflink-check clean on the
example corpus.

Reported in PR #980's review thread by @elevatormind and confirmed
against this code by @scorpio810; fixed here on its own because the
flaw is in already-released code and needs none of that branch to
reach.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 03:23:40 +12:00
Kellermorph 8ee3f90047 Fix multi-second Ctrl+V stall and cursor jump on paste
Two issues on the interactive paste path:

1. Stall: DiagramEventAddPaste's constructor called Diagram::fromXml()
   with no database batching, so every addItem() emitted dataBaseUpdated()
   and each connected table model re-ran its full SQL query. A typical
   paste (~40 elements + ~40 conductors) triggered ~77 rebuilds of the
   table models -- measured at ~2.1 s of pure fromXml time on a large
   project. Project loading already batches this via
   setUpdateBlocked()/blockSignals() (QETProject::readProjectXml); the
   paste path now does the same: block during fromXml, one updateDB()
   after. Measured fromXml: 2114 ms -> 143 ms.

2. Cursor jump: m_initial_cursor was set to the group origin but the
   physical cursor stayed at the Ctrl+V press location, so the first
   mouseMoveEvent computed a large delta and the items jumped on first
   touch. Warp the cursor to the group origin after placement so the
   baseline and the actual cursor position match.
2026-09-22 15:38:22 +02:00
Kellermorph 41e1e8172c Keep paste at original XML position instead of moving to cursor
Commit dd0c194a3 (#913) moved the pasted group to the cursor position
at construction time. The desired behaviour is that items appear at
their original XML coordinates (where they were copied from) so the
user starts from the origin. The grid-snapped movement baseline and
the context-menu restoration from that commit are kept.
2026-09-22 15:03:10 +02:00
Laurent Trinques 1212f48c6d Merge pull request #979 from Kellermorph/colour-programm
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 1m48s
Add custom application color picker in global settings
2026-09-22 13:49:42 +02:00
Laurent Trinques e01f46c5b0 Merge pull request #981 from ispyisail/fix-974-report-link-colour
Fix report-link colour/style mismatch detection (#974)
2026-09-22 13:38:40 +02:00
ispyisail 3cec02b3f3 Fix report-link colour/style mismatch detection (bugtracker #974)
LinkElementCommand::redo() already had a check meant to catch exactly
this -- two report-linked conductors whose properties disagree -- and
ask the user which to keep via PotentialSelectorDialog. It never
worked: it built ONE combined list from three unrelated fields
(tension_protocol, wire_color, wire_section) and tested that whole
list for string equality, so a tension-protocol value could never
equal a wire-colour value even when every field individually matched
across every conductor. Worse, "wire_color"/"wire_section" are
ConductorProperties::m_wire_color/m_wire_section, a separate free-text
documentation pair that says nothing about how the wire is actually
drawn -- that's "color"/"style" -- so the one field #974 is actually
about was never compared at all.

Fixed by comparing each relevant field (text/num, function, tension
protocol, colour, line style) separately. Downloaded the reporter's
actual project, confirmed the mismatched wire reads color="#0000ff" on
one side of a "Folio suivant"/"Folio precedent" link and
color="#55aa00" on the other, with the link's other four conductors
matching correctly (ruling out a rendering artifact) -- see PR #980's
checkContinuity() extension, which now flags this class of mismatch on
sight.

Extracted the comparison into its own static
reportLinkNeedsPotentialChoice(), for the same reason
ConductorCreator::needsPotentialChoice() already exists as its own
method: a caller with nobody there to answer a modal dialog needs to
check first and decline, and the condition must not drift away from
the one redo() actually applies.

Fixing the comparison surfaced a real, previously-latent hang in this
session's own qet.linkElements(): PotentialSelectorDialog::exec() is a
plain QDialog::exec(), not routed through QET::QetMessageBox, so
headless --run has nobody to answer it. Measured directly -- hung
until killed with the property-comparison fix alone, clean refusal
after adding the guard. linkElements() now calls
reportLinkNeedsPotentialChoice() before constructing the command and
declines with a clear reason, the same choice addConductor() already
makes about ConductorCreator's own equivalent dialog.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 23:00:05 +12:00
ispyisail a2e384761c Sync misc/qet-mcp: report-link tests updated for the #974 fix
link_elements() now correctly refuses a mismatched report link instead
of allowing it (see the two preceding commits), so the
report_link_mismatch reproduction can no longer be built by linking
two already-differently-coloured conductors -- that path is refused
before it happens. Updated to patch a saved file's XML directly
instead (the same technique test_continuity_detects_a_tampered_
potential already uses), since a file QElectroTech's own edits produce
can no longer end up in this state at all. Adds
test_link_elements_refuses_a_mismatched_report_link to cover the
refusal itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 22:52:58 +12:00
ispyisail 4283ddad10 linkElements: refuse a report link that would pop a modal dialog
Fixing LinkElementCommand's property comparison (previous commit)
means it now correctly detects a report-link colour/style mismatch --
which means it now correctly pops PotentialSelectorDialog for one,
same as the GUI. Under headless --run there is nobody to answer a
plain QDialog::exec(), so this hangs forever; confirmed directly with
a timeout before adding this guard.

qet.linkElements() now checks LinkElementCommand::
reportLinkNeedsPotentialChoice() before constructing the command and
declines with a clear reason pointing at checkContinuity() and
setConductorProperty(), the same choice addConductor() already makes
about ConductorCreator's own equivalent ambiguous-potential dialog.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 22:52:48 +12:00
ispyisail 1046b97080 Fix report-link colour/style mismatch detection (bugtracker #974)
LinkElementCommand::redo() already had a check meant to catch exactly
this -- two report-linked conductors whose properties disagree -- and
ask the user which to keep via PotentialSelectorDialog. It never
worked: it built ONE combined list from three unrelated fields
(tension_protocol, wire_color, wire_section) and tested that whole
list for string equality, so a tension-protocol value could never
equal a wire-colour value even when every field individually matched
across every conductor. Worse, "wire_color"/"wire_section" are
ConductorProperties::m_wire_color/m_wire_section, a separate free-text
documentation pair that says nothing about how the wire is actually
drawn -- that's "color"/"style" -- so the one field #974 is actually
about was never compared at all.

Fixed by comparing each relevant field (text/num, function, tension
protocol, colour, line style) separately. Downloaded the reporter's
actual project, confirmed the mismatched wire reads color="#0000ff" on
one side of a "Folio suivant"/"Folio precedent" link and
color="#55aa00" on the other, with the link's other four conductors
matching correctly (ruling out a rendering artifact) -- see PR #980's
checkContinuity() extension, which now flags this class of mismatch on
sight.

Extracted the comparison into its own static
reportLinkNeedsPotentialChoice(), for the same reason
ConductorCreator::needsPotentialChoice() already exists as its own
method: a caller with nobody there to answer a modal dialog needs to
check first and decline, and the condition must not drift away from
the one redo() actually applies.

Fixing the comparison surfaced a real, previously-latent hang in this
session's own qet.linkElements(): PotentialSelectorDialog::exec() is a
plain QDialog::exec(), not routed through QET::QetMessageBox, so
headless --run has nobody to answer it. Measured directly -- hung
until killed with the property-comparison fix alone, clean refusal
after adding the guard. linkElements() now calls
reportLinkNeedsPotentialChoice() before constructing the command and
declines with a clear reason, the same choice addConductor() already
makes about ConductorCreator's own equivalent dialog.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 22:52:38 +12:00
ispyisail a766f9a975 Sync misc/qet-mcp: report_link_mismatch check, NEXT_REPORT/PREVIOUS_REPORT fixtures
Brings misc/qet-mcp up to date with checkContinuity()'s new
report_link_mismatch finding: updated qet_continuity's description,
and two new tests reproducing #974 with the shipped 02going_arrow.elmt/
01coming_arrow.elmt pair (no custom fixtures needed -- this element
type already ships).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 22:37:43 +12:00
ispyisail 68503e2a7d checkContinuity: catch cross-folio report-link colour/style mismatches
New report_link_mismatch finding (severity "warning", not "error" the
way potential_mismatch is): a next_report/previous_report folio-jump
pair whose conductors disagree on colour, style, num, or any of the
other checked properties. Unlike potential_mismatch, this one is not
proof of external tampering -- LinkElementCommand::isLinkable() only
ever checks type and freedom (see its own doc comment), never conductor
properties, so nothing in QElectroTech copies one side's colour onto
the other when a report link is made or keeps them in sync afterwards.
This is a real, unenforced gap reachable through completely ordinary
use, not a defect a script or the GUI could introduce.

Reproduces qelectrotech/qelectrotech-source-mirror#974 exactly:
downloaded the reporter's actual project, traced the mismatched wire to
a "Folio suivant"/"Folio precedent" link pair, and confirmed via query
that the two sides read color="#0000ff" and color="#55aa00" while the
link's other four conductors (0V/Low/High/Ground) matched -- ruling out
a rendering artifact. Verified fresh with a synthetic reproduction
(tests in misc/qet-mcp) using the shipped 02going_arrow.elmt/
01coming_arrow.elmt pair, giving exactly one finding, not one per
folio-link conductor.

Also fixes a real gap in the existing potential_mismatch check while
here: checked_properties was missing "color" and "style" entirely,
checking only "conductor_color" (ConductorProperties::m_wire_color, a
separate free-text documentation field, typically empty) -- meaning
the same-folio version of this exact bug class would have gone
undetected too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 22:36:45 +12:00
ispyisail 3b05f5cf13 Sync misc/qet-mcp with the extended scripting API
Brings misc/qet-mcp up to date with the scripting API additions in
this branch: qet_edit gains ops for tables, PLC master IO tables and
PLC-slave linking, manual conductor segment routing, polygon and path
shapes, PDF page import, and project-wide search & replace; a new
qet_continuity tool exposes the electrical continuity/ERC-style checks.

Adds the test suite that did not exist here before (150 tests: unit
validation, JSON-RPC protocol, and Integration/PlcIntegration/
CorpusIntegration runs against a built binary) plus the two minimal
PLC fixture .elmt files it needs (no shipped element has masterType/
slaveType "plc" to test against).

Also removes a __pycache__/*.pyc that had been committed by mistake,
and ignores __pycache__/*.pyc going forward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 22:03:04 +12:00
ispyisail 2906a48aab Add electrical continuity checking to the scripting API
qet.checkContinuity(folioIndex) runs two structural checks against the
live Terminal/Conductor object graph -- Terminal::conductors() and
Conductor::relatedPotentialConductors(), the same primitive
setConductorProperty() already uses -- rather than a heuristic read of
the saved XML:

- unconnected_terminal (info): a terminal with no conductor at all.
  Deliberately low severity -- routine (a spare relay contact, an
  unused optional pin), not necessarily a mistake.
- potential_mismatch (error): two conductors electrically on the same
  potential (following bridged terminal strips and linked report
  elements, matching setConductorProperty()'s own scope) disagreeing
  on num/conductor_color/conductor_section/function/bus/cable.
  QElectroTech's own edits always keep every member of a potential
  identical, so any divergence found here came from hand-edited XML,
  a legacy file, or an external tool -- verified with a test that
  patches a saved file's XML directly to introduce exactly that.

Documented plainly what this does NOT check and why: pin electrical
direction/power conflicts and No/Nc/Common contact shorts, since
QElectroTech's terminal data model (Generic/Inner/Outer/No/Nc/Common --
contact role within one relay, not signal direction) does not carry
the information either would need. This is continuity/consistency
checking, not full ERC.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:31 +12:00
ispyisail a542b911e9 Add project-wide search & replace to the scripting API
qet.searchAndReplace(kind, field, pattern, replacement, useRegex,
caseSensitive) finds and replaces a substring or regular expression
within one text field across every folio, as a single undo step --
kind is element_info, conductor or text. Unlike a script loop over
elementInfo()/setElementInfo() (or the conductor/text equivalents)
doing the same thing one item at a time, each pushing its own undo
entry, this wraps the whole run in one macro.

This is deliberately NOT a wrapper around QET's own "Search and
replace" panel (SearchAndReplaceWorker): that one is a batch
overwrite-with-sentinel template built for picking items from an
interactive tree, a poor fit for a script that can already say
precisely which items it means. This does what the name plainly says
instead -- an actual substring/regex replace within each item's
current value.

Found and fixed while writing the first conductor-kind test: a hub
topology (several conductors sharing one terminal, e.g. a star wiring)
made the conductor branch pick terminal1 unconditionally to address a
Conductor object through setConductorProperty() -- for a hub member
conductor, terminal1 is the shared, ambiguous hub terminal itself
(findConductor() correctly refuses to address a conductor through a
terminal carrying more than one), so every conductor touching that hub
silently failed to update, returning a changed count of 0 with no error
for a genuinely matching project. Fixed by preferring whichever of
terminal1/terminal2 carries exactly that one conductor.

Also caught during testing: an empty macro (a run that matches
nothing) still gets pushed onto the undo stack by QUndoStack::endMacro()
-- it is not silently discarded the way the earlier revision assumed --
leaving a confusing no-op "Rechercher et remplacer" undo entry. Fixed
by counting matches in a dry run first and never touching the undo
stack at all when that count is zero.

textContent() is a new small getter alongside the existing
setTextContent(), filling a gap this needed (reading an independent
text's current content) that is independently useful too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:31 +12:00
ispyisail 9cee71bde2 Add PDF page import to the scripting API
qet.addPdfPage() renders one page of a PDF file to an image and
places it, through the same QPdfDocument::render() call, white-
background compositing (a transparent page would otherwise show
whatever is under it, unlike every other placed image) and
DiagramImageItem/AddGraphicsObjectCommand underneath as the "add PDF"
toolbar action's own file/page-selection dialogs.

Only reachable in a build with the QtPdf module (Qt >= 6.4) -- some
Qt6 distributions omit it entirely (see diagrameventaddpdf.h). The
method is still always declared and compiled, guarded internally
instead of with the class itself: a script asking whether qet.addPdfPage
exists must never get "not a function" for a reason it has no way to
discover. Refuses with a clear reason when the module is missing, the
page number is out of range, the file cannot be loaded, or the
resulting render is degenerate.

Verified against a real 2-page PDF (this session installed qt6-pdf-dev,
which was missing here) that the two rendered pages differ in content
and that dpi scales the rendered pixel size linearly, not just that the
call returns something.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:31 +12:00
ispyisail 687837d7a3 Add polygon and path shapes to the scripting API
addShape()'s own "polygon" only ever produces the degenerate two-point
form -- it shares addShape()'s p1/p2 constructor and nothing else.
addPolygon()/setShapePolygon() take an arbitrary point list through
QetShapeItem's public setPolygon(), pushed via the existing "polygon"
Q_PROPERTY the same way a point-handle drag would.

addPath()/setShapePathNodes() add the Path shape type: a polygon's
points plus, per node, a kind (corner/smooth/symmetric) and optional
bezier in/out handles, the same model the pen tool and node-edit mode
build. PathNode holds std::optional<QPointF> members and isn't
Q_PROPERTY-friendly, so setShapePathNodes() reuses PromoteShapeCommand's
before/after XML snapshot mechanism instead -- the same fallback
QetShapeItem::associatedUndoCommand() already uses for the identical
reason on a PathAnchor/PathControlIn/PathControlOut handle drag.

setShapeClosed() opens or closes a polygon or path through the existing
"close" Q_PROPERTY. shapePolygon()/shapePathNodes() read a shape's
current geometry back in scene coordinates, refusing (empty) on the
wrong shape type.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:31 +12:00
ispyisail ea1f65107e Add manual conductor routing to the scripting API
Conductor::moveSegment(index, dx, dy) is the same primitive
handlerMouseMoveEvent()/handlerMouseReleaseEvent() apply on a drag --
move both axes on the target segment (each of ConductorSegment's
moveX()/moveY() silently no-ops on the wrong axis or a static,
terminal-anchored segment), recompute the path, and push one
ChangeConductorCommand undo step via the existing saveProfile().

Caught while writing the first test for it: moveSegment() never set
modified_path, so Conductor::toXml() skipped writing <segment> children
and a manually rerouted conductor silently reverted to auto-routing on
the very next save -- the change took effect in the running scene but
never reached disk. Fixed by setting the flag, the same as every other
path-modifying call site already does.

qet.conductorSegments() lists a conductor's segments (endpoints in
scene coordinates, orientation, static/movable) so a script can find
the index it wants; qet.moveConductorSegment() applies the move and
refuses a static segment or an out-of-range index.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:31 +12:00
ispyisail 6380e2a46b Add PLC master IO table and PLC-slave group-index linking
addPlcIO/setPlcIO/removePlcIO edit a PLC master's IO table (type,
address, function text, comment) directly through setElementData(),
the same as MasterPropertiesWidget's own PLC IO editor -- and, like it,
these are not undoable: MasterPropertiesWidget::associatedUndo()
deliberately returns nullptr for PLC masters, since their linking is
managed through the IO table rather than the link-tree widget it would
otherwise build an unlink-all command from.

linkElements() gains an optional groupIndex so a PLC slave can be
linked onto one specific IO row instead of leaving the row
unspecified. LinkElementCommand only reads the group index it is given
when the command's own element is the Slave -- when built from the
Master side (a=master, b=slave, the usual call shape) it looks in a
per-slave map this call never populates, and setGroupIndex() is
silently a no-op. Fixed by building the command from whichever of the
two elements is actually the Slave, matching what PlcLinkWidget does.
elementLinkGroupIndex() reads the result back.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:31 +12:00
ispyisail 219273a8e5 Add table placement to the scripting API
QetGraphicsTableFactory::create() only reads settings already set on an
AddTableDialog and never depended on the dialog being shown, so make it
public alongside setTableName()/setAdjustTableToFolio()/
setAddNewTableToNewDiagram() on AddTableDialog -- this lets the scripting
API build and configure a dialog headlessly instead of exec()'ing one.

qet.addTable() requires a non-empty query: ElementQueryWidget and
SummaryQueryWidget both default to zero selected columns, so an empty
query silently produced a table with no rows rather than a sensible
default. qet.tables()/deleteTable() list and remove by a position-sorted
index. qet.setTablePosition() repositions one, since newTable() always
places a new table at a fixed (50, 50) and a folio with more than one
needs to move all but the first itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:31 +12:00
ispyisail 8a2d31d835 Let a script group, bridge and sort a terminal strip's real terminals
stripRealTerminals(strip)              index, owning element, current
                                          physical position, neighbours
  groupTerminals(strip, indices)         merge onto one physical position
  bridgeTerminals(strip, indices)        wire together without merging
  sortTerminalStrip(strip)               canonical physical order

Each goes through the same command the terminal strip editor's own
group/bridge/sort buttons push (GroupTerminalsCommand,
BridgeTerminalsCommand, SortTerminalStripCommand), so a script's changes
undo like the editor's.

groupTerminals() replicates the editor's own receiver-selection heuristic
line for line rather than picking the first terminal named: the physical
position that already carries the most real terminals receives the
others, not necessarily the one at index 0. Verified with a case built to
distinguish the two: three terminals grouped first (one position, three
real terminals), then a fourth, previously-alone terminal grouped with
one of those three, named first in the call -- the alone terminal moved
onto the three-terminal position, ending at four, not the other way
around.

bridgeTerminals() refuses through TerminalStrip::isBridgeable() itself,
the same check the editor's bridge button applies, rather than
re-deriving what "the same level" means. Real terminals are addressed by
index into stripRealTerminals(), the strip's own order; grouping shifts
later physical-position indices down, so the header says to re-list
after a change that adds or removes one, the same rule already
documented for texts, shapes and images.

Verified end to end on four placed terminal elements: added to a strip,
grouped two, refused a group of one and an out-of-range index, bridged
the remaining two, sorted, undo restoring order without disturbing the
grouping (sort doesn't touch it, so it shouldn't), and a bad strip index
refused on all three operations. Qt 6.10.2, build clean, ctest 12/12,
coherence gate clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:31 +12:00
ispyisail a8f883601d Let a script list, embed and apply a folio's title block template
titleBlockTemplates()          embedded + common/company/custom, by name
  embedTitleBlockTemplate(name)  copy one into the project's own collection
  setFolioProperty(f,"template",name)  embed-if-needed, then apply
  folioProperty(f,"template")

Not the trivial addition to the existing title-block-field list it looked
like at first. Diagram::setTitleBlockTemplate() resolves a name only
against QETProject::embeddedTitleBlockTemplatesCollection() -- the exact
same copy-into-the-project step addElement() already goes through for
elements, and for the same reason: a project opened on another machine
must not depend on files only this one has. embedTitleBlockTemplate()
does that copy through get/setTemplateXmlDescription(), the same round
trip the template editor itself uses to save one -- not scripting-specific
code, and unlike defining an auto-numbering context, not undoable, for the
same reason that isn't: the application does both through direct
collection/project calls with no undo command of their own.

Two things found only by testing, not by reading:

- "default" is a real template name in the common collection, and setting
  a folio's template to it is legitimate -- but
  BorderTitleBlock::titleBlockTemplateName() normalises a template
  literally named "default" back to "", indistinguishable from no
  override, since that is genuinely what "no override" renders with. The
  first version compared the raw name and reported success as failure;
  fixed by comparing against that same normalised form, which folioProperty()
  now also documents.
- QElectroTech resolves the common template collection from a compiled-in
  path (here, an absolute /usr/share/qelectrotech/titleblocks, not
  relative to the binary), and --common-tbt-dir, the CLI override, is
  read by QETApp::parseArguments() -- which the --run headless path never
  reaches, confirmed by the CLI itself swallowing the flag as a stray
  positional argument. There is no QSettings fallback the way
  commonElementsDir() has. So testing this at all needed the path to
  genuinely exist; no environment trick from inside the process reaches
  it.

Verified: 10 common templates listed; DIN_A4 embedded and applied,
folioProperty reading it back; re-applying the same name a no-op success;
an unknown name refused; "default" applied and correctly read back as ""
per the note above; both folios exported to PNG and visually compared --
plain default rendering vs. DIN_A4's logo, revision table and field
layout, genuinely different, not just an API call returning true. The
choice survives a save and reload. Qt 6.10.2, ctest 12/12, coherence gate
clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:31 +12:00
ispyisail cd27912605 Let a script read where an element is, and insert a folio at a position
elementGeometry(folio, uuid)   x, y, rotation, left, top, right, bottom
  insertFolio(position)

The API could set an element's position but never read it, so a script
could not lay one thing out relative to another, or check that a move had
landed; verification had to go through the saved file. elementGeometry
returns the origin (what setElementPosition sets), the rotation, and the
box the element occupies on the folio -- its drawn extent, which sits at
the element's hotspot from the origin and, once rotated, is the rotated
extent.

Measured on a coil whose hotspot is (17, 32): placed at (200, 300) the box
is 183..223 by 268..328, exactly that far from the origin; a move of
(+50, -20) shifts both together; a 90 degree turn swaps the box to 60 by
40 about an unchanged origin and 180 turns it back; and the origin agrees
with the saved file (x=250 y=280, orientation 2 for 180 degrees).

insertFolio puts a folio at a position (0 first, folioCount() last) through
QETProject::addNewDiagram(pos), undoable. The position is checked in the
binding: QETProject::addDiagram() hands it straight to QList::insert(),
which is undefined past the end, so -1 and anything above the count are
refused with a reason. Verified: first, middle and last insertions land in
the right order, and undo and redo of an insertion restore the order.
Folio reordering itself still needs the application's project view.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:31 +12:00
ispyisail f087820757 Let a script rename the project and reshape a folio's frame
setProjectTitle(title)
  folioBorder(folio, prop)  setFolioBorder(folio, prop, value)

The frame is the grid of columns and rows around a folio: columns,
column-width, display-columns, rows, row-height, display-rows -- the six
fields the folio properties panel offers -- through ChangeBorderCommand,
so it undoes like a hand edit. The title block's header sizes, which the
panel does not offer, are left alone. Changing the project title is not
undoable, because the application sets it directly too.

Counts are 1 to 99 and sizes 1 to 1000. The panel's upper limits are the
same; its lower limit is 0, which is not offered: a grid with no columns
has no use here and 0 was not tested, so it is refused rather than
assumed safe. The extremes that are offered (99 x 99 cells, widths from 1
to 1000) were exported to PNG without a hang or crash. Fractions, out of
range values, an unknown property and a bad folio are refused with a
reason.

Verified: ten columns of 40 and four rows of 100, undo of the last change,
and the folio and the renamed project read back after a reload. My first
reload check read the wrong folio and briefly looked like the border was
not persisted; the file itself had the right values.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:31 +12:00
ispyisail 2772b690e8 Let a script duplicate elements, with the conductors between them
duplicateElements(fromFolio, [uuids], toFolio, x, y)
  selectedElements(folio)

What Ctrl+C and Ctrl+V do: the named elements are selected, serialised
with Diagram::toXml(false, true), the previous selection is put back, and
the copy is pasted with Diagram::fromXml() and a single
PasteDiagramCommand. A conductor is copied only if both its ends are among
the copied elements. As on a paste in the application, the copies come
without labels or their conductors' wire numbers (measured: empty on
both). One undo removes the elements and the conductor together.

Three properties found by measuring rather than assuming:

- Position is the top left of the pasted group's bounding rectangle, so an
  element's own origin ends up offset by its hotspot: +20, +30 for a coil,
  identical across two trials. (0, 0) is not a position; Diagram::fromXml
  treats the origin as "keep the source coordinates".
- The application's pasted list is in scene order, not the order the
  elements were named. Asking for the elements at x = 700, 100, 900 returned
  the copies of 100, 700, 900, so a caller pairing copies with sources by
  index was wired to the wrong ones with no error. A paste is a pure
  translation, so sorting sources and copies by position pairs them, and
  the result is returned in the order asked. Checked with a scrambled
  request over a zig-zag layout: every copy is the identical translation
  (-30, -20) from the source at its index. Two elements at one point cannot
  be told apart; if the counts disagree it says so and returns the
  unpaired list rather than guess.
- Copying works by selecting, so the previous selection is given back;
  selectedElements() exists to make that checkable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:31 +12:00
ispyisail 279b001a16 Let a script apply element auto-numbering
useElementAutoNum(name)   select the current element numbering context
  numberElement(folio, el)  give one element its label from it

numberElement calls Element::setUpFormula(), the call the "add element"
tool makes right after placing an element, so a script gets the same
numbering: three coils numbered in turn are K1, K2, K3, and the counter
persists in the project (after a reload the next element is K3).

It is a separate call rather than a change to addElement(), which is
merged code: numbering what it places would change what an existing
script produces the moment its project happens to have a context selected.
A slave or a report is refused, since it takes its label from its master,
and so is a project with no context selected, instead of reporting a
success that did nothing.

setUpFormula() has a hazard for an element that is already placed. It
writes the label straight into the element's information and pushes only
the counter's advance onto the undo stack. Placing a new element hides
that, because undoing the placement removes the element; for an existing
one, a single undo rolled the counter back and left the label, so c3 stayed
"K3" while the counter went back to expecting K3 and the next numbering
would repeat a label it had forgotten. So the label it computed is taken,
the information put back, and the change pushed as a command inside the
same macro as the counter: one undo now reverts both, and renumbering c3
afterwards yields K3 again. Redo and the database agree.

Folio auto-numbering is deliberately not offered: in the application it
spawns whole new folios from a context, which is a different operation
from labelling. "Renumber existing conductors" has no equivalent to bind --
conductor numbering is applied when a conductor is created or moved, and
QElectroTech has no renumber-all action.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:31 +12:00