Compare commits

..

81 Commits

Author SHA1 Message Date
Laurent Trinques 1d4c0326d4 Merge pull request #1003 from qelectrotech/revert-986-no-sqlite
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 1m55s
Revert "fix: harden project database export"
2026-09-23 13:51:07 +02:00
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
ispyisail 59cc758b7f Let a script control the text fields drawn on a symbol
elementTexts()  addElementText()  setElementTextProperty()
  elementTextProperty()  deleteElementText()

A symbol arrives with the fields its definition gives it -- coil
bobine_ka_a_remanence has A2, A1 and a label -- and until now a script
could fill the value the label shows (setElementLabel) but not control
the fields themselves: where each sits, its size, whether it is framed,
what it is bound to, or add and delete one.

A field's source is "text" (a fixed string), "info" (follows one of the
element's information keys, so it follows setElementInfo and
setElementLabel) or "composite" (a formula). Changes go through
QPropertyUndoCommand on the item's own properties, as the element-texts
editor does, and adding through AddElementTextCommand. An unknown source,
a key that is not an element information key, a non-positive size and a
bad index are refused with a reason.

Two things called text differ for an information-bound field. The "text"
property is the stored string, an unused placeholder (empty, or "Texte"
for a newly added field); "shows" is what is drawn. I first believed the
displayed text was stale in-session and wrote a helper to read it from the
element's information instead. That was wrong: comparing toPlainText()
against elementInfo() at seven points across relabel, rebinding, setting
and undo found no difference. It was the stored string that looked stale,
and the helper and the comment claiming the item "refreshes lazily" are
removed. The header now says which is which.

Fields are addressed by index in the element's own list, which follows the
definition and shifts on delete; undoing a deletion puts the field back at
the end. Consecutive info/label changes on one element merge into one undo
step (ChangeElementInformationCommand::mergeWith), so one undo can revert
several -- noted, not a bug.

Verified on a coil: the label field moved, enlarged to 14 pt and framed, a
new field bound to "comment" showing "24VDC coil", all through the
project, exported to PNG and looked at. The saved file carries the moved
position, size, frame and bound value, and a reload reads them back.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:31 +12:00
ispyisail 0c958d3b66 Drop "version" from the folio properties: it was a silent no-op
setFolioProperty(f, "version", "V9-USER") returned true and changed
nothing. TitleBlockProperties::version is the file-format stamp
QElectroTech writes on every save, so a value set through the API reads
back as "0.200.1-dev" and is what ends up in the file.

A property that reports success and does nothing is the failure this API
is careful to avoid everywhere else, so it is removed rather than
documented. It went in untested: the earlier verification set author and
plant and never tried the other six names.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:31 +12:00
ispyisail 528d33792d Say what order a terminal index actually follows
The comment on elementTerminals() said the order of Element::terminals()
"also comes from the definition", which reads as file order and is
wrong. Element::parseTerminal() re-sorts the list on every insertion, top
to bottom then left to right on each terminal's local position, so index 0
is the topmost terminal whatever order the .elmt lists them in.

bobine_ka_a_remanence.elmt writes A2 (y=20) before A1 (y=-20) and index 0
is A1. Measured by placing 400 shipped elements and reading the real order
back: a top-to-bottom, left-to-right prediction matched all 400, while
file order matched only the 100 where the two happen to coincide. Of the
837 shipped elements with distinct named terminals, 619 list them in a
different order than QElectroTech indexes them.

Comment only. Two terminals at one point tie and the sort is not stable;
the comment says so.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:31 +12:00
ispyisail fc41cf9f5d Let a script place an image
images()  addImage()  setImageScale()  setImageRotation()  deleteImage()

addImage reads a file the way the "add image" tool does after its file
dialog and pushes the same AddGraphicsObjectCommand. The pixels are
copied into the project, which DiagramImageItem::toXml writes inline, so
the saved file does not refer to the original path: verified by moving the
source file away and reloading, where the image and its scale came back.
The price is that the project grows by about the size of the image, so
files over 10 MB are refused; so are unreadable files, non-images and
missing paths, each with its own reason.

Scaling sets both axes as one undo step. Images are addressed by index in
a position-sorted listing like texts and shapes, by the on-screen
bounding box -- so because an image turns and scales about its centre,
scaling or rotating one can change where it sorts, and the header says to
re-list after either. Observed: a 2x scale moved a listed top-left from
(100,100) to (68,84), which is that, not a displacement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:31 +12:00
ispyisail 7f04bc1572 Let a script style a shape
shapeProperty()/setShapeProperty()   color, fill, width, line-style, rotation

Through QPropertyUndoCommand on the pen, brush and rotation properties
the shape's own style editor changes, so it undoes like a hand edit.
fill takes a colour or "none" for no fill; line-style is solid, dashed,
dotted or dashdot. A colour that does not parse, a non-positive width,
an unknown style or property and a bad index are refused with a reason.

Verified on a rectangle: all five set and read back, fill "none" reads
back as none, undo restores the previous fill, and the whole look
survives save and reload.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:30 +12:00
ispyisail 27bbf51024 Let a script style a conductor
setConductorProperty()/conductorProperty() now also take the properties
that control how a conductor looks, under the names the .qet file uses:

  style        normal, dashed or dashdotted
  bicolor      true/false, with color2 as the second colour
  dash-size    positive integer
  condsize     positive number (line width)
  numsize      positive integer (text size)
  displaytext  true/false

Values are validated rather than stored: a boolean other than
true/false, a non-positive size, an unparseable colour or a line style the
file format cannot express is refused, because ConductorProperties would
write the latter back as a solid line and silently lose it. The change is
still applied to the whole potential.

Verified: all eight set, read back, written to the file in its own form
(style as "line-style: dashed;", displaytext as 0) and read back
again after a reload. Four invalid values decline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:30 +12:00
ispyisail 65299e1178 Let a script define and select auto-numbering
autoNums(kind)                 list contexts and their formulas
  addAutoNum(kind, name, parts)  define one; parts are "type[:value[:increase]]"
  removeAutoNum(kind, name)
  useConductorAutoNum(folio, n)  new conductors on a folio take it

Kinds are conductor, element and folio. Part types are the ones the
auto-numbering dialog offers; anything NumerotationContext rejects, an
unknown kind, a non-numeric increase or a missing context is refused
with a reason rather than dropped.

Defining and removing a context is not undoable, because the
application does it through direct project calls; only the counter
advance is on the undo stack. useConductorAutoNum sets both the folio's
name and the project's current name, because the numbering code reads
the context by one and writes the advanced counter back under the other.

Verified: a "W" + unit context selected on a folio, two conductors
wired, W1 and W2 in the saved file, and the context survives reload.
Depends on the preceding ConductorCreator fix for the database to agree
with the drawing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:30 +12:00
ispyisail 8e2a29deaf Update the database row once an auto-numbered conductor has its number
ConductorCreator inserts a conductor and only afterwards calls
refreshText(), which resolves the auto-numbering formula into
properties.text. The project database inserted its row while text was
still the raw formula, and refreshText() writes the resolved text
without emitting propertiesChange -- the signal the database listens
for -- so nothing corrects the row.

Measured: with a conductor auto-numbering "W%sequ_1" selected, two
wired conductors read W1 and W2 on the live objects and in the saved
file, but "W%sequ_1" and "W%sequ_1" in conductor.text and in
wiring_list_view.wire_number. A full updateDB() corrects it, so the
data was right and only the cache was stale. Anything that reads the
database between creating a conductor and the next rebuild -- the
wiring list, a BOM export, a custom query -- sees the formula, not the
number.

Update the row after refreshText(). The row change deliberately emits no
dataBaseUpdated(), as updateConductor() already documents, so this adds
no model re-queries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:30 +12:00
ispyisail 5b739c3338 Let a script build a terminal strip
terminalStrips()        the project's strips, in its own order
  addTerminalStrip()      AddTerminalStripCommand, as the creation dialog does
  removeTerminalStrip()   RemoveTerminalStripCommand
  addTerminalToStrip()    AddTerminalToStripCommand

Only terminal-type elements can be added, the restriction the editor
enforces by construction; anything else, or a terminal already on a
strip, or a bad index, declines with a reason. Verified: two terminal
elements placed and added, listing shows 2 terminals, removal and undo,
and the strip with its terminals survives save and reload.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:30 +12:00
ispyisail a1e4e153ae Let a script delete a conductor or a folio, and set title block fields
deleteConductor()    the conductor on a terminal, addressed as elsewhere
  removeFolio()        RemoveDiagramCommand, the GUI's delete-folio command
  setFolioProperty()   title, author, filename, plant, locmach, indexrev,
  folioProperty()      version, folio -- via ChangeTitleBlockCommand

deleteConductor removes only the named conductor; unlike a property
change it is not potential-wide, and DeleteQGraphicsItemCommand rebuilds
the rest of the potential so it stays connected, as when a user selects
one conductor and presses Delete. Verified: deleting one leaf of a
three-terminal potential leaves the other conductor, with its number.

removeFolio skips the GUI's confirmation box, which nobody could answer
headlessly. Undo and redo both work; later folio indexes shift down.
Undoing a removal prints a UNIQUE-constraint warning from
projectDataBase::addDiagram. That is inside RemoveDiagramCommand::undo(),
which the GUI runs too, so it predates this change.

The date and template are not offered as folio properties: the date has a
use-current-date mode a plain string cannot express honestly.

Verified headlessly, including save/reload of the title block fields.
Qt 6.10.2, build clean, ctest matches master.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:30 +12:00
Laurent Trinques 56f60be60a CI: try to fix 404 error on downlad page 2026-09-22 10:16:49 +02:00
Laurent Trinques 852f581206 Merge pull request #971 from arummler/fix-translation-syntax-02
Fix translation syntax follow-up
2026-09-22 09:57:30 +02:00
Laurent Trinques 3b626a7d1a CI: try to fix 404 error on downlad page 2026-09-22 09:50:14 +02:00
Andre Rummler 069a75d44b Use pugixml target in test removing the old directly including approach. Fix a static variable which is now missing as the correspondign source file is not used by the particular test. 2026-09-22 09:08:03 +02:00
Andre Rummler e1f887a036 Standarize pugixml header path to recommended variant with target INTERFACE. 2026-09-22 09:07:27 +02:00
Laurent Trinques c5edd0a54b CI: try to fix 404 error on downlad page
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 2m11s
2026-09-22 08:19:23 +02:00
Laurent Trinques 3a13287ba9 CI: delete auto-doxygen.yml 2026-09-22 08:05:53 +02:00
Andre Rummler 24776bfcf6 Avoid setting globally QLocale which was introduced in a recent MR as it will change number formats, etc. which is not necessarily what a user switching language wants. Beside that
small fix of a trasnlation comment.
2026-09-22 08:03:07 +02:00
Andre Rummler 3202c145e8 Fix French origin strings (language improvement). 2026-09-22 08:03:07 +02:00
Laurent Trinques 4888ae87f3 Merge pull request #978 from Kellermorph/feature/refresh-all
fix: update composite text %{label} when folio changes
2026-09-22 07:53:36 +02:00
Laurent Trinques 59344eb565 Merge pull request #977 from ispyisail/fix/591-text-resize-handles
Fix #591's resize handles: unreachable via plain click, wrong position
2026-09-22 07:51:32 +02:00
Kellermorph 5d033bf1b2 Add custom application color picker in global settings
Add a KColorButton next to the 'Utiliser les couleurs du système'
checkbox in the Apparence settings tab:

- When the checkbox is active (default), system colors are used
  and the color button is disabled
- When the checkbox is inactive, the color button becomes active
  and lets the user pick any color for the entire application
- useCustomPalette() builds a full QPalette from the chosen color
  with proper light/dark text contrast, button shading, and icon
  theme switching
- The chosen color is persisted in QSettings as
  'customapplicationcolor' and restored on next startup

Files changed:
- sources/ui/configpage/generalconfigurationpage.ui: HBoxLayout
  with checkbox + KColorButton, customwidget declaration
- sources/ui/configpage/generalconfigurationpage.h: new slot
- sources/ui/configpage/generalconfigurationpage.cpp: load/save
  custom color, enable/disable logic, toggled slot
- sources/qetapp.h: useCustomPalette() declaration
- sources/qetapp.cpp: useCustomPalette() implementation,
  startup restore of custom color
2026-09-21 22:05:31 +02:00
Kellermorph 8985babfe7 fix: update composite text %{label} when folio changes
DynamicElementTextItem::updateLabel() resolved %{label} in composite
text using the stale value from elementInformations()["label"], which
is only set once at load time and never updated when the folio/page
number changes.

Use element->actualLabel() instead, which resolves the label formula
(including %F, %f, %id) against the current folio at call time.
2026-09-21 21:45:03 +02:00
ispyisail 29d16c3337 Fix #591's resize handles: reachable only via Shift/right-click, wrong position
Two bugs reported by @arummler on #591 after merge:

  "It works but to select the text field one has to right click on
  it...I think there are competing handlers or something."
  "the drag elements should be on the border of the box. In the
  moment they appear directly left and right from the text."

Both reproduced headlessly (scripts/qet-gui-dialog.sh) against a fresh
build of current master and root-caused before touching anything.

Selection: DynamicElementTextItem::mousePressEvent() forwards a plain
click (no Shift) straight to parentElement()->mousePressEvent(), by
design and pre-existing -- it's what lets dragging a symbol by its own
label move the whole symbol rather than just the label. That's correct
and untouched here. But it means a plain click leaves the *parent*
selected, not the text, and #591's handles were wired only to the
text's own ItemSelectedHasChanged -- so they were only reachable via
Shift+click or a right-click's context menu (which happens to select
the item under the cursor for its own context menu, unrelated to the
Shift path), neither of which anyone reaches for to resize a text.
Confirmed with screenshots at each step, including that Shift+click
already reached the existing (if misplaced) handles correctly.

Fix: DynamicElementTextItem::refreshResizeHandlesVisibility() shows the
handles when either the text itself or its parent element is selected,
and Element gets an itemChange() override (it had none) that calls it
on each of its own texts when the element's own selection changes. Both
sides driven from itemChange(), Qt's own hook for exactly this and the
same one already used for the text's own selection.

First attempt drove this from paint() instead, since the PR's own
updateResizeHandlesPos() already runs there. That crashed reproducibly
(SIGABRT) on deselecting a text: paint() runs while QGraphicsScene
iterates its item list to draw it, and addResizeHandles()/
removeResizeHandles() mutate that list via QGraphicsScene::addItem()/
removeItem(), which cannot safely happen mid-iteration. Caught it with
the same headless repro before it went anywhere near a PR, moved the
logic to itemChange(), and re-ran the full sequence -- select, resize,
undo, deselect, twice through -- clean.

Position: updateResizeHandlesPos() placed the handles on frameRect(),
which is a box sized to the text's natural (idealWidth()) content and
then re-centred inside boundingRect() -- it does not grow with
textWidth(). Once a text has been widened, frameRect() stays tight
around the glyphs while boundingRect() -- the box QGraphicsView actually
outlines as the selection, and the box a user drags relative to -- grows
around it, leaving the handles stranded well inside the visible
selection border. Fix: position them on boundingRect() instead, which
does track textWidth(); confirmed by widening a text and checking the
handle lands exactly on the new edge rather than partway across it.

