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>
This commit is contained in:
ispyisail
2026-09-16 19:44:14 +12:00
parent 2eed3acd3d
commit eba258f6cd
9 changed files with 540 additions and 0 deletions
+44
View File
@@ -16,6 +16,9 @@
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/
#include "qetdiagrameditor.h"
#ifdef QET_HAS_SCRIPTING
#include "scripting/qetscripting.h"
#endif
#include <QCoreApplication>
#include "ElementsCollection/elementscollectionwidget.h"
#include "QWidgetAnimation/qwidgetanimation.h"
@@ -549,6 +552,15 @@ void QETDiagramEditor::setUpActions()
m_terminal_numbering = new QAction(QET::Icons::TerminalStrip, tr("Numérotation automatique des bornes"), this);
connect(m_terminal_numbering, &QAction::triggered, this, &QETDiagramEditor::slot_terminalNumbering);
#ifdef QET_HAS_SCRIPTING
// Run a JavaScript macro against the current project (bugtracker #162).
m_run_script = new QAction(tr("Exécuter un script..."), this);
m_run_script->setStatusTip(
tr("Exécute un script JavaScript sur le projet courant (voir qet.*"
" dans le script pour l'API disponible)"));
connect(m_run_script, &QAction::triggered, this, &QETDiagramEditor::slot_runScript);
#endif
#ifdef QET_EXPORT_PROJECT_DB
m_export_project_db = new QAction(QET::Icons::DocumentSpreadsheet, tr("Exporter la base de donnée interne du projet"), this);
connect(m_export_project_db, &QAction::triggered, [this]() {
@@ -1002,6 +1014,9 @@ void QETDiagramEditor::setUpMenu()
menu_project -> addAction(m_project_export_wiring_list);
menu_project -> addAction(m_project_wiring_list_view);
menu_project -> addAction(m_terminal_numbering);
#ifdef QET_HAS_SCRIPTING
menu_project -> addAction(m_run_script);
#endif
#ifdef QET_EXPORT_PROJECT_DB
menu_project -> addSeparator();
menu_project -> addAction(m_export_project_db);
@@ -1856,6 +1871,9 @@ void QETDiagramEditor::slot_updateActions()
m_project_export_wiring_list -> setEnabled(opened_project);
m_project_wiring_list_view -> setEnabled(opened_project);
m_terminal_numbering -> setEnabled(editable_project);
#ifdef QET_HAS_SCRIPTING
m_run_script -> setEnabled(opened_project);
#endif
#ifdef QET_EXPORT_PROJECT_DB
m_export_project_db -> setEnabled(editable_project);
#endif
@@ -2970,3 +2988,29 @@ void QETDiagramEditor::slot_terminalNumbering() {
}
}
}
#ifdef QET_HAS_SCRIPTING
/**
@brief QETDiagramEditor::slot_runScript
Run a JavaScript macro against the current project (bugtracker #162).
See QetScriptApi for what a script can do -- read-mostly: folio/element/
conductor counts and the same export operations the --export-* CLI
flags provide. Export/save calls inside the script act on this
project's file on disk, so unsaved edits in the open editor are not
visible to the script; save first if that matters.
*/
void QETDiagramEditor::slot_runScript() {
QETProject *project = currentProject();
if (!project) return;
const QString script_path = QFileDialog::getOpenFileName(
this,
tr("Exécuter un script"),
QString(),
tr("Scripts JavaScript (*.js);;Tous les fichiers (*)")
);
if (script_path.isEmpty()) return;
QetScripting::runOnProject(script_path, project);
}
#endif