diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index 4593d5d56..ad1b3abe6 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -923,6 +923,11 @@ list(APPEND QET_SRC_FILES ${QET_DIR}/sources/scripting/qetscriptapi.h ${QET_DIR}/sources/scripting/qetscripting.cpp ${QET_DIR}/sources/scripting/qetscripting.h + ${QET_DIR}/sources/scripting/scriptheader.h + ${QET_DIR}/sources/scripting/scriptlibrary.cpp + ${QET_DIR}/sources/scripting/scriptlibrary.h + ${QET_DIR}/sources/scripting/assistantinfo.cpp + ${QET_DIR}/sources/scripting/assistantinfo.h ) if(QET_SPACEMOUSE_ENABLED) diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index 0218f7cf8..e73b0ffa6 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -18,6 +18,8 @@ #include "qetdiagrameditor.h" #ifdef QET_HAS_SCRIPTING #include "scripting/qetscripting.h" +#include "scripting/scriptlibrary.h" +#include "scripting/assistantinfo.h" #endif #include #include @@ -81,7 +83,9 @@ #include #include #include +#include #include +#include #include #ifdef BUILD_WITHOUT_KF # include "ui/nokde/kautosavefile.h" @@ -162,6 +166,11 @@ QETDiagramEditor::QETDiagramEditor(const QStringList &files, QWidget *parent) : readSettings(); // restoreGeometry before show() show(); readSettingsState(); // restoreState() must be called after show() in Qt6 +#ifdef QET_HAS_SCRIPTING + //A toolbar saved as shown while there were scripts stays out of + //the way while there are none. + if (m_script_actions.isEmpty()) m_scripts_tool_bar->hide(); +#endif //If valid file path is given as arguments uint opened_projects = 0; @@ -701,6 +710,17 @@ void QETDiagramEditor::setUpActions() 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); + + //Stored scripts are files in a folder, written by hand, by the + //script manager or by an assistant: open it to add one. + m_open_scripts_folder = new QAction(QET::Icons::FolderOpen, tr("Ouvrir le dossier des scripts"), this); + m_open_scripts_folder->setStatusTip( + tr("Chaque fichier .js de ce dossier qui commence par un en-tête" + " // ==QETScript== devient un bouton")); + connect(m_open_scripts_folder, &QAction::triggered, this, []() { + QDir().mkpath(ScriptLibrary::folder()); + QDesktopServices::openUrl(QUrl::fromLocalFile(ScriptLibrary::folder())); + }); #endif #ifdef QET_EXPORT_PROJECT_DB @@ -1206,6 +1226,14 @@ void QETDiagramEditor::setUpToolBar() addToolBar(Qt::TopToolBarArea, diagram_tool_bar); addToolBar(Qt::TopToolBarArea, m_add_item_tool_bar); addToolBar(Qt::TopToolBarArea, m_depth_tool_bar); + + m_scripts_tool_bar = new QToolBar(tr("Scripts", "toolbar title"), this); + m_scripts_tool_bar->setObjectName("scripts"); + addToolBar(Qt::TopToolBarArea, m_scripts_tool_bar); +#ifndef QET_HAS_SCRIPTING + m_scripts_tool_bar->toggleViewAction()->setVisible(false); + m_scripts_tool_bar->hide(); +#endif } /** @@ -1327,7 +1355,11 @@ void QETDiagramEditor::setUpMenu() menu_project -> addAction(m_terminal_numbering); menu_project -> addAction(m_reload_element_drawings); #ifdef QET_HAS_SCRIPTING - menu_project -> addAction(m_run_script); + m_scripts_menu = menu_project -> addMenu(tr("Scripts")); + rebuildScriptActions(); + connect(&ScriptLibrary::instance(), &ScriptLibrary::changed, + this, &QETDiagramEditor::rebuildScriptActions); + AssistantInfo::watch(); #endif #ifdef QET_EXPORT_PROJECT_DB menu_project -> addSeparator(); @@ -2380,6 +2412,9 @@ void QETDiagramEditor::slot_updateUndoStack() */ void QETDiagramEditor::slot_updateComplexActions() { +#ifdef QET_HAS_SCRIPTING + updateScriptActions(); +#endif DiagramView *dv = currentDiagramView(); if(!dv) { @@ -3723,28 +3758,7 @@ void QETDiagramEditor::slot_runScript() { QETProject *project = currentProject(); 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); - } + if (!ensureScriptingEnabled(tr("Exécuter un script"))) return; const QString script_path = QFileDialog::getOpenFileName( this, @@ -3756,6 +3770,137 @@ void QETDiagramEditor::slot_runScript() { QetScripting::runOnProject(script_path, project, currentDiagramView()); } + +/** + @brief QETDiagramEditor::ensureScriptingEnabled + @return true if scripts may run, asking to switch them on if they are + off. Shared by "Run a script..." and the stored script buttons, so + there is one prompt to keep right. +*/ +bool QETDiagramEditor::ensureScriptingEnabled(const QString &title) +{ + // 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, + title, + 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 false; + } + QetSettings::setScriptingEnabled(true); + } + return true; +} + +/** + @brief QETDiagramEditor::runStoredScript + Run the stored script at @a path on the current project, as one undo + step named after the script. The file is read again on every run, so + an edit to it takes effect on the next click. +*/ +void QETDiagramEditor::runStoredScript(const QString &path, const QString &name) +{ + QETProject *project = currentProject(); + if (!project) return; + if (!ensureScriptingEnabled(name)) return; + QetScripting::runOnProject(path, project, currentDiagramView(), name); +} + +/** + @brief QETDiagramEditor::rebuildScriptActions + One action per stored script, in the Projet > Scripts menu and on the + Scripts toolbar, registered with ShortcutManager under + diagrameditor.script.: that one registration is what lists + it in the shortcut settings, the shortcut bar (S) and command search. + Rebuilt from scratch whenever the scripts folder changes. +*/ +void QETDiagramEditor::rebuildScriptActions() +{ + for (QAction *action : std::as_const(m_script_actions)) { + ShortcutManager::instance().unregisterAction( + action, action->property("qet_script_action_id").toString()); + delete action; + } + m_script_actions.clear(); + m_scripts_menu->clear(); + m_scripts_tool_bar->clear(); + + const ScriptLibrary &library = ScriptLibrary::instance(); + for (const ScriptLibrary::Script &script : library.scripts()) { + const ScriptHeader &h = script.header; + auto *action = new QAction(ScriptLibrary::icon(script), h.name, this); + action->setStatusTip(h.tooltip); + action->setToolTip(h.tooltip.isEmpty() ? h.name : h.name + QLatin1Char('\n') + h.tooltip); + action->setProperty("qet_script_action_id", ScriptLibrary::actionId(h.id)); + action->setProperty("qet_script_context", h.context); + connect(action, &QAction::triggered, this, [this, path = script.path, name = h.name]() { + runStoredScript(path, name); + }); + ShortcutManager::instance().registerAction(action, ScriptLibrary::actionId(h.id), + tr("Scripts"), + QKeySequence::fromString(h.shortcut)); + m_scripts_menu->addAction(action); + m_scripts_tool_bar->addAction(action); + m_script_actions << action; + } + + //A script with a header that cannot be used gets no button: say + //which file and why, where its author will look for the button. + const QStringList errors = library.errors(); + if (!errors.isEmpty()) { + m_scripts_menu->addSeparator(); + for (const QString &error : errors) { + QAction *ignored = m_scripts_menu->addAction(QET::Icons::DialogInformation, + tr("Ignoré : %1").arg(error)); + ignored->setEnabled(false); + } + } + + m_scripts_menu->addSeparator(); + m_scripts_menu->addAction(m_run_script); + m_scripts_menu->addAction(m_open_scripts_folder); + + //Show the toolbar when the first script arrives, hide it when the + //last one goes; in between it is the user's to show or hide. + const bool has_scripts = !m_script_actions.isEmpty(); + if (has_scripts != m_had_scripts) m_scripts_tool_bar->setVisible(has_scripts); + m_had_scripts = has_scripts; + + updateScriptActions(); +} + +/** + @brief QETDiagramEditor::updateScriptActions + Enable each script for what its header's @context asks for: always + (canvas), with something selected (selection), or with a conductor + selected (conductor). None without an open project. +*/ +void QETDiagramEditor::updateScriptActions() +{ + DiagramView *dv = currentDiagramView(); + Diagram *diagram = dv ? dv->diagram() : nullptr; + const bool selection = diagram && !diagram->selectedItems().isEmpty(); + const bool conductor = diagram && !diagram->selectedConductors().isEmpty(); + for (QAction *action : std::as_const(m_script_actions)) { + const QString context = action->property("qet_script_context").toString(); + bool enabled = currentProject() != nullptr; + if (context == QLatin1String("selection")) enabled = enabled && selection; + else if (context == QLatin1String("conductor")) enabled = enabled && conductor; + action->setEnabled(enabled); + } +} #endif /** diff --git a/sources/qetdiagrameditor.h b/sources/qetdiagrameditor.h index 0345255d4..8455c4476 100644 --- a/sources/qetdiagrameditor.h +++ b/sources/qetdiagrameditor.h @@ -154,6 +154,10 @@ class QETDiagramEditor : public QETMainWindow void slot_reloadElementDrawings(); #ifdef QET_HAS_SCRIPTING void slot_runScript(); + void rebuildScriptActions(); + void updateScriptActions(); + bool ensureScriptingEnabled(const QString &title); + void runStoredScript(const QString &path, const QString &name); #endif void editDiagramProperties(DiagramView *); void editDiagramProperties(Diagram *); @@ -245,6 +249,7 @@ class QETDiagramEditor : public QETMainWindow *m_reload_element_drawings, ///< Action to redraw every placed element from its current definition #ifdef QET_HAS_SCRIPTING *m_run_script, ///< Action to run a JavaScript macro against the current project + *m_open_scripts_folder, ///< Action to open the folder stored scripts are read from #endif *m_export_project_db, ///Export to file the internal database of the current project *m_tile_window, ///< Show MDI subwindows as tile @@ -310,7 +315,13 @@ class QETDiagramEditor : public QETMainWindow *view_tool_bar = nullptr, *diagram_tool_bar = nullptr, *m_add_item_tool_bar = nullptr, - *m_depth_tool_bar = nullptr; + *m_depth_tool_bar = nullptr, + *m_scripts_tool_bar = nullptr; ///< One button per stored script +#ifdef QET_HAS_SCRIPTING + QMenu *m_scripts_menu = nullptr; + QList m_script_actions; ///< One per stored script, rebuilt when the folder changes + bool m_had_scripts = false; +#endif QUndoGroup undo_group; AutoNumberingDockWidget *m_autonumbering_dock; diff --git a/sources/scripting/assistantinfo.cpp b/sources/scripting/assistantinfo.cpp new file mode 100644 index 000000000..97d236722 --- /dev/null +++ b/sources/scripting/assistantinfo.cpp @@ -0,0 +1,143 @@ +/* + 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 . +*/ +#include "assistantinfo.h" + +#include "qetscriptapi.h" +#include "scriptlibrary.h" +#include "../qetapp.h" +#include "../qetversion.h" +#include "../utils/qetsettings.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + QJsonObject s_live; ///< empty while live mode is closed + bool s_running = true; +} + +namespace AssistantInfo { + +QString path() +{ + return QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + + QStringLiteral("/qet-assistant.json"); +} + +/** + @brief watch + Write the file now, again whenever the stored scripts change, and mark + it stopped when QElectroTech quits. Called by every editor window as it + opens; acts once. +*/ +void watch() +{ + static bool watching = false; + if (watching) return; + watching = true; + QObject::connect(&ScriptLibrary::instance(), &ScriptLibrary::changed, &write); + QObject::connect(qApp, &QCoreApplication::aboutToQuit, &markStopped); + write(); +} + +void write() +{ + QJsonArray scripts, refused; + for (const ScriptLibrary::Script &s : ScriptLibrary::instance().scripts()) { + const ScriptHeader &h = s.header; + scripts.append(QJsonObject{ + {QStringLiteral("id"), h.id}, + {QStringLiteral("name"), h.name}, + {QStringLiteral("file"), s.path}, + {QStringLiteral("icon"), h.icon}, + {QStringLiteral("tooltip"), h.tooltip}, + {QStringLiteral("shortcut"), h.shortcut}, + {QStringLiteral("context"), h.context}, + {QStringLiteral("action_id"), ScriptLibrary::actionId(h.id)}}); + } + for (const QString &e : ScriptLibrary::instance().errors()) refused.append(e); + + const QJsonObject info{ + {QStringLiteral("format"), 1}, + {QStringLiteral("qelectrotech_version"), QetVersion::displayedVersion()}, + {QStringLiteral("program"), QCoreApplication::applicationFilePath()}, + {QStringLiteral("pid"), QCoreApplication::applicationPid()}, + {QStringLiteral("running"), s_running}, + {QStringLiteral("written"), QDateTime::currentDateTimeUtc().toString(Qt::ISODate)}, + {QStringLiteral("folders"), QJsonObject{ + {QStringLiteral("data"), QETApp::dataDir()}, + {QStringLiteral("config"), QETApp::configDir()}, + {QStringLiteral("scripts"), ScriptLibrary::folder()}, + {QStringLiteral("elements_common"), QETApp::commonElementsDir()}, + {QStringLiteral("elements_company"), QETApp::companyElementsDir()}, + {QStringLiteral("elements_custom"), QETApp::customElementsDir()}, + {QStringLiteral("titleblocks_common"), QETApp::commonTitleBlockTemplatesDir()}, + {QStringLiteral("titleblocks_company"), QETApp::companyTitleBlockTemplatesDir()}, + {QStringLiteral("titleblocks_custom"), QETApp::customTitleBlockTemplatesDir()}}}, + {QStringLiteral("features"), QJsonObject{ +#ifdef QET_HAS_SCRIPTING + {QStringLiteral("scripting_available"), true}, +#else + {QStringLiteral("scripting_available"), false}, +#endif + {QStringLiteral("scripting_enabled"), QetSettings::scriptingEnabled()}, + {QStringLiteral("live_mode_open"), !s_live.isEmpty()}}}, + {QStringLiteral("script_api"), QJsonArray::fromStringList(QetScriptApi::signatures())}, + {QStringLiteral("stored_scripts"), scripts}, + {QStringLiteral("refused_scripts"), refused}, + {QStringLiteral("live"), s_live.isEmpty() ? QJsonValue() : QJsonValue(s_live)}}; + + const QString file_path = path(); + QDir().mkpath(QFileInfo(file_path).path()); + QSaveFile file(file_path); + if (!file.open(QIODevice::WriteOnly)) return; + file.write(QJsonDocument(info).toJson(QJsonDocument::Indented)); + if (file.commit()) + QFile::setPermissions(file_path, QFile::ReadOwner | QFile::WriteOwner); +} + +/** + @brief setLive + The live channel's details while it is open (socket, token), or an + empty object once it closes. +*/ +void setLive(const QJsonObject &live) +{ + s_live = live; + write(); +} + +/** + @brief markStopped + QElectroTech is quitting: the file stays, as a record of where things + are, but says nothing is listening any more. +*/ +void markStopped() +{ + s_running = false; + s_live = QJsonObject(); + write(); +} + +} diff --git a/sources/scripting/assistantinfo.h b/sources/scripting/assistantinfo.h new file mode 100644 index 000000000..2243b3deb --- /dev/null +++ b/sources/scripting/assistantinfo.h @@ -0,0 +1,50 @@ +/* + 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 . +*/ +#ifndef ASSISTANTINFO_H +#define ASSISTANTINFO_H + +#include +#include + +/** + @brief qet-assistant.json: what an AI assistant (through the qet MCP + server) needs to know about this QElectroTech, in one file. + + Where its folders are -- data, settings, scripts, the element and title + block collections --, which features are switched on, every call a + script can make, the stored scripts, and, while live mode is open, how + to reach it. QElectroTech rewrites it when it starts and whenever one of + those changes, so the MCP server reads facts instead of guessing them + per platform -- wrong as soon as QElectroTech is started with + --data-dir or --config-dir. + + It always lives in the standard data folder for the platform, even + when --data-dir moves the data elsewhere: that is the one place the + server can look without being told. Readable by the user only: while + live mode is open it holds the channel's token. +*/ +namespace AssistantInfo +{ + QString path(); + void watch(); + void write(); + void setLive(const QJsonObject &live); + void markStopped(); +} + +#endif // ASSISTANTINFO_H diff --git a/sources/scripting/qetscriptapi.cpp b/sources/scripting/qetscriptapi.cpp index 1d33040a1..da835ba19 100644 --- a/sources/scripting/qetscriptapi.cpp +++ b/sources/scripting/qetscriptapi.cpp @@ -133,9 +133,19 @@ int QetScriptApi::folioCount() const all of them. */ QStringList QetScriptApi::apiSignatures() const +{ + return signatures(); +} + +/** + @brief QetScriptApi::signatures + apiSignatures() without a script running: what qet-assistant.json lists + for an assistant before it has run anything. +*/ +QStringList QetScriptApi::signatures() { QStringList list; - const QMetaObject *meta = metaObject(); + const QMetaObject *meta = &staticMetaObject; for (int i = meta->methodOffset(); i < meta->methodCount(); ++i) { const QMetaMethod method = meta->method(i); if (method.methodType() != QMetaMethod::Method diff --git a/sources/scripting/qetscriptapi.h b/sources/scripting/qetscriptapi.h index d725cfa2a..e336ee240 100644 --- a/sources/scripting/qetscriptapi.h +++ b/sources/scripting/qetscriptapi.h @@ -586,6 +586,7 @@ class QetScriptApi : public QObject // -- what a script can call, read from this class itself -- Q_INVOKABLE QStringList apiSignatures() const; + static QStringList signatures(); void setUndoGrouped(bool grouped); diff --git a/sources/scripting/qetscripting.cpp b/sources/scripting/qetscripting.cpp index a85bc9782..ab9943a51 100644 --- a/sources/scripting/qetscripting.cpp +++ b/sources/scripting/qetscripting.cpp @@ -113,7 +113,8 @@ namespace { constexpr int kScriptTimeoutMs = 30000; } -bool runOnProject(const QString &scriptPath, QETProject *project, DiagramView *view) +bool runOnProject(const QString &scriptPath, QETProject *project, DiagramView *view, + const QString &title) { // Checked here as well as at each caller, deliberately: this is the // one function that actually evaluates JavaScript, so it is the one @@ -173,7 +174,8 @@ bool runOnProject(const QString &scriptPath, QETProject *project, DiagramView *v QUndoStack *stack = (view && project) ? project->undoStack() : nullptr; if (stack) { stack->beginMacro(QObject::tr("Script : %1") - .arg(QFileInfo(scriptPath).completeBaseName())); + .arg(title.isEmpty() ? QFileInfo(scriptPath).completeBaseName() + : title)); api->setUndoGrouped(true); } @@ -229,7 +231,7 @@ int run(const QStringList &) return 1; } -bool runOnProject(const QString &, QETProject *, DiagramView *) +bool runOnProject(const QString &, QETProject *, DiagramView *, const QString &) { err << "This build of QElectroTech was compiled without the Qt Qml " "module, so JavaScript scripting is not available.\n"; diff --git a/sources/scripting/qetscripting.h b/sources/scripting/qetscripting.h index 6c9e746be..a1d8deafd 100644 --- a/sources/scripting/qetscripting.h +++ b/sources/scripting/qetscripting.h @@ -57,9 +57,12 @@ namespace QetScripting { @param view the active DiagramView, so the script's zoom methods have something to act on; nullptr from the headless entry point, where they become no-ops (see QetScriptApi). + @param title what the undo step is called; the script's file name + if empty. @return true if the script ran without throwing. */ - bool runOnProject(const QString &scriptPath, QETProject *project, DiagramView *view = nullptr); + bool runOnProject(const QString &scriptPath, QETProject *project, DiagramView *view = nullptr, + const QString &title = QString()); } diff --git a/sources/scripting/scriptheader.h b/sources/scripting/scriptheader.h new file mode 100644 index 000000000..ab2fd494d --- /dev/null +++ b/sources/scripting/scriptheader.h @@ -0,0 +1,118 @@ +/* + 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 . +*/ +#ifndef SCRIPTHEADER_H +#define SCRIPTHEADER_H + +#include +#include +#include + +/** + @brief The ScriptHeader struct + How a stored script's button looks, read from a comment block at the top + of the .js file itself, so one file is all there is to write by hand, + to share, or for an assistant to create: + + @code + // ==QETScript== + // @name Add revision note + // @icon note.svg (a file next to the script, or builtin:) + // @tooltip Puts a "Rev A" note on the folio on screen + // @shortcut Ctrl+Alt+R + // @context canvas (canvas, selection or conductor) + // @api 1 + // ==/QETScript== + @endcode + + Only @name is required. An unknown key is an error rather than ignored: + a misspelt "@shortcut" that silently did nothing would be harder to find + than a script refused with the line that is wrong. + + Header-only, with no QElectroTech dependency, so it is tested on its own. +*/ +struct ScriptHeader +{ + QString id; ///< file name without .js; the action id is diagrameditor.script. + QString name; + QString icon; + QString tooltip; + QString shortcut; ///< as QKeySequence::fromString() reads it + QString context = QStringLiteral("canvas"); + int api = 1; + QString error; ///< empty if the header is usable + + bool isValid() const { return error.isEmpty(); } + + static QStringList contexts() + { + return {QStringLiteral("canvas"), QStringLiteral("selection"), + QStringLiteral("conductor")}; + } + + /** + @brief parse + @param text : the whole script + @param id : the script's file name without its extension + */ + static ScriptHeader parse(const QString &text, const QString &id) + { + ScriptHeader h; + h.id = id; + static const QRegularExpression block( + QStringLiteral("//\\s*==QETScript==\\s*\\n(.*?)//\\s*==/QETScript=="), + QRegularExpression::DotMatchesEverythingOption); + const QRegularExpressionMatch m = block.match(text); + if (!m.hasMatch()) { + h.error = QStringLiteral("no // ==QETScript== header"); + return h; + } + static const QRegularExpression line_re( + QStringLiteral("^\\s*//\\s*@(\\w+)\\s+(.*?)\\s*$")); + const QStringList lines = m.captured(1).split(QLatin1Char('\n')); + for (const QString &line : lines) { + const QRegularExpressionMatch lm = line_re.match(line); + if (!lm.hasMatch()) { + continue; + } + const QString key = lm.captured(1); + const QString value = lm.captured(2); + if (key == QLatin1String("name")) h.name = value; + else if (key == QLatin1String("icon")) h.icon = value; + else if (key == QLatin1String("tooltip")) h.tooltip = value; + else if (key == QLatin1String("shortcut")) h.shortcut = value; + else if (key == QLatin1String("context")) h.context = value; + else if (key == QLatin1String("api")) h.api = value.toInt(); + else { + h.error = QStringLiteral("unknown header key @%1").arg(key); + return h; + } + } + if (h.name.isEmpty()) { + h.error = QStringLiteral("@name is required"); + } else if (!contexts().contains(h.context)) { + h.error = QStringLiteral("@context must be one of: %1") + .arg(contexts().join(QStringLiteral(", "))); + } else if (h.api != 1) { + h.error = QStringLiteral("@api %1 is not supported by this " + "version (1 is)").arg(h.api); + } + return h; + } +}; + +#endif // SCRIPTHEADER_H diff --git a/sources/scripting/scriptlibrary.cpp b/sources/scripting/scriptlibrary.cpp new file mode 100644 index 000000000..0107ab0af --- /dev/null +++ b/sources/scripting/scriptlibrary.cpp @@ -0,0 +1,194 @@ +/* + 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 . +*/ +#include "scriptlibrary.h" + +#include "../qetapp.h" + +#include +#include +#include +#include +#include +#include +#include + +ScriptLibrary &ScriptLibrary::instance() +{ + static ScriptLibrary library; + return library; +} + +/** + @brief ScriptLibrary::folder + Where stored scripts live: "scripts" in the user's data folder, next to + the user's own element and title block collections. +*/ +QString ScriptLibrary::folder() +{ + return QETApp::dataDir() + QStringLiteral("/scripts"); +} + +/** + @brief ScriptLibrary::actionId + The ShortcutManager id of a script's action. The "diagrameditor." + prefix is what puts it among the commands the shortcut bar can show. +*/ +QString ScriptLibrary::actionId(const QString &script_id) +{ + return QStringLiteral("diagrameditor.script.") + script_id; +} + +ScriptLibrary::ScriptLibrary() +{ + //An editor saving a file often writes it more than once, or + //replaces it through a rename: wait for it to settle. + m_rescan_timer.setSingleShot(true); + m_rescan_timer.setInterval(250); + connect(&m_rescan_timer, &QTimer::timeout, this, &ScriptLibrary::rescan); + connect(&m_watcher, &QFileSystemWatcher::directoryChanged, + &m_rescan_timer, qOverload<>(&QTimer::start)); + connect(&m_watcher, &QFileSystemWatcher::fileChanged, + &m_rescan_timer, qOverload<>(&QTimer::start)); + rescan(); +} + +/** + @brief ScriptLibrary::watch + Watch the folder and each script in it, or, while the folder does not + exist yet, the data folder it will be created in. +*/ +void ScriptLibrary::watch() +{ + if (!m_watcher.files().isEmpty()) m_watcher.removePaths(m_watcher.files()); + if (!m_watcher.directories().isEmpty()) m_watcher.removePaths(m_watcher.directories()); + + const QDir dir(folder()); + if (!dir.exists()) { + if (QFileInfo::exists(QETApp::dataDir())) { + m_watcher.addPath(QETApp::dataDir()); + } + return; + } + m_watcher.addPath(dir.path()); + QStringList files; + for (const Script &s : std::as_const(m_scripts)) files << s.path; + for (const QString &e : std::as_const(m_errors)) files << dir.filePath(e.section(QLatin1Char(':'), 0, 0)); + if (!files.isEmpty()) m_watcher.addPaths(files); +} + +void ScriptLibrary::rescan() +{ + QList