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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user