Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
252 changes: 252 additions & 0 deletions inc/Smartling/Extensions/PublishPressRevisions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
<?php

namespace Smartling\Extensions;

use Smartling\DbAl\LocalizationPluginProxyInterface;
use Smartling\Base\ExportedAPI;
use Smartling\Helpers\ContentSerializationHelper;
use Smartling\Helpers\DetectChangesHelper;
use Smartling\Helpers\LoggerSafeTrait;
use Smartling\Helpers\PluginHelper;
use Smartling\Helpers\WordpressFunctionProxyHelper;
use Smartling\Submissions\SubmissionEntity;
use Smartling\Submissions\SubmissionManager;
use Smartling\WP\WPHookInterface;

/**
* 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
* by SubmissionCleanupHelper together with the link to the translated drafts.
*/
class PublishPressRevisions extends PluggableAbstract implements WPHookInterface
{
use LoggerSafeTrait;

public const REVISION_STATUSES = ['draft-revision', 'pending-revision', 'future-revision'];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How exactly connector works with these revisions? Is there a way to setup content from which state we grab and send to smartling? Like, draft or published.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The way the revisions plugin works, it is possible to pick any revision and send content for translation. The download will, however, apply to the last modified version on the target site

public const BASE_POST_META = '_rvy_base_post_id';

/** @var array<int, int> original post id => blog id */
private array $suppressedOriginals = [];

/** @var array<int, array{originalId: int, blogId: int, contentType: string, upToDate: array<int, bool>}> 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The moved submissions keep the hash calculated from the revision post. That hash includes revision-only meta such as _rvy_base_post_id, and _requested_slug where present, which the original doesn't have. Suppression only covers the apply request, so the next unrelated save of the original marks every moved submission Outdated, and re-uploads them with auto-upload.

Suggestion: here, recalculate and store source_content_hash for the moved submissions that were up to date before the move. Alternatively, add _rvy_* meta to ContentSerializationHelper::getRemoveFields()['meta'].

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed by checking while the revision is intact

{
$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);
}
}
2 changes: 2 additions & 0 deletions inc/Smartling/Helpers/ContentSerializationHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ public function getRemoveFields(): array
'post_password',
'post_modified',
'post_modified_gmt',
'post_status',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Upgrade impact: every existing submission becomes outdated. Every source_content_hash already stored was calculated with post_status and post_name included. Commit 4449066 removed the legacy-hash fallback, so after upgrade the next save_post on any translated post (even a no-op save) fails the hash comparison in DetectChangesHelper::update(). That marks the submissions Outdated. On profiles with UPLOAD_ON_CHANGE_AUTO, it also sets them to New and queues a re-upload, so this would trigger a mass re-upload across all installs.

Please bring back the fallback (also accept the hash calculated the old way, then store the new hash), or add a migration that recalculates stored hashes. This should also get a readme/changelog entry.

Also, excluding post_name globally means a slug change on any post no longer marks its translations outdated, not just for revisions. Is that intended?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's a tradeoff I took. Saving a post is usually a manual operation. Post slugs change is intended as well.

'post_name',
],
'meta' => [
'_edit_lock',
Expand Down
23 changes: 23 additions & 0 deletions inc/Smartling/Helpers/DetectChangesHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ class DetectChangesHelper
{
use LoggerSafeTrait;

/** @var array<string, bool> */
private array $suppressed = [];

public function __construct(
private AcfDynamicSupport $acfDynamicSupport,
private ContentSerializationHelper $contentSerializationHelper,
Expand All @@ -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[]
Expand Down Expand Up @@ -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)) {
Expand Down
1 change: 1 addition & 0 deletions inc/config/register-on-startup.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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" ]]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just an idea, can we register these services dynamically? Like this service only makes sense to be loaded if current WP instance really has publishpress plugin installed, seems like.

No action points, just an idea.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It checks in the register handler if the plugin is active and of supported version, and if not, registers no hooks

- [ "addService", [ "@helper.shortcode" ]]
- [ "addService", [ "@helper.gutenberg" ]]
- [ "addService", [ "@meta-field.processor.manager" ]]
Expand Down
10 changes: 10 additions & 0 deletions inc/config/services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
}),
Expand Down
4 changes: 4 additions & 0 deletions readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading
Loading