feat: Seiten-Umschreiber für site-weite Preset-Änderungen (Probelauf, höchstens 50 Seiten, Revision und Protokoll je Seite)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-18 16:59:39 +02:00
co-authored by Claude Opus 5.5
parent d36e8286e8
commit d7a991d130
3 changed files with 1104 additions and 0 deletions
@@ -0,0 +1,533 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use Closure;
use Generator;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\FollowUp;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
use LIT\MCP\Integrations\Divi\Library\UsageQuery;
use LIT\MCP\Integrations\Divi\Library\UsageReport;
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Log;
use WP_Error;
use WP_Post;
defined('ABSPATH') || exit;
/**
* The page rewriter of spec §6.6 (rulings R1, R8, R9) – the shared step of lit/divi-presets-assign and
* lit/divi-design-detach: it changes references in many posts at once. Usages come from GlobalUsages (reportFor()
* for the rights, rowsFor() for the candidates, ID ASC); each candidate is loaded fresh (loadWritable(), no
* tree_rev), rewritten by TreeRewrite with a fresh NodeRewrite and – in apply(), after the caller's confirm – saved
* by DiviSaver (restore point revision, Divi flags, caches; its kses window like every Divi write, R9: only preset
* references and local values change, no client content) with lit_mcp_after_save and a change log entry per post.
* - A live usage the caller may not edit blocks the whole write (ruling P8a, like GlobalEdits); above
* GlobalUsages::HARD_CAP live usages the change is refused. With post_ids only those posts count – each one is
* checked by loadWritable() anyway.
* - At most PAGE_LIMIT posts are saved per call (A6); "remaining" counts the further posts with changes. Every
* candidate is rewritten in memory for that, in chunks of GlobalUsages::CONTENT_CHUNK with a primed post cache
* that is dropped afterwards (audit L1).
* - Skipped (named, never written): posts loadWritable() refuses (rights, Divi 4, unparsable markup – R8: not
* remaining, so repeated calls end), global instances the change concerns (their library entry is a usage of its
* own), library entries shown on live posts the caller may not edit (P8a), and library entries or Theme Builder
* layouts visible site-wide without revisions (no restore point; a bulk write makes no backup copies). Posts the
* caller may not read are never named, only counted (hidden_count, audit L4).
* - Save scope as in DiviWriter. The rewrite changes attributes only, so a library entry keeps its shape
* (LibraryContent::shapeViolation() needs no check).
* - A failed save after other posts were saved answers partial_write with data.saved; a failure before the first
* saved post is thrown like in every Divi write (WriteFailed, DiviLocked).
*
* Not final so abilities can be tested with a mock (like GlobalUsages).
*/
class PageRewriter
{
/** Posts saved per confirmed call (A6); the dry run lists as many. */
public const PAGE_LIMIT = 50;
/** A post named in post_ids that the call does not change – the same text whatever the reason (audit L4). */
public const NO_CHANGE = 'Keine Änderung: Der Eintrag existiert nicht, ist nicht lesbar oder enthält kein '
. 'betroffenes Modul.';
/** Messages name at most this many post IDs. */
private const LISTED_IDS = 10;
public function __construct(
private GlobalUsages $usages,
private DiviPageLoader $loader,
private DiviSaver $saver,
private ChangeLog $log
) {
}
/**
* The dry run: what apply() would write, without writing. Pages are listed up to PAGE_LIMIT, the counts cover
* every post with changes. Refused only above GlobalUsages::HARD_CAP live usages (without post_ids); posts that
* would block the write are named in blocked_ids or counted in blocked_hidden_count.
*
* @param Closure(): NodeRewrite $rewrite A fresh strategy per post.
* @param list<int>|null $postIds Only these posts; null = every usage.
* @return array{pages: list<array{post_id: int, title: string, type: string, status: string,
* nodes: list<array{id: string, module: string, label: string}>, warnings: list<string>}>,
* pages_count: int, nodes_count: int, skipped: list<array{post_id: int, reason: string}>,
* blocked_ids: list<int>, blocked_hidden_count: int, hidden_count: int}|WP_Error
*/
public function preview(UsageQuery $query, Closure $rewrite, ?array $postIds): array|WP_Error
{
$ids = self::ids($postIds);
$report = $this->usages->reportFor($query);
if ($ids === null && $report->tooMany) {
return self::tooMany($report);
}
$skipped = [];
$pages = [];
$pagesCount = 0;
$nodesCount = 0;
foreach ($this->changes($query, $rewrite, $ids, $skipped) as $change) {
++$pagesCount;
$nodesCount += count($change['result']['changed']);
if (count($pages) < self::PAGE_LIMIT) {
$pages[] = self::listing($change);
}
}
$blocked = self::blocked($report, $ids);
return [
'pages' => $pages,
'pages_count' => $pagesCount,
'nodes_count' => $nodesCount,
'skipped' => $skipped,
'blocked_ids' => $blocked['ids'],
'blocked_hidden_count' => $blocked['hidden'],
'hidden_count' => $ids === null ? $report->hidden : 0,
];
}
/**
* Writes at most PAGE_LIMIT posts – call it only after the caller's confirm. Each saved post gets its revision
* (DiviSaver) and a change log entry "Divi "<title>": $summary".
*
* @param Closure(): NodeRewrite $rewrite A fresh strategy per post.
* @param list<int>|null $postIds Only these posts; null = every usage.
* @return array{saved: list<array{post_id: int, revision_id: ?int, nodes: int}>, remaining: int,
* warnings: list<string>, skipped: list<array{post_id: int, reason: string}>}|WP_Error
* unsupported (above HARD_CAP), forbidden (blocked), partial_write (data.saved)
* @throws WriteFailed When saving the first post fails.
* @throws DiviLocked When Divi is not released – before the first post is written.
*/
public function apply(
string $ability,
UsageQuery $query,
Closure $rewrite,
?array $postIds,
string $summary
): array|WP_Error {
$ids = self::ids($postIds);
$refusal = $this->refusal($this->usages->reportFor($query), $ids);
if ($refusal !== null) {
return $refusal;
}
$skipped = [];
$saved = [];
$warnings = [];
$remaining = 0;
foreach ($this->changes($query, $rewrite, $ids, $skipped) as $change) {
if (count($saved) >= self::PAGE_LIMIT) {
++$remaining;
continue;
}
try {
$saved[] = $this->save($ability, $change, $summary, $warnings);
} catch (WriteFailed | DiviLocked $e) {
return $this->failed($ability, $change['page']->post, $e, $saved);
}
}
return ['saved' => $saved, 'remaining' => $remaining, 'warnings' => $warnings, 'skipped' => $skipped];
}
/**
* Every candidate post that the rewrite changes, one after the other (ID order). Candidates are read in chunks
* with a primed post cache that is dropped afterwards (audit L1). Posts left alone with a reason are added to
* $skipped; post_ids that changed nothing and have no other reason get NO_CHANGE at the end.
*
* @param Closure(): NodeRewrite $rewrite
* @param list<int>|null $ids
* @param list<array{post_id: int, reason: string}> $skipped
* @return Generator<int, array{page: DiviPage, result: array{changed: list<array{id: string, module: string,
* label: string}>, warnings: list<string>, skipped: list<array{id: string, reason: string}>},
* content: string, scope: SaveScope}>
*/
private function changes(UsageQuery $query, Closure $rewrite, ?array $ids, array &$skipped): Generator
{
$rows = $this->usages->rowsFor($query);
if ($ids !== null) {
$rows = array_filter($rows, static fn (array $row): bool => in_array($row['id'], $ids, true));
}
$seen = [];
foreach (array_chunk(array_column($rows, 'id'), GlobalUsages::CONTENT_CHUNK) as $chunk) {
$fresh = _get_non_cached_ids($chunk, 'posts');
_prime_post_caches($chunk, false, false);
try {
foreach ($chunk as $id) {
$before = count($skipped);
$change = $this->change((int) $id, $rewrite, $skipped);
if ($change !== null || count($skipped) > $before) {
$seen[] = (int) $id;
}
if ($change !== null) {
yield $change;
}
}
} finally {
foreach ($fresh as $freshId) {
wp_cache_delete((int) $freshId, 'posts');
}
}
}
foreach (array_diff($ids ?? [], $seen) as $id) {
$skipped[] = ['post_id' => $id, 'reason' => self::NO_CHANGE];
}
}
/**
* Post $id loaded fresh and rewritten in memory; null when nothing changes or the post is left alone (then
* with a reason in $skipped, unless the caller may not see it).
*
* @param Closure(): NodeRewrite $rewrite
* @param list<array{post_id: int, reason: string}> $skipped
* @return array{page: DiviPage, result: array{changed: list<array{id: string, module: string, label: string}>,
* warnings: list<string>, skipped: list<array{id: string, reason: string}>}, content: string,
* scope: SaveScope}|null
*/
private function change(int $id, Closure $rewrite, array &$skipped): ?array
{
if (! current_user_can('read_post', $id)) {
return null;
}
$page = $this->loader->loadWritable($id);
if ($page instanceof WP_Error) {
// A hidden library entry or Theme Builder layout reads like a missing ID (P12): never named.
if (Errors::codeOf($page) !== 'not_found') {
$skipped[] = ['post_id' => $id, 'reason' => Errors::clientMessage($page)];
}
return null;
}
$result = (new TreeRewrite($rewrite()))->apply($page->index);
foreach ($result['skipped'] as $node) {
$skipped[] = ['post_id' => $id, 'reason' => $node['id'] . ': ' . $node['reason']];
}
$content = Serializer::document($page->document);
if ($result['changed'] === [] || $content === (string) $page->post->post_content) {
return null;
}
$scope = $this->scope($page);
if (is_string($scope)) {
$skipped[] = ['post_id' => $id, 'reason' => $scope];
return null;
}
return ['page' => $page, 'result' => $result, 'content' => $content, 'scope' => $scope];
}
/**
* The save scope of $page as DiviWriter chooses it, or why the rewriter leaves $page alone: a library entry
* shown on live posts the caller may not edit (P8a), a site-wide entry or a Theme Builder layout without
* revisions (no restore point).
*/
private function scope(DiviPage $page): SaveScope|string
{
$post = $page->post;
$id = (int) $post->ID;
if ($page->isThemeBuilderLayout()) {
return self::withoutRevisions($post, sprintf('Theme-Builder-Layout %d', $id))
?? SaveScope::ThemeBuilderLayout;
}
if (! $page->isLibraryItem()) {
return SaveScope::Page;
}
$report = $this->usages->report($id);
$global = $this->loader->library()?->isGlobal($post) ?? false;
if (! $global && ! $report->exists()) {
return SaveScope::LibraryItem;
}
if ($report->blocks()) {
return self::blockedEntry($id, $report);
}
return self::withoutRevisions($post, sprintf('Bibliothekseintrag %d', $id)) ?? SaveScope::GlobalItem;
}
/**
* Saves one changed post, then lit_mcp_after_save and the change log (failures there are warnings – the post
* is saved). Warnings join $warnings with the post ID in front.
*
* @param array{page: DiviPage, result: array{changed: list<array{id: string, module: string, label: string}>,
* warnings: list<string>, skipped: list<array{id: string, reason: string}>}, content: string,
* scope: SaveScope} $change
* @param list<string> $warnings
* @return array{post_id: int, revision_id: ?int, nodes: int}
* @throws WriteFailed
* @throws DiviLocked
*/
private function save(string $ability, array $change, string $summary, array &$warnings): array
{
$post = $change['page']->post;
$id = (int) $post->ID;
$saved = $this->saver->save($post, $change['content'], $change['scope']);
$notes = array_merge($change['result']['warnings'], $saved['warnings']);
FollowUp::run(
static fn () => do_action('lit_mcp_after_save', $id, $ability, ['post_id' => $id]),
$ability . ' lit_mcp_after_save',
'Eine Erweiterung ist nach dem Speichern auf einen Fehler gestoßen; die Änderung ist gespeichert.',
$notes
);
FollowUp::run(
fn () => $this->log->record(
$ability,
(string) $post->post_type,
(string) $id,
sprintf('Divi "%s": %s', $post->post_title, $summary),
$saved['revision_id']
),
$ability . ' change log',
'Änderungsprotokoll konnte nicht geschrieben werden; die Änderung ist gespeichert.',
$notes
);
foreach ($notes as $note) {
$warnings[] = sprintf('Eintrag %d: %s', $id, $note);
}
return [
'post_id' => $id,
'revision_id' => $saved['revision_id'],
'nodes' => count($change['result']['changed']),
];
}
/**
* Saving $post failed after the posts in $saved: they stay saved (each with its revision) – partial_write
* with data.saved. A post the saver could not roll back is logged as partial (like DiviWriter).
*
* @param list<array{post_id: int, revision_id: ?int, nodes: int}> $saved
* @throws WriteFailed $e itself when nothing was saved before (the registrar answers as for every Divi write).
* @throws DiviLocked $e itself when nothing was saved before.
*/
private function failed(string $ability, WP_Post $post, WriteFailed|DiviLocked $e, array $saved): WP_Error
{
$id = (int) $post->ID;
$partial = $e instanceof WriteFailed && ! $e->rolledBack;
$revisionId = $e instanceof WriteFailed ? $e->revisionId : null;
if ($partial) {
$this->log->recordPartial(
$ability,
(string) $post->post_type,
(string) $id,
sprintf('Divi "%s": Änderung nur teilweise zurückgenommen', $post->post_title),
$revisionId
);
}
if ($saved === []) {
throw $e;
}
Log::failure(sprintf('%s: post %d after %d saved posts', $ability, $id, count($saved)), $e);
$reason = $e instanceof WriteFailed ? $e->reason : Errors::clientMessage($e->toError());
$count = count($saved);
$failedPost = $partial
? sprintf('Eintrag %d ist möglicherweise teilweise geändert', $id)
. ($revisionId === null ? '.' : sprintf(' (Stand vorher: Revision %d).', $revisionId))
: sprintf('Eintrag %d blieb unverändert.', $id);
return Errors::partialWrite(
sprintf('Eintrag %d: %s', $id, $reason),
$id,
$revisionId,
sprintf(
'%d %s davor %s gespeichert (siehe saved, je mit Revision); %s',
$count,
$count === 1 ? 'Eintrag' : 'Einträge',
$count === 1 ? 'ist' : 'sind',
$failedPost
),
sprintf(
'Den Aufruf nicht einfach wiederholen. Zuerst Eintrag %d mit lit-divi-outline prüfen und dem Nutzer '
. 'Bescheid geben; danach denselben Aufruf mit confirm: true erneut senden – bereits geänderte '
. 'Module trifft er nicht mehr.',
$id
),
['saved' => $saved]
);
}
/** Ruling P8a like GlobalEdits::pagesRefusal(): above HARD_CAP or with a blocked live usage nothing is written. */
private function refusal(UsageReport $report, ?array $ids): ?WP_Error
{
if ($ids === null && $report->tooMany) {
return self::tooMany($report);
}
$blocked = self::blocked($report, $ids);
if ($blocked['ids'] === [] && $blocked['hidden'] === 0) {
return null;
}
return Errors::make(
'forbidden',
sprintf(
'Die Änderung betrifft veröffentlichte, private oder geplante Einträge, die dieses Konto nicht '
. 'bearbeiten darf (%s); nichts wurde gespeichert.',
self::blockedList($blocked['ids'], $blocked['hidden'])
),
'Die Änderung von jemandem vornehmen lassen, der alle diese Einträge bearbeiten darf (z. B. Redakteur '
. 'oder Administrator), oder sie mit post_ids auf die eigenen Einträge beschränken.',
403,
['blocked_ids' => $blocked['ids']]
+ ($blocked['hidden'] > 0 ? ['blocked_hidden_count' => $blocked['hidden']] : [])
);
}
private static function tooMany(UsageReport $report): WP_Error
{
return Errors::unsupported(
sprintf(
'Die Änderung betrifft %d veröffentlichte, private oder geplante Einträge – mehr als %d; so große '
. 'Änderungen nimmt LIT MCP nicht vor. Nichts wurde gespeichert.',
$report->live,
GlobalUsages::HARD_CAP
),
'Die Änderung mit post_ids auf einzelne Einträge beschränken oder die Module im Divi-Builder ändern.'
);
}
/**
* The blocking posts that count: all of the report, or with post_ids only those among them (unreadable ones
* cannot be matched to IDs; loadWritable() refuses them one by one).
*
* @param list<int>|null $ids
* @return array{ids: list<int>, hidden: int}
*/
private static function blocked(UsageReport $report, ?array $ids): array
{
if ($ids === null) {
return ['ids' => $report->blocked, 'hidden' => $report->blockedHidden];
}
return ['ids' => array_values(array_intersect($report->blocked, $ids)), 'hidden' => 0];
}
/** Why library entry $id stays unchanged: pages that show it block the edit (P8a, audit L1). */
private static function blockedEntry(int $id, UsageReport $report): string
{
if ($report->tooMany) {
return sprintf(
'Bibliothekseintrag %d erscheint auf mehr als %d veröffentlichten, privaten oder geplanten Einträgen; '
. 'er bleibt unverändert – ihn im Divi-Builder bearbeiten.',
$id,
GlobalUsages::HARD_CAP
);
}
return sprintf(
'Bibliothekseintrag %d erscheint auf veröffentlichten, privaten oder geplanten Einträgen, die dieses Konto '
. 'nicht bearbeiten darf (%s); er bleibt unverändert.',
$id,
self::blockedList($report->blocked, $report->blockedHidden)
);
}
/** Why $post stays unchanged when revisions are off for it (no restore point, no backup copy in bulk). */
private static function withoutRevisions(WP_Post $post, string $label): ?string
{
if (wp_revisions_enabled($post)) {
return null;
}
return sprintf(
'Revisionen sind für %s abgeschaltet (kein Wiederherstellungspunkt); unverändert gelassen – Revisionen '
. 'aktivieren (WP_POST_REVISIONS) oder im Divi-Builder bearbeiten.',
$label
);
}
/**
* Readable blocking posts by ID (at most LISTED_IDS), unreadable ones as a count – as GlobalEdits names them.
*
* @param list<int> $ids
*/
private static function blockedList(array $ids, int $hidden): string
{
$parts = [];
if ($ids !== []) {
$more = count($ids) - self::LISTED_IDS;
$parts[] = 'IDs ' . implode(', ', array_slice($ids, 0, self::LISTED_IDS))
. ($more > 0 ? sprintf(' und %d weitere', $more) : '');
}
if ($hidden > 0) {
$parts[] = sprintf(
'%d %s dieses Konto nicht lesen darf',
$hidden,
$hidden === 1 ? 'Eintrag, den' : 'Einträge, die'
);
}
return implode('; ', $parts);
}
/**
* @param array{page: DiviPage, result: array{changed: list<array{id: string, module: string, label: string}>,
* warnings: list<string>, skipped: list<array{id: string, reason: string}>}, content: string,
* scope: SaveScope} $change
* @return array{post_id: int, title: string, type: string, status: string,
* nodes: list<array{id: string, module: string, label: string}>, warnings: list<string>}
*/
private static function listing(array $change): array
{
$post = $change['page']->post;
return [
'post_id' => (int) $post->ID,
'title' => (string) $post->post_title,
'type' => (string) $post->post_type,
'status' => (string) $post->post_status,
'nodes' => $change['result']['changed'],
'warnings' => $change['result']['warnings'],
];
}
/**
* @param array<mixed>|null $postIds
* @return list<int>|null
*/
private static function ids(?array $postIds): ?array
{
return $postIds === null ? null : array_values(array_unique(array_map('intval', $postIds)));
}
}
+10
View File
@@ -17,6 +17,7 @@ use LIT\MCP\Integrations\Divi\Design\DiviDesignRestoreAbility;
use LIT\MCP\Integrations\Divi\Design\DiviDesignSnapshotsAbility; use LIT\MCP\Integrations\Divi\Design\DiviDesignSnapshotsAbility;
use LIT\MCP\Integrations\Divi\Design\DiviPresetsDeleteAbility; use LIT\MCP\Integrations\Divi\Design\DiviPresetsDeleteAbility;
use LIT\MCP\Integrations\Divi\Design\DiviPresetsSetAbility; use LIT\MCP\Integrations\Divi\Design\DiviPresetsSetAbility;
use LIT\MCP\Integrations\Divi\Design\PageRewriter;
use LIT\MCP\Integrations\Divi\Design\PresetComposer; use LIT\MCP\Integrations\Divi\Design\PresetComposer;
use LIT\MCP\Integrations\Divi\Design\SnapshotStore; use LIT\MCP\Integrations\Divi\Design\SnapshotStore;
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore; use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
@@ -260,6 +261,15 @@ final class DiviIntegration implements Integration
return new DesignChange(new SnapshotStore(), $this->log); return new DesignChange(new SnapshotStore(), $this->log);
} }
/**
* The page rewriter of lit/divi-presets-assign and lit/divi-design-detach (Etappe 4b, spec §6.6): usages with
* this integration's Theme Builder access, posts through $loader (shared with the other Divi tools).
*/
private function pageRewriter(DiviPageLoader $loader): PageRewriter
{
return new PageRewriter($this->globalUsages(), $loader, new DiviSaver(), $this->log);
}
private function presetComposer(): PresetComposer private function presetComposer(): PresetComposer
{ {
$composition = $this->composition(); $composition = $this->composition();
@@ -0,0 +1,561 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use Brain\Monkey\Functions;
use Closure;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
use LIT\MCP\Integrations\Divi\Design\NodeRewrite;
use LIT\MCP\Integrations\Divi\Design\PageRewriter;
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
use LIT\MCP\Integrations\Divi\Library\UsageQuery;
use LIT\MCP\Integrations\Divi\Library\UsageReport;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Support\LibraryFixtures;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use Throwable;
use WP_Post;
/** Spec §6.6, rulings R1, R8, R9: the page rewriter behind lit/divi-presets-assign and lit/divi-design-detach. */
final class PageRewriterTest extends TestCase
{
/** The overlap note the test strategy adds to every block it changes. */
public const NOTE = 'lokale Werte überdecken „CTA“';
private const ABILITY = 'lit/divi-presets-assign';
private const SUMMARY = 'Preset „CTA“ zugewiesen';
private const BUTTON = '<!-- wp:divi/button {"builderVersion":"5.2.1"} /-->';
private const TEXT = '<!-- wp:divi/text {"builderVersion":"5.2.1"} /-->';
private const INSTANCE = '<!-- wp:divi/global-layout {"globalModule":"1059","blockName":"divi/button"} /-->';
/** @var list<array{int, SaveScope}> Post ID and scope of every save. */
private array $saved = [];
/** @var list<Throwable|null> Thrown by the n-th save instead of saving (null = save). */
private array $failures = [];
private FakeChangeLog $log;
protected function setUp(): void
{
parent::setUp();
Functions\when('_get_non_cached_ids')->returnArg(1);
Functions\when('_prime_post_caches')->justReturn(null);
Functions\when('wp_cache_delete')->justReturn(true);
Functions\when('wp_revisions_enabled')->justReturn(true);
$this->log = new FakeChangeLog(1);
}
/** One section, row and column holding $modules (IDs s0.r0.c0.m0, …). */
private static function layout(string ...$modules): string
{
return '<!-- wp:divi/placeholder --><!-- wp:divi/section {"builderVersion":"5.2.1"} -->'
. '<!-- wp:divi/row {"builderVersion":"5.2.1"} --><!-- wp:divi/column {"builderVersion":"5.2.1"} -->'
. implode('', $modules)
. '<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->';
}
/** @param array<string, mixed> $fields */
private static function page(int $id, string $content, array $fields = []): WP_Post
{
return new WP_Post(array_merge([
'ID' => $id,
'post_type' => 'page',
'post_status' => 'draft',
'post_title' => 'Seite ' . $id,
'post_content' => $content,
], $fields));
}
/** @param list<int> $blocked */
private static function report(
int $count,
array $blocked = [],
int $live = 0,
bool $tooMany = false,
int $hidden = 0
): UsageReport {
return new UsageReport($count, [], $hidden, $live, $blocked, 0, $tooMany);
}
/**
* The rewriter over $posts: they are the candidates (ID order) and what get_post() finds; the caller has every
* capability. $report answers reportFor() (default: all posts, nothing blocked), $libraryReports report($id).
*
* @param list<WP_Post> $posts
* @param array<int, UsageReport> $libraryReports
*/
private function rewriter(array $posts, ?UsageReport $report = null, array $libraryReports = []): PageRewriter
{
LibraryFixtures::given($posts);
$rows = array_map(
static fn (WP_Post $post): array => [
'id' => (int) $post->ID,
'status' => (string) $post->post_status,
'type' => (string) $post->post_type,
],
$posts
);
$usages = Mockery::mock(GlobalUsages::class);
$usages->allows('rowsFor')->andReturn($rows);
$usages->allows('reportFor')->andReturn($report ?? self::report(count($rows)));
$usages->allows('report')->andReturnUsing(
static fn (int $id): UsageReport => $libraryReports[$id] ?? self::report(0)
);
return new PageRewriter($usages, LibraryFixtures::loader(), $this->saver(), $this->log);
}
/** Records post ID and scope, puts the content on the post and reports revision 900 + n. */
private function saver(): DiviSaver
{
$saver = Mockery::mock(DiviSaver::class);
$saver->allows('save')->andReturnUsing(function (WP_Post $post, string $content, SaveScope $scope): array {
$failure = array_shift($this->failures);
if ($failure !== null) {
throw $failure;
}
$this->saved[] = [(int) $post->ID, $scope];
$post->post_content = $content;
return ['revision_id' => 900 + count($this->saved), 'warnings' => []];
});
return $saver;
}
/**
* A strategy like AssignRewrite: every block of $modules gets the preset stack ["p-cta"] (a block that has it
* stays), each changed block an overlap note; global instances of these modules concern the change.
*
* @param list<string> $modules
* @return Closure(): NodeRewrite
*/
private static function assign(array $modules = ['divi/button']): Closure
{
return static fn (): NodeRewrite => new class ($modules) implements NodeRewrite {
/** @param list<string> $modules */
public function __construct(private array $modules)
{
}
public function rewrite(Block $block): ?array
{
$attrs = $block->attrs();
if (! in_array($block->name(), $this->modules, true) || self::assigned($attrs)) {
return null;
}
return ['modulePreset' => ['p-cta']] + $attrs;
}
public function notes(Block $block, string $nodeId): array
{
return self::assigned($block->attrs()) ? [$nodeId . ': ' . PageRewriterTest::NOTE] : [];
}
public function concernsInstance(Block $instance): bool
{
return in_array(GlobalInstance::effectiveName($instance), $this->modules, true);
}
/** @param array<string, mixed> $attrs */
private static function assigned(array $attrs): bool
{
return ($attrs['modulePreset'] ?? null) === ['p-cta'];
}
};
}
private static function query(): UsageQuery
{
return Mockery::mock(UsageQuery::class);
}
/** Runs $call with error_log() going to a temp file (Log::failure() writes there). */
private static function quietly(Closure $call): mixed
{
$file = (string) tempnam(sys_get_temp_dir(), 'lit-log');
$previous = ini_set('error_log', $file);
try {
return $call();
} finally {
ini_set('error_log', (string) $previous);
unlink($file);
}
}
public function testPreviewListsPagesAndNodesWithoutWriting(): void
{
$rewriter = $this->rewriter(
[
self::page(101, self::layout(self::BUTTON, self::TEXT, self::BUTTON)),
self::page(102, self::layout(self::BUTTON), ['post_status' => 'publish']),
],
self::report(2, [], 1, false, 3)
);
$preview = $rewriter->preview(self::query(), self::assign(), null);
self::assertSame(2, $preview['pages_count']);
self::assertSame(3, $preview['nodes_count']);
self::assertSame([101, 102], array_column($preview['pages'], 'post_id'));
self::assertSame('Seite 101', $preview['pages'][0]['title']);
self::assertSame('page', $preview['pages'][0]['type']);
self::assertSame('draft', $preview['pages'][0]['status']);
self::assertSame('publish', $preview['pages'][1]['status']);
self::assertSame(['s0.r0.c0.m0', 's0.r0.c0.m2'], array_column($preview['pages'][0]['nodes'], 'id'));
self::assertSame(
['s0.r0.c0.m0: ' . self::NOTE, 's0.r0.c0.m2: ' . self::NOTE],
$preview['pages'][0]['warnings']
);
self::assertSame([], $preview['skipped']);
self::assertSame([], $preview['blocked_ids']);
self::assertSame(0, $preview['blocked_hidden_count']);
self::assertSame(3, $preview['hidden_count']);
self::assertSame([], $this->saved, 'the dry run writes nothing');
self::assertSame([], $this->log->records);
}
/** Ruling P8a like GlobalEdits: one blocked live usage stops the whole write; post_ids limit the check. */
public function testBlockedLivePageStopsTheWholeWrite(): void
{
$rewriter = $this->rewriter(
[
self::page(101, self::layout(self::BUTTON), ['post_status' => 'publish']),
self::page(102, self::layout(self::BUTTON), ['post_status' => 'publish']),
],
self::report(2, [102], 2)
);
$preview = $rewriter->preview(self::query(), self::assign(), null);
self::assertSame([102], $preview['blocked_ids']);
self::assertSame(2, $preview['pages_count']);
$error = self::assertToolError(
$rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY),
'forbidden'
);
self::assertSame([102], $error['blocked_ids']);
self::assertStringContainsString('IDs 102', $error['message']);
self::assertStringContainsString('nichts wurde gespeichert', $error['message']);
self::assertSame([], $this->saved);
self::assertSame([], $this->log->records);
$limited = $rewriter->apply(self::ABILITY, self::query(), self::assign(), [101], self::SUMMARY);
self::assertSame([101], array_column($limited['saved'], 'post_id'));
self::assertSame([[101, SaveScope::Page]], $this->saved);
}
public function testTooManyLiveUsagesAreUnsupported(): void
{
$rewriter = $this->rewriter(
[self::page(101, self::layout(self::BUTTON), ['post_status' => 'publish'])],
self::report(GlobalUsages::HARD_CAP + 1, [], GlobalUsages::HARD_CAP + 1, true)
);
$error = self::assertToolError($rewriter->preview(self::query(), self::assign(), null), 'unsupported');
self::assertStringContainsString(sprintf('mehr als %d', GlobalUsages::HARD_CAP), $error['message']);
self::assertToolError(
$rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY),
'unsupported'
);
self::assertSame([], $this->saved);
}
/** A6: 51 pages – 50 saved, remaining 1; the next call saves the last one, remaining 0. */
public function testWritesAtMostFiftyPagesAndTheNextCallTheRest(): void
{
$posts = array_map(
static fn (int $id): WP_Post => self::page($id, self::layout(self::BUTTON)),
range(101, 151)
);
$rewriter = $this->rewriter($posts);
$preview = $rewriter->preview(self::query(), self::assign(), null);
self::assertSame(51, $preview['pages_count']);
self::assertCount(PageRewriter::PAGE_LIMIT, $preview['pages']);
$first = $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY);
self::assertCount(PageRewriter::PAGE_LIMIT, $first['saved']);
self::assertSame(['post_id' => 101, 'revision_id' => 901, 'nodes' => 1], $first['saved'][0]);
self::assertSame(1, $first['remaining']);
self::assertSame('Eintrag 101: s0.r0.c0.m0: ' . self::NOTE, $first['warnings'][0]);
self::assertSame([], $first['skipped']);
self::assertSame(range(101, 150), array_column($this->saved, 0));
self::assertCount(PageRewriter::PAGE_LIMIT, $this->log->records);
self::assertSame(
[
'ability' => self::ABILITY,
'object_type' => 'page',
'object_id' => '101',
'summary' => 'Divi "Seite 101": ' . self::SUMMARY,
'revision_id' => 901,
'snapshot_id' => null,
'result' => 'ok',
],
$this->log->records[0]
);
self::assertStringContainsString('"modulePreset":["p-cta"]', (string) $posts[0]->post_content);
$second = $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY);
self::assertSame([['post_id' => 151, 'revision_id' => 951, 'nodes' => 1]], $second['saved']);
self::assertSame(0, $second['remaining']);
self::assertSame([], $second['skipped']);
}
public function testPageWithoutChangeIsNotSavedOrCounted(): void
{
$rewriter = $this->rewriter([
self::page(101, self::layout(self::TEXT)),
self::page(102, self::layout(self::BUTTON)),
]);
$preview = $rewriter->preview(self::query(), self::assign(), null);
$result = $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY);
self::assertSame(1, $preview['pages_count']);
self::assertSame([102], array_column($result['saved'], 'post_id'));
self::assertSame(0, $result['remaining']);
self::assertSame([], $result['skipped']);
self::assertSame(['102'], array_column($this->log->records, 'object_id'));
}
/** R8: pages loadWritable() refuses (Divi 4, unparsable – fail-closed candidates) are skipped, not remaining. */
public function testRefusedPagesAreSkippedWithTheLoaderMessage(): void
{
$rewriter = $this->rewriter([
self::page(
101,
'[et_pb_section][et_pb_text]Alt[/et_pb_text][/et_pb_section]',
['post_status' => 'publish']
),
self::page(102, '<!-- wp:divi/section --><!-- wp:divi/row -->'),
self::page(103, self::layout(self::BUTTON)),
]);
$result = self::quietly(
fn (): array => $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY)
);
self::assertSame([103], array_column($result['saved'], 'post_id'));
self::assertSame(0, $result['remaining']);
self::assertSame([101, 102], array_column($result['skipped'], 'post_id'));
self::assertStringContainsString('Divi-4-Inhalt', $result['skipped'][0]['reason']);
self::assertStringContainsString('kein gültiges Block-Markup', $result['skipped'][1]['reason']);
}
/** Spec §6.6: a failed page keeps the pages saved before – partial_write with their revisions. */
public function testFailedPageIsPartial(): void
{
$rewriter = $this->rewriter([
self::page(101, self::layout(self::BUTTON)),
self::page(102, self::layout(self::BUTTON)),
self::page(103, self::layout(self::BUTTON)),
]);
$this->failures = [null, null, WriteFailed::rolledBack(DiviSaver::REASON, postId: 103)];
$result = self::quietly(
fn () => $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY)
);
$error = self::assertToolError($result, 'partial_write');
self::assertSame(
[
['post_id' => 101, 'revision_id' => 901, 'nodes' => 1],
['post_id' => 102, 'revision_id' => 902, 'nodes' => 1],
],
$error['saved']
);
self::assertSame(103, $error['post_id']);
self::assertStringContainsString('Eintrag 103: ' . DiviSaver::REASON, $error['message']);
self::assertStringContainsString('2 Einträge davor sind gespeichert', $error['message']);
self::assertStringContainsString('Eintrag 103 blieb unverändert.', $error['message']);
$this->log->assertRecorded(
['object_id' => '101', 'revision_id' => 901],
['object_id' => '102', 'revision_id' => 902]
);
}
/** Like DiviWriter: a page the saver could not roll back is logged as partial. */
public function testPageThatWasNotRolledBackIsLoggedAsPartial(): void
{
$this->log = FakeChangeLog::any();
$rewriter = $this->rewriter([
self::page(101, self::layout(self::BUTTON)),
self::page(102, self::layout(self::BUTTON)),
]);
$this->failures = [null, WriteFailed::partial(DiviSaver::REASON, postId: 102, revisionId: 812)];
$result = self::quietly(
fn () => $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY)
);
$error = self::assertToolError($result, 'partial_write');
self::assertSame(812, $error['revision_id']);
self::assertStringContainsString('1 Eintrag davor ist gespeichert', $error['message']);
self::assertStringContainsString(
'Eintrag 102 ist möglicherweise teilweise geändert (Stand vorher: Revision 812).',
$error['message']
);
$this->log->assertRecorded(['object_id' => '101']);
$this->log->assertPartials([
'object_id' => '102',
'summary' => 'Divi "Seite 102": Änderung nur teilweise zurückgenommen',
'revision_id' => 812,
]);
}
/** Spec §5.5: an unreleased Divi before the first page is the release refusal (AbilityRegistrar). */
public function testLockedDiviBeforeTheFirstPageIsThrown(): void
{
$rewriter = $this->rewriter([self::page(101, self::layout(self::BUTTON))]);
$this->failures = [new DiviLocked(new ReleaseStatus(ReleaseState::Untested, '5.16.0'))];
$this->expectException(DiviLocked::class);
$rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY);
}
/** R8: a global instance is never rewritten; it is skipped with its node ID, the rest of the page is saved. */
public function testGlobalInstanceIsSkippedAndKeptAsItIs(): void
{
$post = self::page(101, self::layout(self::INSTANCE, self::BUTTON));
$rewriter = $this->rewriter([$post]);
$preview = $rewriter->preview(self::query(), self::assign(), null);
self::assertSame(['s0.r0.c0.m0'], array_column($preview['pages'][0]['nodes'], 'id'));
self::assertCount(1, $preview['skipped']);
self::assertSame(101, $preview['skipped'][0]['post_id']);
self::assertStringStartsWith('s0.r0.c0.g0: ', $preview['skipped'][0]['reason']);
$result = $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY);
self::assertSame([101], array_column($result['saved'], 'post_id'));
self::assertStringContainsString(self::INSTANCE, (string) $post->post_content);
self::assertStringContainsString('"modulePreset":["p-cta"]', (string) $post->post_content);
}
/** R8: library entries are usages of their own, saved with DiviWriter's scope. */
public function testLibraryEntriesAreSavedWithTheirScope(): void
{
$rewriter = $this->rewriter([LibraryFixtures::item(1057), LibraryFixtures::item(1059)]);
$result = $rewriter->apply(
self::ABILITY,
self::query(),
self::assign(['divi/blurb', 'divi/text']),
null,
self::SUMMARY
);
self::assertSame([1057, 1059], array_column($result['saved'], 'post_id'));
self::assertSame([[1057, SaveScope::LibraryItem], [1059, SaveScope::GlobalItem]], $this->saved);
$this->log->assertRecorded(
['object_type' => 'et_pb_layout', 'object_id' => '1057'],
['object_type' => 'et_pb_layout', 'object_id' => '1059']
);
}
/** Audit L2: a normal entry that pages show as instance is saved site-wide as well. */
public function testReferencedLibraryEntryIsSavedSiteWide(): void
{
$rewriter = $this->rewriter([LibraryFixtures::item(1057)], null, [1057 => self::report(1)]);
$rewriter->apply(self::ABILITY, self::query(), self::assign(['divi/blurb']), null, self::SUMMARY);
self::assertSame([[1057, SaveScope::GlobalItem]], $this->saved);
}
/** P8a for the pages that show an entry: blocked → the entry is skipped, the others are saved. */
public function testLibraryEntryShownOnBlockedPagesIsSkipped(): void
{
$rewriter = $this->rewriter(
[LibraryFixtures::item(1059), self::page(101, self::layout(self::TEXT))],
null,
[1059 => self::report(1, [7], 1)]
);
$result = $rewriter->apply(self::ABILITY, self::query(), self::assign(['divi/text']), null, self::SUMMARY);
self::assertSame([[101, SaveScope::Page]], $this->saved);
self::assertSame([1059], array_column($result['skipped'], 'post_id'));
self::assertStringContainsString('Bibliothekseintrag 1059', $result['skipped'][0]['reason']);
self::assertStringContainsString('IDs 7', $result['skipped'][0]['reason']);
}
/** No backup copies in a bulk write: a site-wide entry without revisions is skipped. */
public function testSiteWideEntryWithoutRevisionsIsSkipped(): void
{
Functions\when('wp_revisions_enabled')->justReturn(false);
$rewriter = $this->rewriter([LibraryFixtures::item(1057), LibraryFixtures::item(1059)]);
$result = $rewriter->apply(
self::ABILITY,
self::query(),
self::assign(['divi/blurb', 'divi/text']),
null,
self::SUMMARY
);
self::assertSame([[1057, SaveScope::LibraryItem]], $this->saved);
self::assertSame([1059], array_column($result['skipped'], 'post_id'));
self::assertStringContainsString(
'Revisionen sind für Bibliothekseintrag 1059 abgeschaltet',
$result['skipped'][0]['reason']
);
}
public function testPostIdsLimitTheCandidatesAndNameUnchangedIds(): void
{
$rewriter = $this->rewriter([
self::page(101, self::layout(self::BUTTON)),
self::page(102, self::layout(self::BUTTON)),
]);
$preview = $rewriter->preview(self::query(), self::assign(), [102, 999]);
self::assertSame([102], array_column($preview['pages'], 'post_id'));
self::assertSame([['post_id' => 999, 'reason' => PageRewriter::NO_CHANGE]], $preview['skipped']);
self::assertSame(0, $preview['hidden_count']);
}
/** Audit L4: candidates the caller may not read are neither loaded nor named, only counted. */
public function testUnreadableCandidatesAreOnlyCounted(): void
{
$rewriter = $this->rewriter(
[self::page(101, self::layout(self::BUTTON)), self::page(102, self::layout(self::BUTTON))],
self::report(2, [], 0, false, 1)
);
Functions\when('current_user_can')->alias(
static fn (string $cap, mixed ...$args): bool => ! ($cap === 'read_post' && ($args[0] ?? null) === 102)
);
$preview = $rewriter->preview(self::query(), self::assign(), null);
$result = $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY);
self::assertSame([101], array_column($preview['pages'], 'post_id'));
self::assertSame([], $preview['skipped']);
self::assertSame(1, $preview['hidden_count']);
self::assertSame([[101, SaveScope::Page]], $this->saved);
self::assertSame([], $result['skipped']);
}
}