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>
293 lines
11 KiB
PHP
293 lines
11 KiB
PHP
<?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;
|
||
}
|
||
}
|