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>
155 lines
6.4 KiB
PHP
155 lines
6.4 KiB
PHP
<?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'];
|
||
}
|
||
}
|