diff --git a/LoopEditor/editorhost.cpp b/LoopEditor/editorhost.cpp index e64ac630..0f508c23 100644 --- a/LoopEditor/editorhost.cpp +++ b/LoopEditor/editorhost.cpp @@ -306,7 +306,7 @@ struct EditorHost::PreflightWorkerOutcome QJsonObject auditSummary; }; -EditorHost::EditorHost(QObject* parent) : +EditorHost::EditorHost(QObject* parent, QString recipesDirectory) : QObject(parent), m_session(std::make_unique(this)), m_findingNavigator(std::make_unique( @@ -317,7 +317,8 @@ EditorHost::EditorHost(QObject* parent) : pdfinteraction::FindingTargetingCapabilityRegistry::defaultRegistry(), this)), m_preflight(&m_session->scheduler(), this), - m_actionListController(&m_session->scheduler(), this) + m_actionListController(&m_session->scheduler(), this), + m_actionListCatalog(nullptr, std::move(recipesDirectory)) { // Registers this constructing thread -- the one QML dispatches pointer // and frame callbacks on -- as the thread blocking service adapters @@ -643,31 +644,6 @@ QVariantMap fixStateVisualMap(const pdfquick::tokens::LoopStateVisual& visual) return result; } -/// Recorded rollback points for a document's history sidecar, or nothing when there is no -/// history or it cannot be read. Read-only: the shell never rewrites history. -QList rollbackPointsForDocument(const QString& documentPath) -{ - if (documentPath.isEmpty()) - { - return {}; - } - - const QString historyDirectory = - QFileInfo(documentPath).absoluteFilePath() + QStringLiteral(".loop-history"); - const QString historyPath = QDir(historyDirectory).filePath(QStringLiteral("history.sqlite3")); - if (!QFileInfo::exists(historyPath)) - { - return {}; - } - - pdf::PDFOperationHistoryStore history(historyPath); - QString error; - if (!history.open(&error)) - { - return {}; - } - return history.rollbackPoints(&error); -} } // namespace QString EditorHost::fixCurrentPlanDigest() const @@ -1160,8 +1136,11 @@ QString EditorHost::signatureSummary() const bool EditorHost::fixRollbackAvailable() const { - return m_session->facade().permissions().allowsCorrection() && !m_fixRollbackPoints.isEmpty() && - documentShellStatus() != QLatin1String("MODIFIED"); + return m_historyError.isEmpty() && m_session->facade().permissions().allowsCorrection() && !m_fixRollbackPoints.isEmpty() && + m_rollbackOutcomes.isEmpty() && m_actionListOutcomes.isEmpty() && + m_session->facade().outputState() != pdfinteraction::DocumentOutputState::Pending && + (documentShellStatus() != QLatin1String("MODIFIED") || + m_publishedHistoryRevision == m_session->facade().currentRevision().toString()); } QVariantList EditorHost::fixRollbackPoints() const @@ -1169,13 +1148,45 @@ QVariantList EditorHost::fixRollbackPoints() const return m_fixRollbackPoints; } +QString EditorHost::historyDocumentPath() const +{ + return m_publishedHistoryRevision == m_session->facade().currentRevision().toString() + ? m_publishedHistoryPath + : m_session->facade().source().path; +} + +QString EditorHost::historyArtifactDigest() const +{ + const auto document = m_session->context().getDocumentPointer(); + return document ? QString::fromLatin1(document->getSourceDataHash().toHex()) : QString(); +} + +QString EditorHost::revisionHistorySummary() const +{ + if (!m_historyError.isEmpty()) + return tr("Revision history is unavailable: %1").arg(m_historyError); + return tr("%1 recorded events. Undo and Redo publish forward rollback artifacts; approvals and receipts are retained.") + .arg(m_revisionHistory.size()); +} + +bool EditorHost::historyUndoAvailable() const +{ + return fixRollbackAvailable() && !m_historyUndoId.isEmpty(); +} + +bool EditorHost::historyRedoAvailable() const +{ + return fixRollbackAvailable() && !m_historyRedoId.isEmpty(); +} + QString EditorHost::fixRollbackSummary() const { if (!hasDocument()) { return tr("Open a document to return to a recorded revision."); } - if (documentShellStatus() == QLatin1String("MODIFIED")) + if (documentShellStatus() == QLatin1String("MODIFIED") && + m_publishedHistoryRevision != m_session->facade().currentRevision().toString()) { return tr("Save the document before returning to a recorded revision."); } @@ -2603,9 +2614,9 @@ bool EditorHost::saveActionListRecipe() bool EditorHost::submitActionListJob(pdfinteraction::ActionListRunPhase phase, pdfinteraction::ActionListController::State controllerState) { - if (!m_session->facade().permissions().allowsCorrection()) + if (!m_rollbackOutcomes.isEmpty() || !m_session->facade().permissions().allowsCorrection()) { - announceDocumentState(tr("Document permissions restrict corrections. No plan or output was created.")); + announceDocumentState(tr("A rollback is in progress or document permissions restrict corrections.")); return false; } if (!hasDocument() || m_selectedActionListRecipeId.isEmpty() || !m_session->revisionSource()) @@ -2705,6 +2716,7 @@ bool EditorHost::submitActionListJob(pdfinteraction::ActionListRunPhase phase, preflightProfilePath, preflightProfile, preflightProfileBindings, + historyDocumentPath(), phase == pdfinteraction::ActionListRunPhase::Execute && planSignatureInvalidationAcknowledged())); if (submittedId != jobId) @@ -2996,44 +3008,72 @@ void EditorHost::refreshFixRollbackPoints() void EditorHost::refreshFixRollbackPointsFromHistory() { m_fixRollbackPoints.clear(); - + m_revisionHistory.clear(); + m_historyUndoId.clear(); + m_historyRedoId.clear(); + m_historyError.clear(); if (!hasDocument()) + return; + const QString databasePath = QDir(historyDocumentPath() + QStringLiteral(".loop-history")).filePath(QStringLiteral("history.sqlite3")); + if (!QFileInfo::exists(databasePath)) + return; + pdf::PDFOperationHistoryStore history(databasePath); + if (!history.open(&m_historyError)) + return; + const auto verification = history.verify(); + if (!verification.verified) { - m_fixRollbackSummary = fixRollbackSummary(); + m_historyError = verification.errorMessage; return; } - - const QList points = documentRollbackPoints(); - for (const pdf::PDFRollbackPoint& point : points) - { - // Core returns to a revision only when it is the accepted output of a recorded - // execution, so an evicted artifact or the as-received input (kept as a protected - // retention point rather than a selectable revision) is never offered as one. - if (point.artifactEvicted || point.isOriginalInput) - { + const auto points = history.rollbackPoints(&m_historyError); + const auto events = history.events(&m_historyError); + QHash parents; + QStringList redo; + const QString currentDigest = historyArtifactDigest(); + for (const auto& event : events) + { + QVariantMap row = event.toJson().toVariantMap(); + row.insert(QStringLiteral("current"), event.output && event.output->sha256 == currentDigest); + row.insert(QStringLiteral("receiptText"), QString::fromUtf8(QJsonDocument(event.resultSummary.value(QStringLiteral("governed")).toObject()).toJson(QJsonDocument::Compact))); + m_revisionHistory.append(row); + if (!event.output) continue; + const auto execution = history.execution(event.executionId, &m_historyError); + if (!execution) + continue; + const QString source = execution->input.sha256; + const QString target = event.output->sha256; + if (event.status == pdf::PDFOperationHistoryStatus::Accepted && source != target) + { + parents.insert(target, source); + redo.clear(); + } + else if (event.status == pdf::PDFOperationHistoryStatus::RolledBack) + { + if (parents.value(source) == target) + redo.append(source); + else if (!redo.isEmpty() && redo.last() == target) + redo.removeLast(); + else + redo.clear(); } - QVariantMap entry; - entry.insert(QStringLiteral("rollbackId"), point.rollbackId); - entry.insert(QStringLiteral("documentRevisionDigest"), point.documentRevisionDigest); - entry.insert(QStringLiteral("createdUtc"), point.createdAtUtc.toString(Qt::ISODateWithMs)); - entry.insert(QStringLiteral("operationId"), point.operationId); - entry.insert(QStringLiteral("artifactBytes"), point.artifactBytes); - entry.insert(QStringLiteral("isOriginalInput"), point.isOriginalInput); - entry.insert(QStringLiteral("approvedOutput"), point.approvedOutput); - entry.insert(QStringLiteral("planSummary"), point.planSummary.left(160)); - m_fixRollbackPoints.append(entry); } - m_fixRollbackSummary = fixRollbackSummary(); -} - -QList EditorHost::documentRollbackPoints() const -{ - if (!hasDocument() || !m_session->facade().source().isValid()) + for (const auto& point : points) { - return {}; + const auto accepted = std::find_if(events.cbegin(), events.cend(), [&point](const auto& event) + { return event.entryId == point.auditEventId && event.status == pdf::PDFOperationHistoryStatus::Accepted; }); + if (point.artifactEvicted || (!point.isOriginalInput && accepted == events.cend())) + continue; + QVariantMap entry = point.toJson().toVariantMap(); + entry.insert(QStringLiteral("canReturn"), !point.isOriginalInput || point.documentRevisionDigest == parents.value(currentDigest)); + m_fixRollbackPoints.append(entry); + if (point.documentRevisionDigest == parents.value(currentDigest)) + m_historyUndoId = point.rollbackId; + if (!redo.isEmpty() && point.documentRevisionDigest == redo.last()) + m_historyRedoId = point.rollbackId; } - return rollbackPointsForDocument(m_session->facade().source().path); + refreshFeatureAvailability(); } bool EditorHost::requestFixRollback(const QString& rollbackId) @@ -3043,7 +3083,7 @@ bool EditorHost::requestFixRollback(const QString& rollbackId) return false; } - const QString documentPath = m_session->facade().source().path; + const QString documentPath = historyDocumentPath(); if (documentPath.isEmpty()) { return false; @@ -3081,17 +3121,30 @@ bool EditorHost::requestFixRollback(const QString& rollbackId) const QString currentDigest = QString::fromLatin1(QCryptographicHash::hash(currentBytes, QCryptographicHash::Sha256).toHex()); + if (currentDigest != historyArtifactDigest()) + { + announceDocumentState(tr("The source artifact changed; reopen it before navigating history.")); + return false; + } QUuid targetExecutionId; const QList events = history.events(&error); const auto event = std::find_if(events.cbegin(), events.cend(), - [&point](const pdf::PDFOperationHistoryEvent& candidate) - { return candidate.entryId == point->auditEventId; }); + [&point, &history, ¤tDigest](const pdf::PDFOperationHistoryEvent& candidate) + { + if (!point->isOriginalInput) + return candidate.entryId == point->auditEventId; + const auto execution = history.execution(candidate.executionId); + return execution && candidate.status == pdf::PDFOperationHistoryStatus::Accepted && + candidate.output && candidate.output->sha256 == currentDigest && + execution->input.sha256 == point->documentRevisionDigest; + }); if (event == events.cend()) { announceDocumentState(tr("The recorded revision has no audit event and cannot be returned to.")); return false; } targetExecutionId = event->executionId; + history.close(); // P2: a rollback revalidates the restored revision through the #38 path, so the // Editor must supply the effective profile it already holds. Without a validated @@ -3128,8 +3181,8 @@ bool EditorHost::requestFixRollback(const QString& rollbackId) const QFileInfo documentInfo(documentPath); const QString destination = documentInfo.absoluteDir().filePath(QStringLiteral("%1-rollback-%2.pdf") - .arg(documentInfo.completeBaseName(), - point->documentRevisionDigest.left(8))); + .arg(documentInfo.completeBaseName().left(32), + QUuid::createUuid().toString(QUuid::WithoutBraces))); // One rollback at a time: a second request while the first is still restoring would // race the same history and publish two revisions from one document state. @@ -3144,7 +3197,6 @@ bool EditorHost::requestFixRollback(const QString& rollbackId) // scheduled exactly like the Action List run. The worker builds its own stores: Qt SQL // connections are thread-affine, so the GUI-thread history opened above must never be // touched off-thread. - const QString historyPath = QDir(historyDirectory).filePath(QStringLiteral("history.sqlite3")); const QString jobId = QUuid::createUuid().toString(QUuid::WithoutBraces); pdf::PDFJobSpec spec; @@ -3165,20 +3217,24 @@ bool EditorHost::requestFixRollback(const QString& rollbackId) const QString submittedId = m_session->scheduler().submit( spec, - [request, historyPath, historyDirectory, destination, outcome](pdf::PDFJobContext& context) + [request, historyDirectory, destination, outcome](pdf::PDFJobContext& context) { if (context.isCancellationRequested()) { return; } - pdf::PDFOperationHistoryStore workerHistory(historyPath); QString openError; + const QString sourcePath = historyDirectory.left(historyDirectory.size() - QStringLiteral(".loop-history").size()); + if (!pdfinteraction::retainHistoryForArtifact(sourcePath, destination, &openError)) + throw std::runtime_error(openError.toStdString()); + const QString outputHistoryDirectory = destination + QStringLiteral(".loop-history"); + pdf::PDFOperationHistoryStore workerHistory(QDir(outputHistoryDirectory).filePath(QStringLiteral("history.sqlite3"))); if (!workerHistory.open(&openError)) { throw std::runtime_error( tr("Recorded revisions are unavailable: %1").arg(openError).toStdString()); } - pdf::PDFArtifactStore artifacts(historyDirectory); + pdf::PDFArtifactStore artifacts(outputHistoryDirectory); const pdf::PDFOperationResult rollback = workerHistory.rollbackTo(request, artifacts, destination); if (!rollback) { @@ -3203,7 +3259,8 @@ bool EditorHost::requestFixRollback(const QString& rollbackId) return false; } - // The rollback is scheduled, not complete: finishRollbackJob announces the outcome. + refreshFeatureAvailability(); + bumpPresentation(); return true; } @@ -3769,6 +3826,30 @@ void EditorHost::registerFeatureHandlers() bumpPresentation(); }; m_session->catalog().setHandler(MoveSelectionCommandId, std::move(move)); + for (const QString& command : { QStringLiteral("actionUndo"), QStringLiteral("actionRedo") }) + { + pdfinteraction::CommandCatalog::Handler navigation; + navigation.invoke = [this, command](pdfinteraction::CommandInvocationId invocation, const QVariantMap& parameters) + { + const QString rollbackId = command == QLatin1String("actionUndo") ? m_historyUndoId : m_historyRedoId; + bool accepted = command == QLatin1String("actionUndo") ? historyUndoAvailable() : historyRedoAvailable(); + if (accepted && parameters.isEmpty()) + { + setWorkspace(LoopWorkspace::Fix); + Q_EMIT historyNavigationRequested(command, rollbackId, m_session->facade().currentRevision().toString()); + } + else if (accepted) + { + accepted = parameters.value(QStringLiteral("rollbackId")).toString() == rollbackId && + parameters.value(QStringLiteral("revision")).toString() == m_session->facade().currentRevision().toString() && + requestFixRollback(rollbackId); + } + m_session->catalog().finishInvocation(invocation, accepted ? pdfinteraction::CommandTerminalState::Completed : pdfinteraction::CommandTerminalState::Failed, + accepted ? QString() : QStringLiteral("history/stale-or-unavailable")); + bumpPresentation(); + }; + m_session->catalog().setHandler(command, std::move(navigation)); + } refreshFeatureAvailability(); } @@ -3786,6 +3867,8 @@ void EditorHost::refreshFeatureAvailability() availability.insert(QStringLiteral("actionFindNext"), hasSearchResults); availability.insert(QStringLiteral("actionFindPrevious"), hasSearchResults); availability.insert(QStringLiteral("actionFullscreenMode"), true); + availability.insert(QStringLiteral("actionUndo"), historyUndoAvailable()); + availability.insert(QStringLiteral("actionRedo"), historyRedoAvailable()); availability.insert(MoveSelectionCommandId, ready && m_session->facade().permissions().allowsCorrection()); m_session->catalog().setEnabledBatch(availability); } @@ -3886,6 +3969,8 @@ void EditorHost::syncDocumentLifecycle() void EditorHost::onDocumentGone() { + m_publishedHistoryPath.clear(); + m_publishedHistoryRevision.clear(); cancelPreflight(); cancelActionList(); if (!m_signatureCheckJobId.isEmpty()) @@ -4142,6 +4227,11 @@ void EditorHost::finishActionListJob(const pdf::PDFJobSnapshot& snapshot) m_actionListController.acceptExecution(snapshot.jobId, snapshot.documentRevision, outcome->executionResult)) { m_session->context().setDocument(outcome->candidate); + m_session->scheduler().setCurrentRevision(m_session->revisionSource()->documentKey(), + m_session->facade().currentRevision().toString()); + m_publishedHistoryPath = outcome->publishedPath; + m_publishedHistoryRevision = outcome->publishedPath.isEmpty() ? QString() + : m_session->facade().currentRevision().toString(); m_preflight.markProfileStale(); syncRevisionModels(); announceDocumentState(tr("Action List applied to the open document.")); @@ -4202,7 +4292,9 @@ void EditorHost::finishRollbackJob(const pdf::PDFJobSnapshot& snapshot) } const QString message = outcome->errorMessage.isEmpty() ? snapshot.errorMessage : outcome->errorMessage; + refreshFeatureAvailability(); announceDocumentState(tr("The document was not rolled back: %1").arg(message)); + bumpPresentation(); } void EditorHost::finishSignatureCheckJob(const pdf::PDFJobSnapshot& snapshot) diff --git a/LoopEditor/editorhost.h b/LoopEditor/editorhost.h index 43c74e04..2a3d7829 100644 --- a/LoopEditor/editorhost.h +++ b/LoopEditor/editorhost.h @@ -138,6 +138,11 @@ class EditorHost final : public QObject Q_PROPERTY(bool pageFidelityIsExact READ pageFidelityIsExact NOTIFY presentationChanged) Q_PROPERTY(QString pageFidelityReason READ pageFidelityReason NOTIFY presentationChanged) Q_PROPERTY(bool pageFidelityIsAuthoritative READ pageFidelityIsAuthoritative NOTIFY presentationChanged) + Q_PROPERTY(QString governedArtifactPath READ governedArtifactPath NOTIFY presentationChanged) + Q_PROPERTY(QVariantList revisionHistory READ revisionHistory NOTIFY presentationChanged) + Q_PROPERTY(QString revisionHistorySummary READ revisionHistorySummary NOTIFY presentationChanged) + Q_PROPERTY(bool historyUndoAvailable READ historyUndoAvailable NOTIFY presentationChanged) + Q_PROPERTY(bool historyRedoAvailable READ historyRedoAvailable NOTIFY presentationChanged) Q_PROPERTY(bool searchPanelVisible READ searchPanelVisible NOTIFY presentationChanged) Q_PROPERTY(bool fullscreenRequested READ fullscreenRequested NOTIFY presentationChanged) Q_PROPERTY(int workspaceRequest READ workspaceRequest NOTIFY presentationChanged) @@ -195,7 +200,7 @@ class EditorHost final : public QObject }; Q_ENUM(LoopWorkspace) - explicit EditorHost(QObject* parent = nullptr); + explicit EditorHost(QObject* parent = nullptr, QString recipesDirectory = QString()); ~EditorHost() override; EditorHost(const EditorHost&) = delete; @@ -307,6 +312,13 @@ class EditorHost final : public QObject /// Recorded rollback points for the open document (read-only). QVariantList fixRollbackPoints() const; + QString governedArtifactPath() const { return historyDocumentPath(); } + Q_INVOKABLE void openGovernedArtifact() { openFileUrl(QUrl::fromLocalFile(historyDocumentPath())); } + QVariantList revisionHistory() const { return m_revisionHistory; } + QString revisionHistorySummary() const; + bool historyUndoAvailable() const; + bool historyRedoAvailable() const; + /// Why the rollback affordance is enabled or unavailable, in the operator's words. QString fixRollbackSummary() const; @@ -503,6 +515,7 @@ class EditorHost final : public QObject const DocumentViewSession* sessionForTest() const noexcept { return m_session.get(); } signals: + void historyNavigationRequested(const QString& commandId, const QString& rollbackId, const QString& revision); void passwordRequestChanged(); /// A completed drag was refused rather than committed (#206). Both values are the /// target's stable names so a surface can explain the refusal. @@ -580,8 +593,9 @@ class EditorHost final : public QObject void clearFixReview(); pdf::PDFActionListExecutionResult fixRunResult() const; QVariantMap fixPreviewFromResult() const; - QList documentRollbackPoints() const; void refreshFixRollbackPointsFromHistory(); + QString historyDocumentPath() const; + QString historyArtifactDigest() const; /// Signature invalidation disclosure (#125). `applies` means the plan's /// merged save policy invalidates signatures and the open document has some. @@ -623,6 +637,13 @@ class EditorHost final : public QObject QString retentionError; }; QHash> m_rollbackOutcomes; + QVariantList m_revisionHistory; + QString m_historyError; + QString m_historyUndoId; + QString m_historyRedoId; + QString m_publishedHistoryPath; + QString m_publishedHistoryRevision; + struct PreflightProfileChoice { QString id; @@ -676,7 +697,6 @@ class EditorHost final : public QObject QString m_fixPlannedDocumentRevision; QString m_fixPlannedPlanDigest; QVariantList m_fixRollbackPoints; - QString m_fixRollbackSummary; QString m_fixSignatureInvalidationAcknowledgedDigest; /// One signature check at a time, fenced by the facade token it began with. diff --git a/LoopEditor/qml/ActionListPane.qml b/LoopEditor/qml/ActionListPane.qml index e5319e85..ff25443c 100644 --- a/LoopEditor/qml/ActionListPane.qml +++ b/LoopEditor/qml/ActionListPane.qml @@ -311,7 +311,7 @@ Pane { GroupBox { objectName: "fixRollbackGroup" Layout.fillWidth: true - title: qsTr("Recorded revisions") + title: qsTr("Revision history") Accessible.name: qsTr("Rollback to a recorded revision") ColumnLayout { @@ -326,6 +326,61 @@ Pane { Accessible.name: qsTr("Rollback availability") } + Label { + Layout.fillWidth: true + wrapMode: Text.Wrap + text: root.host ? root.host.revisionHistorySummary : "" + Accessible.name: qsTr("Governed revision history summary") + } + RowLayout { + Button { + objectName: "historyUndoButton" + text: qsTr("Undo") + enabled: root.host && root.host.historyUndoAvailable + onClicked: root.host.invokeCommand("actionUndo") + Accessible.name: qsTr("Undo by publishing a forward rollback") + } + Button { + objectName: "historyRedoButton" + text: qsTr("Redo") + enabled: root.host && root.host.historyRedoAvailable + onClicked: root.host.invokeCommand("actionRedo") + Accessible.name: qsTr("Redo by publishing a forward rollback") + } + Button { + text: qsTr("Open published artifact") + enabled: root.host && root.host.hasDocument + onClicked: root.host.openGovernedArtifact() + Accessible.description: root.host ? root.host.governedArtifactPath : "" + } + } + Repeater { + model: root.host ? root.host.revisionHistory : [] + delegate: ColumnLayout { + required property var modelData + Layout.fillWidth: true + Label { + Layout.fillWidth: true + wrapMode: Text.Wrap + text: qsTr("Event %1 · %2 · revision %3%4 · approval %5 by %6") + .arg(modelData.sequence).arg(modelData.status) + .arg(modelData.output ? modelData.output.sha256 : modelData.documentRevisionDigest) + .arg(modelData.current ? qsTr(" (current)") : "") + .arg(modelData.approval.decision || qsTr("none")) + .arg(modelData.approval.actorId || qsTr("none")) + Accessible.name: text + } + TextArea { + Layout.fillWidth: true + readOnly: true + selectByMouse: true + wrapMode: TextEdit.Wrap + visible: modelData.receiptText !== "{}" + text: modelData.receiptText + Accessible.name: qsTr("Revision approval and publication receipt") + } + } + } Repeater { model: root.host ? root.host.fixRollbackPoints : [] @@ -346,7 +401,7 @@ Pane { Button { text: qsTr("Return to this revision") - enabled: root.host !== null && root.host.fixRollbackAvailable + enabled: root.host !== null && root.host.fixRollbackAvailable && modelData.canReturn onClicked: { root.pendingRollbackId = modelData.rollbackId rollbackConfirm.open() diff --git a/LoopEditor/qml/Main.qml b/LoopEditor/qml/Main.qml index 25b34b81..543daa6e 100644 --- a/LoopEditor/qml/Main.qml +++ b/LoopEditor/qml/Main.qml @@ -131,6 +131,36 @@ ApplicationWindow { saveAsDialog: saveAsDialog } + Dialog { + id: historyConfirm + objectName: "historyNavigationConfirmDialog" + parent: Overlay.overlay + anchors.centerIn: parent + modal: true + title: commandId === "actionUndo" ? qsTr("Undo published correction") : qsTr("Redo published correction") + standardButtons: Dialog.Ok | Dialog.Cancel + property string commandId: "" + property string rollbackId: "" + property string revision: "" + Label { + width: 360 + wrapMode: Text.Wrap + text: qsTr("Publish a new rollback artifact after revalidation? Existing revisions, approvals and receipts are retained.") + Accessible.name: text + } + onAccepted: if (window.host) window.host.invokeCommand(commandId, {rollbackId: rollbackId, revision: revision}) + } + + Connections { + target: window.host + function onHistoryNavigationRequested(commandId, rollbackId, revision) { + historyConfirm.commandId = commandId + historyConfirm.rollbackId = rollbackId + historyConfirm.revision = revision + historyConfirm.open() + } + } + FileDialog { id: openDialog title: qsTr("Open PDF") diff --git a/LoopLibCore/sources/pdfoperationhistorystore.cpp b/LoopLibCore/sources/pdfoperationhistorystore.cpp index 1150bd74..8248e78f 100644 --- a/LoopLibCore/sources/pdfoperationhistorystore.cpp +++ b/LoopLibCore/sources/pdfoperationhistorystore.cpp @@ -1138,8 +1138,17 @@ PDFOperationResult PDFOperationHistoryStore::resolveRollbackTarget(const PDFRoll query.prepare(QStringLiteral("SELECT a.sha256, a.size_bytes, a.media_type, a.logical_name, a.storage_token FROM history_events h JOIN artifacts a ON a.sha256 = h.output_sha256 JOIN rollback_points p ON p.audit_event_id = h.entry_id WHERE h.execution_id = ? AND h.status = 'accepted' AND h.output_sha256 = ? AND p.artifact_evicted = 0 ORDER BY h.sequence DESC LIMIT 1")); query.addBindValue(request.targetExecutionId.toString(QUuid::WithoutBraces)); query.addBindValue(request.targetArtifactSha256.toLower()); - if (!query.exec() || !query.next()) - return PDFOperationResult(QStringLiteral("Rollback target is not an accepted immutable artifact.")); + if (!query.exec()) + return PDFOperationResult(queryError(query)); + if (!query.next()) + { + query.prepare(QStringLiteral("SELECT a.sha256, a.size_bytes, a.media_type, a.logical_name, a.storage_token FROM artifacts a JOIN rollback_points p ON p.document_revision_digest = a.sha256 JOIN executions e ON e.source_sha256 = a.sha256 JOIN history_events h ON h.execution_id = e.execution_id WHERE e.execution_id = ? AND h.status = 'accepted' AND h.output_sha256 = ? AND a.sha256 = ? AND p.is_original_input = 1 AND p.artifact_evicted = 0 AND a.artifact_evicted = 0 LIMIT 1")); + query.addBindValue(request.targetExecutionId.toString(QUuid::WithoutBraces)); + query.addBindValue(request.currentArtifactSha256.toLower()); + query.addBindValue(request.targetArtifactSha256.toLower()); + if (!query.exec() || !query.next()) + return PDFOperationResult(QStringLiteral("Rollback target is neither an accepted immutable artifact nor the retained original input of the accepted revision being undone.")); + } targetArtifact->sha256 = query.value(0).toString(); targetArtifact->size = query.value(1).toLongLong(); targetArtifact->mediaType = query.value(2).toString(); diff --git a/LoopLibCore/sources/pdfoperationhistorystore.h b/LoopLibCore/sources/pdfoperationhistorystore.h index 5f4c1e75..b4e96b84 100644 --- a/LoopLibCore/sources/pdfoperationhistorystore.h +++ b/LoopLibCore/sources/pdfoperationhistorystore.h @@ -156,7 +156,8 @@ class LOOPLIBCORESHARED_EXPORT PDFOperationHistoryStore const QString& destinationPath, qint64* sequence = nullptr); - /// Resolves a rollback target only when it is the output of an accepted event. + /// Resolves an accepted output, or its retained original input when the + /// request binds that input to the accepted execution being undone. PDFOperationResult resolveRollbackTarget(const PDFRollbackRequest& request, PDFArtifactIdentity* targetArtifact) const; diff --git a/LoopLibInteraction/sources/actionlistcatalog.cpp b/LoopLibInteraction/sources/actionlistcatalog.cpp index 4a916900..5cb5d8d8 100644 --- a/LoopLibInteraction/sources/actionlistcatalog.cpp +++ b/LoopLibInteraction/sources/actionlistcatalog.cpp @@ -23,6 +23,7 @@ #include "actionlistcatalog.h" #include +#include #include #include @@ -61,15 +62,17 @@ const QList& builtInRecipes() } // namespace -ActionListCatalog::ActionListCatalog(QObject* parent) : - QObject(parent) +ActionListCatalog::ActionListCatalog(QObject* parent, QString recipesDirectory) : + QObject(parent), + m_recipesDirectory(std::move(recipesDirectory)) { } QString ActionListCatalog::recipesDirectory() const { - return QDir(QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation)) - .filePath(QStringLiteral("recipes")); + return m_recipesDirectory.isEmpty() + ? QDir(QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation)).filePath(QStringLiteral("recipes")) + : m_recipesDirectory; } bool ActionListCatalog::ensureRecipesDirectory(QString* error) const diff --git a/LoopLibInteraction/sources/actionlistcatalog.h b/LoopLibInteraction/sources/actionlistcatalog.h index 510b3d9f..6388ef28 100644 --- a/LoopLibInteraction/sources/actionlistcatalog.h +++ b/LoopLibInteraction/sources/actionlistcatalog.h @@ -53,7 +53,7 @@ class ActionListCatalog final : public QObject Q_OBJECT public: - explicit ActionListCatalog(QObject* parent = nullptr); + explicit ActionListCatalog(QObject* parent = nullptr, QString recipesDirectory = QString()); const QList& recipes() const noexcept { return m_recipes; } QString recipesDirectory() const; @@ -72,6 +72,7 @@ class ActionListCatalog final : public QObject bool loadRecipeFile(const QString& sourcePath, const QJsonObject& bindings, ActionListRecipeEntry* entry); QList m_recipes; + QString m_recipesDirectory; }; } // namespace pdfinteraction diff --git a/LoopLibInteraction/sources/actionlistrunsubmitter.cpp b/LoopLibInteraction/sources/actionlistrunsubmitter.cpp index ae168d08..54d92f86 100644 --- a/LoopLibInteraction/sources/actionlistrunsubmitter.cpp +++ b/LoopLibInteraction/sources/actionlistrunsubmitter.cpp @@ -25,12 +25,18 @@ #include "pdfdocumentwriter.h" #include "pdfgovernedexecution.h" +#include "pdfartifactstore.h" +#include "pdfoperationhistorystore.h" #include "preflightengine.h" #include "preflightprofileresolver.h" #include #include #include +#include +#include +#include +#include #include #include @@ -119,6 +125,66 @@ QVector validationSteps(const pdf::PDFActionList& namespace pdfinteraction { +bool retainHistoryForArtifact(const QString& sourcePath, const QString& destinationPath, QString* error) +{ + const QString sourceRoot = QFileInfo(sourcePath).absoluteFilePath() + QStringLiteral(".loop-history"); + const QString destinationRoot = QFileInfo(destinationPath).absoluteFilePath() + QStringLiteral(".loop-history"); + const QString databasePath = QDir(sourceRoot).filePath(QStringLiteral("history.sqlite3")); + if (!QFileInfo::exists(databasePath)) + return true; + if (QFileInfo::exists(destinationRoot)) + { + *error = QStringLiteral("The new revision history destination already exists."); + return false; + } + pdf::PDFOperationHistoryStore source(databasePath); + if (!source.open(error)) + return false; + const auto verification = source.verify(); + source.close(); + if (!verification.verified) + { + *error = verification.errorMessage; + return false; + } + if (QFileInfo(databasePath + QStringLiteral("-wal")).size() > 0) + { + *error = QStringLiteral("History is in use; retry when its writer has finished."); + return false; + } + QTemporaryDir staging(QFileInfo(destinationPath).absoluteDir().filePath(QStringLiteral(".loop-history-XXXXXX"))); + if (!staging.isValid()) + { + *error = QStringLiteral("Could not stage the retained history."); + return false; + } + QDirIterator files(sourceRoot, QDir::Files | QDir::Hidden | QDir::NoDotAndDotDot, QDirIterator::Subdirectories); + while (files.hasNext()) + { + const QString file = files.next(); + const QString relative = QDir(sourceRoot).relativeFilePath(file); + if (relative.endsWith(QStringLiteral("-wal")) || relative.endsWith(QStringLiteral("-shm"))) + continue; + const QString target = QDir(staging.path()).filePath(relative); + if (files.fileInfo().isSymLink() || !QDir().mkpath(QFileInfo(target).absolutePath()) || !QFile::copy(file, target)) + { + *error = QStringLiteral("Could not retain history artifact %1.").arg(relative); + return false; + } + } + pdf::PDFOperationHistoryStore snapshot(QDir(staging.path()).filePath(QStringLiteral("history.sqlite3"))); + if (!snapshot.open(error)) + return false; + const auto copied = snapshot.verify(); + snapshot.close(); + if (!copied.verified || !QDir().rename(staging.path(), destinationRoot)) + { + *error = QStringLiteral("The retained history snapshot could not be verified or published."); + return false; + } + return true; +} + ActionListRunWorker makeActionListRunWorker(ActionListRunPhase phase, pdf::PDFActionList actionList, pdf::PDFDocumentPointer document, @@ -127,9 +193,10 @@ ActionListRunWorker makeActionListRunWorker(ActionListRunPhase phase, QString preflightProfilePath, QJsonObject preflightProfile, QJsonObject preflightProfileBindings, + QString publicationSourcePath, bool signatureInvalidationAcknowledged) { - return [phase, actionList = std::move(actionList), document = std::move(document), bindings = std::move(bindings), outcome = std::move(outcome), preflightProfilePath = std::move(preflightProfilePath), preflightProfile = std::move(preflightProfile), preflightProfileBindings = std::move(preflightProfileBindings), signatureInvalidationAcknowledged](pdf::PDFJobContext& context) + return [phase, actionList = std::move(actionList), document = std::move(document), bindings = std::move(bindings), outcome = std::move(outcome), preflightProfilePath = std::move(preflightProfilePath), preflightProfile = std::move(preflightProfile), preflightProfileBindings = std::move(preflightProfileBindings), publicationSourcePath = std::move(publicationSourcePath), signatureInvalidationAcknowledged](pdf::PDFJobContext& context) { if (!outcome || !document) { @@ -243,12 +310,9 @@ ActionListRunWorker makeActionListRunWorker(ActionListRunPhase phase, const QString stagedCandidatePath = stagingDirectory.isValid() ? stagingDirectory.filePath(QStringLiteral("editor-candidate.pdf")) : QString(); QTemporaryDir publicationDirectory; - const QString publicationPath = - publicationDirectory.isValid() ? publicationDirectory.filePath(QStringLiteral("editor-publication.pdf")) : QString(); - // The trusted source path is not known at this layer and the - // publication target is a fresh temporary artifact, so this - // validation cannot refuse today; it keeps the publication - // boundary on the same save contract as the CLI write paths. + const QString publicationPath = publicationSourcePath.isEmpty() + ? publicationDirectory.filePath(QStringLiteral("published.pdf")) + : QFileInfo(publicationSourcePath).absoluteDir().filePath(QStringLiteral("%1-revision-%2.pdf").arg(QFileInfo(publicationSourcePath).completeBaseName().left(32), QUuid::createUuid().toString(QUuid::WithoutBraces))); pdf::PDFSaveRequest publicationRequest; publicationRequest.outputPath = publicationPath; publicationRequest.required = outcome->executionResult.savePolicy; @@ -317,9 +381,6 @@ ActionListRunWorker makeActionListRunWorker(ActionListRunPhase phase, approval.approval.evidenceSha256 = approval.planDigest; approval.approval.decisionReference = QStringLiteral("editor-confirmation:%1").arg(approval.planDigest); approval.approval.decidedUtc = QDateTime::currentDateTimeUtc(); - // The Editor worker has no operation-history store in scope, so - // revocation cannot be resolved here; #37 centralizes execution - // where the chain becomes available. pdf::PDFApprovalAuthorizationContext authorizationContext; authorizationContext.evaluatedUtc = QDateTime::currentDateTimeUtc(); authorizationContext.expectedProfileDigest = expectedProfileDigest; @@ -341,8 +402,50 @@ ActionListRunWorker makeActionListRunWorker(ActionListRunPhase phase, mutation.signOffActor = QStringLiteral("Editor"); mutation.signOffPolicy = QStringLiteral("desktop-postflight"); mutation.operationControl = context.operationControl(); + std::unique_ptr history; + if (!publicationSourcePath.isEmpty()) + { + QFile sourceFile(publicationSourcePath); + if (!sourceFile.open(QIODevice::ReadOnly)) + throw std::runtime_error("The governed source artifact cannot be read."); + const QByteArray sourceBytes = sourceFile.readAll(); + if (QString::fromLatin1(QCryptographicHash::hash(sourceBytes, QCryptographicHash::Sha256).toHex()) != approval.sourceSha256) + throw std::runtime_error("The governed source artifact changed before publication."); + QString historyError; + if (!retainHistoryForArtifact(publicationSourcePath, publicationPath, &historyError)) + throw std::runtime_error(historyError.toStdString()); + const QString historyRoot = publicationPath + QStringLiteral(".loop-history"); + pdf::PDFArtifactStore artifacts(historyRoot); + const auto input = artifacts.importBytes(sourceBytes, { QStringLiteral("application/pdf"), QStringLiteral("source.pdf") }); + const auto output = artifacts.importBytes(candidateData, { QStringLiteral("application/pdf"), QStringLiteral("published.pdf") }); + if (!input.success || !output.success) + throw std::runtime_error((input.success ? output.errorMessage : input.errorMessage).toStdString()); + history = std::make_unique(QDir(historyRoot).filePath(QStringLiteral("history.sqlite3"))); + if (!history->open(&historyError)) + throw std::runtime_error(historyError.toStdString()); + const auto registeredInput = history->rollbackPoints().isEmpty() ? history->registerOriginalInput(input.artifact) + : history->registerArtifact(input.artifact); + if (!registeredInput || !history->registerArtifact(output.artifact)) + throw std::runtime_error("The governed history artifacts could not be registered."); + mutation.history = history.get(); + mutation.authorization.history = history.get(); + mutation.operationId = QStringLiteral("action-list.%1").arg(actionList.id); + mutation.inputArtifact = input.artifact; + mutation.outputArtifact = output.artifact; + mutation.parameters = QJsonObject{ { QStringLiteral("planDigest"), approval.planDigest } }; + mutation.resultSummary = [approval](const pdf::PDFGovernedMutationReceipt& receipt) + { + return QJsonObject{ { QStringLiteral("governed"), QJsonObject{ + { QStringLiteral("approval"), approval.toJson() }, + { QStringLiteral("revalidation"), receipt.revalidation.toJson() }, + { QStringLiteral("sign_off"), receipt.signOff.toJson() }, + { QStringLiteral("receipt"), receipt.toJson() } } } }; + }; + } pdf::PDFGovernedMutationReceipt receipt; const pdf::PDFOperationResult governedResult = pdf::executeGovernedMutation(mutation, &receipt); + if (receipt.isPublished() && !publicationSourcePath.isEmpty()) + outcome->publishedPath = publicationPath; outcome->executionResult.governed = QJsonObject{ { QStringLiteral("approval"), approval.toJson() }, { QStringLiteral("revalidation"), receipt.revalidation.toJson() }, diff --git a/LoopLibInteraction/sources/actionlistrunsubmitter.h b/LoopLibInteraction/sources/actionlistrunsubmitter.h index d5182f1d..bdaac1e1 100644 --- a/LoopLibInteraction/sources/actionlistrunsubmitter.h +++ b/LoopLibInteraction/sources/actionlistrunsubmitter.h @@ -53,6 +53,7 @@ struct ActionListWorkerOutcome QVector validationSteps; pdf::PDFActionListExecutionResult executionResult; pdf::PDFDocumentPointer candidate; + QString publishedPath; }; using ActionListRunWorker = std::function; @@ -65,8 +66,11 @@ ActionListRunWorker makeActionListRunWorker(ActionListRunPhase phase, QString preflightProfilePath = {}, QJsonObject preflightProfile = {}, QJsonObject preflightProfileBindings = {}, + QString publicationSourcePath = {}, bool signatureInvalidationAcknowledged = false); +bool retainHistoryForArtifact(const QString& sourcePath, const QString& destinationPath, QString* error); + } // namespace pdfinteraction #endif // ACTIONLISTRUNSUBMITTER_H diff --git a/UnitTests/tst_actionlisttest.cpp b/UnitTests/tst_actionlisttest.cpp index fc83cc15..a468d582 100644 --- a/UnitTests/tst_actionlisttest.cpp +++ b/UnitTests/tst_actionlisttest.cpp @@ -1371,6 +1371,7 @@ void ActionListTest::signatureInvalidatingExecutionRefusesWithoutAcknowledgement defaultPreflightProfilePath(), QJsonObject(), QJsonObject(), + QString(), acknowledged)(local.context); return outcome; }; diff --git a/UnitTests/tst_documentfacadetest.cpp b/UnitTests/tst_documentfacadetest.cpp index 72906c56..da7cfdf1 100644 --- a/UnitTests/tst_documentfacadetest.cpp +++ b/UnitTests/tst_documentfacadetest.cpp @@ -439,7 +439,7 @@ void DocumentFacadeTest::catalogLoadsTheWholeEditorActionSet() QVERIFY(descriptor.capability != pdfinteraction::CommandCapability::Unclassified); } } - QCOMPARE(implemented, 26); + QCOMPARE(implemented, 28); } void DocumentFacadeTest::catalogPublishesAvailabilityAtomically() diff --git a/UnitTests/tst_editorhosttest.cpp b/UnitTests/tst_editorhosttest.cpp index 51ad11f4..53d8eb50 100644 --- a/UnitTests/tst_editorhosttest.cpp +++ b/UnitTests/tst_editorhosttest.cpp @@ -269,7 +269,12 @@ class EditorHostTest : public QObject { Q_OBJECT +private: + std::unique_ptr m_fixtureRecipes; + private slots: + void init(); + void cleanup(); void encryptedOpen_data(); void encryptedOpen(); void passwordFailureIsTypedAndRetryable(); @@ -313,6 +318,18 @@ private slots: void refusedRenderCannotBecomeAuthoritativeEvidence(); }; +void EditorHostTest::init() +{ + QStandardPaths::setTestModeEnabled(true); + m_fixtureRecipes = std::make_unique(); + QVERIFY(m_fixtureRecipes->isValid()); +} + +void EditorHostTest::cleanup() +{ + m_fixtureRecipes.reset(); +} + void EditorHostTest::encryptedOpen_data() { QTest::addColumn("algorithm"); @@ -337,7 +354,7 @@ void EditorHostTest::encryptedOpen() QTemporaryDir directory; const QString path = directory.filePath(QStringLiteral("encrypted.pdf")); QVERIFY(writeEncryptedFixture(path, pdf::PDFSecurityHandlerFactory::Algorithm(algorithm), QStringLiteral("fixture-user"), userPermissions)); - EditorHost host; + EditorHost host(nullptr, m_fixtureRecipes->path()); host.openFileUrl(QUrl::fromLocalFile(path)); QTRY_VERIFY_WITH_TIMEOUT(host.passwordRequestId() != 0, 10000); const quint64 request = host.passwordRequestId(); @@ -374,7 +391,7 @@ void EditorHostTest::passwordFailureIsTypedAndRetryable() QTemporaryDir directory; const QString path = directory.filePath(QStringLiteral("wrong-password-AES-256.pdf")); QVERIFY(writeEncryptedFixture(path, pdf::PDFSecurityHandlerFactory::AES_256)); - EditorHost host; + EditorHost host(nullptr, m_fixtureRecipes->path()); host.openFileUrl(QUrl::fromLocalFile(path)); QTRY_VERIFY_WITH_TIMEOUT(host.passwordRequestId() != 0, 10000); QVERIFY(host.submitPassword(host.passwordRequestId(), QStringLiteral("incorrect-fixture-password"))); @@ -393,7 +410,7 @@ void EditorHostTest::passwordCancelCloseAndSupersessionAreTerminal() QTemporaryDir directory; const QString path = directory.filePath(QStringLiteral("cancelled-AES-256.pdf")); QVERIFY(writeEncryptedFixture(path, pdf::PDFSecurityHandlerFactory::AES_256)); - EditorHost host; + EditorHost host(nullptr, m_fixtureRecipes->path()); host.openFileUrl(QUrl::fromLocalFile(path)); QTRY_VERIFY_WITH_TIMEOUT(host.passwordRequestId() != 0, 10000); QVERIFY(host.cancelPassword(host.passwordRequestId())); @@ -427,7 +444,7 @@ void EditorHostTest::teardownWhilePasswordPromptIsPending() QTemporaryDir directory; const QString path = directory.filePath(QStringLiteral("teardown-AES-256.pdf")); QVERIFY(writeEncryptedFixture(path, pdf::PDFSecurityHandlerFactory::AES_256)); - auto host = std::make_unique(); + auto host = std::make_unique(nullptr, m_fixtureRecipes->path()); host->openFileUrl(QUrl::fromLocalFile(path)); QTRY_VERIFY_WITH_TIMEOUT(host->passwordRequestId() != 0, 10000); QElapsedTimer timer; @@ -448,7 +465,7 @@ void EditorHostTest::restrictedDocumentCannotPlanOrPublish() QVERIFY(source.open(QIODevice::ReadOnly)); const QByteArray original = source.readAll(); source.close(); - EditorHost host; + EditorHost host(nullptr, m_fixtureRecipes->path()); host.openFileUrl(QUrl::fromLocalFile(path)); QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 10000); QCOMPARE(host.passwordRequestId(), quint64(0)); @@ -498,7 +515,7 @@ void EditorHostTest::encryptedInspectionReceiptContainsNoPassword() QTemporaryDir directory; const QString path = directory.filePath(QStringLiteral("inspection-AES-256.pdf")); QVERIFY(writeEncryptedFixture(path, pdf::PDFSecurityHandlerFactory::AES_256)); - EditorHost host; + EditorHost host(nullptr, m_fixtureRecipes->path()); host.openFileUrl(QUrl::fromLocalFile(path)); QTRY_VERIFY_WITH_TIMEOUT(host.passwordRequestId() != 0, 10000); QVERIFY(host.submitPassword(host.passwordRequestId(), QStringLiteral("fixture-user"))); @@ -534,7 +551,7 @@ void EditorHostTest::teardownClearsTheInteractiveThreadRegistration() { QVERIFY(!pdf::PDFBlockingThreadGuard::isInteractiveThreadRegistered()); { - EditorHost host; + EditorHost host(nullptr, m_fixtureRecipes->path()); QVERIFY(pdf::PDFBlockingThreadGuard::isInteractiveThreadRegistered()); QVERIFY(pdf::PDFBlockingThreadGuard::isCurrentThreadInteractive()); } @@ -546,7 +563,7 @@ void EditorHostTest::teardownClearsTheInteractiveThreadRegistration() void EditorHostTest::startsWithNoDocument() { - EditorHost host; + EditorHost host(nullptr, m_fixtureRecipes->path()); QCOMPARE(host.documentState(), QStringLiteral("empty")); QVERIFY(!host.hasDocument()); QCOMPARE(host.pageCount(), 0); @@ -565,7 +582,7 @@ void EditorHostTest::startsWithNoDocument() void EditorHostTest::exposesCatalogDescriptorsWithoutMutating() { - EditorHost host; + EditorHost host(nullptr, m_fixtureRecipes->path()); const QVariantList descriptors = host.commandDescriptors(); QVERIFY(descriptors.size() > 100); @@ -585,7 +602,7 @@ void EditorHostTest::exposesCatalogDescriptorsWithoutMutating() void EditorHostTest::navigationCommandsStayDisabledUntilOpen() { - EditorHost host; + EditorHost host(nullptr, m_fixtureRecipes->path()); QVERIFY(!host.isCommandEnabled(QStringLiteral("actionGoToNextPage"))); QCOMPARE(host.invokeCommand(QStringLiteral("actionGoToNextPage")), quint64(0)); } @@ -634,7 +651,7 @@ void EditorHostTest::preflightRunsOffInteractiveThread() QVERIFY(writer.write(path, &document, true)); } - EditorHost host; + EditorHost host(nullptr, m_fixtureRecipes->path()); host.openFileUrl(QUrl::fromLocalFile(path)); QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 15000); QVERIFY(host.runPreflight()); @@ -673,7 +690,7 @@ void EditorHostTest::preflightStateVisualIsNotCheckedBeforeARun() QVERIFY(writer.write(path, &document, true)); } - EditorHost host; + EditorHost host(nullptr, m_fixtureRecipes->path()); host.openFileUrl(QUrl::fromLocalFile(path)); QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 15000); @@ -775,7 +792,7 @@ void EditorHostTest::exportedPreflightReportMatchesPdfToolForTheSameInputs() // 2. The GUI's report, through the export path the operator uses: // EditorHost::exportPreflightReportFileUrl -> PreflightController::serializedReport. - EditorHost host; + EditorHost host(nullptr, m_fixtureRecipes->path()); host.openFileUrl(QUrl::fromLocalFile(documentPath)); QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 30000); @@ -866,7 +883,7 @@ void EditorHostTest::restrictedPreflightReportMatchesPdfTool() QCOMPARE(cliScope.value(QStringLiteral("pages")).toArray(), QJsonArray{ 1 }); QCOMPARE(cliScope.value(QStringLiteral("object_classes")).toArray(), QJsonArray{ QStringLiteral("image") }); - EditorHost host; + EditorHost host(nullptr, m_fixtureRecipes->path()); QVERIFY(host.importPreflightProfileFileUrl(QUrl::fromLocalFile(profilePath))); host.openFileUrl(QUrl::fromLocalFile(documentPath)); QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 30000); @@ -908,7 +925,6 @@ void EditorHostTest::importValidProfileAddsDigest() { QTemporaryDir temp; QVERIFY(temp.isValid()); - QStandardPaths::setTestModeEnabled(true); pdf::PDFSettings::setSettingsPath(temp.path()); pdf::initializeApplicationIdentity(pdf::PDFApplicationSurface::LoopEditor); @@ -919,7 +935,7 @@ void EditorHostTest::importValidProfileAddsDigest() const QString expectedDigest = profile.value(QStringLiteral("digest")).toString(); QVERIFY(!expectedDigest.isEmpty()); - EditorHost host; + EditorHost host(nullptr, m_fixtureRecipes->path()); QVERIFY(host.importPreflightProfileFileUrl(QUrl::fromLocalFile(sourcePath))); const QString expectedId = QDir(QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation)) @@ -957,7 +973,7 @@ void EditorHostTest::importDigestMismatchProfileIsRejected() QVERIFY(sourceFile.open(QIODevice::WriteOnly)); sourceFile.write(QJsonDocument(profile).toJson(QJsonDocument::Compact)); - EditorHost host; + EditorHost host(nullptr, m_fixtureRecipes->path()); QVERIFY(!host.importPreflightProfileFileUrl(QUrl::fromLocalFile(sourcePath))); } @@ -966,7 +982,7 @@ void EditorHostTest::saveProfileForkRecordsDerivedFrom() QTemporaryDir temp; QVERIFY(temp.isValid()); - EditorHost host; + EditorHost host(nullptr, m_fixtureRecipes->path()); QString parentDigest; for (const QVariant& entry : host.preflightProfiles()) { @@ -1020,7 +1036,7 @@ void EditorHostTest::openLargeDocument() QElapsedTimer openTimer; openTimer.start(); - EditorHost host; + EditorHost host(nullptr, m_fixtureRecipes->path()); host.openFileUrl(QUrl::fromLocalFile(path)); QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 90000); const qint64 openToFirstViewMs = openTimer.elapsed(); @@ -1211,7 +1227,7 @@ void EditorHostTest::openLargeDocument() void EditorHostTest::fixWorkspacePresentsIdleLifecycleAndRefusesToArm() { - EditorHost host; + EditorHost host(nullptr, m_fixtureRecipes->path()); QCOMPARE(host.fixLifecycleStateName(), QStringLiteral("idle")); QVERIFY(!host.fixLifecycleSummary().trimmed().isEmpty()); @@ -1279,7 +1295,7 @@ void EditorHostTest::fixReviewBindsToThePlannedDigestAndTheCurrentRevision() recipe.close(); } - EditorHost host; + EditorHost host(nullptr, m_fixtureRecipes->path()); QVERIFY(host.importActionListRecipe(QUrl::fromLocalFile(recipePath))); // The catalog identifies a recipe by the file that defines it, and importing selects it. QVERIFY(!host.selectedActionListRecipeId().isEmpty()); @@ -1392,7 +1408,7 @@ void EditorHostTest::moveSelectionProposesAPageBoxMoveInTheFixWorkspace() recipe.close(); } - EditorHost host; + EditorHost host(nullptr, m_fixtureRecipes->path()); QVERIFY(!host.isCommandEnabled(commandId)); QCOMPARE(host.invokeCommand(commandId, moveParameters(QStringLiteral("PageBox"), 4.0, 5.0)), quint64(0)); @@ -1426,20 +1442,6 @@ QString shippedTranslatePageBoxRecipeId() return QStringLiteral(":/loop/builtin-recipe-translate-page-box.json"); } -// The operator recipe directory is shared by every test in the process, so each recipe test -// starts from an empty one; the shipped recipe is compiled in and is unaffected. -void clearOperatorActionListRecipes() -{ - QStandardPaths::setTestModeEnabled(true); - QDir(QDir(QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation)).filePath(QStringLiteral("recipes"))) - .removeRecursively(); -} - -QString recipesDirectory() -{ - return QDir(QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation)).filePath(QStringLiteral("recipes")); -} - bool writeTranslatePageBoxRecipe(const QString& path, const QString& id, const QJsonValue& pageIndex) { QFile recipe(path); @@ -1603,9 +1605,7 @@ bool writeSignatureAppendedCopy(const QString& sourcePath, const QString& destin void EditorHostTest::shippedRecipeIsListedButNeverSelectedByDefault() { - clearOperatorActionListRecipes(); - - EditorHost host; + EditorHost host(nullptr, m_fixtureRecipes->path()); bool listed = false; for (const QVariant& entry : host.actionListRecipes()) { @@ -1623,7 +1623,6 @@ void EditorHostTest::shippedRecipeIsListedButNeverSelectedByDefault() void EditorHostTest::shippedPageBoxRecipeRunsDefaultMoveThroughPlanApprovalAndRevalidation() { - clearOperatorActionListRecipes(); QTemporaryDir directory; QVERIFY(directory.isValid()); @@ -1641,7 +1640,7 @@ void EditorHostTest::shippedPageBoxRecipeRunsDefaultMoveThroughPlanApprovalAndRe const QByteArray sourceDigest = fileDigest(documentPath); QVERIFY(!sourceDigest.isEmpty()); - EditorHost host; + EditorHost host(nullptr, m_fixtureRecipes->path()); host.openFileUrl(QUrl::fromLocalFile(documentPath)); QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 15000); @@ -1682,7 +1681,6 @@ void EditorHostTest::shippedPageBoxRecipeRunsDefaultMoveThroughPlanApprovalAndRe void EditorHostTest::operatorRecipeWinsOverShippedRecipe() { - clearOperatorActionListRecipes(); QTemporaryDir directory; QVERIFY(directory.isValid()); @@ -1691,7 +1689,7 @@ void EditorHostTest::operatorRecipeWinsOverShippedRecipe() const QString recipePath = directory.filePath(QStringLiteral("operator-move.json")); QVERIFY(writeTranslatePageBoxRecipe(recipePath, QStringLiteral("operator-move"), 0)); - EditorHost host; + EditorHost host(nullptr, m_fixtureRecipes->path()); QVERIFY(host.importActionListRecipe(QUrl::fromLocalFile(recipePath))); host.openFileUrl(QUrl::fromLocalFile(documentPath)); QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 15000); @@ -1711,7 +1709,6 @@ void EditorHostTest::operatorRecipeWinsOverShippedRecipe() void EditorHostTest::invalidOperatorRecipeStopsMoveWithoutShippedFallback() { - clearOperatorActionListRecipes(); QTemporaryDir directory; QVERIFY(directory.isValid()); @@ -1719,11 +1716,10 @@ void EditorHostTest::invalidOperatorRecipeStopsMoveWithoutShippedFallback() QVERIFY(writeTrimmedPageDocument(documentPath)); // The operator recipe offers the operation with a page index the schema refuses, so it is // invalid. It sits in the recipes directory before the host starts, as an imported copy would. - QVERIFY(QDir().mkpath(recipesDirectory())); - const QString brokenPath = QDir(recipesDirectory()).filePath(QStringLiteral("broken-move.json")); + const QString brokenPath = QDir(m_fixtureRecipes->path()).filePath(QStringLiteral("broken-move.json")); QVERIFY(writeTranslatePageBoxRecipe(brokenPath, QStringLiteral("broken-move"), QStringLiteral("first"))); - EditorHost host; + EditorHost host(nullptr, m_fixtureRecipes->path()); host.openFileUrl(QUrl::fromLocalFile(documentPath)); QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 15000); @@ -1765,7 +1761,7 @@ void EditorHostTest::completedDragOfARefusedKindIsReportedAndChangesNothing() QVERIFY(writer.write(documentPath, &document, true)); } - EditorHost host; + EditorHost host(nullptr, m_fixtureRecipes->path()); host.openFileUrl(QUrl::fromLocalFile(documentPath)); QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 15000); DocumentViewSession* session = host.sessionForTest(); @@ -1859,7 +1855,7 @@ void EditorHostTest::fixJourneyPublishesOnlyAnApprovedPlanBoundToTheDisplayedIde const QByteArray sourceDigest = fileDigest(documentPath); QVERIFY(!sourceDigest.isEmpty()); - EditorHost host; + EditorHost host(nullptr, m_fixtureRecipes->path()); QVERIFY(host.importActionListRecipe(QUrl::fromLocalFile(recipePath))); host.openFileUrl(QUrl::fromLocalFile(documentPath)); QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 15000); @@ -1947,9 +1943,7 @@ void EditorHostTest::fixJourneyPublishesOnlyAnApprovedPlanBoundToTheDisplayedIde QCOMPARE(host.fixLifecycleStateName(), QStringLiteral("succeeded")); // Completion presents the revalidated result and the artifact Core signed, distinct - // from the as-received input and bound to the plan the operator approved. The run - // presents the published bytes; materializing them as a file is the export slice, so - // this asserts the presentation rather than a filesystem write. + // from the as-received input and bound to the plan the operator approved. const QVariantMap recheck = host.fixRecheck(); QVERIFY(recheck.value(QStringLiteral("available")).toBool()); const QString verdictState = recheck.value(QStringLiteral("verdictState")).toString(); @@ -1970,8 +1964,140 @@ void EditorHostTest::fixJourneyPublishesOnlyAnApprovedPlanBoundToTheDisplayedIde QVERIFY2(host.fixLifecycleSummary().contains(QStringLiteral("published")), qPrintable(host.fixLifecycleSummary())); - // The as-received input is never the publication target. QCOMPARE(fileDigest(documentPath), sourceDigest); + const QString publishedPath = host.governedArtifactPath(); + QVERIFY(publishedPath != documentPath); + QCOMPARE(QString::fromLatin1(fileDigest(publishedPath)), publishedSha256); + QVERIFY(host.historyUndoAvailable()); + QVERIFY(!host.historyRedoAvailable()); + const QVariantList recorded = host.revisionHistory(); + QVERIFY(!recorded.isEmpty()); + QVERIFY(std::any_of(recorded.cbegin(), recorded.cend(), [&approvedDigest](const QVariant& value) + { return value.toMap().value(QStringLiteral("receiptText")).toString().contains(approvedDigest); })); + const QString beforeRevision = host.sessionForTest()->facade().currentRevision().toString(); + host.invokeCommand(QStringLiteral("actionZoom_In")); + host.setWorkspace(EditorHost::LoopWorkspace::Inspect); + QVERIFY(host.sessionForTest()->interaction()); + host.sessionForTest()->interaction()->selectTarget(makeFindingTarget(QStringLiteral("view-only-selection"), QRectF(0, 0, 20, 20))); + QCOMPARE(host.sessionForTest()->facade().currentRevision().toString(), beforeRevision); + QCOMPARE(host.revisionHistory(), recorded); + QSignalSpy confirmation(&host, &EditorHost::historyNavigationRequested); + host.invokeCommand(QStringLiteral("actionUndo")); + QCOMPARE(confirmation.count(), 1); + const QList undoIntent = confirmation.takeFirst(); + const QVariantMap undoParameters{ { QStringLiteral("rollbackId"), undoIntent[1] }, + { QStringLiteral("revision"), undoIntent[2] } }; + host.invokeCommand(QStringLiteral("actionUndo"), QVariantMap{ { QStringLiteral("rollbackId"), undoIntent[1] }, + { QStringLiteral("revision"), QStringLiteral("stale") } }); + QCOMPARE(host.governedArtifactPath(), publishedPath); + bool historyJobFinished = false; + QObject::connect(&host.sessionForTest()->scheduler(), &pdf::PDFJobScheduler::jobFinished, &host, + [&historyJobFinished](const pdf::PDFJobSnapshot& snapshot) + { if (snapshot.operationId.startsWith(QStringLiteral("rollback."))) historyJobFinished = true; }); + host.invokeCommand(QStringLiteral("actionUndo"), undoParameters); + QTRY_VERIFY_WITH_TIMEOUT(historyJobFinished, 15000); + QCOMPARE(host.governedArtifactPath(), publishedPath); + QCOMPARE(QString::fromLatin1(fileDigest(publishedPath)), publishedSha256); + QCOMPARE(host.revisionHistory(), recorded); + const QString historyProfilePath = directory.filePath(QStringLiteral("history-profile.json")); + QFile historyProfile(historyProfilePath); + QVERIFY(historyProfile.open(QIODevice::WriteOnly)); + historyProfile.write(QJsonDocument(QJsonObject{ + { QStringLiteral("schema_version"), 1 }, { QStringLiteral("id"), QStringLiteral("history-fixture") }, { QStringLiteral("version"), QStringLiteral("1.0.0") }, { QStringLiteral("name"), QStringLiteral("History fixture") }, { QStringLiteral("checks"), QJsonArray{ QJsonObject{ { QStringLiteral("id"), QStringLiteral("embedded-fonts") }, { QStringLiteral("severity"), QStringLiteral("error") } } } } }) + .toJson()); + historyProfile.close(); + QVERIFY(host.importPreflightProfileFileUrl(QUrl::fromLocalFile(historyProfilePath))); + historyJobFinished = false; + host.invokeCommand(QStringLiteral("actionUndo"), undoParameters); + QTRY_VERIFY_WITH_TIMEOUT(historyJobFinished, 15000); + QTRY_VERIFY_WITH_TIMEOUT(host.sessionForTest()->facade().source().path != documentPath && host.hasDocument(), 15000); + const QString undoPath = host.sessionForTest()->facade().source().path; + QCOMPARE(fileDigest(undoPath), sourceDigest); + QCOMPARE(QString::fromLatin1(fileDigest(publishedPath)), publishedSha256); + QVERIFY(!host.historyUndoAvailable()); + QVERIFY(host.historyRedoAvailable()); + host.reopenDocument(); + QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 15000); + QVERIFY(host.historyRedoAvailable()); + host.invokeCommand(QStringLiteral("actionRedo")); + QCOMPARE(confirmation.count(), 1); + const QList redoIntent = confirmation.takeFirst(); + host.invokeCommand(QStringLiteral("actionRedo"), QVariantMap{ { QStringLiteral("rollbackId"), redoIntent[1] }, + { QStringLiteral("revision"), redoIntent[2] } }); + QTRY_VERIFY_WITH_TIMEOUT(host.sessionForTest()->facade().source().path != undoPath && host.hasDocument(), 60000); + const QString redoPath = host.sessionForTest()->facade().source().path; + QCOMPARE(QString::fromLatin1(fileDigest(redoPath)), publishedSha256); + QVERIFY(host.historyUndoAvailable()); + QVERIFY(!host.historyRedoAvailable()); + host.reopenDocument(); + QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 15000); + QVERIFY(host.historyUndoAvailable()); + QVERIFY(!host.historyRedoAvailable()); + pdf::PDFOperationHistoryStore persisted(redoPath + QStringLiteral(".loop-history/history.sqlite3")); + QVERIFY(persisted.open()); + QVERIFY(persisted.verify().verified); + const auto reopenedEvents = persisted.events(); + QVERIFY(std::any_of(reopenedEvents.cbegin(), reopenedEvents.cend(), [&approvedDigest](const auto& event) + { return event.status == pdf::PDFOperationHistoryStatus::Accepted && + event.resultSummary.value(QStringLiteral("governed")).toObject().value(QStringLiteral("receipt")).toObject().value(QStringLiteral("plan_digest")).toString() == approvedDigest; })); + persisted.close(); + int correctionNumber = 0; + const auto publishAnotherCorrection = [&]() + { + QFile nextRecipe(recipePath); + if (!nextRecipe.open(QIODevice::WriteOnly)) + return false; + nextRecipe.write(QJsonDocument(QJsonObject{ + { QStringLiteral("schema"), QStringLiteral("loop-action-list/2") }, + { QStringLiteral("id"), QStringLiteral("history-next-%1").arg(++correctionNumber) }, + { QStringLiteral("name"), QStringLiteral("Next bleed correction") }, + { QStringLiteral("steps"), QJsonArray{ QJsonObject{ + { QStringLiteral("id"), QStringLiteral("bleed") }, { QStringLiteral("operation"), QStringLiteral("add-bleed") }, { QStringLiteral("params"), QJsonObject{ { QStringLiteral("bleed_mm"), 5 + correctionNumber }, { QStringLiteral("mode"), QStringLiteral("mirror") }, { QStringLiteral("force"), true } } } } } } }) + .toJson()); + nextRecipe.close(); + if (!host.importActionListRecipe(QUrl::fromLocalFile(recipePath)) || !host.validateActionListRecipe() || + !QTest::qWaitFor([&] + { return host.actionList()->property("validationReady").toBool(); }, 15000) || + !host.planActionList() || !QTest::qWaitFor([&] + { return host.fixLifecycleStateName() == QStringLiteral("preview-ready"); }, 15000) || + !host.approveActionListPlan() || !host.executeApprovedActionListPlan()) + return false; + return QTest::qWaitFor([&] + { return host.fixLifecycleStateName() != QStringLiteral("executing"); }, 30000) && + host.fixLifecycleStateName() == QStringLiteral("succeeded"); + }; + const auto navigateHistory = [&](const QString& command, const QByteArray& expected) + { + const QString previousPath = host.sessionForTest()->facade().source().path; + confirmation.clear(); + host.invokeCommand(command); + if (confirmation.count() != 1) + return false; + const auto intent = confirmation.takeFirst(); + host.invokeCommand(command, QVariantMap{ { QStringLiteral("rollbackId"), intent[1] }, { QStringLiteral("revision"), intent[2] } }); + return QTest::qWaitFor([&] + { return host.hasDocument() && host.sessionForTest()->facade().source().path != previousPath; }, 15000) && + fileDigest(host.governedArtifactPath()) == expected; + }; + QVERIFY2(publishAnotherCorrection(), qPrintable(host.fixLifecycleSummary() + QStringLiteral(" ") + host.actionList()->property("operatorSummary").toString())); + const QByteArray secondDigest = fileDigest(host.governedArtifactPath()); + QVERIFY(secondDigest != publishedSha256.toLatin1()); + QVERIFY(navigateHistory(QStringLiteral("actionUndo"), publishedSha256.toLatin1())); + QVERIFY(navigateHistory(QStringLiteral("actionUndo"), sourceDigest)); + QVERIFY(navigateHistory(QStringLiteral("actionRedo"), publishedSha256.toLatin1())); + QVERIFY(host.historyRedoAvailable()); + host.reopenDocument(); + QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 15000); + QVERIFY(navigateHistory(QStringLiteral("actionRedo"), secondDigest)); + QVERIFY(!host.historyRedoAvailable()); + QVERIFY(navigateHistory(QStringLiteral("actionUndo"), publishedSha256.toLatin1())); + QVERIFY(host.historyRedoAvailable()); + QVERIFY2(publishAnotherCorrection(), qPrintable(host.fixLifecycleSummary() + QStringLiteral(" ") + host.actionList()->property("operatorSummary").toString())); + QVERIFY(!host.historyRedoAvailable()); + QCOMPARE(QString::fromLatin1(fileDigest(publishedPath)), publishedSha256); + qInfo().noquote() << "editor-history-fixture" << "source=" << sourceDigest + << "published=" << publishedSha256 << "undo=" << fileDigest(undoPath) + << "redo=" << fileDigest(redoPath) << "events=" << reopenedEvents.size(); } void EditorHostTest::translatePageBoxRefusesAMoveThatIntroducesABlockingFinding() @@ -2012,7 +2138,7 @@ void EditorHostTest::translatePageBoxRefusesAMoveThatIntroducesABlockingFinding( recipe.close(); } - EditorHost host; + EditorHost host(nullptr, m_fixtureRecipes->path()); host.openFileUrl(QUrl::fromLocalFile(documentPath)); QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 15000); QVERIFY(host.importActionListRecipe(QUrl::fromLocalFile(recipePath))); @@ -2075,7 +2201,7 @@ void EditorHostTest::fixRollbackReturnsToARecordedRevision() QCOMPARE(process.exitCode(), 0); } - EditorHost host; + EditorHost host(nullptr, m_fixtureRecipes->path()); host.openFileUrl(QUrl::fromLocalFile(repairedPath)); QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 15000); @@ -2084,15 +2210,15 @@ void EditorHostTest::fixRollbackReturnsToARecordedRevision() QVERIFY(host.fixRollbackAvailable()); QVERIFY(!host.fixRollbackSummary().trimmed().isEmpty()); - // Only revisions Core can actually return to are offered: the as-received input stays a - // protected retention point and is never presented as a selectable revision. for (const QVariant& point : points) { - QCOMPARE(point.toMap().value(QStringLiteral("isOriginalInput")).toBool(), false); QVERIFY(!point.toMap().value(QStringLiteral("rollbackId")).toString().isEmpty()); QVERIFY(!point.toMap().value(QStringLiteral("documentRevisionDigest")).toString().isEmpty()); } - const QString rollbackId = points.first().toMap().value(QStringLiteral("rollbackId")).toString(); + const auto acceptedPoint = std::find_if(points.cbegin(), points.cend(), [](const QVariant& point) + { return !point.toMap().value(QStringLiteral("isOriginalInput")).toBool(); }); + QVERIFY(acceptedPoint != points.cend()); + const QString rollbackId = acceptedPoint->toMap().value(QStringLiteral("rollbackId")).toString(); // An unknown revision is refused before anything is written, and the refusal writes no // revision. (A rollback without a validated profile is refused the same way; Core's @@ -2139,9 +2265,8 @@ void EditorHostTest::fixRollbackReturnsToARecordedRevision() QVERIFY(QFile::exists(sourcePath)); // Core recorded the rollback as a new event and left the existing history intact. - const QString historyPath = - QDir(QFileInfo(repairedPath).absoluteFilePath() + QStringLiteral(".loop-history")) - .filePath(QStringLiteral("history.sqlite3")); + QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument() && host.sessionForTest()->facade().source().path != repairedPath, 15000); + const QString historyPath = host.sessionForTest()->facade().source().path + QStringLiteral(".loop-history/history.sqlite3"); pdf::PDFOperationHistoryStore history(historyPath); QString historyError; QVERIFY2(history.open(&historyError), qPrintable(historyError)); @@ -2183,7 +2308,7 @@ void EditorHostTest::preflightFencesCompletionsThatLostTheirRequestIdentity() QVERIFY(writer.write(path, &document, true)); } - EditorHost host; + EditorHost host(nullptr, m_fixtureRecipes->path()); host.openFileUrl(QUrl::fromLocalFile(path)); QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 15000); @@ -2296,7 +2421,7 @@ void EditorHostTest::actionListFencesCompletionsThatLostTheirRequestIdentity() recipe.close(); } - EditorHost host; + EditorHost host(nullptr, m_fixtureRecipes->path()); QVERIFY(host.importActionListRecipe(QUrl::fromLocalFile(recipePath))); host.openFileUrl(QUrl::fromLocalFile(documentPath)); QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 15000); @@ -2585,7 +2710,7 @@ void EditorHostTest::previewFidelityNamesTheOriginAndSwitchesExplicitly() QVERIFY(writer.write(path, &document, true)); } - EditorHost host; + EditorHost host(nullptr, m_fixtureRecipes->path()); host.openFileUrl(QUrl::fromLocalFile(path)); QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 15000); QCOMPARE(host.previewFidelityStateName(), QStringLiteral("unavailable")); @@ -2610,7 +2735,7 @@ void EditorHostTest::previewFidelityNamesTheOriginAndSwitchesExplicitly() preflightFixturesDir() + QStringLiteral("/overprint-cmyk-mode1-on.pdf"); QVERIFY2(QFileInfo::exists(overprintPath), qPrintable(overprintPath)); - EditorHost overprintHost; + EditorHost overprintHost(nullptr, m_fixtureRecipes->path()); overprintHost.openFileUrl(QUrl::fromLocalFile(overprintPath)); QTRY_VERIFY_WITH_TIMEOUT(overprintHost.hasDocument(), 30000); overprintHost.setViewportGeometry(96.0 / 25.4, 1.0, 1024, 768); @@ -2635,7 +2760,7 @@ void EditorHostTest::previewFidelityNamesTheOriginAndSwitchesExplicitly() void EditorHostTest::refusedRenderCannotBecomeAuthoritativeEvidence() { - EditorHost host; + EditorHost host(nullptr, m_fixtureRecipes->path()); host.openFileUrl(QUrl::fromLocalFile(preflightFixturesDir() + QStringLiteral("/overprint-cmyk-mode1-on.pdf"))); QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 30000); auto* surfaces = host.sessionForTest()->surfaces(); @@ -2701,7 +2826,7 @@ void EditorHostTest::executeApprovedActionListPlanRefusesAnUnreviewedPlan() const QByteArray sourceDigest = fileDigest(documentPath); QVERIFY(!sourceDigest.isEmpty()); - EditorHost host; + EditorHost host(nullptr, m_fixtureRecipes->path()); QVERIFY(host.importActionListRecipe(QUrl::fromLocalFile(recipePath))); host.openFileUrl(QUrl::fromLocalFile(documentPath)); QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 15000); diff --git a/UnitTests/tst_operationhistorytest.cpp b/UnitTests/tst_operationhistorytest.cpp index 30cef003..c59b8516 100644 --- a/UnitTests/tst_operationhistorytest.cpp +++ b/UnitTests/tst_operationhistorytest.cpp @@ -104,6 +104,7 @@ private slots: void rollbackPointsRetentionAndAtomicity(); void rollbackRevalidatesAndRecordsGovernedReceipt(); void rollbackRefusesWithoutProfileOrOnCompromisedChain(); + void rollbackToOriginalRequiresTheAcceptedExecutionBeingUndone(); void externalPayloadTamperingCompromisesChain(); void provenanceKindsRoundTripAndMiddleDeletionCompromisesChain(); void schemaVersionPersistsAcrossReopen(); @@ -1583,5 +1584,75 @@ void OperationHistoryTest::unreadableOrCompromisedApprovalHistoryRefusesPublicat QVERIFY(!QFile::exists(outputPath)); } +void OperationHistoryTest::rollbackToOriginalRequiresTheAcceptedExecutionBeingUndone() +{ + QTemporaryDir directory; + QVERIFY(directory.isValid()); + pdf::PDFArtifactStore artifacts(directory.path()); + const QByteArray originalBytes = minimalPdfBytes(200, 200); + const QByteArray correctedBytes = minimalPdfBytes(220, 220); + const auto original = artifacts.importBytes(originalBytes, { QStringLiteral("application/pdf"), QStringLiteral("original.pdf") }); + const auto corrected = artifacts.importBytes(correctedBytes, { QStringLiteral("application/pdf"), QStringLiteral("corrected.pdf") }); + QVERIFY(original.success && corrected.success); + const QString databasePath = directory.filePath(QStringLiteral("history.sqlite3")); + pdf::PDFOperationHistoryStore history(databasePath); + QVERIFY(history.open()); + QVERIFY(history.registerOriginalInput(original.artifact)); + QVERIFY(history.registerArtifact(corrected.artifact)); + pdf::PDFOperationHistoryExecution execution; + execution.input = original.artifact; + execution.operationId = QStringLiteral("test.correction"); + QUuid executionId; + QVERIFY(history.beginExecution(execution, &executionId)); + pdf::PDFOperationHistoryEvent accepted; + accepted.executionId = executionId; + accepted.status = pdf::PDFOperationHistoryStatus::Accepted; + accepted.output = corrected.artifact; + accepted.approval.kind = pdf::PDFApprovalKind::Human; + accepted.approval.actorId = QStringLiteral("original-approver"); + accepted.approval.decision = QStringLiteral("approve"); + accepted.approval.decidedUtc = QDateTime::currentDateTimeUtc(); + accepted.resultSummary = QJsonObject{ { QStringLiteral("receipt"), QStringLiteral("retained-original-receipt") } }; + QVERIFY(history.appendEvent(accepted)); + const auto before = history.events(); + pdf::PDFRollbackRequest request; + request.currentArtifactSha256 = corrected.artifact.sha256; + request.targetArtifactSha256 = original.artifact.sha256; + request.targetExecutionId = QUuid::createUuid(); + approveRollback(request); + const QString destination = directory.filePath(QStringLiteral("undo.pdf")); + QVERIFY(!history.rollbackTo(request, artifacts, destination)); + QVERIFY(!QFileInfo::exists(destination)); + request.targetExecutionId = executionId; + request.currentArtifactSha256 = original.artifact.sha256; + QVERIFY(!history.rollbackTo(request, artifacts, destination)); + request.currentArtifactSha256 = corrected.artifact.sha256; + QVERIFY(history.rollbackTo(request, artifacts, destination)); + QFile output(destination); + QVERIFY(output.open(QIODevice::ReadOnly)); + QCOMPARE(output.readAll(), originalBytes); + const auto after = history.events(); + QCOMPARE(after.first().toJson(), before.first().toJson()); + QCOMPARE(after.last().status, pdf::PDFOperationHistoryStatus::RolledBack); + QVERIFY(artifacts.verify(original.artifact)); + QVERIFY(artifacts.verify(corrected.artifact)); + qInfo().noquote() << "history-original-fixture" << "source=" << corrected.artifact.sha256 + << "rollback=" << original.artifact.sha256 << "accepted-event=" << before.first().entryId + << "rollback-event=" << after.last().entryId; + history.close(); + pdf::PDFOperationHistoryStore reopened(databasePath); + QVERIFY(reopened.open()); + QVERIFY(reopened.verify().verified); + QCOMPARE(reopened.events().first().toJson(), before.first().toJson()); + QFile corrupt(artifacts.pathFor(original.artifact)); + QVERIFY(QFile::setPermissions(corrupt.fileName(), QFileDevice::ReadOwner | QFileDevice::WriteOwner)); + QVERIFY(corrupt.open(QIODevice::Append)); + corrupt.write("tampered"); + corrupt.close(); + QVERIFY(!reopened.rollbackTo(request, artifacts, directory.filePath(QStringLiteral("refused.pdf")))); + QVERIFY(!QFileInfo::exists(directory.filePath(QStringLiteral("refused.pdf")))); + QCOMPARE(reopened.events().first().toJson(), before.first().toJson()); +} + QTEST_MAIN(OperationHistoryTest) #include "tst_operationhistorytest.moc" \ No newline at end of file diff --git a/UnitTests/tst_productoperatorloop.cpp b/UnitTests/tst_productoperatorloop.cpp index 2e426d54..7b8467ae 100644 --- a/UnitTests/tst_productoperatorloop.cpp +++ b/UnitTests/tst_productoperatorloop.cpp @@ -1159,10 +1159,12 @@ void ProductOperatorLoopTest::governedPublicationParityAcrossSurfaces() // within the action-list envelope family (CLI action-list and Editor), which // is the family the surfaces actually share; repair and PageMaster publish // different plan envelopes by design (ADR-011). - const QString fixture = operatoracceptance::fixturePath(QStringLiteral("bleed-missing.pdf")); + QTemporaryDir outputDirectory; + QVERIFY(outputDirectory.isValid()); + const QString fixture = outputDirectory.filePath(QStringLiteral("bleed-missing.pdf")); + QVERIFY(QFile::copy(operatoracceptance::fixturePath(QStringLiteral("bleed-missing.pdf")), fixture)); const QString profile = operatoracceptance::defaultProfilePath(); const QString recipePath = governedParityDirectory() + QStringLiteral("/action-list-recipe.json"); - QVERIFY2(QFileInfo::exists(fixture), fixture.toUtf8().constData()); QVERIFY2(QFileInfo::exists(profile), profile.toUtf8().constData()); QVERIFY2(QFileInfo::exists(recipePath), recipePath.toUtf8().constData()); @@ -1175,9 +1177,6 @@ void ProductOperatorLoopTest::governedPublicationParityAcrossSurfaces() const pdf::PDFOperationResult parsed = pdf::PDFActionList::fromJson(recipeObject, &actionList); QVERIFY2(parsed, qPrintable(parsed.getErrorMessage())); - QTemporaryDir outputDirectory; - QVERIFY(outputDirectory.isValid()); - QMap records; // 1. CLI repair: the operation-plan envelope. @@ -1319,7 +1318,8 @@ void ProductOperatorLoopTest::planApprovalJourneyBindsDisplayedIdentityAndBlocks QVERIFY(directory.isValid()); const QString recipePath = writeBleedRecipe(directory.path()); QVERIFY(!recipePath.isEmpty()); - const QString source = operatoracceptance::fixturePath(QStringLiteral("bleed-missing.pdf")); + const QString source = directory.filePath(QStringLiteral("bleed-missing.pdf")); + QVERIFY(QFile::copy(operatoracceptance::fixturePath(QStringLiteral("bleed-missing.pdf")), source)); const QByteArray sourceDigest = operatoracceptance::fileSha256(source); QVERIFY(!sourceDigest.isEmpty()); EditorHost host; @@ -1591,8 +1591,8 @@ void ProductOperatorLoopTest::compareWorkspaceNavigatesMaterialDeltasAfterARun() const QString recipePath = writeBleedRecipe(directory.path()); QVERIFY(!recipePath.isEmpty()); - const QString documentPath = operatoracceptance::fixturePath(QStringLiteral("bleed-missing.pdf")); - QVERIFY(QFileInfo::exists(documentPath)); + const QString documentPath = directory.filePath(QStringLiteral("bleed-missing.pdf")); + QVERIFY(QFile::copy(operatoracceptance::fixturePath(QStringLiteral("bleed-missing.pdf")), documentPath)); EditorHost host; QVERIFY(host.importActionListRecipe(QUrl::fromLocalFile(recipePath))); diff --git a/UnitTests/tst_shellworkspacetest.cpp b/UnitTests/tst_shellworkspacetest.cpp index 18c4f0c0..39bb68fc 100644 --- a/UnitTests/tst_shellworkspacetest.cpp +++ b/UnitTests/tst_shellworkspacetest.cpp @@ -44,7 +44,12 @@ class ShellWorkspaceTest : public QObject { Q_OBJECT +private: + std::unique_ptr m_fixtureRecipes; + private slots: + void init(); + void cleanup(); void workspaceTransitionsPreserveClosedDocumentState(); void workspaceTransitionsPreserveOpenDocumentAndPreflightState(); void compareWorkspaceIsReachable(); @@ -58,9 +63,21 @@ private slots: void helpDialogShowsCatalogLimitsAndWelcome(); }; +void ShellWorkspaceTest::init() +{ + QStandardPaths::setTestModeEnabled(true); + m_fixtureRecipes = std::make_unique(); + QVERIFY(m_fixtureRecipes->isValid()); +} + +void ShellWorkspaceTest::cleanup() +{ + m_fixtureRecipes.reset(); +} + void ShellWorkspaceTest::workspaceTransitionsPreserveClosedDocumentState() { - EditorHost host; + EditorHost host(nullptr, m_fixtureRecipes->path()); QCOMPARE(host.workspace(), EditorHost::Document); QCOMPARE(host.documentShellStatus(), QStringLiteral("NO_DOCUMENT")); QCOMPARE(host.preflightStateName(), QStringLiteral("not-checked")); @@ -95,7 +112,7 @@ void ShellWorkspaceTest::workspaceTransitionsPreserveOpenDocumentAndPreflightSta const QString path = directory.filePath(QStringLiteral("shell.pdf")); QVERIFY(writer.write(path, &document, true)); - EditorHost host; + EditorHost host(nullptr, m_fixtureRecipes->path()); host.openFileUrl(QUrl::fromLocalFile(path)); QTRY_VERIFY(host.hasDocument()); @@ -123,7 +140,7 @@ void ShellWorkspaceTest::workspaceTransitionsPreserveOpenDocumentAndPreflightSta void ShellWorkspaceTest::compareWorkspaceIsReachable() { - EditorHost host; + EditorHost host(nullptr, m_fixtureRecipes->path()); QCOMPARE(host.workspace(), EditorHost::Document); QVERIFY(host.isWorkspaceEnabled(EditorHost::Compare)); @@ -139,7 +156,7 @@ void ShellWorkspaceTest::compareWorkspaceIsReachable() void ShellWorkspaceTest::menuPolicyRoutesVisibleActions() { - EditorHost host; + EditorHost host(nullptr, m_fixtureRecipes->path()); const QVariantList descriptors = host.commandDescriptors(); QVERIFY(descriptors.size() > 100); @@ -177,7 +194,7 @@ void ShellWorkspaceTest::menuPolicyRoutesVisibleActions() void ShellWorkspaceTest::developerDiagnosticsFollowReleaseProfile() { - EditorHost host; + EditorHost host(nullptr, m_fixtureRecipes->path()); #ifdef LOOP_LOOP_DISTRIBUTION_BUILD QVERIFY(!host.allowDeveloperDiagnostics()); #else @@ -257,7 +274,6 @@ void ShellWorkspaceTest::sampleCopiesPreserveOperatorEdits() void ShellWorkspaceTest::samplesExerciseRealCoreOutcomes() { - QStandardPaths::setTestModeEnabled(true); QTemporaryDir directory; QSettings settings(directory.filePath(QStringLiteral("settings.ini")), QSettings::IniFormat); OperatorHelp help(settings, directory.filePath(QStringLiteral("samples"))); @@ -266,7 +282,7 @@ void ShellWorkspaceTest::samplesExerciseRealCoreOutcomes() const QVariantMap descriptor = job.toMap(); const QVariantMap sample = help.prepareSample(descriptor.value(QStringLiteral("id")).toString()); QVERIFY(sample.value(QStringLiteral("ok")).toBool()); - EditorHost host; + EditorHost host(nullptr, m_fixtureRecipes->path()); QVERIFY(host.importPreflightProfileFileUrl(sample.value(QStringLiteral("profile")).toUrl())); host.openFileUrl(sample.value(QStringLiteral("pdf")).toUrl()); QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 30000); @@ -284,13 +300,12 @@ void ShellWorkspaceTest::samplesExerciseRealCoreOutcomes() void ShellWorkspaceTest::correctionSampleRunsGovernedCore() { - QStandardPaths::setTestModeEnabled(true); QTemporaryDir directory; QSettings settings(directory.filePath(QStringLiteral("settings.ini")), QSettings::IniFormat); OperatorHelp help(settings, directory.filePath(QStringLiteral("samples"))); const QVariantMap sample = help.prepareSample(QStringLiteral("correction")); QVERIFY(sample.value(QStringLiteral("ok")).toBool()); - EditorHost host; + EditorHost host(nullptr, m_fixtureRecipes->path()); QVERIFY(host.importPreflightProfileFileUrl(sample.value(QStringLiteral("profile")).toUrl())); QVERIFY(host.importActionListRecipe(sample.value(QStringLiteral("recipe")).toUrl())); host.openFileUrl(sample.value(QStringLiteral("pdf")).toUrl()); @@ -316,7 +331,7 @@ void ShellWorkspaceTest::helpDialogShowsCatalogLimitsAndWelcome() QSettings settings(directory.filePath(QStringLiteral("settings.ini")), QSettings::IniFormat); OperatorHelp help(settings, directory.filePath(QStringLiteral("samples"))); QQmlEngine engine; - EditorHost host; + EditorHost host(nullptr, m_fixtureRecipes->path()); QQmlComponent component(&engine, QUrl::fromLocalFile(QStringLiteral(LOOP_OPERATOR_SOURCE_DIR "/LoopEditor/qml/OperatorHelpDialog.qml"))); QTRY_VERIFY_WITH_TIMEOUT(component.status() != QQmlComponent::Loading, 10000); QVERIFY2(!component.isError(), qPrintable(component.errorString())); diff --git a/changes/feat-issue-124-governed-history.evidence.yaml b/changes/feat-issue-124-governed-history.evidence.yaml new file mode 100644 index 00000000..0a62392b --- /dev/null +++ b/changes/feat-issue-124-governed-history.evidence.yaml @@ -0,0 +1,30 @@ +format_version: 1 +kind: evidence +claims: + - id: governed-revision-history + evidence: + - unit:agent-policy:core + - architecture:docs/generated/architecture-catalog.json + - security:scripts/ci/check_source_integrity.py + - differential:UnitTestsConversionOracle + - unit:agent-policy:interaction + - unit:agent-policy:quick + - unit:scripts/agent/test_architecture_contracts.py + - architecture:scripts/agent/check-architecture.py + - unit:UnitTests/tst_operationhistorytest.cpp + - unit:UnitTests/tst_editorhosttest.cpp + - unit:UnitTests/tst_shellworkspacetest.cpp + - unit:UnitTestsShellWorkspace + - unit:scripts/ci/test_check_operator_help.py + - architecture:scripts/ci/check_operator_help.py + - unit:UnitTestsGovernedExecution + - unit:UnitTestsProductOperatorLoop + - unit:scripts/ci/test_check_governed_parity.py + - fixture:regression:canonical-json-golden + - architecture:agent-policy:core + - packaging:linux-build + - packaging:windows-build + - integration:tools/ProductQuickAccessibilitySmoke/main.cpp + - architecture:docs/QUICK_REVISION_HISTORY.md +unresolved: + - core:scripts/ci/check_independent_validation_gate.py diff --git a/changes/feat-issue-124-governed-history.md b/changes/feat-issue-124-governed-history.md new file mode 100644 index 00000000..541ec5b5 --- /dev/null +++ b/changes/feat-issue-124-governed-history.md @@ -0,0 +1,4 @@ +Category: added +Audience: operators +Breaking-Change: no +Summary: Present Quick Undo and Redo as forward rollback artifacts with retained revision identities, approvals, and receipts. diff --git a/docs/QUICK_PDF_PARITY.md b/docs/QUICK_PDF_PARITY.md index e227e92f..6ca9f1c3 100644 --- a/docs/QUICK_PDF_PARITY.md +++ b/docs/QUICK_PDF_PARITY.md @@ -29,7 +29,7 @@ slice is intentionally model-driven: The following remain deliberately declared or policy-excluded until their typed bridge and revision-fenced tests land: annotation/form overlays and -editing, attachments and metadata editing, print/export, undo/redo, encryption +editing, attachments and metadata editing, print/export, encryption creation or changes, sanitization, optimization, signature verification, OCR, PageMaster, Compare, Redaction, signature creation, and deep inspection. @@ -37,3 +37,5 @@ Search currently runs through the Core model on the host thread. It is a functional read-only bridge, but its next hardening step is to submit the same snapshot computation through `PDFJobScheduler` and admit the value on the owner thread, matching the renderer and preflight paths. + +Quick Undo and Redo follow [governed revision history](QUICK_REVISION_HISTORY.md). diff --git a/docs/QUICK_REVISION_HISTORY.md b/docs/QUICK_REVISION_HISTORY.md new file mode 100644 index 00000000..d267c4d7 --- /dev/null +++ b/docs/QUICK_REVISION_HISTORY.md @@ -0,0 +1,42 @@ +# Quick governed revision history + +Issue [#124](https://github.com/studio-berry/loop/issues/124) implements Edit → Undo +and Redo as forward rollback through Core. Each confirmation is bound to the +shown document revision and target identity. Viewport, workspace, and selection +changes never enter the document history. + +The Fix workspace shows canonical event identities, artifact digests, approval +actors and decisions, and governed publication receipt/sign-off details. It +checks Core's history chain before enabling navigation. Undo of an accepted +correction targets that execution's input. Redo follows recorded rollback +transitions; a new correction clears the redo branch. These choices are rebuilt +from the retained chain after reopen, rather than from a transient byte stack. + +Editor corrections now retain their exact published artifact as a unique sibling +file and append Core's execution and receipt to its existing `.loop-history` +sidecar format. A subsequent publication or rollback carries a verified snapshot +of the preceding chain and immutable artifact store. The source file and its +approved events stay intact. Open published artifact opens the retained output; +ordinary Save remains the document facade's existing save behavior. + +The human-approved Core contract extension permits a retained original input as +a rollback target only when its digest is the input of the accepted execution +being undone and the request's current digest identifies that accepted output. +It does not synthesize an acceptance event for the original input. Existing +request types and persisted schemas are unchanged. Core still verifies target +bytes, requires approval, revalidates, and appends a new rolled-back receipt. + +History snapshots require the previous SQLite writer to have closed/checkpointed +and refuse a live WAL. They copy existing metadata and retained artifacts, +verify the resulting chain, and publish the sidecar before the new artifact. +A failed or cancelled request can retain a failed-history sidecar; it never +removes an approved revision or receipt. A rollback already committed by Core +remains retained when the owner completion is cancelled or stale; it is not opened. + +`UnitTestsOperationHistory` covers first-correction rollback, incorrect execution +and current identities, tampered original bytes, unchanged approved events, and +reopen. `UnitTestsEditorHost` covers publish → Undo → reopen → Redo → reopen, +artifact/receipt identity, stale confirmations, and presentation-only state. +The mapped Core, Interaction, Quick, and product Quick smoke lanes provide the +remaining regression proof. No package or native accessibility qualification +is asserted by the software Quick smoke. diff --git a/docs/loop-shell-actions.json b/docs/loop-shell-actions.json index f18322e8..69cdc39f 100644 --- a/docs/loop-shell-actions.json +++ b/docs/loop-shell-actions.json @@ -1083,16 +1083,27 @@ { "id": "actionRedo", "disposition": "KEEP", - "target": "Document", + "target": "Fix", "command": { "label_key": "command.actionRedo.label", "shortcut": { "standard_key": "Redo" }, - "parameters": [], - "capability": "unclassified", + "parameters": [ + { + "name": "rollbackId", + "type": "string", + "required": false + }, + { + "name": "revision", + "type": "string", + "required": false + } + ], + "capability": "document.modify", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Edit" }, @@ -1497,16 +1508,27 @@ { "id": "actionUndo", "disposition": "KEEP", - "target": "Document", + "target": "Fix", "command": { "label_key": "command.actionUndo.label", "shortcut": { "standard_key": "Undo" }, - "parameters": [], - "capability": "unclassified", + "parameters": [ + { + "name": "rollbackId", + "type": "string", + "required": false + }, + { + "name": "revision", + "type": "string", + "required": false + } + ], + "capability": "document.modify", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Edit" }, diff --git a/scripts/verify-command-catalog.py b/scripts/verify-command-catalog.py index e31cb614..0cb35a4f 100644 --- a/scripts/verify-command-catalog.py +++ b/scripts/verify-command-catalog.py @@ -69,6 +69,8 @@ "actionPageLayoutTwoColumns", "actionPageLayoutTwoPages", "actionProperties", + "actionUndo", + "actionRedo", } ) diff --git a/tools/ProductQuickAccessibilitySmoke/qml/ActionListPane.qml b/tools/ProductQuickAccessibilitySmoke/qml/ActionListPane.qml index e5319e85..ff25443c 100644 --- a/tools/ProductQuickAccessibilitySmoke/qml/ActionListPane.qml +++ b/tools/ProductQuickAccessibilitySmoke/qml/ActionListPane.qml @@ -311,7 +311,7 @@ Pane { GroupBox { objectName: "fixRollbackGroup" Layout.fillWidth: true - title: qsTr("Recorded revisions") + title: qsTr("Revision history") Accessible.name: qsTr("Rollback to a recorded revision") ColumnLayout { @@ -326,6 +326,61 @@ Pane { Accessible.name: qsTr("Rollback availability") } + Label { + Layout.fillWidth: true + wrapMode: Text.Wrap + text: root.host ? root.host.revisionHistorySummary : "" + Accessible.name: qsTr("Governed revision history summary") + } + RowLayout { + Button { + objectName: "historyUndoButton" + text: qsTr("Undo") + enabled: root.host && root.host.historyUndoAvailable + onClicked: root.host.invokeCommand("actionUndo") + Accessible.name: qsTr("Undo by publishing a forward rollback") + } + Button { + objectName: "historyRedoButton" + text: qsTr("Redo") + enabled: root.host && root.host.historyRedoAvailable + onClicked: root.host.invokeCommand("actionRedo") + Accessible.name: qsTr("Redo by publishing a forward rollback") + } + Button { + text: qsTr("Open published artifact") + enabled: root.host && root.host.hasDocument + onClicked: root.host.openGovernedArtifact() + Accessible.description: root.host ? root.host.governedArtifactPath : "" + } + } + Repeater { + model: root.host ? root.host.revisionHistory : [] + delegate: ColumnLayout { + required property var modelData + Layout.fillWidth: true + Label { + Layout.fillWidth: true + wrapMode: Text.Wrap + text: qsTr("Event %1 · %2 · revision %3%4 · approval %5 by %6") + .arg(modelData.sequence).arg(modelData.status) + .arg(modelData.output ? modelData.output.sha256 : modelData.documentRevisionDigest) + .arg(modelData.current ? qsTr(" (current)") : "") + .arg(modelData.approval.decision || qsTr("none")) + .arg(modelData.approval.actorId || qsTr("none")) + Accessible.name: text + } + TextArea { + Layout.fillWidth: true + readOnly: true + selectByMouse: true + wrapMode: TextEdit.Wrap + visible: modelData.receiptText !== "{}" + text: modelData.receiptText + Accessible.name: qsTr("Revision approval and publication receipt") + } + } + } Repeater { model: root.host ? root.host.fixRollbackPoints : [] @@ -346,7 +401,7 @@ Pane { Button { text: qsTr("Return to this revision") - enabled: root.host !== null && root.host.fixRollbackAvailable + enabled: root.host !== null && root.host.fixRollbackAvailable && modelData.canReturn onClicked: { root.pendingRollbackId = modelData.rollbackId rollbackConfirm.open() diff --git a/tools/ProductQuickAccessibilitySmoke/qml/Main.qml b/tools/ProductQuickAccessibilitySmoke/qml/Main.qml index 25b34b81..543daa6e 100644 --- a/tools/ProductQuickAccessibilitySmoke/qml/Main.qml +++ b/tools/ProductQuickAccessibilitySmoke/qml/Main.qml @@ -131,6 +131,36 @@ ApplicationWindow { saveAsDialog: saveAsDialog } + Dialog { + id: historyConfirm + objectName: "historyNavigationConfirmDialog" + parent: Overlay.overlay + anchors.centerIn: parent + modal: true + title: commandId === "actionUndo" ? qsTr("Undo published correction") : qsTr("Redo published correction") + standardButtons: Dialog.Ok | Dialog.Cancel + property string commandId: "" + property string rollbackId: "" + property string revision: "" + Label { + width: 360 + wrapMode: Text.Wrap + text: qsTr("Publish a new rollback artifact after revalidation? Existing revisions, approvals and receipts are retained.") + Accessible.name: text + } + onAccepted: if (window.host) window.host.invokeCommand(commandId, {rollbackId: rollbackId, revision: revision}) + } + + Connections { + target: window.host + function onHistoryNavigationRequested(commandId, rollbackId, revision) { + historyConfirm.commandId = commandId + historyConfirm.rollbackId = rollbackId + historyConfirm.revision = revision + historyConfirm.open() + } + } + FileDialog { id: openDialog title: qsTr("Open PDF")