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:
co-authored by
Claude Opus 5.5
parent
d36e8286e8
commit
d7a991d130
@@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user