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:
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Library;
|
||||
|
||||
use Closure;
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use LIT\MCP\Abilities\WriteFailed;
|
||||
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\DiviPage;
|
||||
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
|
||||
use LIT\MCP\Integrations\WordPress\Status\PostCloner;
|
||||
use WP_Error;
|
||||
use WP_Post;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* The extra rules of a Divi write to a library entry (E2, answer F2), checked after the whole
|
||||
* change validated and before anything is saved:
|
||||
* - every entry keeps its shape (LibraryContent::shapeViolation) – instances expect their root type;
|
||||
* - a global entry changes every page that shows it at once (F7): without confirm: true nothing is
|
||||
* saved and the answer lists those pages (a dry run of the fully validated change); with it the
|
||||
* save scope is GlobalItem (site-wide cache) and, when revisions are off for the entry, a backup
|
||||
* copy is made first – the revision itself is DiviSaver's restore point, as for every page.
|
||||
* Confirm stays honour-based (ruling P9, like D10). A confirmed edit is refused while the user may
|
||||
* not edit_post one of the published pages that show the entry (ruling P8, stricter than Divi).
|
||||
* The backup (PostCloner, wp_insert_post with kses) is refused when it could not be faithful: for
|
||||
* users without unfiltered_html, and when the trash is disabled (ruling P9, S37).
|
||||
*/
|
||||
final class GlobalEdits
|
||||
{
|
||||
/** Refusals name at most this many page IDs. */
|
||||
private const LISTED_IDS = 10;
|
||||
|
||||
/** @var Closure(WP_Post): int */
|
||||
private Closure $backup;
|
||||
|
||||
/** @var Closure(): bool */
|
||||
private Closure $trashEnabled;
|
||||
|
||||
/**
|
||||
* @param (Closure(WP_Post): int)|null $backup Backup copy of the entry; null = PostCloner::backup().
|
||||
* @param (Closure(): bool)|null $trashEnabled Whether the trash keeps the backup; null = EMPTY_TRASH_DAYS.
|
||||
*/
|
||||
public function __construct(
|
||||
private LibraryAccess $access,
|
||||
private GlobalUsages $usages,
|
||||
?Closure $backup = null,
|
||||
private Parser $parser = new Parser(),
|
||||
?Closure $trashEnabled = null
|
||||
) {
|
||||
$this->backup = $backup ?? static fn (WP_Post $item): int => (new PostCloner())->backup($item);
|
||||
$this->trashEnabled = $trashEnabled
|
||||
?? static fn (): bool => ! defined('EMPTY_TRASH_DAYS') || (bool) EMPTY_TRASH_DAYS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $input
|
||||
* @return array{scope: SaveScope, details: array<string, mixed>}|WP_Error
|
||||
* @throws WriteFailed When the backup could not be made (nothing was saved).
|
||||
*/
|
||||
public function prepare(DiviPage $page, array $input): array|WP_Error
|
||||
{
|
||||
$item = $page->post;
|
||||
$id = (int) $item->ID;
|
||||
$global = $this->access->isGlobal($item);
|
||||
try {
|
||||
$before = LibraryContent::roots(LibraryContent::load($this->parser, (string) $item->post_content));
|
||||
} catch (MalformedMarkup) {
|
||||
$before = [];
|
||||
}
|
||||
|
||||
$violation = LibraryContent::shapeViolation(
|
||||
$before,
|
||||
LibraryContent::roots($page->document),
|
||||
$this->access->store()->layoutType($id),
|
||||
$global
|
||||
);
|
||||
if ($violation !== null) {
|
||||
return Errors::invalidInput($violation . ' Nichts wurde gespeichert.', ['post_id' => $violation]);
|
||||
}
|
||||
|
||||
if (! $global) {
|
||||
return ['scope' => SaveScope::LibraryItem, 'details' => []];
|
||||
}
|
||||
|
||||
$usages = $this->usages->of($id);
|
||||
$details = ['affected' => $usages['items'], 'affected_count' => $usages['count']];
|
||||
if (($input['confirm'] ?? false) !== true) {
|
||||
return Errors::confirmRequired(
|
||||
sprintf(
|
||||
'Globaler Bibliothekseintrag %d: die Änderung wirkt sofort auf %d %s (siehe affected), auch '
|
||||
. 'veröffentlichte. Nichts wurde gespeichert (Probelauf).',
|
||||
$id,
|
||||
$usages['count'],
|
||||
$usages['count'] === 1 ? 'Seite' : 'Seiten'
|
||||
),
|
||||
$details
|
||||
);
|
||||
}
|
||||
|
||||
$refusal = $this->pagesRefusal($id) ?? $this->backupRefusal($item);
|
||||
if ($refusal !== null) {
|
||||
return $refusal;
|
||||
}
|
||||
|
||||
if (! wp_revisions_enabled($item)) {
|
||||
$details['backup_id'] = ($this->backup)($item);
|
||||
}
|
||||
|
||||
return ['scope' => SaveScope::GlobalItem, 'details' => $details];
|
||||
}
|
||||
|
||||
/**
|
||||
* A tool's own confirm_required (lit-divi-delete) on a global entry, extended by the affected
|
||||
* pages – so the one confirm the client sends covers both questions.
|
||||
*/
|
||||
public function notice(DiviPage $page, WP_Error $error): WP_Error
|
||||
{
|
||||
if (Errors::codeOf($error) !== 'confirm_required' || ! $this->access->isGlobal($page->post)) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
$usages = $this->usages->of((int) $page->post->ID);
|
||||
$data = $error->get_error_data();
|
||||
$details = is_array($data) ? array_diff_key($data, ['status' => true, 'hint' => true]) : [];
|
||||
|
||||
return Errors::confirmRequired(
|
||||
Errors::clientMessage($error)
|
||||
. sprintf(' Globaler Bibliothekseintrag: wirkt auf %d Seiten (siehe affected).', $usages['count']),
|
||||
array_merge($details, ['affected' => $usages['items'], 'affected_count' => $usages['count']])
|
||||
);
|
||||
}
|
||||
|
||||
/** Ruling P8: every published page that shows entry $id must be editable by the user. */
|
||||
private function pagesRefusal(int $id): ?WP_Error
|
||||
{
|
||||
$blocked = array_values(array_filter(
|
||||
$this->usages->publishedIds($id),
|
||||
static fn (int $pageId): bool => ! current_user_can('edit_post', $pageId)
|
||||
));
|
||||
if ($blocked === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$listed = implode(', ', array_slice($blocked, 0, self::LISTED_IDS));
|
||||
$more = count($blocked) - self::LISTED_IDS;
|
||||
|
||||
return Errors::forbidden(
|
||||
sprintf(
|
||||
'Globaler Bibliothekseintrag %d erscheint auf veröffentlichten Einträgen, die dieses Konto nicht '
|
||||
. 'bearbeiten darf (IDs %s%s); nichts wurde gespeichert.',
|
||||
$id,
|
||||
$listed,
|
||||
$more > 0 ? sprintf(' und %d weitere', $more) : ''
|
||||
),
|
||||
'Die Änderung von jemandem vornehmen lassen, der alle diese Seiten bearbeiten darf (z. B. Redakteur '
|
||||
. 'oder Administrator).'
|
||||
);
|
||||
}
|
||||
|
||||
/** Ruling P9: a backup (only made when revisions are off) must be faithful and must survive. */
|
||||
private function backupRefusal(WP_Post $item): ?WP_Error
|
||||
{
|
||||
if (wp_revisions_enabled($item)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$hint = 'Revisionen für die Divi-Bibliothek aktivieren (WP_POST_REVISIONS) oder den Eintrag im Divi-Builder '
|
||||
. 'bearbeiten.';
|
||||
if (! current_user_can('unfiltered_html')) {
|
||||
return Errors::unsupported(
|
||||
sprintf(
|
||||
'Revisionen sind für Bibliothekseintrag %d abgeschaltet, und die Sicherungskopie wäre für dieses '
|
||||
. 'Konto nicht verlustfrei (WordPress filtert HTML); nichts wurde gespeichert.',
|
||||
(int) $item->ID
|
||||
),
|
||||
$hint
|
||||
);
|
||||
}
|
||||
|
||||
if (! ($this->trashEnabled)()) {
|
||||
return Errors::unsupported(
|
||||
sprintf(
|
||||
'Revisionen sind für Bibliothekseintrag %d abgeschaltet, und der Papierkorb ist deaktiviert '
|
||||
. '(EMPTY_TRASH_DAYS = 0) – die Sicherungskopie würde sofort gelöscht; nichts wurde '
|
||||
. 'gespeichert.',
|
||||
(int) $item->ID
|
||||
),
|
||||
$hint
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user