From 07c7de2697e4e9c6b90fe02fd7e5bfa23876897e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 14 Sep 2026 04:30:47 +0000 Subject: [PATCH 1/5] feat: add Action Lists GUI to Fix workspace (#30) Implement Interaction-layer catalog, controller, steps model, and run submitter; wire EditorHost with validate/plan/confirm/execute workflow; replace the Fix placeholder with ActionListPane.qml; extend unit tests and accessibility smoke mirrors. Co-authored-by: michael berry --- LoopEditor/CMakeLists.txt | 1 + LoopEditor/editorhost.cpp | 402 +++++++++++++++++- LoopEditor/editorhost.h | 39 ++ LoopEditor/qml/ActionListPane.qml | 248 +++++++++++ LoopEditor/qml/Workspace.qml | 5 +- LoopLibInteraction/CMakeLists.txt | 10 + .../sources/actionlistcatalog.cpp | 232 ++++++++++ .../sources/actionlistcatalog.h | 76 ++++ .../sources/actionlistcontroller.cpp | 253 +++++++++++ .../sources/actionlistcontroller.h | 115 +++++ .../sources/actionlistrunsubmitter.cpp | 91 ++++ .../sources/actionlistrunsubmitter.h | 67 +++ .../sources/actionliststepsmodel.cpp | 105 +++++ .../sources/actionliststepsmodel.h | 70 +++ .../sources/repairparameterschema.cpp | 41 ++ .../sources/repairparameterschema.h | 40 ++ ProductQuickAccessibilitySmoke/CMakeLists.txt | 1 + .../qml/ActionListPane.qml | 248 +++++++++++ .../qml/Workspace.qml | 5 +- UnitTests/CMakeLists.txt | 2 +- UnitTests/tst_actionlisttest.cpp | 162 +++++++ UnitTests/tst_editorhosttest.cpp | 4 + changes/feat-0-3-0-30-action-lists.md | 4 + 23 files changed, 2210 insertions(+), 11 deletions(-) create mode 100644 LoopEditor/qml/ActionListPane.qml create mode 100644 LoopLibInteraction/sources/actionlistcatalog.cpp create mode 100644 LoopLibInteraction/sources/actionlistcatalog.h create mode 100644 LoopLibInteraction/sources/actionlistcontroller.cpp create mode 100644 LoopLibInteraction/sources/actionlistcontroller.h create mode 100644 LoopLibInteraction/sources/actionlistrunsubmitter.cpp create mode 100644 LoopLibInteraction/sources/actionlistrunsubmitter.h create mode 100644 LoopLibInteraction/sources/actionliststepsmodel.cpp create mode 100644 LoopLibInteraction/sources/actionliststepsmodel.h create mode 100644 LoopLibInteraction/sources/repairparameterschema.cpp create mode 100644 LoopLibInteraction/sources/repairparameterschema.h create mode 100644 ProductQuickAccessibilitySmoke/qml/ActionListPane.qml create mode 100644 changes/feat-0-3-0-30-action-lists.md diff --git a/LoopEditor/CMakeLists.txt b/LoopEditor/CMakeLists.txt index 3d95b92aa..755bbe70e 100644 --- a/LoopEditor/CMakeLists.txt +++ b/LoopEditor/CMakeLists.txt @@ -46,6 +46,7 @@ qt_add_qml_module(LoopEditor qml/DocumentPane.qml qml/CanvasPane.qml qml/PreflightPane.qml + qml/ActionListPane.qml qml/InspectorPane.qml qml/WorkspacePlaceholderPane.qml qml/ShellToolBar.qml diff --git a/LoopEditor/editorhost.cpp b/LoopEditor/editorhost.cpp index 49b52c1f5..17cd28503 100644 --- a/LoopEditor/editorhost.cpp +++ b/LoopEditor/editorhost.cpp @@ -31,6 +31,9 @@ #include "loopstatevisual.h" #include "looptokens.h" #include "pagesurfacecoordinator.h" +#include "actionlistcontroller.h" +#include "actionlistrunsubmitter.h" +#include "repairparameterschema.h" #include "preflightcontroller.h" #include "preflightclirun.h" #include "preflightengine.h" @@ -91,6 +94,30 @@ int rotationToDegrees(pdf::PageRotation rotation) return 0; } +QString actionListStateToString(pdfinteraction::ActionListController::State state) +{ + switch (state) + { + case pdfinteraction::ActionListController::State::Idle: + return QStringLiteral("idle"); + case pdfinteraction::ActionListController::State::Validating: + return QStringLiteral("validating"); + case pdfinteraction::ActionListController::State::Planning: + return QStringLiteral("planning"); + case pdfinteraction::ActionListController::State::Running: + return QStringLiteral("running"); + case pdfinteraction::ActionListController::State::Planned: + return QStringLiteral("planned"); + case pdfinteraction::ActionListController::State::Succeeded: + return QStringLiteral("succeeded"); + case pdfinteraction::ActionListController::State::Failed: + return QStringLiteral("failed"); + case pdfinteraction::ActionListController::State::Cancelled: + return QStringLiteral("cancelled"); + } + return QStringLiteral("idle"); +} + QString preflightStateToString(pdfinteraction::PreflightController::State state) { switch (state) @@ -208,7 +235,8 @@ EditorHost::EditorHost(QObject* parent) : *m_session->overlays(), pdfinteraction::FindingTargetingCapabilityRegistry::defaultRegistry(), this)), - m_preflight(&m_session->scheduler(), this) + m_preflight(&m_session->scheduler(), this), + m_actionListController(&m_session->scheduler(), this) { // Registers this constructing thread -- the one QML dispatches pointer // and frame callbacks on -- as the thread blocking service adapters @@ -223,6 +251,13 @@ EditorHost::EditorHost(QObject* parent) : { reloadPreflightProfiles(); }); reloadPreflightProfiles(); + m_actionListRecipeWatcher = new QFileSystemWatcher(this); + connect(m_actionListRecipeWatcher, &QFileSystemWatcher::directoryChanged, this, [this](const QString&) + { reloadActionListRecipes(); }); + connect(m_actionListRecipeWatcher, &QFileSystemWatcher::fileChanged, this, [this](const QString&) + { reloadActionListRecipes(); }); + reloadActionListRecipes(); + connectFacade(); connectViewport(); connectCatalog(); @@ -237,6 +272,9 @@ EditorHost::EditorHost(QObject* parent) : connect(&m_preflight, &pdfinteraction::PreflightController::stateChanged, this, &EditorHost::bumpPresentation); connect(&m_preflight, &pdfinteraction::PreflightController::progressChanged, this, &EditorHost::bumpPresentation); + connect(&m_actionListController, &pdfinteraction::ActionListController::stateChanged, this, &EditorHost::bumpPresentation); + connect(&m_actionListController, &pdfinteraction::ActionListController::progressChanged, this, &EditorHost::bumpPresentation); + connect(&m_actionListController, &pdfinteraction::ActionListController::resultChanged, this, &EditorHost::bumpPresentation); connect(m_preflight.findingsModel(), &pdfinteraction::PreflightFindingsModel::findingsReplaced, this, &EditorHost::refreshHitTestSources); connect(&m_preflight, &pdfinteraction::PreflightController::navigationRequested, this, &EditorHost::onPreflightNavigation); connect(m_findingNavigator.get(), &pdfinteraction::FindingCanvasNavigator::inspectionModeRequested, @@ -266,18 +304,24 @@ EditorHost::EditorHost(QObject* parent) : m_activeAsyncJobs.insert(snapshot.jobId, snapshot.kind); refreshCanvasTrace(); }); connect(&m_session->scheduler(), &pdf::PDFJobScheduler::jobProgress, this, [this](const pdf::PDFJobSnapshot& snapshot) - { m_preflight.updateProgress(snapshot.jobId, snapshot.documentRevision, snapshot.progress); }); + { + m_preflight.updateProgress(snapshot.jobId, snapshot.documentRevision, snapshot.progress); + m_actionListController.updateProgress(snapshot.jobId, snapshot.documentRevision, snapshot.progress); + }); connect(&m_session->scheduler(), &pdf::PDFJobScheduler::jobFinished, this, [this](const pdf::PDFJobSnapshot& snapshot) { m_activeAsyncJobs.remove(snapshot.jobId); finishPreflightJob(snapshot); + finishActionListJob(snapshot); refreshCanvasTrace(); }); } EditorHost::~EditorHost() { m_acceptPreflightResults = false; + m_acceptActionListResults = false; cancelPreflight(); + cancelActionList(); QObject::disconnect(&m_session->scheduler(), nullptr, this, nullptr); unbindCanvas(); @@ -351,6 +395,11 @@ QObject* EditorHost::preflight() return &m_preflight; } +QObject* EditorHost::actionList() +{ + return &m_actionListController; +} + QObject* EditorHost::inspector() { return &m_inspector; @@ -865,6 +914,279 @@ bool EditorHost::exportPreflightReportFileUrl(const QUrl& url) return true; } +QVariantList EditorHost::actionListRecipes() const +{ + QVariantList recipes; + recipes.reserve(m_actionListCatalog.recipes().size()); + for (const pdfinteraction::ActionListRecipeEntry& recipe : m_actionListCatalog.recipes()) + { + QVariantMap item; + item.insert(QStringLiteral("id"), recipe.id); + item.insert(QStringLiteral("name"), recipe.name); + item.insert(QStringLiteral("source"), recipe.source); + item.insert(QStringLiteral("valid"), recipe.valid); + item.insert(QStringLiteral("diagnostic"), recipe.diagnostic); + item.insert(QStringLiteral("recipeHash"), recipe.recipeHash); + item.insert(QStringLiteral("stepCount"), recipe.actionList.steps.size()); + recipes.append(item); + } + return recipes; +} + +QString EditorHost::selectedActionListRecipeId() const +{ + return m_selectedActionListRecipeId; +} + +QVariantList EditorHost::actionListBindings() const +{ + QVariantList bindings; + for (auto it = m_actionListBindings.begin(); it != m_actionListBindings.end(); ++it) + { + QVariantMap item; + item.insert(QStringLiteral("name"), it.key()); + item.insert(QStringLiteral("value"), it.value().toVariant()); + bindings.append(item); + } + return bindings; +} + +QString EditorHost::actionListStateName() const +{ + return actionListStateToString(m_actionListController.state()); +} + +QVariantList EditorHost::repairOperations() const +{ + const QJsonArray descriptors = pdfinteraction::repairOperationDescriptors(); + QVariantList operations; + operations.reserve(descriptors.size()); + for (const QJsonValue& value : descriptors) + { + operations.append(value.toObject().toVariantMap()); + } + return operations; +} + +QVariantMap EditorHost::repairParameterSchemaForOperation(const QString& operationId) const +{ + return pdfinteraction::repairParameterSchema(operationId).toVariantMap(); +} + +bool EditorHost::importActionListRecipe(const QUrl& url) +{ + if (!url.isValid() || !url.isLocalFile()) + { + return false; + } + QString importedId; + QString error; + if (!m_actionListCatalog.importRecipe(url.toLocalFile(), &importedId, &error)) + { + announceDocumentState(error); + return false; + } + m_selectedActionListRecipeId = importedId; + m_actionListBindings = QJsonObject(); + m_actionListController.markRecipeStale(); + updateActionListRecipeWatch(); + Q_EMIT actionListRecipesChanged(); + bumpPresentation(); + announceDocumentState(tr("Action List recipe imported.")); + return true; +} + +bool EditorHost::exportActionListRecipe(const QUrl& url) +{ + if (!url.isValid() || !url.isLocalFile() || m_selectedActionListRecipeId.isEmpty()) + { + return false; + } + QString error; + if (!m_actionListCatalog.exportRecipe(m_selectedActionListRecipeId, url.toLocalFile(), &error)) + { + announceDocumentState(error); + return false; + } + announceDocumentState(tr("Action List recipe exported.")); + return true; +} + +bool EditorHost::selectActionListRecipe(const QString& id) +{ + const pdfinteraction::ActionListRecipeEntry* recipe = m_actionListCatalog.recipe(id); + if (!recipe || !recipe->valid || id == m_selectedActionListRecipeId) + { + return false; + } + m_selectedActionListRecipeId = id; + m_actionListBindings = QJsonObject(); + m_actionListController.markRecipeStale(); + Q_EMIT actionListRecipesChanged(); + bumpPresentation(); + return true; +} + +bool EditorHost::setActionListBinding(const QString& name, const QVariant& value) +{ + if (name.trimmed().isEmpty()) + { + return false; + } + m_actionListBindings.insert(name, QJsonValue::fromVariant(value)); + m_actionListController.markRecipeStale(); + Q_EMIT actionListRecipesChanged(); + bumpPresentation(); + return true; +} + +bool EditorHost::submitActionListJob(pdfinteraction::ActionListRunPhase phase, + pdfinteraction::ActionListController::State controllerState) +{ + if (!hasDocument() || m_selectedActionListRecipeId.isEmpty() || !m_session->revisionSource()) + { + return false; + } + + const pdfinteraction::ActionListController::State currentState = m_actionListController.state(); + if (currentState == pdfinteraction::ActionListController::State::Validating || + currentState == pdfinteraction::ActionListController::State::Planning || + currentState == pdfinteraction::ActionListController::State::Running) + { + return false; + } + + const pdfinteraction::ActionListRecipeEntry* recipe = m_actionListCatalog.recipe(m_selectedActionListRecipeId); + if (!recipe || !recipe->valid) + { + return false; + } + + const pdf::PDFDocumentPointer document = m_session->context().getDocumentPointer(); + if (!document) + { + return false; + } + + const QString documentKey = m_session->revisionSource()->documentKey(); + const QString documentRevision = m_session->facade().currentRevision().toString(); + const QString jobId = QUuid::createUuid().toString(QUuid::WithoutBraces); + + pdf::PDFJobSpec spec; + spec.jobId = jobId; + spec.kind = pdf::PDFJobKind::Other; + spec.priority = pdf::PDFJobPriority::Operator; + spec.documentKey = documentKey; + spec.documentRevision = documentRevision; + spec.operationId = QStringLiteral("action-list.%1").arg(recipe->actionList.id); + spec.checkId = recipe->actionList.name; + spec.progressModel = QStringLiteral("action-list-progress-v1"); + spec.staleResultPolicy = pdf::PDFJobStaleResultPolicy::Discard; + + m_actionListController.beginRun(controllerState, documentKey, documentRevision, recipe->id, jobId); + auto outcome = std::make_shared(); + m_actionListOutcomes.insert(jobId, outcome); + const pdf::PDFActionList actionList = recipe->actionList; + const QJsonObject bindings = m_actionListBindings; + + const QString submittedId = m_session->scheduler().submit( + spec, + pdfinteraction::makeActionListRunWorker(phase, actionList, document, bindings, outcome)); + if (submittedId != jobId) + { + m_actionListOutcomes.remove(jobId); + m_actionListController.failRun(jobId, documentRevision, tr("Unable to submit Action List work.")); + return false; + } + + bumpPresentation(); + return true; +} + +bool EditorHost::validateActionListRecipe() +{ + return submitActionListJob(pdfinteraction::ActionListRunPhase::Validate, + pdfinteraction::ActionListController::State::Validating); +} + +bool EditorHost::planActionList() +{ + return submitActionListJob(pdfinteraction::ActionListRunPhase::Plan, + pdfinteraction::ActionListController::State::Planning); +} + +bool EditorHost::runActionList() +{ + if (m_actionListController.state() != pdfinteraction::ActionListController::State::Planned) + { + return false; + } + return submitActionListJob(pdfinteraction::ActionListRunPhase::Execute, + pdfinteraction::ActionListController::State::Running); +} + +bool EditorHost::cancelActionList() +{ + return m_actionListController.cancelRun(m_actionListController.jobId()); +} + +bool EditorHost::confirmActionListPlan() +{ + return runActionList(); +} + +void EditorHost::discardActionListPlan() +{ + m_actionListController.discardPlan(); + bumpPresentation(); +} + +void EditorHost::reloadActionListRecipes() +{ + const QString priorId = m_selectedActionListRecipeId; + m_actionListCatalog.reload(); + if (m_selectedActionListRecipeId.isEmpty() || + !m_actionListCatalog.recipe(m_selectedActionListRecipeId) || + !m_actionListCatalog.recipe(m_selectedActionListRecipeId)->valid) + { + const QList& recipes = m_actionListCatalog.recipes(); + const auto valid = std::find_if(recipes.cbegin(), recipes.cend(), + [](const pdfinteraction::ActionListRecipeEntry& recipe) + { return recipe.valid; }); + m_selectedActionListRecipeId = valid == recipes.cend() ? QString() : valid->id; + m_actionListBindings = QJsonObject(); + } + if (!priorId.isEmpty() && priorId != m_selectedActionListRecipeId) + { + m_actionListController.markRecipeStale(); + } + updateActionListRecipeWatch(); + Q_EMIT actionListRecipesChanged(); + bumpPresentation(); +} + +void EditorHost::updateActionListRecipeWatch() +{ + if (!m_actionListRecipeWatcher) + { + return; + } + m_actionListRecipeWatcher->removePaths(m_actionListRecipeWatcher->directories()); + m_actionListRecipeWatcher->removePaths(m_actionListRecipeWatcher->files()); + const QString localDirectory = m_actionListCatalog.recipesDirectory(); + if (QFileInfo::exists(localDirectory)) + { + m_actionListRecipeWatcher->addPath(localDirectory); + } + for (const pdfinteraction::ActionListRecipeEntry& recipe : m_actionListCatalog.recipes()) + { + if (QFileInfo::exists(recipe.source)) + { + m_actionListRecipeWatcher->addPath(recipe.source); + } + } +} + void EditorHost::reloadPreflightProfiles() { const QString priorId = m_selectedPreflightProfileId; @@ -1424,6 +1746,7 @@ void EditorHost::syncDocumentLifecycle() void EditorHost::onDocumentGone() { cancelPreflight(); + cancelActionList(); if (m_findingNavigator) { m_findingNavigator->invalidate(); @@ -1431,6 +1754,7 @@ void EditorHost::onDocumentGone() unbindCanvas(); m_session->clearDocumentView(); m_preflight.clear(); + m_actionListController.clear(); m_inspector.clearSelection(); m_documentModel.clear(); m_searchRow = -1; @@ -1525,6 +1849,79 @@ void EditorHost::finishPreflightJob(const pdf::PDFJobSnapshot& snapshot) } } +void EditorHost::finishActionListJob(const pdf::PDFJobSnapshot& snapshot) +{ + const std::shared_ptr outcome = m_actionListOutcomes.take(snapshot.jobId); + if (snapshot.jobId != m_actionListController.jobId()) + { + return; + } + + const pdfinteraction::ActionListController::State state = m_actionListController.state(); + if (state != pdfinteraction::ActionListController::State::Validating && + state != pdfinteraction::ActionListController::State::Planning && + state != pdfinteraction::ActionListController::State::Running) + { + return; + } + + switch (snapshot.status) + { + case pdf::PDFJobStatus::Succeeded: + if (!outcome) + { + m_actionListController.failRun(snapshot.jobId, snapshot.documentRevision, + tr("Action List result was unavailable.")); + break; + } + if (state == pdfinteraction::ActionListController::State::Validating) + { + if (m_acceptActionListResults) + { + m_actionListController.acceptValidation(snapshot.jobId, snapshot.documentRevision, + outcome->validationErrors); + } + } + else if (state == pdfinteraction::ActionListController::State::Planning) + { + if (m_acceptActionListResults) + { + m_actionListController.acceptPlan(snapshot.jobId, snapshot.documentRevision, outcome->executionResult); + } + } + else if (state == pdfinteraction::ActionListController::State::Running) + { + if (m_acceptActionListResults && + m_actionListController.acceptExecution(snapshot.jobId, snapshot.documentRevision, outcome->executionResult) && + outcome->candidate) + { + m_session->context().setDocument(outcome->candidate); + m_preflight.markProfileStale(); + syncRevisionModels(); + announceDocumentState(tr("Action List applied to the open document.")); + } + } + bumpPresentation(); + break; + case pdf::PDFJobStatus::Failed: + m_actionListController.failRun(snapshot.jobId, snapshot.documentRevision, + snapshot.errorMessage.isEmpty() ? tr("Action List failed.") + : snapshot.errorMessage); + bumpPresentation(); + break; + case pdf::PDFJobStatus::Cancelled: + m_actionListController.cancelRun(snapshot.jobId); + bumpPresentation(); + break; + case pdf::PDFJobStatus::Stale: + syncRevisionModels(); + break; + case pdf::PDFJobStatus::Queued: + case pdf::PDFJobStatus::Running: + break; + } +} + void EditorHost::refreshCanvasTrace() { if (m_canvas) @@ -1548,6 +1945,7 @@ void EditorHost::syncRevisionModels() const QString documentKey = m_session->revisionSource()->documentKey(); const QString documentRevision = m_session->facade().currentRevision().toString(); m_preflight.setCurrentRevision(documentKey, documentRevision); + m_actionListController.setCurrentRevision(documentKey, documentRevision); m_inspector.setCurrentRevision(documentKey, documentRevision); m_preview.setCurrentRevision(documentKey, documentRevision); m_production.setCurrentRevision(documentKey, documentRevision); diff --git a/LoopEditor/editorhost.h b/LoopEditor/editorhost.h index 91afefd24..1baccecc5 100644 --- a/LoopEditor/editorhost.h +++ b/LoopEditor/editorhost.h @@ -33,6 +33,9 @@ #include "inspectormodel.h" #include "jobsubmitter.h" #include "pagesurfacerenderer.h" +#include "actionlistcatalog.h" +#include "actionlistcontroller.h" +#include "actionlistrunsubmitter.h" #include "preflightcontroller.h" #include "preflightoverlaybridge.h" #include "previewstatemodel.h" @@ -91,6 +94,7 @@ class EditorHost final : public QObject Q_PROPERTY(bool unsupported READ unsupported NOTIFY presentationChanged) Q_PROPERTY(int commandEpoch READ commandEpoch NOTIFY commandEpochChanged) Q_PROPERTY(QObject* preflight READ preflight CONSTANT) + Q_PROPERTY(QObject* actionList READ actionList CONSTANT) Q_PROPERTY(QObject* inspector READ inspector CONSTANT) Q_PROPERTY(QObject* preview READ preview CONSTANT) Q_PROPERTY(QObject* documentModel READ documentModel CONSTANT) @@ -102,6 +106,11 @@ class EditorHost final : public QObject Q_PROPERTY(QVariantList preflightProfiles READ preflightProfiles NOTIFY preflightProfilesChanged) Q_PROPERTY(QVariantList preflightVariables READ preflightVariables NOTIFY preflightProfilesChanged) Q_PROPERTY(QString selectedPreflightProfileId READ selectedPreflightProfileId NOTIFY preflightProfilesChanged) + Q_PROPERTY(QVariantList actionListRecipes READ actionListRecipes NOTIFY actionListRecipesChanged) + Q_PROPERTY(QString selectedActionListRecipeId READ selectedActionListRecipeId NOTIFY actionListRecipesChanged) + Q_PROPERTY(QVariantList actionListBindings READ actionListBindings NOTIFY actionListRecipesChanged) + Q_PROPERTY(QString actionListStateName READ actionListStateName NOTIFY presentationChanged) + Q_PROPERTY(QVariantList repairOperations READ repairOperations CONSTANT) Q_PROPERTY(bool hasPreflightReport READ hasPreflightReport NOTIFY presentationChanged) Q_PROPERTY(QString previewSummary READ previewSummary NOTIFY presentationChanged) Q_PROPERTY(QString inspectorTitle READ inspectorTitle NOTIFY presentationChanged) @@ -152,6 +161,7 @@ class EditorHost final : public QObject int commandEpoch() const noexcept { return m_commandEpoch; } QObject* preflight(); + QObject* actionList(); QObject* inspector(); QObject* preview(); QObject* documentModel() { return &m_documentModel; } @@ -172,6 +182,11 @@ class EditorHost final : public QObject QVariantList preflightProfiles() const; QVariantList preflightVariables() const; QString selectedPreflightProfileId() const; + QVariantList actionListRecipes() const; + QString selectedActionListRecipeId() const; + QVariantList actionListBindings() const; + QString actionListStateName() const; + QVariantList repairOperations() const; bool hasPreflightReport() const noexcept { return m_preflight.hasResult(); } QString previewSummary() const; QString inspectorTitle() const; @@ -210,6 +225,17 @@ class EditorHost final : public QObject Q_INVOKABLE bool setPreflightVariable(const QString& name, const QVariant& value); Q_INVOKABLE void requestPreflightReportExport(); Q_INVOKABLE bool exportPreflightReportFileUrl(const QUrl& url); + Q_INVOKABLE bool importActionListRecipe(const QUrl& url); + Q_INVOKABLE bool exportActionListRecipe(const QUrl& url); + Q_INVOKABLE bool selectActionListRecipe(const QString& id); + Q_INVOKABLE bool setActionListBinding(const QString& name, const QVariant& value); + Q_INVOKABLE bool validateActionListRecipe(); + Q_INVOKABLE bool planActionList(); + Q_INVOKABLE bool runActionList(); + Q_INVOKABLE bool cancelActionList(); + Q_INVOKABLE bool confirmActionListPlan(); + Q_INVOKABLE void discardActionListPlan(); + Q_INVOKABLE QVariantMap repairParameterSchemaForOperation(const QString& operationId) const; /// Toggles the current page between the fast approximate render and the /// authoritative overprint-accurate one. Re-renders only that page; @@ -262,6 +288,7 @@ class EditorHost final : public QObject void commandEpochChanged(); void workspaceChanged(LoopWorkspace from, LoopWorkspace to); void preflightProfilesChanged(); + void actionListRecipesChanged(); void preflightReportExportRequested(); private: @@ -289,6 +316,11 @@ class EditorHost final : public QObject const QString& documentRevision, const pdf::PreflightResult& result); void finishPreflightJob(const pdf::PDFJobSnapshot& snapshot); + void finishActionListJob(const pdf::PDFJobSnapshot& snapshot); + bool submitActionListJob(pdfinteraction::ActionListRunPhase phase, + pdfinteraction::ActionListController::State controllerState); + void reloadActionListRecipes(); + void updateActionListRecipeWatch(); void refreshCanvasTrace(); void reloadPreflightProfiles(); void updatePreflightProfileWatch(); @@ -305,6 +337,8 @@ class EditorHost final : public QObject std::unique_ptr m_session; std::unique_ptr m_findingNavigator; pdfinteraction::PreflightController m_preflight; + pdfinteraction::ActionListCatalog m_actionListCatalog; + pdfinteraction::ActionListController m_actionListController; pdfinteraction::PreflightOverlayBridge m_preflightOverlayBridge; pdfinteraction::InspectorModel m_inspector; pdfinteraction::PreviewStateModel m_preview; @@ -317,6 +351,7 @@ class EditorHost final : public QObject QHash m_activeAsyncJobs; struct PreflightWorkerOutcome; QHash> m_preflightOutcomes; + QHash> m_actionListOutcomes; struct PreflightProfileChoice { QString id; @@ -333,7 +368,11 @@ class EditorHost final : public QObject QJsonObject m_preflightBindings; QString m_selectedPreflightProfileId; class QFileSystemWatcher* m_preflightProfileWatcher = nullptr; + class QFileSystemWatcher* m_actionListRecipeWatcher = nullptr; bool m_acceptPreflightResults = true; + bool m_acceptActionListResults = true; + QString m_selectedActionListRecipeId; + QJsonObject m_actionListBindings; int m_commandEpoch = 0; bool m_documentBound = false; bool m_searchPanelVisible = false; diff --git a/LoopEditor/qml/ActionListPane.qml b/LoopEditor/qml/ActionListPane.qml new file mode 100644 index 000000000..a48b5664b --- /dev/null +++ b/LoopEditor/qml/ActionListPane.qml @@ -0,0 +1,248 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Dialogs + +Pane { + id: root + + objectName: "actionListPane" + + property var host: editorHost + property var stepsModel: host ? host.actionList.stepsModel : null + readonly property bool preferReducedMotion: host ? host.preferReducedMotion : false + readonly property bool busy: host && ["validating", "planning", "running"].indexOf(host.actionListStateName) >= 0 + + padding: 8 + + Accessible.role: Accessible.Grouping + Accessible.name: qsTr("Action List") + Accessible.description: qsTr("Validate, plan, and execute reusable repair recipes against the open document.") + + FileDialog { + id: importDialog + title: qsTr("Import Action List recipe") + nameFilters: [qsTr("JSON recipes (*.json)")] + fileMode: FileDialog.OpenFile + onAccepted: if (root.host) root.host.importActionListRecipe(selectedFile) + } + + FileDialog { + id: exportDialog + title: qsTr("Export Action List recipe") + nameFilters: [qsTr("JSON recipes (*.json)")] + fileMode: FileDialog.SaveFile + onAccepted: if (root.host) root.host.exportActionListRecipe(selectedFile) + } + + ColumnLayout { + anchors.fill: parent + spacing: 8 + + Label { + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: host ? (host.actionList.operatorSummary.length > 0 + ? host.actionList.operatorSummary + : qsTr("Action List status: %1").arg(host.actionListStateName)) : "" + Accessible.name: qsTr("Action List status") + } + + RowLayout { + Layout.fillWidth: true + + ComboBox { + id: recipeSelector + objectName: "actionListRecipeSelector" + Layout.fillWidth: true + model: root.host ? root.host.actionListRecipes : [] + textRole: "name" + valueRole: "id" + enabled: root.host && !root.busy + currentIndex: { + if (!root.host) + return -1 + for (var i = 0; i < model.length; ++i) { + if (model[i].id === root.host.selectedActionListRecipeId) + return i + } + return -1 + } + Accessible.name: qsTr("Action List recipe") + onActivated: function(index) { + if (root.host && model[index]) + root.host.selectActionListRecipe(model[index].id) + } + } + + Button { + text: qsTr("Import") + enabled: root.host && !root.busy + onClicked: importDialog.open() + Accessible.name: qsTr("Import Action List recipe") + } + + Button { + text: qsTr("Export") + enabled: root.host && root.host.selectedActionListRecipeId.length > 0 && !root.busy + onClicked: exportDialog.open() + Accessible.name: qsTr("Export Action List recipe") + } + } + + Repeater { + model: root.host ? root.host.actionListBindings : [] + delegate: RowLayout { + Layout.fillWidth: true + required property var modelData + + Label { + text: modelData.name + Accessible.name: qsTr("Binding %1").arg(modelData.name) + } + TextField { + Layout.fillWidth: true + enabled: !root.busy + text: modelData.value === undefined || modelData.value === null ? "" : String(modelData.value) + Accessible.name: qsTr("Binding value for %1").arg(modelData.name) + onEditingFinished: if (root.host) root.host.setActionListBinding(modelData.name, text) + } + } + } + + RowLayout { + Layout.fillWidth: true + + Button { + objectName: "validateActionListButton" + text: qsTr("Validate") + enabled: root.host && root.host.hasDocument && !root.busy + onClicked: root.host.validateActionListRecipe() + Accessible.name: qsTr("Validate Action List recipe") + } + + Button { + objectName: "planActionListButton" + text: qsTr("Plan") + enabled: root.host && root.host.hasDocument && !root.busy + onClicked: root.host.planActionList() + Accessible.name: qsTr("Plan Action List") + } + + Button { + objectName: "confirmActionListButton" + text: qsTr("Confirm and Run") + enabled: root.host && host.actionListStateName === "planned" && !root.busy + onClicked: root.host.confirmActionListPlan() + Accessible.name: qsTr("Confirm Action List plan and run") + } + + Button { + objectName: "discardActionListPlanButton" + text: qsTr("Discard Plan") + enabled: root.host && host.actionListStateName === "planned" && !root.busy + onClicked: root.host.discardActionListPlan() + Accessible.name: qsTr("Discard Action List plan") + } + + Button { + objectName: "cancelActionListButton" + text: qsTr("Cancel") + enabled: root.busy + onClicked: root.host.cancelActionList() + Accessible.name: qsTr("Cancel Action List") + } + + ProgressBar { + objectName: "actionListProgress" + Layout.fillWidth: true + from: 0 + to: 100 + value: root.host ? root.host.actionList.progress : 0 + enabled: root.busy + Accessible.name: qsTr("Action List progress") + } + } + + Label { + Layout.fillWidth: true + visible: root.host && root.host.actionList.recipeHash.length > 0 + text: qsTr("Recipe hash: %1").arg(root.host.actionList.recipeHash) + wrapMode: Text.WordWrap + Accessible.name: qsTr("Action List recipe hash") + } + + ListView { + id: stepsView + objectName: "actionListStepsView" + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + focus: visible + activeFocusOnTab: true + model: root.stepsModel + + Accessible.role: Accessible.List + Accessible.name: qsTr("Action List step diagnostics") + Accessible.description: qsTr("Planned or executed steps with distinct status, diagnostics, and affected scope.") + + delegate: ItemDelegate { + width: stepsView.width + text: "%1 — %2 (%3 ms)".arg(model.stepId, model.statusName, model.durationMs) + Accessible.role: Accessible.ListItem + Accessible.name: text + Accessible.description: qsTr("Operation %1. Parameters %2. Diagnostics %3.") + .arg(model.operationId).arg(model.resolvedParameters).arg(JSON.stringify(model.diagnostics)) + + contentItem: ColumnLayout { + spacing: 4 + width: parent.width + + Label { + Layout.fillWidth: true + text: "%1 — %2".arg(model.stepId, model.statusName) + font.bold: true + color: { + switch (model.statusName) { + case "pending": return "#6b7280" + case "running": return "#2563eb" + case "succeeded": return "#15803d" + case "skipped": return "#a16207" + case "failed": return "#b91c1c" + case "cancelled": return "#7c3aed" + default: return palette.text + } + } + } + + Label { + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: qsTr("Operation: %1").arg(model.operationId) + } + + Label { + Layout.fillWidth: true + wrapMode: Text.WordWrap + visible: model.resolvedParameters.length > 0 + text: qsTr("Parameters: %1").arg(model.resolvedParameters) + } + + Label { + Layout.fillWidth: true + wrapMode: Text.WordWrap + visible: model.affectedScope && model.affectedScope.length > 0 + text: qsTr("Affected scope: %1").arg(JSON.stringify(model.affectedScope)) + } + + Label { + Layout.fillWidth: true + wrapMode: Text.WordWrap + visible: model.diagnostics && model.diagnostics.length > 0 + text: qsTr("Diagnostics: %1").arg(JSON.stringify(model.diagnostics)) + } + } + } + } + } +} diff --git a/LoopEditor/qml/Workspace.qml b/LoopEditor/qml/Workspace.qml index 8931206e4..564e9b853 100644 --- a/LoopEditor/qml/Workspace.qml +++ b/LoopEditor/qml/Workspace.qml @@ -182,11 +182,8 @@ Item { Accessible.name: qsTr("Inspect workspace") } - WorkspacePlaceholderPane { + ActionListPane { host: root.host - titleText: qsTr("Fix") - descriptionText: qsTr("Bounded corrective operations will appear here.") - Accessible.name: qsTr("Fix workspace") } WorkspacePlaceholderPane { diff --git a/LoopLibInteraction/CMakeLists.txt b/LoopLibInteraction/CMakeLists.txt index a5efc123b..b52cfb5ea 100644 --- a/LoopLibInteraction/CMakeLists.txt +++ b/LoopLibInteraction/CMakeLists.txt @@ -93,6 +93,16 @@ add_library(LoopLibInteraction STATIC sources/preflightfindingsmodel.h sources/preflightoverlaybridge.cpp sources/preflightoverlaybridge.h + sources/actionlistcatalog.cpp + sources/actionlistcatalog.h + sources/actionlistcontroller.cpp + sources/actionlistcontroller.h + sources/actionliststepsmodel.cpp + sources/actionliststepsmodel.h + sources/actionlistrunsubmitter.cpp + sources/actionlistrunsubmitter.h + sources/repairparameterschema.cpp + sources/repairparameterschema.h sources/inspectormodel.cpp sources/inspectormodel.h sources/previewstatemodel.cpp diff --git a/LoopLibInteraction/sources/actionlistcatalog.cpp b/LoopLibInteraction/sources/actionlistcatalog.cpp new file mode 100644 index 000000000..c06e91b65 --- /dev/null +++ b/LoopLibInteraction/sources/actionlistcatalog.cpp @@ -0,0 +1,232 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#include "actionlistcatalog.h" + +#include + +#include +#include +#include +#include +#include +#include + +namespace pdfinteraction +{ + +ActionListCatalog::ActionListCatalog(QObject* parent) : + QObject(parent) +{ +} + +QString ActionListCatalog::recipesDirectory() const +{ + return QDir(QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation)) + .filePath(QStringLiteral("recipes")); +} + +bool ActionListCatalog::ensureRecipesDirectory(QString* error) const +{ + const QString directory = recipesDirectory(); + if (QDir().mkpath(directory)) + { + return true; + } + if (error) + { + *error = QStringLiteral("Unable to create Action List recipe directory '%1'.").arg(directory); + } + return false; +} + +bool ActionListCatalog::loadRecipeFile(const QString& sourcePath, ActionListRecipeEntry* entry) +{ + if (!entry) + { + return false; + } + + QFile input(sourcePath); + if (!input.open(QIODevice::ReadOnly)) + { + entry->id = sourcePath; + entry->source = sourcePath; + entry->name = QFileInfo(sourcePath).completeBaseName(); + entry->diagnostic = QStringLiteral("Unable to read Action List recipe."); + return false; + } + + QJsonParseError parseError; + const QJsonDocument parsed = QJsonDocument::fromJson(input.readAll(), &parseError); + if (parseError.error != QJsonParseError::NoError || !parsed.isObject()) + { + entry->id = sourcePath; + entry->source = sourcePath; + entry->name = QFileInfo(sourcePath).completeBaseName(); + entry->diagnostic = QStringLiteral("Action List recipe is not valid JSON: %1").arg(parseError.errorString()); + return false; + } + + entry->id = sourcePath; + entry->source = sourcePath; + entry->name = QFileInfo(sourcePath).completeBaseName(); + if (const pdf::PDFOperationResult parseResult = pdf::PDFActionList::fromJson(parsed.object(), &entry->actionList); !parseResult) + { + entry->diagnostic = parseResult.getErrorMessage(); + return false; + } + + entry->name = entry->actionList.name; + pdf::PDFActionListExecutionOptions options; + QStringList validationErrors; + entry->valid = static_cast(pdf::PDFActionListExecutor().validate(entry->actionList, options, &validationErrors)); + entry->validationErrors = validationErrors; + if (!entry->valid) + { + entry->diagnostic = validationErrors.join(QLatin1Char('\n')); + return false; + } + + pdf::PDFActionListExecutionResult planned; + if (const pdf::PDFOperationResult planResult = pdf::PDFActionListExecutor().plan(entry->actionList, pdf::PDFDocument(), options, &planned); planResult) + { + entry->recipeHash = planned.recipeHash; + } + entry->diagnostic.clear(); + return true; +} + +bool ActionListCatalog::reload() +{ + QList recipes; + const QString directory = recipesDirectory(); + const QDir local(directory); + if (local.exists()) + { + for (const QFileInfo& file : local.entryInfoList({ QStringLiteral("*.json") }, QDir::Files, QDir::Name)) + { + ActionListRecipeEntry entry; + loadRecipeFile(file.absoluteFilePath(), &entry); + recipes.append(std::move(entry)); + } + } + + m_recipes = std::move(recipes); + Q_EMIT recipesChanged(); + return true; +} + +bool ActionListCatalog::importRecipe(const QString& sourcePath, QString* importedId, QString* error) +{ + const QFileInfo source(sourcePath); + if (!source.exists() || !source.isFile()) + { + if (error) + { + *error = QStringLiteral("Action List recipe '%1' does not exist.").arg(sourcePath); + } + return false; + } + + QString directoryError; + if (!ensureRecipesDirectory(&directoryError)) + { + if (error) + { + *error = directoryError; + } + return false; + } + + const QString destination = QDir(recipesDirectory()).filePath(source.fileName()); + if (QFileInfo(destination).absoluteFilePath() != source.absoluteFilePath()) + { + if (QFile::exists(destination) && !QFile::remove(destination)) + { + if (error) + { + *error = QStringLiteral("Unable to replace existing Action List recipe '%1'.").arg(destination); + } + return false; + } + if (!QFile::copy(source.absoluteFilePath(), destination)) + { + if (error) + { + *error = QStringLiteral("Unable to import Action List recipe to '%1'.").arg(destination); + } + return false; + } + } + + reload(); + if (importedId) + { + *importedId = destination; + } + return true; +} + +bool ActionListCatalog::exportRecipe(const QString& recipeId, const QString& destinationPath, QString* error) +{ + const ActionListRecipeEntry* entry = recipe(recipeId); + if (!entry) + { + if (error) + { + *error = QStringLiteral("Action List recipe '%1' was not found.").arg(recipeId); + } + return false; + } + + QFile output(destinationPath); + if (!output.open(QIODevice::WriteOnly | QIODevice::Truncate)) + { + if (error) + { + *error = QStringLiteral("Unable to write Action List recipe to '%1'.").arg(destinationPath); + } + return false; + } + + const QJsonDocument document(entry->actionList.toJson()); + if (output.write(document.toJson(QJsonDocument::Indented)) < 0) + { + if (error) + { + *error = QStringLiteral("Unable to serialize Action List recipe."); + } + return false; + } + return true; +} + +const ActionListRecipeEntry* ActionListCatalog::recipe(const QString& recipeId) const +{ + const auto it = std::find_if(m_recipes.cbegin(), m_recipes.cend(), + [&recipeId](const ActionListRecipeEntry& entry) + { return entry.id == recipeId; }); + return it == m_recipes.cend() ? nullptr : &(*it); +} + +} // namespace pdfinteraction diff --git a/LoopLibInteraction/sources/actionlistcatalog.h b/LoopLibInteraction/sources/actionlistcatalog.h new file mode 100644 index 000000000..d1b3626cd --- /dev/null +++ b/LoopLibInteraction/sources/actionlistcatalog.h @@ -0,0 +1,76 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#ifndef ACTIONLISTCATALOG_H +#define ACTIONLISTCATALOG_H + +#include "interactionglobal.h" + +#include "pdfactionlist.h" + +#include +#include +#include + +namespace pdfinteraction +{ + +struct ActionListRecipeEntry +{ + QString id; + QString name; + QString source; + QString diagnostic; + QString recipeHash; + bool valid = false; + pdf::PDFActionList actionList; + QStringList validationErrors; +}; + +class ActionListCatalog final : public QObject +{ + Q_OBJECT + +public: + explicit ActionListCatalog(QObject* parent = nullptr); + + const QList& recipes() const noexcept { return m_recipes; } + QString recipesDirectory() const; + + bool reload(); + bool importRecipe(const QString& sourcePath, QString* importedId = nullptr, QString* error = nullptr); + bool exportRecipe(const QString& recipeId, const QString& destinationPath, QString* error = nullptr); + const ActionListRecipeEntry* recipe(const QString& recipeId) const; + +signals: + void recipesChanged(); + +private: + bool ensureRecipesDirectory(QString* error = nullptr) const; + bool loadRecipeFile(const QString& sourcePath, ActionListRecipeEntry* entry); + + QList m_recipes; +}; + +} // namespace pdfinteraction + +#endif // ACTIONLISTCATALOG_H diff --git a/LoopLibInteraction/sources/actionlistcontroller.cpp b/LoopLibInteraction/sources/actionlistcontroller.cpp new file mode 100644 index 000000000..20a0b6d1c --- /dev/null +++ b/LoopLibInteraction/sources/actionlistcontroller.cpp @@ -0,0 +1,253 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#include "actionlistcontroller.h" + +namespace pdfinteraction +{ + +ActionListController::ActionListController(pdf::PDFJobScheduler* scheduler, QObject* parent) : + QObject(parent), + m_steps(this), + m_scheduler(scheduler ? scheduler : &pdf::PDFJobScheduler::global()) +{ + qRegisterMetaType(); +} + +void ActionListController::setState(State state) +{ + if (m_state == state) + { + return; + } + m_state = state; + Q_EMIT stateChanged(m_state); +} + +void ActionListController::cancelSchedulerJob() +{ + if (!m_scheduler || m_jobId.isEmpty()) + { + return; + } + const pdf::PDFJobSnapshot snapshot = m_scheduler->snapshot(m_jobId); + if (snapshot.jobId == m_jobId && + (snapshot.status == pdf::PDFJobStatus::Queued || snapshot.status == pdf::PDFJobStatus::Running)) + { + m_scheduler->cancel(m_jobId); + } +} + +void ActionListController::setCurrentRevision(QString documentKey, QString documentRevision) +{ + const bool changed = documentKey != m_documentKey || documentRevision != m_documentRevision; + m_documentKey = std::move(documentKey); + m_documentRevision = std::move(documentRevision); + if (changed && m_state != State::Idle && m_state != State::Validating && m_state != State::Planning && + m_state != State::Running) + { + markRecipeStale(); + } +} + +void ActionListController::markRecipeStale() +{ + if (m_state == State::Idle) + { + return; + } + m_operatorSummary = QStringLiteral("Action List results are stale for the current document revision."); + setState(State::Idle); + m_steps.clear(); + m_result = pdf::PDFActionListExecutionResult(); + Q_EMIT resultChanged(); +} + +void ActionListController::beginRun(State phase, + QString documentKey, + QString documentRevision, + QString recipeId, + QString jobId) +{ + m_documentKey = std::move(documentKey); + m_documentRevision = std::move(documentRevision); + m_recipeId = std::move(recipeId); + m_jobId = std::move(jobId); + m_cancelRequested = false; + m_progress = 0; + m_operatorSummary.clear(); + m_result = pdf::PDFActionListExecutionResult(); + m_steps.clear(); + setState(phase); + Q_EMIT progressChanged(0); + Q_EMIT resultChanged(); +} + +bool ActionListController::updateProgress(const QString& jobId, const QString& documentRevision, int progress) +{ + if (jobId != m_jobId || documentRevision != m_documentRevision || + (m_state != State::Validating && m_state != State::Planning && m_state != State::Running)) + { + return false; + } + m_progress = qBound(0, progress, 100); + Q_EMIT progressChanged(m_progress); + return true; +} + +bool ActionListController::acceptValidation(const QString& jobId, + const QString& documentRevision, + const QStringList& errors) +{ + if (jobId != m_jobId || documentRevision != m_documentRevision || m_state != State::Validating || m_cancelRequested) + { + return false; + } + + m_progress = 100; + Q_EMIT progressChanged(m_progress); + if (!errors.isEmpty()) + { + m_operatorSummary = errors.join(QLatin1Char('\n')); + setState(State::Failed); + return true; + } + + m_operatorSummary = QStringLiteral("Action List recipe validated."); + setState(State::Idle); + return true; +} + +bool ActionListController::acceptPlan(const QString& jobId, + const QString& documentRevision, + const pdf::PDFActionListExecutionResult& result) +{ + if (jobId != m_jobId || documentRevision != m_documentRevision || m_state != State::Planning || m_cancelRequested) + { + return false; + } + + m_result = result; + m_steps.replace(result.steps); + m_progress = 100; + Q_EMIT progressChanged(m_progress); + Q_EMIT resultChanged(); + if (result.status == QStringLiteral("planned")) + { + m_operatorSummary = QStringLiteral("Action List plan is ready for confirmation."); + setState(State::Planned); + } + else + { + m_operatorSummary = QStringLiteral("Action List planning failed."); + setState(State::Failed); + } + return true; +} + +bool ActionListController::acceptExecution(const QString& jobId, + const QString& documentRevision, + const pdf::PDFActionListExecutionResult& result) +{ + if (jobId != m_jobId || documentRevision != m_documentRevision || m_state != State::Running || m_cancelRequested) + { + return false; + } + + m_result = result; + m_steps.replace(result.steps); + m_progress = 100; + Q_EMIT progressChanged(m_progress); + Q_EMIT resultChanged(); + if (result.status == QStringLiteral("succeeded")) + { + m_operatorSummary = QStringLiteral("Action List completed successfully."); + setState(State::Succeeded); + } + else if (result.status == QStringLiteral("cancelled")) + { + m_operatorSummary = QStringLiteral("Action List was cancelled."); + setState(State::Cancelled); + } + else + { + m_operatorSummary = QStringLiteral("Action List execution failed."); + setState(State::Failed); + } + return true; +} + +bool ActionListController::failRun(const QString& jobId, const QString& documentRevision, QString errorMessage) +{ + if (jobId != m_jobId || documentRevision != m_documentRevision) + { + return false; + } + m_operatorSummary = std::move(errorMessage); + m_progress = 100; + Q_EMIT progressChanged(m_progress); + setState(State::Failed); + return true; +} + +bool ActionListController::cancelRun(const QString& jobId) +{ + if (jobId != m_jobId || + (m_state != State::Validating && m_state != State::Planning && m_state != State::Running)) + { + return false; + } + m_cancelRequested = true; + cancelSchedulerJob(); + setState(State::Cancelled); + m_operatorSummary = QStringLiteral("Action List was cancelled."); + return true; +} + +void ActionListController::discardPlan() +{ + if (m_state != State::Planned) + { + return; + } + m_result = pdf::PDFActionListExecutionResult(); + m_steps.clear(); + m_operatorSummary.clear(); + setState(State::Idle); + Q_EMIT resultChanged(); +} + +void ActionListController::clear() +{ + m_jobId.clear(); + m_recipeId.clear(); + m_progress = 0; + m_cancelRequested = false; + m_operatorSummary.clear(); + m_result = pdf::PDFActionListExecutionResult(); + m_steps.clear(); + setState(State::Idle); + Q_EMIT progressChanged(0); + Q_EMIT resultChanged(); +} + +} // namespace pdfinteraction diff --git a/LoopLibInteraction/sources/actionlistcontroller.h b/LoopLibInteraction/sources/actionlistcontroller.h new file mode 100644 index 000000000..c2019019c --- /dev/null +++ b/LoopLibInteraction/sources/actionlistcontroller.h @@ -0,0 +1,115 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#ifndef ACTIONLISTCONTROLLER_H +#define ACTIONLISTCONTROLLER_H + +#include "actionliststepsmodel.h" + +#include "pdfactionlist.h" +#include "pdfjobscheduler.h" + +#include +#include + +namespace pdfinteraction +{ + +class ActionListController final : public QObject +{ + Q_OBJECT + + Q_PROPERTY(ActionListStepsModel* stepsModel READ stepsModel CONSTANT) + Q_PROPERTY(QString operatorSummary READ operatorSummary NOTIFY stateChanged) + Q_PROPERTY(int progress READ progress NOTIFY progressChanged) + Q_PROPERTY(QString recipeHash READ recipeHash NOTIFY resultChanged) + Q_PROPERTY(QString resultStatus READ resultStatus NOTIFY resultChanged) + +public: + enum class State + { + Idle, + Validating, + Planning, + Running, + Planned, + Succeeded, + Failed, + Cancelled + }; + Q_ENUM(State) + + explicit ActionListController(pdf::PDFJobScheduler* scheduler = nullptr, QObject* parent = nullptr); + + ActionListStepsModel* stepsModel() { return &m_steps; } + const ActionListStepsModel* stepsModel() const { return &m_steps; } + State state() const { return m_state; } + QString operatorSummary() const { return m_operatorSummary; } + QString documentKey() const { return m_documentKey; } + QString documentRevision() const { return m_documentRevision; } + QString recipeId() const { return m_recipeId; } + QString jobId() const { return m_jobId; } + int progress() const noexcept { return m_progress; } + QString recipeHash() const { return m_result.recipeHash; } + QString resultStatus() const { return m_result.status; } + const pdf::PDFActionListExecutionResult& result() const noexcept { return m_result; } + bool hasPlannedResult() const noexcept { return m_state == State::Planned; } + + void setCurrentRevision(QString documentKey, QString documentRevision); + void markRecipeStale(); + void beginRun(State phase, QString documentKey, QString documentRevision, QString recipeId, QString jobId); + bool updateProgress(const QString& jobId, const QString& documentRevision, int progress); + bool acceptValidation(const QString& jobId, const QString& documentRevision, const QStringList& errors); + bool acceptPlan(const QString& jobId, const QString& documentRevision, const pdf::PDFActionListExecutionResult& result); + bool acceptExecution(const QString& jobId, const QString& documentRevision, const pdf::PDFActionListExecutionResult& result); + bool failRun(const QString& jobId, const QString& documentRevision, QString errorMessage); + bool cancelRun(const QString& jobId); + void discardPlan(); + void clear(); + +signals: + void stateChanged(pdfinteraction::ActionListController::State state); + void progressChanged(int progress); + void resultChanged(); + +private: + void setState(State state); + void cancelSchedulerJob(); + + ActionListStepsModel m_steps; + State m_state = State::Idle; + QString m_operatorSummary; + QString m_documentKey; + QString m_documentRevision; + QString m_recipeId; + QString m_jobId; + int m_progress = 0; + bool m_cancelRequested = false; + pdf::PDFActionListExecutionResult m_result; + pdf::PDFJobScheduler* m_scheduler = nullptr; +}; + +} // namespace pdfinteraction + +Q_DECLARE_METATYPE(pdfinteraction::ActionListController::State) + +#endif // ACTIONLISTCONTROLLER_H diff --git a/LoopLibInteraction/sources/actionlistrunsubmitter.cpp b/LoopLibInteraction/sources/actionlistrunsubmitter.cpp new file mode 100644 index 000000000..7cff0fcd0 --- /dev/null +++ b/LoopLibInteraction/sources/actionlistrunsubmitter.cpp @@ -0,0 +1,91 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#include "actionlistrunsubmitter.h" + +#include + +namespace pdfinteraction +{ + +ActionListRunWorker makeActionListRunWorker(ActionListRunPhase phase, + pdf::PDFActionList actionList, + pdf::PDFDocumentPointer document, + QJsonObject bindings, + std::shared_ptr outcome) +{ + return [phase, actionList = std::move(actionList), document = std::move(document), bindings = std::move(bindings), outcome = std::move(outcome)](pdf::PDFJobContext& context) + { + if (!outcome || !document) + { + throw std::runtime_error("Action List worker inputs are unavailable."); + } + if (context.isCancellationRequested()) + { + return; + } + + outcome->phase = phase; + pdf::PDFActionListExecutionOptions options; + options.bindings = bindings; + options.operationControl = context.operationControl(); + pdf::PDFActionListExecutor executor; + + context.reportProgress(10); + if (phase == ActionListRunPhase::Validate) + { + const pdf::PDFOperationResult validation = executor.validate(actionList, options, &outcome->validationErrors); + outcome->ok = bool(validation); + context.reportProgress(95); + context.setResultSummary(outcome->ok ? QStringLiteral("Action List validated.") + : QStringLiteral("Action List validation failed.")); + return; + } + + if (phase == ActionListRunPhase::Plan) + { + const pdf::PDFOperationResult planResult = executor.plan(actionList, *document, options, &outcome->executionResult); + outcome->ok = bool(planResult); + context.reportProgress(95); + context.setResultSummary(outcome->ok ? QStringLiteral("Action List planned.") + : QStringLiteral("Action List planning failed.")); + return; + } + + pdf::PDFDocument candidate; + const pdf::PDFOperationResult executeResult = executor.execute(actionList, *document, options, &candidate, &outcome->executionResult); + outcome->ok = bool(executeResult); + if (context.isCancellationRequested()) + { + return; + } + if (outcome->ok && candidate != pdf::PDFDocument()) + { + outcome->candidate = pdf::PDFDocumentPointer(new pdf::PDFDocument(std::move(candidate))); + } + context.reportProgress(95); + context.setResultSummary(outcome->ok ? QStringLiteral("Action List executed.") + : QStringLiteral("Action List execution finished.")); + }; +} + +} // namespace pdfinteraction diff --git a/LoopLibInteraction/sources/actionlistrunsubmitter.h b/LoopLibInteraction/sources/actionlistrunsubmitter.h new file mode 100644 index 000000000..e96f98f8f --- /dev/null +++ b/LoopLibInteraction/sources/actionlistrunsubmitter.h @@ -0,0 +1,67 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#ifndef ACTIONLISTRUNSUBMITTER_H +#define ACTIONLISTRUNSUBMITTER_H + +#include "interactionglobal.h" + +#include "pdfactionlist.h" +#include "pdfdocument.h" +#include "pdfjobscheduler.h" + +#include +#include +#include +#include + +namespace pdfinteraction +{ + +enum class ActionListRunPhase +{ + Validate, + Plan, + Execute +}; + +struct ActionListWorkerOutcome +{ + ActionListRunPhase phase = ActionListRunPhase::Validate; + bool ok = false; + QString errorMessage; + QStringList validationErrors; + pdf::PDFActionListExecutionResult executionResult; + pdf::PDFDocumentPointer candidate; +}; + +using ActionListRunWorker = std::function; + +ActionListRunWorker makeActionListRunWorker(ActionListRunPhase phase, + pdf::PDFActionList actionList, + pdf::PDFDocumentPointer document, + QJsonObject bindings, + std::shared_ptr outcome); + +} // namespace pdfinteraction + +#endif // ACTIONLISTRUNSUBMITTER_H diff --git a/LoopLibInteraction/sources/actionliststepsmodel.cpp b/LoopLibInteraction/sources/actionliststepsmodel.cpp new file mode 100644 index 000000000..0eafdde3b --- /dev/null +++ b/LoopLibInteraction/sources/actionliststepsmodel.cpp @@ -0,0 +1,105 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#include "actionliststepsmodel.h" + +#include + +namespace pdfinteraction +{ + +ActionListStepsModel::ActionListStepsModel(QObject* parent) : + QAbstractListModel(parent) +{ +} + +int ActionListStepsModel::rowCount(const QModelIndex& parent) const +{ + return parent.isValid() ? 0 : m_steps.size(); +} + +QVariant ActionListStepsModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_steps.size()) + { + return {}; + } + + const pdf::PDFActionListStepResult& step = m_steps.at(index.row()); + switch (role) + { + case StepIdRole: + return step.stepId; + case OperationIdRole: + return step.operationId; + case StatusRole: + return static_cast(step.status); + case StatusNameRole: + return pdf::pdfActionListStepStatusName(step.status); + case DurationMsRole: + return step.durationMs; + case ResolvedParametersRole: + return QString::fromUtf8(QJsonDocument(step.resolvedParameters).toJson(QJsonDocument::Compact)); + case AffectedScopeRole: + return step.affectedScope; + case DiagnosticsRole: + return step.diagnostics; + case Qt::DisplayRole: + return QStringLiteral("%1 — %2").arg(step.stepId, pdf::pdfActionListStepStatusName(step.status)); + default: + return {}; + } +} + +QHash ActionListStepsModel::roleNames() const +{ + return { + { StepIdRole, "stepId" }, + { OperationIdRole, "operationId" }, + { StatusRole, "status" }, + { StatusNameRole, "statusName" }, + { DurationMsRole, "durationMs" }, + { ResolvedParametersRole, "resolvedParameters" }, + { AffectedScopeRole, "affectedScope" }, + { DiagnosticsRole, "diagnostics" } + }; +} + +void ActionListStepsModel::replace(const QVector& steps) +{ + beginResetModel(); + m_steps = steps; + endResetModel(); +} + +void ActionListStepsModel::clear() +{ + if (m_steps.isEmpty()) + { + return; + } + beginResetModel(); + m_steps.clear(); + endResetModel(); +} + +} // namespace pdfinteraction diff --git a/LoopLibInteraction/sources/actionliststepsmodel.h b/LoopLibInteraction/sources/actionliststepsmodel.h new file mode 100644 index 000000000..299990bea --- /dev/null +++ b/LoopLibInteraction/sources/actionliststepsmodel.h @@ -0,0 +1,70 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#ifndef ACTIONLISTSTEPSMODEL_H +#define ACTIONLISTSTEPSMODEL_H + +#include "interactionglobal.h" + +#include "pdfactionlist.h" + +#include +#include +#include + +namespace pdfinteraction +{ + +class ActionListStepsModel final : public QAbstractListModel +{ + Q_OBJECT + +public: + enum Role + { + StepIdRole = Qt::UserRole + 1, + OperationIdRole, + StatusRole, + StatusNameRole, + DurationMsRole, + ResolvedParametersRole, + AffectedScopeRole, + DiagnosticsRole + }; + Q_ENUM(Role) + + explicit ActionListStepsModel(QObject* parent = nullptr); + + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + void replace(const QVector& steps); + void clear(); + +private: + QVector m_steps; +}; + +} // namespace pdfinteraction + +#endif // ACTIONLISTSTEPSMODEL_H diff --git a/LoopLibInteraction/sources/repairparameterschema.cpp b/LoopLibInteraction/sources/repairparameterschema.cpp new file mode 100644 index 000000000..4b2d8ff11 --- /dev/null +++ b/LoopLibInteraction/sources/repairparameterschema.cpp @@ -0,0 +1,41 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#include "repairparameterschema.h" + +#include "pdfrepairoperation.h" + +namespace pdfinteraction +{ + +QJsonObject repairParameterSchema(const QString& operationId) +{ + const pdf::PDFRepairOperation* operation = pdf::PDFRepairRegistry::instance().find(operationId); + return operation ? operation->parameterSchema() : QJsonObject(); +} + +QJsonArray repairOperationDescriptors() +{ + return pdf::PDFRepairRegistry::instance().descriptors(); +} + +} // namespace pdfinteraction diff --git a/LoopLibInteraction/sources/repairparameterschema.h b/LoopLibInteraction/sources/repairparameterschema.h new file mode 100644 index 000000000..85f08dcc0 --- /dev/null +++ b/LoopLibInteraction/sources/repairparameterschema.h @@ -0,0 +1,40 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#ifndef REPAIRPARAMETERSCHEMA_H +#define REPAIRPARAMETERSCHEMA_H + +#include "interactionglobal.h" + +#include +#include +#include + +namespace pdfinteraction +{ + +QJsonObject repairParameterSchema(const QString& operationId); +QJsonArray repairOperationDescriptors(); + +} // namespace pdfinteraction + +#endif // REPAIRPARAMETERSCHEMA_H diff --git a/ProductQuickAccessibilitySmoke/CMakeLists.txt b/ProductQuickAccessibilitySmoke/CMakeLists.txt index 8d73a6047..19a49eab1 100644 --- a/ProductQuickAccessibilitySmoke/CMakeLists.txt +++ b/ProductQuickAccessibilitySmoke/CMakeLists.txt @@ -27,6 +27,7 @@ qt_add_qml_module(ProductQuickAccessibilitySmoke qml/Main.qml qml/Workspace.qml qml/PreflightPane.qml + qml/ActionListPane.qml qml/InspectorPane.qml qml/DocumentPane.qml qml/CanvasPane.qml diff --git a/ProductQuickAccessibilitySmoke/qml/ActionListPane.qml b/ProductQuickAccessibilitySmoke/qml/ActionListPane.qml new file mode 100644 index 000000000..a48b5664b --- /dev/null +++ b/ProductQuickAccessibilitySmoke/qml/ActionListPane.qml @@ -0,0 +1,248 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Dialogs + +Pane { + id: root + + objectName: "actionListPane" + + property var host: editorHost + property var stepsModel: host ? host.actionList.stepsModel : null + readonly property bool preferReducedMotion: host ? host.preferReducedMotion : false + readonly property bool busy: host && ["validating", "planning", "running"].indexOf(host.actionListStateName) >= 0 + + padding: 8 + + Accessible.role: Accessible.Grouping + Accessible.name: qsTr("Action List") + Accessible.description: qsTr("Validate, plan, and execute reusable repair recipes against the open document.") + + FileDialog { + id: importDialog + title: qsTr("Import Action List recipe") + nameFilters: [qsTr("JSON recipes (*.json)")] + fileMode: FileDialog.OpenFile + onAccepted: if (root.host) root.host.importActionListRecipe(selectedFile) + } + + FileDialog { + id: exportDialog + title: qsTr("Export Action List recipe") + nameFilters: [qsTr("JSON recipes (*.json)")] + fileMode: FileDialog.SaveFile + onAccepted: if (root.host) root.host.exportActionListRecipe(selectedFile) + } + + ColumnLayout { + anchors.fill: parent + spacing: 8 + + Label { + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: host ? (host.actionList.operatorSummary.length > 0 + ? host.actionList.operatorSummary + : qsTr("Action List status: %1").arg(host.actionListStateName)) : "" + Accessible.name: qsTr("Action List status") + } + + RowLayout { + Layout.fillWidth: true + + ComboBox { + id: recipeSelector + objectName: "actionListRecipeSelector" + Layout.fillWidth: true + model: root.host ? root.host.actionListRecipes : [] + textRole: "name" + valueRole: "id" + enabled: root.host && !root.busy + currentIndex: { + if (!root.host) + return -1 + for (var i = 0; i < model.length; ++i) { + if (model[i].id === root.host.selectedActionListRecipeId) + return i + } + return -1 + } + Accessible.name: qsTr("Action List recipe") + onActivated: function(index) { + if (root.host && model[index]) + root.host.selectActionListRecipe(model[index].id) + } + } + + Button { + text: qsTr("Import") + enabled: root.host && !root.busy + onClicked: importDialog.open() + Accessible.name: qsTr("Import Action List recipe") + } + + Button { + text: qsTr("Export") + enabled: root.host && root.host.selectedActionListRecipeId.length > 0 && !root.busy + onClicked: exportDialog.open() + Accessible.name: qsTr("Export Action List recipe") + } + } + + Repeater { + model: root.host ? root.host.actionListBindings : [] + delegate: RowLayout { + Layout.fillWidth: true + required property var modelData + + Label { + text: modelData.name + Accessible.name: qsTr("Binding %1").arg(modelData.name) + } + TextField { + Layout.fillWidth: true + enabled: !root.busy + text: modelData.value === undefined || modelData.value === null ? "" : String(modelData.value) + Accessible.name: qsTr("Binding value for %1").arg(modelData.name) + onEditingFinished: if (root.host) root.host.setActionListBinding(modelData.name, text) + } + } + } + + RowLayout { + Layout.fillWidth: true + + Button { + objectName: "validateActionListButton" + text: qsTr("Validate") + enabled: root.host && root.host.hasDocument && !root.busy + onClicked: root.host.validateActionListRecipe() + Accessible.name: qsTr("Validate Action List recipe") + } + + Button { + objectName: "planActionListButton" + text: qsTr("Plan") + enabled: root.host && root.host.hasDocument && !root.busy + onClicked: root.host.planActionList() + Accessible.name: qsTr("Plan Action List") + } + + Button { + objectName: "confirmActionListButton" + text: qsTr("Confirm and Run") + enabled: root.host && host.actionListStateName === "planned" && !root.busy + onClicked: root.host.confirmActionListPlan() + Accessible.name: qsTr("Confirm Action List plan and run") + } + + Button { + objectName: "discardActionListPlanButton" + text: qsTr("Discard Plan") + enabled: root.host && host.actionListStateName === "planned" && !root.busy + onClicked: root.host.discardActionListPlan() + Accessible.name: qsTr("Discard Action List plan") + } + + Button { + objectName: "cancelActionListButton" + text: qsTr("Cancel") + enabled: root.busy + onClicked: root.host.cancelActionList() + Accessible.name: qsTr("Cancel Action List") + } + + ProgressBar { + objectName: "actionListProgress" + Layout.fillWidth: true + from: 0 + to: 100 + value: root.host ? root.host.actionList.progress : 0 + enabled: root.busy + Accessible.name: qsTr("Action List progress") + } + } + + Label { + Layout.fillWidth: true + visible: root.host && root.host.actionList.recipeHash.length > 0 + text: qsTr("Recipe hash: %1").arg(root.host.actionList.recipeHash) + wrapMode: Text.WordWrap + Accessible.name: qsTr("Action List recipe hash") + } + + ListView { + id: stepsView + objectName: "actionListStepsView" + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + focus: visible + activeFocusOnTab: true + model: root.stepsModel + + Accessible.role: Accessible.List + Accessible.name: qsTr("Action List step diagnostics") + Accessible.description: qsTr("Planned or executed steps with distinct status, diagnostics, and affected scope.") + + delegate: ItemDelegate { + width: stepsView.width + text: "%1 — %2 (%3 ms)".arg(model.stepId, model.statusName, model.durationMs) + Accessible.role: Accessible.ListItem + Accessible.name: text + Accessible.description: qsTr("Operation %1. Parameters %2. Diagnostics %3.") + .arg(model.operationId).arg(model.resolvedParameters).arg(JSON.stringify(model.diagnostics)) + + contentItem: ColumnLayout { + spacing: 4 + width: parent.width + + Label { + Layout.fillWidth: true + text: "%1 — %2".arg(model.stepId, model.statusName) + font.bold: true + color: { + switch (model.statusName) { + case "pending": return "#6b7280" + case "running": return "#2563eb" + case "succeeded": return "#15803d" + case "skipped": return "#a16207" + case "failed": return "#b91c1c" + case "cancelled": return "#7c3aed" + default: return palette.text + } + } + } + + Label { + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: qsTr("Operation: %1").arg(model.operationId) + } + + Label { + Layout.fillWidth: true + wrapMode: Text.WordWrap + visible: model.resolvedParameters.length > 0 + text: qsTr("Parameters: %1").arg(model.resolvedParameters) + } + + Label { + Layout.fillWidth: true + wrapMode: Text.WordWrap + visible: model.affectedScope && model.affectedScope.length > 0 + text: qsTr("Affected scope: %1").arg(JSON.stringify(model.affectedScope)) + } + + Label { + Layout.fillWidth: true + wrapMode: Text.WordWrap + visible: model.diagnostics && model.diagnostics.length > 0 + text: qsTr("Diagnostics: %1").arg(JSON.stringify(model.diagnostics)) + } + } + } + } + } +} diff --git a/ProductQuickAccessibilitySmoke/qml/Workspace.qml b/ProductQuickAccessibilitySmoke/qml/Workspace.qml index 8931206e4..564e9b853 100644 --- a/ProductQuickAccessibilitySmoke/qml/Workspace.qml +++ b/ProductQuickAccessibilitySmoke/qml/Workspace.qml @@ -182,11 +182,8 @@ Item { Accessible.name: qsTr("Inspect workspace") } - WorkspacePlaceholderPane { + ActionListPane { host: root.host - titleText: qsTr("Fix") - descriptionText: qsTr("Bounded corrective operations will appear here.") - Accessible.name: qsTr("Fix workspace") } WorkspacePlaceholderPane { diff --git a/UnitTests/CMakeLists.txt b/UnitTests/CMakeLists.txt index da4875bfb..7717a96a2 100644 --- a/UnitTests/CMakeLists.txt +++ b/UnitTests/CMakeLists.txt @@ -262,7 +262,7 @@ add_executable(UnitTestsActionList tst_actionlisttest.cpp ) -target_link_libraries(UnitTestsActionList PRIVATE LoopLibCore Qt6::Core Qt6::Gui Qt6::Test) +target_link_libraries(UnitTestsActionList PRIVATE LoopLibCore LoopLibInteraction Qt6::Core Qt6::Gui Qt6::Test) set_target_properties(UnitTestsActionList PROPERTIES WIN32_EXECUTABLE OFF diff --git a/UnitTests/tst_actionlisttest.cpp b/UnitTests/tst_actionlisttest.cpp index 7031b2cd1..5dcfa78f4 100644 --- a/UnitTests/tst_actionlisttest.cpp +++ b/UnitTests/tst_actionlisttest.cpp @@ -20,10 +20,15 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. +#include "actionlistrunsubmitter.h" #include "pdfactionlist.h" #include "pdfdocumentbuilder.h" +#include "pdfjobscheduler.h" +#include "pdfrepairdiff.h" +#include #include +#include #include class CancelActionListControl final : public pdf::PDFOperationControl @@ -35,6 +40,28 @@ class CancelActionListControl final : public pdf::PDFOperationControl } }; +namespace +{ + +pdf::PDFActionList bleedRecipe(const QString& id) +{ + pdf::PDFActionList actionList; + const pdf::PDFOperationResult parsed = pdf::PDFActionList::fromJson(QJsonObject{ + { QStringLiteral("schema"), QStringLiteral("loop-action-list/1") }, + { QStringLiteral("id"), id }, + { QStringLiteral("name"), id }, + { QStringLiteral("steps"), QJsonArray{QJsonObject{ + { QStringLiteral("id"), QStringLiteral("bleed") }, + { QStringLiteral("operation"), QStringLiteral("add-bleed") }, + { QStringLiteral("params"), QJsonObject{{QStringLiteral("bleed_mm"), 3.0}, {QStringLiteral("force"), true}}} + }} } + }, &actionList); + Q_ASSERT(parsed); + return actionList; +} + +} // namespace + class ActionListTest : public QObject { Q_OBJECT @@ -45,6 +72,9 @@ private slots: void dryRunDoesNotMutateSource(); void executesRegisteredOperationOnCandidate(); void cancellationLeavesSourceUntouched(); + void adapterContractValidatePlanExecute(); + void cliParityRecipeHashAndOutputSha256(); + void surfacesPerStepValidationErrors(); }; void ActionListTest::parsesAndRoundTripsRecipe() @@ -150,6 +180,138 @@ void ActionListTest::executesRegisteredOperationOnCandidate() QCOMPARE(source.getCatalog()->getPage(0)->getMediaBox().width(), 100.0); } +void ActionListTest::adapterContractValidatePlanExecute() +{ + pdf::PDFDocumentBuilder builder; + builder.appendPage(QRectF(0, 0, 100, 100)); + const pdf::PDFDocument source = builder.build(); + const pdf::PDFDocumentPointer document(new pdf::PDFDocument(source)); + const pdf::PDFActionList actionList = bleedRecipe(QStringLiteral("adapter")); + + auto validateOutcome = std::make_shared(); + { + struct LocalContext + { + pdf::PDFJobCancellationTokenPtr token = std::make_shared(); + pdf::PDFJobContext context{ token, pdf::PDFProcessingLimits(), [](int) {} }; + } local; + pdfinteraction::makeActionListRunWorker(pdfinteraction::ActionListRunPhase::Validate, + actionList, + document, + QJsonObject(), + validateOutcome)(local.context); + } + QVERIFY(validateOutcome->ok); + QVERIFY(validateOutcome->validationErrors.isEmpty()); + + auto planOutcome = std::make_shared(); + { + struct LocalContext + { + pdf::PDFJobCancellationTokenPtr token = std::make_shared(); + pdf::PDFJobContext context{ token, pdf::PDFProcessingLimits(), [](int) {} }; + } local; + pdfinteraction::makeActionListRunWorker(pdfinteraction::ActionListRunPhase::Plan, + actionList, + document, + QJsonObject(), + planOutcome)(local.context); + } + QVERIFY(planOutcome->ok); + QCOMPARE(planOutcome->executionResult.status, QStringLiteral("planned")); + QVERIFY(!planOutcome->candidate); + + auto executeOutcome = std::make_shared(); + { + struct LocalContext + { + pdf::PDFJobCancellationTokenPtr token = std::make_shared(); + pdf::PDFJobContext context{ token, pdf::PDFProcessingLimits(), [](int) {} }; + } local; + pdfinteraction::makeActionListRunWorker(pdfinteraction::ActionListRunPhase::Execute, + actionList, + document, + QJsonObject(), + executeOutcome)(local.context); + } + QVERIFY(executeOutcome->ok); + QCOMPARE(executeOutcome->executionResult.status, QStringLiteral("succeeded")); + QVERIFY(executeOutcome->candidate); + QCOMPARE(source.getCatalog()->getPage(0)->getMediaBox().width(), 100.0); + QVERIFY(executeOutcome->candidate->getCatalog()->getPage(0)->getMediaBox().width() > 100.0); +} + +void ActionListTest::cliParityRecipeHashAndOutputSha256() +{ + pdf::PDFDocumentBuilder builder; + builder.appendPage(QRectF(0, 0, 100, 100)); + const pdf::PDFDocument source = builder.build(); + const pdf::PDFDocumentPointer document(new pdf::PDFDocument(source)); + const pdf::PDFActionList actionList = bleedRecipe(QStringLiteral("parity")); + + pdf::PDFActionListExecutionResult cliResult; + pdf::PDFDocument cliCandidate; + QVERIFY(pdf::PDFActionListExecutor().execute(actionList, source, {}, &cliCandidate, &cliResult)); + + auto adapterOutcome = std::make_shared(); + { + struct LocalContext + { + pdf::PDFJobCancellationTokenPtr token = std::make_shared(); + pdf::PDFJobContext context{ token, pdf::PDFProcessingLimits(), [](int) {} }; + } local; + pdfinteraction::makeActionListRunWorker(pdfinteraction::ActionListRunPhase::Execute, + actionList, + document, + QJsonObject(), + adapterOutcome)(local.context); + } + QVERIFY(adapterOutcome->ok); + QCOMPARE(adapterOutcome->executionResult.recipeHash, cliResult.recipeHash); + + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + const QString outputPath = tempDir.filePath(QStringLiteral("parity.pdf")); + QByteArray cliData; + QByteArray adapterData; + pdf::PDFDocument reopenedCli; + pdf::PDFDocument reopenedAdapter; + QVERIFY(pdf::PDFRepairDiffEngine::buildSerializedCandidate( + cliCandidate, [](pdf::PDFDocument*) { return pdf::PDFOperationResult(true); }, outputPath, &reopenedCli, &cliData)); + QVERIFY(pdf::PDFRepairDiffEngine::buildSerializedCandidate( + *adapterOutcome->candidate, [](pdf::PDFDocument*) { return pdf::PDFOperationResult(true); }, + tempDir.filePath(QStringLiteral("adapter.pdf")), &reopenedAdapter, &adapterData)); + QCOMPARE(QString::fromLatin1(QCryptographicHash::hash(cliData, QCryptographicHash::Sha256).toHex()), + QString::fromLatin1(QCryptographicHash::hash(adapterData, QCryptographicHash::Sha256).toHex())); +} + +void ActionListTest::surfacesPerStepValidationErrors() +{ + pdf::PDFActionList actionList; + QVERIFY(pdf::PDFActionList::fromJson(QJsonObject{ + { QStringLiteral("schema"), QStringLiteral("loop-action-list/1") }, + { QStringLiteral("id"), QStringLiteral("bad-steps") }, + { QStringLiteral("name"), QStringLiteral("Bad steps") }, + { QStringLiteral("steps"), QJsonArray{ + QJsonObject{ + { QStringLiteral("id"), QStringLiteral("one") }, + { QStringLiteral("operation"), QStringLiteral("missing") }, + { QStringLiteral("params"), QJsonObject() } + }, + QJsonObject{ + { QStringLiteral("id"), QStringLiteral("two") }, + { QStringLiteral("operation"), QStringLiteral("add-bleed") }, + { QStringLiteral("params"), QJsonObject{{QStringLiteral("force"), QStringLiteral("yes")}} } + } + } } + }, &actionList)); + + QStringList errors; + QVERIFY(!pdf::PDFActionListExecutor().validate(actionList, {}, &errors)); + QVERIFY(errors.join(QLatin1Char('\n')).contains(QStringLiteral("step 'one'"))); + QVERIFY(errors.join(QLatin1Char('\n')).contains(QStringLiteral("step.two.params"))); +} + void ActionListTest::cancellationLeavesSourceUntouched() { pdf::PDFDocumentBuilder builder; diff --git a/UnitTests/tst_editorhosttest.cpp b/UnitTests/tst_editorhosttest.cpp index f16bf8e9b..2e1763faa 100644 --- a/UnitTests/tst_editorhosttest.cpp +++ b/UnitTests/tst_editorhosttest.cpp @@ -266,6 +266,10 @@ void EditorHostTest::startsWithNoDocument() QVERIFY(defaultProfile.value(QStringLiteral("valid")).toBool()); QVERIFY(!defaultProfile.value(QStringLiteral("digest")).toString().isEmpty()); QVERIFY(defaultProfile.value(QStringLiteral("diagnostic")).toString().isEmpty()); + QVERIFY(host.actionList()); + QCOMPARE(host.actionListStateName(), QStringLiteral("idle")); + QVERIFY(!host.repairOperations().isEmpty()); + QVERIFY(!host.planActionList()); } void EditorHostTest::exposesCatalogDescriptorsWithoutMutating() diff --git a/changes/feat-0-3-0-30-action-lists.md b/changes/feat-0-3-0-30-action-lists.md new file mode 100644 index 000000000..658813ae9 --- /dev/null +++ b/changes/feat-0-3-0-30-action-lists.md @@ -0,0 +1,4 @@ +Category: added +Audience: operators +Breaking-Change: no +Summary: Add Action Lists GUI to the Loop Editor Fix workspace with recipe catalog management, validate/plan/confirm/run workflow, step diagnostics, and Interaction-layer controllers wired through EditorHost. From 846de4e846e1904c0f11eb8242026af53eedf0e2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 14 Sep 2026 04:33:54 +0000 Subject: [PATCH 2/5] chore: fix changelog fragment name for branch slug Co-authored-by: michael berry --- ...eat-0-3-0-30-action-lists.md => feat-0.3.0-30-action-lists.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changes/{feat-0-3-0-30-action-lists.md => feat-0.3.0-30-action-lists.md} (100%) diff --git a/changes/feat-0-3-0-30-action-lists.md b/changes/feat-0.3.0-30-action-lists.md similarity index 100% rename from changes/feat-0-3-0-30-action-lists.md rename to changes/feat-0.3.0-30-action-lists.md From e0fd9f7c5636b35c63c3937d16ea7f707c4c7bbd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 15 Sep 2026 22:17:02 +0000 Subject: [PATCH 3/5] chore: fix CI format, phase5 evidence, and GUI source allowlist - Run clang-format on editorhost.cpp and tst_actionlisttest.cpp - Regenerate docs/generated/phase5-widgets-inventory.json for ActionListPane - Register ActionListPane.qml in EXPECTED_GUI_SOURCES for both app trees Co-authored-by: michael berry --- LoopEditor/editorhost.cpp | 7 +- UnitTests/tst_actionlisttest.cpp | 147 ++++++++---------- docs/generated/phase5-widgets-inventory.json | 5 +- .../ci/test_check_preflight_truth_source.py | 2 + 4 files changed, 77 insertions(+), 84 deletions(-) diff --git a/LoopEditor/editorhost.cpp b/LoopEditor/editorhost.cpp index 17cd28503..8265f2107 100644 --- a/LoopEditor/editorhost.cpp +++ b/LoopEditor/editorhost.cpp @@ -306,8 +306,7 @@ EditorHost::EditorHost(QObject* parent) : connect(&m_session->scheduler(), &pdf::PDFJobScheduler::jobProgress, this, [this](const pdf::PDFJobSnapshot& snapshot) { m_preflight.updateProgress(snapshot.jobId, snapshot.documentRevision, snapshot.progress); - m_actionListController.updateProgress(snapshot.jobId, snapshot.documentRevision, snapshot.progress); - }); + m_actionListController.updateProgress(snapshot.jobId, snapshot.documentRevision, snapshot.progress); }); connect(&m_session->scheduler(), &pdf::PDFJobScheduler::jobFinished, this, [this](const pdf::PDFJobSnapshot& snapshot) { m_activeAsyncJobs.remove(snapshot.jobId); @@ -1905,8 +1904,8 @@ void EditorHost::finishActionListJob(const pdf::PDFJobSnapshot& snapshot) break; case pdf::PDFJobStatus::Failed: m_actionListController.failRun(snapshot.jobId, snapshot.documentRevision, - snapshot.errorMessage.isEmpty() ? tr("Action List failed.") - : snapshot.errorMessage); + snapshot.errorMessage.isEmpty() ? tr("Action List failed.") + : snapshot.errorMessage); bumpPresentation(); break; case pdf::PDFJobStatus::Cancelled: diff --git a/UnitTests/tst_actionlisttest.cpp b/UnitTests/tst_actionlisttest.cpp index 5dcfa78f4..c8746aac8 100644 --- a/UnitTests/tst_actionlisttest.cpp +++ b/UnitTests/tst_actionlisttest.cpp @@ -47,15 +47,14 @@ pdf::PDFActionList bleedRecipe(const QString& id) { pdf::PDFActionList actionList; const pdf::PDFOperationResult parsed = pdf::PDFActionList::fromJson(QJsonObject{ - { QStringLiteral("schema"), QStringLiteral("loop-action-list/1") }, - { QStringLiteral("id"), id }, - { QStringLiteral("name"), id }, - { QStringLiteral("steps"), QJsonArray{QJsonObject{ - { QStringLiteral("id"), QStringLiteral("bleed") }, - { QStringLiteral("operation"), QStringLiteral("add-bleed") }, - { QStringLiteral("params"), QJsonObject{{QStringLiteral("bleed_mm"), 3.0}, {QStringLiteral("force"), true}}} - }} } - }, &actionList); + { QStringLiteral("schema"), QStringLiteral("loop-action-list/1") }, + { QStringLiteral("id"), id }, + { QStringLiteral("name"), id }, + { QStringLiteral("steps"), QJsonArray{ QJsonObject{ + { QStringLiteral("id"), QStringLiteral("bleed") }, + { QStringLiteral("operation"), QStringLiteral("add-bleed") }, + { QStringLiteral("params"), QJsonObject{ { QStringLiteral("bleed_mm"), 3.0 }, { QStringLiteral("force"), true } } } } } } }, + &actionList); Q_ASSERT(parsed); return actionList; } @@ -85,15 +84,12 @@ void ActionListTest::parsesAndRoundTripsRecipe() { QStringLiteral("name"), QStringLiteral("Press ready") }, { QStringLiteral("onFailure"), QStringLiteral("stop") }, { QStringLiteral("steps"), QJsonArray{ - QJsonObject{ - { QStringLiteral("id"), QStringLiteral("bleed") }, - { QStringLiteral("operation"), QStringLiteral("add-bleed") }, - { QStringLiteral("params"), QJsonObject{ - { QStringLiteral("bleed_mm"), QStringLiteral("${job.bleed}") }, - { QStringLiteral("force"), true } - } } - } - } } + QJsonObject{ + { QStringLiteral("id"), QStringLiteral("bleed") }, + { QStringLiteral("operation"), QStringLiteral("add-bleed") }, + { QStringLiteral("params"), QJsonObject{ + { QStringLiteral("bleed_mm"), QStringLiteral("${job.bleed}") }, + { QStringLiteral("force"), true } } } } } } }; pdf::PDFActionList actionList; QVERIFY(pdf::PDFActionList::fromJson(json, &actionList)); @@ -106,22 +102,19 @@ void ActionListTest::rejectsUnknownOperationAndWrongParameterType() { pdf::PDFActionList actionList; QVERIFY(pdf::PDFActionList::fromJson(QJsonObject{ - { QStringLiteral("schema"), QStringLiteral("loop-action-list/1") }, - { QStringLiteral("id"), QStringLiteral("bad") }, - { QStringLiteral("name"), QStringLiteral("Bad") }, - { QStringLiteral("steps"), QJsonArray{ - QJsonObject{ - { QStringLiteral("id"), QStringLiteral("one") }, - { QStringLiteral("operation"), QStringLiteral("missing") }, - { QStringLiteral("params"), QJsonObject() } - }, - QJsonObject{ - { QStringLiteral("id"), QStringLiteral("two") }, - { QStringLiteral("operation"), QStringLiteral("add-bleed") }, - { QStringLiteral("params"), QJsonObject{{QStringLiteral("force"), QStringLiteral("yes")}} } - } - } } - }, &actionList)); + { QStringLiteral("schema"), QStringLiteral("loop-action-list/1") }, + { QStringLiteral("id"), QStringLiteral("bad") }, + { QStringLiteral("name"), QStringLiteral("Bad") }, + { QStringLiteral("steps"), QJsonArray{ + QJsonObject{ + { QStringLiteral("id"), QStringLiteral("one") }, + { QStringLiteral("operation"), QStringLiteral("missing") }, + { QStringLiteral("params"), QJsonObject() } }, + QJsonObject{ + { QStringLiteral("id"), QStringLiteral("two") }, + { QStringLiteral("operation"), QStringLiteral("add-bleed") }, + { QStringLiteral("params"), QJsonObject{ { QStringLiteral("force"), QStringLiteral("yes") } } } } } } }, + &actionList)); QStringList errors; QVERIFY(!pdf::PDFActionListExecutor().validate(actionList, {}, &errors)); QVERIFY(errors.join(QLatin1Char('\n')).contains(QStringLiteral("Unknown operation"))); @@ -135,15 +128,14 @@ void ActionListTest::dryRunDoesNotMutateSource() const pdf::PDFDocument source = builder.build(); pdf::PDFActionList actionList; QVERIFY(pdf::PDFActionList::fromJson(QJsonObject{ - { QStringLiteral("schema"), QStringLiteral("loop-action-list/1") }, - { QStringLiteral("id"), QStringLiteral("dry") }, - { QStringLiteral("name"), QStringLiteral("Dry") }, - { QStringLiteral("steps"), QJsonArray{QJsonObject{ - { QStringLiteral("id"), QStringLiteral("bleed") }, - { QStringLiteral("operation"), QStringLiteral("add-bleed") }, - { QStringLiteral("params"), QJsonObject{{QStringLiteral("bleed_mm"), 3.0}, {QStringLiteral("force"), true}}} - }} } - }, &actionList)); + { QStringLiteral("schema"), QStringLiteral("loop-action-list/1") }, + { QStringLiteral("id"), QStringLiteral("dry") }, + { QStringLiteral("name"), QStringLiteral("Dry") }, + { QStringLiteral("steps"), QJsonArray{ QJsonObject{ + { QStringLiteral("id"), QStringLiteral("bleed") }, + { QStringLiteral("operation"), QStringLiteral("add-bleed") }, + { QStringLiteral("params"), QJsonObject{ { QStringLiteral("bleed_mm"), 3.0 }, { QStringLiteral("force"), true } } } } } } }, + &actionList)); pdf::PDFActionListExecutionOptions options; options.dryRun = true; pdf::PDFActionListExecutionResult result; @@ -161,15 +153,14 @@ void ActionListTest::executesRegisteredOperationOnCandidate() const pdf::PDFDocument source = builder.build(); pdf::PDFActionList actionList; QVERIFY(pdf::PDFActionList::fromJson(QJsonObject{ - { QStringLiteral("schema"), QStringLiteral("loop-action-list/1") }, - { QStringLiteral("id"), QStringLiteral("execute") }, - { QStringLiteral("name"), QStringLiteral("Execute") }, - { QStringLiteral("steps"), QJsonArray{QJsonObject{ - { QStringLiteral("id"), QStringLiteral("bleed") }, - { QStringLiteral("operation"), QStringLiteral("add-bleed") }, - { QStringLiteral("params"), QJsonObject{{QStringLiteral("bleed_mm"), 3.0}, {QStringLiteral("force"), true}}} - }} } - }, &actionList)); + { QStringLiteral("schema"), QStringLiteral("loop-action-list/1") }, + { QStringLiteral("id"), QStringLiteral("execute") }, + { QStringLiteral("name"), QStringLiteral("Execute") }, + { QStringLiteral("steps"), QJsonArray{ QJsonObject{ + { QStringLiteral("id"), QStringLiteral("bleed") }, + { QStringLiteral("operation"), QStringLiteral("add-bleed") }, + { QStringLiteral("params"), QJsonObject{ { QStringLiteral("bleed_mm"), 3.0 }, { QStringLiteral("force"), true } } } } } } }, + &actionList)); pdf::PDFActionListExecutionResult result; pdf::PDFDocument candidate; QVERIFY(pdf::PDFActionListExecutor().execute(actionList, source, {}, &candidate, &result)); @@ -277,9 +268,11 @@ void ActionListTest::cliParityRecipeHashAndOutputSha256() pdf::PDFDocument reopenedCli; pdf::PDFDocument reopenedAdapter; QVERIFY(pdf::PDFRepairDiffEngine::buildSerializedCandidate( - cliCandidate, [](pdf::PDFDocument*) { return pdf::PDFOperationResult(true); }, outputPath, &reopenedCli, &cliData)); + cliCandidate, [](pdf::PDFDocument*) + { return pdf::PDFOperationResult(true); }, outputPath, &reopenedCli, &cliData)); QVERIFY(pdf::PDFRepairDiffEngine::buildSerializedCandidate( - *adapterOutcome->candidate, [](pdf::PDFDocument*) { return pdf::PDFOperationResult(true); }, + *adapterOutcome->candidate, [](pdf::PDFDocument*) + { return pdf::PDFOperationResult(true); }, tempDir.filePath(QStringLiteral("adapter.pdf")), &reopenedAdapter, &adapterData)); QCOMPARE(QString::fromLatin1(QCryptographicHash::hash(cliData, QCryptographicHash::Sha256).toHex()), QString::fromLatin1(QCryptographicHash::hash(adapterData, QCryptographicHash::Sha256).toHex())); @@ -289,22 +282,19 @@ void ActionListTest::surfacesPerStepValidationErrors() { pdf::PDFActionList actionList; QVERIFY(pdf::PDFActionList::fromJson(QJsonObject{ - { QStringLiteral("schema"), QStringLiteral("loop-action-list/1") }, - { QStringLiteral("id"), QStringLiteral("bad-steps") }, - { QStringLiteral("name"), QStringLiteral("Bad steps") }, - { QStringLiteral("steps"), QJsonArray{ - QJsonObject{ - { QStringLiteral("id"), QStringLiteral("one") }, - { QStringLiteral("operation"), QStringLiteral("missing") }, - { QStringLiteral("params"), QJsonObject() } - }, - QJsonObject{ - { QStringLiteral("id"), QStringLiteral("two") }, - { QStringLiteral("operation"), QStringLiteral("add-bleed") }, - { QStringLiteral("params"), QJsonObject{{QStringLiteral("force"), QStringLiteral("yes")}} } - } - } } - }, &actionList)); + { QStringLiteral("schema"), QStringLiteral("loop-action-list/1") }, + { QStringLiteral("id"), QStringLiteral("bad-steps") }, + { QStringLiteral("name"), QStringLiteral("Bad steps") }, + { QStringLiteral("steps"), QJsonArray{ + QJsonObject{ + { QStringLiteral("id"), QStringLiteral("one") }, + { QStringLiteral("operation"), QStringLiteral("missing") }, + { QStringLiteral("params"), QJsonObject() } }, + QJsonObject{ + { QStringLiteral("id"), QStringLiteral("two") }, + { QStringLiteral("operation"), QStringLiteral("add-bleed") }, + { QStringLiteral("params"), QJsonObject{ { QStringLiteral("force"), QStringLiteral("yes") } } } } } } }, + &actionList)); QStringList errors; QVERIFY(!pdf::PDFActionListExecutor().validate(actionList, {}, &errors)); @@ -319,15 +309,14 @@ void ActionListTest::cancellationLeavesSourceUntouched() const pdf::PDFDocument source = builder.build(); pdf::PDFActionList actionList; QVERIFY(pdf::PDFActionList::fromJson(QJsonObject{ - { QStringLiteral("schema"), QStringLiteral("loop-action-list/1") }, - { QStringLiteral("id"), QStringLiteral("cancel") }, - { QStringLiteral("name"), QStringLiteral("Cancel") }, - { QStringLiteral("steps"), QJsonArray{QJsonObject{ - { QStringLiteral("id"), QStringLiteral("bleed") }, - { QStringLiteral("operation"), QStringLiteral("add-bleed") }, - { QStringLiteral("params"), QJsonObject{{QStringLiteral("bleed_mm"), 3.0}, {QStringLiteral("force"), true}}} - }} } - }, &actionList)); + { QStringLiteral("schema"), QStringLiteral("loop-action-list/1") }, + { QStringLiteral("id"), QStringLiteral("cancel") }, + { QStringLiteral("name"), QStringLiteral("Cancel") }, + { QStringLiteral("steps"), QJsonArray{ QJsonObject{ + { QStringLiteral("id"), QStringLiteral("bleed") }, + { QStringLiteral("operation"), QStringLiteral("add-bleed") }, + { QStringLiteral("params"), QJsonObject{ { QStringLiteral("bleed_mm"), 3.0 }, { QStringLiteral("force"), true } } } } } } }, + &actionList)); CancelActionListControl control; pdf::PDFActionListExecutionOptions options; diff --git a/docs/generated/phase5-widgets-inventory.json b/docs/generated/phase5-widgets-inventory.json index ca3e98a7e..06cbb5428 100644 --- a/docs/generated/phase5-widgets-inventory.json +++ b/docs/generated/phase5-widgets-inventory.json @@ -470,6 +470,7 @@ "LoopEditor", "LoopLibQuick", "ProductQuickAccessibilitySmoke", + "UnitTestsActionList", "UnitTestsFindingNavigation", "UnitTestsPreflightInteraction", "UnitTestsPreflightVerdict" @@ -758,6 +759,7 @@ "build_only_in_profile": false, "direct_links": [ "LoopLibCore", + "LoopLibInteraction", "Qt6::Core", "Qt6::Gui", "Qt6::Test" @@ -768,7 +770,8 @@ "Test" ], "transitive_targets": [ - "LoopLibCore" + "LoopLibCore", + "LoopLibInteraction" ], "transitive_qt_modules": [ "Sql", diff --git a/scripts/ci/test_check_preflight_truth_source.py b/scripts/ci/test_check_preflight_truth_source.py index a5894254e..b64a90205 100644 --- a/scripts/ci/test_check_preflight_truth_source.py +++ b/scripts/ci/test_check_preflight_truth_source.py @@ -48,6 +48,7 @@ EXPECTED_GUI_SOURCES = frozenset( { "LoopEditor/editorhost.cpp", + "LoopEditor/qml/ActionListPane.qml", "LoopEditor/qml/CanvasPane.qml", "LoopEditor/qml/DocumentPane.qml", "LoopEditor/qml/InspectorPane.qml", @@ -59,6 +60,7 @@ "LoopEditor/qml/Workspace.qml", "LoopEditor/qml/WorkspacePlaceholderPane.qml", "ProductQuickAccessibilitySmoke/main.cpp", + "ProductQuickAccessibilitySmoke/qml/ActionListPane.qml", "ProductQuickAccessibilitySmoke/qml/CanvasPane.qml", "ProductQuickAccessibilitySmoke/qml/DocumentPane.qml", "ProductQuickAccessibilitySmoke/qml/InspectorPane.qml", From d29aed63e9a39aa104a9d1111f1ffec8409036fd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 15 Sep 2026 22:20:25 +0000 Subject: [PATCH 4/5] chore: refresh phase5 widgets inventory after dev merge Regenerate docs/generated/phase5-widgets-inventory.json to include UnitTestsPageBoxCorpus from dev so PR merge checkouts match push. Co-authored-by: michael berry --- docs/generated/phase5-widgets-inventory.json | 44 +++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/docs/generated/phase5-widgets-inventory.json b/docs/generated/phase5-widgets-inventory.json index 06cbb5428..58396f95b 100644 --- a/docs/generated/phase5-widgets-inventory.json +++ b/docs/generated/phase5-widgets-inventory.json @@ -90,6 +90,7 @@ "UnitTests/CMakeLists.txt", "UnitTests/CMakeLists.txt", "UnitTests/CMakeLists.txt", + "UnitTests/CMakeLists.txt", "loop-preflight/tools/CMakeLists.txt" ], "shell_ledger": "docs/loop-shell.json", @@ -408,6 +409,7 @@ "UnitTestsOperatorAcceptance", "UnitTestsOverprint", "UnitTestsOverprintRender", + "UnitTestsPageBoxCorpus", "UnitTestsPageMasterExport", "UnitTestsPluginAbi", "UnitTestsPreflightEngine", @@ -2193,6 +2195,46 @@ "widgets_paths": [], "consumers": [] }, + { + "id": "UnitTestsPageBoxCorpus", + "kind": "executable", + "cmake": "UnitTests/CMakeLists.txt", + "profile_enabled": false, + "profile_condition": "qualification target excluded from the product-surface manifest", + "install_rule": false, + "installed_in_profile": false, + "build_only_in_profile": false, + "direct_links": [ + "LoopLibCore", + "Qt6::Core", + "Qt6::Gui", + "Qt6::Test" + ], + "direct_qt_modules": [ + "Core", + "Gui", + "Test" + ], + "transitive_targets": [ + "LoopLibCore" + ], + "transitive_qt_modules": [ + "Sql", + "Svg", + "Xml" + ], + "qt_modules": [ + "Core", + "Gui", + "Sql", + "Svg", + "Test", + "Xml" + ], + "widgets_linkage": "none", + "widgets_paths": [], + "consumers": [] + }, { "id": "UnitTestsPageMasterExport", "kind": "executable", @@ -3244,7 +3286,7 @@ } ], "counts": { - "targets": 74, + "targets": 75, "installed_in_profile": 4, "build_only_in_profile": 2, "widgets_surfaces": 4, From 843f08a4786c18374740fac8b00514f4d4e6f273 Mon Sep 17 00:00:00 2001 From: mberrys <212453881+mberrys@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:06:21 -0700 Subject: [PATCH 5/5] fix: close Action List plan and parity gaps --- LoopEditor/editorhost.cpp | 221 +++++++++++++++++- LoopEditor/editorhost.h | 7 + LoopEditor/qml/ActionListPane.qml | 79 ++++++- .../sources/actionlistcatalog.cpp | 33 +++ .../sources/actionlistcatalog.h | 1 + .../sources/actionlistcontroller.cpp | 77 +++++- .../sources/actionlistcontroller.h | 33 ++- .../sources/actionlistrunsubmitter.cpp | 42 ++++ .../sources/actionlistrunsubmitter.h | 1 + .../qml/ActionListPane.qml | 79 ++++++- UnitTests/tst_actionlisttest.cpp | 120 +++++++++- 11 files changed, 668 insertions(+), 25 deletions(-) diff --git a/LoopEditor/editorhost.cpp b/LoopEditor/editorhost.cpp index 8265f2107..c9fba8b09 100644 --- a/LoopEditor/editorhost.cpp +++ b/LoopEditor/editorhost.cpp @@ -53,6 +53,7 @@ #include #include #include +#include #include #include #include @@ -77,6 +78,67 @@ namespace { const QString QuitCommandId = QStringLiteral("actionQuit"); + +QString actionListBindingsHash(const QJsonObject& bindings) +{ + return QString::fromLatin1(QCryptographicHash::hash(QJsonDocument(bindings).toJson(QJsonDocument::Compact), + QCryptographicHash::Sha256) + .toHex()); +} + +QJsonValue actionListEditorValue(const QVariant& value, const QJsonObject& schema, bool* omit) +{ + if (omit) + { + *omit = false; + } + const QString type = schema.value(QStringLiteral("type")).toString(); + const QString text = value.toString().trimmed(); + if (type == QStringLiteral("boolean")) + { + if (value.metaType().id() == QMetaType::Bool) + { + return value.toBool(); + } + if (text.compare(QStringLiteral("true"), Qt::CaseInsensitive) == 0) + { + return true; + } + if (text.compare(QStringLiteral("false"), Qt::CaseInsensitive) == 0) + { + return false; + } + } + else if (type == QStringLiteral("integer")) + { + bool ok = false; + const qlonglong integer = text.toLongLong(&ok); + if (ok) + { + return integer; + } + } + else if (type == QStringLiteral("number")) + { + bool ok = false; + const double number = text.toDouble(&ok); + if (ok) + { + return number; + } + } + else if (type == QStringLiteral("string")) + { + return value.toString(); + } + + if (text.isEmpty() && omit) + { + *omit = true; + } + return QJsonValue::fromVariant(value); +} + int rotationToDegrees(pdf::PageRotation rotation) { switch (rotation) @@ -950,6 +1012,29 @@ QVariantList EditorHost::actionListBindings() const return bindings; } +QVariantList EditorHost::actionListSteps() const +{ + QVariantList steps; + if (!m_actionListDraftValid) + { + return steps; + } + steps.reserve(m_actionListDraft.steps.size()); + for (int index = 0; index < m_actionListDraft.steps.size(); ++index) + { + const pdf::PDFActionListStep& step = m_actionListDraft.steps.at(index); + QVariantMap item; + item.insert(QStringLiteral("index"), index); + item.insert(QStringLiteral("id"), step.id); + item.insert(QStringLiteral("operation"), step.operationId); + item.insert(QStringLiteral("parameters"), step.parameters.toVariantMap()); + item.insert(QStringLiteral("parameterSchema"), + pdfinteraction::repairParameterSchema(step.operationId).toVariantMap()); + steps.append(item); + } + return steps; +} + QString EditorHost::actionListStateName() const { return actionListStateToString(m_actionListController.state()); @@ -987,6 +1072,7 @@ bool EditorHost::importActionListRecipe(const QUrl& url) } m_selectedActionListRecipeId = importedId; m_actionListBindings = QJsonObject(); + syncActionListDraft(); m_actionListController.markRecipeStale(); updateActionListRecipeWatch(); Q_EMIT actionListRecipesChanged(); @@ -1020,6 +1106,7 @@ bool EditorHost::selectActionListRecipe(const QString& id) } m_selectedActionListRecipeId = id; m_actionListBindings = QJsonObject(); + syncActionListDraft(); m_actionListController.markRecipeStale(); Q_EMIT actionListRecipesChanged(); bumpPresentation(); @@ -1032,13 +1119,96 @@ bool EditorHost::setActionListBinding(const QString& name, const QVariant& value { return false; } - m_actionListBindings.insert(name, QJsonValue::fromVariant(value)); + const QJsonValue parsed = actionListEditorValue(value, QJsonObject{ { QStringLiteral("type"), QStringLiteral("string") } }, nullptr); + const QString text = value.toString().trimmed(); + if (value.metaType().id() == QMetaType::QString) + { + if (text.compare(QStringLiteral("true"), Qt::CaseInsensitive) == 0) + { + m_actionListBindings.insert(name, true); + } + else if (text.compare(QStringLiteral("false"), Qt::CaseInsensitive) == 0) + { + m_actionListBindings.insert(name, false); + } + else + { + bool integerOk = false; + const qlonglong integer = text.toLongLong(&integerOk); + if (integerOk) + { + m_actionListBindings.insert(name, integer); + } + else + { + bool numberOk = false; + const double number = text.toDouble(&numberOk); + m_actionListBindings.insert(name, numberOk ? QJsonValue(number) : parsed); + } + } + } + else + { + m_actionListBindings.insert(name, parsed); + } + m_actionListController.markRecipeStale(); + Q_EMIT actionListRecipesChanged(); + bumpPresentation(); + return true; +} + +bool EditorHost::setActionListStepParameter(int stepIndex, const QString& name, const QVariant& value) +{ + if (!m_actionListDraftValid || stepIndex < 0 || stepIndex >= m_actionListDraft.steps.size() || name.trimmed().isEmpty()) + { + return false; + } + pdf::PDFActionListStep& step = m_actionListDraft.steps[stepIndex]; + const QJsonObject operationSchema = pdfinteraction::repairParameterSchema(step.operationId); + const QJsonObject schema = operationSchema.value(QStringLiteral("properties")).toObject().value(name).toObject(); + if (schema.isEmpty()) + { + return false; + } + bool omit = false; + const QJsonValue converted = actionListEditorValue(value, schema, &omit); + bool required = false; + for (const QJsonValue& requiredValue : operationSchema.value(QStringLiteral("required")).toArray()) + { + required = required || requiredValue.toString() == name; + } + if (omit && !required) + { + step.parameters.remove(name); + } + else + { + step.parameters.insert(name, converted); + } m_actionListController.markRecipeStale(); Q_EMIT actionListRecipesChanged(); bumpPresentation(); return true; } +bool EditorHost::saveActionListRecipe() +{ + if (!m_actionListDraftValid || m_selectedActionListRecipeId.isEmpty()) + { + return false; + } + QString error; + if (!m_actionListCatalog.saveRecipe(m_selectedActionListRecipeId, m_actionListDraft, &error)) + { + announceDocumentState(error); + return false; + } + m_actionListController.markRecipeStale(); + reloadActionListRecipes(); + announceDocumentState(tr("Action List recipe saved.")); + return true; +} + bool EditorHost::submitActionListJob(pdfinteraction::ActionListRunPhase phase, pdfinteraction::ActionListController::State controllerState) { @@ -1069,6 +1239,21 @@ bool EditorHost::submitActionListJob(pdfinteraction::ActionListRunPhase phase, const QString documentKey = m_session->revisionSource()->documentKey(); const QString documentRevision = m_session->facade().currentRevision().toString(); + const QString bindingsHash = actionListBindingsHash(m_actionListBindings); + if (phase == pdfinteraction::ActionListRunPhase::Plan && + !m_actionListController.validationMatches(documentKey, documentRevision, recipe->recipeHash, bindingsHash)) + { + announceDocumentState(tr("Validate the current Action List recipe and bindings before planning.")); + return false; + } + if (phase == pdfinteraction::ActionListRunPhase::Execute && + !m_actionListController.planMatches(documentKey, documentRevision, recipe->recipeHash, bindingsHash)) + { + m_actionListController.markRecipeStale(); + announceDocumentState(tr("The Action List plan is stale; validate and plan again.")); + bumpPresentation(); + return false; + } const QString jobId = QUuid::createUuid().toString(QUuid::WithoutBraces); pdf::PDFJobSpec spec; @@ -1082,7 +1267,13 @@ bool EditorHost::submitActionListJob(pdfinteraction::ActionListRunPhase phase, spec.progressModel = QStringLiteral("action-list-progress-v1"); spec.staleResultPolicy = pdf::PDFJobStaleResultPolicy::Discard; - m_actionListController.beginRun(controllerState, documentKey, documentRevision, recipe->id, jobId); + m_actionListController.beginRun(controllerState, + documentKey, + documentRevision, + recipe->id, + recipe->recipeHash, + bindingsHash, + jobId); auto outcome = std::make_shared(); m_actionListOutcomes.insert(jobId, outcome); const pdf::PDFActionList actionList = recipe->actionList; @@ -1143,6 +1334,9 @@ void EditorHost::discardActionListPlan() void EditorHost::reloadActionListRecipes() { const QString priorId = m_selectedActionListRecipeId; + const QString priorHash = priorId.isEmpty() || !m_actionListCatalog.recipe(priorId) + ? QString() + : m_actionListCatalog.recipe(priorId)->recipeHash; m_actionListCatalog.reload(); if (m_selectedActionListRecipeId.isEmpty() || !m_actionListCatalog.recipe(m_selectedActionListRecipeId) || @@ -1155,7 +1349,10 @@ void EditorHost::reloadActionListRecipes() m_selectedActionListRecipeId = valid == recipes.cend() ? QString() : valid->id; m_actionListBindings = QJsonObject(); } - if (!priorId.isEmpty() && priorId != m_selectedActionListRecipeId) + syncActionListDraft(); + const pdfinteraction::ActionListRecipeEntry* currentRecipe = m_actionListCatalog.recipe(m_selectedActionListRecipeId); + if (!priorId.isEmpty() && + (priorId != m_selectedActionListRecipeId || !currentRecipe || priorHash != currentRecipe->recipeHash)) { m_actionListController.markRecipeStale(); } @@ -1164,6 +1361,19 @@ void EditorHost::reloadActionListRecipes() bumpPresentation(); } +void EditorHost::syncActionListDraft() +{ + const pdfinteraction::ActionListRecipeEntry* recipe = m_actionListCatalog.recipe(m_selectedActionListRecipeId); + if (!recipe || !recipe->valid) + { + m_actionListDraft = pdf::PDFActionList(); + m_actionListDraftValid = false; + return; + } + m_actionListDraft = recipe->actionList; + m_actionListDraftValid = true; +} + void EditorHost::updateActionListRecipeWatch() { if (!m_actionListRecipeWatcher) @@ -1840,7 +2050,9 @@ void EditorHost::finishPreflightJob(const pdf::PDFJobSnapshot& snapshot) m_preflight.cancelRun(snapshot.jobId); break; case pdf::PDFJobStatus::Stale: + m_actionListController.markRecipeStale(); syncRevisionModels(); + bumpPresentation(); break; case pdf::PDFJobStatus::Queued: case pdf::PDFJobStatus::Running: @@ -1878,7 +2090,8 @@ void EditorHost::finishActionListJob(const pdf::PDFJobSnapshot& snapshot) if (m_acceptActionListResults) { m_actionListController.acceptValidation(snapshot.jobId, snapshot.documentRevision, - outcome->validationErrors); + outcome->validationErrors, + outcome->validationSteps); } } else if (state == pdfinteraction::ActionListController::State::Planning) diff --git a/LoopEditor/editorhost.h b/LoopEditor/editorhost.h index 1baccecc5..fe416eccf 100644 --- a/LoopEditor/editorhost.h +++ b/LoopEditor/editorhost.h @@ -109,6 +109,7 @@ class EditorHost final : public QObject Q_PROPERTY(QVariantList actionListRecipes READ actionListRecipes NOTIFY actionListRecipesChanged) Q_PROPERTY(QString selectedActionListRecipeId READ selectedActionListRecipeId NOTIFY actionListRecipesChanged) Q_PROPERTY(QVariantList actionListBindings READ actionListBindings NOTIFY actionListRecipesChanged) + Q_PROPERTY(QVariantList actionListSteps READ actionListSteps NOTIFY actionListRecipesChanged) Q_PROPERTY(QString actionListStateName READ actionListStateName NOTIFY presentationChanged) Q_PROPERTY(QVariantList repairOperations READ repairOperations CONSTANT) Q_PROPERTY(bool hasPreflightReport READ hasPreflightReport NOTIFY presentationChanged) @@ -185,6 +186,7 @@ class EditorHost final : public QObject QVariantList actionListRecipes() const; QString selectedActionListRecipeId() const; QVariantList actionListBindings() const; + QVariantList actionListSteps() const; QString actionListStateName() const; QVariantList repairOperations() const; bool hasPreflightReport() const noexcept { return m_preflight.hasResult(); } @@ -229,6 +231,8 @@ class EditorHost final : public QObject Q_INVOKABLE bool exportActionListRecipe(const QUrl& url); Q_INVOKABLE bool selectActionListRecipe(const QString& id); Q_INVOKABLE bool setActionListBinding(const QString& name, const QVariant& value); + Q_INVOKABLE bool setActionListStepParameter(int stepIndex, const QString& name, const QVariant& value); + Q_INVOKABLE bool saveActionListRecipe(); Q_INVOKABLE bool validateActionListRecipe(); Q_INVOKABLE bool planActionList(); Q_INVOKABLE bool runActionList(); @@ -321,6 +325,7 @@ class EditorHost final : public QObject pdfinteraction::ActionListController::State controllerState); void reloadActionListRecipes(); void updateActionListRecipeWatch(); + void syncActionListDraft(); void refreshCanvasTrace(); void reloadPreflightProfiles(); void updatePreflightProfileWatch(); @@ -373,6 +378,8 @@ class EditorHost final : public QObject bool m_acceptActionListResults = true; QString m_selectedActionListRecipeId; QJsonObject m_actionListBindings; + pdf::PDFActionList m_actionListDraft; + bool m_actionListDraftValid = false; int m_commandEpoch = 0; bool m_documentBound = false; bool m_searchPanelVisible = false; diff --git a/LoopEditor/qml/ActionListPane.qml b/LoopEditor/qml/ActionListPane.qml index a48b5664b..073733590 100644 --- a/LoopEditor/qml/ActionListPane.qml +++ b/LoopEditor/qml/ActionListPane.qml @@ -110,6 +110,75 @@ Pane { } } + GroupBox { + Layout.fillWidth: true + title: qsTr("Recipe steps") + visible: root.host && root.host.actionListSteps.length > 0 + Accessible.name: qsTr("Schema-driven Action List recipe editor") + + ScrollView { + anchors.fill: parent + clip: true + + Column { + width: parent.width + spacing: 6 + + Repeater { + model: root.host ? root.host.actionListSteps : [] + + delegate: ColumnLayout { + required property var modelData + property var stepData: modelData + Layout.fillWidth: true + spacing: 3 + + Label { + Layout.fillWidth: true + text: qsTr("Step %1 — %2").arg(stepData.id).arg(stepData.operation) + font.bold: true + } + + Repeater { + model: stepData.parameterSchema && stepData.parameterSchema.properties + ? Object.keys(stepData.parameterSchema.properties) : [] + + delegate: RowLayout { + required property string modelData + Layout.fillWidth: true + property string parameterName: modelData + property var parameterSchema: stepData.parameterSchema.properties[parameterName] + + Label { + Layout.preferredWidth: 150 + text: qsTr("%1 (%2)").arg(parameterName).arg(parameterSchema.type || qsTr("value")) + elide: Text.ElideRight + } + + CheckBox { + visible: parameterSchema.type === "boolean" + checked: Boolean(stepData.parameters[parameterName]) + text: qsTr("Enabled") + onToggled: if (root.host) root.host.setActionListStepParameter(stepData.index, parameterName, checked) + } + + TextField { + Layout.fillWidth: true + visible: parameterSchema.type !== "boolean" + text: stepData.parameters[parameterName] === undefined + ? "" : String(stepData.parameters[parameterName]) + placeholderText: parameterSchema.enum ? parameterSchema.enum.join(" | ") : "" + onEditingFinished: if (root.host) + root.host.setActionListStepParameter(stepData.index, parameterName, text) + } + } + } + } + } + } + } + } + RowLayout { Layout.fillWidth: true @@ -124,11 +193,19 @@ Pane { Button { objectName: "planActionListButton" text: qsTr("Plan") - enabled: root.host && root.host.hasDocument && !root.busy + enabled: root.host && root.host.hasDocument && root.host.actionList.validationReady && !root.busy onClicked: root.host.planActionList() Accessible.name: qsTr("Plan Action List") } + Button { + objectName: "saveActionListRecipeButton" + text: qsTr("Save recipe") + enabled: root.host && root.host.selectedActionListRecipeId.length > 0 && !root.busy + onClicked: root.host.saveActionListRecipe() + Accessible.name: qsTr("Save edited Action List recipe") + } + Button { objectName: "confirmActionListButton" text: qsTr("Confirm and Run") diff --git a/LoopLibInteraction/sources/actionlistcatalog.cpp b/LoopLibInteraction/sources/actionlistcatalog.cpp index c06e91b65..e01858be6 100644 --- a/LoopLibInteraction/sources/actionlistcatalog.cpp +++ b/LoopLibInteraction/sources/actionlistcatalog.cpp @@ -29,6 +29,7 @@ #include #include #include +#include #include namespace pdfinteraction @@ -221,6 +222,38 @@ bool ActionListCatalog::exportRecipe(const QString& recipeId, const QString& des return true; } +bool ActionListCatalog::saveRecipe(const QString& recipeId, const pdf::PDFActionList& actionList, QString* error) +{ + const ActionListRecipeEntry* entry = recipe(recipeId); + if (!entry) + { + if (error) + { + *error = QStringLiteral("Action List recipe '%1' was not found.").arg(recipeId); + } + return false; + } + + QSaveFile output(entry->source); + if (!output.open(QIODevice::WriteOnly)) + { + if (error) + { + *error = QStringLiteral("Unable to write Action List recipe to '%1'.").arg(entry->source); + } + return false; + } + if (output.write(QJsonDocument(actionList.toJson()).toJson(QJsonDocument::Indented)) < 0 || !output.commit()) + { + if (error) + { + *error = QStringLiteral("Unable to save Action List recipe '%1'.").arg(entry->source); + } + return false; + } + return true; +} + const ActionListRecipeEntry* ActionListCatalog::recipe(const QString& recipeId) const { const auto it = std::find_if(m_recipes.cbegin(), m_recipes.cend(), diff --git a/LoopLibInteraction/sources/actionlistcatalog.h b/LoopLibInteraction/sources/actionlistcatalog.h index d1b3626cd..4a77d324e 100644 --- a/LoopLibInteraction/sources/actionlistcatalog.h +++ b/LoopLibInteraction/sources/actionlistcatalog.h @@ -59,6 +59,7 @@ class ActionListCatalog final : public QObject bool reload(); bool importRecipe(const QString& sourcePath, QString* importedId = nullptr, QString* error = nullptr); bool exportRecipe(const QString& recipeId, const QString& destinationPath, QString* error = nullptr); + bool saveRecipe(const QString& recipeId, const pdf::PDFActionList& actionList, QString* error = nullptr); const ActionListRecipeEntry* recipe(const QString& recipeId) const; signals: diff --git a/LoopLibInteraction/sources/actionlistcontroller.cpp b/LoopLibInteraction/sources/actionlistcontroller.cpp index 20a0b6d1c..56f948c71 100644 --- a/LoopLibInteraction/sources/actionlistcontroller.cpp +++ b/LoopLibInteraction/sources/actionlistcontroller.cpp @@ -62,8 +62,8 @@ void ActionListController::setCurrentRevision(QString documentKey, QString docum const bool changed = documentKey != m_documentKey || documentRevision != m_documentRevision; m_documentKey = std::move(documentKey); m_documentRevision = std::move(documentRevision); - if (changed && m_state != State::Idle && m_state != State::Validating && m_state != State::Planning && - m_state != State::Running) + if (changed && (m_state == State::Validating || m_state == State::Planning || m_state == State::Running || + m_state == State::Planned)) { markRecipeStale(); } @@ -71,14 +71,24 @@ void ActionListController::setCurrentRevision(QString documentKey, QString docum void ActionListController::markRecipeStale() { - if (m_state == State::Idle) + if (m_state == State::Idle && m_validatedRecipeHash.isEmpty()) { return; } - m_operatorSummary = QStringLiteral("Action List results are stale for the current document revision."); + cancelSchedulerJob(); + m_cancelRequested = true; + m_operatorSummary = QStringLiteral("Action List results are stale for the current document or recipe inputs."); setState(State::Idle); m_steps.clear(); m_result = pdf::PDFActionListExecutionResult(); + m_validatedDocumentKey.clear(); + m_validatedDocumentRevision.clear(); + m_validatedRecipeHash.clear(); + m_validatedBindingsHash.clear(); + m_plannedDocumentKey.clear(); + m_plannedDocumentRevision.clear(); + m_plannedRecipeHash.clear(); + m_plannedBindingsHash.clear(); Q_EMIT resultChanged(); } @@ -86,11 +96,15 @@ void ActionListController::beginRun(State phase, QString documentKey, QString documentRevision, QString recipeId, + QString recipeHash, + QString bindingsHash, QString jobId) { m_documentKey = std::move(documentKey); m_documentRevision = std::move(documentRevision); m_recipeId = std::move(recipeId); + m_runRecipeHash = std::move(recipeHash); + m_runBindingsHash = std::move(bindingsHash); m_jobId = std::move(jobId); m_cancelRequested = false; m_progress = 0; @@ -102,6 +116,26 @@ void ActionListController::beginRun(State phase, Q_EMIT resultChanged(); } +bool ActionListController::validationMatches(const QString& documentKey, + const QString& documentRevision, + const QString& recipeHash, + const QString& bindingsHash) const +{ + return !m_validatedRecipeHash.isEmpty() && m_state == State::Idle && + documentKey == m_validatedDocumentKey && documentRevision == m_validatedDocumentRevision && + recipeHash == m_validatedRecipeHash && bindingsHash == m_validatedBindingsHash; +} + +bool ActionListController::planMatches(const QString& documentKey, + const QString& documentRevision, + const QString& recipeHash, + const QString& bindingsHash) const +{ + return m_state == State::Planned && documentKey == m_plannedDocumentKey && + documentRevision == m_plannedDocumentRevision && recipeHash == m_plannedRecipeHash && + bindingsHash == m_plannedBindingsHash; +} + bool ActionListController::updateProgress(const QString& jobId, const QString& documentRevision, int progress) { if (jobId != m_jobId || documentRevision != m_documentRevision || @@ -116,7 +150,8 @@ bool ActionListController::updateProgress(const QString& jobId, const QString& d bool ActionListController::acceptValidation(const QString& jobId, const QString& documentRevision, - const QStringList& errors) + const QStringList& errors, + const QVector& validationSteps) { if (jobId != m_jobId || documentRevision != m_documentRevision || m_state != State::Validating || m_cancelRequested) { @@ -127,13 +162,27 @@ bool ActionListController::acceptValidation(const QString& jobId, Q_EMIT progressChanged(m_progress); if (!errors.isEmpty()) { + m_validatedDocumentKey.clear(); + m_validatedDocumentRevision.clear(); + m_validatedRecipeHash.clear(); + m_validatedBindingsHash.clear(); + m_plannedDocumentKey.clear(); + m_plannedDocumentRevision.clear(); + m_plannedRecipeHash.clear(); + m_plannedBindingsHash.clear(); + m_steps.replace(validationSteps); m_operatorSummary = errors.join(QLatin1Char('\n')); setState(State::Failed); return true; } + m_validatedDocumentKey = m_documentKey; + m_validatedDocumentRevision = documentRevision; + m_validatedRecipeHash = m_runRecipeHash; + m_validatedBindingsHash = m_runBindingsHash; m_operatorSummary = QStringLiteral("Action List recipe validated."); setState(State::Idle); + Q_EMIT resultChanged(); return true; } @@ -147,6 +196,10 @@ bool ActionListController::acceptPlan(const QString& jobId, } m_result = result; + m_plannedDocumentKey = m_documentKey; + m_plannedDocumentRevision = documentRevision; + m_plannedRecipeHash = m_runRecipeHash; + m_plannedBindingsHash = m_runBindingsHash; m_steps.replace(result.steps); m_progress = 100; Q_EMIT progressChanged(m_progress); @@ -193,6 +246,10 @@ bool ActionListController::acceptExecution(const QString& jobId, m_operatorSummary = QStringLiteral("Action List execution failed."); setState(State::Failed); } + m_plannedDocumentKey.clear(); + m_plannedDocumentRevision.clear(); + m_plannedRecipeHash.clear(); + m_plannedBindingsHash.clear(); return true; } @@ -240,6 +297,16 @@ void ActionListController::clear() { m_jobId.clear(); m_recipeId.clear(); + m_runRecipeHash.clear(); + m_runBindingsHash.clear(); + m_validatedDocumentKey.clear(); + m_validatedDocumentRevision.clear(); + m_validatedRecipeHash.clear(); + m_validatedBindingsHash.clear(); + m_plannedDocumentKey.clear(); + m_plannedDocumentRevision.clear(); + m_plannedRecipeHash.clear(); + m_plannedBindingsHash.clear(); m_progress = 0; m_cancelRequested = false; m_operatorSummary.clear(); diff --git a/LoopLibInteraction/sources/actionlistcontroller.h b/LoopLibInteraction/sources/actionlistcontroller.h index c2019019c..5bb791b21 100644 --- a/LoopLibInteraction/sources/actionlistcontroller.h +++ b/LoopLibInteraction/sources/actionlistcontroller.h @@ -43,6 +43,7 @@ class ActionListController final : public QObject Q_PROPERTY(int progress READ progress NOTIFY progressChanged) Q_PROPERTY(QString recipeHash READ recipeHash NOTIFY resultChanged) Q_PROPERTY(QString resultStatus READ resultStatus NOTIFY resultChanged) + Q_PROPERTY(bool validationReady READ validationReady NOTIFY resultChanged) public: enum class State @@ -71,14 +72,32 @@ class ActionListController final : public QObject int progress() const noexcept { return m_progress; } QString recipeHash() const { return m_result.recipeHash; } QString resultStatus() const { return m_result.status; } + bool validationReady() const noexcept { return !m_validatedRecipeHash.isEmpty() && m_state == State::Idle; } const pdf::PDFActionListExecutionResult& result() const noexcept { return m_result; } bool hasPlannedResult() const noexcept { return m_state == State::Planned; } void setCurrentRevision(QString documentKey, QString documentRevision); void markRecipeStale(); - void beginRun(State phase, QString documentKey, QString documentRevision, QString recipeId, QString jobId); + void beginRun(State phase, + QString documentKey, + QString documentRevision, + QString recipeId, + QString recipeHash, + QString bindingsHash, + QString jobId); + bool validationMatches(const QString& documentKey, + const QString& documentRevision, + const QString& recipeHash, + const QString& bindingsHash) const; + bool planMatches(const QString& documentKey, + const QString& documentRevision, + const QString& recipeHash, + const QString& bindingsHash) const; bool updateProgress(const QString& jobId, const QString& documentRevision, int progress); - bool acceptValidation(const QString& jobId, const QString& documentRevision, const QStringList& errors); + bool acceptValidation(const QString& jobId, + const QString& documentRevision, + const QStringList& errors, + const QVector& validationSteps); bool acceptPlan(const QString& jobId, const QString& documentRevision, const pdf::PDFActionListExecutionResult& result); bool acceptExecution(const QString& jobId, const QString& documentRevision, const pdf::PDFActionListExecutionResult& result); bool failRun(const QString& jobId, const QString& documentRevision, QString errorMessage); @@ -102,6 +121,16 @@ class ActionListController final : public QObject QString m_documentRevision; QString m_recipeId; QString m_jobId; + QString m_runRecipeHash; + QString m_runBindingsHash; + QString m_validatedDocumentKey; + QString m_validatedDocumentRevision; + QString m_validatedRecipeHash; + QString m_validatedBindingsHash; + QString m_plannedDocumentKey; + QString m_plannedDocumentRevision; + QString m_plannedRecipeHash; + QString m_plannedBindingsHash; int m_progress = 0; bool m_cancelRequested = false; pdf::PDFActionListExecutionResult m_result; diff --git a/LoopLibInteraction/sources/actionlistrunsubmitter.cpp b/LoopLibInteraction/sources/actionlistrunsubmitter.cpp index 7cff0fcd0..bb7ce5fc6 100644 --- a/LoopLibInteraction/sources/actionlistrunsubmitter.cpp +++ b/LoopLibInteraction/sources/actionlistrunsubmitter.cpp @@ -24,6 +24,44 @@ #include +namespace +{ + +void appendValidationDiagnostic(pdf::PDFActionListStepResult* step, const QString& message) +{ + step->status = pdf::PDFActionListStepStatus::Failed; + step->diagnostics.append(QJsonObject{ + { QStringLiteral("code"), QStringLiteral("action-list.validation-failed") }, + { QStringLiteral("severity"), QStringLiteral("error") }, + { QStringLiteral("message"), message } }); +} + +QVector validationSteps(const pdf::PDFActionList& actionList, + const QStringList& errors) +{ + QVector steps; + steps.reserve(actionList.steps.size()); + for (const pdf::PDFActionListStep& actionStep : actionList.steps) + { + pdf::PDFActionListStepResult step; + step.stepId = actionStep.id; + step.operationId = actionStep.operationId; + step.status = pdf::PDFActionListStepStatus::Pending; + for (const QString& error : errors) + { + if (error.contains(QStringLiteral("step '%1'").arg(actionStep.id), Qt::CaseInsensitive) || + error.contains(QStringLiteral("step.%1.").arg(actionStep.id), Qt::CaseSensitive)) + { + appendValidationDiagnostic(&step, error); + } + } + steps.append(std::move(step)); + } + return steps; +} + +} // namespace + namespace pdfinteraction { @@ -55,6 +93,10 @@ ActionListRunWorker makeActionListRunWorker(ActionListRunPhase phase, { const pdf::PDFOperationResult validation = executor.validate(actionList, options, &outcome->validationErrors); outcome->ok = bool(validation); + if (!outcome->ok) + { + outcome->validationSteps = ::validationSteps(actionList, outcome->validationErrors); + } context.reportProgress(95); context.setResultSummary(outcome->ok ? QStringLiteral("Action List validated.") : QStringLiteral("Action List validation failed.")); diff --git a/LoopLibInteraction/sources/actionlistrunsubmitter.h b/LoopLibInteraction/sources/actionlistrunsubmitter.h index e96f98f8f..3d13d0521 100644 --- a/LoopLibInteraction/sources/actionlistrunsubmitter.h +++ b/LoopLibInteraction/sources/actionlistrunsubmitter.h @@ -50,6 +50,7 @@ struct ActionListWorkerOutcome bool ok = false; QString errorMessage; QStringList validationErrors; + QVector validationSteps; pdf::PDFActionListExecutionResult executionResult; pdf::PDFDocumentPointer candidate; }; diff --git a/ProductQuickAccessibilitySmoke/qml/ActionListPane.qml b/ProductQuickAccessibilitySmoke/qml/ActionListPane.qml index a48b5664b..073733590 100644 --- a/ProductQuickAccessibilitySmoke/qml/ActionListPane.qml +++ b/ProductQuickAccessibilitySmoke/qml/ActionListPane.qml @@ -110,6 +110,75 @@ Pane { } } + GroupBox { + Layout.fillWidth: true + title: qsTr("Recipe steps") + visible: root.host && root.host.actionListSteps.length > 0 + Accessible.name: qsTr("Schema-driven Action List recipe editor") + + ScrollView { + anchors.fill: parent + clip: true + + Column { + width: parent.width + spacing: 6 + + Repeater { + model: root.host ? root.host.actionListSteps : [] + + delegate: ColumnLayout { + required property var modelData + property var stepData: modelData + Layout.fillWidth: true + spacing: 3 + + Label { + Layout.fillWidth: true + text: qsTr("Step %1 — %2").arg(stepData.id).arg(stepData.operation) + font.bold: true + } + + Repeater { + model: stepData.parameterSchema && stepData.parameterSchema.properties + ? Object.keys(stepData.parameterSchema.properties) : [] + + delegate: RowLayout { + required property string modelData + Layout.fillWidth: true + property string parameterName: modelData + property var parameterSchema: stepData.parameterSchema.properties[parameterName] + + Label { + Layout.preferredWidth: 150 + text: qsTr("%1 (%2)").arg(parameterName).arg(parameterSchema.type || qsTr("value")) + elide: Text.ElideRight + } + + CheckBox { + visible: parameterSchema.type === "boolean" + checked: Boolean(stepData.parameters[parameterName]) + text: qsTr("Enabled") + onToggled: if (root.host) root.host.setActionListStepParameter(stepData.index, parameterName, checked) + } + + TextField { + Layout.fillWidth: true + visible: parameterSchema.type !== "boolean" + text: stepData.parameters[parameterName] === undefined + ? "" : String(stepData.parameters[parameterName]) + placeholderText: parameterSchema.enum ? parameterSchema.enum.join(" | ") : "" + onEditingFinished: if (root.host) + root.host.setActionListStepParameter(stepData.index, parameterName, text) + } + } + } + } + } + } + } + } + RowLayout { Layout.fillWidth: true @@ -124,11 +193,19 @@ Pane { Button { objectName: "planActionListButton" text: qsTr("Plan") - enabled: root.host && root.host.hasDocument && !root.busy + enabled: root.host && root.host.hasDocument && root.host.actionList.validationReady && !root.busy onClicked: root.host.planActionList() Accessible.name: qsTr("Plan Action List") } + Button { + objectName: "saveActionListRecipeButton" + text: qsTr("Save recipe") + enabled: root.host && root.host.selectedActionListRecipeId.length > 0 && !root.busy + onClicked: root.host.saveActionListRecipe() + Accessible.name: qsTr("Save edited Action List recipe") + } + Button { objectName: "confirmActionListButton" text: qsTr("Confirm and Run") diff --git a/UnitTests/tst_actionlisttest.cpp b/UnitTests/tst_actionlisttest.cpp index c8746aac8..33a2b6553 100644 --- a/UnitTests/tst_actionlisttest.cpp +++ b/UnitTests/tst_actionlisttest.cpp @@ -21,13 +21,19 @@ // SOFTWARE. #include "actionlistrunsubmitter.h" +#include "actionlistcontroller.h" #include "pdfactionlist.h" #include "pdfdocumentbuilder.h" #include "pdfjobscheduler.h" #include "pdfrepairdiff.h" #include +#include +#include #include +#include +#include +#include #include #include @@ -74,6 +80,7 @@ private slots: void adapterContractValidatePlanExecute(); void cliParityRecipeHashAndOutputSha256(); void surfacesPerStepValidationErrors(); + void controllerFencesValidationPlanAndStaleCompletion(); }; void ActionListTest::parsesAndRoundTripsRecipe() @@ -238,11 +245,38 @@ void ActionListTest::cliParityRecipeHashAndOutputSha256() builder.appendPage(QRectF(0, 0, 100, 100)); const pdf::PDFDocument source = builder.build(); const pdf::PDFDocumentPointer document(new pdf::PDFDocument(source)); - const pdf::PDFActionList actionList = bleedRecipe(QStringLiteral("parity")); + const QJsonObject recipeJson{ + { QStringLiteral("schema"), QStringLiteral("loop-action-list/1") }, + { QStringLiteral("id"), QStringLiteral("parity") }, + { QStringLiteral("name"), QStringLiteral("Parity") }, + { QStringLiteral("steps"), QJsonArray{ QJsonObject{ + { QStringLiteral("id"), QStringLiteral("bleed") }, + { QStringLiteral("operation"), QStringLiteral("add-bleed") }, + { QStringLiteral("params"), QJsonObject{ { QStringLiteral("bleed_mm"), QStringLiteral("${job.bleed}") }, { QStringLiteral("force"), true } } } } } } + }; + pdf::PDFActionList actionList; + QVERIFY(pdf::PDFActionList::fromJson(recipeJson, &actionList)); + const QJsonObject bindings{ { QStringLiteral("bleed"), 3 } }; + + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + const QString inputPath = tempDir.filePath(QStringLiteral("input.pdf")); + const QString recipePath = tempDir.filePath(QStringLiteral("recipe.json")); + const QString outputPath = tempDir.filePath(QStringLiteral("output.pdf")); + QFile recipeFile(recipePath); + QVERIFY(recipeFile.open(QIODevice::WriteOnly)); + QVERIFY(recipeFile.write(QJsonDocument(recipeJson).toJson(QJsonDocument::Indented)) > 0); + recipeFile.close(); + pdf::PDFDocument reopenedInput; + QVERIFY(pdf::PDFRepairDiffEngine::buildSerializedCandidate( + source, [](pdf::PDFDocument*) + { return pdf::PDFOperationResult(true); }, inputPath, &reopenedInput, nullptr)); pdf::PDFActionListExecutionResult cliResult; pdf::PDFDocument cliCandidate; - QVERIFY(pdf::PDFActionListExecutor().execute(actionList, source, {}, &cliCandidate, &cliResult)); + pdf::PDFActionListExecutionOptions adapterOptions; + adapterOptions.bindings = bindings; + QVERIFY(pdf::PDFActionListExecutor().execute(actionList, source, adapterOptions, &cliCandidate, &cliResult)); auto adapterOutcome = std::make_shared(); { @@ -254,27 +288,42 @@ void ActionListTest::cliParityRecipeHashAndOutputSha256() pdfinteraction::makeActionListRunWorker(pdfinteraction::ActionListRunPhase::Execute, actionList, document, - QJsonObject(), + bindings, adapterOutcome)(local.context); } QVERIFY(adapterOutcome->ok); QCOMPARE(adapterOutcome->executionResult.recipeHash, cliResult.recipeHash); - QTemporaryDir tempDir; - QVERIFY(tempDir.isValid()); - const QString outputPath = tempDir.filePath(QStringLiteral("parity.pdf")); - QByteArray cliData; + QProcess process; + const QString pdfTool = QDir(QCoreApplication::applicationDirPath()).filePath( +#ifdef Q_OS_WIN + QStringLiteral("PdfTool.exe") +#else + QStringLiteral("PdfTool") +#endif + ); + QVERIFY2(QFileInfo::exists(pdfTool), qPrintable(QStringLiteral("PdfTool was not found at %1").arg(pdfTool))); + process.start(pdfTool, + { QStringLiteral("action-list"), QStringLiteral("run"), recipePath, inputPath, + QStringLiteral("--param"), QStringLiteral("bleed=3"), QStringLiteral("--output"), outputPath, + QStringLiteral("--console-format"), QStringLiteral("json") }); + QVERIFY(process.waitForFinished(30000)); + QCOMPARE(process.exitStatus(), QProcess::NormalExit); + QCOMPARE(process.exitCode(), 0); + const QJsonDocument cliOutput = QJsonDocument::fromJson(process.readAllStandardOutput()); + QVERIFY2(cliOutput.isObject(), qPrintable(QString::fromLocal8Bit(process.readAllStandardError()))); + const QJsonObject cliData = cliOutput.object().value(QStringLiteral("data")).toObject(); + QCOMPARE(cliData.value(QStringLiteral("recipe_hash")).toString(), cliResult.recipeHash); + const QString cliOutputHash = cliData.value(QStringLiteral("output")).toObject().value(QStringLiteral("sha256")).toString(); + QVERIFY(!cliOutputHash.isEmpty()); + QByteArray adapterData; - pdf::PDFDocument reopenedCli; pdf::PDFDocument reopenedAdapter; - QVERIFY(pdf::PDFRepairDiffEngine::buildSerializedCandidate( - cliCandidate, [](pdf::PDFDocument*) - { return pdf::PDFOperationResult(true); }, outputPath, &reopenedCli, &cliData)); QVERIFY(pdf::PDFRepairDiffEngine::buildSerializedCandidate( *adapterOutcome->candidate, [](pdf::PDFDocument*) { return pdf::PDFOperationResult(true); }, tempDir.filePath(QStringLiteral("adapter.pdf")), &reopenedAdapter, &adapterData)); - QCOMPARE(QString::fromLatin1(QCryptographicHash::hash(cliData, QCryptographicHash::Sha256).toHex()), + QCOMPARE(cliOutputHash, QString::fromLatin1(QCryptographicHash::hash(adapterData, QCryptographicHash::Sha256).toHex())); } @@ -302,6 +351,53 @@ void ActionListTest::surfacesPerStepValidationErrors() QVERIFY(errors.join(QLatin1Char('\n')).contains(QStringLiteral("step.two.params"))); } +void ActionListTest::controllerFencesValidationPlanAndStaleCompletion() +{ + pdfinteraction::ActionListController controller; + controller.beginRun(pdfinteraction::ActionListController::State::Validating, + QStringLiteral("doc"), + QStringLiteral("rev-1"), + QStringLiteral("recipe"), + QStringLiteral("recipe-hash-a"), + QStringLiteral("bindings-hash-a"), + QStringLiteral("validate-job")); + QVERIFY(controller.acceptValidation(QStringLiteral("validate-job"), QStringLiteral("rev-1"), {}, {})); + QVERIFY(controller.validationReady()); + QVERIFY(controller.validationMatches(QStringLiteral("doc"), QStringLiteral("rev-1"), + QStringLiteral("recipe-hash-a"), QStringLiteral("bindings-hash-a"))); + + controller.beginRun(pdfinteraction::ActionListController::State::Planning, + QStringLiteral("doc"), + QStringLiteral("rev-1"), + QStringLiteral("recipe"), + QStringLiteral("recipe-hash-a"), + QStringLiteral("bindings-hash-a"), + QStringLiteral("plan-job")); + pdf::PDFActionListExecutionResult plan; + plan.recipeHash = QStringLiteral("recipe-hash-a"); + plan.status = QStringLiteral("planned"); + QVERIFY(controller.acceptPlan(QStringLiteral("plan-job"), QStringLiteral("rev-1"), plan)); + QVERIFY(controller.planMatches(QStringLiteral("doc"), QStringLiteral("rev-1"), + QStringLiteral("recipe-hash-a"), QStringLiteral("bindings-hash-a"))); + QVERIFY(!controller.planMatches(QStringLiteral("doc"), QStringLiteral("rev-1"), + QStringLiteral("recipe-hash-b"), QStringLiteral("bindings-hash-a"))); + + controller.setCurrentRevision(QStringLiteral("doc"), QStringLiteral("rev-2")); + QCOMPARE(controller.state(), pdfinteraction::ActionListController::State::Idle); + QVERIFY(!controller.validationReady()); + + controller.beginRun(pdfinteraction::ActionListController::State::Running, + QStringLiteral("doc"), + QStringLiteral("rev-2"), + QStringLiteral("recipe"), + QStringLiteral("recipe-hash-a"), + QStringLiteral("bindings-hash-a"), + QStringLiteral("run-job")); + controller.setCurrentRevision(QStringLiteral("doc"), QStringLiteral("rev-3")); + QCOMPARE(controller.state(), pdfinteraction::ActionListController::State::Idle); + QVERIFY(!controller.validationReady()); +} + void ActionListTest::cancellationLeavesSourceUntouched() { pdf::PDFDocumentBuilder builder;