From f2c8c21f765533d5bcea9bc76c4598e922f56793 Mon Sep 17 00:00:00 2001 From: Vitalii Solovei Date: Tue, 29 Sep 2026 18:57:43 +0200 Subject: [PATCH 1/6] add PublishPress Revisions support: keep translations when revision is published (WP-934) Co-Authored-By: Claude Sonnet 5.5 --- .../Extensions/PublishPressRevisions.php | 163 ++++++++++++++++++ inc/config/register-on-startup.yml | 1 + inc/config/services.yml | 8 + .../Extensions/PublishPressRevisionsTest.php | 123 +++++++++++++ 4 files changed, 295 insertions(+) create mode 100644 inc/Smartling/Extensions/PublishPressRevisions.php create mode 100644 tests/Smartling/Extensions/PublishPressRevisionsTest.php diff --git a/inc/Smartling/Extensions/PublishPressRevisions.php b/inc/Smartling/Extensions/PublishPressRevisions.php new file mode 100644 index 000000000..d158c105d --- /dev/null +++ b/inc/Smartling/Extensions/PublishPressRevisions.php @@ -0,0 +1,163 @@ +getPluginSupportLevel() !== Pluggable::SUPPORTED) { + return; + } + + $this->wpProxy->add_filter('revisionary_apply_revision_data', [$this, 'moveSubmissionsToOriginal'], 10, 3); + $this->wpProxy->add_filter(ExportedAPI::FILTER_SMARTLING_METADATA_FIELD_PROCESS, [$this, 'sanitizeTargetField'], 5, 3); + } + + /** + * Fires before PublishPress Revisions deletes the applied revision, so submissions can still be preserved. + * + * @param array|mixed $update + * @param \WP_Post|object|mixed $revision + * @param \WP_Post|object|mixed $published + */ + public function moveSubmissionsToOriginal($update, $revision, $published) + { + $revisionId = (int)($revision->ID ?? 0); + $originalId = (int)($published->ID ?? 0); + if ($revisionId === 0 || $originalId === 0 || $revisionId === $originalId) { + return $update; + } + + try { + $sourceBlogId = $this->wpProxy->get_current_blog_id(); + $contentType = (string)($published->post_type ?? $revision->post_type ?? 'post'); + foreach ($this->submissionManager->find([ + SubmissionEntity::FIELD_SOURCE_BLOG_ID => $sourceBlogId, + SubmissionEntity::FIELD_CONTENT_TYPE => $contentType, + SubmissionEntity::FIELD_SOURCE_ID => $revisionId, + ]) as $submission) { + $this->moveSubmission($submission, $originalId); + } + } catch (\Throwable $e) { + $this->getLogger()->error("Unable to preserve submissions of revision id=$revisionId, original id=$originalId: {$e->getMessage()}"); + } + + return $update; + } + + private function moveSubmission(SubmissionEntity $submission, int $originalId): void + { + $existing = $this->submissionManager->findTargetBlogSubmission( + $submission->getContentType(), + $submission->getSourceBlogId(), + $originalId, + $submission->getTargetBlogId(), + ); + + if ($existing !== null) { + if ($existing->getTargetId() > $submission->getTargetId()) { + // original already has a newer translation, nothing to preserve + return; + } + $this->unlink($existing); + $this->submissionManager->delete($existing); + } + + $this->unlink($submission); + $submission->setSourceId($originalId); + $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 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()}"); + } + } + + /** + * Target of a revision must be an ordinary post: no revision status and no parent (the original is the parent). + * + * @param mixed $name + * @param mixed $value + * @param mixed $submission + */ + public function sanitizeTargetField($name, $value, $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/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..0ae2585c9 100644 --- a/inc/config/services.yml +++ b/inc/config/services.yml @@ -222,6 +222,14 @@ services: - "@helper.plugins" - "@wp.proxy" + plugin.publishpress-revisions: + class: Smartling\Extensions\PublishPressRevisions + arguments: + - "@helper.plugins" + - "@wp.proxy" + - "@multilang.proxy" + - "@manager.submission" + content.relations.handler: class: Smartling\Services\ContentRelationsHandler arguments: diff --git a/tests/Smartling/Extensions/PublishPressRevisionsTest.php b/tests/Smartling/Extensions/PublishPressRevisionsTest.php new file mode 100644 index 000000000..2d07f70fa --- /dev/null +++ b/tests/Smartling/Extensions/PublishPressRevisionsTest.php @@ -0,0 +1,123 @@ +setContentType('page'); + $submission->setSourceBlogId(1); + $submission->setTargetBlogId(2); + $submission->setSourceId($sourceId); + $submission->setTargetId($targetId); + + return $submission; + } + + private function x( + ?SubmissionManager $manager = null, + ?LocalizationPluginProxyInterface $multilang = null, + ?WordpressFunctionProxyHelper $wp = null, + ): PublishPressRevisions { + return new PublishPressRevisions( + $this->createMock(PluginHelper::class), + $wp ?? $this->createMock(WordpressFunctionProxyHelper::class), + $multilang ?? $this->createMock(LocalizationPluginProxyInterface::class), + $manager ?? $this->createMock(SubmissionManager::class), + ); + } + + public function testRevisionSubmissionIsMovedToOriginal(): void + { + $submission = $this->submission(20, 200); + $wp = $this->createMock(WordpressFunctionProxyHelper::class); + $wp->method('get_current_blog_id')->willReturn(1); + $manager = $this->createMock(SubmissionManager::class); + $manager->expects($this->once())->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'); + + $update = ['a' => 'b']; + $this->assertSame($update, $this->x($manager, $multilang, $wp)->moveSubmissionsToOriginal( + $update, + (object)['ID' => 20, 'post_type' => 'page'], + (object)['ID' => 10, 'post_type' => 'page'], + )); + } + + public function testExistingOriginalSubmissionWithOlderTargetIsReplaced(): void + { + $existing = $this->submission(10, 100); + $wp = $this->createMock(WordpressFunctionProxyHelper::class); + $wp->method('get_current_blog_id')->willReturn(1); + $manager = $this->createMock(SubmissionManager::class); + $manager->method('find')->willReturn([$this->submission(20, 200)]); + $manager->method('findTargetBlogSubmission')->willReturn($existing); + $manager->expects($this->once())->method('delete')->with($existing); + $manager->expects($this->once())->method('storeEntity')->willReturnArgument(0); + + $this->x($manager, null, $wp)->moveSubmissionsToOriginal([], (object)['ID' => 20], (object)['ID' => 10, 'post_type' => 'page']); + } + + public function testExistingOriginalSubmissionWithNewerTargetIsKept(): void + { + $wp = $this->createMock(WordpressFunctionProxyHelper::class); + $wp->method('get_current_blog_id')->willReturn(1); + $manager = $this->createMock(SubmissionManager::class); + $manager->method('find')->willReturn([$this->submission(20, 200)]); + $manager->method('findTargetBlogSubmission')->willReturn($this->submission(10, 300)); + $manager->expects($this->never())->method('delete'); + $manager->expects($this->never())->method('storeEntity'); + + $this->x($manager, null, $wp)->moveSubmissionsToOriginal([], (object)['ID' => 20], (object)['ID' => 10, 'post_type' => 'page']); + } + + public function testFailureDoesNotBreakRevisionPublishing(): void + { + $manager = $this->createMock(SubmissionManager::class); + $manager->method('find')->willThrowException(new \RuntimeException('db down')); + $update = ['a' => 'b']; + + $this->assertSame($update, $this->x($manager)->moveSubmissionsToOriginal($update, (object)['ID' => 20], (object)['ID' => 10])); + } + + 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))); + } +} From 37bdfb9fdbf8fc9ff26b0a9ec5a48bc2328fe155 Mon Sep 17 00:00:00 2001 From: Vitalii Solovei Date: Tue, 29 Sep 2026 19:04:34 +0200 Subject: [PATCH 2/6] skip unmapped job locales when selecting an existing job (WP-976) Co-Authored-By: Claude Sonnet 5.5 --- js/app.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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)); } } }), From 16914fbceb4cb5e2d548c215ca247fc73a1d273d Mon Sep 17 00:00:00 2001 From: Vitalii Solovei Date: Tue, 29 Sep 2026 19:32:26 +0200 Subject: [PATCH 3/6] keep submission status and outdated flag when source draft is published (WP-934) Ignore post_status and post_name in the content hash, accept legacy hash for existing submissions, and suppress change detection while PublishPress Revisions applies a revision. Co-Authored-By: Claude Sonnet 5.5 --- .../Extensions/PublishPressRevisions.php | 21 +++++++ .../Helpers/ContentSerializationHelper.php | 40 +++++++++++-- inc/Smartling/Helpers/DetectChangesHelper.php | 30 +++++++++- inc/config/services.yml | 1 + .../Extensions/PublishPressRevisionsTest.php | 19 ++++++ .../ContentSerializationHelperTest.php | 58 +++++++++++++++++++ 6 files changed, 161 insertions(+), 8 deletions(-) create mode 100644 tests/Smartling/Helpers/ContentSerializationHelperTest.php diff --git a/inc/Smartling/Extensions/PublishPressRevisions.php b/inc/Smartling/Extensions/PublishPressRevisions.php index d158c105d..43f721c80 100644 --- a/inc/Smartling/Extensions/PublishPressRevisions.php +++ b/inc/Smartling/Extensions/PublishPressRevisions.php @@ -4,6 +4,7 @@ use Smartling\DbAl\LocalizationPluginProxyInterface; use Smartling\Base\ExportedAPI; +use Smartling\Helpers\DetectChangesHelper; use Smartling\Helpers\LoggerSafeTrait; use Smartling\Helpers\PluginHelper; use Smartling\Helpers\WordpressFunctionProxyHelper; @@ -23,11 +24,15 @@ class PublishPressRevisions extends PluggableAbstract implements WPHookInterface public const REVISION_STATUSES = ['draft-revision', 'pending-revision', 'future-revision']; public const BASE_POST_META = '_rvy_base_post_id'; + /** @var array original post id => blog id */ + private array $suppressedOriginals = []; + public function __construct( PluginHelper $pluginHelper, WordpressFunctionProxyHelper $wpProxy, private LocalizationPluginProxyInterface $multilangProxy, private SubmissionManager $submissionManager, + private DetectChangesHelper $detectChangesHelper, ) { parent::__construct($pluginHelper, $wpProxy); } @@ -59,6 +64,7 @@ public function register(): void } $this->wpProxy->add_filter('revisionary_apply_revision_data', [$this, 'moveSubmissionsToOriginal'], 10, 3); + $this->wpProxy->add_action('revision_applied', [$this, 'resumeChangeDetection']); $this->wpProxy->add_filter(ExportedAPI::FILTER_SMARTLING_METADATA_FIELD_PROCESS, [$this, 'sanitizeTargetField'], 5, 3); } @@ -80,6 +86,9 @@ public function moveSubmissionsToOriginal($update, $revision, $published) try { $sourceBlogId = $this->wpProxy->get_current_blog_id(); $contentType = (string)($published->post_type ?? $revision->post_type ?? 'post'); + // the original is about to be updated with the content the submissions were created from + $this->detectChangesHelper->suppress($sourceBlogId, $originalId); + $this->suppressedOriginals[$originalId] = $sourceBlogId; foreach ($this->submissionManager->find([ SubmissionEntity::FIELD_SOURCE_BLOG_ID => $sourceBlogId, SubmissionEntity::FIELD_CONTENT_TYPE => $contentType, @@ -94,6 +103,18 @@ public function moveSubmissionsToOriginal($update, $revision, $published) return $update; } + /** + * @param int|mixed $originalId + */ + public function resumeChangeDetection($originalId): void + { + $originalId = (int)$originalId; + if (array_key_exists($originalId, $this->suppressedOriginals)) { + $this->detectChangesHelper->resume($this->suppressedOriginals[$originalId], $originalId); + unset($this->suppressedOriginals[$originalId]); + } + } + private function moveSubmission(SubmissionEntity $submission, int $originalId): void { $existing = $this->submissionManager->findTargetBlogSubmission( diff --git a/inc/Smartling/Helpers/ContentSerializationHelper.php b/inc/Smartling/Helpers/ContentSerializationHelper.php index 56a5e7364..bed613ffb 100644 --- a/inc/Smartling/Helpers/ContentSerializationHelper.php +++ b/inc/Smartling/Helpers/ContentSerializationHelper.php @@ -34,9 +34,25 @@ public function getRemoveFields(): array ]; } - private function cleanUpFields(array $fields): array + /** + * Fields that change when a draft is published without any change in content to translate. + * Ignoring them prevents marking translated submissions outdated on publishing. + * Submissions hashed before these fields were ignored are matched with calculateLegacyHash(). + */ + private function getPublishingFields(): array { - foreach ($this->getRemoveFields() as $part => $keys) { + return [ + 'entity' => [ + 'post_status', + 'post_name', + ], + ]; + } + + private function cleanUpFields(array $fields, bool $legacy = false): array + { + $removeFields = $legacy ? $this->getRemoveFields() : array_merge_recursive($this->getRemoveFields(), $this->getPublishingFields()); + foreach ($removeFields as $part => $keys) { foreach ($keys as $key) { if (array_key_exists($part, $fields) && array_key_exists($key, $fields[$part])) { unset($fields[$part][$key]); @@ -48,6 +64,19 @@ private function cleanUpFields(array $fields): array } public function calculateHash(SubmissionEntity $submission): string + { + return $this->calculateHashInternal($submission, false); + } + + /** + * Hash as it was calculated before publishing related fields were ignored + */ + public function calculateLegacyHash(SubmissionEntity $submission): string + { + return $this->calculateHashInternal($submission, true); + } + + private function calculateHashInternal(SubmissionEntity $submission, bool $legacy): string { $cache = RuntimeCacheHelper::getInstance(); $key = implode( @@ -58,15 +87,16 @@ public function calculateHash(SubmissionEntity $submission): string $submission->getSourceId(), ] ); + $cacheGroup = $legacy ? 'legacyHashCalculator' : 'hashCalculator'; - if (false === ($cached = $cache->get($key, 'hashCalculator'))) { + if (false === ($cached = $cache->get($key, $cacheGroup))) { $collectedContent = $this->collectSubmissionSourceContent($submission); - $collectedContent = $this->cleanUpFields($collectedContent); + $collectedContent = $this->cleanUpFields($collectedContent, $legacy); $serializedContent = serialize($collectedContent); $this->getLogger()->debug(vsprintf('Calculating hash for submission=%s using data=%s', [$submission->getId(), base64_encode($serializedContent)])); $hash = md5($serializedContent); $cached = $hash; - $cache->set($key, $hash, 'hashCalculator'); + $cache->set($key, $hash, $cacheGroup); } return $cached; diff --git a/inc/Smartling/Helpers/DetectChangesHelper.php b/inc/Smartling/Helpers/DetectChangesHelper.php index 876989393..7385934f2 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[] @@ -59,10 +76,10 @@ private function getProfiles(int $blogId): array return $this->settingsManager->findEntityByMainLocale($blogId); } - private function update(SubmissionEntity $submission, bool $needUpdateStatus, string $currentHash): SubmissionEntity + private function update(SubmissionEntity $submission, bool $needUpdateStatus, string $currentHash, ?string $legacyHash = null): SubmissionEntity { $this->getLogger()->debug(vsprintf('Checking submission id=%s.', [$submission->getId()])); - if ($currentHash !== $submission->getSourceContentHash()) { + if ($currentHash !== $submission->getSourceContentHash() && ($legacyHash === null || $legacyHash !== $submission->getSourceContentHash())) { $this->getLogger()->debug( vsprintf('Submission id=%s has outdated hash. Setting up Outdated flag.', [$submission->getId()]) ); @@ -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)) { @@ -129,11 +152,12 @@ public function detectChanges(int $blogId, int $contentId, string $contentType): $profile = $profiles[0]; $currentHash = $this->contentSerializationHelper->calculateHash($submissions[0]); + $legacyHash = $this->contentSerializationHelper->calculateLegacyHash($submissions[0]); $needUpdateStatus = $profile->getUploadOnUpdate() === ConfigurationProfileEntity::UPLOAD_ON_CHANGE_AUTO; foreach ($submissions as $submission) { - $this->update($submission, $needUpdateStatus, $currentHash); + $this->update($submission, $needUpdateStatus, $currentHash, $legacyHash); } $this->submissionManager->storeSubmissions($submissions); diff --git a/inc/config/services.yml b/inc/config/services.yml index 0ae2585c9..3925d1bfe 100644 --- a/inc/config/services.yml +++ b/inc/config/services.yml @@ -229,6 +229,7 @@ services: - "@wp.proxy" - "@multilang.proxy" - "@manager.submission" + - "@detect-changes.helper" content.relations.handler: class: Smartling\Services\ContentRelationsHandler diff --git a/tests/Smartling/Extensions/PublishPressRevisionsTest.php b/tests/Smartling/Extensions/PublishPressRevisionsTest.php index 2d07f70fa..d22a3929d 100644 --- a/tests/Smartling/Extensions/PublishPressRevisionsTest.php +++ b/tests/Smartling/Extensions/PublishPressRevisionsTest.php @@ -5,6 +5,7 @@ use PHPUnit\Framework\TestCase; use Smartling\DbAl\LocalizationPluginProxyInterface; use Smartling\Extensions\PublishPressRevisions; +use Smartling\Helpers\DetectChangesHelper; use Smartling\Helpers\PluginHelper; use Smartling\Helpers\WordpressFunctionProxyHelper; use Smartling\Submissions\SubmissionEntity; @@ -28,12 +29,14 @@ private function x( ?SubmissionManager $manager = null, ?LocalizationPluginProxyInterface $multilang = null, ?WordpressFunctionProxyHelper $wp = null, + ?DetectChangesHelper $detectChanges = null, ): PublishPressRevisions { return new PublishPressRevisions( $this->createMock(PluginHelper::class), $wp ?? $this->createMock(WordpressFunctionProxyHelper::class), $multilang ?? $this->createMock(LocalizationPluginProxyInterface::class), $manager ?? $this->createMock(SubmissionManager::class), + $detectChanges ?? $this->createMock(DetectChangesHelper::class), ); } @@ -120,4 +123,20 @@ public function testParentIsResetOnlyForRevisions(): void $this->assertSame(0, $x->sanitizeTargetField('post_parent', 10, $this->submission(20, 0))); $this->assertSame(10, $x->sanitizeTargetField('post_parent', 10, $this->submission(30, 0))); } + + public function testChangeDetectionIsSuppressedUntilRevisionIsApplied(): void + { + $wp = $this->createMock(WordpressFunctionProxyHelper::class); + $wp->method('get_current_blog_id')->willReturn(1); + $manager = $this->createMock(SubmissionManager::class); + $manager->method('find')->willReturn([]); + $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, $wp, $detectChanges); + + $x->moveSubmissionsToOriginal([], (object)['ID' => 20], (object)['ID' => 10, 'post_type' => 'page']); + $x->resumeChangeDetection(10); + $x->resumeChangeDetection(10); + } } diff --git a/tests/Smartling/Helpers/ContentSerializationHelperTest.php b/tests/Smartling/Helpers/ContentSerializationHelperTest.php new file mode 100644 index 000000000..6dd7e8ee0 --- /dev/null +++ b/tests/Smartling/Helpers/ContentSerializationHelperTest.php @@ -0,0 +1,58 @@ +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); + $this->assertNotSame( + $this->helper($draft)->calculateLegacyHash($this->submission(103)), + $this->helper($published)->calculateLegacyHash($this->submission(104)), + ); + } + + 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)), + ); + } +} From 444906634cbcfe49bc8a65c51d16a85699986e03 Mon Sep 17 00:00:00 2001 From: Vitalii Solovei Date: Wed, 30 Sep 2026 12:16:25 +0200 Subject: [PATCH 4/6] remove legacy content hash fallback, tighten PublishPressRevisions argument checks (WP-934) Co-Authored-By: Claude Sonnet 5.5 --- .../Extensions/PublishPressRevisions.php | 38 ++++++++-------- .../Helpers/ContentSerializationHelper.php | 43 ++++--------------- inc/Smartling/Helpers/DetectChangesHelper.php | 9 ++-- .../Extensions/PublishPressRevisionsTest.php | 4 +- .../ContentSerializationHelperTest.php | 4 -- 5 files changed, 34 insertions(+), 64 deletions(-) diff --git a/inc/Smartling/Extensions/PublishPressRevisions.php b/inc/Smartling/Extensions/PublishPressRevisions.php index 43f721c80..6d84c072f 100644 --- a/inc/Smartling/Extensions/PublishPressRevisions.php +++ b/inc/Smartling/Extensions/PublishPressRevisions.php @@ -14,7 +14,7 @@ /** * PublishPress Revisions stores a revision as a post (post_mime_type = *-revision, post_parent = original) and - * deletes it after applying it to the original. Without this class the submissions of the revision would be removed + * deletes it after applying it to the original. Without this class, the submissions of the revision would be removed * by SubmissionCleanupHelper together with the link to the translated drafts. */ class PublishPressRevisions extends PluggableAbstract implements WPHookInterface @@ -71,21 +71,30 @@ public function register(): void /** * Fires before PublishPress Revisions deletes the applied revision, so submissions can still be preserved. * - * @param array|mixed $update - * @param \WP_Post|object|mixed $revision - * @param \WP_Post|object|mixed $published + * @param \WP_Post $revision + * @param \WP_Post $published */ - public function moveSubmissionsToOriginal($update, $revision, $published) + public function moveSubmissionsToOriginal(mixed $update, mixed $revision, mixed $published) { - $revisionId = (int)($revision->ID ?? 0); - $originalId = (int)($published->ID ?? 0); + 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 moveSubmissionsToOriginal'); + 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 = (string)($published->post_type ?? $revision->post_type ?? 'post'); + $contentType = $published->post_type ?? $revision->post_type ?? 'post'; // the original is about to be updated with the content the submissions were created from $this->detectChangesHelper->suppress($sourceBlogId, $originalId); $this->suppressedOriginals[$originalId] = $sourceBlogId; @@ -103,10 +112,7 @@ public function moveSubmissionsToOriginal($update, $revision, $published) return $update; } - /** - * @param int|mixed $originalId - */ - public function resumeChangeDetection($originalId): void + public function resumeChangeDetection(mixed $originalId): void { $originalId = (int)$originalId; if (array_key_exists($originalId, $this->suppressedOriginals)) { @@ -154,13 +160,9 @@ private function unlink(SubmissionEntity $submission): void } /** - * Target of a revision must be an ordinary post: no revision status and no parent (the original is the parent). - * - * @param mixed $name - * @param mixed $value - * @param mixed $submission + * The target of a revision must be an ordinary post: no revision status and no parent (the original is the parent). */ - public function sanitizeTargetField($name, $value, $submission = null) + public function sanitizeTargetField(mixed $name, mixed $value, mixed $submission = null) { if ($name === 'post_mime_type' && in_array($value, self::REVISION_STATUSES, true)) { return ''; diff --git a/inc/Smartling/Helpers/ContentSerializationHelper.php b/inc/Smartling/Helpers/ContentSerializationHelper.php index bed613ffb..d00a56987 100644 --- a/inc/Smartling/Helpers/ContentSerializationHelper.php +++ b/inc/Smartling/Helpers/ContentSerializationHelper.php @@ -24,6 +24,9 @@ public function getRemoveFields(): array 'post_password', 'post_modified', 'post_modified_gmt', + // change when a draft is published, not a change of content to translate + 'post_status', + 'post_name', ], 'meta' => [ '_edit_lock', @@ -34,25 +37,9 @@ public function getRemoveFields(): array ]; } - /** - * Fields that change when a draft is published without any change in content to translate. - * Ignoring them prevents marking translated submissions outdated on publishing. - * Submissions hashed before these fields were ignored are matched with calculateLegacyHash(). - */ - private function getPublishingFields(): array + private function cleanUpFields(array $fields): array { - return [ - 'entity' => [ - 'post_status', - 'post_name', - ], - ]; - } - - private function cleanUpFields(array $fields, bool $legacy = false): array - { - $removeFields = $legacy ? $this->getRemoveFields() : array_merge_recursive($this->getRemoveFields(), $this->getPublishingFields()); - foreach ($removeFields as $part => $keys) { + foreach ($this->getRemoveFields() as $part => $keys) { foreach ($keys as $key) { if (array_key_exists($part, $fields) && array_key_exists($key, $fields[$part])) { unset($fields[$part][$key]); @@ -64,19 +51,6 @@ private function cleanUpFields(array $fields, bool $legacy = false): array } public function calculateHash(SubmissionEntity $submission): string - { - return $this->calculateHashInternal($submission, false); - } - - /** - * Hash as it was calculated before publishing related fields were ignored - */ - public function calculateLegacyHash(SubmissionEntity $submission): string - { - return $this->calculateHashInternal($submission, true); - } - - private function calculateHashInternal(SubmissionEntity $submission, bool $legacy): string { $cache = RuntimeCacheHelper::getInstance(); $key = implode( @@ -87,16 +61,15 @@ private function calculateHashInternal(SubmissionEntity $submission, bool $legac $submission->getSourceId(), ] ); - $cacheGroup = $legacy ? 'legacyHashCalculator' : 'hashCalculator'; - if (false === ($cached = $cache->get($key, $cacheGroup))) { + if (false === ($cached = $cache->get($key, 'hashCalculator'))) { $collectedContent = $this->collectSubmissionSourceContent($submission); - $collectedContent = $this->cleanUpFields($collectedContent, $legacy); + $collectedContent = $this->cleanUpFields($collectedContent); $serializedContent = serialize($collectedContent); $this->getLogger()->debug(vsprintf('Calculating hash for submission=%s using data=%s', [$submission->getId(), base64_encode($serializedContent)])); $hash = md5($serializedContent); $cached = $hash; - $cache->set($key, $hash, $cacheGroup); + $cache->set($key, $hash, 'hashCalculator'); } return $cached; diff --git a/inc/Smartling/Helpers/DetectChangesHelper.php b/inc/Smartling/Helpers/DetectChangesHelper.php index 7385934f2..4fa4bec35 100644 --- a/inc/Smartling/Helpers/DetectChangesHelper.php +++ b/inc/Smartling/Helpers/DetectChangesHelper.php @@ -15,7 +15,7 @@ class DetectChangesHelper { use LoggerSafeTrait; - /** @var array */ + /** @var array */ private array $suppressed = []; public function __construct( @@ -76,10 +76,10 @@ private function getProfiles(int $blogId): array return $this->settingsManager->findEntityByMainLocale($blogId); } - private function update(SubmissionEntity $submission, bool $needUpdateStatus, string $currentHash, ?string $legacyHash = null): SubmissionEntity + private function update(SubmissionEntity $submission, bool $needUpdateStatus, string $currentHash): SubmissionEntity { $this->getLogger()->debug(vsprintf('Checking submission id=%s.', [$submission->getId()])); - if ($currentHash !== $submission->getSourceContentHash() && ($legacyHash === null || $legacyHash !== $submission->getSourceContentHash())) { + if ($currentHash !== $submission->getSourceContentHash()) { $this->getLogger()->debug( vsprintf('Submission id=%s has outdated hash. Setting up Outdated flag.', [$submission->getId()]) ); @@ -152,12 +152,11 @@ public function detectChanges(int $blogId, int $contentId, string $contentType): $profile = $profiles[0]; $currentHash = $this->contentSerializationHelper->calculateHash($submissions[0]); - $legacyHash = $this->contentSerializationHelper->calculateLegacyHash($submissions[0]); $needUpdateStatus = $profile->getUploadOnUpdate() === ConfigurationProfileEntity::UPLOAD_ON_CHANGE_AUTO; foreach ($submissions as $submission) { - $this->update($submission, $needUpdateStatus, $currentHash, $legacyHash); + $this->update($submission, $needUpdateStatus, $currentHash); } $this->submissionManager->storeSubmissions($submissions); diff --git a/tests/Smartling/Extensions/PublishPressRevisionsTest.php b/tests/Smartling/Extensions/PublishPressRevisionsTest.php index d22a3929d..e9d857253 100644 --- a/tests/Smartling/Extensions/PublishPressRevisionsTest.php +++ b/tests/Smartling/Extensions/PublishPressRevisionsTest.php @@ -75,7 +75,7 @@ public function testExistingOriginalSubmissionWithOlderTargetIsReplaced(): void $manager->expects($this->once())->method('delete')->with($existing); $manager->expects($this->once())->method('storeEntity')->willReturnArgument(0); - $this->x($manager, null, $wp)->moveSubmissionsToOriginal([], (object)['ID' => 20], (object)['ID' => 10, 'post_type' => 'page']); + $this->x($manager, null, $wp)->moveSubmissionsToOriginal([], (object)['ID' => 20, 'post_type' => 'page'], (object)['ID' => 10, 'post_type' => 'page']); } public function testExistingOriginalSubmissionWithNewerTargetIsKept(): void @@ -135,7 +135,7 @@ public function testChangeDetectionIsSuppressedUntilRevisionIsApplied(): void $detectChanges->expects($this->once())->method('resume')->with(1, 10); $x = $this->x($manager, null, $wp, $detectChanges); - $x->moveSubmissionsToOriginal([], (object)['ID' => 20], (object)['ID' => 10, 'post_type' => 'page']); + $x->moveSubmissionsToOriginal([], (object)['ID' => 20, 'post_type' => 'page'], (object)['ID' => 10, 'post_type' => 'page']); $x->resumeChangeDetection(10); $x->resumeChangeDetection(10); } diff --git a/tests/Smartling/Helpers/ContentSerializationHelperTest.php b/tests/Smartling/Helpers/ContentSerializationHelperTest.php index 6dd7e8ee0..5b64f5bd2 100644 --- a/tests/Smartling/Helpers/ContentSerializationHelperTest.php +++ b/tests/Smartling/Helpers/ContentSerializationHelperTest.php @@ -42,10 +42,6 @@ public function testPublishingDoesNotChangeHash(): void $publishedHash = $this->helper($published)->calculateHash($this->submission(102)); $this->assertSame($draftHash, $publishedHash); - $this->assertNotSame( - $this->helper($draft)->calculateLegacyHash($this->submission(103)), - $this->helper($published)->calculateLegacyHash($this->submission(104)), - ); } public function testContentChangeChangesHash(): void From 79fae79d1a941e3d706eff3232070ace7547f324 Mon Sep 17 00:00:00 2001 From: Vitalii Solovei Date: Wed, 30 Sep 2026 12:18:25 +0200 Subject: [PATCH 5/6] remove comment (WP-934) --- inc/Smartling/Helpers/ContentSerializationHelper.php | 1 - 1 file changed, 1 deletion(-) diff --git a/inc/Smartling/Helpers/ContentSerializationHelper.php b/inc/Smartling/Helpers/ContentSerializationHelper.php index d00a56987..18a1ea554 100644 --- a/inc/Smartling/Helpers/ContentSerializationHelper.php +++ b/inc/Smartling/Helpers/ContentSerializationHelper.php @@ -24,7 +24,6 @@ public function getRemoveFields(): array 'post_password', 'post_modified', 'post_modified_gmt', - // change when a draft is published, not a change of content to translate 'post_status', 'post_name', ], From 9f766df11a34f45bef428faed2ca3baa3dc76081 Mon Sep 17 00:00:00 2001 From: Vitalii Solovei Date: Thu, 1 Oct 2026 15:04:25 +0200 Subject: [PATCH 6/6] address PR #634 review comments (WP-934) Move submissions only after the revision was applied, recalculate hash of moved submissions, compare applied dates, fix tests, add readme entry. Co-Authored-By: Claude Sonnet 5.5 --- .../Extensions/PublishPressRevisions.php | 88 +++++++-- inc/config/services.yml | 1 + readme.txt | 4 + .../Extensions/PublishPressRevisionsTest.php | 169 +++++++++++++----- 4 files changed, 209 insertions(+), 53 deletions(-) diff --git a/inc/Smartling/Extensions/PublishPressRevisions.php b/inc/Smartling/Extensions/PublishPressRevisions.php index 6d84c072f..2b0a1ed78 100644 --- a/inc/Smartling/Extensions/PublishPressRevisions.php +++ b/inc/Smartling/Extensions/PublishPressRevisions.php @@ -4,6 +4,7 @@ use Smartling\DbAl\LocalizationPluginProxyInterface; use Smartling\Base\ExportedAPI; +use Smartling\Helpers\ContentSerializationHelper; use Smartling\Helpers\DetectChangesHelper; use Smartling\Helpers\LoggerSafeTrait; use Smartling\Helpers\PluginHelper; @@ -27,12 +28,16 @@ class PublishPressRevisions extends PluggableAbstract implements WPHookInterface /** @var array 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); } @@ -63,18 +68,23 @@ public function register(): void return; } - $this->wpProxy->add_filter('revisionary_apply_revision_data', [$this, 'moveSubmissionsToOriginal'], 10, 3); + $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 deletes the applied revision, so submissions can still be preserved. + * 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 moveSubmissionsToOriginal(mixed $update, mixed $revision, mixed $published) + public function recordPendingMove(mixed $update, mixed $revision, mixed $published) { if (!is_object($revision) || !is_object($published) @@ -83,7 +93,7 @@ public function moveSubmissionsToOriginal(mixed $update, mixed $revision, mixed || !property_exists($published, 'post_type') || !property_exists($revision, 'post_type') ) { - $this->getLogger()->warning('Invalid arguments passed to moveSubmissionsToOriginal'); + $this->getLogger()->warning('Invalid arguments passed to recordPendingMove'); return $update; } $revisionId = ($revision->ID ?? 0); @@ -95,23 +105,58 @@ public function moveSubmissionsToOriginal(mixed $update, mixed $revision, mixed try { $sourceBlogId = $this->wpProxy->get_current_blog_id(); $contentType = $published->post_type ?? $revision->post_type ?? 'post'; - // the original is about to be updated with the content the submissions were created from - $this->detectChangesHelper->suppress($sourceBlogId, $originalId); - $this->suppressedOriginals[$originalId] = $sourceBlogId; + $upToDate = []; foreach ($this->submissionManager->find([ SubmissionEntity::FIELD_SOURCE_BLOG_ID => $sourceBlogId, SubmissionEntity::FIELD_CONTENT_TYPE => $contentType, SubmissionEntity::FIELD_SOURCE_ID => $revisionId, ]) as $submission) { - $this->moveSubmission($submission, $originalId); + // 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 preserve submissions of revision id=$revisionId, original id=$originalId: {$e->getMessage()}"); + $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; @@ -121,7 +166,17 @@ public function resumeChangeDetection(mixed $originalId): void } } - private function moveSubmission(SubmissionEntity $submission, int $originalId): void + /** + * 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(), @@ -131,7 +186,7 @@ private function moveSubmission(SubmissionEntity $submission, int $originalId): ); if ($existing !== null) { - if ($existing->getTargetId() > $submission->getTargetId()) { + if ($this->getAppliedAt($existing) > $this->getAppliedAt($submission)) { // original already has a newer translation, nothing to preserve return; } @@ -141,6 +196,10 @@ private function moveSubmission(SubmissionEntity $submission, int $originalId): $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); @@ -150,6 +209,13 @@ private function moveSubmission(SubmissionEntity $submission, int $originalId): $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 { diff --git a/inc/config/services.yml b/inc/config/services.yml index 3925d1bfe..3d51a7123 100644 --- a/inc/config/services.yml +++ b/inc/config/services.yml @@ -230,6 +230,7 @@ services: - "@multilang.proxy" - "@manager.submission" - "@detect-changes.helper" + - "@content-serialization.helper" content.relations.handler: class: Smartling\Services\ContentRelationsHandler 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 index e9d857253..9c312a167 100644 --- a/tests/Smartling/Extensions/PublishPressRevisionsTest.php +++ b/tests/Smartling/Extensions/PublishPressRevisionsTest.php @@ -5,6 +5,7 @@ use PHPUnit\Framework\TestCase; use Smartling\DbAl\LocalizationPluginProxyInterface; use Smartling\Extensions\PublishPressRevisions; +use Smartling\Helpers\ContentSerializationHelper; use Smartling\Helpers\DetectChangesHelper; use Smartling\Helpers\PluginHelper; use Smartling\Helpers\WordpressFunctionProxyHelper; @@ -13,14 +14,17 @@ class PublishPressRevisionsTest extends TestCase { - private function submission(int $sourceId, int $targetId): SubmissionEntity + private function submission(int $sourceId, int $targetId, ?string $appliedDate = null, string $hash = 'old'): SubmissionEntity { $submission = new SubmissionEntity(); + $submission->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; } @@ -30,23 +34,57 @@ private function x( ?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 ?? $this->createMock(WordpressFunctionProxyHelper::class), + $wp, $multilang ?? $this->createMock(LocalizationPluginProxyInterface::class), $manager ?? $this->createMock(SubmissionManager::class), $detectChanges ?? $this->createMock(DetectChangesHelper::class), + $serialization, ); } - public function testRevisionSubmissionIsMovedToOriginal(): void + 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); - $wp = $this->createMock(WordpressFunctionProxyHelper::class); - $wp->method('get_current_blog_id')->willReturn(1); $manager = $this->createMock(SubmissionManager::class); - $manager->expects($this->once())->method('find')->willReturn([$submission]); + $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') @@ -56,48 +94,111 @@ public function testRevisionSubmissionIsMovedToOriginal(): void $multilang->expects($this->once())->method('unlinkObjects'); $multilang->expects($this->once())->method('linkObjects'); - $update = ['a' => 'b']; - $this->assertSame($update, $this->x($manager, $multilang, $wp)->moveSubmissionsToOriginal( - $update, - (object)['ID' => 20, 'post_type' => 'page'], - (object)['ID' => 10, 'post_type' => 'page'], - )); + $x = $this->x($manager, $multilang); + $x->recordPendingMove([], $this->revision(), $this->original()); + $x->moveSubmissions(20); + $x->moveSubmissions(20); } - public function testExistingOriginalSubmissionWithOlderTargetIsReplaced(): void + public function testUnrelatedPostDeletionDoesNothing(): void { - $existing = $this->submission(10, 100); - $wp = $this->createMock(WordpressFunctionProxyHelper::class); - $wp->method('get_current_blog_id')->willReturn(1); $manager = $this->createMock(SubmissionManager::class); - $manager->method('find')->willReturn([$this->submission(20, 200)]); + $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); - $this->x($manager, null, $wp)->moveSubmissionsToOriginal([], (object)['ID' => 20, 'post_type' => 'page'], (object)['ID' => 10, 'post_type' => 'page']); + $x = $this->x($manager); + $x->recordPendingMove([], $this->revision(), $this->original()); + $x->moveSubmissions(20); } - public function testExistingOriginalSubmissionWithNewerTargetIsKept(): void + public function testExistingOriginalSubmissionWithNewerTranslationIsKept(): void { - $wp = $this->createMock(WordpressFunctionProxyHelper::class); - $wp->method('get_current_blog_id')->willReturn(1); $manager = $this->createMock(SubmissionManager::class); - $manager->method('find')->willReturn([$this->submission(20, 200)]); - $manager->method('findTargetBlogSubmission')->willReturn($this->submission(10, 300)); + $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'); - $this->x($manager, null, $wp)->moveSubmissionsToOriginal([], (object)['ID' => 20], (object)['ID' => 10, 'post_type' => 'page']); + $x = $this->x($manager); + $x->recordPendingMove([], $this->revision(), $this->original()); + $x->moveSubmissions(20); } public function testFailureDoesNotBreakRevisionPublishing(): void { $manager = $this->createMock(SubmissionManager::class); - $manager->method('find')->willThrowException(new \RuntimeException('db down')); + $manager->expects($this->once())->method('find')->willThrowException(new \RuntimeException('db down')); $update = ['a' => 'b']; - $this->assertSame($update, $this->x($manager)->moveSubmissionsToOriginal($update, (object)['ID' => 20], (object)['ID' => 10])); + $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 @@ -123,20 +224,4 @@ public function testParentIsResetOnlyForRevisions(): void $this->assertSame(0, $x->sanitizeTargetField('post_parent', 10, $this->submission(20, 0))); $this->assertSame(10, $x->sanitizeTargetField('post_parent', 10, $this->submission(30, 0))); } - - public function testChangeDetectionIsSuppressedUntilRevisionIsApplied(): void - { - $wp = $this->createMock(WordpressFunctionProxyHelper::class); - $wp->method('get_current_blog_id')->willReturn(1); - $manager = $this->createMock(SubmissionManager::class); - $manager->method('find')->willReturn([]); - $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, $wp, $detectChanges); - - $x->moveSubmissionsToOriginal([], (object)['ID' => 20, 'post_type' => 'page'], (object)['ID' => 10, 'post_type' => 'page']); - $x->resumeChangeDetection(10); - $x->resumeChangeDetection(10); - } }