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
374 lines
15 KiB
PHP
374 lines
15 KiB
PHP
<?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)));
|
||
}
|
||
}
|