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
@@ -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;
}
}
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Library;
defined('ABSPATH') || exit;
/**
* Pages (and Theme Builder layouts) that show global library entry $id: every living post whose
* content has "globalModule":"<id>" – Divi's own way to find instances (string scan,
* BlockParserStore, F10). Titles only for posts the user may read. Not final so tests can mock it.
*/
class GlobalUsages
{
public const LIMIT = 50;
private const WHERE = "post_content LIKE %s AND post_type <> 'revision' "
. "AND post_status NOT IN ('trash', 'auto-draft', 'inherit')";
public function __construct(private ?\wpdb $db = null)
{
}
/** @return array{items: list<array{post_id: int, title: string, status: string, type: string}>, count: int} */
public function of(int $libraryId): array
{
$db = $this->db();
$like = self::like($db, $libraryId);
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
$count = (int) $db->get_var(
$db->prepare('SELECT COUNT(*) FROM ' . $db->posts . ' WHERE ' . self::WHERE, $like)
);
$rows = $db->get_results(
$db->prepare(
'SELECT ID, post_title, post_status, post_type FROM ' . $db->posts . ' WHERE ' . self::WHERE
. ' ORDER BY ID ASC LIMIT %d',
$like,
self::LIMIT
),
'ARRAY_A'
);
// phpcs:enable
$items = [];
foreach (is_array($rows) ? $rows : [] as $row) {
$id = (int) $row['ID'];
$items[] = [
'post_id' => $id,
'title' => current_user_can('read_post', $id) ? (string) $row['post_title'] : '(nicht lesbar)',
'status' => (string) $row['post_status'],
'type' => (string) $row['post_type'],
];
}
return ['items' => $items, 'count' => max($count, count($items))];
}
/**
* IDs of every published post that shows $libraryId – no limit (ruling P8 checks edit_post on
* each of them before a confirmed global edit).
*
* @return list<int>
*/
public function publishedIds(int $libraryId): array
{
$db = $this->db();
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
$ids = $db->get_col($db->prepare(
'SELECT ID FROM ' . $db->posts . " WHERE post_content LIKE %s AND post_type <> 'revision' "
. "AND post_status = 'publish' ORDER BY ID ASC",
self::like($db, $libraryId)
));
// phpcs:enable
return array_values(array_map('intval', is_array($ids) ? $ids : []));
}
private function db(): \wpdb
{
return $this->db ?? $GLOBALS['wpdb'];
}
private static function like(\wpdb $db, int $libraryId): string
{
return '%' . $db->esc_like('"globalModule":"' . $libraryId . '"') . '%';
}
}
@@ -53,7 +53,7 @@ class LibraryAccess
* names no library entry (the caller goes on with PostGuard). With the library hidden (ruling
* P9) or without the library right at all (ruling P12), an entry is refused exactly like an ID
* that does not exist, so its type and existence stay unknown; only a user with the library
* right who lacks edit_post on the entry gets forbidden (like pages).
* right who lacks edit_post on the entry gets forbidden (like pages). $forWrite adds canEdit().
*/
public function item(mixed $postId, bool $forWrite): WP_Post|WP_Error|null
{
@@ -67,7 +67,7 @@ class LibraryAccess
return PostGuard::notFound((int) $post->ID);
}
return $this->canRead($post) ?? $post;
return ($forWrite ? $this->canEdit($post) : $this->canRead($post)) ?? $post;
}
/** Listing the library: edit_posts and the Divi role option divi_library. */
@@ -93,6 +93,47 @@ class LibraryAccess
: Errors::forbidden(sprintf('Keine Berechtigung, Bibliothekseintrag %d zu bearbeiten.', (int) $item->ID));
}
/**
* Changing one entry: canRead(), the role option save_library, the Tools-tab gate and a live
* entry; a global entry additionally needs edit_global_library (E3 – Divi's builder route
* does not check edit_post on the entry, F9; LIT MCP does).
*/
public function canEdit(WP_Post $item): ?WP_Error
{
$refusal = $this->canRead($item) ?? $this->roles->refusal('save_library') ?? $this->writableGate();
if ($refusal !== null) {
return $refusal;
}
if ((string) $item->post_status !== 'publish') {
return Errors::invalidInput(
sprintf(
'Bibliothekseintrag %d ist nicht aktiv (Status „%s“); nichts wurde gespeichert.',
(int) $item->ID,
(string) $item->post_status
),
['post_id' => 'nur aktive Bibliothekseinträge'],
'Den Eintrag zuerst in der Divi-Bibliothek wiederherstellen.'
);
}
return $this->isGlobal($item) ? $this->roles->refusal('edit_global_library') : null;
}
/** Refusal while the Tools tab keeps this connection from writing to the library. */
public function writableGate(): ?WP_Error
{
if ($this->writable === null || ($this->writable)()) {
return null;
}
return Errors::forbidden(
'Schreiben in die Divi-Bibliothek ist für diese Verbindung abgeschaltet (Gruppe „Divi-Bibliothek“ auf '
. '„nur lesen“ oder deaktiviert).',
'Einen Administrator fragen: Einstellungen → LIT MCP → Tools.'
);
}
public function isGlobal(WP_Post $item): bool
{
return $this->store->isGlobal((int) $item->ID);