Files
WP-LIT-MCP/src/Integrations/Divi/Pages/DiviTextSearch.php
T
janniclas.loosenandClaude Opus 5.5 82ef69a99c fix: link targets never reveal library entries by type (ruling Q14)
WpLinkTargets answers the library, internal "_" types and every type
without a Divi post_link_url option like a missing ID; replace-text strips
library IDs only from global subtrees.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 15:13:40 +02:00

155 lines
6.4 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\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;
defined('ABSPATH') || exit;
/**
* 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. Without $globalIds (ruling P13: no library
* access) the library IDs a global instance stores are no text to find: a global node is
* matched without globalModule/globalParent, as if they were not stored; every other opaque
* node (e.g. undecodable attributes) is matched as stored, the same with or without access.
*
* @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,
bool $globalIds = true
): array {
$count = 0;
$samples = [];
$blocks = [];
$skipped = [];
foreach ($page->index->entries() as $entry) {
$block = $entry['block'];
if ($block->isDivi() && $block->isOpaque()) {
$markup = $globalIds || ! $block->isGlobal()
? Serializer::block($block)
: self::withoutLibraryIds($block);
if ($replacer->mentions($markup)) {
$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];
}
/**
* Ruling P13: the markup of the global node $block (decodable attributes, as isGlobal() needs)
* with every globalModule/globalParent left out, at every depth – only for matching, never
* saved. Openers are rebuilt from the decoded attributes, so Divi's raw spelling is not kept.
*/
private static function withoutLibraryIds(Block $block): string
{
$attrs = $block->attrs();
unset($attrs['globalModule'], $attrs['globalParent']);
$markup = '<!-- wp:' . $block->name() . ' ' . Serializer::attributes($attrs) . ' -->';
foreach ($block->isVoid() ? [] : $block->parts() as $part) {
$markup .= $part instanceof Block ? self::withoutLibraryIds($part) : $part;
}
return $markup . '<!-- /wp:' . $block->name() . ' -->';
}
/** 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'];
}
}