Commit Graph

4 Commits

Author SHA1 Message Date
ispyisail c740cdf1ac Let a script wire, label and rotate, not only place
The scripting API (bugtracker #162) could place an element and move it,
and could count conductors but not make one. So a script could put a
coil and a motor on a folio and had no way to connect them, which is
most of what drawing is. This adds the missing verbs:

  addConductor()      wire terminal i of one element to terminal j of another
  rotateElement()
  setElementInfo()    any information key
  setElementLabel()   the label key, by name, since it is the one people want
  addFolio()
  setFolioTitle()
  elementUuids()      what is on this folio
  elementName()
  elementTerminals()  which terminal index is which, before wiring it

Each goes through the command the GUI already uses, so a script's edits
undo like manual ones and reach the project database the same way:
ConductorCreator (the drag-a-rectangle-over-terminals path, which is
what makes a new conductor inherit an existing potential's properties
and join auto-numbering), ChangeElementInformationCommand,
QETProject::addNewDiagram(), ChangeTitleBlockCommand. rotateElement()
pushes the same QPropertyUndoCommand on "rotation" that
RotateSelectionCommand pushes for an Element, rather than
RotateSelectionCommand itself, which works on the diagram's selection
and would mean rewriting the user's selection to rotate one element.

Terminals are addressed by index, not uuid. Terminal::uuid() is a
property of the catalog .elmt definition: empty for most of the
installed base, and where present, identical across every instance of
that element -- two coils of the same type placed side by side have
byte-identical terminal uuids, so a uuid cannot say which coil's A1 is
meant. elementTerminals() exists so a script can see the indexing
instead of guessing it.

The one real hazard is that ConductorCreator asks the user which
potential to inherit from when the two terminals sit on two different
existing ones, and it asks with a plain modal QDialog that
QET::QetMessageBox's non-interactive mode does not cover -- so under
headless --run there is nobody to answer and the call never returns.
Measured: with the check removed, that one call hangs until killed;
with it, it declines in 0.4 s. addConductor() therefore refuses that
case, the same way and for the same reason addElement() already refuses
the import-conflict dialog.

To make that check without duplicating the condition, existingPotential()
becomes static over an explicit terminal list and ConductorCreator gains
a public needsPotentialChoice() predicate. Behaviour of the GUI path is
unchanged; setUpPropertieToUse() passes m_terminals_list to the same code
it called before.

Verified headlessly against a copy of examples/ArduinoLCD.qet: new folio
titled, two coils placed, wired, labelled, an info key set and the
element rotated; saved, reloaded, and the conductor, label, title and
rotation (persisted as orientation="1") all read back. Re-saving the
result is byte-identical. qet-lint clean on the generated project;
qet-coherence-check clean on it and on the 24-project example corpus,
and shown to report 9 findings on a deliberately broken copy of the same
file, so the clean result discriminates. Qt 6.10.2, ctest identical to
master (the 61 failures are the vendored KDE ECM suite, present on both).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 17:57:29 +12:00
ispyisail 4a83521306 Fix scripting review findings: import, save, backups, timeout
Addresses scorpio810's review of PR #891:

- addElement() now imports the element into the project's own embedded
  collection first (QETProject::importElement()), same as the
  drag-from-collection-panel path -- otherwise the saved .qet referenced
  a definition outside the project, missing on another machine. Also
  found and fixed while wiring this up: ElementsLocation::setPath()
  forces any path to embed:// once a non-null project is passed, so the
  unconditional ElementsLocation(locationPath, m_project) this method
  used before silently broke every common://custom:// call. Refuses
  on an import-collision case that would otherwise reach
  QETProject::importElement()'s own modal ImportElementDialog, with
  nobody there to answer it in a script.
- addElement()/setElementPosition()/moveElement()/deleteElement() refuse
  on a read-only project, matching their GUI equivalents.
- save() goes through QETProject::write() when no path is given (read
  -only handling, saveddate/savedtime, QSaveFile via writeXmlFile()) and
  QET::writeXmlFile() directly for an explicit output path, instead of a
  plain QFile that skipped all of that.
- Interactive "Run Script...": exports briefly disable project backups
  around the temporary QETProject CLIExport::run() opens on the same
  file, so it doesn't race the real open project's own KAutoSaveFile.
  Restored right after -- the headless --run path is untouched, since it
  depends on backups staying off for the whole run.
- A watchdog thread now calls QJSEngine::setInterrupted() after 30s,
  so a runaway script (`while(true){}`) can't freeze the GUI or hang a
  CI job forever. First version used sleep_for() and blocked every run,
  fast ones included, for the full 30s on join() -- caught by testing a
  one-line script, fixed with wait_for() on a condition variable so a
  script that finishes early wakes the watchdog immediately.
- Interactive script errors now also show a QetMessageBox, not just
  stderr (invisible on Windows).
- Ran update_translations (lupdate) to pick up the 37 strings this
  feature had not yet added to the .ts files.

Not applied: wrapping the whole script run in one undo macro. It would
make the script's own qet.undo()/qet.redo() calls silent no-ops for the
run's duration -- QUndoStack ignores undo()/redo() while a macro is
open -- which would break that already-shipped, explicitly requested
capability to get one convenience Ctrl+Z instead.

Verified: Qt 6.10.2, builds clean, ctest 6/6. Ran each fix against a
real project: addElement() on a common:// path now succeeds and the
saved file embeds the definition (embed://import/...); read-only
project refuses addElement(); save("") and save(otherpath) both
produce a correctly embedded file; `while(true){}` under --run is
interrupted at 30s where it previously hung forever, and a normal
script now exits in ~0.3s instead of blocking for the full timeout
budget.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 22:28:06 +12:00
ispyisail 0920e82188 Add geometry editing, undo integration, and navigation to scripting
Extends the scripting surface from the previous commit with exactly
the three things explicitly scoped out there, per follow-up direction:
editing geometry, undo integration, and driving the GUI -- the last
one narrowed to select/zoom/message after discussion, since "invoke
any menu action by name" would let a script trigger a modal
QDialog::exec() with nobody there to dismiss it, the same hang class
investigated for bugtracker #882.

## New capabilities

- addElement/setElementPosition/moveElement/deleteElement, through the
  same undo commands the GUI itself uses: AddGraphicsObjectCommand
  (the same one drag-from-collection-panel placement uses),
  QPropertyUndoCommand on the standard `pos` property, and
  DeleteQGraphicsItemCommand (refuses a non-deletable terminal, same
  as the Delete key).
- undo/redo/canUndo/canRedo against the project's real QUndoStack --
  the same one QETDiagramEditor's Ctrl+Z is wired to via
  undo_group.activeStack(), not a parallel mechanism.
- selectElement/deselectAll (scene state, no view required -- works
  headless), zoomFit/zoomToContent/zoomReset (need the active
  DiagramView, so false headless where there is nothing to zoom), and
  showMessage (a modal QET::QetMessageBox::information -- safe headless
  because non-interactive mode is already on for the whole process
  before any script runs).

## Two real bugs caught by testing this, not assumed away

1. save() was still going through the same reopen-from-disk path as
   every export method: it opened a *second*, unmodified copy of the
   project from its file on disk and rewrote that. addElement() and
   friends operate on the live in-memory project, so nothing they did
   ever reached the saved file -- an element counted correctly in
   memory and then silently vanished from the output. Fixed by having
   save() write m_project->toXml() directly, the only method that
   touches the live instance rather than a fresh copy of the file.

2. A script calling setElementPosition() then moveElement() on the
   same element produced a saved position that didn't match either
   call, and undo/redo didn't step through them independently. Traced
   to QPropertyUndoCommand::mergeWith() (pre-existing, not new):
   consecutive commands on the same object+property merge when their
   text() also matches, and both calls build the identical "Déplacer
   %1" text for a given element -- exactly the same collapsing
   dragging an item repeatedly gets. Not a bug in the new code; a
   wrong assumption in the first test. Re-verified against the
   correct, merge-aware expectation: add -> merged move -> undo (back
   to first position) -> undo (element removed) -> redo (element back)
   -> redo (merged move reapplied) landed at the exact predicted final
   position, read back from the saved XML.

## Verified

Qt6, build clean from a fresh reconfigure, ctest 6/6.

- Headless: addElement returns a real uuid and the count updates;
  select/set-position/move all report correctly; the merge-aware
  undo/redo/save round trip above, confirmed against the saved file's
  actual XML, not just in-memory counters.
- Corpus: the existing read-model smoke script re-run against all 24
  shipped example projects on the fixed binary, 0 failures.
- zoomFit correctly returns false headless (no view to act on),
  confirming the "narrowed GUI-driving" scope holds in code, not just
  in the doc comment.
- GUI: running the add-only script via "Exécuter un script..." marked
  the project [modifié] in the title bar, the same change-tracking
  path a manual edit goes through -- consistent with the undo command
  actually being pushed onto the project's real stack rather than some
  side channel invisible to the rest of the application.

Refs #162.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 20:20:58 +12:00
ispyisail eba258f6cd Add JavaScript scripting: --run and "Run Script..." (bugtracker #162)
Following up on my own comments there: a deliberately small, mostly
read-only scripting surface, exposed to scripts as a single global
`qet` object (QetScriptApi) built on QJSEngine rather than an embedded
Python interpreter -- no new toolchain to package (QJSEngine ships in
every Qt SDK QET already targets, via the Qml module), no GIL, no
version pinning, automatic reflection of the QObject-derived core
classes' own methods with no hand-written binding layer.

## What a script can do

- Read the model: project title, file path, folio count/titles,
  element/conductor counts per folio.
- Trigger the same operations the --export-* CLI flags already do
  (pdf/png/svg/cables/wires/bom/wiring/nets/links/info), plus
  set-titleblock and save -- thin wrappers around CLIExport::run(),
  reusing its already-tested logic rather than duplicating it.

Deliberately NOT in this version: creating or editing diagram
geometry, undo integration, driving the GUI. All explicitly out of
scope per the discussion on #162.

## Two entry points, both built and tested

- `qelectrotech --run script.js project.qet` -- headless/CI.
- Projet > "Exécuter un script..." -- an interactive macro against the
  currently open project. Export/save calls act on the project's file
  on disk (see QetScriptApi's class comment for why), so unsaved GUI
  edits aren't visible to the script; save first if that matters.

## Optional dependency, not a hard requirement

Qt::Qml is probed the same way QtPdf already is in this codebase:
QUIET, non-fatal, behind a QET_HAS_SCRIPTING compile definition. A
build without it compiles and links identically; the CLI flag and
menu action are simply absent (main.cpp) or compile to a clear
"not available" stderr message rather than silently disappearing
(qetscripting.cpp), matching the existing QtPdf pattern rather than
introducing a new one.

One real bug caught building this, not assumed away: my first pass
conditionally excluded the new source files from QET_SRC_FILES behind
`if(QET_HAS_SCRIPTING)` inside qet_compilation_vars.cmake -- but that
file is included before QET_HAS_SCRIPTING is set in the top-level
CMakeLists.txt, so the variable didn't exist yet at that point and the
files were silently never compiled, only caught by an undefined-symbol
link error. Fixed by following the QtPdf file's own precedent:
compile the files unconditionally, guard their Qt::Qml-dependent
content internally instead.

## Verified

Qt6, build clean, ctest 6/6.

- Headless: a script reading project/folio/element/conductor counts,
  calling exportInfo() and exportPdf() against a real project --
  correct JSON, a real single-page PDF confirmed with `file`.
  Error paths: a thrown script exception reports file:line:message and
  exit 1; missing script/project arguments exit 2 (matching
  CLIExport's own usage-error convention); a missing project file is
  reported and does not hang.
- Corpus: the same read-model script run against all 24 shipped
  example projects, 0 failures.
- GUI: "Exécuter un script..." opens a real file dialog filtered to
  *.js, running the picked script against the live open project
  produced the exact expected JSON export file, and the application
  was still fully responsive afterward.

Refs #162.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 19:44:14 +12:00