diff --git a/LoopEditor/CMakeLists.txt b/LoopEditor/CMakeLists.txt index 3d95b92a..755bbe70 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 49b52c1f..c9fba8b0 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" @@ -50,6 +53,7 @@ #include #include #include +#include #include #include #include @@ -74,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) @@ -91,6 +156,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 +297,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 +313,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 +334,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 +366,23 @@ 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 +456,11 @@ QObject* EditorHost::preflight() return &m_preflight; } +QObject* EditorHost::actionList() +{ + return &m_actionListController; +} + QObject* EditorHost::inspector() { return &m_inspector; @@ -865,6 +975,427 @@ 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; +} + +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()); +} + +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(); + syncActionListDraft(); + 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(); + syncActionListDraft(); + m_actionListController.markRecipeStale(); + Q_EMIT actionListRecipesChanged(); + bumpPresentation(); + return true; +} + +bool EditorHost::setActionListBinding(const QString& name, const QVariant& value) +{ + if (name.trimmed().isEmpty()) + { + return false; + } + 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) +{ + 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 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; + 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, + recipe->recipeHash, + bindingsHash, + 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; + 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) || + !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(); + } + syncActionListDraft(); + const pdfinteraction::ActionListRecipeEntry* currentRecipe = m_actionListCatalog.recipe(m_selectedActionListRecipeId); + if (!priorId.isEmpty() && + (priorId != m_selectedActionListRecipeId || !currentRecipe || priorHash != currentRecipe->recipeHash)) + { + m_actionListController.markRecipeStale(); + } + updateActionListRecipeWatch(); + Q_EMIT actionListRecipesChanged(); + 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) + { + 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 +1955,7 @@ void EditorHost::syncDocumentLifecycle() void EditorHost::onDocumentGone() { cancelPreflight(); + cancelActionList(); if (m_findingNavigator) { m_findingNavigator->invalidate(); @@ -1431,6 +1963,7 @@ void EditorHost::onDocumentGone() unbindCanvas(); m_session->clearDocumentView(); m_preflight.clear(); + m_actionListController.clear(); m_inspector.clearSelection(); m_documentModel.clear(); m_searchRow = -1; @@ -1516,6 +2049,82 @@ void EditorHost::finishPreflightJob(const pdf::PDFJobSnapshot& snapshot) case pdf::PDFJobStatus::Cancelled: m_preflight.cancelRun(snapshot.jobId); break; + case pdf::PDFJobStatus::Stale: + m_actionListController.markRecipeStale(); + syncRevisionModels(); + bumpPresentation(); + break; + case pdf::PDFJobStatus::Queued: + case pdf::PDFJobStatus::Running: + break; + } +} + +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, + outcome->validationSteps); + } + } + 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; @@ -1548,6 +2157,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 91afefd2..fe416ecc 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,12 @@ 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(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) Q_PROPERTY(QString previewSummary READ previewSummary NOTIFY presentationChanged) Q_PROPERTY(QString inspectorTitle READ inspectorTitle NOTIFY presentationChanged) @@ -152,6 +162,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 +183,12 @@ class EditorHost final : public QObject QVariantList preflightProfiles() const; QVariantList preflightVariables() const; QString selectedPreflightProfileId() const; + 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(); } QString previewSummary() const; QString inspectorTitle() const; @@ -210,6 +227,19 @@ 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 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(); + 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 +292,7 @@ class EditorHost final : public QObject void commandEpochChanged(); void workspaceChanged(LoopWorkspace from, LoopWorkspace to); void preflightProfilesChanged(); + void actionListRecipesChanged(); void preflightReportExportRequested(); private: @@ -289,6 +320,12 @@ 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 syncActionListDraft(); void refreshCanvasTrace(); void reloadPreflightProfiles(); void updatePreflightProfileWatch(); @@ -305,6 +342,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 +356,7 @@ class EditorHost final : public QObject QHash m_activeAsyncJobs; struct PreflightWorkerOutcome; QHash> m_preflightOutcomes; + QHash> m_actionListOutcomes; struct PreflightProfileChoice { QString id; @@ -333,7 +373,13 @@ 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; + 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 new file mode 100644 index 00000000..07373359 --- /dev/null +++ b/LoopEditor/qml/ActionListPane.qml @@ -0,0 +1,325 @@ +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) + } + } + } + + 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 + + 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.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") + 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 8931206e..564e9b85 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 a5efc123..b52cfb5e 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 00000000..e01858be --- /dev/null +++ b/LoopLibInteraction/sources/actionlistcatalog.cpp @@ -0,0 +1,265 @@ +// 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 +#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; +} + +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(), + [&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 00000000..4a77d324 --- /dev/null +++ b/LoopLibInteraction/sources/actionlistcatalog.h @@ -0,0 +1,77 @@ +// 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); + bool saveRecipe(const QString& recipeId, const pdf::PDFActionList& actionList, 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 00000000..56f948c7 --- /dev/null +++ b/LoopLibInteraction/sources/actionlistcontroller.cpp @@ -0,0 +1,320 @@ +// 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::Validating || m_state == State::Planning || m_state == State::Running || + m_state == State::Planned)) + { + markRecipeStale(); + } +} + +void ActionListController::markRecipeStale() +{ + if (m_state == State::Idle && m_validatedRecipeHash.isEmpty()) + { + return; + } + 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(); +} + +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; + m_operatorSummary.clear(); + m_result = pdf::PDFActionListExecutionResult(); + m_steps.clear(); + setState(phase); + Q_EMIT progressChanged(0); + 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 || + (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, + const QVector& validationSteps) +{ + 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_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; +} + +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_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); + 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); + } + m_plannedDocumentKey.clear(); + m_plannedDocumentRevision.clear(); + m_plannedRecipeHash.clear(); + m_plannedBindingsHash.clear(); + 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_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(); + 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 00000000..5bb791b2 --- /dev/null +++ b/LoopLibInteraction/sources/actionlistcontroller.h @@ -0,0 +1,144 @@ +// 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) + Q_PROPERTY(bool validationReady READ validationReady 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; } + 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 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, + 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); + 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; + 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; + 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 00000000..bb7ce5fc --- /dev/null +++ b/LoopLibInteraction/sources/actionlistrunsubmitter.cpp @@ -0,0 +1,133 @@ +// 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 +{ + +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 +{ + +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); + 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.")); + 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 00000000..3d13d052 --- /dev/null +++ b/LoopLibInteraction/sources/actionlistrunsubmitter.h @@ -0,0 +1,68 @@ +// 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; + QVector validationSteps; + 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 00000000..0eafdde3 --- /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 00000000..299990be --- /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 00000000..4b2d8ff1 --- /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 00000000..85f08dcc --- /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 8d73a604..19a49eab 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 00000000..07373359 --- /dev/null +++ b/ProductQuickAccessibilitySmoke/qml/ActionListPane.qml @@ -0,0 +1,325 @@ +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) + } + } + } + + 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 + + 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.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") + 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 8931206e..564e9b85 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 061ce340..da55b7be 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 7031b2cd..33a2b655 100644 --- a/UnitTests/tst_actionlisttest.cpp +++ b/UnitTests/tst_actionlisttest.cpp @@ -20,10 +20,21 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // 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 class CancelActionListControl final : public pdf::PDFOperationControl @@ -35,6 +46,27 @@ 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 +77,10 @@ private slots: void dryRunDoesNotMutateSource(); void executesRegisteredOperationOnCandidate(); void cancellationLeavesSourceUntouched(); + void adapterContractValidatePlanExecute(); + void cliParityRecipeHashAndOutputSha256(); + void surfacesPerStepValidationErrors(); + void controllerFencesValidationPlanAndStaleCompletion(); }; void ActionListTest::parsesAndRoundTripsRecipe() @@ -55,15 +91,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)); @@ -76,22 +109,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"))); @@ -105,15 +135,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; @@ -131,15 +160,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)); @@ -150,6 +178,226 @@ 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 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; + pdf::PDFActionListExecutionOptions adapterOptions; + adapterOptions.bindings = bindings; + QVERIFY(pdf::PDFActionListExecutor().execute(actionList, source, adapterOptions, &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, + bindings, + adapterOutcome)(local.context); + } + QVERIFY(adapterOutcome->ok); + QCOMPARE(adapterOutcome->executionResult.recipeHash, cliResult.recipeHash); + + 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 reopenedAdapter; + QVERIFY(pdf::PDFRepairDiffEngine::buildSerializedCandidate( + *adapterOutcome->candidate, [](pdf::PDFDocument*) + { return pdf::PDFOperationResult(true); }, + tempDir.filePath(QStringLiteral("adapter.pdf")), &reopenedAdapter, &adapterData)); + QCOMPARE(cliOutputHash, + 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::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; @@ -157,15 +405,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/UnitTests/tst_editorhosttest.cpp b/UnitTests/tst_editorhosttest.cpp index f16bf8e9..2e1763fa 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 00000000..658813ae --- /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. diff --git a/docs/generated/phase5-widgets-inventory.json b/docs/generated/phase5-widgets-inventory.json index ca3e98a7..58396f95 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", @@ -470,6 +472,7 @@ "LoopEditor", "LoopLibQuick", "ProductQuickAccessibilitySmoke", + "UnitTestsActionList", "UnitTestsFindingNavigation", "UnitTestsPreflightInteraction", "UnitTestsPreflightVerdict" @@ -758,6 +761,7 @@ "build_only_in_profile": false, "direct_links": [ "LoopLibCore", + "LoopLibInteraction", "Qt6::Core", "Qt6::Gui", "Qt6::Test" @@ -768,7 +772,8 @@ "Test" ], "transitive_targets": [ - "LoopLibCore" + "LoopLibCore", + "LoopLibInteraction" ], "transitive_qt_modules": [ "Sql", @@ -2190,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", @@ -3241,7 +3286,7 @@ } ], "counts": { - "targets": 74, + "targets": 75, "installed_in_profile": 4, "build_only_in_profile": 2, "widgets_surfaces": 4, diff --git a/scripts/ci/test_check_preflight_truth_source.py b/scripts/ci/test_check_preflight_truth_source.py index a5894254..b64a9020 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",