Files
WP-LIT-MCP/src/Integrations/Divi/Pages/DiviWriter.php
T
janniclas.loosenandClaude Opus 5.5 771f3794a0 fix: failed library and layout saves name the revision screen (audit L3)
A failed save of a library entry or Theme Builder layout now carries
restore_url when a revision exists; a partial write's hint points to the
WordPress revision screen and lit-divi-outline instead of
lit/revisions-list and lit/revision-restore, which answer not found for
these types. Pages keep the default hint.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2026-09-27 23:25:16 +02:00

374 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog;
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\Divi\Library\SimilarSections;
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderEdits;
use LIT\MCP\Integrations\WordPress\PostSaveFollowUp;
use WP_Error;
defined('ABSPATH') || exit;
/**
* The shared frame of every Divi write (spec §5.6): load with rights and the tree_rev lock
* (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. commit() is the entry point
* of the tools: library entries additionally pass GlobalEdits (shape, dry run/confirm, backup), Theme
* Builder layouts ThemeBuilderEdits (live layouts: dry run/confirm, backup; ruling Q2/Q9). SimilarSections adds
* the "similar section" warning (spec §5.5, E11) – not for lit/library-insert, whose sections come from the library.
*/
final class DiviWriter
{
public const OUTLINE_TRUNCATED = 'Die Übersicht der geänderten Abschnitte ist gekürzt; vollständig mit '
. 'lit-divi-outline lesen.';
/** Inserts library entries: no advice to use the library (SimilarSections). */
private const FROM_LIBRARY = 'lit/library-insert';
public function __construct(
private DiviPageLoader $loader,
private DiviSaver $saver,
private ChangeLog $log,
private DesignSystemReader $design,
private DiviComposition $composition,
private ?GlobalEdits $globals = null,
private ?ThemeBuilderEdits $themeBuilder = null,
private ?SimilarSections $similar = null
) {
}
/**
* The page to change, after rights, type, layout and the tree_rev lock were checked.
*
* @param array<string, mixed> $input
*/
public function page(array $input): DiviPage|WP_Error
{
$page = $this->loader->loadWritable($input['post_id'] ?? null);
if ($page instanceof WP_Error) {
return $page;
}
if (! is_string($input['tree_rev'] ?? null) || $input['tree_rev'] !== $page->treeRev()) {
return Errors::revMismatch(
'Die Divi-Seite wurde seit dem letzten Lesen geändert (tree_rev passt nicht); nichts wurde '
. 'geschrieben.',
DiviPageLoader::OUTLINE_HINT
);
}
return $page;
}
/**
* The Compose classes for $page: bound to its entry, so placeholders and $variable(…)$
* references are checked against Divi's dynamic content options for it (ruling S22); a Theme
* Builder layout also by its area (F22: modules Divi renders only elsewhere are refused).
*/
public function composition(DiviPage $page): DiviComposition
{
return $this->composition->forPost((int) $page->post->ID, $page->themeBuilderArea());
}
/**
* Saves the changed tree of $page and returns the ToolResult array. Call it only when the
* whole input validated – every error before means nothing is written.
*
* @param array<string, mixed> $input
* @return array<string, mixed>
* @throws WriteFailed
*/
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) {
return ToolResult::ok('Keine Änderung – die Seite ist bereits so.')
->rev(Rev::ofPost($post))
->with('tree_rev', $page->treeRev())
->warnings($change->warnings())
->urls((string) get_preview_post_link($post), $page->editUrl())
->toArray();
}
$index = NodeIndex::build($page->document);
$quality = $this->composition->warnings()->collect($index, $change->touched());
$similar = $ability === self::FROM_LIBRARY ? [] : ($this->similar?->warnings($change) ?? []);
$warnings = array_merge($change->warnings(), $quality, $similar);
try {
$saved = $this->saver->save($post, $content, $scope);
} catch (WriteFailed $e) {
if (! $e->rolledBack) {
$this->log->recordPartial(
$ability,
(string) $post->post_type,
(string) $post->ID,
sprintf('Divi "%s": Änderung nur teilweise zurückgenommen', $post->post_title),
$e->revisionId
);
}
throw self::withRestorePoint($page, $e);
}
$outline = $this->outline($index, $change);
if ($outline['truncated']) {
$warnings[] = self::OUTLINE_TRUNCATED;
}
$result = (new PostSaveFollowUp(
$ability,
$this->log,
(int) $post->ID,
'die Änderung',
array_merge($warnings, $saved['warnings'])
))->afterSave($input);
$updated = $result->reread($post) ?? $post;
$summary = sprintf('Divi "%s": %s', $post->post_title, $change->summary);
$result->log((string) $post->post_type, $summary, $saved['revision_id']);
$status = sprintf('%s (Status unverändert: %s).', $change->summary, $updated->post_status);
$tool = $result->result($status, $updated)
->revisionId($saved['revision_id'])
->outline($outline['text'])
->with('tree_rev', Rev::of((string) $updated->post_content))
->urls((string) get_preview_post_link($updated), $page->editUrl());
foreach ($change->entries() as $entry) {
$tool->changed(DiviChange::idOf($entry, $index) ?? '?', $entry['type'], $entry['action'], $entry['label']);
}
$restore = self::restoreUrl($page, $saved['revision_id']);
if ($restore !== null) {
$tool->with('restore_url', $restore);
}
return $tool->toArray();
}
/**
* Audit L3: lit/revision-restore takes only the content types (ContentSchema) – not library entries or Theme
* Builder layouts. Their revision is restored in WordPress' revision screen; null for everything else.
*/
private static function restoreUrl(DiviPage $page, mixed $revisionId): ?string
{
$noRestoreTool = $page->isLibraryItem() || $page->isThemeBuilderLayout();
if (! $noRestoreTool || ! is_int($revisionId) || $revisionId <= 0) {
return null;
}
return admin_url('revision.php?revision=' . $revisionId);
}
/**
* Audit L3 on the failure path: for a library entry or a Theme Builder layout the error names the WordPress
* revision screen (restore_url) and says so in the hint of a partial write, instead of lit/revisions-list and
* lit/revision-restore, which answer "not found" for these types. Pages keep the default hint.
*/
private static function withRestorePoint(DiviPage $page, WriteFailed $e): WriteFailed
{
if (! $page->isLibraryItem() && ! $page->isThemeBuilderLayout()) {
return $e;
}
$restore = self::restoreUrl($page, $e->revisionId);
$details = $restore === null ? [] : ['restore_url' => $restore];
if ($e->rolledBack) {
return $details === [] ? $e : $e->withDetails($details);
}
$back = $restore === null
? ''
: sprintf(
' Den vorherigen Stand (Revision %d) bei Bedarf im Revisionsvergleich von WordPress wiederherstellen '
. '(restore_url).',
(int) $e->revisionId
);
return $e->withDetails(
$details,
'Den Aufruf nicht einfach wiederholen. Zuerst den aktuellen Stand mit lit-divi-outline prüfen.' . $back
. ' Dem Nutzer Bescheid geben.'
);
}
/**
* 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. Theme Builder layouts: commitLayout().
*
* @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->isThemeBuilderLayout()) {
return $this->commitLayout($ability, $page, $change, $input);
}
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 $this->savePrepared($ability, $page, $change, $input, $prepared, 'des Bibliothekseintrags');
}
/**
* save() for a Theme Builder layout: a layout visitors see needs confirm and gets the restore point
* of a visible change (ThemeBuilderEdits, rulings Q2/Q9); the result names the templates that show it.
*
* @param array<string, mixed> $input
* @return array<string, mixed>|WP_Error
* @throws WriteFailed
*/
private function commitLayout(string $ability, DiviPage $page, DiviChange $change, array $input): array|WP_Error
{
if ($this->themeBuilder === null) {
return Errors::unsupported(
'Theme-Builder-Layouts lassen sich über diese Verbindung nicht ändern.',
'Das Layout im Divi Theme Builder bearbeiten.'
);
}
if (Serializer::document($page->document) === (string) $page->post->post_content) {
return $this->save($ability, $page, $change, $input, SaveScope::ThemeBuilderLayout);
}
// E12: fields in a body checked against the templates' post types – warnings only, also in the dry run
// and in any refusal (review M-12 a). The live templates are read once for both checks.
$live = $this->themeBuilder->live();
$fieldWarnings = $this->themeBuilder->fieldWarnings($page, $change, $live);
foreach ($fieldWarnings as $warning) {
$change->warning($warning);
}
$prepared = $this->themeBuilder->prepare($page, $input, $live);
if ($prepared instanceof WP_Error) {
return $fieldWarnings === [] ? $prepared : self::withWarnings($prepared, $fieldWarnings);
}
return $this->savePrepared($ability, $page, $change, $input, $prepared, 'des Theme-Builder-Layouts');
}
/**
* $error with the field warnings of the change (key warnings): a confirm_required also says so in its message.
*
* @param list<string> $warnings
*/
private static function withWarnings(WP_Error $error, array $warnings): WP_Error
{
if (Errors::codeOf($error) === 'confirm_required') {
return VisibleEdits::merged($error, 'Hinweise zu Feldern siehe warnings.', ['warnings' => $warnings]);
}
$data = $error->get_error_data();
$data = is_array($data) ? $data : [];
return Errors::make(
Errors::codeOf($error),
Errors::clientMessage($error),
(string) ($data['hint'] ?? ''),
(int) ($data['status'] ?? 400),
array_merge(array_diff_key($data, ['status' => true, 'hint' => true]), ['warnings' => $warnings])
);
}
/**
* save() with the scope GlobalEdits/ThemeBuilderEdits prepared; their details join the result, and a
* failure after their backup names it (ruling P14, VisibleEdits). $entry names the entry in that error.
*
* @param array<string, mixed> $input
* @param array{scope: SaveScope, details: array<string, mixed>} $prepared
* @return array<string, mixed>
* @throws WriteFailed
*/
private function savePrepared(
string $ability,
DiviPage $page,
DiviChange $change,
array $input,
array $prepared,
string $entry
): array {
try {
$saved = $this->save($ability, $page, $change, $input, $prepared['scope']);
} catch (WriteFailed $e) {
throw VisibleEdits::keepingBackup($e, $prepared['details']['backup_id'] ?? null, $entry);
}
return array_merge($saved, $prepared['details']);
}
/** A tool's confirm_required on a global library entry or a live Theme Builder layout, extended by who sees it. */
public function notice(DiviPage $page, WP_Error $error): WP_Error
{
if ($page->isThemeBuilderLayout()) {
return $this->themeBuilder?->notice($page, $error) ?? $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)
* section – unless the removed node was that section itself: its ID now names the next section,
* which did not change (review m-7).
*
* @return array{text: ?string, truncated: bool}
*/
private function outline(NodeIndex $index, DiviChange $change): array
{
$sections = [];
foreach ($change->entries() as $entry) {
$id = DiviChange::idOf($entry, $index);
$wholeSection = $entry['block'] === null && $id !== null && ! str_contains($id, '.');
if ($id !== null && ! $wholeSection) {
$sections[explode('.', $id)[0]] = true;
}
}
if ($sections === []) {
return ['text' => null, 'truncated' => false];
}
$outline = new Outline($this->design->read()->presetNames(), $this->loader->libraryIdsVisible());
return $outline->summary($index, array_map('strval', array_keys($sections)));
}
}