refactor: DiviTextSearch out of the replace-text ability (M2)
The SQL prefilter and the per-page hit computation move to DiviTextSearch; DiviPageLoader::loadWritable serves DiviWriter::page and the dry run; PostStatuses::NOT_CONTENT replaces the duplicated status list (same set, order in the SQL list only). The private log() wrapper is inlined. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
@@ -66,6 +66,17 @@ class DiviPageLoader
|
|||||||
return new DiviPage($post, $document, NodeIndex::build($document));
|
return new DiviPage($post, $document, NodeIndex::build($document));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The entry loaded for writing and checked by writable() – the refusal of either step. */
|
||||||
|
public function loadWritable(mixed $postId): DiviPage|WP_Error
|
||||||
|
{
|
||||||
|
$page = $this->load($postId, true);
|
||||||
|
if ($page instanceof WP_Error) {
|
||||||
|
return $page;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->writable($page) ?? $page;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Additional checks for writing: the type must use the Divi builder, the content must be a
|
* Additional checks for writing: the type must use the Divi builder, the content must be a
|
||||||
* Divi 5 layout (or empty) and saving must not publish a missed schedule (spec §2).
|
* Divi 5 layout (or empty) and saving must not publish a missed schedule (spec §2).
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ use LIT\MCP\Abilities\Errors;
|
|||||||
use LIT\MCP\Abilities\ToolResult;
|
use LIT\MCP\Abilities\ToolResult;
|
||||||
use LIT\MCP\Abilities\WriteFailed;
|
use LIT\MCP\Abilities\WriteFailed;
|
||||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||||
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
|
|
||||||
use LIT\MCP\Integrations\Divi\Compose\StoredForms;
|
|
||||||
use LIT\MCP\Integrations\Divi\Compose\TextReplacer;
|
use LIT\MCP\Integrations\Divi\Compose\TextReplacer;
|
||||||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||||
@@ -39,16 +37,15 @@ final class DiviReplaceTextAbility implements Ability
|
|||||||
|
|
||||||
private const DRY_RUN_LIMIT = 10;
|
private const DRY_RUN_LIMIT = 10;
|
||||||
|
|
||||||
private const SAMPLES = 5;
|
private DiviTextSearch $search;
|
||||||
|
|
||||||
public const GLOBAL_REASON = 'globales Modul – in der Bibliothek bearbeiten';
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private ContentSchema $schema,
|
private ContentSchema $schema,
|
||||||
private DiviPageLoader $loader,
|
private DiviPageLoader $loader,
|
||||||
private DiviWriter $writer,
|
private DiviWriter $writer,
|
||||||
private ?\wpdb $db = null
|
?\wpdb $db = null
|
||||||
) {
|
) {
|
||||||
|
$this->search = new DiviTextSearch($db);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function name(): string
|
public function name(): string
|
||||||
@@ -172,7 +169,8 @@ final class DiviReplaceTextAbility implements Ability
|
|||||||
}
|
}
|
||||||
|
|
||||||
$limit = Cursor::limit($input['limit'] ?? null, self::DRY_RUN_LIMIT);
|
$limit = Cursor::limit($input['limit'] ?? null, self::DRY_RUN_LIMIT);
|
||||||
$candidates = $this->candidates($postType, (string) $input['search'], $caseSensitive, $limit + 1, $offset);
|
$search = (string) $input['search'];
|
||||||
|
$candidates = $this->search->candidates($postType, $search, $caseSensitive, $limit + 1, $offset);
|
||||||
$page = Cursor::page($candidates, $limit, $offset);
|
$page = Cursor::page($candidates, $limit, $offset);
|
||||||
$matches = [];
|
$matches = [];
|
||||||
$skipped = [];
|
$skipped = [];
|
||||||
@@ -184,15 +182,14 @@ final class DiviReplaceTextAbility implements Ability
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$divi = $this->loader->load($postId, true);
|
$divi = $this->loader->loadWritable($postId);
|
||||||
$problem = $divi instanceof WP_Error ? $divi : $this->loader->writable($divi);
|
if ($divi instanceof WP_Error) {
|
||||||
if ($problem instanceof WP_Error) {
|
$skipped[] = ['post_id' => $postId, 'reason' => Errors::clientMessage($divi)];
|
||||||
$skipped[] = ['post_id' => $postId, 'reason' => Errors::clientMessage($problem)];
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$found = $this->replace($divi, $replacer());
|
$found = $this->hits($divi, $replacer());
|
||||||
} catch (InvalidArgumentException $e) {
|
} catch (InvalidArgumentException $e) {
|
||||||
$skipped[] = ['post_id' => $postId, 'reason' => $e->getMessage()];
|
$skipped[] = ['post_id' => $postId, 'reason' => $e->getMessage()];
|
||||||
continue;
|
continue;
|
||||||
@@ -319,11 +316,11 @@ final class DiviReplaceTextAbility implements Ability
|
|||||||
try {
|
try {
|
||||||
return $this->saveReplaced($replacer, $input);
|
return $this->saveReplaced($replacer, $input);
|
||||||
} catch (WriteFailed $e) {
|
} catch (WriteFailed $e) {
|
||||||
self::log($postId, $e);
|
Log::failure(sprintf('lit/divi-replace-text on post %d', $postId), $e);
|
||||||
|
|
||||||
return ['post_id' => $postId, 'ok' => false, 'error' => Errors::clientMessage($e->toError())];
|
return ['post_id' => $postId, 'ok' => false, 'error' => Errors::clientMessage($e->toError())];
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
self::log($postId, $e);
|
Log::failure(sprintf('lit/divi-replace-text on post %d', $postId), $e);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'post_id' => $postId,
|
'post_id' => $postId,
|
||||||
@@ -350,7 +347,7 @@ final class DiviReplaceTextAbility implements Ability
|
|||||||
$post = $page->post;
|
$post = $page->post;
|
||||||
$base = ['post_id' => $postId, 'type' => (string) $post->post_type, 'title' => (string) $post->post_title];
|
$base = ['post_id' => $postId, 'type' => (string) $post->post_type, 'title' => (string) $post->post_title];
|
||||||
try {
|
try {
|
||||||
$found = $this->replace($page, $replacer);
|
$found = $this->hits($page, $replacer);
|
||||||
} catch (InvalidArgumentException $e) {
|
} catch (InvalidArgumentException $e) {
|
||||||
return $base + ['ok' => false, 'error' => $e->getMessage() . ' Diese Seite wurde nicht geändert.'];
|
return $base + ['ok' => false, 'error' => $e->getMessage() . ' Diese Seite wurde nicht geändert.'];
|
||||||
}
|
}
|
||||||
@@ -379,102 +376,15 @@ final class DiviReplaceTextAbility implements Ability
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every hit of $page, computed without changing it. The replacement is cleaned per place by
|
* Every hit of $page (DiviTextSearch::replace()), cleaned by the choke point of the page's
|
||||||
* the entry's ValueCleaner (field kind; markers rejected); URL and code fields are skipped.
|
* own composition (ruling S22).
|
||||||
*
|
*
|
||||||
* @return array{count: int, samples: list<array<string, string>>,
|
* @return array{count: int, samples: list<array<string, string>>,
|
||||||
* blocks: list<array{Block, array<string, mixed>}>, skipped_nodes: list<array{id: string, reason: string}>}
|
* blocks: list<array{Block, array<string, mixed>}>, skipped_nodes: list<array{id: string, reason: string}>}
|
||||||
* @throws InvalidArgumentException When a place would be changed unsafely – the page fails.
|
* @throws InvalidArgumentException When a place would be changed unsafely – the page fails.
|
||||||
*/
|
*/
|
||||||
private function replace(DiviPage $page, TextReplacer $replacer): array
|
private function hits(DiviPage $page, TextReplacer $replacer): array
|
||||||
{
|
{
|
||||||
$cleaner = $this->writer->composition($page)->cleaner();
|
return $this->search->replace($page, $replacer, $this->writer->composition($page)->cleaner());
|
||||||
$count = 0;
|
|
||||||
$samples = [];
|
|
||||||
$blocks = [];
|
|
||||||
$skipped = [];
|
|
||||||
foreach ($page->index->entries() as $entry) {
|
|
||||||
$block = $entry['block'];
|
|
||||||
if ($block->isDivi() && $block->isOpaque()) {
|
|
||||||
if ($replacer->mentions(Serializer::block($block))) {
|
|
||||||
$skipped[] = ['id' => (string) $entry['id'], 'reason' => self::opaqueReason($block)];
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (! $block->isDivi()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$result = $replacer->apply($block->attrs(), $cleaner->textReplacementFor($block->name()));
|
|
||||||
} catch (InvalidArgumentException $e) {
|
|
||||||
throw new InvalidArgumentException(sprintf('Knoten %s: %s', $entry['id'], $e->getMessage()), 0, $e);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($result['count'] > 0) {
|
|
||||||
$count += $result['count'];
|
|
||||||
$blocks[] = [$block, $result['attrs']];
|
|
||||||
foreach ($result['samples'] as $sample) {
|
|
||||||
if (count($samples) < self::SAMPLES) {
|
|
||||||
$samples[] = ['id' => (string) $entry['id']] + $sample;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ['count' => $count, 'samples' => $samples, 'blocks' => $blocks, 'skipped_nodes' => $skipped];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* IDs of Divi entries whose content contains the search text in one of its stored forms
|
|
||||||
* (StoredForms::of()). The exact match happens in the tree; this is only a prefilter.
|
|
||||||
*
|
|
||||||
* @return list<int>
|
|
||||||
*/
|
|
||||||
private function candidates(string $postType, string $search, bool $caseSensitive, int $limit, int $offset): array
|
|
||||||
{
|
|
||||||
$db = $this->db();
|
|
||||||
$forms = StoredForms::of($search, $caseSensitive);
|
|
||||||
$likes = array_map(
|
|
||||||
static fn (string $form): string => '%' . $db->esc_like($form) . '%',
|
|
||||||
$forms
|
|
||||||
);
|
|
||||||
|
|
||||||
$sql = "SELECT ID FROM {$db->posts} WHERE post_type = %s"
|
|
||||||
. " AND post_status NOT IN ('trash', 'auto-draft', 'inherit')"
|
|
||||||
. ' AND post_content LIKE %s AND (' . implode(' OR ', array_fill(0, count($likes), 'post_content LIKE %s'))
|
|
||||||
. ')';
|
|
||||||
$args = array_merge([$postType, '%' . $db->esc_like('<!-- wp:divi/') . '%'], $likes);
|
|
||||||
$author = PostGuard::ownPostsOnly($postType);
|
|
||||||
if ($author !== null) {
|
|
||||||
$sql .= ' AND post_author = %d';
|
|
||||||
$args[] = $author;
|
|
||||||
}
|
|
||||||
$sql .= ' ORDER BY ID ASC LIMIT %d OFFSET %d';
|
|
||||||
array_push($args, $limit, $offset);
|
|
||||||
|
|
||||||
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $sql holds only the core posts table and one placeholder per argument.
|
|
||||||
$ids = $db->get_col($db->prepare($sql, ...$args));
|
|
||||||
// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
|
|
||||||
|
|
||||||
return array_map('intval', is_array($ids) ? $ids : []);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Why an opaque node is not changed (global modules are edited in the Divi library, Etappe 3). */
|
|
||||||
private static function opaqueReason(Block $block): string
|
|
||||||
{
|
|
||||||
return $block->isGlobal()
|
|
||||||
? self::GLOBAL_REASON
|
|
||||||
: 'Knoten nicht lesbar (Attribute kein JSON-Objekt) – im Divi-Builder bearbeiten';
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function log(int $postId, Throwable $e): void
|
|
||||||
{
|
|
||||||
Log::failure(sprintf('lit/divi-replace-text on post %d', $postId), $e);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function db(): \wpdb
|
|
||||||
{
|
|
||||||
return $this->db ?? $GLOBALS['wpdb'];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Pages;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
|
||||||
|
use LIT\MCP\Integrations\Divi\Compose\StoredForms;
|
||||||
|
use LIT\MCP\Integrations\Divi\Compose\TextReplacer;
|
||||||
|
use LIT\MCP\Integrations\Divi\Compose\ValueCleaner;
|
||||||
|
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||||||
|
use LIT\MCP\Integrations\WordPress\PostStatuses;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The search part of lit/divi-replace-text (D10): the SQL prefilter for candidate pages and
|
||||||
|
* the hits of one page, computed without changing it. The replacement is cleaned per place by
|
||||||
|
* the entry's ValueCleaner (ValueCleaner::textReplacementFor(): field kind, markers rejected);
|
||||||
|
* URL and code fields are skipped; opaque nodes that mention the search are reported, never
|
||||||
|
* changed.
|
||||||
|
*/
|
||||||
|
final class DiviTextSearch
|
||||||
|
{
|
||||||
|
private const SAMPLES = 5;
|
||||||
|
|
||||||
|
public const GLOBAL_REASON = 'globales Modul – in der Bibliothek bearbeiten';
|
||||||
|
|
||||||
|
public function __construct(private ?\wpdb $db = null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IDs of Divi entries whose content contains the search text in one of its stored forms
|
||||||
|
* (StoredForms::of()). The exact match happens in the tree; this is only a prefilter.
|
||||||
|
*
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
public function candidates(string $postType, string $search, bool $caseSensitive, int $limit, int $offset): array
|
||||||
|
{
|
||||||
|
$db = $this->db();
|
||||||
|
$forms = StoredForms::of($search, $caseSensitive);
|
||||||
|
$likes = array_map(
|
||||||
|
static fn (string $form): string => '%' . $db->esc_like($form) . '%',
|
||||||
|
$forms
|
||||||
|
);
|
||||||
|
|
||||||
|
$sql = "SELECT ID FROM {$db->posts} WHERE post_type = %s"
|
||||||
|
. ' AND post_status NOT IN (' . PostStatuses::NOT_CONTENT . ')'
|
||||||
|
. ' AND post_content LIKE %s AND (' . implode(' OR ', array_fill(0, count($likes), 'post_content LIKE %s'))
|
||||||
|
. ')';
|
||||||
|
$args = array_merge([$postType, '%' . $db->esc_like('<!-- wp:divi/') . '%'], $likes);
|
||||||
|
$author = PostGuard::ownPostsOnly($postType);
|
||||||
|
if ($author !== null) {
|
||||||
|
$sql .= ' AND post_author = %d';
|
||||||
|
$args[] = $author;
|
||||||
|
}
|
||||||
|
$sql .= ' ORDER BY ID ASC LIMIT %d OFFSET %d';
|
||||||
|
array_push($args, $limit, $offset);
|
||||||
|
|
||||||
|
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $sql holds only the core posts table, a constant status list and one placeholder per argument.
|
||||||
|
$ids = $db->get_col($db->prepare($sql, ...$args));
|
||||||
|
// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
|
||||||
|
|
||||||
|
return array_map('intval', is_array($ids) ? $ids : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every hit of $page, computed without changing it.
|
||||||
|
*
|
||||||
|
* @return array{count: int, samples: list<array<string, string>>,
|
||||||
|
* blocks: list<array{Block, array<string, mixed>}>, skipped_nodes: list<array{id: string, reason: string}>}
|
||||||
|
* @throws InvalidArgumentException When a place would be changed unsafely – the page fails.
|
||||||
|
*/
|
||||||
|
public function replace(DiviPage $page, TextReplacer $replacer, ValueCleaner $cleaner): array
|
||||||
|
{
|
||||||
|
$count = 0;
|
||||||
|
$samples = [];
|
||||||
|
$blocks = [];
|
||||||
|
$skipped = [];
|
||||||
|
foreach ($page->index->entries() as $entry) {
|
||||||
|
$block = $entry['block'];
|
||||||
|
if ($block->isDivi() && $block->isOpaque()) {
|
||||||
|
if ($replacer->mentions(Serializer::block($block))) {
|
||||||
|
$skipped[] = ['id' => (string) $entry['id'], 'reason' => self::opaqueReason($block)];
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! $block->isDivi()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $replacer->apply($block->attrs(), $cleaner->textReplacementFor($block->name()));
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Knoten %s: %s', $entry['id'], $e->getMessage()), 0, $e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($result['count'] > 0) {
|
||||||
|
$count += $result['count'];
|
||||||
|
$blocks[] = [$block, $result['attrs']];
|
||||||
|
foreach ($result['samples'] as $sample) {
|
||||||
|
if (count($samples) < self::SAMPLES) {
|
||||||
|
$samples[] = ['id' => (string) $entry['id']] + $sample;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['count' => $count, 'samples' => $samples, 'blocks' => $blocks, 'skipped_nodes' => $skipped];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why an opaque node is not changed (global modules are edited in the Divi library, Etappe 3). */
|
||||||
|
private static function opaqueReason(Block $block): string
|
||||||
|
{
|
||||||
|
return $block->isGlobal()
|
||||||
|
? self::GLOBAL_REASON
|
||||||
|
: 'Knoten nicht lesbar (Attribute kein JSON-Objekt) – im Divi-Builder bearbeiten';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function db(): \wpdb
|
||||||
|
{
|
||||||
|
return $this->db ?? $GLOBALS['wpdb'];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,16 +45,11 @@ class DiviWriter
|
|||||||
*/
|
*/
|
||||||
public function page(array $input): DiviPage|WP_Error
|
public function page(array $input): DiviPage|WP_Error
|
||||||
{
|
{
|
||||||
$page = $this->loader->load($input['post_id'] ?? null, true);
|
$page = $this->loader->loadWritable($input['post_id'] ?? null);
|
||||||
if ($page instanceof WP_Error) {
|
if ($page instanceof WP_Error) {
|
||||||
return $page;
|
return $page;
|
||||||
}
|
}
|
||||||
|
|
||||||
$problem = $this->loader->writable($page);
|
|
||||||
if ($problem !== null) {
|
|
||||||
return $problem;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! is_string($input['tree_rev'] ?? null) || $input['tree_rev'] !== $page->treeRev()) {
|
if (! is_string($input['tree_rev'] ?? null) || $input['tree_rev'] !== $page->treeRev()) {
|
||||||
return Errors::revMismatch(
|
return Errors::revMismatch(
|
||||||
'Die Divi-Seite wurde seit dem letzten Lesen geändert (tree_rev passt nicht); nichts wurde '
|
'Die Divi-Seite wurde seit dem letzten Lesen geändert (tree_rev passt nicht); nichts wurde '
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace LIT\MCP\Integrations\Media;
|
namespace LIT\MCP\Integrations\Media;
|
||||||
|
|
||||||
use Closure;
|
use Closure;
|
||||||
|
use LIT\MCP\Integrations\WordPress\PostStatuses;
|
||||||
use LIT\MCP\Log;
|
use LIT\MCP\Log;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,8 +60,6 @@ class MediaUsageFinder
|
|||||||
/** [gallery ids="…"] and Divi 4 gallery_ids="…" – quotes also JSON-escaped inside Divi 5 blocks. */
|
/** [gallery ids="…"] and Divi 4 gallery_ids="…" – quotes also JSON-escaped inside Divi 5 blocks. */
|
||||||
private const ID_LIST_PATTERN = '/(?:\[gallery\b[^\]]*?\bids|\bgallery_ids)=(?:"|\'|\\\\u0022)?([\d,\s]+)/i';
|
private const ID_LIST_PATTERN = '/(?:\[gallery\b[^\]]*?\bids|\bgallery_ids)=(?:"|\'|\\\\u0022)?([\d,\s]+)/i';
|
||||||
|
|
||||||
private const EXCLUDED_STATUSES = "'auto-draft', 'trash', 'inherit'";
|
|
||||||
|
|
||||||
/** Set by results() when a query of the current collect() run failed. */
|
/** Set by results() when a query of the current collect() run failed. */
|
||||||
private bool $queryFailed = false;
|
private bool $queryFailed = false;
|
||||||
|
|
||||||
@@ -190,7 +189,7 @@ class MediaUsageFinder
|
|||||||
/** @return list<object> */
|
/** @return list<object> */
|
||||||
private function featured(\wpdb $wpdb, int $attachmentId, int $limit): array
|
private function featured(\wpdb $wpdb, int $attachmentId, int $limit): array
|
||||||
{
|
{
|
||||||
$statuses = self::EXCLUDED_STATUSES;
|
$statuses = PostStatuses::NOT_CONTENT;
|
||||||
|
|
||||||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- only table names and a constant.
|
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- only table names and a constant.
|
||||||
return $this->results($wpdb, $wpdb->prepare(
|
return $this->results($wpdb, $wpdb->prepare(
|
||||||
@@ -213,7 +212,7 @@ class MediaUsageFinder
|
|||||||
}
|
}
|
||||||
|
|
||||||
$placeholders = implode(', ', array_fill(0, count($keys), '%s'));
|
$placeholders = implode(', ', array_fill(0, count($keys), '%s'));
|
||||||
$statuses = self::EXCLUDED_STATUSES;
|
$statuses = PostStatuses::NOT_CONTENT;
|
||||||
|
|
||||||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $placeholders holds one %s per key.
|
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $placeholders holds one %s per key.
|
||||||
return $this->results($wpdb, $wpdb->prepare(
|
return $this->results($wpdb, $wpdb->prepare(
|
||||||
@@ -381,7 +380,7 @@ class MediaUsageFinder
|
|||||||
}
|
}
|
||||||
|
|
||||||
$conditions = implode(' OR ', array_fill(0, count($likes), 'p.post_content LIKE %s'));
|
$conditions = implode(' OR ', array_fill(0, count($likes), 'p.post_content LIKE %s'));
|
||||||
$statuses = self::EXCLUDED_STATUSES;
|
$statuses = PostStatuses::NOT_CONTENT;
|
||||||
|
|
||||||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- only placeholders, table names, a constant.
|
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- only placeholders, table names, a constant.
|
||||||
return $this->results($wpdb, $wpdb->prepare(
|
return $this->results($wpdb, $wpdb->prepare(
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\WordPress;
|
||||||
|
|
||||||
|
/** Post statuses shared by the plugin's own SQL queries. */
|
||||||
|
final class PostStatuses
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Rows that are no content of the site (auto drafts, trash, revisions and attachments), as a
|
||||||
|
* SQL list for "post_status NOT IN (…)". A constant – never built from input.
|
||||||
|
*/
|
||||||
|
public const NOT_CONTENT = "'auto-draft', 'trash', 'inherit'";
|
||||||
|
}
|
||||||
@@ -83,4 +83,17 @@ final class DiviPageLoaderTest extends TestCase
|
|||||||
self::assertTrue($missed->isDiviLayout());
|
self::assertTrue($missed->isDiviLayout());
|
||||||
self::assertToolError($loader->writable($missed), 'validation_failed');
|
self::assertToolError($loader->writable($missed), 'validation_failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testLoadWritableCombinesLoadAndWritable(): void
|
||||||
|
{
|
||||||
|
$loader = new DiviPageLoader(DiviFixtures::schema());
|
||||||
|
DiviFixtures::givenPost(DiviFixtures::markup('synthetic-basic.html'));
|
||||||
|
self::assertInstanceOf(DiviPage::class, $loader->loadWritable(7));
|
||||||
|
|
||||||
|
DiviFixtures::givenPost(DiviFixtures::markup('synthetic-basic.html'), ['post_status' => 'trash']);
|
||||||
|
self::assertToolError($loader->loadWritable(7), 'validation_failed');
|
||||||
|
|
||||||
|
DiviFixtures::givenPost("<!-- wp:paragraph -->\n<p>Gutenberg</p>\n<!-- /wp:paragraph -->");
|
||||||
|
self::assertToolError($loader->loadWritable(7), 'unsupported');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user