feat: lit/library-make-global replaces identical occurrences – dry run, confirm, revision per page

- library_id + targets (post_id, tree_rev, ids from lit/library-find-similar):
  only nodes with the entry's content fingerprint, pages and posts only
- every target checked first (DiviWriter::page: edit_post + tree_rev, P8a;
  StoredNodeGuard of the page on the instance); the entry must be an active
  global entry with one readable root and no reference in any spelling (Q7)
- dry run lists pages with tree_rev, status and live flag; confirm writes each
  page through DiviWriter::commit (revision, change log); live pages without
  revisions get VisibleEdits' backup, refused up front where not faithful
- failure on page N: earlier pages stay replaced, N rolled back, later pages
  untouched; the error names changed_posts/pages, failed_post, unchanged_posts

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-27 21:41:23 +02:00
co-authored by Claude Opus 5.5
parent 44c1804c4c
commit e733065b60
2 changed files with 607 additions and 9 deletions
@@ -11,15 +11,25 @@ use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\Fingerprint;
use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Pages\DiviChange;
use LIT\MCP\Integrations\Divi\Pages\DiviErrors;
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
use LIT\MCP\Integrations\Divi\Pages\DiviSchema;
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
use LIT\MCP\Integrations\Divi\Pages\VisibleEdits;
use LIT\MCP\Integrations\WordPress\Content\NewDraft;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\PostSaveFollowUp;
use LIT\MCP\Log;
use Throwable;
use WP_Error;
use WP_Post;
defined('ABSPATH') || exit;
@@ -33,22 +43,50 @@ defined('ABSPATH') || exit;
* already show it. Sources are pages and posts only: no library entries (no nesting, ruling P11) and no Theme
* Builder layouts (controller ruling; there lit/library-save and lit-library-insert). Needs the rights of
* lit/library-save with scope "global" and of lit-library-insert, and follows lit-divi-insert (audit M2).
*
* Second mode (E10, answer F2): library_id plus targets replaces further occurrences – only nodes whose content
* fingerprint equals the entry's root ("identical" in lit/library-find-similar) – by instances of that active
* global entry (one readable root, no reference to any entry in any spelling, ruling Q7). A global edit: every
* target is checked first (edit_post and tree_rev through DiviWriter::page(), P8a – the account changes only pages
* it may edit; identical nodes; the instance passes the page's StoredNodeGuard; pages and posts only, no library
* entries or Theme Builder layouts), then a dry run lists the pages (tree_rev, status, live – drafts are listed,
* like P8a), and only confirm: true writes. Every page goes through DiviWriter::commit() with its own tree_rev
* lock, revision and change-log row; a live page (publish, private, future) without revisions first gets
* VisibleEdits' backup, refused for every page before anything is written where it could not be faithful (P9).
* Pages are written in the given order; when page N fails, pages 1..N-1 stay replaced (each with its restore
* point), page N is rolled back by DiviSaver (or reported partial), pages after N are not attempted – the error
* names all three groups (changed_posts with pages, failed_post, unchanged_posts; D10, S10).
*/
final class LibraryMakeGlobalAbility implements Ability
{
public const REASON_PAGE = 'Die Seite konnte nicht gespeichert werden; der neue globale Eintrag wurde wieder '
. 'entfernt.';
public const MAX_TARGETS = 20;
public const MAX_IDS = 20;
private const TYPE_LABELS = ['section' => 'Abschnitt', 'row' => 'Zeile', 'module' => 'Modul'];
/** @param (Closure(): bool)|null $diviWritable Whether lit/divi-insert is enabled (audit M2); null = always. */
/** Inputs of the first mode, refused next to library_id. */
private const NODE_INPUT = ['post_id', 'tree_rev', 'id', 'title', 'categories', 'tags'];
private VisibleEdits $visible;
/**
* @param (Closure(): bool)|null $diviWritable Whether lit/divi-insert is enabled (audit M2); null = always.
* @param VisibleEdits|null $visible Backup of live pages without revisions; null = the default.
*/
public function __construct(
private LibraryAccess $access,
private DiviWriter $writer,
private NewLibraryEntry $entries,
private ChangeLog $log,
private ?Closure $diviWritable = null
private ?Closure $diviWritable = null,
?VisibleEdits $visible = null,
private Parser $parser = new Parser()
) {
$this->visible = $visible ?? new VisibleEdits();
}
public function name(): string
@@ -66,7 +104,11 @@ final class LibraryMakeGlobalAbility implements Ability
return 'Wandelt einen Divi-Knoten (post_id, tree_rev und id aus lit-divi-outline, title) auf einer Seite oder '
. 'in einem Beitrag in ein globales Modul um: legt einen globalen Bibliothekseintrag mit genau diesem '
. 'Inhalt an und ersetzt den Knoten durch die verknüpfte Instanz – alles oder nichts. Danach zeigt die '
. 'Seite den Inhalt aus der Bibliothek; Änderungen dort wirken auf alle Seiten mit diesem Modul.';
. 'Seite den Inhalt aus der Bibliothek; Änderungen dort wirken auf alle Seiten mit diesem Modul.'
. ' Gleichartige Vorkommen danach ersetzen: library_id plus targets (je post_id, tree_rev und ids '
. 'inhaltsgleicher Knoten aus lit/library-find-similar, match "identical"; nur Seiten und Beiträge); das '
. 'ist zuerst ein Probelauf mit den betroffenen Seiten und braucht dann confirm: true. Jede Seite bekommt '
. 'eine Revision.';
}
public function inputSchema(): array
@@ -78,8 +120,33 @@ final class LibraryMakeGlobalAbility implements Ability
'title' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 200],
'categories' => ['type' => 'array', 'maxItems' => 10, 'items' => ['type' => 'string']],
'tags' => ['type' => 'array', 'maxItems' => 10, 'items' => ['type' => 'string']],
'library_id' => [
'type' => 'integer',
'minimum' => 1,
'description' => 'Globaler Eintrag, dessen inhaltsgleiche Vorkommen ersetzt werden (mit targets).',
],
'targets' => [
'type' => 'array',
'minItems' => 1,
'maxItems' => self::MAX_TARGETS,
'items' => [
'type' => 'object',
'properties' => [
'post_id' => ['type' => 'integer', 'minimum' => 1],
'tree_rev' => ['type' => 'string'],
'ids' => [
'type' => 'array',
'minItems' => 1,
'maxItems' => self::MAX_IDS,
'items' => ['type' => 'string'],
],
],
'required' => ['post_id', 'tree_rev', 'ids'],
'additionalProperties' => false,
],
],
]),
'required' => ['post_id', 'tree_rev', 'id', 'title'],
'required' => [],
'additionalProperties' => false,
];
}
@@ -89,6 +156,7 @@ final class LibraryMakeGlobalAbility implements Ability
return ToolResult::schema([
'library_id' => ['type' => 'integer'],
'tree_rev' => ['type' => 'string'],
'pages' => ['type' => 'array', 'items' => ['type' => 'object']],
], ['library_id']);
}
@@ -115,18 +183,25 @@ final class LibraryMakeGlobalAbility implements Ability
return $refusal;
}
return $this->makeGlobal($input);
return self::replaces($input) ? $this->replaceOccurrences($input) : $this->makeGlobal($input);
}
/** @param array<string, mixed> $input Whether the call replaces occurrences (second mode). */
private static function replaces(array $input): bool
{
return array_key_exists('library_id', $input) || array_key_exists('targets', $input);
}
/**
* Rights of lit-library-insert (add_library, library group writable) and of lit/library-save with scope
* "global" (publish_posts, save_library, edit_global_library), and lit-divi-insert's Tools-tab state (M2).
* Rights of lit-library-insert (add_library, library group writable) and – for a new entry only – of
* lit/library-save with scope "global" (publish_posts, save_library, edit_global_library), and
* lit-divi-insert's Tools-tab state (M2).
*
* @param array<string, mixed> $input
*/
private function precheck(array $input): ?WP_Error
{
$refusal = $this->access->canInsert() ?? $this->access->canSave(true);
$refusal = $this->access->canInsert() ?? (self::replaces($input) ? null : $this->access->canSave(true));
if ($refusal !== null) {
return $refusal;
}
@@ -212,6 +287,297 @@ final class LibraryMakeGlobalAbility implements Ability
return $this->result($result, $spec, $libraryId, $created['warnings'], $page, $input);
}
/**
* @param array<string, mixed> $input
* @return array<string, mixed>|WP_Error
* @throws WriteFailed
*/
private function replaceOccurrences(array $input): array|WP_Error
{
$mixed = array_values(array_intersect(self::NODE_INPUT, array_keys($input)));
if ($mixed !== [] || ! array_key_exists('library_id', $input) || ! array_key_exists('targets', $input)) {
return Errors::invalidInput(
'Zum Ersetzen library_id und targets angeben (ohne post_id, id und title); nichts wurde gespeichert.',
['library_id' => 'library_id und targets zusammen, ohne ' . implode(', ', $mixed ?: self::NODE_INPUT)]
);
}
$root = $this->globalRoot($input['library_id']);
if ($root instanceof WP_Error) {
return $root;
}
[$libraryId, $block] = $root;
$plans = $this->plans($libraryId, $block, $input['targets']);
if ($plans instanceof WP_Error) {
return $plans;
}
$affected = array_map(static fn (array $plan): array => [
'post_id' => (int) $plan[0]->post->ID,
'title' => (string) $plan[0]->post->post_title,
'status' => (string) $plan[0]->post->post_status,
'live' => self::isLive($plan[0]->post),
'tree_rev' => $plan[0]->treeRev(),
'ids' => array_keys($plan[1]),
], $plans);
if (($input['confirm'] ?? false) !== true) {
$live = count(array_filter(array_column($affected, 'live')));
return Errors::confirmRequired(
sprintf(
'%d Vorkommen auf %d %s (%d davon veröffentlicht, privat oder geplant) würden durch das globale '
. 'Modul %d ersetzt; die Seiten zeigen danach den Inhalt aus der Bibliothek. Nichts wurde '
. 'gespeichert (Probelauf).',
array_sum(array_map(static fn (array $row): int => count($row['ids']), $affected)),
count($affected),
count($affected) === 1 ? 'Seite' : 'Seiten',
$live,
$libraryId
),
['affected' => $affected, 'affected_count' => count($affected), 'live_count' => $live]
);
}
foreach ($plans as [$page]) {
$refusal = self::isLive($page->post) ? $this->visible->backupRefusal(
$page->post,
sprintf('Seite %d', (int) $page->post->ID),
'Revisionen aktivieren (WP_POST_REVISIONS) oder die Seite im Divi-Builder bearbeiten.'
) : null;
if ($refusal !== null) {
return $refusal;
}
}
return $this->replace($libraryId, $block, $plans);
}
/**
* Every target checked before anything is written: page rights and tree_rev (DiviWriter::page(), P8a),
* pages and posts only, each once, identical nodes, the instance through the page's StoredNodeGuard.
*
* @return list<array{0: DiviPage, 1: array<string, Block>}>|WP_Error
*/
private function plans(int $libraryId, Block $root, mixed $targets): array|WP_Error
{
$targets = is_array($targets) ? array_values($targets) : [];
if ($targets === [] || count($targets) > self::MAX_TARGETS) {
return Errors::invalidInput(
sprintf('targets fehlt oder hat mehr als %d Seiten; nichts wurde gespeichert.', self::MAX_TARGETS),
['targets' => sprintf('1 bis %d Seiten', self::MAX_TARGETS)]
);
}
$fingerprint = Fingerprint::content($root);
$plans = [];
$errors = [];
$seen = [];
foreach ($targets as $i => $target) {
$target = is_array($target) ? $target : [];
$page = $this->writer->page([
'post_id' => $target['post_id'] ?? null,
'tree_rev' => $target['tree_rev'] ?? null,
]);
if ($page instanceof WP_Error) {
return $page;
}
$postId = (int) $page->post->ID;
if ($page->isLibraryItem() || $page->isThemeBuilderLayout() || isset($seen[$postId])) {
$errors["targets.$i.post_id"] = 'nur Seiten und Beiträge, jede einmal (Theme-Builder-Layouts und '
. 'Bibliothekseinträge einzeln mit lit-library-insert)';
continue;
}
$seen[$postId] = true;
$ids = is_array($target['ids'] ?? null) ? array_values($target['ids']) : [];
if ($ids === [] || count($ids) > self::MAX_IDS) {
$errors["targets.$i.ids"] = sprintf('1 bis %d Pfad-IDs', self::MAX_IDS);
}
$nodes = [];
foreach ($ids as $j => $id) {
$node = is_string($id) ? $page->index->find($id) : null;
if ($node === null || $node->isOpaque() || Fingerprint::content($node) !== $fingerprint) {
$errors["targets.$i.ids.$j"] = 'kein inhaltsgleiches Vorkommen des Eintrags (nur match "identical" '
. 'aus lit/library-find-similar)';
continue;
}
$nodes[(string) $id] = $node;
}
$guard = $this->writer->composition($page)->storedGuard();
$problems = $guard->problems(GlobalInstance::of($root, $libraryId));
if ($problems !== []) {
$errors["targets.$i.post_id"] = implode('; ', $problems);
}
$plans[] = [$page, $nodes];
}
if ($errors !== []) {
return Errors::invalidInput('Nicht alle Ziele lassen sich ersetzen; nichts wurde gespeichert.', $errors);
}
return $plans;
}
/**
* Writes the pages in order (see the class comment for a failure on page N).
*
* @param list<array{0: DiviPage, 1: array<string, Block>}> $plans
* @return array<string, mixed>
* @throws WriteFailed
*/
private function replace(int $libraryId, Block $root, array $plans): array
{
$pages = [];
$warnings = [];
$changed = [];
foreach ($plans as $n => [$page, $nodes]) {
$postId = (int) $page->post->ID;
$change = new DiviChange(
sprintf('%d Vorkommen durch das globale Modul %d ersetzt', count($nodes), $libraryId)
);
foreach ($nodes as $node) {
$instance = GlobalInstance::of($root, $libraryId);
$page->index->parentOf($node)?->replaceChild($node, $instance);
$change->record($instance, 'replaced');
}
$rest = array_map(static fn (array $plan): int => (int) $plan[0]->post->ID, array_slice($plans, $n + 1));
$backup = [];
try {
$backup = self::isLive($page->post) ? $this->visible->backup($page->post) : [];
$saved = $this->writer->commit(
$this->name(),
$page,
$change,
['post_id' => $postId, 'tree_rev' => $page->treeRev(), 'library_id' => $libraryId]
);
if ($saved instanceof WP_Error) {
throw WriteFailed::rolledBack(Errors::clientMessage($saved), detail: 'commit refused the page');
}
} catch (WriteFailed $e) {
$e = VisibleEdits::keepingBackup($e, $backup['backup_id'] ?? null, 'der Seite');
throw self::failedOn($postId, $e, $pages, $rest);
} catch (Throwable $e) {
Log::failure(sprintf('%s on post %d', $this->name(), $postId), $e);
$unknown = WriteFailed::partial(
'Interner Fehler beim Speichern dieser Seite (Details im PHP-Error-Log der Website).',
$e,
postId: $postId
);
throw self::failedOn($postId, $unknown, $pages, $rest);
}
$pages[] = array_merge([
'post_id' => $postId,
'tree_rev' => (string) ($saved['tree_rev'] ?? ''),
'revision_id' => $saved['revision_id'] ?? null,
], $backup);
$post = $page->post;
$changed[] = [(string) $postId, (string) $post->post_type, 'replaced', (string) $post->post_title];
foreach (is_array($saved['warnings'] ?? null) ? $saved['warnings'] : [] as $warning) {
$warnings[] = sprintf('Eintrag %d: %s', $postId, (string) $warning);
}
}
$tool = ToolResult::ok(sprintf(
'Vorkommen auf %d %s durch das globale Modul %d ersetzt (je mit Revision).',
count($pages),
count($pages) === 1 ? 'Seite' : 'Seiten',
$libraryId
));
foreach ($changed as $entry) {
$tool->changed(...$entry);
}
return $tool->warnings($warnings)
->with('library_id', $libraryId)
->with('pages', $pages)
->toArray();
}
/**
* The failure of page $postId with the state of the whole call: pages already replaced (each with its
* revision or backup), the failed one, the ones not attempted.
*
* @param list<array<string, mixed>> $pages Pages replaced before.
* @param list<int> $rest Pages after the failed one.
*/
private static function failedOn(int $postId, WriteFailed $e, array $pages, array $rest): WriteFailed
{
$done = array_map(static fn (array $row): int => (int) $row['post_id'], $pages);
$details = ['changed_posts' => $done, 'pages' => $pages, 'failed_post' => $postId, 'unchanged_posts' => $rest];
if ($done === []) {
return $e->withDetails($details);
}
$state = sprintf(
'Seite %d wurde nicht geändert. Bereits ersetzt (je mit Revision): %s; unverändert: %s.',
$postId,
implode(', ', $done),
$rest === [] ? 'keine weiteren' : implode(', ', $rest)
);
return $e->withDetails(
$details,
'Den Stand mit lit/library-find-similar neu prüfen und nur die übrigen Seiten mit neuem tree_rev ersetzen.',
$e->rolledBack ? $state : null
);
}
/** Pages visitors see (or will see): changes there get VisibleEdits' restore point (P8a statuses). */
private static function isLive(WP_Post $post): bool
{
return in_array((string) $post->post_status, GlobalUsages::LIVE, true);
}
/**
* The active global entry $libraryId (LibraryAccess::item(): the library right and edit_post on it) with
* its single readable root, holding no global module and naming no entry in any spelling (P11, Q7).
*
* @return array{0: int, 1: Block}|WP_Error
*/
private function globalRoot(mixed $libraryId): array|WP_Error
{
$id = PostGuard::id($libraryId);
if ($id === null) {
return Errors::invalidInput('library_id ist keine ID; nichts wurde gespeichert.', [
'library_id' => 'ID aus lit/library-list',
]);
}
$item = $this->access->item($id, false);
if ($item === null) {
return PostGuard::notFound($id);
}
if ($item instanceof WP_Error) {
return $item;
}
$content = (string) $item->post_content;
try {
$roots = LibraryContent::roots(LibraryContent::load($this->parser, $content));
} catch (MalformedMarkup) {
$roots = [];
}
$valid = (string) $item->post_status === 'publish' && $this->access->isGlobal($item) && count($roots) === 1
&& ! $roots[0]->isOpaque() && ! LibraryContent::containsGlobal($roots[0])
&& ! UsageMatcher::referencesAny($content);
if (! $valid) {
return Errors::invalidInput(
sprintf(
'Bibliothekseintrag %d ist kein aktiver globaler Eintrag mit genau einem Knoten ohne Verweis auf '
. 'andere Einträge; nichts wurde gespeichert.',
$id
),
['library_id' => 'globaler Eintrag (lit/library-list, scope "global")']
);
}
return [$id, $roots[0]];
}
/**
* Only pages and posts: a library entry would nest global modules (P11), a Theme Builder layout is left to
* lit/library-save plus lit-library-insert (controller ruling on the plan's open point).
@@ -7,7 +7,9 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use LIT\MCP\Integrations\Divi\Library\LibraryItemSpec;
use LIT\MCP\Integrations\Divi\Library\LibraryMakeGlobalAbility;
use LIT\MCP\Integrations\Divi\Library\NewLibraryEntry;
@@ -15,6 +17,7 @@ use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
use LIT\MCP\Integrations\Divi\Pages\VisibleEdits;
use LIT\MCP\Tests\Support\DiviFixtures;
use LIT\MCP\Tests\Support\DiviWriterFixture;
use LIT\MCP\Tests\Support\FakeChangeLog;
@@ -40,6 +43,9 @@ final class LibraryMakeGlobalAbilityTest extends TestCase
private FakeChangeLog $log;
/** @var list<array{post_id: int, backup_id: int}> Backups VisibleEdits made. */
private array $backups = [];
protected function setUp(): void
{
parent::setUp();
@@ -85,7 +91,12 @@ final class LibraryMakeGlobalAbilityTest extends TestCase
),
new NewLibraryEntry($access, $saver, DiviWriterFixture::modules()),
$this->log,
static fn (): bool => $diviWritable
static fn (): bool => $diviWritable,
new VisibleEdits(function (WP_Post $post): int {
$this->backups[] = ['post_id' => (int) $post->ID, 'backup_id' => 1071];
return 1071;
})
);
}
@@ -258,4 +269,225 @@ final class LibraryMakeGlobalAbilityTest extends TestCase
self::assertSame(2300, $e->details['library_id']);
}
}
/** Pages 8 and 9: a plain copy of the global section 1058; 9 also a variation with another text. */
private function givenCopiesOf1058(): string
{
$root = LibraryContent::roots(LibraryContent::load(
new Parser(),
DiviFixtures::markup('stage3/library-global--globaler-abschnitt.html')
))[0];
$copy = Serializer::block($root);
$variation = str_replace('Global', 'Lokal', $copy);
LibraryFixtures::given([
$this->page,
LibraryFixtures::item(1058),
new WP_Post(['ID' => 8, 'post_type' => 'page', 'post_status' => 'publish', 'post_title' => 'Team',
'post_content' => '<!-- wp:divi/placeholder -->' . $copy . '<!-- /wp:divi/placeholder -->']),
new WP_Post(['ID' => 9, 'post_type' => 'page', 'post_status' => 'draft', 'post_title' => 'Jobs',
'post_content' => '<!-- wp:divi/placeholder -->' . $variation . $copy . '<!-- /wp:divi/placeholder -->']),
]);
return $copy;
}
/** @return array<string, mixed> */
private static function targets(array $ids8 = ['s0'], array $ids9 = ['s1'], bool $confirm = false): array
{
return array_filter([
'library_id' => 1058,
'targets' => [
['post_id' => 8, 'tree_rev' => Rev::of((string) get_post(8)->post_content), 'ids' => $ids8],
['post_id' => 9, 'tree_rev' => Rev::of((string) get_post(9)->post_content), 'ids' => $ids9],
],
'confirm' => $confirm ?: null,
], static fn (mixed $value): bool => $value !== null);
}
public function testReplacingOccurrencesIsADryRunFirstThenEveryPageWithItsRevision(): void
{
$this->givenCopiesOf1058();
$dry = $this->ability()->execute(self::targets());
self::assertToolError($dry, 'confirm_required');
self::assertSame(2, $dry->get_error_data()['affected_count']);
self::assertSame([8, 9], array_column($dry->get_error_data()['affected'], 'post_id'));
self::assertSame([], $this->saved);
$result = $this->ability()->execute(self::targets(['s0'], ['s1'], true));
self::assertTrue($result['ok']);
self::assertSame([8, 9], array_column($result['pages'], 'post_id'));
self::assertSame([811, 811], array_column($result['pages'], 'revision_id'));
self::assertCount(2, $this->saved);
self::assertStringContainsString('{"globalModule":"1058","blockName":"divi/section"', $this->saved[0]);
self::assertStringContainsString('Lokal', $this->saved[1], 'the variation stays');
self::assertSame([], $this->specs, 'no new entry');
}
public function testTargetsMustBeIdenticalEditableAndCurrent(): void
{
$this->givenCopiesOf1058();
$variation = $this->ability()->execute(self::targets(['s0'], ['s0'], true));
self::assertToolError($variation, 'validation_failed');
self::assertArrayHasKey('targets.1.ids.0', self::errorFields($variation));
$stale = self::targets(['s0'], ['s1'], true);
$stale['targets'][1]['tree_rev'] = 'alt';
self::assertToolError($this->ability()->execute($stale), 'rev_mismatch');
$library = self::targets(['s0'], ['s1'], true);
$library['targets'][1] = ['post_id' => 1055, 'tree_rev' => Rev::of((string) LibraryFixtures::item(1055)->post_content), 'ids' => ['s0']];
LibraryFixtures::given([LibraryFixtures::item(1055), LibraryFixtures::item(1058), get_post(8), get_post(9)]);
self::assertToolError($this->ability()->execute($library), 'validation_failed');
LibraryFixtures::given([LibraryFixtures::item(1058), get_post(8), get_post(9)]);
Functions\when('current_user_can')->alias(static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && ($args[0] ?? 0) === 9));
self::assertToolError($this->ability()->execute(self::targets(['s0'], ['s1'], true)), 'forbidden');
self::assertSame([], $this->saved, 'nothing was written in any case');
}
public function testOnlyGlobalEntriesWithOneReadableRoot(): void
{
$this->givenCopiesOf1058();
LibraryFixtures::given([LibraryFixtures::item(1055), get_post(8), get_post(9)]);
$normal = self::targets();
$normal['library_id'] = 1055;
self::assertToolError($this->ability()->execute($normal), 'validation_failed');
}
public function testAFailureMidwayNamesThePagesAlreadyChanged(): void
{
$this->givenCopiesOf1058();
try {
$this->ability([], true, false, [null, WriteFailed::rolledBack('Die Divi-Seite konnte nicht gespeichert werden.')])
->execute(self::targets(['s0'], ['s1'], true));
self::fail('no WriteFailed');
} catch (WriteFailed $e) {
self::assertSame([8], $e->details['changed_posts']);
}
}
/** The dry run lists every target with its tree_rev and whether visitors see it (P8a: drafts only listed). */
public function testTheDryRunListsTreeRevAndLiveState(): void
{
$this->givenCopiesOf1058();
$data = $this->ability()->execute(self::targets())->get_error_data();
self::assertSame(Rev::of((string) get_post(8)->post_content), $data['affected'][0]['tree_rev']);
self::assertTrue($data['affected'][0]['live']);
self::assertFalse($data['affected'][1]['live']);
self::assertSame(['s1'], $data['affected'][1]['ids']);
self::assertSame(1, $data['live_count']);
}
/** Ruling Q7: an entry that names another entry in any spelling never becomes an instance. */
public function testRefusesAnEntryWithAReferenceInAnySpelling(): void
{
$copy = $this->givenCopiesOf1058();
$quote = chr(92) . '"';
$content = str_replace(
'Globaler Abschnitt',
'[et_pb_text global_module=' . $quote . '1059' . $quote . ']x[/et_pb_text]',
DiviFixtures::markup('stage3/library-global--globaler-abschnitt.html')
);
LibraryFixtures::given([LibraryFixtures::item(1058, ['post_content' => $content]), get_post(8), get_post(9)]);
$result = $this->ability()->execute(self::targets(['s0'], ['s1'], true));
self::assertToolError($result, 'validation_failed');
self::assertArrayHasKey('library_id', self::errorFields($result));
self::assertSame([], $this->saved);
self::assertNotSame('', $copy);
}
public function testThemeBuilderLayoutsAreNoTargets(): void
{
$this->givenCopiesOf1058();
$input = self::targets(['s0'], ['s1'], true);
$body = ThemeBuilderFixtures::layout(ThemeBuilderFixtures::BODY);
LibraryFixtures::given([LibraryFixtures::item(1058), get_post(8), $body]);
Functions\when('admin_url')->alias(static fn (string $path): string => 'https://kunde.test/wp-admin/' . $path);
$loader = new DiviPageLoader(
DiviFixtures::schema(),
new Parser(),
LibraryFixtures::access(),
ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([ThemeBuilderFixtures::template()]))
);
$input['targets'][1] = ['post_id' => 1062, 'tree_rev' => Rev::of((string) $body->post_content), 'ids' => ['s0']];
$result = $this->ability([], true, false, [], $loader)->execute($input);
self::assertToolError($result, 'validation_failed');
self::assertArrayHasKey('targets.1.post_id', self::errorFields($result));
self::assertSame([], $this->saved);
}
/** Replacing needs lit-library-insert's rights (not lit/library-save's) and a writable library group. */
public function testReplacingNeedsInsertRightsOnly(): void
{
$this->givenCopiesOf1058();
self::assertToolError($this->ability(['add_library'])->execute(self::targets([], [], true)), 'forbidden');
self::assertToolError($this->ability([], false)->execute(self::targets()), 'unsupported');
self::assertToolError($this->ability(['edit_global_library'])->execute(self::targets()), 'confirm_required');
self::assertSame([], $this->saved);
}
/** Live pages without revisions get VisibleEdits' backup – refused before any page is written where it would lose bytes. */
public function testLivePagesWithoutRevisionsGetABackupOrNothingIsWritten(): void
{
$this->givenCopiesOf1058();
Functions\when('wp_revisions_enabled')->justReturn(false);
$result = $this->ability()->execute(self::targets(['s0'], ['s1'], true));
self::assertSame([['post_id' => 8, 'backup_id' => 1071]], $this->backups);
self::assertSame(1071, $result['pages'][0]['backup_id']);
self::assertArrayNotHasKey('backup_id', $result['pages'][1], 'drafts are only listed (P8a)');
$this->backups = [];
$this->saved = [];
$this->givenCopiesOf1058();
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'unfiltered_html');
self::assertToolError($this->ability()->execute(self::targets(['s0'], ['s1'], true)), 'unsupported');
self::assertSame([], $this->backups);
self::assertSame([], $this->saved);
}
/** A failure on page N: the pages before stay replaced (each with revision), the rest untouched – all named. */
public function testAFailureMidwayReportsEveryPage(): void
{
$this->givenCopiesOf1058();
try {
$this->ability([], true, false, [null, WriteFailed::rolledBack('Die Divi-Seite konnte nicht gespeichert werden.')])
->execute(self::targets(['s0'], ['s1'], true));
self::fail('no WriteFailed');
} catch (WriteFailed $e) {
self::assertSame(9, $e->details['failed_post']);
self::assertSame([], $e->details['unchanged_posts']);
self::assertSame(811, $e->details['pages'][0]['revision_id']);
self::assertStringNotContainsString('Nichts wurde gespeichert', (string) $e->toError()->get_error_message());
}
self::assertCount(1, $this->saved);
}
public function testAFailureOnTheFirstPageLeavesEverythingUnchanged(): void
{
$this->givenCopiesOf1058();
try {
$this->ability([], true, false, [WriteFailed::rolledBack('Die Divi-Seite konnte nicht gespeichert werden.')])
->execute(self::targets(['s0'], ['s1'], true));
self::fail('no WriteFailed');
} catch (WriteFailed $e) {
self::assertSame([], $e->details['changed_posts']);
self::assertSame([9], $e->details['unchanged_posts']);
}
self::assertSame([], $this->saved);
}
}