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:
@@ -11,6 +11,8 @@ use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
|
||||
use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility;
|
||||
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
|
||||
use LIT\MCP\Integrations\Divi\Library\DiviRoles;
|
||||
use LIT\MCP\Integrations\Divi\Library\GlobalEdits;
|
||||
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
|
||||
use LIT\MCP\Integrations\Divi\Library\LibraryAccess;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;
|
||||
@@ -98,7 +100,14 @@ final class DiviIntegration implements Integration
|
||||
{
|
||||
$loader = new DiviPageLoader($this->schema, new Parser(), $this->access);
|
||||
$composition = new DiviComposition($this->modules, $this->design);
|
||||
$writer = new DiviWriter($loader, new DiviSaver(), $this->log, $this->design, $composition);
|
||||
$writer = new DiviWriter(
|
||||
$loader,
|
||||
new DiviSaver(),
|
||||
$this->log,
|
||||
$this->design,
|
||||
$composition,
|
||||
new GlobalEdits($this->access, new GlobalUsages())
|
||||
);
|
||||
|
||||
return [
|
||||
new DiviOutlineAbility($loader, $this->design),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user