Files
WP-LIT-MCP/src/Integrations/Divi/Library/GlobalEdits.php
T
janniclas.loosenandClaude Opus 5.5 fec5d9da7b fix: GlobalUsages finds every reference Divi renders, fail closed (F1, ruling P23)
Candidates come from one ID/status/type query: content with globalModule,
global_module or a JSON-escaped letter. Their content is read in chunks of
50 and parsed by UsageMatcher: Divi 5 openers are json-decoded (escaped keys
and values, leading zeros, any depth), and Divi 4 global_module attributes
are read in any quoting. An undecodable escaped opener counts as a possible
reference. Beyond 5000 candidates the rest count unread. The guide names
the formats and the wp_block parity. Also fixes the grammar of the
blocked-hidden count.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 04:42:46 +02:00

293 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 – and any entry that pages show as an instance, global scope or not (audit L2:
* Divi renders every referenced 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, private or scheduled posts that show the entry (rulings
* P8/P8a, stricter than Divi); the dry run names them already (blocked_ids – posts the user may
* not read only as counts, audit L4). One GlobalUsages report per call (audit L1); above
* GlobalUsages::HARD_CAP live posts a confirmed edit is refused (Divi builder instead).
* 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]);
}
// Audit L2: Divi renders every entry an instance names, global scope or not.
$report = $this->usages->report($id);
if (! $global && ! $report->exists()) {
return ['scope' => SaveScope::LibraryItem, 'details' => []];
}
if (($input['confirm'] ?? false) !== true) {
return Errors::confirmRequired(
sprintf(
'%s: die Änderung wirkt sofort auf %d %s (siehe affected), auch veröffentlichte. Nichts wurde '
. 'gespeichert (Probelauf).',
self::label($id, $global),
$report->count,
self::pages($report->count)
) . self::hiddenNote($report) . self::blockedNote($report),
$report->details()
);
}
$refusal = $this->pagesRefusal(self::label($id, $global), $report) ?? $this->backupRefusal($item);
if ($refusal !== null) {
return $refusal;
}
$details = $report->listing();
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 or referenced entry (audit L2),
* 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') {
return $error;
}
$global = $this->access->isGlobal($page->post);
$report = $this->usages->report((int) $page->post->ID);
if (! $global && ! $report->exists()) {
return $error;
}
$data = $error->get_error_data();
$details = is_array($data) ? array_diff_key($data, ['status' => true, 'hint' => true]) : [];
return Errors::confirmRequired(
Errors::clientMessage($error)
. sprintf(
' %s: wirkt auf %d %s (siehe affected).',
$global ? 'Globaler Bibliothekseintrag' : 'Bibliothekseintrag (als globales Modul verwendet)',
$report->count,
self::pages($report->count)
)
. self::hiddenNote($report)
. self::blockedNote($report),
array_merge($details, $report->details())
);
}
/**
* Ruling P8a: a confirmed edit needs edit_post on every live post that shows entry $id; above
* GlobalUsages::HARD_CAP live posts it is refused as a whole (audit L1).
*/
private function pagesRefusal(string $label, UsageReport $report): ?WP_Error
{
if ($report->tooMany) {
return Errors::unsupported(
sprintf(
'%s erscheint auf %d veröffentlichten, privaten oder geplanten Einträgen – mehr als %d; so große '
. 'Änderungen nimmt LIT MCP nicht vor. Nichts wurde gespeichert.',
$label,
$report->live,
GlobalUsages::HARD_CAP
),
'Den Eintrag im Divi-Builder bearbeiten.'
);
}
if (! $report->blocks()) {
return null;
}
return Errors::forbidden(
sprintf(
'%s erscheint auf veröffentlichten, privaten oder geplanten Einträgen, die dieses Konto nicht '
. 'bearbeiten darf (%s); nichts wurde gespeichert.',
$label,
self::blockedList($report)
),
'Die Änderung von jemandem vornehmen lassen, der alle diese Einträge bearbeiten darf (z. B. Redakteur '
. 'oder Administrator).'
);
}
/** Audit L4: posts the caller may not read appear only as a count. */
private static function hiddenNote(UsageReport $report): string
{
return $report->hidden === 0 ? '' : sprintf(
' %d davon darf dieses Konto nicht lesen (nur gezählt, siehe hidden_count).',
$report->hidden
);
}
private static function blockedNote(UsageReport $report): string
{
if ($report->tooMany) {
return sprintf(
' Achtung: Die Bestätigung würde abgelehnt – mehr als %d veröffentlichte, private oder geplante '
. 'Einträge zeigen den Eintrag; ihn im Divi-Builder bearbeiten.',
GlobalUsages::HARD_CAP
);
}
return ! $report->blocks() ? '' : sprintf(
' Achtung: Die Bestätigung würde abgelehnt – veröffentlichte, private oder geplante Einträge, die dieses '
. 'Konto nicht bearbeiten darf, zeigen den Eintrag (%s).',
self::blockedList($report)
);
}
/** Readable blocking posts by ID (at most LISTED_IDS, see blocked_ids), unreadable ones as a count. */
private static function blockedList(UsageReport $report): string
{
$parts = [];
if ($report->blocked !== []) {
$parts[] = 'IDs ' . self::listed($report->blocked) . ', siehe blocked_ids';
}
if ($report->blockedHidden > 0) {
$parts[] = sprintf(
'%d %s dieses Konto nicht lesen darf',
$report->blockedHidden,
$report->blockedHidden === 1 ? 'Eintrag, den' : 'Einträge, die'
);
}
return implode('; ', $parts);
}
/** @param list<int> $ids At most LISTED_IDS of them, then "und N weitere". */
private static function listed(array $ids): string
{
$more = count($ids) - self::LISTED_IDS;
return implode(', ', array_slice($ids, 0, self::LISTED_IDS))
. ($more > 0 ? sprintf(' und %d weitere', $more) : '');
}
/** How messages name entry $id: global, or a normal entry that pages show as a global module (L2). */
private static function label(int $id, bool $global): string
{
return $global
? sprintf('Globaler Bibliothekseintrag %d', $id)
: sprintf('Bibliothekseintrag %d (auf Seiten als globales Modul verwendet)', $id);
}
private static function pages(int $count): string
{
return $count === 1 ? 'Seite' : 'Seiten';
}
/** 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;
}
}