Verified headlessly end to end on the original report's own element
("motor off" on grafcet.qet, folio 1): a single plain left-click (no
Shift, no right-click) now shows both handles at the true box border;
dragging resizes correctly and the handle tracks the growing edge;
Ctrl+Z restores the -1 auto-width sentinel and the handles stay at the
reverted position; clicking away removes them; repeated twice with no
crash. Qt 6.10.2, ctest 12/12, no new warnings in either changed file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 07:30:39 +12:00
Laurent Trinques 61f5e5e502 Merge pull request #972 from arummler/fix-pugixml-linking
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 1m42s
fix PUGIXML linking
2026-09-21 19:36:28 +02:00
Laurent Trinques 77bc9ed8e4 Merge pull request #976 from arummler/untrack-qch
Stop tracking *.qch via LFS.
2026-09-21 19:35:48 +02:00
Andre Rummler 9b26d5dc6a Stop tracking *.qch via LFS. 2026-09-21 17:57:59 +02:00
Andre Rummler b76d8ce8a1 Fix: remove PUGIXML files from compilation fileset. This is redundant using the target approach and leads sometimes to failures. 2026-09-21 13:29:40 +02:00
elevatormind 78caeaec48 Merge branch 'qelectrotech:master' into no-sqlite 2026-09-21 07:34:32 +02:00
Magnus Hellströmer 27dea3ffab refactor: use VACUUM INTO for database export
Remove the native SQLite backup API and direct SQLite library
dependency.
2026-09-20 20:49:02 +02:00
77 changed files with 9678 additions and 695 deletions
-1
View File
@@ -1 +0,0 @@
*.qch filter=lfs diff=lfs merge=lfs -text
+4 -2
View File
@@ -28,7 +28,7 @@ jobs:
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates build-essential cmake ninja-build git pkg-config \ ca-certificates build-essential cmake ninja-build git pkg-config \
qt6-base-dev qt6-base-private-dev qt6-tools-dev qt6-tools-dev-tools \ qt6-base-dev qt6-base-private-dev qt6-tools-dev qt6-tools-dev-tools \
libqt6svg6-dev libqt6sql6-sqlite libsqlite3-dev libcups2-dev \ libqt6svg6-dev libqt6sql6-sqlite libcups2-dev \
libxkbcommon-x11-0 \ libxkbcommon-x11-0 \
xvfb openbox xdotool x11-utils xvfb openbox xdotool x11-utils
# extra-cmake-modules and the KF6 libraries are installed rather than # extra-cmake-modules and the KF6 libraries are installed rather than
@@ -53,7 +53,9 @@ jobs:
git config --global --add safe.directory "$GITHUB_WORKSPACE" git config --global --add safe.directory "$GITHUB_WORKSPACE"
cmake -B build -G Ninja \ cmake -B build -G Ninja \
-DCMAKE_BUILD_TYPE=Debug \ -DCMAKE_BUILD_TYPE=Debug \
-DQT_VERSION_MAJOR=6 -DQT_VERSION_MAJOR=6 \
-DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
-DQET_EXPORT_PROJECT_DB=ON
- name: Build - name: Build
run: cmake --build build --parallel $(nproc) run: cmake --build build --parallel $(nproc)
+1 -10
View File
@@ -49,7 +49,6 @@ jobs:
mingw-w64-ucrt-x86_64-qt6-tools mingw-w64-ucrt-x86_64-qt6-tools
mingw-w64-ucrt-x86_64-qt6-translations mingw-w64-ucrt-x86_64-qt6-translations
mingw-w64-ucrt-x86_64-qt6-pdf mingw-w64-ucrt-x86_64-qt6-pdf
mingw-w64-ucrt-x86_64-sqlite3
mingw-w64-ucrt-x86_64-pkg-config mingw-w64-ucrt-x86_64-pkg-config
mingw-w64-ucrt-x86_64-kwidgetsaddons mingw-w64-ucrt-x86_64-kwidgetsaddons
mingw-w64-ucrt-x86_64-kcoreaddons mingw-w64-ucrt-x86_64-kcoreaddons
@@ -122,8 +121,7 @@ jobs:
-DQET_EXPORT_PROJECT_DB=ON \ -DQET_EXPORT_PROJECT_DB=ON \
-DCMAKE_C_COMPILER_LAUNCHER=/ucrt64/bin/ccache \ -DCMAKE_C_COMPILER_LAUNCHER=/ucrt64/bin/ccache \
-DCMAKE_CXX_COMPILER_LAUNCHER=/ucrt64/bin/ccache \ -DCMAKE_CXX_COMPILER_LAUNCHER=/ucrt64/bin/ccache \
-DSQLite3_INCLUDE_DIR=/ucrt64/include \
-DSQLite3_LIBRARY=/ucrt64/lib/libsqlite3.dll.a \
.. ..
ninja -j"$NPROC" ninja -j"$NPROC"
@@ -196,13 +194,6 @@ jobs:
cp /ucrt64/bin/libgcc_s_seh-1.dll "$BIN/" cp /ucrt64/bin/libgcc_s_seh-1.dll "$BIN/"
cp /ucrt64/bin/libstdc++-6.dll "$BIN/" cp /ucrt64/bin/libstdc++-6.dll "$BIN/"
cp /ucrt64/bin/libwinpthread-1.dll "$BIN/" cp /ucrt64/bin/libwinpthread-1.dll "$BIN/"
SQLITE=$(find /ucrt64/bin -name "libsqlite3*.dll" | head -1)
if [ -n "$SQLITE" ]; then
cp "$SQLITE" "$BIN/"
echo "SQLite3 copied: $(basename $SQLITE)"
else
echo "WARNING: libsqlite3 not found in /ucrt64/bin/"
fi
cp "$GITHUB_WORKSPACE/build-aux/windows/QET64.nsi" "$NSIS_ROOT/" cp "$GITHUB_WORKSPACE/build-aux/windows/QET64.nsi" "$NSIS_ROOT/"
cp "$GITHUB_WORKSPACE/build-aux/windows/lang_extra.nsh" "$NSIS_ROOT/" cp "$GITHUB_WORKSPACE/build-aux/windows/lang_extra.nsh" "$NSIS_ROOT/"
+53 -10
View File
@@ -59,17 +59,41 @@ jobs:
# ---------------------------------------------------------------- # ----------------------------------------------------------------
# 2. Download the portable artifact for this flavor # 2. Download the portable artifact for this flavor
#
# Wrapped in nick-fields/retry: actions/download-artifact@v8 has
# shown repeated "Artifact download failed after 5 retries"
# failures on this cross-workflow download (via run-id) — not a
# real content/digest problem, just flaky Azure blob delivery.
# Two separate occurrences observed within days of each other
# (different artifact IDs/digests, same failure signature), each
# one enough to fail build-msi outright and block the rest of the
# pipeline. Retrying the whole download 3x is cheap insurance.
# Switched to `gh run download` here because nick-fields/retry
# can only retry a shell command, not re-invoke a `uses:` step.
# ---------------------------------------------------------------- # ----------------------------------------------------------------
- name: Download portable artifact - name: Download portable artifact
uses: actions/download-artifact@v8 uses: nick-fields/retry@v3
with: with:
name: ${{ matrix.portable_artifact }} timeout_minutes: 10
path: artifact\files max_attempts: 3
# workflow_run => use the triggering run's ID retry_wait_seconds: 30
# workflow_dispatch => use input run_id if provided, otherwise current run shell: pwsh
run-id: ${{ github.event.workflow_run.id || github.event.inputs.run_id || github.run_id }} command: |
github-token: ${{ secrets.GITHUB_TOKEN }} if (Test-Path "artifact\files") { Remove-Item -Recurse -Force "artifact\files" }
repository: ${{ github.repository }} New-Item -ItemType Directory -Force -Path "artifact\files" | Out-Null
$runId = "${{ github.event.workflow_run.id || github.event.inputs.run_id || github.run_id }}"
gh run download $runId `
--repo "${{ github.repository }}" `
--name "${{ matrix.portable_artifact }}" `
--dir "artifact\files"
if ($LASTEXITCODE -ne 0) {
Write-Error "gh run download failed (exit $LASTEXITCODE)"
exit $LASTEXITCODE
}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# ---------------------------------------------------------------- # ----------------------------------------------------------------
# 3. Extract version # 3. Extract version
@@ -336,7 +360,13 @@ jobs:
if-no-files-found: error if-no-files-found: error
- name: Delete old nightly .msi asset - name: Delete old nightly .msi asset
if: always() # Only run if a new MSI actually exists in dist/ — otherwise the old
# (still working) nightly .msi would be deleted without anything to
# replace it, leaving the nightly release with no MSI at all until
# the next successful build (see windows-msi-pipeline notes,
# run 35694391567: an upstream artifact-download failure meant
# dist\*.msi never existed for that run).
if: always() && hashFiles('dist/*.msi') != ''
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }} REPO: ${{ github.repository }}
@@ -381,7 +411,20 @@ jobs:
deploy-pages: deploy-pages:
needs: build-msi needs: build-msi
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: always() && needs.build-msi.result == 'success' # Regenerate the page whenever the MSI job actually ran (success OR
# packaging/signing failure) — not only on full success.
# generate-page.py queries the published assets on the "nightly"
# release itself (line 407: `gh release view nightly --json assets`),
# so it already handles a missing MSI natively (empty MSI_NAME -> no
# MSI button on the page). The page only needs the exe/zip already
# published by windows-build.yml, independent of the MSI's fate.
# Only "skipped"/"cancelled" are excluded: if build-msi never ran at
# all (e.g. Windows Build itself failed), there is nothing new to
# publish and regenerating the page would be pointless.
if: >
always() &&
needs.build-msi.result != 'skipped' &&
needs.build-msi.result != 'cancelled'
permissions: permissions:
contents: write contents: write
pages: write pages: write
+2
View File
@@ -1,5 +1,7 @@
*.snap *.snap
.flatpak-builder .flatpak-builder
__pycache__/
*.pyc
# Qt build output # Qt build output
*.user *.user
# doxygen Doxyfile output # doxygen Doxyfile output
-1
View File
@@ -318,7 +318,6 @@ target_include_directories(
${QET_DIR}/sources/NameList ${QET_DIR}/sources/NameList
${QET_DIR}/sources/NameList/ui ${QET_DIR}/sources/NameList/ui
${QET_DIR}/sources/utils ${QET_DIR}/sources/utils
${QET_DIR}/pugixml/src
${QET_DIR}/sources/dataBase ${QET_DIR}/sources/dataBase
${QET_DIR}/sources/dataBase/ui ${QET_DIR}/sources/dataBase/ui
${QET_DIR}/sources/factory/ui ${QET_DIR}/sources/factory/ui
+7 -21
View File
@@ -27,18 +27,12 @@ git submodule update --init --recursive
| C++17 compiler | required | GCC or Clang on Unix-like platforms; MSVC or MinGW-w64 g++ on Windows — see [Choosing a compiler](#3-choosing-a-compiler-unix) / [Building on Windows](#6-building-on-windows-msvc--mingw) | | C++17 compiler | required | GCC or Clang on Unix-like platforms; MSVC or MinGW-w64 g++ on Windows — see [Choosing a compiler](#3-choosing-a-compiler-unix) / [Building on Windows](#6-building-on-windows-msvc--mingw) |
| Qt6 base + widgets | required | | | Qt6 base + widgets | required | |
| Qt6 **GuiPrivate** headers | required | needed for clickable PDF hyperlinks; **hard build failure** at CMake generate time if missing, see below | | Qt6 **GuiPrivate** headers | required | needed for clickable PDF hyperlinks; **hard build failure** at CMake generate time if missing, see below |
| SQLite3 | required | used by the nomenclature/summary database |
| Qt Linguist tools (`lrelease`) | required | compiles the tracked `.ts` files into `.qm` as part of every normal build | | Qt Linguist tools (`lrelease`) | required | compiles the tracked `.ts` files into `.qm` as part of every normal build |
| pugixml | handled automatically | fetched and built via CMake FetchContent if not already present on the system — see [pugixml](#8-pugixml) below | | pugixml | handled automatically | fetched and built via CMake FetchContent if not already present on the system — see [pugixml](#8-pugixml) below |
| Qt Test module | required if building tests | `PACKAGE_TESTS` is `ON` by default; QtTest ships as part of the base Qt6 dev packages listed below on every platform, no extra package needed | | Qt Test module | required if building tests | `PACKAGE_TESTS` is `ON` by default; QtTest ships as part of the base Qt6 dev packages listed below on every platform, no extra package needed |
| KDE Frameworks (KF6) | optional | see [Building without KDE Frameworks](#9-building-without-kde-frameworks) | | KDE Frameworks (KF6) | optional | see [Building without KDE Frameworks](#9-building-without-kde-frameworks) |
| QtPdf module | optional | see [PDF page import](#7-pdf-page-import-qtpdf) | | QtPdf module | optional | see [PDF page import](#7-pdf-page-import-qtpdf) |
A note on CMake versions: the project declares a minimum of 3.5 but is
routinely built with much newer releases; if your CMake is older than 4.3 it
simply won't have the newer `SQLite3::SQLite3` target name, which the build
script compensates for automatically. There is nothing you need to do either
way.
## 3. Building (out-of-source build) ## 3. Building (out-of-source build)
@@ -106,7 +100,7 @@ the closest match.
sudo apt install \ sudo apt install \
build-essential cmake ninja-build git \ build-essential cmake ninja-build git \
qt6-base-dev qt6-base-private-dev qt6-tools-dev qt6-tools-dev-tools \ qt6-base-dev qt6-base-private-dev qt6-tools-dev qt6-tools-dev-tools \
libsqlite3-dev \
libkf6coreaddons-dev libkf6widgetsaddons-dev libkf6coreaddons-dev libkf6widgetsaddons-dev
``` ```
@@ -138,7 +132,6 @@ sudo apt install libpugixml-dev
sudo dnf install \ sudo dnf install \
cmake gcc-c++ git \ cmake gcc-c++ git \
qt6-qtbase-devel qt6-qtbase-private-devel qt6-qttools-devel \ qt6-qtbase-devel qt6-qtbase-private-devel qt6-qttools-devel \
sqlite-devel \
kf6-kcoreaddons-devel kf6-kwidgetsaddons-devel kf6-kcoreaddons-devel kf6-kwidgetsaddons-devel
``` ```
@@ -155,11 +148,10 @@ Optional, for a system pugixml: `sudo dnf install pugixml-devel`.
pkg install \ pkg install \
cmake git \ cmake git \
qt6-base qt6-tools \ qt6-base qt6-tools \
sqlite3 \
kf6-kcoreaddons kf6-kwidgetsaddons kf6-kcoreaddons kf6-kwidgetsaddons
``` ```
(from ports: `devel/qt6-base`, `devel/qt6-tools`, `databases/sqlite3`, (from ports: `devel/qt6-base`, `devel/qt6-tools`,
`devel/kf6-kcoreaddons`, `x11-toolkits/kf6-kwidgetsaddons`.) Qt6's `devel/kf6-kcoreaddons`, `x11-toolkits/kf6-kwidgetsaddons`.) Qt6's
`GuiPrivate` headers ship as part of `qt6-base` on FreeBSD, no separate `GuiPrivate` headers ship as part of `qt6-base` on FreeBSD, no separate
package is needed. `QtPdf` is not packaged on FreeBSD at the time of writing package is needed. `QtPdf` is not packaged on FreeBSD at the time of writing
@@ -175,7 +167,7 @@ Optional, for a system pugixml: `pkg install pugixml` (`devel/pugixml`).
Using [Homebrew](https://brew.sh): Using [Homebrew](https://brew.sh):
```sh ```sh
brew install cmake qt sqlite ninja brew install cmake qt ninja
``` ```
Homebrew's `qt` formula is Qt6 and includes the private headers, so no Homebrew's `qt` formula is Qt6 and includes the private headers, so no
@@ -193,7 +185,7 @@ Optional, for a system pugixml: `brew install pugixml`.
See [Building on Windows](#6-building-on-windows-msvc--mingw) below — the See [Building on Windows](#6-building-on-windows-msvc--mingw) below — the
package sources differ enough from the Unix-like platforms above (no system package sources differ enough from the Unix-like platforms above (no system
package manager, SQLite3 and Qt aren't provided the same way) that it gets package manager and Qt aren't provided the same way) that it gets
its own section. its own section.
## 5. pugixml ## 5. pugixml
@@ -215,7 +207,7 @@ on Debian/Ubuntu, `pugixml-devel` on Fedora).
Both toolchains QET's CMake build targets on Windows are covered here: Both toolchains QET's CMake build targets on Windows are covered here:
**MSVC** (Visual Studio 2019/2022) and **MinGW-w64** (gcc). Unlike the **MSVC** (Visual Studio 2019/2022) and **MinGW-w64** (gcc). Unlike the
Unix-like platforms above, there's no single system package manager, so Unix-like platforms above, there's no single system package manager, so
Qt, SQLite3 and (optionally) KDE Frameworks each need to be sourced Qt and (optionally) KDE Frameworks each need to be sourced
separately per toolchain. separately per toolchain.
One piece of good news either way: unlike Debian/Fedora, the official Qt One piece of good news either way: unlike Debian/Fedora, the official Qt
@@ -236,11 +228,7 @@ normally use `-DBUILD_WITH_KF=OFF` (see the
1. Install Visual Studio with the "Desktop development with C++" workload, 1. Install Visual Studio with the "Desktop development with C++" workload,
and install Qt6 for MSVC (e.g. the `msvc2019_64` or `msvc2022_64` kit) and install Qt6 for MSVC (e.g. the `msvc2019_64` or `msvc2022_64` kit)
via the [Qt Online Installer](https://www.qt.io/download-qt-installer). via the [Qt Online Installer](https://www.qt.io/download-qt-installer).
2. Get SQLite3 — the simplest route is [vcpkg](https://vcpkg.io): 2. Configure and build from an "x64 Native Tools Command Prompt for VS":
```bat
vcpkg install sqlite3:x64-windows
```
3. Configure and build from an "x64 Native Tools Command Prompt for VS":
```bat ```bat
mkdir build && cd build mkdir build && cd build
cmake .. -G "Visual Studio 17 2022" -A x64 ^ cmake .. -G "Visual Studio 17 2022" -A x64 ^
@@ -293,9 +281,7 @@ Using the Qt Online Installer's bundled MinGW kit instead: point
`CMAKE_PREFIX_PATH` at that kit (e.g. `C:\Qt\6.x.x\mingw_64`) and make sure `CMAKE_PREFIX_PATH` at that kit (e.g. `C:\Qt\6.x.x\mingw_64`) and make sure
its bundled `g++.exe` comes first on `PATH`, or pass its bundled `g++.exe` comes first on `PATH`, or pass
`-DCMAKE_C_COMPILER`/`-DCMAKE_CXX_COMPILER` explicitly so CMake doesn't pick `-DCMAKE_C_COMPILER`/`-DCMAKE_CXX_COMPILER` explicitly so CMake doesn't pick
up a different MinGW installation. SQLite3 still has to come from elsewhere up a different MinGW installation.
in this path — vcpkg with a `mingw`-flavoured triplet, or MSYS2's package as
above.
## 7. Qt6 private headers (mandatory) ## 7. Qt6 private headers (mandatory)
+1 -2
View File
@@ -71,7 +71,6 @@ parts:
source: . source: .
stage-packages: stage-packages:
- git - git
- sqlite3
- xdg-user-dirs - xdg-user-dirs
- libqt6qml6 - libqt6qml6
# libxcb-cursor0 workaround was needed against the Qt5/KF5 core22 content # libxcb-cursor0 workaround was needed against the Qt5/KF5 core22 content
@@ -82,7 +81,7 @@ parts:
- git - git
- cmake - cmake
- ninja-build - ninja-build
- libsqlite3-dev
- qt6-tools-dev - qt6-tools-dev
- qt6-base-private-dev - qt6-base-private-dev
- qt6-declarative-dev - qt6-declarative-dev
@@ -62,7 +62,9 @@ if(WIN32)
# puts the .qm files (see build-aux/windows/QElectroTech.wxs and the # puts the .qm files (see build-aux/windows/QElectroTech.wxs and the
# windows-build workflow), and what the shortcuts pass as --lang-dir. # windows-build workflow), and what the shortcuts pass as --lang-dir.
set(QET_LANG_PATH "lang/") set(QET_LANG_PATH "lang/")
set(QET_EXAMPLES_PATH "examples/")
set(QET_LICENSE_PATH "./") set(QET_LICENSE_PATH "./")
set(QET_ICONS_PATH "icons/hicolor/")
# Liste des ressources Windows # Liste des ressources Windows
#RC_FILE = qelectrotech.rc #RC_FILE = qelectrotech.rc
endif() endif()
+4 -4
View File
@@ -312,6 +312,8 @@ set(QET_SRC_FILES
${QET_DIR}/sources/dataBase/projectdatabase.cpp ${QET_DIR}/sources/dataBase/projectdatabase.cpp
${QET_DIR}/sources/dataBase/projectdatabase.h ${QET_DIR}/sources/dataBase/projectdatabase.h
${QET_DIR}/sources/dataBase/sqlreadonly.cpp
${QET_DIR}/sources/dataBase/sqlreadonly.h
${QET_DIR}/sources/dataBase/ui/elementquerywidget.cpp ${QET_DIR}/sources/dataBase/ui/elementquerywidget.cpp
${QET_DIR}/sources/dataBase/ui/elementquerywidget.h ${QET_DIR}/sources/dataBase/ui/elementquerywidget.h
@@ -507,10 +509,6 @@ set(QET_SRC_FILES
${QET_DIR}/sources/PropertiesEditor/propertieseditorwidget.cpp ${QET_DIR}/sources/PropertiesEditor/propertieseditorwidget.cpp
${QET_DIR}/sources/PropertiesEditor/propertieseditorwidget.h ${QET_DIR}/sources/PropertiesEditor/propertieseditorwidget.h
${QET_DIR}/pugixml/src/pugiconfig.hpp
${QET_DIR}/pugixml/src/pugixml.cpp
${QET_DIR}/pugixml/src/pugixml.hpp
${QET_DIR}/sources/qetgraphicsitem/conductor.cpp ${QET_DIR}/sources/qetgraphicsitem/conductor.cpp
${QET_DIR}/sources/qetgraphicsitem/conductor.h ${QET_DIR}/sources/qetgraphicsitem/conductor.h
${QET_DIR}/sources/qetgraphicsitem/conductortextitem.cpp ${QET_DIR}/sources/qetgraphicsitem/conductortextitem.cpp
@@ -725,6 +723,8 @@ set(QET_SRC_FILES
${QET_DIR}/sources/ui/backupdialog.h ${QET_DIR}/sources/ui/backupdialog.h
${QET_DIR}/sources/ui/dialogwaiting.cpp ${QET_DIR}/sources/ui/dialogwaiting.cpp
${QET_DIR}/sources/ui/dialogwaiting.h ${QET_DIR}/sources/ui/dialogwaiting.h
${QET_DIR}/sources/ui/duplicateoffsetdialog.cpp
${QET_DIR}/sources/ui/duplicateoffsetdialog.h
${QET_DIR}/sources/ui/dynamicelementtextitemeditor.cpp ${QET_DIR}/sources/ui/dynamicelementtextitemeditor.cpp
${QET_DIR}/sources/ui/dynamicelementtextitemeditor.h ${QET_DIR}/sources/ui/dynamicelementtextitemeditor.h
${QET_DIR}/sources/ui/dynamicelementtextmodel.cpp ${QET_DIR}/sources/ui/dynamicelementtextmodel.cpp
+242 -23
View File
@@ -30,14 +30,20 @@ here read the model.
| `qet_project_info` | title, format version, folios, element and conductor counts | | `qet_project_info` | title, format version, folios, element and conductor counts |
| `qet_elements` | placed elements: uuid, type, position, label, information bag | | `qet_elements` | placed elements: uuid, type, position, label, information bag |
| `qet_conductors` | conductors and their documentation fields; filter by attribute | | `qet_conductors` | conductors and their documentation fields; filter by attribute |
| `qet_diff` | **what an edit actually changed**moves with deltas, adds, removes, relabels, conductor field changes | | `qet_diff` | **what an edit actually changed**element moves, adds, removes, relabels; conductor changes; and folio fields, texts, shapes, images, symbol text fields and terminal strips |
| `qet_scan` | sweep a directory of projects, counting nodes carrying an attribute | | `qet_scan` | sweep a directory of projects, counting nodes carrying an attribute |
| `qet_element_info` | a `.elmt`: translated names, terminals, info fields, part counts | | `qet_element_info` | a `.elmt`: translated names, terminals, info fields, part counts |
| `qet_export` | run a headless export (pdf, png, svg, bom, cables, wires, wiring, nets, links, info) | | `qet_export` | run a headless export (pdf, png, svg, bom, cables, wires, wiring, nets, links, info) |
| `qet_edit` | **change a project** — place, move, rotate, label, wire, number, cross-reference, add text, shapes and images, restyle a symbol's text fields, delete; then diff the result |
| `qet_element_build` | **author a `.elmt`** — draw a new symbol, with terminals to wire it by |
| `qet_project_new` | **start from nothing** — an empty project with a title and folios |
| `qet_element_search` | **find a symbol** in a collection by name (any language), type or terminal count |
| `qet_check` | **design-rule checks** — duplicate labels, unlabelled masters, unnumbered conductors, empty folios |
| `qet_query` | **ask the project database** — read-only SQL over the views and tables |
Only `qet_export` launches QElectroTech. Everything else parses the file `qet_export` and `qet_edit` launch QElectroTech. Everything else parses the
directly, which is faster, needs no display, and cannot be confused by a file directly, which is faster, needs no display, and cannot be confused by
dialog. a dialog.
## Running it ## Running it
@@ -56,12 +62,80 @@ Register it with an MCP client, for example:
"mcpServers": { "mcpServers": {
"qet": { "qet": {
"command": "python3", "command": "python3",
"args": ["/path/to/qelectrotech/misc/qet-mcp/qet_mcp.py"] "args": ["/path/to/qelectrotech/misc/qet-mcp/qet_mcp.py"],
"env": {
"QET_MCP_WORKSPACE": "/home/you/drawings",
"QET_ENABLE_SCRIPTING": "1"
}
} }
} }
} }
``` ```
## Five tools need scripting switched on
A QElectroTech with JavaScript scripting switched off refuses `--run`, and
off is the default from
[#984](https://github.com/qelectrotech/qelectrotech-source-mirror/pull/984)
onwards. Five tools here drive it that way and stop working until it is
turned on:
| | |
|---|---|
| need `QET_ENABLE_SCRIPTING=1` | `qet_query`, `qet_continuity`, `qet_check`, `qet_project_new`, `qet_edit` |
| unaffected | everything else — they read the `.qet` directly, or, in `qet_export`'s case, use a plain CLI flag |
The variable goes in the environment this server is started in, which for an
MCP client is the `env` block above; the server passes its environment
straight through to QElectroTech. It 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.
Without it, those five come back `"ok": false` with a `hint` naming the
variable. Older builds, from before the setting existed, need nothing.
## What the server is allowed to touch
Every path in a tool call is chosen by the model, so without a policy this
server would be a read/write primitive for anything the operating system
lets the process reach: read any project on the disk, export one somewhere
else, overwrite an unrelated file, embed an arbitrary local image or PDF.
So **data paths are confined to a workspace**:
| | |
|---|---|
| `QET_MCP_WORKSPACE` | the directories tool calls may read and write, separated by `:` (`;` on Windows) |
| unset | the directory the server was started in |
| `QET_MCP_ALLOW_ANY_PATH=1` | turns the check off entirely |
Set the workspace to the folder your drawings live in. A path outside it is
refused with an error naming what was allowed; symlinks are resolved first,
so a link planted inside the workspace is judged by where it points.
Two arguments are deliberately **not** confined: `binary` (the
`qelectrotech` executable) and `elements_dir` (the element collection).
Those are configuration, chosen once by whoever runs the server, and both
normally live in `/usr` or a build tree — outside any sensible workspace.
Confining them would reject the ordinary case while stopping nothing.
`QET_MCP_ALLOW_ANY_PATH=1` is equivalent to granting the client local
filesystem access with this process's privileges. It exists so that is a
deliberate choice rather than the default.
**Nothing is overwritten unasked.** `qet_export`, `qet_edit`,
`qet_project_new` and `qet_element_build` refuse an `output` that already
exists unless the call passes `"overwrite": true`. Replacing a file is the
one step this server cannot undo, so it is the one step it will not take on
its own.
The confinement is applied where tool arguments enter the server, not inside
each tool. Importing `qet_mcp` and calling `tool_export()` from your own
Python is not confined and is not meant to be — that is your code calling a
library, and you already chose the paths.
## Worked examples ## Worked examples
**What did that edit change?** **What did that edit change?**
@@ -79,6 +153,74 @@ Register it with an MCP client, for example:
Four elements moved by one uniform delta; nothing was relabelled. That is Four elements moved by one uniform delta; nothing was relabelled. That is
the answer a screenshot gave wrongly. the answer a screenshot gave wrongly.
**Draw something, and check it landed**
```json
{"name": "qet_edit", "arguments": {
"binary": "/path/to/qelectrotech",
"project": "in.qet", "output": "out.qet",
"elements_dir": "/path/to/qelectrotech/elements",
"operations": [
{"op": "add_folio", "id": "f"},
{"op": "set_folio_title", "folio": "$f", "title": "Starter"},
{"op": "add_element", "id": "k1", "folio": "$f", "path": "common://.../coil.elmt", "x": 100, "y": 100},
{"op": "add_element", "id": "k2", "folio": "$f", "path": "common://.../coil.elmt", "x": 320, "y": 100},
{"op": "add_conductor", "folio": "$f", "from": "$k1", "from_terminal": 0, "to": "$k2", "to_terminal": 0},
{"op": "set_conductor", "folio": "$f", "element": "$k1", "terminal": 0, "property": "num", "value": "W7"},
{"op": "set_label", "folio": "$f", "element": "$k1", "label": "KM1"}
]}}
```
An op that creates something takes an `"id"`; later ops name it as `"$id"`.
Terminals are addressed by index — top to bottom, then left to right, **not**
the order the `.elmt` lists them. `qet_element_info` and `qet_element_search`
both report that index order. The answer carries a per-operation result
*and* a `qet_diff`, because "addConductor → true" says the call was
accepted, not that the file came out right:
```json
"diff": {"elements": {"before": 11, "after": 13, "added": ["{0aa3…}", "{6f63…}"]},
"conductors": {"before": 47, "after": 48, "added": ["4:{0aa3…}/{2904…}--{6f63…}/{2904…}"],
"removed": []}}
```
**Draw a symbol that does not exist yet**
```json
{"name": "qet_element_build", "arguments": {
"output": "/path/to/collection/99_custom/my_resistor.elmt",
"names": {"en": "Test resistor", "fr": "Résistance de test"},
"parts": [
{"type": "rect", "x": -10, "y": -20, "width": 20, "height": 40},
{"type": "line", "x1": 0, "y1": -30, "x2": 0, "y2": -20},
{"type": "line", "x1": 0, "y1": 20, "x2": 0, "y2": 30},
{"type": "text", "x": 14, "y": -4, "text": "R"}
],
"terminals": [{"x": 0, "y": -30, "orientation": "n", "name": "1"},
{"x": 0, "y": 30, "orientation": "s", "name": "2"}]}}
```
Then place it with `qet_edit` like any catalogue element. Unlike a
project, a `.elmt` is not rewritten by QElectroTech on a round trip, so
generating one here is safe in a way that generating a `.qet` would not
be — there is no `toXml()` waiting to drop what this writer did not know
to emit.
**Ask a question the XML cannot answer**
```json
{"name": "qet_query", "arguments": {
"binary": "/path/to/qelectrotech", "project": "industrial.qet",
"sql": "SELECT label, COUNT(*) AS n FROM element_nomenclature_view WHERE label <> '' GROUP BY label HAVING n > 1 ORDER BY n DESC"}}
```
```json
"rows": [{"label": "V6", "n": 7}, {"label": "V5", "n": 6}, {"label": "V4", "n": 6}]
```
Duplicate element labels in a shipped example — a design-rule question,
answered by the database that already knew it.
**How much of a corpus uses a field?** **How much of a corpus uses a field?**
```json ```json
@@ -92,14 +234,43 @@ the answer a screenshot gave wrongly.
Across the shipped examples: 3190 conductors, not one with a cable value. Across the shipped examples: 3190 conductors, not one with a cable value.
## Testing
```bash
python3 test_qet_mcp.py # unit + protocol, no QElectroTech needed
QET_BINARY=/path/to/qelectrotech \
QET_ELEMENTS=/path/to/qelectrotech/elements \
QET_EXAMPLES=/path/to/qelectrotech/examples \
QET_ENABLE_SCRIPTING=1 \
python3 test_qet_mcp.py # everything
```
`QET_ENABLE_SCRIPTING=1` matters from #984 onwards: without it the
integration tests that drive QElectroTech through a script all fail, and
they fail as "the edit did nothing" rather than as "scripting is off", which
reads like a regression in the thing under test.
176 tests in three layers: unit (validation, script generation, the terminal
order rule, the diff, the part schema), the real stdio transport, and
integration against a built QElectroTech. Several exist because the
behaviour they pin was once wrong and looked right, and say so in their
docstrings. To check the suite itself rather than trust it, each of those
bugs was reintroduced in turn and the suite confirmed to fail: ten in the
Python, plus the hang guard on `addConductor` and the database refresh in
`ConductorCreator` in the C++.
## Notes and limits ## Notes and limits
- **The project database is not reachable from outside the application.** - **The project database is reachable now, through `qet_query`.** It was
`projectDataBase::newQuery()` and `isReadOnlySelect()` are C++-internal not when this server was written, which is why every other structural
and the JavaScript scripting API exposes no SQL binding, so structural tool here re-derives its answer from the XML. Prefer the views —
queries here are done over the XML. A `--query` CLI verb, or a scripting `element_nomenclature_view`, `project_summary_view`, `wiring_list_view`
binding, would let this server expose the guarded read-only SQL surface — which exist to be queried; the underlying tables are how the cache is
instead, and would be a better foundation. arranged today and a column may move. Call `qet_query` with no `sql` to
list both. Only `SELECT` and `WITH` are accepted, which is the rule
QElectroTech applies to its own custom-query box, not one invented here.
An empty result and a failed query are told apart: `row_count` 0 with no
`error` means nothing matched, and a typo'd column name says so.
- **`qet_export` isolates its launch.** SingleApplication keys its socket - **`qet_export` isolates its launch.** SingleApplication keys its socket
on `applicationFilePath()`, so a second launch of the same binary path on `applicationFilePath()`, so a second launch of the same binary path
forwards its request to an already-running instance and returns *that* forwards its request to an already-running instance and returns *that*
@@ -115,16 +286,64 @@ Across the shipped examples: 3190 conductors, not one with a cable value.
its ends with `terminal1`/`terminal2`, and the project format has two its ends with `terminal1`/`terminal2`, and the project format has two
schemes: folio-scoped integer ids in older files, terminal-definition schemes: folio-scoped integer ids in older files, terminal-definition
uuids plus `element1`/`element2` in newer ones. The integer ids are uuids plus `element1`/`element2` in newer ones. The integer ids are
**renumbered on every save**, so keying on them made all 47 conductors of **renumbered on every save**, so keying on them — which this tool did at
an untouched `ArduinoLCD.qet` read as 29 removed and 29 re-added the first — made all 47 conductors of an untouched folio read as removed and
moment the other side had been through QElectroTech. Ends are now keyed re-added the moment the other side had been through QElectroTech, which
by owning element uuid plus terminal, which is stable across a save: is exactly what `qet_edit` produces. They are now keyed by owning element
measured at 0 colliding keys over 3190 conductors in the 24 shipped uuid plus terminal, which is stable across a save: measured at 0 colliding
examples, and 0 churn on a re-saved but otherwise untouched project. keys over 3190 conductors in the 24 shipped examples, and 0 churn on a
Where an element predates persisted uuids the end cannot be resolved and no-op edit. Where an element predates persisted uuids the end cannot be
keeps a `#`-marked unstable key; the diff then reports `unstable_keys` resolved and keeps a `#`-marked unstable key; the diff then reports
and says so rather than pretending to be comparable. `unstable_keys` and says so rather than pretending to be comparable.
- **Texts, shapes and images have no uuid**, so `qet_diff` cannot say "the same
text, edited": an edited text reads as the old one removed and a new one
added, both shown. Shapes and images are keyed by position, so a restyle
or rescale *is* reported as a change to that item, but a move reads as a
removal plus an addition. The folio `version` attribute is left out of the
comparison on purpose: QElectroTech rewrites it on every save, and
including it made every folio of any re-saved project look edited.
- **Elements** written before persisted uuids fall back to a positional key, - **Elements** written before persisted uuids fall back to a positional key,
which makes a move in such a file read as a remove plus an add rather which makes a move in such a file read as a remove plus an add.
than as a move. - **`qet_edit` needs a build whose scripting API carries the drawing verbs.**
- Read-only by design. Nothing here writes to a project. Against an older one it reports exactly which methods are missing and
changes nothing. `addElement` and the move/delete verbs shipped with the
scripting API; `addConductor`, `rotateElement`, `setElementLabel`,
`setElementInfo` and `setFolioTitle` are newer.
- **`elements_dir` is not optional for `common://` paths.** The sandboxed
run has its own empty HOME, so QElectroTech falls back to the compiled-in
collection path, which on a machine that never ran `make install` does not
exist. The only symptom is `addElement` reporting that a file plainly
present "does not resolve to an element". An absolute `.elmt` path works
without it.
- **`set_conductor` changes the whole potential, not one segment.** That is
what the application does — a wire number describes a potential — so name
a terminal carrying exactly one conductor and the change reaches every
conductor electrically joined to it. A terminal several conductors meet
at names none of them and is refused, so address a potential from one of
its leaves. Property names are the file's own, so `qet_conductors` reads
back exactly what was set.
- **`link_elements` takes a folio for each end**, because a master and its
slave are normally on different folios. Whether a pair may be linked is
decided by QElectroTech's own `isLinkable()`, so a script cannot make a
link the GUI would refuse.
- **An element must live inside a collection to be placeable.** This is
not about the path syntax: an absolute `.elmt` path works, but only if
the file sits under a directory QElectroTech knows as a collection.
Write it under the tree you pass as `elements_dir` and `qet_edit` can
place it, by absolute path or as `common://…`; write it anywhere else
and `add_element` reports only "does not resolve to an element".
- **`qet_element_build` computes the `.elmt` size header, and checks it.**
`width`/`height`/`hotspot_x`/`hotspot_y` relate to the drawing by a
containment constraint, not a formula — the declared box runs from
`(-hotspot_x, -hotspot_y)` to `(width - hotspot_x, height - hotspot_y)`
and the drawing must fit inside it. The shipped collection shows authors
picking their own margins (one element pads 2 units left and 3 right,
another 8 and 2), so there is no convention to copy, only an invariant
to satisfy. A drawing that escaped its box is the classic way a
hand-written element renders clipped in the collection panel while
looking fine in XML.
- **QElectroTech interrupts a script at 30 s** of its own accord, separately
from this tool's `timeout`. A very long operation list will hit that
first.
- **`qet_edit` never writes the input.** It saves to a separate file and
diffs the two, so the original is always the thing the diff is against.
Binary file not shown.
@@ -0,0 +1,44 @@
<definition version="0.100.0" type="element" link_type="master" width="40" height="60" hotspot_x="17" hotspot_y="32">
<uuid uuid="{6d3714f6-a0e1-71b3-e4bd-5a35d4cbbec1}"/>
<names>
<name lang="ar">ملف KA بمحتفظ مغناطيسي</name>
<name lang="ca">Bobina</name>
<name lang="cs">Bistabilní remanentní relé</name>
<name lang="de">Remanenzrelais</name>
<name lang="el">Πηνίο με μανδάλωση</name>
<name lang="en">Coil</name>
<name lang="es">Bobina KA de remanencia</name>
<name lang="fr">Bobine KA à rémanence</name>
<name lang="hu">Tekercs</name>
<name lang="it">Bobina</name>
<name lang="nl">spoel remanent</name>
<name lang="nl_BE">Spoel KA remanent</name>
<name lang="pl">Cewka przekaźnika remanencyjnego</name>
<name lang="pt_BR">Bobina</name>
<name lang="ru">Обмотка</name>
<name lang="zh">剩磁保持线圈</name>
</names>
<kindInformations>
<kindInformation name="type">plc</kindInformation>
</kindInformations>
<informations>Author: The QElectroTech team
License: see http://qelectrotech.org/wiki/doc/elements_license</informations>
<description>
<rect x="14" y="-8" width="6" height="16" rx="0" ry="0" style="line-style:normal;line-weight:normal;filling:none;color:black" antialias="false"/>
<line x1="0" y1="-20" x2="0" y2="-8" end1="none" end2="none" length1="1.5" length2="1.5" style="line-style:normal;line-weight:normal;filling:none;color:black" antialias="false"/>
<dynamic_text x="2" y="11.33" z="5" text_width="-1" Halignment="AlignLeft" Valignment="AlignTop" frame="false" rotation="0" keep_visual_rotation="false" text_from="UserText" uuid="{647e33ed-520b-4d80-bbc4-7b31177b8f26}" font="Liberation Sans,4,-1,5,25,0,0,0,0,0,Regular">
<text>A2</text>
</dynamic_text>
<dynamic_text x="2" y="-24.67" z="6" text_width="-1" Halignment="AlignLeft" Valignment="AlignTop" frame="false" rotation="0" keep_visual_rotation="false" text_from="UserText" uuid="{fb1f8de9-70c0-4801-8dc7-12e6bf88d8f5}" font="Liberation Sans,4,-1,5,25,0,0,0,0,0,Regular">
<text>A1</text>
</dynamic_text>
<line x1="0" y1="8" x2="0" y2="20" end1="none" end2="none" length1="1.5" length2="1.5" style="line-style:normal;line-weight:normal;filling:none;color:black" antialias="false"/>
<dynamic_text x="30" y="-9.17" z="8" text_width="-1" Halignment="AlignLeft" Valignment="AlignTop" frame="false" rotation="0" keep_visual_rotation="false" text_from="ElementInfo" uuid="{b7fccfc3-05f1-459c-9766-f49a481bd0ff}" font="Liberation Sans,9,-1,5,50,0,0,0,0,0,Regular">
<text></text>
<info_name>label</info_name>
</dynamic_text>
<polygon x1="-14" y1="-8" x2="14" y2="-8" x3="20" y3="8" x4="-14" y4="8" antialias="false" style="line-style:normal;line-weight:normal;filling:none;color:black"/>
<terminal uuid="{6a87c921-6f5d-4f5c-8673-228a6d13c5c8}" name="A2" x="0" y="20" orientation="s" type="Generic"/>
<terminal uuid="{2904e5fa-6bbe-4127-acdd-f92feadd6ece}" name="A1" x="0" y="-20" orientation="n" type="Generic"/>
</description>
</definition>
+16
View File
@@ -0,0 +1,16 @@
<definition version="0.100.0" type="element" link_type="slave" width="20" height="60" hotspot_x="9" hotspot_y="30">
<uuid uuid="{94d56f06-6814-5561-9148-2da3a9c4f900}"/>
<names>
<name lang="en">PLC Slave Test</name>
</names>
<kindInformations>
<kindInformation name="type">plc</kindInformation>
<kindInformation name="state">NO</kindInformation>
<kindInformation name="number">1</kindInformation>
</kindInformations>
<description>
<rect x="0" y="-8" width="6" height="16" rx="0" ry="0" style="line-style:normal;line-weight:normal;filling:none;color:black" antialias="false"/>
<terminal uuid="{6a87c921-6f5d-4f5c-8673-228a6d13c500}" name="1" x="0" y="20" orientation="s" type="Generic"/>
<terminal uuid="{2904e5fa-6bbe-4127-acdd-f92feadd6e00}" name="2" x="0" y="-20" orientation="n" type="Generic"/>
</description>
</definition>
+2060 -8
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
#!/bin/bash #!/bin/bash
#Based on raspberry pi 5 8 Gb Trixie #Based on raspberry pi 5 8 Gb Trixie
#sudo apt install git ssh rsync libqt5svg5-dev qt5-qmake qtbase5-dev libkf5widgetsaddons-dev libkf5coreaddons-dev libsqlite3-dev pkgconf libqt5waylandclient5-dev libqt5waylandcompositor5-dev g++ make #sudo apt install git ssh rsync libqt5svg5-dev qt5-qmake qtbase5-dev libkf5widgetsaddons-dev libkf5coreaddons-dev pkgconf libqt5waylandclient5-dev libqt5waylandcompositor5-dev g++ make
#mkdir -p AppImage/0.100.0/aarch64 #mkdir -p AppImage/0.100.0/aarch64
# Get GIT sources # Get GIT sources
#git clone --recursive https://github.com/qelectrotech/qelectrotech-source-mirror.git #git clone --recursive https://github.com/qelectrotech/qelectrotech-source-mirror.git
@@ -20,7 +20,7 @@
#include "../NameList/nameslist.h" #include "../NameList/nameslist.h"
#include "../diagramcontext.h" #include "../diagramcontext.h"
#include "pugixml/src/pugixml.hpp" #include "pugixml.hpp"
#include <QIcon> #include <QIcon>
#include <QString> #include <QString>
+1 -1
View File
@@ -17,7 +17,7 @@
*/ */
#ifndef NAMES_LIST_H #ifndef NAMES_LIST_H
#define NAMES_LIST_H #define NAMES_LIST_H
#include "pugixml/src/pugixml.hpp" #include "pugixml.hpp"
#include <QtXml> #include <QtXml>
/** /**
@@ -171,20 +171,20 @@ MoveTerminalCommand::MoveTerminalCommand(QSharedPointer<PhysicalTerminal> termin
QString text; QString text;
if (t_label.isEmpty()) { if (t_label.isEmpty()) {
if (strip_name.isEmpty() && new_strip_name.isEmpty()) if (strip_name.isEmpty() && new_strip_name.isEmpty())
text = QObject::tr("Déplacer une borne d'un groupe de bornes vers un groupe de bornes"); text = QObject::tr("Déplacer une borne d'un groupe de bornes vers un autre groupe de bornes");
else if (strip_name.isEmpty()) else if (strip_name.isEmpty())
text = QObject::tr("Déplacer une borne d'un groupe de bornes vers le groupe de bornes %1").arg(new_strip_name); text = QObject::tr("Déplacer une borne d'un groupe de bornes vers le groupe de bornes %1").arg(new_strip_name);
else if (new_strip_name.isEmpty()) else if (new_strip_name.isEmpty())
text = QObject::tr("Déplacer une borne du groupe de bornes %1 vers un groupe de bornes").arg(strip_name); text = QObject::tr("Déplacer une borne du groupe de bornes %1 vers un autre groupe de bornes").arg(strip_name);
else else
text = QObject::tr("Déplacer une borne du groupe de bornes %1 vers le groupe de bornes %2").arg(strip_name, new_strip_name); text = QObject::tr("Déplacer une borne du groupe de bornes %1 vers le groupe de bornes %2").arg(strip_name, new_strip_name);
} else { } else {
if (strip_name.isEmpty() && new_strip_name.isEmpty()) if (strip_name.isEmpty() && new_strip_name.isEmpty())
text = QObject::tr("Déplacer la borne %1 d'un groupe de bornes vers un groupe de bornes").arg(t_label); text = QObject::tr("Déplacer la borne %1 d'un groupe de bornes vers un autre groupe de bornes").arg(t_label);
else if (strip_name.isEmpty()) else if (strip_name.isEmpty())
text = QObject::tr("Déplacer la borne %1 d'un groupe de bornes vers le groupe de bornes %2").arg(t_label, new_strip_name); text = QObject::tr("Déplacer la borne %1 d'un groupe de bornes vers le groupe de bornes %2").arg(t_label, new_strip_name);
else if (new_strip_name.isEmpty()) else if (new_strip_name.isEmpty())
text = QObject::tr("Déplacer la borne %1 du groupe de bornes %2 vers un groupe de bornes").arg(t_label, strip_name); text = QObject::tr("Déplacer la borne %1 du groupe de bornes %2 vers un autre groupe de bornes").arg(t_label, strip_name);
else else
text = QObject::tr("Déplacer la borne %1 du groupe de bornes %2 vers le groupe de bornes %3").arg(t_label, strip_name, new_strip_name); text = QObject::tr("Déplacer la borne %1 du groupe de bornes %2 vers le groupe de bornes %3").arg(t_label, strip_name, new_strip_name);
} }
@@ -205,11 +205,11 @@ MoveTerminalCommand::MoveTerminalCommand(QVector<QSharedPointer<PhysicalTerminal
QString text; QString text;
if (strip_name.isEmpty() && new_strip_name.isEmpty()) if (strip_name.isEmpty() && new_strip_name.isEmpty())
text = QObject::tr("Déplacer %n borne(s) d'un groupe de bornes vers un groupe de bornes", "", count); text = QObject::tr("Déplacer %n borne(s) d'un groupe de bornes vers un autre groupe de bornes", "", count);
else if (strip_name.isEmpty()) else if (strip_name.isEmpty())
text = QObject::tr("Déplacer %n borne(s) d'un groupe de bornes vers le groupe de bornes %1", "", count).arg(new_strip_name); text = QObject::tr("Déplacer %n borne(s) d'un groupe de bornes vers le groupe de bornes %1", "", count).arg(new_strip_name);
else if (new_strip_name.isEmpty()) else if (new_strip_name.isEmpty())
text = QObject::tr("Déplacer %n borne(s) du groupe de bornes %1 vers un groupe de bornes", "", count).arg(strip_name); text = QObject::tr("Déplacer %n borne(s) du groupe de bornes %1 vers un autre groupe de bornes", "", count).arg(strip_name);
else else
text = QObject::tr("Déplacer %n borne(s) du groupe de bornes %1 vers le groupe de bornes %2", "", count).arg(strip_name, new_strip_name); text = QObject::tr("Déplacer %n borne(s) du groupe de bornes %1 vers le groupe de bornes %2", "", count).arg(strip_name, new_strip_name);
setText(text); setText(text);
+12 -2
View File
@@ -896,10 +896,20 @@ void BorderTitleBlock::updateDiagramContextForTitleBlock(
// An empty page-level value means the variable was auto-added to the // An empty page-level value means the variable was auto-added to the
// folio's Custom tab (#495) but never actually set by the user, so it // folio's Custom tab (#495) but never actually set by the user, so it
// must not shadow a real project-level value of the same name (#531). // must not shadow a real project-level value of the same name (#531).
//
// That guard has to stop short of removing the key outright, though
// (#973). TitleBlockTemplate::interpreteVariables() only replaces a
// "%name"/"%{name}" placeholder when "name" is a key in this context at
// all -- an unset variable that never makes it in is left as its own
// literal placeholder text in the rendered title block, not blank.
// So an empty page-level value is skipped only when a real project-level
// one is already there to show through; otherwise it still goes in
// empty, which is what makes the placeholder resolve to nothing.
DiagramContext context = initial_context; DiagramContext context = initial_context;
foreach (QString key, additional_fields_.keys()) { foreach (QString key, additional_fields_.keys()) {
if (!additional_fields_[key].toString().isEmpty()) const QVariant value = additional_fields_[key];
context.addValue(key, additional_fields_[key]); if (!value.toString().isEmpty() || !context.contains(key))
context.addValue(key, value);
} }
// ... overridden by the historical and/or dynamically generated fields // ... overridden by the historical and/or dynamically generated fields
+41 -21
View File
@@ -17,6 +17,8 @@
*/ */
#include "projectdatabase.h" #include "projectdatabase.h"
#include "sqlreadonly.h"
#include "../diagram.h" #include "../diagram.h"
#include "../diagramposition.h" #include "../diagramposition.h"
#include "../elementprovider.h" #include "../elementprovider.h"
@@ -28,13 +30,15 @@
#include "../qetproject.h" #include "../qetproject.h"
#include <QLocale> #include <QLocale>
#include <QFile>
#include <QRegularExpression> #include <QRegularExpression>
#include <QSqlError>
#include <QSqlDriver> #include <QSqlDriver>
#include <QSqlError>
#include <sqlite3.h> #include <sqlite3.h>
/** /**
@brief projectDataBase::projectDataBase @brief projectDataBase::projectDataBase
Default constructor Default constructor
@@ -208,6 +212,12 @@ bool projectDataBase::isReadOnlySelect(const QString &query, QString *error)
*/ */
QSqlQuery projectDataBase::newQuery(const QString &query, QString *error) { QSqlQuery projectDataBase::newQuery(const QString &query, QString *error) {
QString reason; QString reason;
// First gate: which kind of statement is acceptable here at all. A
// textual check is the right tool for that and the wrong tool for
// anything else -- see isReadOnlySelect()'s own comment. It is what
// keeps ATTACH, BEGIN and PRAGMA out, none of which SQLite itself
// considers writes.
if (!isReadOnlySelect(query, &reason)) { if (!isReadOnlySelect(query, &reason)) {
qWarning().noquote() << "projectDataBase::newQuery: rejected query:" << reason << "--" << query; qWarning().noquote() << "projectDataBase::newQuery: rejected query:" << reason << "--" << query;
if (error) { if (error) {
@@ -215,6 +225,24 @@ QSqlQuery projectDataBase::newQuery(const QString &query, QString *error) {
} }
return QSqlQuery(m_data_base); return QSqlQuery(m_data_base);
} }
// Second gate, and the one that actually enforces read-only: SQLite is
// asked about the statement it compiled, instead of the text being read
// for clues. The first gate cannot see through a CTE prefix --
// "WITH x AS (SELECT 1) DELETE FROM element" starts with WITH, contains
// no semicolon, and deletes every row. That matters beyond the
// custom-query box, because this path is reachable from a file: a
// <graphics_table>'s saved <query> is read straight out of the .qet by
// ProjectDBModel::fromXml() and executed by fillValue(), so opening or
// exporting a project someone else produced would have been enough.
if (!QETSql::isSingleReadOnlyStatement(sqliteHandle(&m_data_base), query, &reason)) {
qWarning().noquote() << "projectDataBase::newQuery: rejected query:" << reason << "--" << query;
if (error) {
*error = reason;
}
return QSqlQuery(m_data_base);
}
return QSqlQuery(query, m_data_base); return QSqlQuery(query, m_data_base);
} }
@@ -1245,7 +1273,6 @@ void projectDataBase::bindDiagramInfoValues(QSqlQuery &query, Diagram *diagram)
} }
} }
#ifdef QET_EXPORT_PROJECT_DB
/** /**
@brief projectDataBase::sqliteHandle @brief projectDataBase::sqliteHandle
@param db @param db
@@ -1263,6 +1290,7 @@ sqlite3 *projectDataBase::sqliteHandle(QSqlDatabase *db)
return handle; return handle;
} }
#ifdef QET_EXPORT_PROJECT_DB
/** /**
* @brief projectDataBase::exportDb * @brief projectDataBase::exportDb
@@ -1298,27 +1326,19 @@ void projectDataBase::exportDb(projectDataBase *db,
return; return;
} }
QString connection_name("export_project_db_" % db->project()->uuid().toString()); // VACUUM INTO requires the destination not to exist. QFileDialog may ask
// about overwriting, but it does not remove the existing file for us.
if (true) //Enter in a scope only to nicely use QSqlDatabase::removeDatabase just after the end of the scope if (QFile::exists(path_) && !QFile::remove(path_)) {
{ qWarning() << "Unable to replace project database export:" << path_;
auto file_db = QSqlDatabase::addDatabase("QSQLITE", connection_name);
file_db.setDatabaseName(path_);
if (!file_db.open()) {
return; return;
} }
auto memory_db_handle = sqliteHandle(&db->m_data_base); // VACUUM INTO creates a standalone copy of the current database without
auto file_db_handle = sqliteHandle(&file_db); // requiring access to the SQLite driver's native connection handle.
const auto escaped_path = path_.replace("'", "''");
auto sqlite_backup = sqlite3_backup_init(file_db_handle, "main", memory_db_handle, "main"); QSqlQuery query(db->m_data_base);
if (sqlite_backup) if (!query.exec("VACUUM INTO '" % escaped_path % "'")) {
{ qWarning() << "Unable to export project database:" << query.lastError().text();
sqlite3_backup_step(sqlite_backup, -1);
sqlite3_backup_finish(sqlite_backup);
} }
file_db.close();
}
QSqlDatabase::removeDatabase(connection_name);
} }
#endif #endif
+26 -1
View File
@@ -61,6 +61,26 @@ class projectDataBase : public QObject
QETProject *project() const; QETProject *project() const;
QSqlQuery newQuery(const QString &query = QString(), QString *error = nullptr); QSqlQuery newQuery(const QString &query = QString(), QString *error = nullptr);
static bool isReadOnlySelect(const QString &query, QString *error = nullptr); static bool isReadOnlySelect(const QString &query, QString *error = nullptr);
/**
The most rows any caller reads out of one query result.
A SELECT is not bounded by how much data the project holds:
SQLite produces rows lazily, so a query that never stops
producing them makes the loop that reads them never stop
either. A recursive CTE does exactly that in one line, and
a <graphics_table>'s <query> is stored in the .qet and run
on load -- so the text can arrive from a file rather than
from the person at the keyboard, and opening that file is
the whole attack.
100000 is far above any real result: the largest table in
the shipped examples is 396 rows. It is a backstop, not a
page size -- a caller that hits it has almost certainly
been handed something it should not run to completion, and
says so rather than truncating quietly.
*/
static constexpr int MaxResultRows = 100000;
QSqlDatabase database() const {return m_data_base;} QSqlDatabase database() const {return m_data_base;}
int excludedConductorCount() const; int excludedConductorCount() const;
@@ -135,9 +155,14 @@ class projectDataBase : public QObject
m_cascade_remove_conductor_query, m_cascade_remove_conductor_query,
m_cascade_remove_element_query; m_cascade_remove_element_query;
public:
// Deliberately outside the QET_EXPORT_PROJECT_DB guard below:
// newQuery() needs the raw connection to ask SQLite whether a
// query only reads, and that check runs in every build.
static sqlite3 *sqliteHandle(QSqlDatabase *db);
#ifdef QET_EXPORT_PROJECT_DB #ifdef QET_EXPORT_PROJECT_DB
public: public:
static sqlite3 *sqliteHandle(QSqlDatabase *db);
static void exportDb(projectDataBase *db, static void exportDb(projectDataBase *db,
QWidget *parent = nullptr, QWidget *parent = nullptr,
const QString &caption = QString(), const QString &caption = QString(),
+134
View File
@@ -0,0 +1,134 @@
/*
Copyright 2006-2026 The QElectroTech Team
This file is part of QElectroTech.
QElectroTech is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 2 of the License, or
(at your option) any later version.
QElectroTech is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/
#include "sqlreadonly.h"
#include <QCoreApplication>
#include <sqlite3.h>
namespace QETSql {
/**
@brief QETSql::isSingleReadOnlyStatement
Ask SQLite itself whether @p query is exactly one statement, and whether
that statement only reads.
Why SQLite is asked rather than the text inspected: a check on the
query's first keyword cannot see what the statement actually does.
SQLite has allowed a CTE prefix in front of a data-modifying statement
since 3.8.3, so
@code
WITH x AS (SELECT 1) DELETE FROM element
@endcode
begins with WITH, contains no semicolon, and deletes every row.
sqlite3_stmt_readonly() reports on the statement SQLite compiled, not
on how it was spelled, so the same query is correctly refused here
while an ordinary WITH ... SELECT still passes.
The statement is compiled and immediately finalised; sqlite3_prepare_v2()
does not run it, so nothing is executed to reach this verdict.
This is a read-only test, NOT a statement-type allowlist: SQLite
considers ATTACH, BEGIN and several PRAGMAs read-only too, because none
of them change the contents of the database. Callers that need to
restrict which *kind* of statement is acceptable must say so separately
-- projectDataBase::newQuery() keeps isReadOnlySelect() in front of this
for exactly that reason.
@param handle the connection the query would run on. A null handle is
refused rather than waved through: without it there is nothing to ask,
and guessing from the text is the weakness this exists to replace.
@param query the raw SQL text
@param error set to a human-readable reason when this returns false
@return true if @p query is a single, read-only statement
*/
bool isSingleReadOnlyStatement(sqlite3 *handle, const QString &query, QString *error)
{
if (error) {
error->clear();
}
if (!handle) {
if (error) {
*error = QCoreApplication::translate("QETSql",
"Impossible de vérifier la requête : "
"aucune connexion SQLite disponible.");
}
return false;
}
const QByteArray utf8 = query.toUtf8();
sqlite3_stmt *statement = nullptr;
const char *tail = nullptr;
if (sqlite3_prepare_v2(handle, utf8.constData(), utf8.size(),
&statement, &tail) != SQLITE_OK)
{
if (error) {
*error = QCoreApplication::translate("QETSql",
"Requête SQL invalide : %1")
.arg(QString::fromUtf8(sqlite3_errmsg(handle)));
}
sqlite3_finalize(statement);
return false;
}
// Whitespace or a bare comment compiles successfully to no statement
// at all, and sqlite3_stmt_readonly() must not be handed that.
if (!statement) {
if (error) {
*error = QCoreApplication::translate("QETSql",
"La requête ne contient aucune instruction.");
}
return false;
}
const bool read_only = sqlite3_stmt_readonly(statement) != 0;
sqlite3_finalize(statement);
if (!read_only) {
if (error) {
*error = QCoreApplication::translate("QETSql",
"Seules les requêtes en lecture seule sont autorisées : "
"cette requête modifierait la base de données.");
}
return false;
}
// tail points just past the first statement, semicolon included.
// Anything left once semicolons and spacing are stripped is a second
// statement -- caught structurally here, where "SELECT ';'" is a
// perfectly ordinary query rather than a suspicious string.
if (tail) {
QString rest = QString::fromUtf8(tail);
rest.remove(QLatin1Char(';'));
if (!rest.trimmed().isEmpty()) {
if (error) {
*error = QCoreApplication::translate("QETSql",
"Une seule requête est autorisée.");
}
return false;
}
}
return true;
}
} // namespace QETSql
+43
View File
@@ -0,0 +1,43 @@
/*
Copyright 2006-2026 The QElectroTech Team
This file is part of QElectroTech.
QElectroTech is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 2 of the License, or
(at your option) any later version.
QElectroTech is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef SQLREADONLY_H
#define SQLREADONLY_H
#include <QString>
struct sqlite3;
/**
Deciding whether a piece of SQL only reads.
Deliberately its own translation unit, depending on nothing but QString
and SQLite: it is the enforcement point for every query QElectroTech
runs against a project database, including queries that arrive from
outside the application (a .qet file's saved report/table query), so it
is worth being able to test it in isolation -- see
tests/qttest/tst_sqlreadonly.cpp, which links this file and nothing
else of QElectroTech.
*/
namespace QETSql {
bool isSingleReadOnlyStatement(sqlite3 *handle,
const QString &query,
QString *error = nullptr);
}
#endif // SQLREADONLY_H
+11 -3
View File
@@ -151,8 +151,8 @@ bool DiagramContext::operator!=(const DiagramContext &dc) const
void DiagramContext::toXml(QDomElement &e, const QString &tag_name) const void DiagramContext::toXml(QDomElement &e, const QString &tag_name) const
{ {
foreach (QString key, keys()) { foreach (QString key, keys()) {
if ((tag_name == "elementInformation") && const QString raw = m_content[key].toString();
(m_content[key].toString().trimmed().isEmpty())) { if ((tag_name == "elementInformation") && raw.trimmed().isEmpty()) {
continue; continue;
} }
QDomElement property = e.ownerDocument().createElement(tag_name); QDomElement property = e.ownerDocument().createElement(tag_name);
@@ -161,7 +161,15 @@ void DiagramContext::toXml(QDomElement &e, const QString &tag_name) const
property.removeAttribute("name"); property.removeAttribute("name");
property.setAttribute("show", m_content_show[key]); property.setAttribute("show", m_content_show[key]);
property.setAttribute("name", key); property.setAttribute("name", key);
QDomText value = e.ownerDocument().createTextNode(m_content[key].toString().trimmed()); // Trim stray leading/trailing whitespace around real content, but
// not a value that IS whitespace: unconditionally trimming an
// all-whitespace string collapses it to "", which is silently
// indistinguishable from a value that was never set. A title-block
// custom variable set to a single space -- a workaround for #973,
// where an unset variable renders as its own literal placeholder --
// would otherwise vanish on the very next save.
const QString stored = raw.trimmed().isEmpty() ? raw : raw.trimmed();
QDomText value = e.ownerDocument().createTextNode(stored);
property.appendChild(value); property.appendChild(value);
e.appendChild(property); e.appendChild(property);
} }
+1 -1
View File
@@ -17,7 +17,7 @@
*/ */
#ifndef DIAGRAM_CONTEXT_H #ifndef DIAGRAM_CONTEXT_H
#define DIAGRAM_CONTEXT_H #define DIAGRAM_CONTEXT_H
#include "pugixml/src/pugixml.hpp" #include "pugixml.hpp"
#include <QDomElement> #include <QDomElement>
#include <QHash> #include <QHash>
+32 -15
View File
@@ -22,6 +22,7 @@
#include "../qetapp.h" #include "../qetapp.h"
#include "../qetdiagrameditor.h" #include "../qetdiagrameditor.h"
#include "../qetgraphicsitem/conductor.h" #include "../qetgraphicsitem/conductor.h"
#include "../qetproject.h"
#include <QSettings> #include <QSettings>
@@ -40,6 +41,7 @@
DiagramEventAddPaste::DiagramEventAddPaste(Diagram *diagram, const QPointF &start_pos) : DiagramEventAddPaste::DiagramEventAddPaste(Diagram *diagram, const QPointF &start_pos) :
DiagramEventInterface(diagram) DiagramEventInterface(diagram)
{ {
Q_UNUSED(start_pos); // items stay at their original XML position
//DiagramEventInterface::init() is called by Diagram::setEventInterface //DiagramEventInterface::init() is called by Diagram::setEventInterface
//only when it is replacing an earlier interface, so call it here as //only when it is replacing an earlier interface, so call it here as
//DiagramEventAddMacro does. //DiagramEventAddMacro does.
@@ -51,8 +53,25 @@
QDomDocument document_xml; QDomDocument document_xml;
if (!document_xml.setContent(clipboard_text)) return; if (!document_xml.setContent(clipboard_text)) return;
//Batch the database work the same way project loading does
//(QETProject::readProjectXml): without this, every addItem()
//below emits dataBaseUpdated(), which makes each connected
//table model re-run its full SQL query -- ~77 queries for a
//typical paste, i.e. the multi-second stall on Ctrl+V.
auto *db = m_diagram->project() ? m_diagram->project()->dataBase() : nullptr;
if (db) {
db->blockSignals(true);
db->setUpdateBlocked(true);
}
//Load items at their original XML coordinates. //Load items at their original XML coordinates.
m_diagram->fromXml(document_xml, QPointF(), false, &m_content); m_diagram->fromXml(document_xml, QPointF(), false, &m_content);
if (db) {
db->blockSignals(false);
db->setUpdateBlocked(false);
db->updateDB();
}
if (!m_content.count()) return; if (!m_content.count()) return;
const QList<QGraphicsItem *> movable = m_content.items(MovableItems); const QList<QGraphicsItem *> movable = m_content.items(MovableItems);
@@ -85,33 +104,23 @@
}; };
const QPointF grid_origin = snapGrid(top_left); const QPointF grid_origin = snapGrid(top_left);
//Move the group to the cursor, rather than the cursor to the //Store each item's original position. moveTo() applies a
//group. Both put the copy under the pointer, but warping the
//pointer also drags it back to the original's position, so the
//copy appears exactly on top of what was copied until the mouse
//is moved -- which is the thing pasting under the cursor was
//meant to avoid (issue #913). Taking the pointer away from
//where the user put it is also its own surprise.
m_group_origin = snapGrid(start_pos);
const QPointF offset = m_group_origin - grid_origin;
//Store each item's position after the move. moveTo() applies a
//grid-snapped delta from the baseline to these, so items //grid-snapped delta from the baseline to these, so items
//preserve their layout and move in whole grid steps. //preserve their layout and move in whole grid steps.
for (auto *item : movable) { for (auto *item : movable) {
item->setPos(item->pos() + offset);
m_relative_pos.insert(item, item->pos()); m_relative_pos.insert(item, item->pos());
} }
m_group_origin = grid_origin;
//The conductors were laid out against the old terminal //The conductors were laid out against the original terminal
//positions, so re-route them before anything is drawn. //positions, so re-route them before anything is drawn.
const QList<Conductor *> conductors = m_content.conductors(DiagramContent::AnyConductor); const QList<Conductor *> conductors = m_content.conductors(DiagramContent::AnyConductor);
for (auto *conductor : conductors) { for (auto *conductor : conductors) {
conductor->updatePath(); conductor->updatePath();
} }
//The baseline is known now, so moveTo() does not have to //The baseline is the group's grid-snapped origin, so moveTo()
//capture one from the first mouse movement. //does not have to capture one from the first mouse movement.
m_initial_cursor = m_group_origin; m_initial_cursor = m_group_origin;
m_baseline_captured = true; m_baseline_captured = true;
@@ -125,6 +134,14 @@
if (const auto qde = QETApp::diagramEditorAncestorOf(view)) { if (const auto qde = QETApp::diagramEditorAncestorOf(view)) {
m_status_bar = qde->statusBar(); m_status_bar = qde->statusBar();
} }
//Warp the cursor to the group's grid-snapped origin so
//the actual cursor position matches m_initial_cursor.
//Without this the first mouseMoveEvent computes a large
//delta (cursor is still at the Ctrl+V press location)
//and the items jump on first touch.
const QPoint view_pos = view->mapFromScene(m_initial_cursor);
const QPoint global_pos = view->viewport()->mapToGlobal(view_pos);
QCursor::setPos(global_pos);
} }
} }
showHint(); showHint();
+76
View File
@@ -445,6 +445,82 @@ void DiagramView::pasteHere()
paste(mapToScene(m_paste_here_pos)); paste(mapToScene(m_paste_here_pos));
} }
/**
@brief DiagramView::duplicate
Copy the current selection and place the copy at @p stepOffset grid
steps from it, landing immediately rather than following the cursor
like Ctrl+V does (bugtracker #991). @p stepOffset comes from
DuplicateOffsetDialog: (1, 0) is one grid step right, (0, -1) is one
grid step up, and so on -- QET's own scene axes, X right and Y down.
No interactive placement step on purpose: the point of a duplicate
shortcut is unattended, repeatable stamping (configure the offset
once, then tap Ctrl+D to lay out a row), which following the cursor
would interrupt on every press. QET already reselects whatever a
paste just added (see PasteDiagramCommand::redo()), so the next
Ctrl+D naturally continues from the copy just placed, not the
original -- a press-and-hold row falls out of that for free, with no
special-casing needed here for "keep going from the last one".
The offset is applied by hand rather than by asking paste()/
Diagram::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 the hard way
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. fromXml() is instead called with no position at all, which
leaves every item at its source coordinates (landing the copy
exactly 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 translated set: fromXml() itself does not
reposition them either -- they are loaded from XML 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. Likewise dynamic element texts are not translated separately:
they are children of their element and move with it under Qt's
normal parent-child transform.
*/
void DiagramView::duplicate(const QPoint &stepOffset)
{
if (!isInteractive() || m_diagram->isReadOnly()) return;
const QList<QGraphicsItem *> selection = m_diagram->selectedItems();
if (selection.isEmpty()) return;
QSettings settings;
const int x_grid = settings.value(QStringLiteral("diagrameditor/Xgrid"),
Diagram::xGrid).toInt();
const int y_grid = settings.value(QStringLiteral("diagrameditor/Ygrid"),
Diagram::yGrid).toInt();
const QPointF offset(stepOffset.x() * x_grid, stepOffset.y() * y_grid);
// Mirrors copy(), but does not touch the system clipboard: Ctrl+D
// should not clobber whatever the user last copied with Ctrl+C.
QDomDocument document = m_diagram->toXml(false, true);
DiagramContent pasted;
// No position argument -- see the function comment above for why
// the offset is not passed here.
m_diagram->fromXml(document, QPointF(), false, &pasted);
if (!pasted.count()) return;
const int movable = DiagramContent::Elements | DiagramContent::TextFields
| DiagramContent::Images | DiagramContent::Shapes
| DiagramContent::Tables | DiagramContent::TerminalStrip;
for (QGraphicsItem *item : pasted.items(movable))
item->setPos(item->pos() + offset);
m_diagram->clearSelection();
m_diagram->undoStack().push(new PasteDiagramCommand(m_diagram, pasted));
adjustSceneRect();
}
/** /**
Manage the events press click : Manage the events press click :
* click to add an independent text field * click to add an independent text field
+1
View File
@@ -137,6 +137,7 @@ class DiagramView : public PaletteGraphicsView
void copy(); void copy();
void paste(const QPointF & = QPointF(), QClipboard::Mode = QClipboard::Clipboard); void paste(const QPointF & = QPointF(), QClipboard::Mode = QClipboard::Clipboard);
void pasteHere(); void pasteHere();
void duplicate(const QPoint &stepOffset);
void adjustSceneRect(); void adjustSceneRect();
void updateWindowTitle(); void updateWindowTitle();
void resetConductors(); void resetConductors();
+2 -1
View File
@@ -19,6 +19,7 @@
#include "QPropertyUndoCommand/qpropertyundocommand.h" #include "QPropertyUndoCommand/qpropertyundocommand.h"
#include "diagram.h" #include "diagram.h"
#include "qetapp.h"
#include "qetgraphicsitem/dynamicelementtextitem.h" #include "qetgraphicsitem/dynamicelementtextitem.h"
#include "qetgraphicsitem/elementtextitemgroup.h" #include "qetgraphicsitem/elementtextitemgroup.h"
@@ -146,5 +147,5 @@ QString ElementTextsMover::undoText() const
if (parts.isEmpty()) if (parts.isEmpty())
return QString(); // should never occur return QString(); // should never occur
return QObject::tr("Déplacer %1").arg(QLocale().createSeparatedList(parts)); return QObject::tr("Déplacer %1").arg(QLocale(QETApp::interfaceLanguage()).createSeparatedList(parts));
} }
+7 -1
View File
@@ -32,8 +32,14 @@ class QetGraphicsTableFactory
static void createAndAddNomenclature(Diagram *diagram); static void createAndAddNomenclature(Diagram *diagram);
static void createAndAddSummary(Diagram *diagram); static void createAndAddSummary(Diagram *diagram);
private: // Public so a caller that has already built and configured an
// AddTableDialog itself (never shown or exec'd -- the two
// methods above always exec() one, which the scripting API
// cannot use headlessly) can create a table from it directly.
// create() only reads settings already on the dialog; nothing
// about it depends on the dialog having been shown.
static void create(Diagram *diagram, AddTableDialog *dialog); static void create(Diagram *diagram, AddTableDialog *dialog);
private:
static QetGraphicsTableItem *newTable( static QetGraphicsTableItem *newTable(
Diagram *diagram, Diagram *diagram,
AddTableDialog *dialog, AddTableDialog *dialog,
+30
View File
@@ -72,6 +72,15 @@ bool AddTableDialog::adjustTableToFolio() const
return ui->m_adjust_table_size_cb->isChecked(); return ui->m_adjust_table_size_cb->isChecked();
} }
/**
@brief AddTableDialog::setAdjustTableToFolio
@param set
*/
void AddTableDialog::setAdjustTableToFolio(bool set)
{
ui->m_adjust_table_size_cb->setChecked(set);
}
/** /**
@brief AddTableDialog::addNewTableToNewDiagram @brief AddTableDialog::addNewTableToNewDiagram
@return @return
@@ -81,6 +90,15 @@ bool AddTableDialog::addNewTableToNewDiagram() const
return ui->m_add_table_and_folio->isChecked(); return ui->m_add_table_and_folio->isChecked();
} }
/**
@brief AddTableDialog::setAddNewTableToNewDiagram
@param set
*/
void AddTableDialog::setAddNewTableToNewDiagram(bool set)
{
ui->m_add_table_and_folio->setChecked(set);
}
/** /**
@brief AddTableDialog::tableName @brief AddTableDialog::tableName
@return @return
@@ -90,6 +108,18 @@ QString AddTableDialog::tableName() const
return ui->m_table_name_le->text(); return ui->m_table_name_le->text();
} }
/**
@brief AddTableDialog::setTableName
Set the name field directly, so a caller that builds this dialog to
read from (never shows or execs it -- the scripting API's addTable())
does not need a name typed by a user who was never there to type one.
@param name
*/
void AddTableDialog::setTableName(const QString &name)
{
ui->m_table_name_le->setText(name);
}
/** /**
@brief AddTableDialog::headerMargins @brief AddTableDialog::headerMargins
@return @return
+3
View File
@@ -44,9 +44,12 @@ class AddTableDialog : public QDialog
void setQueryWidget(QWidget *widget); void setQueryWidget(QWidget *widget);
bool adjustTableToFolio() const; bool adjustTableToFolio() const;
void setAdjustTableToFolio(bool set);
bool addNewTableToNewDiagram() const; bool addNewTableToNewDiagram() const;
void setAddNewTableToNewDiagram(bool set);
QString tableName() const; QString tableName() const;
void setTableName(const QString &name);
QMargins headerMargins() const; QMargins headerMargins() const;
Qt::Alignment headerAlignment() const; Qt::Alignment headerAlignment() const;
+10 -9
View File
@@ -16,6 +16,7 @@
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>. along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/ */
#include "qet.h" #include "qet.h"
#include "qetapp.h"
#include "qeticons.h" #include "qeticons.h"
#include "shortcutmanager.h" #include "shortcutmanager.h"
@@ -276,7 +277,7 @@ QString QET::ElementsAndConductorsSentence(
parts.append( parts.append(
QObject::tr( QObject::tr(
"%n élément(s)", "%n élément(s)",
"part of a enumerative partial sentence listing the content of a diagram", "Sentence fragment used in an automatically generated list of different objects, e.g. objects moved at the same time, which will be combined into a sentence.",
elements_count elements_count
) )
); );
@@ -286,7 +287,7 @@ QString QET::ElementsAndConductorsSentence(
parts.append( parts.append(
QObject::tr( QObject::tr(
"%n conducteur(s)", "%n conducteur(s)",
"part of a enumerative partial sentence listing the content of a diagram", "Sentence fragment used in an automatically generated list of different objects, e.g. objects moved at the same time, which will be combined into a sentence.",
conductors_count conductors_count
) )
); );
@@ -296,7 +297,7 @@ QString QET::ElementsAndConductorsSentence(
parts.append( parts.append(
QObject::tr( QObject::tr(
"%n champ(s) de texte", "%n champ(s) de texte",
"part of a enumerative partial sentence listing the content of a diagram", "Sentence fragment used in an automatically generated list of different objects, e.g. objects moved at the same time, which will be combined into a sentence.",
texts_count texts_count
) )
); );
@@ -306,7 +307,7 @@ QString QET::ElementsAndConductorsSentence(
parts.append( parts.append(
QObject::tr( QObject::tr(
"%n image(s)", "%n image(s)",
"part of a enumerative partial sentence listing the content of a diagram", "Sentence fragment used in an automatically generated list of different objects, e.g. objects moved at the same time, which will be combined into a sentence.",
images_count images_count
) )
); );
@@ -316,7 +317,7 @@ QString QET::ElementsAndConductorsSentence(
parts.append( parts.append(
QObject::tr( QObject::tr(
"%n forme(s)", "%n forme(s)",
"part of a enumerative partial sentence listing the content of a diagram", "Sentence fragment used in an automatically generated list of different objects, e.g. objects moved at the same time, which will be combined into a sentence.",
shapes_count shapes_count
) )
); );
@@ -326,7 +327,7 @@ QString QET::ElementsAndConductorsSentence(
parts.append( parts.append(
QObject::tr( QObject::tr(
"%n texte(s) d'élément", "%n texte(s) d'élément",
"part of a enumerative partial sentence listing the content of a diagram", "Sentence fragment used in an automatically generated list of different objects, e.g. objects moved at the same time, which will be combined into a sentence.",
element_text_count element_text_count
) )
); );
@@ -336,7 +337,7 @@ QString QET::ElementsAndConductorsSentence(
parts.append( parts.append(
QObject::tr( QObject::tr(
"%n tableau(s)", "%n tableau(s)",
"part of a enumerative partial sentence listing the content of diagram", "Sentence fragment used in an automatically generated list of different objects, e.g. objects moved at the same time, which will be combined into a sentence.",
tables_count tables_count
) )
); );
@@ -346,13 +347,13 @@ QString QET::ElementsAndConductorsSentence(
parts.append( parts.append(
QObject::tr( QObject::tr(
"%n plan(s) de bornes", "%n plan(s) de bornes",
"part of a enumerative partial sentence listing the content of a diagram", "Sentence fragment used in an automatically generated list of different objects, e.g. objects moved at the same time, which will be combined into a sentence.",
terminal_strip_count terminal_strip_count
) )
); );
} }
return QLocale().createSeparatedList(parts); return QLocale(QETApp::interfaceLanguage()).createSeparatedList(parts);
} }
/** /**
+85 -2
View File
@@ -236,7 +236,7 @@ QString QETApp::loadedQtTranslationFile()
void QETApp::setLanguage(const QString &desired_language) { void QETApp::setLanguage(const QString &desired_language) {
QString languages_path = languagesPath(); QString languages_path = languagesPath();
QLocale::setDefault(QLocale(desired_language)); m_interface_language = desired_language;
// load Qt library translations // load Qt library translations
QString qt_l10n_path = QLibraryInfo::path(QLibraryInfo::TranslationsPath); QString qt_l10n_path = QLibraryInfo::path(QLibraryInfo::TranslationsPath);
@@ -1816,6 +1816,81 @@ void QETApp::useSystemPalette(bool use) {
QET::Palette::refreshStyleSheets(); QET::Palette::refreshStyleSheets();
} }
/**
@brief QETApp::useCustomPalette
Apply a user-chosen color as the application-wide palette.
Builds a full QPalette from \a color, keeping the system palette
as a fallback for roles we don't touch.
@param color the user-chosen base color
*/
void QETApp::useCustomPalette(const QColor &color) {
if (!color.isValid())
return;
// Derive readable text colors from the chosen color.
const bool dark = color.lightness() < 128;
const QColor text = dark ? QColor(220, 220, 220) : QColor(30, 30, 30);
const QColor disabled_text = dark ? QColor(175, 175, 175) : QColor(128, 128, 128);
// Slightly lighter/darker for button and window shading.
QColor button = color;
button = QColor::fromHslF(color.hslHueF(),
color.hslSaturationF(),
dark ? qMin(color.lightnessF() + 0.08, 1.0)
: qMax(color.lightnessF() - 0.08, 0.0));
QColor light = QColor::fromHslF(color.hslHueF(),
color.hslSaturationF(),
dark ? qMin(color.lightnessF() + 0.15, 1.0)
: qMax(color.lightnessF() - 0.15, 0.0));
QColor mid = QColor::fromHslF(color.hslHueF(),
color.hslSaturationF(),
dark ? qMin(color.lightnessF() + 0.04, 1.0)
: qMax(color.lightnessF() - 0.04, 0.0));
QColor dark_c = QColor::fromHslF(color.hslHueF(),
color.hslSaturationF(),
dark ? qMin(color.lightnessF() - 0.04, 1.0)
: qMax(color.lightnessF() - 0.12, 0.0));
QColor shadow = QColor::fromHslF(color.hslHueF(),
color.hslSaturationF(),
dark ? qMin(color.lightnessF() - 0.10, 1.0)
: qMax(color.lightnessF() - 0.20, 0.0));
QPalette p;
// Active and Inactive get the same colors; only Disabled differs.
for (auto group : {QPalette::Active, QPalette::Inactive}) {
p.setColor(group, QPalette::Window, color);
p.setColor(group, QPalette::WindowText, text);
p.setColor(group, QPalette::Base, color);
p.setColor(group, QPalette::AlternateBase, button);
p.setColor(group, QPalette::Text, text);
p.setColor(group, QPalette::Button, button);
p.setColor(group, QPalette::ButtonText, text);
p.setColor(group, QPalette::BrightText, dark ? QColor(255,90,90) : Qt::white);
p.setColor(group, QPalette::Highlight, QColor(30, 96, 176));
p.setColor(group, QPalette::HighlightedText, Qt::white);
p.setColor(group, QPalette::ToolTipBase, button);
p.setColor(group, QPalette::ToolTipText, text);
p.setColor(group, QPalette::Light, light);
p.setColor(group, QPalette::Midlight, mid);
p.setColor(group, QPalette::Mid, mid);
p.setColor(group, QPalette::Dark, dark_c);
p.setColor(group, QPalette::Shadow, shadow);
#if QT_VERSION >= QT_VERSION_CHECK(6, 6, 0)
p.setColor(group, QPalette::Accent, QColor(30, 96, 176));
#endif
}
p.setColor(QPalette::Disabled, QPalette::WindowText, disabled_text);
p.setColor(QPalette::Disabled, QPalette::Text, disabled_text);
p.setColor(QPalette::Disabled, QPalette::ButtonText, disabled_text);
qApp->setPalette(p);
qApp->setStyleSheet(QString());
// Switch icon theme to match light/dark.
applyIconTheme(p);
QET::Palette::refreshStyleSheets();
}
/** /**
@brief QETApp::quitQET @brief QETApp::quitQET
Request the closing of all windows; Request the closing of all windows;
@@ -2404,7 +2479,13 @@ void QETApp::initStyle()
//Apply or not the system style //Apply or not the system style
QSettings settings; QSettings settings;
useSystemPalette(settings.value("usesystemcolors", true).toBool()); if (settings.value("usesystemcolors", true).toBool()) {
useSystemPalette(true);
} else if (settings.contains("customapplicationcolor")) {
useCustomPalette(QColor(settings.value("customapplicationcolor").toString()));
} else {
useSystemPalette(false);
}
#if defined(Q_OS_MACOS) && QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) #if defined(Q_OS_MACOS) && QT_VERSION >= QT_VERSION_CHECK(6, 5, 0)
// Setting an application palette stops Qt from following the OS // Setting an application palette stops Qt from following the OS
@@ -3045,3 +3126,5 @@ int QETApp::projectId(const QETProject *project) {
} }
return(-1); return(-1);
} }
QString QETApp::m_interface_language;
+4
View File
@@ -68,6 +68,7 @@ class QETApp : public QObject
public: public:
static QETApp *instance(); static QETApp *instance();
void setLanguage(const QString &); void setLanguage(const QString &);
static QString interfaceLanguage() { return m_interface_language; }
static QString langFromSetting (); static QString langFromSetting ();
void switchLayout(Qt::LayoutDirection); void switchLayout(Qt::LayoutDirection);
static void printHelp(); static void printHelp();
@@ -247,6 +248,8 @@ class QETApp : public QObject
static QString m_user_custom_tbt_dir; static QString m_user_custom_tbt_dir;
static QString m_user_macros_dir; static QString m_user_macros_dir;
static QString m_interface_language;
public slots: public slots:
void systray(QSystemTrayIcon::ActivationReason); void systray(QSystemTrayIcon::ActivationReason);
void reduceEveryEditor(); void reduceEveryEditor();
@@ -263,6 +266,7 @@ class QETApp : public QObject
void setMainWindowVisible(QMainWindow *, bool); void setMainWindowVisible(QMainWindow *, bool);
void invertMainWindowVisibility(QWidget *); void invertMainWindowVisibility(QWidget *);
void useSystemPalette(bool); void useSystemPalette(bool);
void useCustomPalette(const QColor &color);
void quitQET(); void quitQET();
void checkRemainingWindows(); void checkRemainingWindows();
void openFiles(const QETArguments &); void openFiles(const QETArguments &);
+70
View File
@@ -52,11 +52,13 @@
#include "ui/bomexportdialog.h" #include "ui/bomexportdialog.h"
#include "ui/conductorcolortoolbutton.h" #include "ui/conductorcolortoolbutton.h"
#include "ui/diagrambgcolorbutton.h" #include "ui/diagrambgcolorbutton.h"
#include "ui/duplicateoffsetdialog.h"
#include "ui/jumptoelementdialog.h" #include "ui/jumptoelementdialog.h"
#include "ui/diagrampropertieseditordockwidget.h" #include "ui/diagrampropertieseditordockwidget.h"
#include "ui/backupdialog.h" #include "ui/backupdialog.h"
#include "ui/dialogwaiting.h" #include "ui/dialogwaiting.h"
#include "undocommand/addelementtextcommand.h" #include "undocommand/addelementtextcommand.h"
#include "utils/qetsettings.h"
#include "utils/qetutils.h" #include "utils/qetutils.h"
#include "undocommand/rotateselectioncommand.h" #include "undocommand/rotateselectioncommand.h"
#include "undocommand/rotatetextscommand.h" #include "undocommand/rotatetextscommand.h"
@@ -374,6 +376,46 @@ void QETDiagramEditor::setUpActions()
new DiagramEventAddPaste(dv->diagram(), start_pos)); new DiagramEventAddPaste(dv->diagram(), start_pos));
}); });
//Duplicate: copy the selection and place it at a configured,
//grid-step offset immediately -- no interactive follow-the-
//cursor step, unlike Ctrl+V above. That is deliberate (#991):
//the point of a duplicate shortcut is repeatable, unattended
//stamping (configure the offset once, then tap Ctrl+D to lay
//out a row), which an interactive placement would interrupt on
//every press.
m_duplicate = new QAction(QET::Icons::EditCopy, tr("Dupli&quer"), this);
ShortcutManager::instance().registerAction(m_duplicate, "diagrameditor.duplicate", tr("Éditeur de schémas"), Qt::CTRL | Qt::Key_D);
m_duplicate->setStatusTip(tr("Copie la sélection, décalée de l'espacement configuré", "status bar tip"));
connect(m_duplicate, &QAction::triggered, [this]() {
auto *dv = currentDiagramView();
if (!dv || !dv->diagram()) return;
//Ask the first time only -- every later press reuses whatever
//was confirmed then, so the shortcut can be tapped repeatedly
//without an interruption each time. m_configure_duplicate
//below is the deliberate way back into this dialog.
if (!DuplicateOffsetDialog::hasSavedStepOffset()) {
DuplicateOffsetDialog dialog(this);
if (dialog.exec() != QDialog::Accepted) return;
DuplicateOffsetDialog::saveStepOffset(dialog.stepOffset());
}
dv->duplicate(DuplicateOffsetDialog::savedStepOffset());
});
//Reopens the dialog above on demand, to change the spacing or
//direction a later Ctrl+D should use. Enabled unconditionally
//(see slot_updateComplexActions()): it only ever writes a
//setting, so it does not need a diagram open or anything
//selected the way m_duplicate itself does.
m_configure_duplicate = new QAction(tr("Configurer la duplication..."), this);
m_configure_duplicate->setStatusTip(tr("Choisir l'espacement et la direction utilisés par Dupliquer", "status bar tip"));
connect(m_configure_duplicate, &QAction::triggered, [this]() {
DuplicateOffsetDialog dialog(this);
if (dialog.exec() == QDialog::Accepted) {
DuplicateOffsetDialog::saveStepOffset(dialog.stepOffset());
}
});
//Reset conductor path //Reset conductor path
m_conductor_reset = new QAction(QET::Icons::ConductorSettings, tr("Réinitialiser les conducteurs"), this); m_conductor_reset = new QAction(QET::Icons::ConductorSettings, tr("Réinitialiser les conducteurs"), this);
ShortcutManager::instance().registerAction(m_conductor_reset, "diagrameditor.conductor_reset", tr("Éditeur de schémas"), Qt::CTRL | Qt::Key_K); ShortcutManager::instance().registerAction(m_conductor_reset, "diagrameditor.conductor_reset", tr("Éditeur de schémas"), Qt::CTRL | Qt::Key_K);
@@ -894,6 +936,7 @@ void QETDiagramEditor::setUpToolBar()
main_tool_bar -> addAction(m_cut); main_tool_bar -> addAction(m_cut);
main_tool_bar -> addAction(m_copy); main_tool_bar -> addAction(m_copy);
main_tool_bar -> addAction(m_paste); main_tool_bar -> addAction(m_paste);
main_tool_bar -> addAction(m_duplicate);
main_tool_bar -> addSeparator(); main_tool_bar -> addSeparator();
main_tool_bar -> addAction(m_delete_selection); main_tool_bar -> addAction(m_delete_selection);
main_tool_bar -> addAction(m_rotate_selection); main_tool_bar -> addAction(m_rotate_selection);
@@ -985,6 +1028,8 @@ void QETDiagramEditor::setUpMenu()
menu_edition -> addAction(m_cut); menu_edition -> addAction(m_cut);
menu_edition -> addAction(m_copy); menu_edition -> addAction(m_copy);
menu_edition -> addAction(m_paste); menu_edition -> addAction(m_paste);
menu_edition -> addAction(m_duplicate);
menu_edition -> addAction(m_configure_duplicate);
menu_edition -> addSeparator(); menu_edition -> addSeparator();
//The same actions the "Ajouter" toolbar holds. They were toolbar-only, //The same actions the "Ajouter" toolbar holds. They were toolbar-only,
//which left them unreachable for anyone working without a mouse: a //which left them unreachable for anyone working without a mouse: a
@@ -1947,6 +1992,7 @@ void QETDiagramEditor::slot_updateComplexActions()
<< m_find_element << m_find_element
<< m_cut << m_cut
<< m_copy << m_copy
<< m_duplicate
<< m_delete_selection << m_delete_selection
<< m_rotate_selection << m_rotate_selection
<< m_rotate_group_selection << m_rotate_group_selection
@@ -1976,6 +2022,7 @@ void QETDiagramEditor::slot_updateComplexActions()
bool deletable_items = dc.hasDeletableItems(); bool deletable_items = dc.hasDeletableItems();
m_cut -> setEnabled(!ro && copiable_items); m_cut -> setEnabled(!ro && copiable_items);
m_copy -> setEnabled(copiable_items); m_copy -> setEnabled(copiable_items);
m_duplicate -> setEnabled(!ro && copiable_items);
m_delete_selection -> setEnabled(!ro && deletable_items); m_delete_selection -> setEnabled(!ro && deletable_items);
m_rotate_selection -> setEnabled(!ro && diagram_->canRotateSelection()); m_rotate_selection -> setEnabled(!ro && diagram_->canRotateSelection());
m_rotate_group_selection -> setEnabled(!ro && diagram_->canRotateSelection()); m_rotate_group_selection -> setEnabled(!ro && diagram_->canRotateSelection());
@@ -3122,6 +3169,29 @@ void QETDiagramEditor::slot_runScript() {
QETProject *project = currentProject(); QETProject *project = currentProject();
if (!project) return; if (!project) return;
// Scripting is off until somebody says otherwise, so the first use has
// to ask. Asking here rather than greying the action out keeps the
// feature discoverable: a disabled menu entry tells a user that
// something exists and nothing about how to have it.
if (!QetSettings::scriptingEnabled()) {
const QMessageBox::StandardButton answer = QET::QetMessageBox::question(
this,
tr("Exécuter un script"),
tr("Les scripts sont désactivés.\n\n"
"Un script s'exécute avec vos droits : il peut lire et "
"modifier le projet ouvert et écrire des fichiers. "
"N'exécutez que des scripts dont vous connaissez "
"l'origine.\n\n"
"Activer les scripts ? Ce réglage est modifiable dans "
"Configurer QElectroTech > Général > Projets."),
QMessageBox::Yes | QMessageBox::Cancel,
QMessageBox::Cancel);
if (answer != QMessageBox::Yes) {
return;
}
QetSettings::setScriptingEnabled(true);
}
const QString script_path = QFileDialog::getOpenFileName( const QString script_path = QFileDialog::getOpenFileName(
this, this,
tr("Exécuter un script"), tr("Exécuter un script"),
+2
View File
@@ -199,6 +199,8 @@ class QETDiagramEditor : public QETMainWindow
*undo, ///< Cancel the latest action *undo, ///< Cancel the latest action
*redo, ///< Redo the latest cancelled operation *redo, ///< Redo the latest cancelled operation
*m_paste, ///< Paste clipboard content on the current diagram *m_paste, ///< Paste clipboard content on the current diagram
*m_duplicate, ///< Copy selection, offset by the configured step (#991)
*m_configure_duplicate, ///< Reopen the duplicate offset/direction dialog (#991)
*m_auto_conductor, ///< Enable/Disable the use of auto conductor *m_auto_conductor, ///< Enable/Disable the use of auto conductor
*m_auto_break_conductor, ///< Enable/Disable the use of auto break conductor *m_auto_break_conductor, ///< Enable/Disable the use of auto break conductor
*m_draw_grid, ///< Switch the background grid display or not *m_draw_grid, ///< Switch the background grid display or not
@@ -379,6 +379,21 @@ void ProjectDBModel::fillValue()
while (query_.next()) while (query_.next())
{ {
//This query text comes out of the project file, so its result is
//not bounded by anything the project actually contains: a
//recursive CTE produces rows for as long as anyone reads them.
//Without this, opening such a file hangs QElectroTech at 100% CPU
//while m_record grows until memory runs out. @see
//projectDataBase::MaxResultRows.
if (m_record.size() >= projectDataBase::MaxResultRows) {
qWarning().noquote()
<< "ProjectDBModel: query stopped after"
<< projectDataBase::MaxResultRows
<< "rows, which is far more than a folio table can show."
<< "The table is incomplete. Query:" << m_query;
break;
}
QStringList record_; QStringList record_;
auto i=0; auto i=0;
while (query_.value(i).isValid()) while (query_.value(i).isValid())
+28
View File
@@ -1310,6 +1310,34 @@ const QList<ConductorSegment *> Conductor::segmentsList() const
return(segments_vector); return(segments_vector);
} }
/**
@brief Conductor::moveSegment
Move one segment of this conductor by (dx, dy), the same primitive
handlerMouseMoveEvent()/handlerMouseReleaseEvent() apply on a manual
drag -- moveX()/moveY() each silently no-op on the wrong axis or a
static (terminal-anchored) segment, so both are always called and
whichever applies takes effect. Unlike a drag this commits the whole
move as a single undo step.
@param index a segmentsList() index
@param dx @param dy the movement, in the diagram's own coordinates
@return false if index is out of range
*/
bool Conductor::moveSegment(int index, qreal dx, qreal dy)
{
const QList<ConductorSegment *> segs = segmentsList();
if (index < 0 || index >= segs.count()) return false;
before_mov_text_pos_ = m_text_item->pos();
ConductorSegment *seg = segs.at(index);
seg->moveX(dx);
seg->moveY(dy);
modified_path = true;
segmentsToPath();
calculateTextItemPosition();
saveProfile();
return true;
}
/** /**
@brief Conductor::length @brief Conductor::length
@return the length of this conductor @return the length of this conductor
+1
View File
@@ -114,6 +114,7 @@ class Conductor : public QGraphicsObject
public: public:
QVector <QPointF> handlerPoints() const; QVector <QPointF> handlerPoints() const;
const QList<ConductorSegment *> segmentsList() const; const QList<ConductorSegment *> segmentsList() const;
bool moveSegment(int index, qreal dx, qreal dy);
void setPropertyToPotential( void setPropertyToPotential(
const ConductorProperties &property, const ConductorProperties &property,
@@ -732,6 +732,16 @@ void DynamicElementTextItem::paint(QPainter *painter, const QStyleOptionGraphics
{ {
DiagramTextItem::paint(painter, option, widget); DiagramTextItem::paint(painter, option, widget);
//Only ever repositions already-existing sibling items here --
//never adds or removes one. paint() runs while QGraphicsScene is
//iterating its item list to draw it, and mutating that list mid
//-iteration (which addResizeHandles()/removeResizeHandles() do,
//through QGraphicsScene::addItem()/removeItem()) crashes. An
//earlier version of this fix called them from here and crashed
//qelectrotech reproducibly on deselecting a text (SIGABRT); see
//refreshResizeHandlesVisibility() for where that now happens
//instead -- itemChange(), Qt's own safe hook for exactly this,
//already used below for this item's own selection.
if (m_left_resize_handle || m_right_resize_handle) if (m_left_resize_handle || m_right_resize_handle)
updateResizeHandlesPos(); updateResizeHandlesPos();
@@ -826,10 +836,7 @@ QVariant DynamicElementTextItem::itemChange(QGraphicsItem::GraphicsItemChange ch
} }
else if (change == QGraphicsItem::ItemSelectedHasChanged) else if (change == QGraphicsItem::ItemSelectedHasChanged)
{ {
if (value.toBool()) refreshResizeHandlesVisibility();
addResizeHandles();
else
removeResizeHandles();
} }
else if (change == QGraphicsItem::ItemSceneHasChanged && !scene()) else if (change == QGraphicsItem::ItemSceneHasChanged && !scene())
{ {
@@ -878,6 +885,31 @@ bool DynamicElementTextItem::sceneEventFilter(QGraphicsItem *watched, QEvent *ev
return false; return false;
} }
/**
@brief DynamicElementTextItem::refreshResizeHandlesVisibility
Show the resize handles when this text is selected directly, OR when its
parent element is -- which is what an ordinary click without Shift
selects (DynamicElementTextItem::mousePressEvent() forwards a plain
click to the parent, so dragging a symbol by its label moves the whole
symbol; a pre-existing, unrelated behaviour, left untouched here).
Without this, the handles were reachable only via Shift+click or a
right-click's context menu, neither of which a user reaches for to
resize a text field (qelectrotech#591, reported by @arummler).
Called from itemChange() -- both this item's own ItemSelectedHasChanged,
below, and Element::itemChange() on the parent's, which calls this on
every one of its texts. Not from paint(): see the comment there for why
that crashed.
*/
void DynamicElementTextItem::refreshResizeHandlesVisibility()
{
const bool handles_wanted = isSelected() || (m_parent_element && m_parent_element->isSelected());
if (handles_wanted && !m_left_resize_handle)
addResizeHandles();
else if (!handles_wanted && m_left_resize_handle)
removeResizeHandles();
}
/** /**
@brief DynamicElementTextItem::addResizeHandles @brief DynamicElementTextItem::addResizeHandles
Create and show the two width-resize handles (left/right edge of Create and show the two width-resize handles (left/right edge of
@@ -917,20 +949,32 @@ void DynamicElementTextItem::removeResizeHandles()
/** /**
@brief DynamicElementTextItem::updateResizeHandlesPos @brief DynamicElementTextItem::updateResizeHandlesPos
Keep the two resize handles at the vertical middle of frameRect()'s left Keep the two resize handles at the vertical middle of boundingRect()'s
and right edges, in scene coordinates -- called on every paint() so it left and right edges, in scene coordinates -- called on every paint() so
stays correct across every kind of change that can move this item or it stays correct across every kind of change that can move this item or
change its size (position, rotation, font, text, textWidth...) without change its size (position, rotation, font, text, textWidth...) without
needing a dedicated hook for each one. needing a dedicated hook for each one.
Deliberately boundingRect(), not frameRect(): frameRect() is a tight box
around the text's own natural (idealWidth()) size, re-centred inside
boundingRect() -- it does not grow with textWidth(). Once a text has
been widened, that leaves a growing gap between the tight frame and the
dashed selection outline QGraphicsView draws at boundingRect(), which is
the box a user actually sees and expects a resize handle to sit on
(qelectrotech#591, reported by @arummler: "the drag elements should be
on the border of the box"). boundingRect() reflects the full
textWidth() (it is QGraphicsTextItem's own, driven by the document's
laid-out size), so the handles now track the box that is visibly
resized rather than the text glyphs inside it.
*/ */
void DynamicElementTextItem::updateResizeHandlesPos() void DynamicElementTextItem::updateResizeHandlesPos()
{ {
if (!m_left_resize_handle || !m_right_resize_handle) if (!m_left_resize_handle || !m_right_resize_handle)
return; return;
QRectF fr = frameRect(); QRectF br = boundingRect();
m_left_resize_handle->setPos(mapToScene(QPointF(fr.left(), fr.center().y()))); m_left_resize_handle->setPos(mapToScene(QPointF(br.left(), br.center().y())));
m_right_resize_handle->setPos(mapToScene(QPointF(fr.right(), fr.center().y()))); m_right_resize_handle->setPos(mapToScene(QPointF(br.right(), br.center().y())));
} }
/** /**
@@ -1289,9 +1333,15 @@ void DynamicElementTextItem::updateLabel()
if(m_text_from == ElementInfo && element) { if(m_text_from == ElementInfo && element) {
setPlainText(element->actualLabel()); QString new_label = element->actualLabel();
if (toPlainText() != new_label) {
setPlainText(new_label);
}
} }
else if (m_text_from == CompositeText) { else if (m_text_from == CompositeText) {
// Use actualLabel() to ensure %{label} reflects the current
// resolved label (e.g. after a folio/page-number change)
dc.addValue(QStringLiteral("label"), element->actualLabel());
setPlainText(autonum::AssignVariables::replaceVariable(m_composite_text, dc)); setPlainText(autonum::AssignVariables::replaceVariable(m_composite_text, dc));
} }
} }
@@ -121,6 +121,12 @@ class DynamicElementTextItem : public DiagramTextItem
void setRotationPointCenter(bool set); void setRotationPointCenter(bool set);
bool rotationPointCenter() const; bool rotationPointCenter() const;
//Called by Element::itemChange() when the PARENT's selection
//changes, so the parent can keep each of its texts' resize
//handles in sync with its own selection state. Public for that;
//see the .cpp for why it exists.
void refreshResizeHandlesVisibility();
protected: protected:
void mousePressEvent(QGraphicsSceneMouseEvent *event) override; void mousePressEvent(QGraphicsSceneMouseEvent *event) override;
void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override; void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override;
+25
View File
@@ -1664,6 +1664,31 @@ void Element::hoverLeaveEvent(QGraphicsSceneHoverEvent *e)
update(); update();
} }
/**
@brief Element::itemChange
On ItemSelectedHasChanged, tell each of this element's own dynamic texts
to re-check whether its resize handles should be showing --
DynamicElementTextItem::refreshResizeHandlesVisibility() shows them when
either the text itself or its parent (this) is selected. An ordinary
click with no Shift selects the parent, not the text
(DynamicElementTextItem::mousePressEvent() forwards it), so without this
a plain click on a symbol never showed the resize handles this PR adds
to its texts (qelectrotech#591, reported by @arummler) -- only
Shift+click or a right-click's context menu did, since those are the
paths that leave the text itself selected.
*/
QVariant Element::itemChange(GraphicsItemChange change, const QVariant &value)
{
if (change == QGraphicsItem::ItemSelectedHasChanged)
{
const QList<DynamicElementTextItem *> texts = dynamicTextItems();
for (DynamicElementTextItem *deti : texts) {
deti->refreshResizeHandlesVisibility();
}
}
return QetGraphicsItem::itemChange(change, value);
}
/** /**
@brief Element::setUpFormula @brief Element::setUpFormula
Set up the formula used to create the label of this element Set up the formula used to create the label of this element
+1
View File
@@ -255,6 +255,7 @@ class Element : public QetGraphicsItem
QGraphicsSceneMouseEvent *event) override; QGraphicsSceneMouseEvent *event) override;
void hoverEnterEvent(QGraphicsSceneHoverEvent *) override; void hoverEnterEvent(QGraphicsSceneHoverEvent *) override;
void hoverLeaveEvent(QGraphicsSceneHoverEvent *) override; void hoverLeaveEvent(QGraphicsSceneHoverEvent *) override;
QVariant itemChange(GraphicsItemChange change, const QVariant &value) override;
protected: protected:
//ATTRIBUTES related to linked element //ATTRIBUTES related to linked element
+8
View File
@@ -20,7 +20,9 @@
#include <QApplication> #include <QApplication>
#include <QColor> #include <QColor>
#include <QImage> #include <QImage>
#include <QMdiArea>
#include <QStyle> #include <QStyle>
#include <QTabBar>
#include <QWidget> #include <QWidget>
#include <cmath> #include <cmath>
@@ -307,4 +309,10 @@ void QET::Palette::refreshStyleSheets()
for (QWidget *widget : widgets) for (QWidget *widget : widgets)
if (!widget->styleSheet().isEmpty()) if (!widget->styleSheet().isEmpty())
widget->setStyleSheet(widget->styleSheet()); widget->setStyleSheet(widget->styleSheet());
// Force an immediate repaint on tab bars and MDI areas so their text
// updates together with the rest of the UI, not one event loop later.
for (QWidget *widget : widgets)
if (qobject_cast<QTabBar *>(widget) || qobject_cast<QMdiArea *>(widget))
widget->update();
} }
+12 -1
View File
@@ -334,7 +334,18 @@ QETProject::ProjectState QETProject::openFile(QFile *file)
//file without a persisted uuid derives its uuid from them. //file without a persisted uuid derives its uuid from them.
const QByteArray content = file->readAll(); const QByteArray content = file->readAll();
QDomDocument xml_project; QDomDocument xml_project;
if (!xml_project.setContent(content)) // PreserveSpacingOnlyNodes: without it, a text node that is entirely
// whitespace -- e.g. a title-block custom variable deliberately set to
// a single space, the only way to give it a value other than blank
// (bugtracker #973) -- is silently dropped by Qt's default parsing,
// and QDomElement::text() then returns "" for it exactly as if it had
// never been set. Confirmed in isolation: <a> </a> parses to text()=="",
// this option makes it text()==" ". Every place in this codebase that
// walks a QDomNode's children already filters on isElement() (see
// QET::findInDomElement()), so the extra whitespace-only text nodes
// this keeps around are inert everywhere but the two elements that
// call .text() on themselves -- which is exactly where the bug was.
if (!xml_project.setContent(content, QDomDocument::ParseOption::PreserveSpacingOnlyNodes))
{ {
if(opened_here) { if(opened_here) {
file->close(); file->close();
File diff suppressed because it is too large Load Diff
+289 -4
View File
@@ -22,6 +22,7 @@
#include <QString> #include <QString>
#include <QStringList> #include <QStringList>
#include <QVariantList> #include <QVariantList>
#include <QVariantMap>
class QETProject; class QETProject;
class DiagramView; class DiagramView;
@@ -30,6 +31,9 @@ class Terminal;
class Conductor; class Conductor;
class IndependentTextItem; class IndependentTextItem;
class QetShapeItem; class QetShapeItem;
class DiagramImageItem;
class DynamicElementTextItem;
class QetGraphicsTableItem;
/** /**
@brief The QetScriptApi class @brief The QetScriptApi class
@@ -93,13 +97,18 @@ class QetShapeItem;
Terminals are addressed by their @b index in Element::terminals(), Terminals are addressed by their @b index in Element::terminals(),
not by uuid, and elementTerminals() prints that indexing so a script not by uuid, and elementTerminals() prints that indexing so a script
can see what it is about to wire. Terminal uuids look like the can see what it is about to wire. The index is the terminal's place
in the element's own top-to-bottom, left-to-right ordering, not the
order its definition file lists them. Terminal uuids look like the
obvious key and are not one: Terminal::uuid() is a property of the obvious key and are not one: Terminal::uuid() is a property of the
catalog .elmt definition, empty for most of the installed base and, catalog .elmt definition, empty for most of the installed base and,
where present, identical across every instance of that element -- so where present, identical across every instance of that element -- so
it does not distinguish one placed coil's A1 from another's. it does not distinguish one placed coil's A1 from another's.
- @b Conductor properties and @b cross-references: set a conductor's - @b Conductor properties and @b cross-references: set a conductor's
number, formula, colour or section, and link a master to a slave or number, formula, colour or section (and its look: style normal/
dashed/dashdotted, two-colour mode and second colour, dash size,
line width, text size, whether its number is shown -- all under the
names the .qet file uses for them), and link a master to a slave or
one report to another. Both follow the application's own rules rather one report to another. Both follow the application's own rules rather
than writing the field: a conductor property is applied to every than writing the field: a conductor property is applied to every
conductor of the same electrical potential, which is what the GUI and conductor of the same electrical potential, which is what the GUI and
@@ -123,7 +132,11 @@ class QetShapeItem;
its circuit -- a free-standing note, a line, a rectangle, an ellipse its circuit -- a free-standing note, a line, a rectangle, an ellipse
-- added with the same AddGraphicsObjectCommand the corresponding GUI -- added with the same AddGraphicsObjectCommand the corresponding GUI
tools use, and changed through the plainText/color/rotation tools use, and changed through the plainText/color/rotation
properties those items already publish. properties those items already publish. A shape's look is set with
setShapeProperty(): color and fill (a colour name, or "none" for no
fill), width, line-style (solid, dashed, dotted, dashdot) and
rotation, through the pen/brush/rotation properties the shape's own
style editor changes.
These are addressed by @b index into a listing sorted by position These are addressed by @b index into a listing sorted by position
(top to bottom, then left to right), because unlike an element they (top to bottom, then left to right), because unlike an element they
@@ -150,6 +163,157 @@ class QetShapeItem;
depend on. The underlying tables are how the cache happens to be depend on. The underlying tables are how the cache happens to be
arranged today, and a column may move. tables() lists both so a arranged today, and a column may move. tables() lists both so a
script can see what it is querying rather than guess. script can see what it is querying rather than guess.
- @b Terminal @b strips: create a strip, put terminal-type elements on
it, remove it. Strips are addressed by index into terminalStrips(),
which is the project's own order (unlike texts and shapes it is not
re-sorted) and does shift when one is removed. Only elements whose
link type is "terminal" can be added, the same restriction the
editor enforces by construction.
stripRealTerminals() lists the strip's real terminals -- the actual
wire-ends added by addTerminalToStrip(), one per index -- which
physical position (clamp) each currently sits on and how many
neighbours share it, since that is what groupTerminals() and
bridgeTerminals() address by index into.
groupTerminals() merges several real terminals onto one physical
position, choosing the receiving position the same way the terminal
strip editor's own "group" button does: the position among the ones
named that already carries the most real terminals, not necessarily
the first one given -- a script asking to group indices [0, 1] is not
guaranteed index 0's position is where they end up. bridgeTerminals()
wires several real terminals together electrically without merging
their positions, refused (TerminalStrip::isBridgeable()) when they
are not all at the same level -- the same check the editor's bridge
button applies, not a rule reimplemented here. sortTerminalStrip()
reorders the strip's physical positions into the canonical order the
editor's own sort button computes.
- @b Tables: a BOM/nomenclature or a summary (table of contents) placed
on a folio, through QetGraphicsTableFactory::create() -- the same
factory call the "add table" menu action makes, minus the modal
AddTableDialog it collects its settings from first. That dialog is
still built here, off-screen and never shown or exec'd: addTable()
calls setTableName() and the query widget's setQuery() on it, the
same as a user filling in the form, and forces its two checkboxes
("adjust table to folio" and "add a new folio if the table overflows"
it) off regardless of their .ui-file default of checked -- a script
calling addTable() once should create exactly the one table it asked
for, not possibly several spread across folios it never asked to add.
A script that wants either behaviour can resize the result itself or
add its own folio.
Neither creating nor deleting a table is undoable:
QetGraphicsTableFactory::newTable(), which create() calls, calls
Diagram::addItem() directly, with no undo command of its own, in the
stock "add table" action as much as here -- a pre-existing gap in the
application, not something introduced by this API. Tables are
addressed by index in a position-sorted listing, like texts, shapes
and images.
- @b Auto-numbering: define a named numbering context of kind
"conductor", "element" or "folio", built from parts written
"type[:value[:increase]]" -- types are the ones the auto-numbering
dialog offers (string, unit, ten, hundred, alpha, idfolio, folio,
plant, locmach, elementline, elementcolumn, elementprefix, wrap,
unitfolio, tenfolio, hundredfolio) -- and select which one a folio's
new conductors use. Defining or removing a context is not undoable,
because the application itself does it through direct project calls
and only the counter advance is on the undo stack; the numbering
actually applied to a conductor is.
For elements, useElementAutoNum() selects the current context and
numberElement() applies it to one element, as the "add element" tool
does right after placing one. addElement() deliberately does not
number what it places: doing it silently would change what an existing
script produces the moment its project happens to have a context
selected, so it is a separate, explicit call. Folio auto-numbering is
not offered: in the application it spawns whole new folios from a
context, which is a different operation from labelling.
- @b Duplicating: copy elements, together with the conductors that run
between them, to a position on the same or another folio, through
Diagram::toXml() and fromXml() and PasteDiagramCommand -- what Ctrl+C
and Ctrl+V do, so a paste behaves as a paste does there: the copies
come without their labels and without their conductors' wire numbers,
which the application clears on paste (measured: '' on both).
The position is the top left of the pasted group's bounding rectangle,
so an element's own origin ends up offset from it by its hotspot
(measured: +20, +30 for a coil); (0, 0) is not a position but means
"keep the source coordinates", as Diagram::fromXml() treats it. The
result lists the copies in the order the elements were named --
the application's own list is in scene order, and a caller pairing by
index would otherwise be wired to the wrong copies -- paired by
position, which a paste preserves, so two elements at the same point
cannot be told apart. A conductor is copied
only if both its ends are among the copied elements. The previous
selection is put back afterwards, since copying works by selecting.
- @b Project title and folio frame: setProjectTitle(), and the grid that
frames each folio -- columns and rows, their size, and whether the
headers show (columns, column-width, display-columns, rows, row-height,
display-rows) -- through ChangeBorderCommand. These are the six fields
the folio properties panel offers; the title block's header sizes,
which it does not, are left alone. Changing the project title is not
undoable: the application sets it directly too.
A folio's title block @b template is a seventh, separate case:
Diagram::setTitleBlockTemplate() resolves a name only against
QETProject::embeddedTitleBlockTemplatesCollection() -- the same
copy-into-the-project step addElement() already does for elements,
and for the same reason (a project opened on another machine must not
depend on files only this one has). titleBlockTemplates() lists what
is embedded and what is available to embed from the common/company
/custom collections, each name suffixed with its source;
embedTitleBlockTemplate() does the copy (QDomElement in, unmodified,
via *TemplatesCollection::get/setTemplateXmlDescription() -- neither
side is scripting-specific code, both already exist for the template
editor to call). setFolioProperty(folio, "template", name) then
embeds it first if it is not already, refusing only if no collection
has that name at all. Embedding is not undoable, the same as defining
an auto-numbering context is not: the application does both through
direct collection/project calls with no undo command of their own.
A template literally named "default" reads back as folioProperty()
"" afterwards, not "default": BorderTitleBlock::titleBlockTemplateName()
treats the two as the same thing, since "no override" already renders
with the template named "default".
- @b Geometry and folio order: elementGeometry() reads where an element
is -- x, y (its origin), rotation, and the box it occupies on the folio
(left, top, right, bottom) -- so a script can lay one thing out relative
to another instead of only setting absolute coordinates, and can check
that a move landed. insertFolio() puts a new folio at a position
instead of at the end, which is what reordering is mostly for while
moving an existing folio still needs the application's project view.
- @b Images: place a picture from a file. The pixels are copied into
the project, which stores them inline in the .qet -- the saved file
does not refer to the original path, so it opens on another machine,
and it grows by roughly the size of the image, which is why files
over 10 MB are refused. Images are addressed by index in a
position-sorted listing, like texts and shapes -- by the on-screen
bounding box, so scaling or rotating an image, which turns about its
centre, can change where it sorts. Re-list after either.
- @b Element @b texts: the text fields drawn on a symbol -- its label,
the names beside its terminals, any value the definition placed there.
A symbol arrives with the fields its definition gives it; setElementLabel()
fills the value one of them shows, and these methods control the fields
themselves: where each sits, its size, whether it draws a frame, what it
shows, and adding or deleting one. Addressed by index in the element's
own list, which follows the definition's order and shifts when one is
deleted -- and undoing a deletion puts the field back at the end, so
list again after either.
Two things called text, which differ for a field bound to an
information key: the @b "text" property is the field's stored string,
which for an information-bound field is an unused placeholder (empty,
or "Texte" once one has been added), and @b "shows" is what is drawn,
which follows the element's information straight away -- compared
against elementInfo() at seven points across relabel, rebinding,
setting and undo, with no difference. Read "shows".
Consecutive setElementInfo()/setElementLabel() calls on one element
merge into a single undo step, as ChangeElementInformationCommand
does, so one undo can revert several.
A field's @b source is "text" (a fixed string), "info" (the value of one
of the element's information keys, so it follows setElementInfo() and
setElementLabel()) or "composite" (a formula over several). Position is in
the element's own coordinates, not the folio's.
- @b Navigating and @b messaging: select an element, zoom the active - @b Navigating and @b messaging: select an element, zoom the active
view, and show the user a message. Deliberately narrow: selection and view, and show the user a message. Deliberately narrow: selection and
messaging work with no view at all (headless `--run`); zoom is a no-op messaging work with no view at all (headless `--run`); zoom is a no-op
@@ -238,16 +402,38 @@ class QetScriptApi : public QObject
int terminalIndex, const QString &property, int terminalIndex, const QString &property,
const QString &value); const QString &value);
// -- a conductor's own drawn path, not the whole potential's
// properties above -- one conductor only, addressed the same way --
Q_INVOKABLE QStringList conductorSegments(int folioIndex, const QString &elementUuid,
int terminalIndex) const;
Q_INVOKABLE bool moveConductorSegment(int folioIndex, const QString &elementUuid,
int terminalIndex, int segmentIndex,
double dx, double dy);
// -- cross-references: master/slave and report links -- // -- cross-references: master/slave and report links --
Q_INVOKABLE QString elementLinkType(int folioIndex, const QString &elementUuid) const; Q_INVOKABLE QString elementLinkType(int folioIndex, const QString &elementUuid) const;
Q_INVOKABLE QStringList linkedElements(int folioIndex, const QString &elementUuid) const; Q_INVOKABLE QStringList linkedElements(int folioIndex, const QString &elementUuid) const;
Q_INVOKABLE bool linkElements(int folioIndexA, const QString &elementUuidA, Q_INVOKABLE bool linkElements(int folioIndexA, const QString &elementUuidA,
int folioIndexB, const QString &elementUuidB); int folioIndexB, const QString &elementUuidB,
int groupIndex = -1);
Q_INVOKABLE bool unlinkElement(int folioIndex, const QString &elementUuid); Q_INVOKABLE bool unlinkElement(int folioIndex, const QString &elementUuid);
Q_INVOKABLE int elementLinkGroupIndex(int folioIndex, const QString &elementUuid,
int otherFolioIndex, const QString &otherElementUuid) const;
// -- a PLC master's IO table: address/function/comment rows a PLC
// slave links onto via linkElements()'s groupIndex --
Q_INVOKABLE QStringList plcIOs(int folioIndex, const QString &elementUuid) const;
Q_INVOKABLE int addPlcIO(int folioIndex, const QString &elementUuid, const QString &type,
const QString &address, const QString &functionText,
const QString &comment);
Q_INVOKABLE bool setPlcIO(int folioIndex, const QString &elementUuid, int ioIndex,
const QString &property, const QString &value);
Q_INVOKABLE bool removePlcIO(int folioIndex, const QString &elementUuid, int ioIndex);
// -- independent text and drawing shapes -- // -- independent text and drawing shapes --
Q_INVOKABLE QStringList texts(int folioIndex) const; Q_INVOKABLE QStringList texts(int folioIndex) const;
Q_INVOKABLE int addText(int folioIndex, const QString &text, double x, double y); Q_INVOKABLE int addText(int folioIndex, const QString &text, double x, double y);
Q_INVOKABLE QString textContent(int folioIndex, int textIndex) const;
Q_INVOKABLE bool setTextContent(int folioIndex, int textIndex, const QString &text); Q_INVOKABLE bool setTextContent(int folioIndex, int textIndex, const QString &text);
Q_INVOKABLE bool setTextColor(int folioIndex, int textIndex, const QString &color); Q_INVOKABLE bool setTextColor(int folioIndex, int textIndex, const QString &color);
Q_INVOKABLE bool setTextRotation(int folioIndex, int textIndex, double angle); Q_INVOKABLE bool setTextRotation(int folioIndex, int textIndex, double angle);
@@ -257,14 +443,97 @@ class QetScriptApi : public QObject
Q_INVOKABLE int addShape(int folioIndex, const QString &type, Q_INVOKABLE int addShape(int folioIndex, const QString &type,
double x1, double y1, double x2, double y2); double x1, double y1, double x2, double y2);
Q_INVOKABLE bool deleteShape(int folioIndex, int shapeIndex); Q_INVOKABLE bool deleteShape(int folioIndex, int shapeIndex);
Q_INVOKABLE QString shapeProperty(int folioIndex, int shapeIndex, const QString &property) const;
Q_INVOKABLE bool setShapeProperty(int folioIndex, int shapeIndex,
const QString &property, const QString &value);
// -- polygon and path shapes: more than addShape()'s two-point box --
Q_INVOKABLE int addPolygon(int folioIndex, const QVariantList &points, bool closed);
Q_INVOKABLE QVariantList shapePolygon(int folioIndex, int shapeIndex) const;
Q_INVOKABLE bool setShapePolygon(int folioIndex, int shapeIndex, const QVariantList &points);
Q_INVOKABLE int addPath(int folioIndex, const QVariantList &nodes, bool closed);
Q_INVOKABLE QVariantList shapePathNodes(int folioIndex, int shapeIndex) const;
Q_INVOKABLE bool setShapePathNodes(int folioIndex, int shapeIndex, const QVariantList &nodes);
Q_INVOKABLE bool setShapeClosed(int folioIndex, int shapeIndex, bool closed);
// -- query the project database -- // -- query the project database --
Q_INVOKABLE QStringList tables() const; Q_INVOKABLE QStringList tables() const;
Q_INVOKABLE QVariantList query(const QString &sql); Q_INVOKABLE QVariantList query(const QString &sql);
Q_INVOKABLE QString queryError() const; Q_INVOKABLE QString queryError() const;
// -- removing a conductor or a folio; folio properties beyond the title --
Q_INVOKABLE bool deleteConductor(int folioIndex, const QString &elementUuid, int terminalIndex);
Q_INVOKABLE bool removeFolio(int folioIndex);
Q_INVOKABLE bool setFolioProperty(int folioIndex, const QString &property, const QString &value);
Q_INVOKABLE QString folioProperty(int folioIndex, const QString &property) const;
// -- terminal strips (borniers) --
Q_INVOKABLE QStringList terminalStrips() const;
Q_INVOKABLE int addTerminalStrip(const QString &installation, const QString &location,
const QString &name);
Q_INVOKABLE bool removeTerminalStrip(int stripIndex);
Q_INVOKABLE bool addTerminalToStrip(int stripIndex, int folioIndex,
const QString &elementUuid);
Q_INVOKABLE QStringList stripRealTerminals(int stripIndex) const;
Q_INVOKABLE bool groupTerminals(int stripIndex, const QVariantList &realTerminalIndices);
Q_INVOKABLE bool bridgeTerminals(int stripIndex, const QVariantList &realTerminalIndices);
Q_INVOKABLE bool sortTerminalStrip(int stripIndex);
// -- a BOM/nomenclature or summary table placed on a folio --
Q_INVOKABLE QStringList tables(int folioIndex) const;
Q_INVOKABLE int addTable(int folioIndex, const QString &kind, const QString &name,
const QString &query);
Q_INVOKABLE bool deleteTable(int folioIndex, int tableIndex);
Q_INVOKABLE bool setTablePosition(int folioIndex, int tableIndex, double x, double y);
// -- auto-numbering contexts (conductor, element, folio) --
Q_INVOKABLE QStringList autoNums(const QString &kind) const;
Q_INVOKABLE bool addAutoNum(const QString &kind, const QString &name, const QStringList &parts);
Q_INVOKABLE bool removeAutoNum(const QString &kind, const QString &name);
Q_INVOKABLE bool useConductorAutoNum(int folioIndex, const QString &name);
Q_INVOKABLE bool useElementAutoNum(const QString &name);
Q_INVOKABLE bool numberElement(int folioIndex, const QString &elementUuid);
// -- images, embedded in the project --
Q_INVOKABLE QStringList images(int folioIndex) const;
Q_INVOKABLE int addImage(int folioIndex, const QString &filePath, double x, double y);
Q_INVOKABLE bool setImageScale(int folioIndex, int imageIndex, double factor);
Q_INVOKABLE bool setImageRotation(int folioIndex, int imageIndex, double angle);
Q_INVOKABLE bool deleteImage(int folioIndex, int imageIndex);
Q_INVOKABLE int addPdfPage(int folioIndex, const QString &pdfPath, int pageNumber,
int dpi, double x, double y);
// -- the text fields shown on a symbol (label, terminal names, ...) --
Q_INVOKABLE QStringList elementTexts(int folioIndex, const QString &elementUuid) const;
Q_INVOKABLE int addElementText(int folioIndex, const QString &elementUuid,
const QString &source, const QString &value,
double x, double y);
Q_INVOKABLE bool setElementTextProperty(int folioIndex, const QString &elementUuid,
int textIndex, const QString &property,
const QString &value);
Q_INVOKABLE QString elementTextProperty(int folioIndex, const QString &elementUuid,
int textIndex, const QString &property) const;
Q_INVOKABLE bool deleteElementText(int folioIndex, const QString &elementUuid, int textIndex);
// -- copy elements (with the conductors between them) to a position --
Q_INVOKABLE QStringList duplicateElements(int fromFolioIndex, const QStringList &elementUuids,
int toFolioIndex, double x, double y);
// -- the project title, and each folio's frame (grid of columns and rows) --
Q_INVOKABLE bool setProjectTitle(const QString &title);
Q_INVOKABLE QString folioBorder(int folioIndex, const QString &property) const;
Q_INVOKABLE bool setFolioBorder(int folioIndex, const QString &property, const QString &value);
// -- title block templates: which exist, embedding one into the project --
Q_INVOKABLE QStringList titleBlockTemplates() const;
Q_INVOKABLE bool embedTitleBlockTemplate(const QString &name);
// -- read an element's geometry --
Q_INVOKABLE QVariantMap elementGeometry(int folioIndex, const QString &elementUuid) const;
// -- folios -- // -- folios --
Q_INVOKABLE int addFolio(); Q_INVOKABLE int addFolio();
Q_INVOKABLE int insertFolio(int position);
Q_INVOKABLE bool setFolioTitle(int folioIndex, const QString &title); Q_INVOKABLE bool setFolioTitle(int folioIndex, const QString &title);
Q_INVOKABLE bool undo(); Q_INVOKABLE bool undo();
@@ -272,9 +541,21 @@ class QetScriptApi : public QObject
Q_INVOKABLE bool canUndo() const; Q_INVOKABLE bool canUndo() const;
Q_INVOKABLE bool canRedo() const; Q_INVOKABLE bool canRedo() const;
// -- project-wide text search & replace, one undo step for the
// whole run, in the same spirit as the "Search and replace" panel --
Q_INVOKABLE int searchAndReplace(const QString &kind, const QString &field,
const QString &pattern, const QString &replacement,
bool useRegex, bool caseSensitive);
// -- electrical continuity / ERC: read-only, structural checks
// against the live object graph rather than the XML -- see the
// .cpp doc comment for exactly what is and is not covered --
Q_INVOKABLE QVariantList checkContinuity(int folioIndex);
// -- navigate and message -- // -- navigate and message --
Q_INVOKABLE bool selectElement(const QString &elementUuid); Q_INVOKABLE bool selectElement(const QString &elementUuid);
Q_INVOKABLE void deselectAll(int folioIndex); Q_INVOKABLE void deselectAll(int folioIndex);
Q_INVOKABLE QStringList selectedElements(int folioIndex) const;
Q_INVOKABLE bool zoomFit(); Q_INVOKABLE bool zoomFit();
Q_INVOKABLE bool zoomToContent(); Q_INVOKABLE bool zoomToContent();
Q_INVOKABLE bool zoomReset(); Q_INVOKABLE bool zoomReset();
@@ -292,6 +573,10 @@ class QetScriptApi : public QObject
const QString &caller); const QString &caller);
QList<IndependentTextItem *> sortedTexts(int folioIndex) const; QList<IndependentTextItem *> sortedTexts(int folioIndex) const;
QList<QetShapeItem *> sortedShapes(int folioIndex) const; QList<QetShapeItem *> sortedShapes(int folioIndex) const;
QList<QetGraphicsTableItem *> sortedTables(int folioIndex) const;
QList<DiagramImageItem *> sortedImages(int folioIndex) const;
DynamicElementTextItem *findElementText(int folioIndex, const QString &elementUuid,
int textIndex, const QString &caller) const;
IndependentTextItem *findText(int folioIndex, int textIndex, const QString &caller); IndependentTextItem *findText(int folioIndex, int textIndex, const QString &caller);
bool setInfoKey(int folioIndex, const QString &elementUuid, bool setInfoKey(int folioIndex, const QString &elementUuid,
const QString &key, const QString &value, const QString &caller); const QString &key, const QString &value, const QString &caller);
+40
View File
@@ -20,6 +20,7 @@
#include "qetscriptapi.h" #include "qetscriptapi.h"
#include "../qetmessagebox.h" #include "../qetmessagebox.h"
#include "../qetproject.h" #include "../qetproject.h"
#include "../utils/qetsettings.h"
#include <QFile> #include <QFile>
#include <QFileInfo> #include <QFileInfo>
@@ -48,8 +49,33 @@ bool isRunRequest(const QStringList &args)
#ifdef QET_HAS_SCRIPTING #ifdef QET_HAS_SCRIPTING
namespace {
/**
@brief refusalMessage
What to tell somebody whose script was not run, and how to change
that. Written once because the command line and the graphical
editor both need to say it, and an explanation that names only one
of the two ways out sends half the people down the wrong path.
*/
QString refusalMessage()
{
return QObject::tr(
"Les scripts sont désactivés.\n\n"
"Un script a accès à l'ensemble du projet et peut écrire des "
"fichiers, aussi cette fonction est-elle désactivée par défaut.\n\n"
"Pour l'activer : Configurer QElectroTech > Général > Projets, "
"ou définir la variable d'environnement QET_ENABLE_SCRIPTING=1 "
"pour une exécution sans interface (CI, traitement par lot).");
}
}
int run(const QStringList &args) int run(const QStringList &args)
{ {
if (!QetSettings::scriptingEnabled()) {
err << refusalMessage() << "\n";
return 3;
}
const int idx = args.indexOf(QStringLiteral("--run")); const int idx = args.indexOf(QStringLiteral("--run"));
const QString script_path = args.value(idx + 1); const QString script_path = args.value(idx + 1);
const QString project_path = args.value(idx + 2); const QString project_path = args.value(idx + 2);
@@ -88,6 +114,20 @@ namespace {
bool runOnProject(const QString &scriptPath, QETProject *project, DiagramView *view) bool runOnProject(const QString &scriptPath, QETProject *project, DiagramView *view)
{ {
// Checked here as well as at each caller, deliberately: this 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 this one can -- a usable exit code
// on the command line, an offer to switch the setting on in the editor.
if (!QetSettings::scriptingEnabled()) {
err << refusalMessage() << "\n";
if (view) {
QET::QetMessageBox::warning(nullptr, QObject::tr("Script"),
refusalMessage());
}
return false;
}
QFile file(scriptPath); QFile file(scriptPath);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
err << "Cannot open script: " << scriptPath << "\n"; err << "Cannot open script: " << scriptPath << "\n";
@@ -23,6 +23,7 @@
#include "../../utils/qetsettings.h" #include "../../utils/qetsettings.h"
#include "../../utils/qetutils.h" #include "../../utils/qetutils.h"
#include "../../qetmessagebox.h" #include "../../qetmessagebox.h"
#include "../nokde/kcolorbutton.h"
#include <QFileDialog> #include <QFileDialog>
#include <QFontDialog> #include <QFontDialog>
#include <QSettings> #include <QSettings>
@@ -74,6 +75,12 @@ GeneralConfigurationPage::GeneralConfigurationPage(QWidget *parent) :
ui->DiagramEditor_Grid_PointSize_min_sb->setValue(settings.value("diagrameditor/grid_pointsize_min", 1).toInt()); ui->DiagramEditor_Grid_PointSize_min_sb->setValue(settings.value("diagrameditor/grid_pointsize_min", 1).toInt());
ui->DiagramEditor_Grid_PointSize_max_sb->setValue(settings.value("diagrameditor/grid_pointsize_max", 1).toInt()); ui->DiagramEditor_Grid_PointSize_max_sb->setValue(settings.value("diagrameditor/grid_pointsize_max", 1).toInt());
ui->m_use_system_color_cb->setChecked(settings.value("usesystemcolors", "true").toBool()); ui->m_use_system_color_cb->setChecked(settings.value("usesystemcolors", "true").toBool());
bool sysColors = ui->m_use_system_color_cb->isChecked();
ui->m_custom_app_color_kpb->setEnabled(!sysColors);
if (settings.contains("customapplicationcolor"))
ui->m_custom_app_color_kpb->setColor(QColor(settings.value("customapplicationcolor").toString()));
else
ui->m_custom_app_color_kpb->setColor(QApplication::palette().color(QPalette::Window));
bool tabbed = settings.value("diagrameditor/viewmode", "tabbed") == "tabbed"; bool tabbed = settings.value("diagrameditor/viewmode", "tabbed") == "tabbed";
if(tabbed) if(tabbed)
ui->m_use_tab_mode_rb->setChecked(true); ui->m_use_tab_mode_rb->setChecked(true);
@@ -82,6 +89,25 @@ GeneralConfigurationPage::GeneralConfigurationPage(QWidget *parent) :
ui->m_zoom_out_beyond_folio->setChecked(settings.value("diagrameditor/zoom-out-beyond-of-folio", false).toBool()); ui->m_zoom_out_beyond_folio->setChecked(settings.value("diagrameditor/zoom-out-beyond-of-folio", false).toBool());
ui->m_use_gesture_trackpad->setChecked(settings.value("diagramview/gestures", false).toBool()); ui->m_use_gesture_trackpad->setChecked(settings.value("diagramview/gestures", false).toBool());
ui->m_save_label_paste->setChecked(settings.value("diagramcommands/erase-label-on-copy", true).toBool()); ui->m_save_label_paste->setChecked(settings.value("diagramcommands/erase-label-on-copy", true).toBool());
ui->m_enable_scripting->setChecked(QetSettings::scriptingEnabled());
#ifdef QET_HAS_SCRIPTING
if (QetSettings::scriptingForcedByEnvironment()) {
//QET_ENABLE_SCRIPTING wins over the stored value, so let the box
//say so rather than offer a tick that changes nothing.
ui->m_enable_scripting->setEnabled(false);
ui->m_enable_scripting->setToolTip(
tr("Activé par la variable d'environnement "
"QET_ENABLE_SCRIPTING ; ce réglage est sans effet "
"tant qu'elle est définie."));
}
#else
//Built without Qt Qml: there is no scripting to allow. Disabled as
//well as hidden, so applyConf() leaves the stored value alone --
//a hidden box still reports its state, and writing it here would
//quietly clear a preference set on a build that does have Qml.
ui->m_enable_scripting->setVisible(false);
ui->m_enable_scripting->setEnabled(false);
#endif
ui->m_use_folio_label->setChecked(settings.value("genericpanel/folio", true).toBool()); ui->m_use_folio_label->setChecked(settings.value("genericpanel/folio", true).toBool());
ui->m_border_0->setChecked(settings.value("border-columns_0", false).toBool()); ui->m_border_0->setChecked(settings.value("border-columns_0", false).toBool());
ui->m_autosave_sb->setValue(settings.value("diagrameditor/autosave-interval", 0).toInt()); ui->m_autosave_sb->setValue(settings.value("diagrameditor/autosave-interval", 0).toInt());
@@ -206,7 +232,17 @@ void GeneralConfigurationPage::applyConf()
bool must_use_system_colors = ui->m_use_system_color_cb->isChecked(); bool must_use_system_colors = ui->m_use_system_color_cb->isChecked();
settings.setValue("usesystemcolors", must_use_system_colors); settings.setValue("usesystemcolors", must_use_system_colors);
if (was_using_system_colors != must_use_system_colors) { if (was_using_system_colors != must_use_system_colors) {
QETApp::instance()->useSystemPalette(must_use_system_colors); if (must_use_system_colors) {
QETApp::instance()->useSystemPalette(true);
} else {
QColor custom_color = ui->m_custom_app_color_kpb->color();
settings.setValue("customapplicationcolor", custom_color.name());
QETApp::instance()->useCustomPalette(custom_color);
}
} else if (!must_use_system_colors) {
QColor custom_color = ui->m_custom_app_color_kpb->color();
settings.setValue("customapplicationcolor", custom_color.name());
QETApp::instance()->useCustomPalette(custom_color);
} }
settings.setValue("border-columns_0",ui->m_border_0->isChecked()); settings.setValue("border-columns_0",ui->m_border_0->isChecked());
settings.setValue("lang", ui->m_lang_cb->itemData(ui->m_lang_cb->currentIndex()).toString()); settings.setValue("lang", ui->m_lang_cb->itemData(ui->m_lang_cb->currentIndex()).toString());
@@ -227,6 +263,14 @@ void GeneralConfigurationPage::applyConf()
//DIAGRAM COMMAND //DIAGRAM COMMAND
settings.setValue("diagramcommands/erase-label-on-copy", ui->m_save_label_paste->isChecked()); settings.setValue("diagramcommands/erase-label-on-copy", ui->m_save_label_paste->isChecked());
//SCRIPTING
//Left alone while the environment forces it on: the box is disabled
//in that case and writing its state would silently clear the user's
//real preference the first time this dialog is accepted.
if (ui->m_enable_scripting->isEnabled()) {
QetSettings::setScriptingEnabled(ui->m_enable_scripting->isChecked());
}
//GENERIC PANEL //GENERIC PANEL
settings.setValue("genericpanel/folio",ui->m_use_folio_label->isChecked()); settings.setValue("genericpanel/folio",ui->m_use_folio_label->isChecked());
@@ -625,3 +669,14 @@ void GeneralConfigurationPage::on_m_hdpi_round_cb_clicked(bool checked)
ui->m_hdpi_round_policy_cb->setEnabled(checked); ui->m_hdpi_round_policy_cb->setEnabled(checked);
} }
/**
@brief GeneralConfigurationPage::on_m_use_system_color_cb_toggled
Enable/disable the custom color picker when the system color
checkbox is toggled.
@param checked
*/
void GeneralConfigurationPage::on_m_use_system_color_cb_toggled(bool checked)
{
ui->m_custom_app_color_kpb->setEnabled(!checked);
}
@@ -53,6 +53,7 @@ class GeneralConfigurationPage : public ConfigPage
void on_ElementEditor_Grid_PointSize_min_sb_valueChanged(int value); void on_ElementEditor_Grid_PointSize_min_sb_valueChanged(int value);
void on_m_hdpi_round_cb_clicked(bool checked); void on_m_hdpi_round_cb_clicked(bool checked);
void on_m_use_system_color_cb_toggled(bool checked);
private: private:
void fillLang(); void fillLang();
@@ -24,6 +24,8 @@
<string>Apparence</string> <string>Apparence</string>
</attribute> </attribute>
<layout class="QVBoxLayout" name="verticalLayout"> <layout class="QVBoxLayout" name="verticalLayout">
<item>
<layout class="QHBoxLayout" name="m_system_color_layout">
<item> <item>
<widget class="QCheckBox" name="m_use_system_color_cb"> <widget class="QCheckBox" name="m_use_system_color_cb">
<property name="text"> <property name="text">
@@ -31,6 +33,15 @@
</property> </property>
</widget> </widget>
</item> </item>
<item>
<widget class="KColorButton" name="m_custom_app_color_kpb">
<property name="toolTip">
<string>Couleur de l'application</string>
</property>
</widget>
</item>
</layout>
</item>
<item> <item>
<widget class="Line" name="line"> <widget class="Line" name="line">
<property name="orientation"> <property name="orientation">
@@ -207,6 +218,16 @@
</widget> </widget>
</item> </item>
<item row="4" column="0"> <item row="4" column="0">
<widget class="QCheckBox" name="m_enable_scripting">
<property name="text">
<string>Autoriser l'exécution de scripts JavaScript (Projet &gt; Exécuter un script, et --run)</string>
</property>
<property name="toolTip">
<string>Un script s'exécute avec vos droits : il peut lire et modifier le projet ouvert et écrire des fichiers. Désactivé par défaut ; n'exécutez que des scripts dont vous connaissez l'origine.</string>
</property>
</widget>
</item>
<item row="5" column="0">
<spacer name="verticalSpacer_2"> <spacer name="verticalSpacer_2">
<property name="orientation"> <property name="orientation">
<enum>Qt::Vertical</enum> <enum>Qt::Vertical</enum>
@@ -1161,4 +1182,11 @@ Vous pouvez spécifier ici la valeur par défaut de ce champ pour les éléments
</tabstops> </tabstops>
<resources/> <resources/>
<connections/> <connections/>
<customwidgets>
<customwidget>
<class>KColorButton</class>
<extends>QPushButton</extends>
<header>nokde/kcolorbutton.h</header>
</customwidget>
</customwidgets>
</ui> </ui>
+114
View File
@@ -0,0 +1,114 @@
/*
Copyright 2006-2026 The QElectroTech Team
This file is part of QElectroTech.
QElectroTech is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 2 of the License, or
(at your option) any later version.
QElectroTech is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/
#include "duplicateoffsetdialog.h"
#include <QComboBox>
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QSettings>
#include <QSpinBox>
#include <QVBoxLayout>
namespace {
// Stored as one integer per axis, in grid steps -- not pixels, so the
// remembered offset still makes sense if the grid size ever changes.
const QString kOffsetXKey = QStringLiteral("diagrameditor/duplicate_offset_x");
const QString kOffsetYKey = QStringLiteral("diagrameditor/duplicate_offset_y");
}
DuplicateOffsetDialog::DuplicateOffsetDialog(QWidget *parent) :
QDialog(parent)
{
setWindowTitle(tr("Dupliquer"));
auto *form = new QFormLayout;
m_spacing = new QSpinBox(this);
m_spacing->setRange(1, 1000);
m_spacing->setSuffix(tr(" pas de grille"));
form->addRow(tr("Espacement :"), m_spacing);
m_direction = new QComboBox(this);
// Order matches the Direction enum, so currentIndex() can be used
// directly wherever Direction is needed.
m_direction->addItem(tr("Haut"));
m_direction->addItem(tr("Bas"));
m_direction->addItem(tr("Gauche"));
m_direction->addItem(tr("Droite"));
form->addRow(tr("Direction :"), m_direction);
const QPoint saved = savedStepOffset();
// The saved value is a signed (dx, dy) pair, not itself a
// spacing+direction pair, so it has to be decomposed back into the
// two the dialog shows. Exactly one axis is ever non-zero (see
// stepOffset()), so whichever one is picks the direction; a value
// that somehow has neither (only possible if QSettings was hand-
// edited) falls back to the same default stepOffset() would.
int spacing = 1;
Direction direction = Right;
if (saved.x() > 0) { direction = Right; spacing = saved.x(); }
else if (saved.x() < 0) { direction = Left; spacing = -saved.x(); }
else if (saved.y() > 0) { direction = Down; spacing = saved.y(); }
else if (saved.y() < 0) { direction = Up; spacing = -saved.y(); }
m_spacing->setValue(spacing);
m_direction->setCurrentIndex(static_cast<int>(direction));
auto *buttons = new QDialogButtonBox(
QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
auto *layout = new QVBoxLayout(this);
layout->addLayout(form);
layout->addWidget(buttons);
}
QPoint DuplicateOffsetDialog::stepOffset() const
{
const int spacing = m_spacing->value();
switch (static_cast<Direction>(m_direction->currentIndex())) {
case Up: return QPoint(0, -spacing);
case Down: return QPoint(0, spacing);
case Left: return QPoint(-spacing, 0);
case Right: return QPoint(spacing, 0);
}
return QPoint(spacing, 0); // unreachable; keeps -Wreturn-type quiet
}
QPoint DuplicateOffsetDialog::savedStepOffset()
{
QSettings settings;
if (!hasSavedStepOffset()) {
return QPoint(1, 0); // default: one grid step to the right
}
return QPoint(settings.value(kOffsetXKey).toInt(),
settings.value(kOffsetYKey).toInt());
}
void DuplicateOffsetDialog::saveStepOffset(const QPoint &steps)
{
QSettings settings;
settings.setValue(kOffsetXKey, steps.x());
settings.setValue(kOffsetYKey, steps.y());
}
bool DuplicateOffsetDialog::hasSavedStepOffset()
{
QSettings settings;
return settings.contains(kOffsetXKey) && settings.contains(kOffsetYKey);
}
+65
View File
@@ -0,0 +1,65 @@
/*
Copyright 2006-2026 The QElectroTech Team
This file is part of QElectroTech.
QElectroTech is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 2 of the License, or
(at your option) any later version.
QElectroTech is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef DUPLICATEOFFSETDIALOG_H
#define DUPLICATEOFFSETDIALOG_H
#include <QDialog>
#include <QPoint>
class QSpinBox;
class QComboBox;
/**
@brief The DuplicateOffsetDialog class
Asks how far, and in which of the four cardinal directions, Ctrl+D
(DiagramView::duplicate(), bugtracker #991) should offset a copy from
its source. Shown once, then remembered: the answer is stored in
QSettings and reused by every later Ctrl+D press without asking
again, until this dialog is reopened deliberately.
*/
class DuplicateOffsetDialog : public QDialog
{
Q_OBJECT
public:
enum Direction { Up, Down, Left, Right };
explicit DuplicateOffsetDialog(QWidget *parent = nullptr);
/// The offset in grid steps, positive along X to the right
/// and positive along Y downward -- QET's own scene axes,
/// matching the sign convention setPos() already uses
/// everywhere else in this codebase.
QPoint stepOffset() const;
/// Reads the last-confirmed spacing/direction from QSettings,
/// or the default (1 step, right) if none was ever set.
static QPoint savedStepOffset();
/// Writes @p steps to QSettings, in the same X/Y convention
/// as stepOffset().
static void saveStepOffset(const QPoint &steps);
/// Whether a direction/spacing has already been confirmed
/// once, i.e. whether Ctrl+D can skip the dialog.
static bool hasSavedStepOffset();
private:
QSpinBox *m_spacing = nullptr;
QComboBox *m_direction = nullptr;
};
#endif // DUPLICATEOFFSETDIALOG_H
+63 -21
View File
@@ -314,31 +314,15 @@ void LinkElementCommand::redo()
if(m_element->diagram()) m_element->diagram()->showMe(); if(m_element->diagram()) m_element->diagram()->showMe();
makeLink(m_linked_after); makeLink(m_linked_after);
//If the action is to link two reports together, we check if the conductors //If the action is to link two reports together, and the conductors
//of the new potential have the same text, function, and protocol. //of the new potential disagree on a property that matters, a
//if not, a dialog ask what do to. //dialog asks what to do. See reportLinkNeedsPotentialChoice() for
//what "disagree" checks and the bug fixed there (bugtracker #974).
if (m_first_redo && (m_element->linkType() & Element::AllReport) \ if (m_first_redo && (m_element->linkType() & Element::AllReport) \
&& m_element->conductors().size() \ && m_element->conductors().size() \
&& m_linked_after.size() && m_linked_after.first()->conductors().size()) && m_linked_after.size() && m_linked_after.first()->conductors().size())
{ {
//fill list of potential if (reportLinkNeedsPotentialChoice(m_element, m_linked_after.first()))
QSet <Conductor *> c_list = m_element->conductors().first()->relatedPotentialConductors();
c_list << m_element->conductors().first();
//fill list of text
QStringList str_txt;
QStringList str_funct;
QStringList str_tens;
for (const Conductor *c : c_list)
{
str_txt << c->properties().text;
str_funct << c->properties().m_function;
str_tens << c->properties().m_tension_protocol;
str_tens << c->properties().m_wire_color;
str_tens << c->properties().m_wire_section;
}
//check text list, isn't same in potential, ask user what to do
if (!QET::eachStrIsEqual(str_txt) || !QET::eachStrIsEqual(str_funct) || !QET::eachStrIsEqual(str_tens))
{ {
PotentialSelectorDialog psd(m_element, this); PotentialSelectorDialog psd(m_element, this);
psd.exec(); psd.exec();
@@ -348,6 +332,64 @@ void LinkElementCommand::redo()
QUndoCommand::redo(); QUndoCommand::redo();
} }
/**
@brief LinkElementCommand::reportLinkNeedsPotentialChoice
Whether linking these two report elements (next_report/previous_report)
would pop PotentialSelectorDialog -- i.e. whether their conductors (if
any exist yet, on either side) disagree on a property redo() cares
about. Exposed as its own static method, rather than left inline in
redo(), for the same reason ConductorCreator::needsPotentialChoice()
is: a caller with nobody there to answer a modal dialog (the scripting
API) can check first and decline, and the condition cannot drift away
from the one redo() actually applies.
Bug fixed here (bugtracker #974): the original check built ONE
combined list from three unrelated fields (tension_protocol,
wire_color, wire_section) and tested that whole list for equality --
comparing a tension-protocol string against a wire-colour string is
never equal even when each field individually matches across every
conductor, and 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 is
"color"/"style"). Net effect: the dialog could not reliably detect a
real mismatch, including the exact case #974 reported -- two
report-linked conductors drawn in different colours -- and could just
as easily fire on conductors that matched in every way that mattered.
Comparing each relevant field (text/num, function, tension protocol,
colour, line style) on its own fixes both.
@param element_a @param element_b the two elements about to be (or
already) linked; order does not matter
@return true if the dialog would (or does) open
*/
bool LinkElementCommand::reportLinkNeedsPotentialChoice(Element *element_a, Element *element_b)
{
if (!element_a || !element_b) return false;
if (element_a->conductors().isEmpty() || element_b->conductors().isEmpty()) return false;
QSet<Conductor *> c_list;
for (Element *e : {element_a, element_b})
{
if (e->conductors().isEmpty()) continue;
c_list << e->conductors().first();
c_list += e->conductors().first()->relatedPotentialConductors();
}
if (c_list.size() < 2) return false;
QStringList str_txt, str_funct, str_tens, str_color, str_style;
for (const Conductor *c : std::as_const(c_list))
{
str_txt << c->properties().text;
str_funct << c->properties().m_function;
str_tens << c->properties().m_tension_protocol;
str_color << c->properties().color.name();
str_style << QString::number(int(c->properties().style));
}
return !QET::eachStrIsEqual(str_txt) || !QET::eachStrIsEqual(str_funct)
|| !QET::eachStrIsEqual(str_tens) || !QET::eachStrIsEqual(str_color)
|| !QET::eachStrIsEqual(str_style);
}
/** /**
@brief LinkElementCommand::setUpNewLink @brief LinkElementCommand::setUpNewLink
Update the content of m_link_after with the content of element_list. Update the content of m_link_after with the content of element_list.
+1
View File
@@ -38,6 +38,7 @@ class LinkElementCommand : public QUndoCommand
bool mergeWith(const QUndoCommand *other) override; bool mergeWith(const QUndoCommand *other) override;
static bool isLinkable (Element *element_a, Element *element_b, bool already_linked = false); static bool isLinkable (Element *element_a, Element *element_b, bool already_linked = false);
static bool reportLinkNeedsPotentialChoice(Element *element_a, Element *element_b);
void setLink (const QList<Element *>& element_list); void setLink (const QList<Element *>& element_list);
void setLink (Element *element_); void setLink (Element *element_);
+1 -1
View File
@@ -60,7 +60,7 @@ m_diagram(diagram)
parts << QObject::tr("%n texte(s)", "", texts_list.count()); parts << QObject::tr("%n texte(s)", "", texts_list.count());
if (groups_list.count()) if (groups_list.count())
parts << QObject::tr("%n groupe(s) de textes", "", groups_list.count()); parts << QObject::tr("%n groupe(s) de textes", "", groups_list.count());
setText(QObject::tr("Pivoter %1").arg(QLocale().createSeparatedList(parts))); setText(QObject::tr("Pivoter %1").arg(QLocale(QETApp::interfaceLanguage()).createSeparatedList(parts)));
for(DiagramTextItem *dti : texts_list) for(DiagramTextItem *dti : texts_list)
setupAnimation(dti, "rotation", dti->rotation(), m_rotation); setupAnimation(dti, "rotation", dti->rotation(), m_rotation);
+11
View File
@@ -18,7 +18,9 @@
#include "conductorcreator.h" #include "conductorcreator.h"
#include "../conductorautonumerotation.h" #include "../conductorautonumerotation.h"
#include "../dataBase/projectdatabase.h"
#include "../diagram.h" #include "../diagram.h"
#include "../qetproject.h"
#include "../undocommand/addgraphicsobjectcommand.h" #include "../undocommand/addgraphicsobjectcommand.h"
#include "../qetgraphicsitem/conductor.h" #include "../qetgraphicsitem/conductor.h"
#include "../qetgraphicsitem/element.h" #include "../qetgraphicsitem/element.h"
@@ -68,6 +70,15 @@ ConductorCreator::ConductorCreator(Diagram *d, QList<Terminal *> terminals_list)
for(Conductor *c : c_list) { for(Conductor *c : c_list) {
c->refreshText(); c->refreshText();
//refreshText() resolves an auto-numbering formula into
//properties.text without emitting propertiesChange, which is
//what the project database listens to. The row was inserted
//while text was still the raw formula ("W%sequ_1"), so without
//this the wiring list and BOM read the formula, not "W1",
//until something forces a full rebuild.
if (d->project() && d->project()->dataBase()) {
d->project()->dataBase()->updateConductor(c);
}
} }
} }
+47
View File
@@ -19,6 +19,7 @@
#include "qetsettings.h" #include "qetsettings.h"
#include <QSettings> #include <QSettings>
#include <QVariant> #include <QVariant>
#include <QByteArray>
namespace QetSettings namespace QetSettings
{ {
@@ -105,4 +106,50 @@ namespace QetSettings
return default_policy; return default_policy;
} }
} }
/**
* @brief scriptingForcedByEnvironment
* @return true if QET_ENABLE_SCRIPTING is set to 1 in the environment.
*
* The way to turn scripting on where there is nobody to tick a box:
* a headless run, a CI job, a build server. Those have no settings
* file worth writing to -- and on a machine whose HOME is created
* fresh for the run, writing one would not survive anyway.
*/
bool scriptingForcedByEnvironment()
{
return qgetenv("QET_ENABLE_SCRIPTING") == QByteArray("1");
}
/**
* @brief scriptingEnabled
* @return whether QElectroTech may run a JavaScript script.
*
* Off unless the user turned it on. A script reaches the whole project
* and the filesystem through the export calls, so it is capability the
* great majority of users never asked for; leaving it on by default
* would hand it to them anyway. @sa setScriptingEnabled
*
* The environment override wins over the stored value, and is checked
* first so that a machine with no settings at all still answers.
*/
bool scriptingEnabled()
{
if (scriptingForcedByEnvironment()) {
return true;
}
QSettings settings;
return settings.value("scripting/enabled", false).toBool();
}
/**
* @brief setScriptingEnabled
* Store whether scripting is allowed. @sa scriptingEnabled
* @param enabled
*/
void setScriptingEnabled(bool enabled)
{
QSettings settings;
settings.setValue("scripting/enabled", enabled);
}
} }
+4
View File
@@ -32,6 +32,10 @@ namespace QetSettings
void setHdpiScaleFactorRoundingPolicy(Qt::HighDpiScaleFactorRoundingPolicy policy); void setHdpiScaleFactorRoundingPolicy(Qt::HighDpiScaleFactorRoundingPolicy policy);
Qt::HighDpiScaleFactorRoundingPolicy hdpiScaleFactorRoundingPolicy( Qt::HighDpiScaleFactorRoundingPolicy hdpiScaleFactorRoundingPolicy(
Qt::HighDpiScaleFactorRoundingPolicy default_policy = Qt::HighDpiScaleFactorRoundingPolicy::PassThrough); Qt::HighDpiScaleFactorRoundingPolicy default_policy = Qt::HighDpiScaleFactorRoundingPolicy::PassThrough);
bool scriptingEnabled();
void setScriptingEnabled(bool enabled);
bool scriptingForcedByEnvironment();
} }
#endif // QETSETTINGS_H #endif // QETSETTINGS_H
-4
View File
@@ -25,10 +25,6 @@ message(". PROJECT_SOURCE_DIR :" ${PROJECT_SOURCE_DIR})
# Add sub directories # Add sub directories
message(". Add sub directory catch") message(". Add sub directory catch")
add_subdirectory(catch) add_subdirectory(catch)
message(". Add sub directory googletest")
add_subdirectory(googletest)
message(". Add sub directory googlemock")
add_subdirectory(googlemock)
message(". Add sub directory modal-quit-regression") message(". Add sub directory modal-quit-regression")
add_subdirectory(modal-quit-regression) add_subdirectory(modal-quit-regression)
message(". Add sub directory qttest") message(". Add sub directory qttest")
-67
View File
@@ -1,67 +0,0 @@
---
BasedOnStyle: LLVM
AlignAfterOpenBracket: AlwaysBreak
AlignConsecutiveMacros: 'true'
AlignConsecutiveAssignments: 'true'
AlignConsecutiveDeclarations: 'true'
AlignEscapedNewlines: Right
AlignOperands: 'true'
AlignTrailingComments: 'true'
AllowAllArgumentsOnNextLine: 'false'
AllowAllConstructorInitializersOnNextLine: 'true'
AllowAllParametersOfDeclarationOnNextLine: 'true'
AllowShortBlocksOnASingleLine: 'true'
AllowShortCaseLabelsOnASingleLine: 'true'
AllowShortFunctionsOnASingleLine: All
AllowShortIfStatementsOnASingleLine: Always
AllowShortLambdasOnASingleLine: All
AllowShortLoopsOnASingleLine: 'true'
AlwaysBreakAfterReturnType: None
AlwaysBreakBeforeMultilineStrings: 'true'
AlwaysBreakTemplateDeclarations: 'Yes'
BinPackArguments: 'false'
BinPackParameters: 'false'
BreakAfterJavaFieldAnnotations: 'true'
BreakBeforeBinaryOperators: NonAssignment
BreakBeforeBraces: Allman
BreakBeforeTernaryOperators: 'false'
BreakConstructorInitializers: AfterColon
BreakInheritanceList: AfterColon
BreakStringLiterals: 'true'
ColumnLimit: '80'
CompactNamespaces: 'false'
ConstructorInitializerAllOnOneLineOrOnePerLine: 'true'
Cpp11BracedListStyle: 'true'
FixNamespaceComments: 'true'
IncludeBlocks: Regroup
IndentCaseLabels: 'false'
IndentPPDirectives: AfterHash
IndentWidth: '4'
JavaScriptWrapImports: 'true'
Language: Cpp
MaxEmptyLinesToKeep: '1'
NamespaceIndentation: All
PointerAlignment: Left
ReflowComments: 'true'
SortIncludes: 'true'
SortUsingDeclarations: 'true'
SpaceAfterCStyleCast: 'true'
SpaceAfterLogicalNot: 'true'
SpaceAfterTemplateKeyword: 'true'
SpaceBeforeAssignmentOperators: 'true'
SpaceBeforeCpp11BracedList: 'true'
SpaceBeforeCtorInitializerColon: 'true'
SpaceBeforeInheritanceColon: 'true'
SpaceBeforeParens: ControlStatements
SpaceBeforeRangeBasedForLoopColon: 'true'
SpaceInEmptyParentheses: 'false'
SpacesInAngles: 'false'
SpacesInCStyleCastParentheses: 'false'
SpacesInContainerLiterals: 'false'
SpacesInParentheses: 'false'
SpacesInSquareBrackets: 'false'
Standard: Cpp11
TabWidth: '4'
UseTab: Always
...
-73
View File
@@ -1,73 +0,0 @@
# This file is used to ignore files which are generated
# ----------------------------------------------------------------------------
*~
*.autosave
*.a
*.core
*.moc
*.o
*.obj
*.orig
*.rej
*.so
*.so.*
*_pch.h.cpp
*_resource.rc
*.qm
.#*
*.*#
core
!core/
tags
.DS_Store
.directory
*.debug
Makefile*
*.prl
*.app
moc_*.cpp
ui_*.h
qrc_*.cpp
Thumbs.db
*.res
*.rc
/.qmake.cache
/.qmake.stash
# qtcreator generated files
*.pro.user*
# xemacs temporary files
*.flc
# Vim temporary files
.*.swp
# Visual Studio generated files
*.ib_pdb_index
*.idb
*.ilk
*.pdb
*.sln
*.suo
*.vcproj
*vcproj.*.*.user
*.ncb
*.sdf
*.opensdf
*.vcxproj
*vcxproj.*
# MinGW generated files
*.Debug
*.Release
# Python byte code
*.pyc
# Binaries
# --------
*.dll
*.exe
-78
View File
@@ -1,78 +0,0 @@
# Copyright 2006 The QElectroTech Team
# This file is part of QElectroTech.
#
# QElectroTech is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 2 of the License, or
# (at your option) any later version.
#
# QElectroTech is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
cmake_minimum_required(VERSION 3.5)
message("..___________________________________________________________________")
project(G_unitmocktests LANGUAGES CXX)
set(CMAKE_INCLUDE_CURRENT_DIR ON)
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)
set(CMAKE_AUTOUIC ON)
SET(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
message(".. PROJECT_NAME :" ${PROJECT_NAME})
message(".. PROJECT_SOURCE_DIR :" ${PROJECT_SOURCE_DIR})
if(NOT DEFINED QET_DIR)
set(QET_DIR "../..")
message(".. QET_DIR is not set, assuming QET is ../..")
endif()
message(".. QET_DIR :" ${QET_DIR})
if(NOT DEFINED QET_COMPONENTS)
message(".. QET_COMPONENTS is not set !!! I set them up !!!")
include(../../cmake/qet_compilation_vars.cmake)
endif()
find_package(
Qt6
COMPONENTS
${QET_COMPONENTS}
REQUIRED)
Include(FetchContent)
FetchContent_Declare(
GTest
GIT_REPOSITORY https://github.com/google/googletest.git
GIT_TAG release-1.10.0)
set(INSTALL_GTEST OFF CACHE INTERNAL "")
FetchContent_MakeAvailable(GTest)
include(../../cmake/fetch_kdeaddons.cmake)
include(../../cmake/fetch_singleapplication.cmake)
include(../../cmake/fetch_pugixml.cmake)
enable_testing()
add_executable(
${PROJECT_NAME}
tst_My_test.cpp
main.cpp
)
target_link_libraries(
${PROJECT_NAME}
PUBLIC
gmock gmock_main
PRIVATE
${KF_PRIVATE_LIBRARIES}
${QET_PRIVATE_LIBRARIES})
-15
View File
@@ -1,15 +0,0 @@
#include <QtGui/QGuiApplication>
#include <gmock/gmock.h>
int main(int argc, char** argv)
{
QGuiApplication app(argc, argv);
// disable the whole debug output (we want only the output from gtest)
// Debug::instance()->setDebugLevelLogFile(Debug::DebugLevel_t::Nothing);
// Debug::instance()->setDebugLevelStderr(Debug::DebugLevel_t::Nothing);
// init gtest and run all tests
::testing::InitGoogleMock(&argc, argv);
return RUN_ALL_TESTS();
}
-5
View File
@@ -1,5 +0,0 @@
#include "../../sources/borderproperties.h"
#include <gmock/gmock.h>
TEST(googlemocktest, sample_mock) { EXPECT_EQ(1, 1); }
-67
View File
@@ -1,67 +0,0 @@
---
BasedOnStyle: LLVM
AlignAfterOpenBracket: AlwaysBreak
AlignConsecutiveMacros: 'true'
AlignConsecutiveAssignments: 'true'
AlignConsecutiveDeclarations: 'true'
AlignEscapedNewlines: Right
AlignOperands: 'true'
AlignTrailingComments: 'true'
AllowAllArgumentsOnNextLine: 'false'
AllowAllConstructorInitializersOnNextLine: 'true'
AllowAllParametersOfDeclarationOnNextLine: 'true'
AllowShortBlocksOnASingleLine: 'true'
AllowShortCaseLabelsOnASingleLine: 'true'
AllowShortFunctionsOnASingleLine: All
AllowShortIfStatementsOnASingleLine: Always
AllowShortLambdasOnASingleLine: All
AllowShortLoopsOnASingleLine: 'true'
AlwaysBreakAfterReturnType: None
AlwaysBreakBeforeMultilineStrings: 'true'
AlwaysBreakTemplateDeclarations: 'Yes'
BinPackArguments: 'false'
BinPackParameters: 'false'
BreakAfterJavaFieldAnnotations: 'true'
BreakBeforeBinaryOperators: NonAssignment
BreakBeforeBraces: Allman
BreakBeforeTernaryOperators: 'false'
BreakConstructorInitializers: AfterColon
BreakInheritanceList: AfterColon
BreakStringLiterals: 'true'
ColumnLimit: '80'
CompactNamespaces: 'false'
ConstructorInitializerAllOnOneLineOrOnePerLine: 'true'
Cpp11BracedListStyle: 'true'
FixNamespaceComments: 'true'
IncludeBlocks: Regroup
IndentCaseLabels: 'false'
IndentPPDirectives: AfterHash
IndentWidth: '4'
JavaScriptWrapImports: 'true'
Language: Cpp
MaxEmptyLinesToKeep: '1'
NamespaceIndentation: All
PointerAlignment: Left
ReflowComments: 'true'
SortIncludes: 'true'
SortUsingDeclarations: 'true'
SpaceAfterCStyleCast: 'true'
SpaceAfterLogicalNot: 'true'
SpaceAfterTemplateKeyword: 'true'
SpaceBeforeAssignmentOperators: 'true'
SpaceBeforeCpp11BracedList: 'true'
SpaceBeforeCtorInitializerColon: 'true'
SpaceBeforeInheritanceColon: 'true'
SpaceBeforeParens: ControlStatements
SpaceBeforeRangeBasedForLoopColon: 'true'
SpaceInEmptyParentheses: 'false'
SpacesInAngles: 'false'
SpacesInCStyleCastParentheses: 'false'
SpacesInContainerLiterals: 'false'
SpacesInParentheses: 'false'
SpacesInSquareBrackets: 'false'
Standard: Cpp11
TabWidth: '4'
UseTab: Always
...
-73
View File
@@ -1,73 +0,0 @@
# This file is used to ignore files which are generated
# ----------------------------------------------------------------------------
*~
*.autosave
*.a
*.core
*.moc
*.o
*.obj
*.orig
*.rej
*.so
*.so.*
*_pch.h.cpp
*_resource.rc
*.qm
.#*
*.*#
core
!core/
tags
.DS_Store
.directory
*.debug
Makefile*
*.prl
*.app
moc_*.cpp
ui_*.h
qrc_*.cpp
Thumbs.db
*.res
*.rc
/.qmake.cache
/.qmake.stash
# qtcreator generated files
*.pro.user*
# xemacs temporary files
*.flc
# Vim temporary files
.*.swp
# Visual Studio generated files
*.ib_pdb_index
*.idb
*.ilk
*.pdb
*.sln
*.suo
*.vcproj
*vcproj.*.*.user
*.ncb
*.sdf
*.opensdf
*.vcxproj
*vcxproj.*
# MinGW generated files
*.Debug
*.Release
# Python byte code
*.pyc
# Binaries
# --------
*.dll
*.exe
-77
View File
@@ -1,77 +0,0 @@
# Copyright 2006 The QElectroTech Team
# This file is part of QElectroTech.
#
# QElectroTech is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 2 of the License, or
# (at your option) any later version.
#
# QElectroTech is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
cmake_minimum_required(VERSION 3.5)
message("..___________________________________________________________________")
project(G_unittests LANGUAGES CXX)
set(CMAKE_INCLUDE_CURRENT_DIR ON)
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)
set(CMAKE_AUTOUIC ON)
SET(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
message(".. PROJECT_NAME :" ${PROJECT_NAME})
message(".. PROJECT_SOURCE_DIR :" ${PROJECT_SOURCE_DIR})
if(NOT DEFINED QET_DIR)
set(QET_DIR "../..")
message(".. QET_DIR is not set, assuming QET is ../..")
endif()
message(".. QET_DIR :" ${QET_DIR})
if(NOT DEFINED QET_COMPONENTS)
message(".. QET_COMPONENTS is not set !!! I set them up !!!")
include(../../cmake/qet_compilation_vars.cmake)
endif()
find_package(
Qt6
COMPONENTS
${QET_COMPONENTS}
REQUIRED)
Include(FetchContent)
FetchContent_Declare(
GTest
GIT_REPOSITORY https://github.com/google/googletest.git
GIT_TAG v1.17.0)
set(INSTALL_GTEST OFF CACHE INTERNAL "")
FetchContent_MakeAvailable(GTest)
include(../../cmake/fetch_kdeaddons.cmake)
include(../../cmake/fetch_singleapplication.cmake)
include(../../cmake/fetch_pugixml.cmake)
enable_testing()
add_executable(
${PROJECT_NAME}
tst_My_test.cpp
main.cpp
)
target_link_libraries(
${PROJECT_NAME}
PUBLIC
gtest gtest_main
PRIVATE
${KF_PRIVATE_LIBRARIES}
${QET_PRIVATE_LIBRARIES})
-15
View File
@@ -1,15 +0,0 @@
#include <QtGui/QGuiApplication>
#include <gtest/gtest.h>
int main(int argc, char** argv)
{
QGuiApplication app(argc, argv);
// disable the whole debug output (we want only the output from gtest)
// Debug::instance()->setDebugLevelLogFile(Debug::DebugLevel_t::Nothing);
// Debug::instance()->setDebugLevelStderr(Debug::DebugLevel_t::Nothing);
// init gtest and run all tests
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
-5
View File
@@ -1,5 +0,0 @@
#include "../../sources/borderproperties.h"
#include <gtest/gtest.h>
TEST(googletest, sample_test) { EXPECT_EQ(1, 1); }
+32 -7
View File
@@ -141,7 +141,34 @@ add_executable(
${QET_DIR}/sources/shortcutmanager.cpp) ${QET_DIR}/sources/shortcutmanager.cpp)
add_test(NAME tst_qetstrings COMMAND tst_qetstrings) add_test(NAME tst_qetstrings COMMAND tst_qetstrings)
target_include_directories(tst_qetstrings PRIVATE ${QET_DIR}/sources) target_include_directories(tst_qetstrings PRIVATE ${QET_DIR}/sources)
target_link_libraries(tst_qetstrings PRIVATE Qt::Test Qt::Widgets Qt::Xml) target_link_libraries(tst_qetstrings PRIVATE Qt::Test Qt::Widgets Qt::Xml pugixml::pugixml)
# QETSql::isSingleReadOnlyStatement() -- read-only enforcement for every
# project-database query, including the ones a .qet file carries. Compiles
# sqlreadonly.cpp alone against its own in-memory SQLite, so the security
# property is checked without standing up a QETProject.
find_package(SQLite3 REQUIRED)
if(NOT TARGET SQLite3::SQLite3 AND TARGET SQLite::SQLite3)
add_library(SQLite3::SQLite3 ALIAS SQLite::SQLite3)
endif()
add_executable(
tst_sqlreadonly
tst_sqlreadonly.cpp
${QET_DIR}/sources/dataBase/sqlreadonly.cpp)
add_test(NAME tst_sqlreadonly COMMAND tst_sqlreadonly)
target_include_directories(tst_sqlreadonly PRIVATE ${QET_DIR}/sources)
target_link_libraries(tst_sqlreadonly PRIVATE Qt::Test SQLite3::SQLite3)
# QetSettings::scriptingEnabled() -- whether QElectroTech may run a script.
# Compiles qetsettings.cpp alone: the setting is deliberately a plain
# QSettings read, so the test needs nothing else of QElectroTech.
add_executable(
tst_scriptingsetting
tst_scriptingsetting.cpp
${QET_DIR}/sources/utils/qetsettings.cpp)
add_test(NAME tst_scriptingsetting COMMAND tst_scriptingsetting)
target_include_directories(tst_scriptingsetting PRIVATE ${QET_DIR}/sources)
target_link_libraries(tst_scriptingsetting PRIVATE Qt::Test Qt::Gui)
# CrashHandler::formatInt() -- the async-signal-safe decimal formatter the # CrashHandler::formatInt() -- the async-signal-safe decimal formatter the
# signal handler uses for the "Signal: N" line of a crash dump. Compiles # signal handler uses for the "Signal: N" line of a crash dump. Compiles
@@ -179,13 +206,11 @@ add_test(NAME tst_crashdumps COMMAND tst_crashdumps)
target_include_directories(tst_crashdumps PRIVATE target_include_directories(tst_crashdumps PRIVATE
${QET_DIR} ${QET_DIR}
${QET_DIR}/sources ${QET_DIR}/sources
${QET_DIR}/sources/NameList ${QET_DIR}/sources/NameList)
${QET_DIR}/pugixml/src) target_link_libraries(tst_crashdumps PRIVATE
Qt::Test Qt::Widgets Qt::Xml pugixml::pugixml)
if(Backtrace_FOUND) if(Backtrace_FOUND)
target_link_libraries(tst_crashdumps PRIVATE target_link_libraries(tst_crashdumps PRIVATE ${Backtrace_LIBRARIES})
Qt::Test Qt::Widgets Qt::Xml ${Backtrace_LIBRARIES})
else()
target_link_libraries(tst_crashdumps PRIVATE Qt::Test Qt::Widgets Qt::Xml)
endif() endif()
add_executable( add_executable(
+3
View File
@@ -1,6 +1,9 @@
#include <QtTest> #include <QtTest>
#include "qet.h" #include "qet.h"
#include "qetapp.h"
QString QETApp::m_interface_language;
/** /**
QET::joinWithSpaces() / QET::splitWithSpaces() are the wire format for the QET::joinWithSpaces() / QET::splitWithSpaces() are the wire format for the
+146
View File
@@ -0,0 +1,146 @@
/*
Copyright 2006-2026 The QElectroTech Team
This file is part of QElectroTech.
QElectroTech is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 2 of the License, or
(at your option) any later version.
QElectroTech is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/
/*
QetSettings::scriptingEnabled() -- the switch that decides whether
QElectroTech will run JavaScript at all.
Its default is the whole point: a setting nobody has touched must read
as off, because that is the state every existing installation is in
after an upgrade. The other case worth pinning is the environment
override, which exists so a headless run has a way in without a dialog
-- and which must beat a stored "false", or a CI machine that once had
the box unticked can never script again.
This test owns its own QSettings scope (organization + application
name), so it cannot read or write the real configuration of whoever
runs it.
*/
#include "utils/qetsettings.h"
#include <QtTest>
#include <QSettings>
class TstScriptingSetting : public QObject
{
Q_OBJECT
private slots:
void initTestCase();
void init();
void defaultsToOff();
void storedValueIsHonoured();
void environmentOverridesAStoredFalse();
void environmentIsReportedSeparately();
void writingDoesNotDependOnReading();
private:
void clearStoredValue();
};
void TstScriptingSetting::initTestCase()
{
// A scope of this test's own: whatever this writes must not land in
// the configuration of the account running the suite.
QCoreApplication::setOrganizationName(
QStringLiteral("QElectroTech-tst_scriptingsetting"));
QCoreApplication::setApplicationName(
QStringLiteral("tst_scriptingsetting"));
QSettings settings;
settings.clear();
}
void TstScriptingSetting::clearStoredValue()
{
QSettings settings;
settings.remove(QStringLiteral("scripting/enabled"));
settings.sync();
}
void TstScriptingSetting::init()
{
clearStoredValue();
qunsetenv("QET_ENABLE_SCRIPTING");
}
void TstScriptingSetting::defaultsToOff()
{
QVERIFY2(!QetSettings::scriptingEnabled(),
"an untouched installation must not run scripts");
}
void TstScriptingSetting::storedValueIsHonoured()
{
QetSettings::setScriptingEnabled(true);
QVERIFY(QetSettings::scriptingEnabled());
QetSettings::setScriptingEnabled(false);
QVERIFY(!QetSettings::scriptingEnabled());
}
void TstScriptingSetting::environmentOverridesAStoredFalse()
{
// The headless case: no dialog to tick, and a stored false that must
// not be able to lock a CI job out of --run forever.
QetSettings::setScriptingEnabled(false);
qputenv("QET_ENABLE_SCRIPTING", "1");
QVERIFY(QetSettings::scriptingEnabled());
// Only "1" counts. An empty or accidental value is not consent.
for (const QByteArray &value : {QByteArray(""), QByteArray("0"),
QByteArray("true"), QByteArray("yes")}) {
qputenv("QET_ENABLE_SCRIPTING", value);
QVERIFY2(!QetSettings::scriptingEnabled(),
qPrintable(QStringLiteral("accepted QET_ENABLE_SCRIPTING=%1")
.arg(QString::fromUtf8(value))));
}
}
void TstScriptingSetting::environmentIsReportedSeparately()
{
// The configuration dialog asks this to explain why its checkbox is
// disabled, so it must answer about the environment alone and not be
// confused by the stored value.
QetSettings::setScriptingEnabled(true);
QVERIFY(!QetSettings::scriptingForcedByEnvironment());
qputenv("QET_ENABLE_SCRIPTING", "1");
QVERIFY(QetSettings::scriptingForcedByEnvironment());
}
void TstScriptingSetting::writingDoesNotDependOnReading()
{
// While the environment forces scripting on, scriptingEnabled() says
// true whatever is stored -- so read the stored value directly to be
// sure a write still lands. The configuration dialog relies on this:
// it skips the write in that state on purpose, and would be silently
// wrong if setScriptingEnabled() were a no-op instead.
qputenv("QET_ENABLE_SCRIPTING", "1");
QetSettings::setScriptingEnabled(false);
QSettings settings;
QCOMPARE(settings.value(QStringLiteral("scripting/enabled")).toBool(), false);
QetSettings::setScriptingEnabled(true);
QSettings other;
QCOMPARE(other.value(QStringLiteral("scripting/enabled")).toBool(), true);
}
QTEST_MAIN(TstScriptingSetting)
#include "tst_scriptingsetting.moc"
+198
View File
@@ -0,0 +1,198 @@
/*
Copyright 2006-2026 The QElectroTech Team
This file is part of QElectroTech.
QElectroTech is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 2 of the License, or
(at your option) any later version.
QElectroTech is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/
/*
QETSql::isSingleReadOnlyStatement() -- the read-only enforcement every
project-database query goes through.
The case that matters most here is the CTE prefix. SQLite has allowed
WITH in front of DELETE/UPDATE/INSERT since 3.8.3, so a check on the
first keyword lets a write through while reading as though it refused
one. That is not hypothetical for QElectroTech: a <graphics_table>'s
<query> is stored in the .qet and executed on load, so the text can
arrive from a file rather than from the person at the keyboard.
This test owns its own in-memory database and links nothing of
QElectroTech but sqlreadonly.cpp, so it stays a fast, hermetic check of
the security property itself.
*/
#include "dataBase/sqlreadonly.h"
#include <QtTest>
#include <sqlite3.h>
class TstSqlReadOnly : public QObject
{
Q_OBJECT
private slots:
void initTestCase();
void cleanupTestCase();
void acceptsOrdinaryReads();
void acceptsLegitimateCommonTableExpression();
void refusesCtePrefixedWrites_data();
void refusesCtePrefixedWrites();
void refusesBareWrites_data();
void refusesBareWrites();
void refusesTrailingStatement();
void refusesEmptyAndCommentOnly_data();
void refusesEmptyAndCommentOnly();
void refusesWithoutAConnection();
void reportsAReason();
void doesNotExecuteWhatItRefuses();
private:
sqlite3 *m_db = nullptr;
int rowCount();
};
int TstSqlReadOnly::rowCount()
{
sqlite3_stmt *st = nullptr;
sqlite3_prepare_v2(m_db, "SELECT COUNT(*) FROM element", -1, &st, nullptr);
sqlite3_step(st);
const int n = sqlite3_column_int(st, 0);
sqlite3_finalize(st);
return n;
}
void TstSqlReadOnly::initTestCase()
{
QCOMPARE(sqlite3_open(":memory:", &m_db), SQLITE_OK);
QCOMPARE(sqlite3_exec(m_db,
"CREATE TABLE element (uuid TEXT);"
"INSERT INTO element VALUES ('a'),('b');", nullptr, nullptr, nullptr),
SQLITE_OK);
QCOMPARE(rowCount(), 2);
}
void TstSqlReadOnly::cleanupTestCase()
{
sqlite3_close(m_db);
m_db = nullptr;
}
void TstSqlReadOnly::acceptsOrdinaryReads()
{
QVERIFY(QETSql::isSingleReadOnlyStatement(m_db, "SELECT * FROM element"));
QVERIFY(QETSql::isSingleReadOnlyStatement(m_db, "SELECT uuid FROM element WHERE uuid = 'a'"));
// A semicolon inside a string literal is not a second statement. The
// textual check this replaced rejected exactly this.
QVERIFY(QETSql::isSingleReadOnlyStatement(m_db, "SELECT ';' AS semicolon"));
// One trailing semicolon is ordinary punctuation, not a second statement.
QVERIFY(QETSql::isSingleReadOnlyStatement(m_db, "SELECT * FROM element;"));
}
void TstSqlReadOnly::acceptsLegitimateCommonTableExpression()
{
// WITH must keep working -- the fix is not "ban CTEs".
QVERIFY(QETSql::isSingleReadOnlyStatement(m_db,
"WITH x AS (SELECT 1 AS n) SELECT n FROM x"));
QVERIFY(QETSql::isSingleReadOnlyStatement(m_db,
"WITH RECURSIVE c(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM c) "
"SELECT n FROM c LIMIT 3"));
}
void TstSqlReadOnly::refusesCtePrefixedWrites_data()
{
QTest::addColumn<QString>("query");
QTest::newRow("delete") << "WITH x AS (SELECT 1) DELETE FROM element";
QTest::newRow("update") << "WITH x AS (SELECT 1) UPDATE element SET uuid = 'pwned'";
QTest::newRow("insert") << "WITH x AS (SELECT 1) INSERT INTO element VALUES ('injected')";
}
void TstSqlReadOnly::refusesCtePrefixedWrites()
{
QFETCH(QString, query);
QVERIFY2(!QETSql::isSingleReadOnlyStatement(m_db, query),
qPrintable(QStringLiteral("accepted a write: %1").arg(query)));
}
void TstSqlReadOnly::refusesBareWrites_data()
{
QTest::addColumn<QString>("query");
QTest::newRow("delete") << "DELETE FROM element";
QTest::newRow("update") << "UPDATE element SET uuid = 'pwned'";
QTest::newRow("insert") << "INSERT INTO element VALUES ('injected')";
QTest::newRow("drop") << "DROP TABLE element";
}
void TstSqlReadOnly::refusesBareWrites()
{
QFETCH(QString, query);
QVERIFY(!QETSql::isSingleReadOnlyStatement(m_db, query));
}
void TstSqlReadOnly::refusesTrailingStatement()
{
QVERIFY(!QETSql::isSingleReadOnlyStatement(m_db, "SELECT 1; DROP TABLE element"));
QVERIFY(!QETSql::isSingleReadOnlyStatement(m_db, "SELECT 1; SELECT 2"));
}
void TstSqlReadOnly::refusesEmptyAndCommentOnly_data()
{
QTest::addColumn<QString>("query");
QTest::newRow("empty") << "";
QTest::newRow("whitespace") << " ";
QTest::newRow("comment") << "-- nothing to see here";
}
void TstSqlReadOnly::refusesEmptyAndCommentOnly()
{
// sqlite3_prepare_v2() reports success and a null statement for these;
// sqlite3_stmt_readonly() must never be handed that.
QFETCH(QString, query);
QVERIFY(!QETSql::isSingleReadOnlyStatement(m_db, query));
}
void TstSqlReadOnly::refusesWithoutAConnection()
{
// Fails closed: with no connection there is nothing to ask, and
// guessing from the text is the weakness this replaced.
QVERIFY(!QETSql::isSingleReadOnlyStatement(nullptr, "SELECT * FROM element"));
}
void TstSqlReadOnly::reportsAReason()
{
QString reason;
QVERIFY(!QETSql::isSingleReadOnlyStatement(
m_db, "WITH x AS (SELECT 1) DELETE FROM element", &reason));
QVERIFY2(!reason.isEmpty(), "a refusal must say why");
reason = QStringLiteral("stale");
QVERIFY(QETSql::isSingleReadOnlyStatement(m_db, "SELECT * FROM element", &reason));
QVERIFY2(reason.isEmpty(), "an accepted query must not leave a reason behind");
}
void TstSqlReadOnly::doesNotExecuteWhatItRefuses()
{
// The check compiles the statement to inspect it. Proving the table is
// untouched afterwards is what says it compiled without running it --
// and this same assertion goes red if the refusals above ever stop
// refusing, since then the caller would run the DELETE for real.
QCOMPARE(rowCount(), 2);
QVERIFY(!QETSql::isSingleReadOnlyStatement(m_db, "WITH x AS (SELECT 1) DELETE FROM element"));
QVERIFY(!QETSql::isSingleReadOnlyStatement(m_db, "DELETE FROM element"));
QCOMPARE(rowCount(), 2);
}
QTEST_APPLESS_MAIN(TstSqlReadOnly)
#include "tst_sqlreadonly.moc"