diff --git a/inc/Smartling/Extensions/PublishPressRevisions.php b/inc/Smartling/Extensions/PublishPressRevisions.php new file mode 100644 index 000000000..2b0a1ed78 --- /dev/null +++ b/inc/Smartling/Extensions/PublishPressRevisions.php @@ -0,0 +1,252 @@ + original post id => blog id */ + private array $suppressedOriginals = []; + + /** @var array}> revision id => move to do */ + private array $pendingMoves = []; + + public function __construct( + PluginHelper $pluginHelper, + WordpressFunctionProxyHelper $wpProxy, + private LocalizationPluginProxyInterface $multilangProxy, + private SubmissionManager $submissionManager, + private DetectChangesHelper $detectChangesHelper, + private ContentSerializationHelper $contentSerializationHelper, + ) { + parent::__construct($pluginHelper, $wpProxy); + } + + public function getMaxVersion(): string + { + return '4'; + } + + public function getMinVersion(): string + { + return '3'; + } + + public function getPluginId(): string + { + return 'publishpress-revisions'; + } + + public function getPluginPaths(): array + { + return ['revisionary/revisionary.php']; + } + + public function register(): void + { + if ($this->getPluginSupportLevel() !== Pluggable::SUPPORTED) { + return; + } + + $this->wpProxy->add_filter('revisionary_apply_revision_data', [$this, 'recordPendingMove'], 10, 3); + // priority is lower than that of SubmissionCleanupHelper, so submissions are moved before they are cleaned up + $this->wpProxy->add_action('before_delete_post', [$this, 'moveSubmissions'], 5); + $this->wpProxy->add_action('revision_applied', [$this, 'resumeChangeDetection']); + $this->wpProxy->add_action('shutdown', [$this, 'resumeAllChangeDetection']); + $this->wpProxy->add_filter(ExportedAPI::FILTER_SMARTLING_METADATA_FIELD_PROCESS, [$this, 'sanitizeTargetField'], 5, 3); + } + + /** + * Fires before PublishPress Revisions updates the original with the revision, and the update may fail. Nothing is + * changed here except remembering what to move once the revision is deleted, which happens only after a + * successful update. + * + * @param \WP_Post $revision + * @param \WP_Post $published + */ + public function recordPendingMove(mixed $update, mixed $revision, mixed $published) + { + if (!is_object($revision) + || !is_object($published) + || !property_exists($revision, 'ID') + || !property_exists($published, 'ID') + || !property_exists($published, 'post_type') + || !property_exists($revision, 'post_type') + ) { + $this->getLogger()->warning('Invalid arguments passed to recordPendingMove'); + return $update; + } + $revisionId = ($revision->ID ?? 0); + $originalId = ($published->ID ?? 0); + if ($revisionId === 0 || $originalId === 0 || $revisionId === $originalId) { + return $update; + } + + try { + $sourceBlogId = $this->wpProxy->get_current_blog_id(); + $contentType = $published->post_type ?? $revision->post_type ?? 'post'; + $upToDate = []; + foreach ($this->submissionManager->find([ + SubmissionEntity::FIELD_SOURCE_BLOG_ID => $sourceBlogId, + SubmissionEntity::FIELD_CONTENT_TYPE => $contentType, + SubmissionEntity::FIELD_SOURCE_ID => $revisionId, + ]) as $submission) { + // the revision is not modified yet, so this tells if the translation matches its content + $upToDate[(int)$submission->getId()] = $this->contentSerializationHelper->calculateHash($submission) === $submission->getSourceContentHash(); + } + if ($upToDate !== []) { + // the original is about to be updated with the content the submissions were created from + $this->detectChangesHelper->suppress($sourceBlogId, $originalId); + $this->suppressedOriginals[$originalId] = $sourceBlogId; + $this->pendingMoves[$revisionId] = [ + 'originalId' => $originalId, + 'blogId' => $sourceBlogId, + 'contentType' => $contentType, + 'upToDate' => $upToDate, + ]; + } + } catch (\Throwable $e) { + $this->getLogger()->error("Unable to record submissions of revision id=$revisionId, original id=$originalId: {$e->getMessage()}"); + } + + return $update; + } + + /** + * Moves submissions of the revision that is being deleted after it was applied to the original + */ + public function moveSubmissions(mixed $postId): void + { + $revisionId = (int)$postId; + if (!array_key_exists($revisionId, $this->pendingMoves)) { + return; + } + $pending = $this->pendingMoves[$revisionId]; + unset($this->pendingMoves[$revisionId]); + + try { + foreach ($this->submissionManager->find([ + SubmissionEntity::FIELD_SOURCE_BLOG_ID => $pending['blogId'], + SubmissionEntity::FIELD_CONTENT_TYPE => $pending['contentType'], + SubmissionEntity::FIELD_SOURCE_ID => $revisionId, + ]) as $submission) { + $this->moveSubmission($submission, $pending['originalId'], $pending['upToDate'][(int)$submission->getId()] ?? false); + } + } catch (\Throwable $e) { + $this->getLogger()->error("Unable to preserve submissions of revision id=$revisionId, original id={$pending['originalId']}: {$e->getMessage()}"); + } + } + + public function resumeChangeDetection(mixed $originalId): void + { + $originalId = (int)$originalId; + if (array_key_exists($originalId, $this->suppressedOriginals)) { + $this->detectChangesHelper->resume($this->suppressedOriginals[$originalId], $originalId); + unset($this->suppressedOriginals[$originalId]); + } + } + + /** + * revision_applied does not fire if PublishPress Revisions fails to update the original + */ + public function resumeAllChangeDetection(): void + { + foreach (array_keys($this->suppressedOriginals) as $originalId) { + $this->resumeChangeDetection($originalId); + } + } + + private function moveSubmission(SubmissionEntity $submission, int $originalId, bool $wasUpToDate): void + { + $existing = $this->submissionManager->findTargetBlogSubmission( + $submission->getContentType(), + $submission->getSourceBlogId(), + $originalId, + $submission->getTargetBlogId(), + ); + + if ($existing !== null) { + if ($this->getAppliedAt($existing) > $this->getAppliedAt($submission)) { + // original already has a newer translation, nothing to preserve + return; + } + $this->unlink($existing); + $this->submissionManager->delete($existing); + } + + $this->unlink($submission); + $submission->setSourceId($originalId); + if ($wasUpToDate) { + // hash was calculated from the revision, which has meta and fields the original doesn't + $submission->setSourceContentHash($this->contentSerializationHelper->calculateHash($submission)); + } + $submission = $this->submissionManager->storeEntity($submission); + try { + $this->multilangProxy->linkObjects($submission); + } catch (\Throwable $e) { + $this->getLogger()->notice("Unable to link objects for submission id={$submission->getId()}: {$e->getMessage()}"); + } + $this->getLogger()->info("Moved submission id={$submission->getId()} to original post id=$originalId"); + } + + private function getAppliedAt(SubmissionEntity $submission): string + { + $appliedDate = $submission->getAppliedDate() ?? ''; + + return str_starts_with($appliedDate, '0000') ? '' : $appliedDate; + } + + private function unlink(SubmissionEntity $submission): void + { + try { + $this->multilangProxy->unlinkObjects($submission); + } catch (\Throwable $e) { + $this->getLogger()->notice("Unable to unlink objects for submission id={$submission->getId()}: {$e->getMessage()}"); + } + } + + /** + * The target of a revision must be an ordinary post: no revision status and no parent (the original is the parent). + */ + public function sanitizeTargetField(mixed $name, mixed $value, mixed $submission = null) + { + if ($name === 'post_mime_type' && in_array($value, self::REVISION_STATUSES, true)) { + return ''; + } + + if ($name === 'post_parent' && $submission instanceof SubmissionEntity && $this->isRevision($submission)) { + return 0; + } + + return $value; + } + + private function isRevision(SubmissionEntity $submission): bool + { + $post = $this->wpProxy->get_post($submission->getSourceId()); + + return is_object($post) + && (in_array($post->post_mime_type ?? '', self::REVISION_STATUSES, true) + || (int)$this->wpProxy->getPostMeta($submission->getSourceId(), self::BASE_POST_META, true) > 0); + } +} diff --git a/inc/Smartling/Helpers/ContentSerializationHelper.php b/inc/Smartling/Helpers/ContentSerializationHelper.php index 56a5e7364..18a1ea554 100644 --- a/inc/Smartling/Helpers/ContentSerializationHelper.php +++ b/inc/Smartling/Helpers/ContentSerializationHelper.php @@ -24,6 +24,8 @@ public function getRemoveFields(): array 'post_password', 'post_modified', 'post_modified_gmt', + 'post_status', + 'post_name', ], 'meta' => [ '_edit_lock', diff --git a/inc/Smartling/Helpers/DetectChangesHelper.php b/inc/Smartling/Helpers/DetectChangesHelper.php index 876989393..4fa4bec35 100644 --- a/inc/Smartling/Helpers/DetectChangesHelper.php +++ b/inc/Smartling/Helpers/DetectChangesHelper.php @@ -15,6 +15,9 @@ class DetectChangesHelper { use LoggerSafeTrait; + /** @var array */ + private array $suppressed = []; + public function __construct( private AcfDynamicSupport $acfDynamicSupport, private ContentSerializationHelper $contentSerializationHelper, @@ -24,6 +27,20 @@ public function __construct( ) { } + /** + * Ignore content changes of the given content until resume() is called, for the cases when content is + * updated with the data the submissions already represent. + */ + public function suppress(int $blogId, int $contentId): void + { + $this->suppressed["$blogId:$contentId"] = true; + } + + public function resume(int $blogId, int $contentId): void + { + unset($this->suppressed["$blogId:$contentId"]); + } + /** * @param string[] $contentTypes * @return SubmissionEntity[] @@ -105,6 +122,12 @@ private function update(SubmissionEntity $submission, bool $needUpdateStatus, st public function detectChanges(int $blogId, int $contentId, string $contentType): void { + if (array_key_exists("$blogId:$contentId", $this->suppressed)) { + $this->getLogger()->debug("Change detection suppressed for $contentType blog=$blogId, id=$contentId"); + + return; + } + $submissions = $this->getSubmissions($blogId, $contentId, [$contentType]); if (0 === count($submissions)) { diff --git a/inc/config/register-on-startup.yml b/inc/config/register-on-startup.yml index 7e9b325cf..c77f8a03c 100644 --- a/inc/config/register-on-startup.yml +++ b/inc/config/register-on-startup.yml @@ -22,6 +22,7 @@ services: - [ "addService", [ "@smartling.helper.relative-image-path-support" ]] - [ "addService", [ "@smartling.helper.absolute-image-path-support" ]] - [ "addService", [ "@service.submission-cleanup" ]] + - [ "addService", [ "@plugin.publishpress-revisions" ]] - [ "addService", [ "@helper.shortcode" ]] - [ "addService", [ "@helper.gutenberg" ]] - [ "addService", [ "@meta-field.processor.manager" ]] diff --git a/inc/config/services.yml b/inc/config/services.yml index fa521f264..3d51a7123 100644 --- a/inc/config/services.yml +++ b/inc/config/services.yml @@ -222,6 +222,16 @@ services: - "@helper.plugins" - "@wp.proxy" + plugin.publishpress-revisions: + class: Smartling\Extensions\PublishPressRevisions + arguments: + - "@helper.plugins" + - "@wp.proxy" + - "@multilang.proxy" + - "@manager.submission" + - "@detect-changes.helper" + - "@content-serialization.helper" + content.relations.handler: class: Smartling\Services\ContentRelationsHandler arguments: diff --git a/js/app.js b/js/app.js index b687bf311..c287321cf 100644 --- a/js/app.js +++ b/js/app.js @@ -353,7 +353,8 @@ function JobWizard({ isBulkSubmitPage, contentType, contentId, locales, ajaxUrl, setDescription(job.description || ''); setDueDate(job.dueDate ? new Date(job.dueDate).toISOString().slice(0, 16) : ''); setSelectedLocales((job.targetLocaleIds || []) - .map(localeCode => locales.find(l => l.smartlingLocale === localeCode)?.blogId)); + .map(localeCode => locales.find(l => l.smartlingLocale === localeCode)?.blogId) + .filter(blogId => blogId !== undefined)); } } }), diff --git a/readme.txt b/readme.txt index b6dc191ec..7a6e2e9f7 100755 --- a/readme.txt +++ b/readme.txt @@ -62,6 +62,10 @@ Additional information on the Smartling Connector for WordPress can be found [he 3. Track translation status within WordPress from the Submissions Board. View overall progress of submitted translation requests as well as resend updated content. == Changelog == += 5.8.0 = +* Added support for PublishPress Revisions: translations of a revision are kept and linked to the original post when the revision is published. A revision is translated into a separate post in each target site, whose status on download follows the "Translation publishing mode" profile setting. If the original post already had a translation, that previous translation is no longer linked and keeps its status, so remove or replace it when publishing the new one. +* Publishing a draft or changing its slug no longer marks its translation as outdated. Submissions created before this release can be marked as outdated once on the next save. + = 5.7.4 = * Fixed Translation Lock popup silently failing to save any change (locking or unlocking fields) diff --git a/tests/Smartling/Extensions/PublishPressRevisionsTest.php b/tests/Smartling/Extensions/PublishPressRevisionsTest.php new file mode 100644 index 000000000..9c312a167 --- /dev/null +++ b/tests/Smartling/Extensions/PublishPressRevisionsTest.php @@ -0,0 +1,227 @@ +setId($sourceId * 10); + $submission->setContentType('page'); + $submission->setSourceBlogId(1); + $submission->setTargetBlogId(2); + $submission->setSourceId($sourceId); + $submission->setTargetId($targetId); + $submission->setSourceContentHash($hash); + $submission->setAppliedDate($appliedDate); + + return $submission; + } + + private function x( + ?SubmissionManager $manager = null, + ?LocalizationPluginProxyInterface $multilang = null, + ?WordpressFunctionProxyHelper $wp = null, + ?DetectChangesHelper $detectChanges = null, + ?ContentSerializationHelper $serialization = null, + ): PublishPressRevisions { + if ($wp === null) { + $wp = $this->createMock(WordpressFunctionProxyHelper::class); + $wp->method('get_current_blog_id')->willReturn(1); + } + if ($serialization === null) { + $serialization = $this->createMock(ContentSerializationHelper::class); + $serialization->method('calculateHash')->willReturn('new'); + } + + return new PublishPressRevisions( + $this->createMock(PluginHelper::class), + $wp, + $multilang ?? $this->createMock(LocalizationPluginProxyInterface::class), + $manager ?? $this->createMock(SubmissionManager::class), + $detectChanges ?? $this->createMock(DetectChangesHelper::class), + $serialization, + ); + } + + private function revision(): object + { + return (object)['ID' => 20, 'post_type' => 'page']; + } + + private function original(): object + { + return (object)['ID' => 10, 'post_type' => 'page']; + } + + public function testNothingIsChangedUntilRevisionIsDeleted(): void + { + $manager = $this->createMock(SubmissionManager::class); + $manager->expects($this->once())->method('find')->willReturn([$this->submission(20, 200)]); + $manager->expects($this->never())->method('findTargetBlogSubmission'); + $manager->expects($this->never())->method('delete'); + $manager->expects($this->never())->method('storeEntity'); + $multilang = $this->createMock(LocalizationPluginProxyInterface::class); + $multilang->expects($this->never())->method('unlinkObjects'); + $multilang->expects($this->never())->method('linkObjects'); + + $update = ['a' => 'b']; + $this->assertSame($update, $this->x($manager, $multilang)->recordPendingMove($update, $this->revision(), $this->original())); + } + + public function testRevisionSubmissionIsMovedToOriginalWhenRevisionIsDeleted(): void + { + $submission = $this->submission(20, 200); + $manager = $this->createMock(SubmissionManager::class); + $manager->expects($this->exactly(2))->method('find')->willReturn([$submission]); + $manager->method('findTargetBlogSubmission')->willReturn(null); + $manager->expects($this->never())->method('delete'); + $manager->expects($this->once())->method('storeEntity') + ->with($this->callback(static fn(SubmissionEntity $s) => $s->getSourceId() === 10 && $s->getTargetId() === 200)) + ->willReturnArgument(0); + $multilang = $this->createMock(LocalizationPluginProxyInterface::class); + $multilang->expects($this->once())->method('unlinkObjects'); + $multilang->expects($this->once())->method('linkObjects'); + + $x = $this->x($manager, $multilang); + $x->recordPendingMove([], $this->revision(), $this->original()); + $x->moveSubmissions(20); + $x->moveSubmissions(20); + } + + public function testUnrelatedPostDeletionDoesNothing(): void + { + $manager = $this->createMock(SubmissionManager::class); + $manager->expects($this->never())->method('storeEntity'); + + $this->x($manager)->moveSubmissions(99); + } + + public function testHashIsRecalculatedOnlyForUpToDateSubmissions(): void + { + $upToDate = $this->submission(20, 200, null, 'current'); + $outdated = $this->submission(21, 201, null, 'stale'); + $serialization = $this->createMock(ContentSerializationHelper::class); + $serialization->method('calculateHash')->willReturnCallback( + static fn(SubmissionEntity $s) => $s->getSourceId() === 10 ? 'original-hash' : ($s->getId() === 200 ? 'current' : 'different'), + ); + $manager = $this->createMock(SubmissionManager::class); + $manager->method('find')->willReturn([$upToDate, $outdated]); + $manager->method('findTargetBlogSubmission')->willReturn(null); + $manager->method('storeEntity')->willReturnArgument(0); + + $x = $this->x($manager, null, null, null, $serialization); + $x->recordPendingMove([], $this->revision(), $this->original()); + $x->moveSubmissions(20); + + $this->assertSame('original-hash', $upToDate->getSourceContentHash()); + $this->assertSame('stale', $outdated->getSourceContentHash()); + } + + public function testExistingOriginalSubmissionWithOlderTranslationIsReplaced(): void + { + $existing = $this->submission(10, 100, '2026-09-01 10:00:00'); + $manager = $this->createMock(SubmissionManager::class); + $manager->method('find')->willReturn([$this->submission(20, 50, '2026-09-02 10:00:00')]); + $manager->method('findTargetBlogSubmission')->willReturn($existing); + $manager->expects($this->once())->method('delete')->with($existing); + $manager->expects($this->once())->method('storeEntity')->willReturnArgument(0); + + $x = $this->x($manager); + $x->recordPendingMove([], $this->revision(), $this->original()); + $x->moveSubmissions(20); + } + + public function testExistingOriginalSubmissionWithNewerTranslationIsKept(): void + { + $manager = $this->createMock(SubmissionManager::class); + $manager->expects($this->exactly(2))->method('find')->willReturn([$this->submission(20, 900, '2026-09-01 10:00:00')]); + $manager->expects($this->once())->method('findTargetBlogSubmission')->willReturn($this->submission(10, 100, '2026-09-02 10:00:00')); + $manager->expects($this->never())->method('delete'); + $manager->expects($this->never())->method('storeEntity'); + + $x = $this->x($manager); + $x->recordPendingMove([], $this->revision(), $this->original()); + $x->moveSubmissions(20); + } + + public function testFailureDoesNotBreakRevisionPublishing(): void + { + $manager = $this->createMock(SubmissionManager::class); + $manager->expects($this->once())->method('find')->willThrowException(new \RuntimeException('db down')); + $update = ['a' => 'b']; + + $this->assertSame($update, $this->x($manager)->recordPendingMove($update, $this->revision(), $this->original())); + } + + public function testInvalidArgumentsAreIgnored(): void + { + $manager = $this->createMock(SubmissionManager::class); + $manager->expects($this->never())->method('find'); + + $this->assertSame([], $this->x($manager)->recordPendingMove([], (object)['ID' => 20], (object)['ID' => 10])); + } + + public function testChangeDetectionIsSuppressedUntilRevisionIsApplied(): void + { + $manager = $this->createMock(SubmissionManager::class); + $manager->method('find')->willReturn([$this->submission(20, 200)]); + $detectChanges = $this->createMock(DetectChangesHelper::class); + $detectChanges->expects($this->once())->method('suppress')->with(1, 10); + $detectChanges->expects($this->once())->method('resume')->with(1, 10); + $x = $this->x($manager, null, null, $detectChanges); + + $x->recordPendingMove([], $this->revision(), $this->original()); + $x->resumeChangeDetection(10); + $x->resumeChangeDetection(10); + } + + public function testChangeDetectionIsResumedOnShutdownWhenRevisionWasNotApplied(): void + { + $manager = $this->createMock(SubmissionManager::class); + $manager->method('find')->willReturn([$this->submission(20, 200)]); + $detectChanges = $this->createMock(DetectChangesHelper::class); + $detectChanges->expects($this->once())->method('suppress')->with(1, 10); + $detectChanges->expects($this->once())->method('resume')->with(1, 10); + $x = $this->x($manager, null, null, $detectChanges); + + $x->recordPendingMove([], $this->revision(), $this->original()); + $x->resumeAllChangeDetection(); + $x->resumeAllChangeDetection(); + } + + public function testRevisionMimeTypeIsResetOnTarget(): void + { + $x = $this->x(); + foreach (PublishPressRevisions::REVISION_STATUSES as $status) { + $this->assertSame('', $x->sanitizeTargetField('post_mime_type', $status)); + } + $this->assertSame('image/png', $x->sanitizeTargetField('post_mime_type', 'image/png')); + $this->assertSame('title', $x->sanitizeTargetField('post_title', 'title')); + } + + public function testParentIsResetOnlyForRevisions(): void + { + $wp = $this->createMock(WordpressFunctionProxyHelper::class); + $wp->method('get_post')->willReturnMap([ + [20, (object)['post_mime_type' => 'draft-revision']], + [30, (object)['post_mime_type' => '']], + ]); + $wp->method('getPostMeta')->willReturn(''); + $x = $this->x(null, null, $wp); + + $this->assertSame(0, $x->sanitizeTargetField('post_parent', 10, $this->submission(20, 0))); + $this->assertSame(10, $x->sanitizeTargetField('post_parent', 10, $this->submission(30, 0))); + } +} diff --git a/tests/Smartling/Helpers/ContentSerializationHelperTest.php b/tests/Smartling/Helpers/ContentSerializationHelperTest.php new file mode 100644 index 000000000..5b64f5bd2 --- /dev/null +++ b/tests/Smartling/Helpers/ContentSerializationHelperTest.php @@ -0,0 +1,54 @@ +createMock(Entity::class); + $content->method('toArray')->willReturn($entity); + $contentHelper = $this->createMock(ContentHelper::class); + $contentHelper->method('readSourceContent')->willReturn($content); + $contentHelper->method('readSourceMetadata')->willReturn(['meta' => 'value']); + + return new ContentSerializationHelper($contentHelper, $this->createMock(SettingsManager::class)); + } + + private function submission(int $id): SubmissionEntity + { + $submission = new SubmissionEntity(); + $submission->setSourceBlogId(1); + $submission->setContentType('post'); + $submission->setSourceId($id); + + return $submission; + } + + public function testPublishingDoesNotChangeHash(): void + { + $draft = ['post_title' => 'title', 'post_content' => 'content', 'post_status' => 'draft', 'post_name' => '']; + $published = ['post_title' => 'title', 'post_content' => 'content', 'post_status' => 'publish', 'post_name' => 'title']; + + $draftHash = $this->helper($draft)->calculateHash($this->submission(101)); + $publishedHash = $this->helper($published)->calculateHash($this->submission(102)); + + $this->assertSame($draftHash, $publishedHash); + } + + public function testContentChangeChangesHash(): void + { + $this->assertNotSame( + $this->helper(['post_content' => 'a', 'post_status' => 'draft'])->calculateHash($this->submission(111)), + $this->helper(['post_content' => 'b', 'post_status' => 'draft'])->calculateHash($this->submission(112)), + ); + } +}