Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
543 lines
23 KiB
PHP
543 lines
23 KiB
PHP
<?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)));
|
||
}
|
||
}
|