Files
WP-LIT-MCP/src/Integrations/Divi/Design/PageRewriter.php
T

543 lines
23 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\Design;
use Closure;
use Generator;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\FollowUp;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
use LIT\MCP\Integrations\Divi\Library\UsageQuery;
use LIT\MCP\Integrations\Divi\Library\UsageReport;
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Log;
use WP_Error;
use WP_Post;
defined('ABSPATH') || exit;
/**
* The page rewriter of spec §6.6 (rulings R1, R8, R9) – the shared step of lit/divi-presets-assign and
* lit/divi-design-detach: it changes references in many posts at once. Usages come from GlobalUsages (reportFor()
* for the rights, rowsFor() for the candidates, ID ASC); each candidate is loaded fresh (loadWritable(), no
* tree_rev), rewritten by TreeRewrite with a fresh NodeRewrite and – in apply(), after the caller's confirm – saved
* by DiviSaver (restore point revision, Divi flags, caches; its kses window like every Divi write, R9: only preset
* references and local values change, no client content) with lit_mcp_after_save and a change log entry per post.
* - A live usage the caller may not edit blocks the whole write (ruling P8a, like GlobalEdits); above
* GlobalUsages::HARD_CAP live usages the change is refused. With post_ids only those posts count – each one is
* checked by loadWritable() anyway.
* - At most PAGE_LIMIT posts are saved per call (A6); "remaining" counts the further posts with changes. Every
* candidate is rewritten in memory for that, in chunks of GlobalUsages::CONTENT_CHUNK with a primed post cache
* that is dropped afterwards (audit L1).
* - Skipped (named, never written): posts loadWritable() refuses (rights, Divi 4, unparsable markup – R8: not
* remaining, so repeated calls end), global instances the change concerns (their library entry is a usage of its
* own; its ID is named only when DiviPageLoader::libraryIdsVisible(), ruling P13), library entries shown on
* live posts the caller may not edit (P8a), and library entries or Theme Builder layouts visible site-wide
* without revisions (no restore point; a bulk write makes no backup copies). Posts the caller may not read are
* never named, only counted (hidden_count, audit L4).
* - Save scope as in DiviWriter. The rewrite changes attributes only, so a library entry keeps its shape
* (LibraryContent::shapeViolation() needs no check).
* - A failed save after other posts were saved answers partial_write with data.saved; a failure before the first
* saved post is thrown like in every Divi write (WriteFailed, DiviLocked).
*
* Not final so abilities can be tested with a mock (like GlobalUsages).
*/
class PageRewriter
{
/** Posts saved per confirmed call (A6); the dry run lists as many. */
public const PAGE_LIMIT = 50;
/** A post named in post_ids that the call does not change – the same text whatever the reason (audit L4). */
public const NO_CHANGE = 'Keine Änderung: Der Eintrag existiert nicht, ist nicht lesbar oder enthält kein '
. 'betroffenes Modul.';
/** Messages name at most this many post IDs. */
private const LISTED_IDS = 10;
public function __construct(
private GlobalUsages $usages,
private DiviPageLoader $loader,
private DiviSaver $saver,
private ChangeLog $log
) {
}
/**
* The dry run: what apply() would write, without writing. Pages are listed up to PAGE_LIMIT, the counts cover
* every post with changes. Refused only above GlobalUsages::HARD_CAP live usages (without post_ids); posts that
* would block the write are named in blocked_ids or counted in blocked_hidden_count.
*
* @param Closure(): NodeRewrite $rewrite A fresh strategy per post.
* @param list<int>|null $postIds Only these posts; null = every usage.
* @return array{pages: list<array{post_id: int, title: string, type: string, status: string,
* nodes: list<array{id: string, module: string, label: string}>, warnings: list<string>}>,
* pages_count: int, nodes_count: int, skipped: list<array{post_id: int, reason: string}>,
* blocked_ids: list<int>, blocked_hidden_count: int, hidden_count: int}|WP_Error
*/
public function preview(UsageQuery $query, Closure $rewrite, ?array $postIds): array|WP_Error
{
$ids = self::ids($postIds);
$report = $this->usages->reportFor($query);
if ($ids === null && $report->tooMany) {
return self::tooMany($report);
}
$skipped = [];
$pages = [];
$pagesCount = 0;
$nodesCount = 0;
foreach ($this->changes($query, $rewrite, $ids, $skipped) as $change) {
++$pagesCount;
$nodesCount += count($change['result']['changed']);
if (count($pages) < self::PAGE_LIMIT) {
$pages[] = self::listing($change);
}
}
$blocked = self::blocked($report, $ids);
return [
'pages' => $pages,
'pages_count' => $pagesCount,
'nodes_count' => $nodesCount,
'skipped' => $skipped,
'blocked_ids' => $blocked['ids'],
'blocked_hidden_count' => $blocked['hidden'],
'hidden_count' => $ids === null ? $report->hidden : 0,
];
}
/**
* Writes at most PAGE_LIMIT posts – call it only after the caller's confirm. Each saved post gets its revision
* (DiviSaver) and a change log entry "Divi "<title>": $summary".
*
* @param Closure(): NodeRewrite $rewrite A fresh strategy per post.
* @param list<int>|null $postIds Only these posts; null = every usage.
* @return array{saved: list<array{post_id: int, revision_id: ?int, nodes: int}>, remaining: int,
* warnings: list<string>, skipped: list<array{post_id: int, reason: string}>, hidden_count: int}|WP_Error
* hidden_count = usages this account may not read (never rewritten, as in preview(); 0 with post_ids);
* errors: unsupported (above HARD_CAP), forbidden (blocked), partial_write (data.saved)
* @throws WriteFailed When saving the first post fails.
* @throws DiviLocked When Divi is not released – before the first post is written.
*/
public function apply(
string $ability,
UsageQuery $query,
Closure $rewrite,
?array $postIds,
string $summary
): array|WP_Error {
$ids = self::ids($postIds);
$report = $this->usages->reportFor($query);
$refusal = $this->refusal($report, $ids);
if ($refusal !== null) {
return $refusal;
}
$skipped = [];
$saved = [];
$warnings = [];
$remaining = 0;
foreach ($this->changes($query, $rewrite, $ids, $skipped) as $change) {
if (count($saved) >= self::PAGE_LIMIT) {
++$remaining;
continue;
}
try {
$saved[] = $this->save($ability, $change, $summary, $warnings);
} catch (WriteFailed | DiviLocked $e) {
return $this->failed($ability, $change['page']->post, $e, $saved);
}
}
return [
'saved' => $saved,
'remaining' => $remaining,
'warnings' => $warnings,
'skipped' => $skipped,
'hidden_count' => $ids === null ? $report->hidden : 0,
];
}
/**
* Every candidate post that the rewrite changes, one after the other (ID order). Candidates are read in chunks
* with a primed post cache that is dropped afterwards (audit L1). Posts left alone with a reason are added to
* $skipped; post_ids that changed nothing and have no other reason get NO_CHANGE at the end.
*
* @param Closure(): NodeRewrite $rewrite
* @param list<int>|null $ids
* @param list<array{post_id: int, reason: string}> $skipped
* @return Generator<int, array{page: DiviPage, result: array{changed: list<array{id: string, module: string,
* label: string}>, warnings: list<string>, skipped: list<array{id: string, reason: string}>},
* content: string, scope: SaveScope}>
*/
private function changes(UsageQuery $query, Closure $rewrite, ?array $ids, array &$skipped): Generator
{
$rows = $this->usages->rowsFor($query);
if ($ids !== null) {
$rows = array_filter($rows, static fn (array $row): bool => in_array($row['id'], $ids, true));
}
$seen = [];
foreach (array_chunk(array_column($rows, 'id'), GlobalUsages::CONTENT_CHUNK) as $chunk) {
$fresh = _get_non_cached_ids($chunk, 'posts');
_prime_post_caches($chunk, false, false);
try {
foreach ($chunk as $id) {
$before = count($skipped);
$change = $this->change((int) $id, $rewrite, $skipped);
if ($change !== null || count($skipped) > $before) {
$seen[] = (int) $id;
}
if ($change !== null) {
yield $change;
}
}
} finally {
foreach ($fresh as $freshId) {
wp_cache_delete((int) $freshId, 'posts');
}
}
}
foreach (array_diff($ids ?? [], $seen) as $id) {
$skipped[] = ['post_id' => $id, 'reason' => self::NO_CHANGE];
}
}
/**
* Post $id loaded fresh and rewritten in memory; null when nothing changes or the post is left alone (then
* with a reason in $skipped, unless the caller may not see it).
*
* @param Closure(): NodeRewrite $rewrite
* @param list<array{post_id: int, reason: string}> $skipped
* @return array{page: DiviPage, result: array{changed: list<array{id: string, module: string, label: string}>,
* warnings: list<string>, skipped: list<array{id: string, reason: string}>}, content: string,
* scope: SaveScope}|null
*/
private function change(int $id, Closure $rewrite, array &$skipped): ?array
{
if (! current_user_can('read_post', $id)) {
return null;
}
$page = $this->loader->loadWritable($id);
if ($page instanceof WP_Error) {
// A hidden library entry or Theme Builder layout reads like a missing ID (P12): never named.
if (Errors::codeOf($page) !== 'not_found') {
$skipped[] = ['post_id' => $id, 'reason' => Errors::clientMessage($page)];
}
return null;
}
$result = (new TreeRewrite($rewrite(), $this->loader->libraryIdsVisible()))->apply($page->index);
foreach ($result['skipped'] as $node) {
$skipped[] = ['post_id' => $id, 'reason' => $node['id'] . ': ' . $node['reason']];
}
$content = Serializer::document($page->document);
if ($result['changed'] === [] || $content === (string) $page->post->post_content) {
return null;
}
$scope = $this->scope($page);
if (is_string($scope)) {
$skipped[] = ['post_id' => $id, 'reason' => $scope];
return null;
}
return ['page' => $page, 'result' => $result, 'content' => $content, 'scope' => $scope];
}
/**
* The save scope of $page as DiviWriter chooses it, or why the rewriter leaves $page alone: a library entry
* shown on live posts the caller may not edit (P8a), a site-wide entry or a Theme Builder layout without
* revisions (no restore point).
*/
private function scope(DiviPage $page): SaveScope|string
{
$post = $page->post;
$id = (int) $post->ID;
if ($page->isThemeBuilderLayout()) {
return self::withoutRevisions($post, sprintf('Theme-Builder-Layout %d', $id))
?? SaveScope::ThemeBuilderLayout;
}
if (! $page->isLibraryItem()) {
return SaveScope::Page;
}
$report = $this->usages->report($id);
$global = $this->loader->library()?->isGlobal($post) ?? false;
if (! $global && ! $report->exists()) {
return SaveScope::LibraryItem;
}
if ($report->blocks()) {
return self::blockedEntry($id, $report);
}
return self::withoutRevisions($post, sprintf('Bibliothekseintrag %d', $id)) ?? SaveScope::GlobalItem;
}
/**
* Saves one changed post, then lit_mcp_after_save and the change log (failures there are warnings – the post
* is saved). Warnings join $warnings with the post ID in front.
*
* @param array{page: DiviPage, result: array{changed: list<array{id: string, module: string, label: string}>,
* warnings: list<string>, skipped: list<array{id: string, reason: string}>}, content: string,
* scope: SaveScope} $change
* @param list<string> $warnings
* @return array{post_id: int, revision_id: ?int, nodes: int}
* @throws WriteFailed
* @throws DiviLocked
*/
private function save(string $ability, array $change, string $summary, array &$warnings): array
{
$post = $change['page']->post;
$id = (int) $post->ID;
$saved = $this->saver->save($post, $change['content'], $change['scope']);
$notes = array_merge($change['result']['warnings'], $saved['warnings']);
FollowUp::run(
static fn () => do_action('lit_mcp_after_save', $id, $ability, ['post_id' => $id]),
$ability . ' lit_mcp_after_save',
'Eine Erweiterung ist nach dem Speichern auf einen Fehler gestoßen; die Änderung ist gespeichert.',
$notes
);
FollowUp::run(
fn () => $this->log->record(
$ability,
(string) $post->post_type,
(string) $id,
sprintf('Divi "%s": %s', $post->post_title, $summary),
$saved['revision_id']
),
$ability . ' change log',
'Änderungsprotokoll konnte nicht geschrieben werden; die Änderung ist gespeichert.',
$notes
);
foreach ($notes as $note) {
$warnings[] = sprintf('Eintrag %d: %s', $id, $note);
}
return [
'post_id' => $id,
'revision_id' => $saved['revision_id'],
'nodes' => count($change['result']['changed']),
];
}
/**
* Saving $post failed after the posts in $saved: they stay saved (each with its revision) – partial_write
* with data.saved. A post the saver could not roll back is logged as partial (like DiviWriter).
*
* @param list<array{post_id: int, revision_id: ?int, nodes: int}> $saved
* @throws WriteFailed $e itself when nothing was saved before (the registrar answers as for every Divi write).
* @throws DiviLocked $e itself when nothing was saved before.
*/
private function failed(string $ability, WP_Post $post, WriteFailed|DiviLocked $e, array $saved): WP_Error
{
$id = (int) $post->ID;
$partial = $e instanceof WriteFailed && ! $e->rolledBack;
$revisionId = $e instanceof WriteFailed ? $e->revisionId : null;
if ($partial) {
$this->log->recordPartial(
$ability,
(string) $post->post_type,
(string) $id,
sprintf('Divi "%s": Änderung nur teilweise zurückgenommen', $post->post_title),
$revisionId
);
}
if ($saved === []) {
throw $e;
}
Log::failure(sprintf('%s: post %d after %d saved posts', $ability, $id, count($saved)), $e);
$reason = $e instanceof WriteFailed ? $e->reason : Errors::clientMessage($e->toError());
$count = count($saved);
$failedPost = $partial
? sprintf('Eintrag %d ist möglicherweise teilweise geändert', $id)
. ($revisionId === null ? '.' : sprintf(' (Stand vorher: Revision %d).', $revisionId))
: sprintf('Eintrag %d blieb unverändert.', $id);
return Errors::partialWrite(
sprintf('Eintrag %d: %s', $id, $reason),
$id,
$revisionId,
sprintf(
'%d %s davor %s gespeichert (siehe saved, je mit Revision); %s',
$count,
$count === 1 ? 'Eintrag' : 'Einträge',
$count === 1 ? 'ist' : 'sind',
$failedPost
),
sprintf(
'Den Aufruf nicht einfach wiederholen. Zuerst Eintrag %d mit lit-divi-outline prüfen und dem Nutzer '
. 'Bescheid geben; danach denselben Aufruf mit confirm: true erneut senden – bereits geänderte '
. 'Module trifft er nicht mehr.',
$id
),
['saved' => $saved]
);
}
/** Ruling P8a like GlobalEdits::pagesRefusal(): above HARD_CAP or with a blocked live usage nothing is written. */
private function refusal(UsageReport $report, ?array $ids): ?WP_Error
{
if ($ids === null && $report->tooMany) {
return self::tooMany($report);
}
$blocked = self::blocked($report, $ids);
if ($blocked['ids'] === [] && $blocked['hidden'] === 0) {
return null;
}
return Errors::make(
'forbidden',
sprintf(
'Die Änderung betrifft veröffentlichte, private oder geplante Einträge, die dieses Konto nicht '
. 'bearbeiten darf (%s); nichts wurde gespeichert.',
self::blockedList($blocked['ids'], $blocked['hidden'])
),
'Die Änderung von jemandem vornehmen lassen, der alle diese Einträge bearbeiten darf (z. B. Redakteur '
. 'oder Administrator), oder sie mit post_ids auf die eigenen Einträge beschränken.',
403,
['blocked_ids' => $blocked['ids']]
+ ($blocked['hidden'] > 0 ? ['blocked_hidden_count' => $blocked['hidden']] : [])
);
}
private static function tooMany(UsageReport $report): WP_Error
{
return Errors::unsupported(
sprintf(
'Die Änderung betrifft %d veröffentlichte, private oder geplante Einträge – mehr als %d; so große '
. 'Änderungen nimmt LIT MCP nicht vor. Nichts wurde gespeichert.',
$report->live,
GlobalUsages::HARD_CAP
),
'Die Änderung mit post_ids auf einzelne Einträge beschränken oder die Module im Divi-Builder ändern.'
);
}
/**
* The blocking posts that count: all of the report, or with post_ids only those among them (unreadable ones
* cannot be matched to IDs; loadWritable() refuses them one by one).
*
* @param list<int>|null $ids
* @return array{ids: list<int>, hidden: int}
*/
private static function blocked(UsageReport $report, ?array $ids): array
{
if ($ids === null) {
return ['ids' => $report->blocked, 'hidden' => $report->blockedHidden];
}
return ['ids' => array_values(array_intersect($report->blocked, $ids)), 'hidden' => 0];
}
/** Why library entry $id stays unchanged: pages that show it block the edit (P8a, audit L1). */
private static function blockedEntry(int $id, UsageReport $report): string
{
if ($report->tooMany) {
return sprintf(
'Bibliothekseintrag %d erscheint auf mehr als %d veröffentlichten, privaten oder geplanten Einträgen; '
. 'er bleibt unverändert – ihn im Divi-Builder bearbeiten.',
$id,
GlobalUsages::HARD_CAP
);
}
return sprintf(
'Bibliothekseintrag %d erscheint auf veröffentlichten, privaten oder geplanten Einträgen, die dieses Konto '
. 'nicht bearbeiten darf (%s); er bleibt unverändert.',
$id,
self::blockedList($report->blocked, $report->blockedHidden)
);
}
/** Why $post stays unchanged when revisions are off for it (no restore point, no backup copy in bulk). */
private static function withoutRevisions(WP_Post $post, string $label): ?string
{
if (wp_revisions_enabled($post)) {
return null;
}
return sprintf(
'Revisionen sind für %s abgeschaltet (kein Wiederherstellungspunkt); unverändert gelassen – Revisionen '
. 'aktivieren (WP_POST_REVISIONS) oder im Divi-Builder bearbeiten.',
$label
);
}
/**
* Readable blocking posts by ID (at most LISTED_IDS), unreadable ones as a count – as GlobalEdits names them.
*
* @param list<int> $ids
*/
private static function blockedList(array $ids, int $hidden): string
{
$parts = [];
if ($ids !== []) {
$more = count($ids) - self::LISTED_IDS;
$parts[] = 'IDs ' . implode(', ', array_slice($ids, 0, self::LISTED_IDS))
. ($more > 0 ? sprintf(' und %d weitere', $more) : '');
}
if ($hidden > 0) {
$parts[] = sprintf(
'%d %s dieses Konto nicht lesen darf',
$hidden,
$hidden === 1 ? 'Eintrag, den' : 'Einträge, die'
);
}
return implode('; ', $parts);
}
/**
* @param array{page: DiviPage, result: array{changed: list<array{id: string, module: string, label: string}>,
* warnings: list<string>, skipped: list<array{id: string, reason: string}>}, content: string,
* scope: SaveScope} $change
* @return array{post_id: int, title: string, type: string, status: string,
* nodes: list<array{id: string, module: string, label: string}>, warnings: list<string>}
*/
private static function listing(array $change): array
{
$post = $change['page']->post;
return [
'post_id' => (int) $post->ID,
'title' => (string) $post->post_title,
'type' => (string) $post->post_type,
'status' => (string) $post->post_status,
'nodes' => $change['result']['changed'],
'warnings' => $change['result']['warnings'],
];
}
/**
* @param array<mixed>|null $postIds
* @return list<int>|null
*/
private static function ids(?array $postIds): ?array
{
return $postIds === null ? null : array_values(array_unique(array_map('intval', $postIds)));
}
}