feat: Divi tools write library entries; global ones with dry run, confirm and backup (E2, F2)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 01:15:58 +02:00
co-authored by Claude Opus 5.5
parent 681bb755ff
commit d66ad36c2e
23 changed files with 1066 additions and 44 deletions
@@ -49,7 +49,6 @@ final class DiviDeleteAbility extends DiviWriteAbility
'items' => ['type' => 'string'],
'description' => 'Pfad-IDs der zu löschenden Knoten (aus lit-divi-outline).',
],
'confirm' => ['type' => 'boolean', 'description' => 'true erst nach Rückfrage beim Nutzer.'],
]),
'required' => ['post_id', 'tree_rev', 'ids'],
'additionalProperties' => false,
@@ -44,8 +44,8 @@ final class DiviPageLoader
/** Readers pass $forWrite = false (trashed entries may be read, like lit-content-get). */
public function load(mixed $postId, bool $forWrite): DiviPage|WP_Error
{
// Library entries (E1): only for reading so far, and only with a library access.
$item = $forWrite ? null : $this->library?->item($postId, false);
// Library entries (E1): with a library access, for reading and writing.
$item = $this->library?->item($postId, $forWrite);
$post = $item ?? PostGuard::editable($postId, $this->schema, 'post_id', ! $forWrite);
if ($post instanceof WP_Error) {
return $post;
@@ -93,13 +93,14 @@ final class DiviPageLoader
}
/**
* Additional checks for writing: the type must use the Divi builder, the content must be a
* Divi 5 layout (or empty) and saving must not publish a missed schedule (spec §2).
* Additional checks for writing: the type must use the Divi builder (library entries always do),
* the content must be a Divi 5 layout (or empty) and saving must not publish a missed schedule
* (spec §2).
*/
public function writable(DiviPage $page): ?WP_Error
{
$post = $page->post;
if (! $this->schema->diviEnabled((string) $post->post_type)) {
if (! $page->isLibraryItem() && ! $this->schema->diviEnabled((string) $post->post_type)) {
return Errors::unsupported(
sprintf('Für den Inhaltstyp "%s" ist der Divi-Builder nicht aktiviert.', $post->post_type),
'In Divi → Theme-Optionen → Builder den Builder für diesen Inhaltstyp aktivieren (Administrator).'
@@ -29,9 +29,11 @@ defined('ABSPATH') || exit;
*
* Per page all or nothing: DiviWriter::page() checks rights and the tree_rev lock, every hit is
* computed first (TextReplacer, ruling S8), a single refused place fails the page and nothing of
* it is written; DiviWriter::save() stores it with its own restore-point revision and change
* log. The batch reports every page (spec §8.2, ruling S10): any Throwable of one page is logged
* and reported there, the others go on. Inserted text is cleaned by the field kind through
* it is written; DiviWriter::commit() stores it with its own restore-point revision and change
* log (ruling P1: a library entry keeps its shape and gets no Divi flags; a global entry is never
* changed here – the target reports the pages it shows on, answer F2). The batch reports every
* page (spec §8.2, ruling S10): any Throwable of one page is logged and reported there, the
* others go on. Inserted text is cleaned by the field kind through
* ValueCleaner::textReplacementFor() (S2/S28); URL and code fields are never touched.
*/
final class DiviReplaceTextAbility implements Ability
@@ -366,7 +368,13 @@ final class DiviReplaceTextAbility implements Ability
$change->record($block, 'updated');
}
$saved = $this->writer->save($this->name(), $page, $change, $input);
// Ruling P1: through commit() like every Divi tool – library entries keep their shape and get
// no Divi flags. $input never carries confirm, so a global entry only answers with its dry
// run (the pages it would change, answer F2) and is never saved from here.
$saved = $this->writer->commit($this->name(), $page, $change, $input);
if ($saved instanceof WP_Error) {
return $base + self::refused($saved);
}
return $base + [
'ok' => true,
@@ -378,6 +386,27 @@ final class DiviReplaceTextAbility implements Ability
];
}
/**
* A page commit() refused: its message, and for a global library entry the pages it shows on.
*
* @return array<string, mixed>
*/
private static function refused(WP_Error $error): array
{
$data = $error->get_error_data();
if (Errors::codeOf($error) !== 'confirm_required' || ! is_array($data)) {
return ['ok' => false, 'error' => Errors::clientMessage($error) . ' Diese Seite wurde nicht geändert.'];
}
return [
'ok' => false,
'error' => Errors::clientMessage($error) . ' Globale Bibliothekseinträge nicht mit lit/divi-replace-text, '
. 'sondern nach Rückfrage mit lit-divi-update (confirm: true) ändern.',
'affected' => $data['affected'] ?? [],
'affected_count' => $data['affected_count'] ?? 0,
];
}
/**
* Every hit of $page (DiviTextSearch::replace()), cleaned by the choke point of the page's
* own composition (ruling S22).
+28 -13
View File
@@ -50,6 +50,9 @@ defined('ABSPATH') || exit;
* pages the bytes change for every user – reported as CHANGED_ON_SAVE. Finding R2 / rulings
* S5, S7: docs/superpowers/notes/2026-09-26-etappe-2-entscheidungen.md.
*
* SaveScope: Bibliothekseinträge bekommen keine Divi-Flags; ein globaler Eintrag leert zusätzlich den CSS-Cache
* aller Seiten und den Theme-Builder-Cache.
*
* If saving throws, the previous content is written back (WriteFailed: rolled back or not).
* Not final so abilities can be tested with a mock.
*/
@@ -64,7 +67,7 @@ class DiviSaver
* @return array{revision_id: ?int, warnings: list<string>}
* @throws WriteFailed
*/
public function save(WP_Post $post, string $content): array
public function save(WP_Post $post, string $content, SaveScope $scope = SaveScope::Page): array
{
$postId = (int) $post->ID;
$revisionId = null;
@@ -90,17 +93,27 @@ class DiviSaver
}
$warnings = [];
if ($scope->setsFlags()) {
FollowUp::run(
static fn () => DiviFlags::set($postId),
'divi builder flags',
'Die Divi-Kennzeichen der Seite konnten nicht gesetzt werden; der Inhalt ist gespeichert – die Seite '
. 'einmal im Divi-Builder öffnen und speichern.',
$warnings
);
}
FollowUp::run(
static fn () => DiviFlags::set($postId),
'divi builder flags',
'Die Divi-Kennzeichen der Seite konnten nicht gesetzt werden; der Inhalt ist gespeichert – die Seite '
. 'einmal im Divi-Builder öffnen und speichern.',
$warnings
);
FollowUp::run(
static function () use ($postId): void {
if (class_exists('ET_Core_PageResource')) {
\ET_Core_PageResource::remove_static_resources($postId, 'all', true);
static function () use ($postId, $scope): void {
if (! class_exists('ET_Core_PageResource')) {
return;
}
\ET_Core_PageResource::remove_static_resources($postId, 'all', true);
// A global library entry is part of every page that shows it (spec §5.6 step 5).
if ($scope->clearsSiteCss()) {
\ET_Core_PageResource::remove_static_resources('all', 'all', true);
if (function_exists('et_theme_builder_clear_wp_cache')) {
et_theme_builder_clear_wp_cache('all');
}
}
},
'divi static css',
@@ -128,14 +141,16 @@ class DiviSaver
* @return list<string> Warnings (CHANGED_ON_SAVE).
* @throws RuntimeException
*/
public function saveNew(int $postId, string $content): array
public function saveNew(int $postId, string $content, SaveScope $scope = SaveScope::Page): array
{
$refusal = self::refusal($this->writeContent($postId, $content));
if ($refusal !== null) {
throw new RuntimeException(sprintf('wp_update_post(%d) refused the Divi layout: %s', $postId, $refusal));
}
DiviFlags::set($postId);
if ($scope->setsFlags()) {
DiviFlags::set($postId);
}
$stored = get_post($postId);
@@ -18,6 +18,11 @@ final class DiviSchema
'type' => 'string',
'description' => 'tree_rev aus lit-divi-outline bzw. der letzten Divi-Änderung (Pflicht).',
],
'confirm' => [
'type' => 'boolean',
'description' => 'Bei globalen Bibliothekseinträgen (und beim Löschen): true erst, nachdem der Nutzer '
. 'den Probelauf (confirm_required mit den betroffenen Seiten) bestätigt hat.',
],
];
}
@@ -14,8 +14,8 @@ defined('ABSPATH') || exit;
* The frame of the Divi write tools (spec §5.6): permission and execute both load the page
* through DiviWriter::page() (rights, Divi layout, tree_rev lock – execute never trusts state
* from permission), the tool changes the tree in change() – only when its whole input
* validated, all or nothing (ruling S39) – and DiviWriter::save() stores it and builds the
* result. The composition is bound to the page (DiviWriter::composition(), ruling S22).
* validated, all or nothing (ruling S39) – and DiviWriter::commit() stores it (library entries:
* GlobalEdits) and builds the result. The composition is bound to the page (DiviWriter::composition(), ruling S22).
*/
abstract class DiviWriteAbility implements Ability
{
@@ -33,7 +33,14 @@ abstract class DiviWriteAbility implements Ability
public function outputSchema(): ?array
{
return ToolResult::schema(['tree_rev' => ['type' => 'string']], ['tree_rev']);
return ToolResult::schema([
'tree_rev' => ['type' => 'string'],
// Global library entries (answer F2): the pages that show the entry, and the backup
// made when revisions are off.
'affected' => ['type' => 'array', 'items' => ['type' => 'object']],
'affected_count' => ['type' => 'integer'],
'backup_id' => ['type' => 'integer'],
], ['tree_rev']);
}
public function isDirect(): bool
@@ -43,6 +50,11 @@ abstract class DiviWriteAbility implements Ability
final public function permission(array $input): bool|WP_Error
{
$refusal = $this->precheck($input);
if ($refusal !== null) {
return $refusal;
}
$page = $this->writer->page($input);
return $page instanceof WP_Error ? $page : true;
@@ -50,6 +62,11 @@ abstract class DiviWriteAbility implements Ability
final public function execute(array $input): mixed
{
$refusal = $this->precheck($input);
if ($refusal !== null) {
return $refusal;
}
$page = $this->writer->page($input);
if ($page instanceof WP_Error) {
return $page;
@@ -57,9 +74,20 @@ abstract class DiviWriteAbility implements Ability
$change = $this->change($page, $input, $this->writer->composition($page));
if ($change instanceof WP_Error) {
return $change;
return $this->writer->notice($page, $change);
}
return $this->writer->save($this->name(), $page, $change, $input);
return $this->writer->commit($this->name(), $page, $change, $input);
}
/**
* Checks of a tool beyond the page (rights and Tools-tab gates of another group) – run in
* permission() and again in execute(). Null = none.
*
* @param array<string, mixed> $input
*/
protected function precheck(array $input): ?WP_Error
{
return null;
}
}
+53 -5
View File
@@ -13,6 +13,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Outline;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Library\GlobalEdits;
use LIT\MCP\Integrations\WordPress\PostSaveFollowUp;
use WP_Error;
@@ -23,7 +24,8 @@ defined('ABSPATH') || exit;
* (ruling P16; a mismatch is rev_mismatch and nothing is written), let the ability change the
* tree – only when its whole input validated (all or nothing) –, serialize, save via DiviSaver,
* then lit_mcp_after_save, change log and the ToolResult with changed nodes, affected outline,
* quality warnings (spec §5.5), revision_id, the new rev and tree_rev.
* quality warnings (spec §5.5), revision_id, the new rev and tree_rev. commit() is the entry point
* of the tools: library entries additionally pass GlobalEdits (shape, dry run/confirm, backup).
*/
final class DiviWriter
{
@@ -35,7 +37,8 @@ final class DiviWriter
private DiviSaver $saver,
private ChangeLog $log,
private DesignSystemReader $design,
private DiviComposition $composition
private DiviComposition $composition,
private ?GlobalEdits $globals = null
) {
}
@@ -79,8 +82,13 @@ final class DiviWriter
* @return array<string, mixed>
* @throws WriteFailed
*/
public function save(string $ability, DiviPage $page, DiviChange $change, array $input): array
{
public function save(
string $ability,
DiviPage $page,
DiviChange $change,
array $input,
SaveScope $scope = SaveScope::Page
): array {
$post = $page->post;
$content = Serializer::document($page->document);
if ($content === (string) $post->post_content) {
@@ -97,7 +105,7 @@ final class DiviWriter
$warnings = array_merge($change->warnings(), $quality);
try {
$saved = $this->saver->save($post, $content);
$saved = $this->saver->save($post, $content, $scope);
} catch (WriteFailed $e) {
if (! $e->rolledBack) {
$this->log->recordPartial(
@@ -140,6 +148,46 @@ final class DiviWriter
return $tool->toArray();
}
/**
* save() for the Divi tools (E2): pages as before; a library entry keeps its shape, a global one
* needs confirm (dry run first), gets a backup when revisions are off and clears every page's
* cache; the result then names the affected pages.
*
* @param array<string, mixed> $input
* @return array<string, mixed>|WP_Error
* @throws WriteFailed
*/
public function commit(string $ability, DiviPage $page, DiviChange $change, array $input): array|WP_Error
{
if (! $page->isLibraryItem()) {
return $this->save($ability, $page, $change, $input);
}
if ($this->globals === null) {
return Errors::unsupported(
'Bibliothekseinträge lassen sich über diese Verbindung nicht ändern.',
'Den Eintrag im Divi-Builder bearbeiten.'
);
}
if (Serializer::document($page->document) === (string) $page->post->post_content) {
return $this->save($ability, $page, $change, $input, SaveScope::LibraryItem);
}
$prepared = $this->globals->prepare($page, $input);
if ($prepared instanceof WP_Error) {
return $prepared;
}
return array_merge($this->save($ability, $page, $change, $input, $prepared['scope']), $prepared['details']);
}
/** A tool's confirm_required on a global library entry, extended by the affected pages. */
public function notice(DiviPage $page, WP_Error $error): WP_Error
{
return $page->isLibraryItem() && $this->globals !== null ? $this->globals->notice($page, $error) : $error;
}
/**
* Outline of the sections that contain changed nodes (breadth limit of ruling S18). A removed
* node has no block any more, but its ID from before the removal still names the (surviving)
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
defined('ABSPATH') || exit;
// The installed PHPCompatibility sniffs predate enums (PHP 8.1) and read their methods as plain functions.
// phpcs:disable PHPCompatibility.Variables.ForbiddenThisUseContexts.OutsideObjectContext
/** What DiviSaver writes to – it decides the Divi flags and how much static CSS is cleared. */
enum SaveScope
{
/** A page or post: Divi flags, its own static CSS. */
case Page;
/** A library entry: no Divi flags (Divi sets none there, fixture finding 4), its own CSS. */
case LibraryItem;
/** A global library entry: like LibraryItem, plus the CSS of every page and the Theme Builder cache (spec §5.6). */
case GlobalItem;
public function setsFlags(): bool
{
return $this === self::Page;
}
public function clearsSiteCss(): bool
{
return $this === self::GlobalItem;
}
}