|null $postIds Only these posts; null = every usage. * @return array{pages: list, warnings: list}>, * pages_count: int, nodes_count: int, skipped: list, * blocked_ids: list, 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 "": $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}>}|WP_Error * 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); $refusal = $this->refusal($this->usages->reportFor($query), $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]; } /** * 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))); } }