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
+6
View File
@@ -181,6 +181,12 @@ final class ExposurePolicy
return isset($this->readonlyGroups[$group]); return isset($this->readonlyGroups[$group]);
} }
/** Whether abilities of $group may write: the group is neither "nur lesen" nor disabled as a whole. */
public function groupWritable(string $group): bool
{
return ! $this->isReadonly($group) && $this->groupMode($group) !== self::DISABLED;
}
/** /**
* Whether $group is switched on at all: not disabled as a whole ("nur lesen" still reads). The * Whether $group is switched on at all: not disabled as a whole ("nur lesen" still reads). The
* group "Divi-Bibliothek" disabled hides library entries from the Divi read tools (ruling P9). * group "Divi-Bibliothek" disabled hides library entries from the Divi read tools (ruling P9).
+10 -1
View File
@@ -11,6 +11,8 @@ use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility; use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility;
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore; use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
use LIT\MCP\Integrations\Divi\Library\DiviRoles; 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\Library\LibraryAccess;
use LIT\MCP\Integrations\Divi\Pages\DiviComposition; use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer; 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); $loader = new DiviPageLoader($this->schema, new Parser(), $this->access);
$composition = new DiviComposition($this->modules, $this->design); $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 [ return [
new DiviOutlineAbility($loader, $this->design), 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 * 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 * 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 * 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 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 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. */ /** 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)); : 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 public function isGlobal(WP_Post $item): bool
{ {
return $this->store->isGlobal((int) $item->ID); return $this->store->isGlobal((int) $item->ID);
@@ -49,7 +49,6 @@ final class DiviDeleteAbility extends DiviWriteAbility
'items' => ['type' => 'string'], 'items' => ['type' => 'string'],
'description' => 'Pfad-IDs der zu löschenden Knoten (aus lit-divi-outline).', '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'], 'required' => ['post_id', 'tree_rev', 'ids'],
'additionalProperties' => false, 'additionalProperties' => false,
@@ -44,8 +44,8 @@ final class DiviPageLoader
/** Readers pass $forWrite = false (trashed entries may be read, like lit-content-get). */ /** Readers pass $forWrite = false (trashed entries may be read, like lit-content-get). */
public function load(mixed $postId, bool $forWrite): DiviPage|WP_Error public function load(mixed $postId, bool $forWrite): DiviPage|WP_Error
{ {
// Library entries (E1): only for reading so far, and only with a library access. // Library entries (E1): with a library access, for reading and writing.
$item = $forWrite ? null : $this->library?->item($postId, false); $item = $this->library?->item($postId, $forWrite);
$post = $item ?? PostGuard::editable($postId, $this->schema, 'post_id', ! $forWrite); $post = $item ?? PostGuard::editable($postId, $this->schema, 'post_id', ! $forWrite);
if ($post instanceof WP_Error) { if ($post instanceof WP_Error) {
return $post; 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 * Additional checks for writing: the type must use the Divi builder (library entries always do),
* Divi 5 layout (or empty) and saving must not publish a missed schedule (spec §2). * 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 public function writable(DiviPage $page): ?WP_Error
{ {
$post = $page->post; $post = $page->post;
if (! $this->schema->diviEnabled((string) $post->post_type)) { if (! $page->isLibraryItem() && ! $this->schema->diviEnabled((string) $post->post_type)) {
return Errors::unsupported( return Errors::unsupported(
sprintf('Für den Inhaltstyp "%s" ist der Divi-Builder nicht aktiviert.', $post->post_type), 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).' '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 * 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 * 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 * it is written; DiviWriter::commit() 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 * log (ruling P1: a library entry keeps its shape and gets no Divi flags; a global entry is never
* and reported there, the others go on. Inserted text is cleaned by the field kind through * 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. * ValueCleaner::textReplacementFor() (S2/S28); URL and code fields are never touched.
*/ */
final class DiviReplaceTextAbility implements Ability final class DiviReplaceTextAbility implements Ability
@@ -366,7 +368,13 @@ final class DiviReplaceTextAbility implements Ability
$change->record($block, 'updated'); $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 + [ return $base + [
'ok' => true, '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 * Every hit of $page (DiviTextSearch::replace()), cleaned by the choke point of the page's
* own composition (ruling S22). * own composition (ruling S22).
+19 -4
View File
@@ -50,6 +50,9 @@ defined('ABSPATH') || exit;
* pages the bytes change for every user – reported as CHANGED_ON_SAVE. Finding R2 / rulings * 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. * 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). * If saving throws, the previous content is written back (WriteFailed: rolled back or not).
* Not final so abilities can be tested with a mock. * Not final so abilities can be tested with a mock.
*/ */
@@ -64,7 +67,7 @@ class DiviSaver
* @return array{revision_id: ?int, warnings: list<string>} * @return array{revision_id: ?int, warnings: list<string>}
* @throws WriteFailed * @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; $postId = (int) $post->ID;
$revisionId = null; $revisionId = null;
@@ -90,6 +93,7 @@ class DiviSaver
} }
$warnings = []; $warnings = [];
if ($scope->setsFlags()) {
FollowUp::run( FollowUp::run(
static fn () => DiviFlags::set($postId), static fn () => DiviFlags::set($postId),
'divi builder flags', 'divi builder flags',
@@ -97,10 +101,19 @@ class DiviSaver
. 'einmal im Divi-Builder öffnen und speichern.', . 'einmal im Divi-Builder öffnen und speichern.',
$warnings $warnings
); );
}
FollowUp::run( FollowUp::run(
static function () use ($postId): void { static function () use ($postId, $scope): void {
if (class_exists('ET_Core_PageResource')) { if (! class_exists('ET_Core_PageResource')) {
return;
}
\ET_Core_PageResource::remove_static_resources($postId, 'all', true); \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', 'divi static css',
@@ -128,14 +141,16 @@ class DiviSaver
* @return list<string> Warnings (CHANGED_ON_SAVE). * @return list<string> Warnings (CHANGED_ON_SAVE).
* @throws RuntimeException * @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)); $refusal = self::refusal($this->writeContent($postId, $content));
if ($refusal !== null) { if ($refusal !== null) {
throw new RuntimeException(sprintf('wp_update_post(%d) refused the Divi layout: %s', $postId, $refusal)); throw new RuntimeException(sprintf('wp_update_post(%d) refused the Divi layout: %s', $postId, $refusal));
} }
if ($scope->setsFlags()) {
DiviFlags::set($postId); DiviFlags::set($postId);
}
$stored = get_post($postId); $stored = get_post($postId);
@@ -18,6 +18,11 @@ final class DiviSchema
'type' => 'string', 'type' => 'string',
'description' => 'tree_rev aus lit-divi-outline bzw. der letzten Divi-Änderung (Pflicht).', '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 * 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 * 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 * 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 * validated, all or nothing (ruling S39) – and DiviWriter::commit() stores it (library entries:
* result. The composition is bound to the page (DiviWriter::composition(), ruling S22). * GlobalEdits) and builds the result. The composition is bound to the page (DiviWriter::composition(), ruling S22).
*/ */
abstract class DiviWriteAbility implements Ability abstract class DiviWriteAbility implements Ability
{ {
@@ -33,7 +33,14 @@ abstract class DiviWriteAbility implements Ability
public function outputSchema(): ?array 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 public function isDirect(): bool
@@ -43,6 +50,11 @@ abstract class DiviWriteAbility implements Ability
final public function permission(array $input): bool|WP_Error final public function permission(array $input): bool|WP_Error
{ {
$refusal = $this->precheck($input);
if ($refusal !== null) {
return $refusal;
}
$page = $this->writer->page($input); $page = $this->writer->page($input);
return $page instanceof WP_Error ? $page : true; return $page instanceof WP_Error ? $page : true;
@@ -50,6 +62,11 @@ abstract class DiviWriteAbility implements Ability
final public function execute(array $input): mixed final public function execute(array $input): mixed
{ {
$refusal = $this->precheck($input);
if ($refusal !== null) {
return $refusal;
}
$page = $this->writer->page($input); $page = $this->writer->page($input);
if ($page instanceof WP_Error) { if ($page instanceof WP_Error) {
return $page; return $page;
@@ -57,9 +74,20 @@ abstract class DiviWriteAbility implements Ability
$change = $this->change($page, $input, $this->writer->composition($page)); $change = $this->change($page, $input, $this->writer->composition($page));
if ($change instanceof WP_Error) { 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;
} }
} }
+53 -5
View File
@@ -13,6 +13,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Outline; use LIT\MCP\Integrations\Divi\BlockTree\Outline;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer; use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Library\GlobalEdits;
use LIT\MCP\Integrations\WordPress\PostSaveFollowUp; use LIT\MCP\Integrations\WordPress\PostSaveFollowUp;
use WP_Error; 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 * (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, * 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, * 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 final class DiviWriter
{ {
@@ -35,7 +37,8 @@ final class DiviWriter
private DiviSaver $saver, private DiviSaver $saver,
private ChangeLog $log, private ChangeLog $log,
private DesignSystemReader $design, private DesignSystemReader $design,
private DiviComposition $composition private DiviComposition $composition,
private ?GlobalEdits $globals = null
) { ) {
} }
@@ -79,8 +82,13 @@ final class DiviWriter
* @return array<string, mixed> * @return array<string, mixed>
* @throws WriteFailed * @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; $post = $page->post;
$content = Serializer::document($page->document); $content = Serializer::document($page->document);
if ($content === (string) $post->post_content) { if ($content === (string) $post->post_content) {
@@ -97,7 +105,7 @@ final class DiviWriter
$warnings = array_merge($change->warnings(), $quality); $warnings = array_merge($change->warnings(), $quality);
try { try {
$saved = $this->saver->save($post, $content); $saved = $this->saver->save($post, $content, $scope);
} catch (WriteFailed $e) { } catch (WriteFailed $e) {
if (! $e->rolledBack) { if (! $e->rolledBack) {
$this->log->recordPartial( $this->log->recordPartial(
@@ -140,6 +148,46 @@ final class DiviWriter
return $tool->toArray(); 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 * 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) * node has no block any more, but its ID from before the removal still names the (surviving)
+33
View File
@@ -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;
}
}
+5 -1
View File
@@ -78,7 +78,6 @@ final class Plugin
$libraryVisible = static function () use (&$policy): bool { $libraryVisible = static function () use (&$policy): bool {
return $policy instanceof ExposurePolicy && $policy->groupEnabled(LibraryAccess::GROUP); return $policy instanceof ExposurePolicy && $policy->groupEnabled(LibraryAccess::GROUP);
}; };
$divi = new DiviIntegration($schema, $changeLog, libraryVisible: $libraryVisible);
// Security M2: content-create's divi input follows lit/divi-insert (asked at execute time, // Security M2: content-create's divi input follows lit/divi-insert (asked at execute time,
// when registry and policy exist), so the Divi group's switches stop every Divi layout write. // when registry and policy exist), so the Divi group's switches stop every Divi layout write.
$diviWritable = static function () use (&$registry, &$policy): bool { $diviWritable = static function () use (&$registry, &$policy): bool {
@@ -86,6 +85,11 @@ final class Plugin
&& $policy instanceof ExposurePolicy && $policy instanceof ExposurePolicy
&& $policy->allows('lit/divi-insert', $registry->abilities()); && $policy->allows('lit/divi-insert', $registry->abilities());
}; };
// The Divi tools write to library entries only while the group "Divi-Bibliothek" may write (E9).
$libraryWritable = static function () use (&$policy): bool {
return $policy instanceof ExposurePolicy && $policy->groupWritable(LibraryAccess::GROUP);
};
$divi = new DiviIntegration($schema, $changeLog, $libraryWritable, libraryVisible: $libraryVisible);
$registry = new IntegrationRegistry( $registry = new IntegrationRegistry(
new CoreIntegration(self::pluginDir() . '/docs/guides'), new CoreIntegration(self::pluginDir() . '/docs/guides'),
new WordPressIntegration($schema, $changeLog, $divi->contentComposer($diviWritable)), new WordPressIntegration($schema, $changeLog, $divi->contentComposer($diviWritable)),
+19
View File
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
if (! class_exists('ET_Core_PageResource')) {
/**
* Divi's static CSS cache for the saver tests: records every remove_static_resources() call.
*/
class ET_Core_PageResource
{
/** @var list<array{0: int|string, 1: string, 2: bool}> */
public static array $removed = [];
public static function remove_static_resources($postId, $owner, $force = false): void
{
self::$removed[] = [$postId, $owner, (bool) $force];
}
}
}
+5 -2
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace LIT\MCP\Tests\Support; namespace LIT\MCP\Tests\Support;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Library\GlobalEdits;
use LIT\MCP\Integrations\Divi\Pages\DiviComposition; use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader; use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver; use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
@@ -98,7 +99,8 @@ final class DiviWriterFixture
array &$saved, array &$saved,
array &$failures = [], array &$failures = [],
bool $unfiltered = false, bool $unfiltered = false,
?DiviPageLoader $loader = null ?DiviPageLoader $loader = null,
?GlobalEdits $globals = null
): DiviWriter { ): DiviWriter {
$design = self::designReader(); $design = self::designReader();
@@ -107,7 +109,8 @@ final class DiviWriterFixture
self::saver($saved, $failures), self::saver($saved, $failures),
self::log(), self::log(),
$design, $design,
self::composition($design, $unfiltered) self::composition($design, $unfiltered),
$globals
); );
} }
} }
@@ -321,4 +321,12 @@ final class ExposurePolicyTest extends TestCase
self::assertFalse((new ExposurePolicy())->allows('lit/divi-insert', []), 'Divi not available'); self::assertFalse((new ExposurePolicy())->allows('lit/divi-insert', []), 'Divi not available');
} }
public function testGroupWritable(): void
{
self::assertTrue((new ExposurePolicy([]))->groupWritable('divi-library'));
self::assertFalse((new ExposurePolicy(['@readonly:divi-library' => true]))->groupWritable('divi-library'));
self::assertFalse((new ExposurePolicy(['@group:divi-library' => 'disabled']))->groupWritable('divi-library'));
self::assertTrue((new ExposurePolicy(['@group:divi-library' => 'indirect']))->groupWritable('divi-library'));
}
} }
@@ -0,0 +1,345 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Integrations\Divi\Library\GlobalEdits;
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviDuplicateNodeAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviReplaceTextAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\DiviUpdateAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
use LIT\MCP\Tests\Support\DiviFixtures;
use LIT\MCP\Tests\Support\DiviWriterFixture;
use LIT\MCP\Tests\Support\LibraryFixtures;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;
/** The Divi write tools on library entries (E2, answer F2). */
final class GlobalEditsTest extends TestCase
{
/** @var list<array{string, SaveScope}> Content and scope handed to the saver. */
private array $saved = [];
private int $backups = 0;
/** @var list<int> Published pages that show the global entry (ruling P8). */
private array $published = [1053];
private bool $trash = true;
protected function setUp(): void
{
parent::setUp();
Functions\when('wp_revisions_enabled')->justReturn(true);
}
private function usages(): GlobalUsages
{
$usages = Mockery::mock(GlobalUsages::class);
$usages->allows('of')->andReturn([
'items' => [
['post_id' => 1053, 'title' => 'Globale Instanzen', 'status' => 'publish', 'type' => 'page'],
['post_id' => 1070, 'title' => '(nicht lesbar)', 'status' => 'draft', 'type' => 'page'],
],
'count' => 2,
]);
$usages->allows('publishedIds')->andReturnUsing(fn (): array => $this->published);
return $usages;
}
/** @param list<string> $deniedRoles */
private function writer(bool $writable = true, array $deniedRoles = []): DiviWriter
{
$saver = Mockery::mock(DiviSaver::class);
$saver->allows('save')->andReturnUsing(function (WP_Post $post, string $content, SaveScope $scope): array {
$this->saved[] = [$content, $scope];
$post->post_content = $content;
return ['revision_id' => 811, 'warnings' => []];
});
$access = LibraryFixtures::access($deniedRoles, $writable);
$design = DiviWriterFixture::designReader();
$globals = new GlobalEdits(
$access,
$this->usages(),
function (WP_Post $item): int {
++$this->backups;
return 1071;
},
trashEnabled: fn (): bool => $this->trash
);
return new DiviWriter(
LibraryFixtures::loader($access),
$saver,
DiviWriterFixture::log(),
$design,
DiviWriterFixture::composition($design),
$globals
);
}
private static function rev(int $id): string
{
return Rev::of((string) LibraryFixtures::item($id)->post_content);
}
/** @return array<string, mixed> */
private static function relabel(int $id, bool $confirm = false): array
{
return array_merge(
['post_id' => $id, 'tree_rev' => self::rev($id), 'changes' => [['id' => 'm0', 'label' => 'Neu']]],
$confirm ? ['confirm' => true] : []
);
}
/** A user with every capability but $cap on the posts $ids. */
private static function denyOn(string $cap, array $ids): void
{
Functions\when('current_user_can')->alias(
static fn (string $asked, mixed ...$args): bool => ! ($asked === $cap
&& ($ids === [] || in_array((int) ($args[0] ?? 0), $ids, true)))
);
}
public function testPlainLibraryItemIsSavedWithoutConfirmAndWithoutDiviFlags(): void
{
LibraryFixtures::given([LibraryFixtures::item(1057)]);
$result = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1057));
self::assertTrue($result['ok']);
self::assertSame(SaveScope::LibraryItem, $this->saved[0][1]);
self::assertStringStartsWith('<!-- wp:divi/placeholder --><!-- wp:divi/blurb ', $this->saved[0][0]);
self::assertArrayNotHasKey('affected', $result);
}
public function testGlobalItemDryRunNamesTheAffectedPagesAndSavesNothing(): void
{
LibraryFixtures::given([LibraryFixtures::item(1059)]);
$result = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059));
self::assertToolError($result, 'confirm_required');
self::assertStringContainsString('wirkt sofort auf 2 Seiten', $result->get_error_message());
self::assertSame(1053, $result->get_error_data()['affected'][0]['post_id']);
self::assertSame(2, $result->get_error_data()['affected_count']);
self::assertSame([], $this->saved);
}
public function testConfirmedGlobalEditClearsEveryPageAndKeepsARevision(): void
{
LibraryFixtures::given([LibraryFixtures::item(1059)]);
$result = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059, true));
self::assertTrue($result['ok']);
self::assertSame(SaveScope::GlobalItem, $this->saved[0][1]);
self::assertSame(811, $result['revision_id']);
self::assertSame(2, $result['affected_count']);
self::assertArrayNotHasKey('backup_id', $result);
self::assertSame(0, $this->backups);
}
public function testWithoutRevisionsABackupIsMadeFirst(): void
{
Functions\when('wp_revisions_enabled')->justReturn(false);
LibraryFixtures::given([LibraryFixtures::item(1059)]);
$result = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059, true));
self::assertSame(1071, $result['backup_id']);
self::assertSame(1, $this->backups);
}
/** Ruling P9 (M-6): PostCloner's backup goes through kses – for these users it would not be byte-faithful. */
public function testWithoutRevisionsAUserWithoutUnfilteredHtmlIsRefused(): void
{
Functions\when('wp_revisions_enabled')->justReturn(false);
LibraryFixtures::given([LibraryFixtures::item(1059)]);
self::denyOn('unfiltered_html', []);
$result = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059, true));
self::assertToolError($result, 'unsupported');
self::assertStringContainsString('Revisionen', $result->get_error_message());
self::assertSame(0, $this->backups);
self::assertSame([], $this->saved);
}
/** Ruling P9 (M-7): with the trash disabled the backup would be deleted at once. */
public function testWithoutRevisionsADisabledTrashIsRefused(): void
{
Functions\when('wp_revisions_enabled')->justReturn(false);
LibraryFixtures::given([LibraryFixtures::item(1059)]);
$this->trash = false;
$result = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059, true));
self::assertToolError($result, 'unsupported');
self::assertStringContainsString('Papierkorb', $result->get_error_message());
self::assertSame(0, $this->backups);
self::assertSame([], $this->saved);
}
/** Revisions on: neither unfiltered_html nor the trash matter (the revision is taken in the kses window). */
public function testWithRevisionsAFilteredUserMayConfirm(): void
{
LibraryFixtures::given([LibraryFixtures::item(1059)]);
self::denyOn('unfiltered_html', []);
$this->trash = false;
$result = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059, true));
self::assertTrue($result['ok']);
self::assertSame(SaveScope::GlobalItem, $this->saved[0][1]);
}
/** Ruling P8: a confirmed global edit needs edit_post on every published page that shows the entry. */
public function testConfirmIsRefusedWhenAPublishedPageShowingTheEntryIsNotEditable(): void
{
LibraryFixtures::given([LibraryFixtures::item(1059)]);
$this->published = [1053, 1080];
self::denyOn('edit_post', [1080]);
$dryRun = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059));
$result = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059, true));
self::assertToolError($dryRun, 'confirm_required', 'the dry run stays unchanged');
self::assertToolError($result, 'forbidden');
self::assertStringContainsString('1080', $result->get_error_message());
self::assertStringNotContainsString('1053', $result->get_error_message());
self::assertSame([], $this->saved);
self::assertSame(0, $this->backups);
}
public function testRefusesToEmptyOrDoubleTheRoot(): void
{
LibraryFixtures::given([LibraryFixtures::item(1058)]);
$emptied = (new DiviDeleteAbility($this->writer()))->execute(
['post_id' => 1058, 'tree_rev' => self::rev(1058), 'ids' => ['s0'], 'confirm' => true]
);
$doubled = (new DiviDuplicateNodeAbility($this->writer()))->execute(
['post_id' => 1058, 'tree_rev' => self::rev(1058), 'id' => 's0', 'confirm' => true]
);
self::assertToolError($emptied, 'validation_failed');
self::assertStringContainsString('nicht leer', $emptied->get_error_message());
self::assertToolError($doubled, 'validation_failed');
self::assertStringContainsString('genau ein divi/section', $doubled->get_error_message());
self::assertSame([], $this->saved);
}
public function testDeleteInAGlobalItemNamesTheAffectedPagesBeforeConfirm(): void
{
LibraryFixtures::given([LibraryFixtures::item(1058)]);
$result = (new DiviDeleteAbility($this->writer()))->execute(
['post_id' => 1058, 'tree_rev' => self::rev(1058), 'ids' => ['s0.r0.c0.m1']]
);
self::assertToolError($result, 'confirm_required');
self::assertStringContainsString('Globaler Bibliothekseintrag: wirkt auf 2 Seiten', $result->get_error_message());
self::assertSame(['s0.r0.c0.m1'], $result->get_error_data()['nodes']);
self::assertCount(2, $result->get_error_data()['affected']);
}
public function testConfirmWithAStaleTreeRevWritesNothing(): void
{
LibraryFixtures::given([LibraryFixtures::item(1059)]);
$result = (new DiviUpdateAbility($this->writer()))->execute(
['tree_rev' => 'abcdef012345'] + self::relabel(1059, true)
);
self::assertToolError($result, 'rev_mismatch');
self::assertSame([], $this->saved);
}
public function testGlobalItemsNeedEditGlobalLibraryAndAWritableGroup(): void
{
LibraryFixtures::given([LibraryFixtures::item(1059), LibraryFixtures::item(1057)]);
self::assertToolError(
(new DiviUpdateAbility($this->writer(true, ['edit_global_library'])))->permission(self::relabel(1059)),
'forbidden'
);
self::assertToolError((new DiviUpdateAbility($this->writer(false)))->permission(self::relabel(1057)), 'forbidden');
self::assertTrue(
(new DiviUpdateAbility($this->writer(true, ['edit_global_library'])))->permission(self::relabel(1057))
);
}
public function testTheOutputSchemaDeclaresTheGlobalFields(): void
{
$properties = (new DiviUpdateAbility($this->writer()))->outputSchema()['properties'];
self::assertSame('array', $properties['affected']['type']);
self::assertSame('integer', $properties['affected_count']['type']);
self::assertSame('integer', $properties['backup_id']['type']);
}
/** Ruling P1: lit-divi-replace-text saves library entries through DiviWriter::commit(). */
public function testReplaceTextSavesAPlainLibraryItemWithoutDiviFlags(): void
{
LibraryFixtures::given([LibraryFixtures::item(1057)]);
$result = $this->replaceText('Library-Modul', 'Bibliotheksmodul', 1057);
self::assertTrue($result['results'][0]['ok'], (string) ($result['results'][0]['error'] ?? ''));
self::assertSame(1, $result['results'][0]['count']);
self::assertSame(SaveScope::LibraryItem, $this->saved[0][1]);
self::assertStringContainsString('Bibliotheksmodul', $this->saved[0][0]);
}
/** Ruling P1 / answer F2: a global entry is never changed without the dry run that names its pages. */
public function testReplaceTextNeverChangesAGlobalItemAndNamesItsPages(): void
{
LibraryFixtures::given([LibraryFixtures::item(1059)]);
$result = $this->replaceText('Öffnungszeiten', 'Zeiten', 1059);
$entry = $result['results'][0];
self::assertFalse($entry['ok']);
self::assertStringContainsString('wirkt sofort auf 2 Seiten', $entry['error']);
self::assertStringContainsString('lit-divi-update', $entry['error']);
self::assertSame(2, $entry['affected_count']);
self::assertSame(1053, $entry['affected'][0]['post_id']);
self::assertSame([], $this->saved);
self::assertSame(0, $this->backups);
}
/** @return array<string, mixed> */
private function replaceText(string $search, string $replace, int $id): array
{
Functions\when('get_post_type_object')->justReturn(
(object) ['cap' => (object) ['edit_posts' => 'edit_pages', 'edit_others_posts' => 'edit_others_pages']]
);
$writer = $this->writer();
$ability = new DiviReplaceTextAbility(
DiviFixtures::schema(),
LibraryFixtures::loader(LibraryFixtures::access()),
$writer,
Mockery::mock(\wpdb::class)->makePartial()
);
return $ability->execute([
'search' => $search,
'replace' => $replace,
'dry_run' => false,
'confirm' => true,
'targets' => [['post_id' => $id, 'tree_rev' => self::rev($id)]],
]);
}
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class GlobalUsagesTest extends TestCase
{
public function testFindsInstancesLikeDiviAndHidesUnreadableTitles(): void
{
$db = Mockery::mock(\wpdb::class)->makePartial();
$queries = [];
$db->allows('get_var')->andReturnUsing(static function (string $sql) use (&$queries): string {
$queries[] = $sql;
return '3';
});
$db->allows('get_results')->andReturnUsing(static function (string $sql) use (&$queries): array {
$queries[] = $sql;
return [
['ID' => '1053', 'post_title' => 'Globale Instanzen', 'post_status' => 'draft', 'post_type' => 'page'],
['ID' => '1061', 'post_title' => 'Geheim', 'post_status' => 'private', 'post_type' => 'et_header_layout'],
];
});
Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => $id !== 1061);
$usages = (new GlobalUsages($db))->of(1058);
self::assertSame(3, $usages['count']);
self::assertSame(
[
['post_id' => 1053, 'title' => 'Globale Instanzen', 'status' => 'draft', 'type' => 'page'],
['post_id' => 1061, 'title' => '(nicht lesbar)', 'status' => 'private', 'type' => 'et_header_layout'],
],
$usages['items']
);
self::assertStringContainsString("LIKE '%\"globalModule\":\"1058\"%'", $queries[0]);
self::assertStringContainsString("post_status NOT IN ('trash', 'auto-draft', 'inherit')", $queries[1]);
self::assertStringContainsString('LIMIT 50', $queries[1]);
}
/** Ruling P8: every published page showing the entry, beyond the listing limit. */
public function testPublishedIdsAreUnlimited(): void
{
$db = Mockery::mock(\wpdb::class)->makePartial();
$queries = [];
$db->allows('get_col')->andReturnUsing(static function (string $sql) use (&$queries): array {
$queries[] = $sql;
return ['1053', '1080'];
});
self::assertSame([1053, 1080], (new GlobalUsages($db))->publishedIds(1059));
self::assertStringContainsString("LIKE '%\"globalModule\":\"1059\"%'", $queries[0]);
self::assertStringContainsString("post_status = 'publish'", $queries[0]);
self::assertStringContainsString("post_type <> 'revision'", $queries[0]);
self::assertStringNotContainsString('LIMIT', $queries[0]);
}
}
@@ -113,4 +113,33 @@ final class LibraryAccessTest extends TestCase
self::assertSame($missing->get_error_data(), $refusal->get_error_data()); self::assertSame($missing->get_error_data(), $refusal->get_error_data());
} }
} }
public function testEditingNeedsSaveLibraryAPublishedItemAWritableGroupAndForGlobalsEditGlobalLibrary(): void
{
LibraryFixtures::given([
LibraryFixtures::item(1057),
LibraryFixtures::item(1059),
LibraryFixtures::item(1055, ['post_status' => 'trash']),
]);
self::assertInstanceOf(WP_Post::class, LibraryFixtures::access()->item(1057, true));
self::assertToolError(LibraryFixtures::access(['save_library'])->item(1057, true), 'forbidden');
$readonly = LibraryFixtures::access([], false)->item(1057, true);
self::assertToolError($readonly, 'forbidden');
self::assertStringContainsString('Divi-Bibliothek', $readonly->get_error_message());
self::assertToolError(LibraryFixtures::access()->item(1055, true), 'validation_failed');
self::assertInstanceOf(WP_Post::class, LibraryFixtures::access()->item(1059, true));
self::assertToolError(LibraryFixtures::access(['edit_global_library'])->item(1059, true), 'forbidden');
self::assertInstanceOf(WP_Post::class, LibraryFixtures::access(['edit_global_library'])->item(1057, true));
}
public function testEditingNeedsEditPostOnTheEntryAndReadOnlyStillReads(): void
{
LibraryFixtures::given([LibraryFixtures::item(1057)], ['edit_post']);
self::assertToolError(LibraryFixtures::access()->item(1057, true), 'forbidden');
LibraryFixtures::given([LibraryFixtures::item(1057)]);
self::assertInstanceOf(WP_Post::class, LibraryFixtures::access([], false)->item(1057, false));
self::assertNull(LibraryFixtures::access()->writableGate());
}
} }
@@ -127,15 +127,28 @@ final class DiviPageLoaderTest extends TestCase
); );
} }
public function testLibraryItemsAreNotWritableYet(): void public function testLibraryItemsAreWritableWhateverTheBuilderSettingOfTheirType(): void
{ {
LibraryFixtures::given([LibraryFixtures::item(1058)]); LibraryFixtures::given([LibraryFixtures::item(1057)]);
$loader = new DiviPageLoader(DiviFixtures::schema(false), new Parser(), LibraryFixtures::access());
$page = $loader->loadWritable(1057);
self::assertInstanceOf(DiviPage::class, $page);
self::assertTrue($page->isLibraryItem());
}
public function testWritingALibraryItemNeedsTheLibraryAccessAndItsEditRights(): void
{
LibraryFixtures::given([LibraryFixtures::item(1057)]);
$schema = Mockery::mock(ContentSchema::class); $schema = Mockery::mock(ContentSchema::class);
$schema->allows('isManaged')->with('et_pb_layout')->andReturn(false); $schema->allows('isManaged')->with('et_pb_layout')->andReturn(false);
$result = (new DiviPageLoader($schema, new Parser(), LibraryFixtures::access()))->load(1058, true); self::assertToolError((new DiviPageLoader($schema))->loadWritable(1057), 'not_found');
self::assertToolError(
self::assertToolError($result, 'not_found'); (new DiviPageLoader($schema, new Parser(), LibraryFixtures::access([], false)))->loadWritable(1057),
'forbidden'
);
} }
/** Ruling P9: the group "Divi-Bibliothek" disabled hides library entries from the Divi read tools. */ /** Ruling P9: the group "Divi-Bibliothek" disabled hides library entries from the Divi read tools. */
@@ -7,6 +7,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
use Brain\Monkey\Functions; use Brain\Monkey\Functions;
use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver; use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
use LIT\MCP\Tests\Unit\TestCase; use LIT\MCP\Tests\Unit\TestCase;
use RuntimeException; use RuntimeException;
use WP_Error; use WP_Error;
@@ -267,4 +268,36 @@ final class DiviSaverTest extends TestCase
$this->expectException(RuntimeException::class); $this->expectException(RuntimeException::class);
(new DiviSaver())->saveNew(42, 'NEU'); (new DiviSaver())->saveNew(42, 'NEU');
} }
public function testLibraryItemsGetNoDiviFlagsAndGlobalOnesClearEveryPage(): void
{
\ET_Core_PageResource::$removed = [];
Functions\when('current_user_can')->justReturn(true);
Functions\when('wp_update_post')->justReturn(7);
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 7, 'post_content' => 'NEU']));
$cleared = [];
Functions\when('et_theme_builder_clear_wp_cache')->alias(static function (string $what) use (&$cleared): void {
$cleared[] = $what;
});
(new DiviSaver())->save($this->post, 'NEU', SaveScope::LibraryItem);
self::assertSame([], array_filter($this->calls, static fn (string $call): bool => str_starts_with($call, 'meta:')));
self::assertSame([[7, 'all', true]], \ET_Core_PageResource::$removed);
self::assertSame([], $cleared);
\ET_Core_PageResource::$removed = [];
(new DiviSaver())->save($this->post, 'NEU', SaveScope::GlobalItem);
self::assertSame([[7, 'all', true], ['all', 'all', true]], \ET_Core_PageResource::$removed);
self::assertSame(['all'], $cleared);
}
public function testSaveNewOfALibraryItemSetsNoFlags(): void
{
Functions\when('current_user_can')->justReturn(true);
Functions\when('wp_update_post')->justReturn(42);
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 42, 'post_content' => 'NEU']));
self::assertSame([], (new DiviSaver())->saveNew(42, 'NEU', SaveScope::LibraryItem));
self::assertSame([], array_filter($this->calls, static fn (string $call): bool => str_starts_with($call, 'meta:')));
}
} }
+1
View File
@@ -16,3 +16,4 @@ require_once __DIR__ . '/Stubs/WpTerm.php';
require_once __DIR__ . '/Stubs/WpApplicationPasswords.php'; require_once __DIR__ . '/Stubs/WpApplicationPasswords.php';
require_once __DIR__ . '/Stubs/WpListTable.php'; require_once __DIR__ . '/Stubs/WpListTable.php';
require_once __DIR__ . '/Stubs/RequestsException.php'; require_once __DIR__ . '/Stubs/RequestsException.php';
require_once __DIR__ . '/Stubs/EtCorePageResource.php';