diff --git a/src/Integrations/Divi/Design/ReferenceUsageQuery.php b/src/Integrations/Divi/Design/ReferenceUsageQuery.php new file mode 100644 index 0000000..f4c512c --- /dev/null +++ b/src/Integrations/Divi/Design/ReferenceUsageQuery.php @@ -0,0 +1,111 @@ +",…}})$ into block attributes, the + * quotes JSON-escaped in post_content (formats §1, §2). Fail closed (ruling P23): candidates contain an ID, a + * JSON-escaped hyphen after gcid/gvid or an escaped letter; a candidate matches when its raw content names an ID + * (also Divi 4 shortcodes), when the decoded attributes of any block do – the whole tree, also inside global modules, + * which NodeIndex does not descend into –, when a block's attributes cannot be read or when the parser rejects the + * markup. + */ +final class ReferenceUsageQuery implements UsageQuery +{ + /** MySQL REGEXP for "gcid"/"gvid" followed by a JSON-escaped hyphen ("\\u002d"), which LIKE does not find. */ + public const ESCAPED_HYPHEN_REGEX = 'g[cv]id\\\\u002[dD]'; + + /** @var list */ + private array $ids; + + /** + * @param list $ids gcid-/gvid- IDs. + * @throws InvalidArgumentException Without an ID. + */ + public function __construct(array $ids, private Parser $parser = new Parser()) + { + $ids = array_values(array_unique(array_filter( + $ids, + static fn (mixed $id): bool => is_string($id) && $id !== '' + ))); + if ($ids === []) { + throw new InvalidArgumentException('ReferenceUsageQuery needs at least one ID.'); + } + $this->ids = $ids; + } + + /** + * Whether a string inside $value (nested arrays included, keys not) contains one of $ids. + * + * @param list $ids + */ + public static function mentions(mixed $value, array $ids): bool + { + if (is_string($value)) { + foreach ($ids as $id) { + if (str_contains($value, $id)) { + return true; + } + } + + return false; + } + if (is_array($value)) { + foreach ($value as $inner) { + if (self::mentions($inner, $ids)) { + return true; + } + } + } + + return false; + } + + public function candidateCondition(\wpdb $db): string + { + $likes = array_map(static fn (string $id): string => '%' . $db->esc_like($id) . '%', $this->ids); + + return (string) $db->prepare( + '(' . str_repeat('post_content LIKE %s OR ', count($likes)) + . 'post_content REGEXP %s OR post_content REGEXP %s)', + ...array_merge($likes, [self::ESCAPED_HYPHEN_REGEX, self::ESCAPED_LETTER_REGEX]) + ); + } + + public function matches(string $content): bool + { + if (self::mentions($content, $this->ids)) { + return true; + } + + try { + return $this->inBlocks($this->parser->parse($content)); + } catch (MalformedMarkup) { + return true; + } + } + + private function inBlocks(Container $container): bool + { + foreach ($container->children() as $block) { + // The parser keeps attributes that are no JSON object as [] on an opaque block: unreadable, so it counts. + $unreadable = $block->isOpaque() && $block->attrs() === [] && str_contains((string) $block->rawOpen(), '{'); + if ($unreadable || self::mentions($block->attrs(), $this->ids) || $this->inBlocks($block)) { + return true; + } + } + + return false; + } +} diff --git a/tests/Unit/Integrations/Divi/Design/ReferenceUsageQueryTest.php b/tests/Unit/Integrations/Divi/Design/ReferenceUsageQueryTest.php new file mode 100644 index 0000000..b40a3f3 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/ReferenceUsageQueryTest.php @@ -0,0 +1,82 @@ +allows('esc_like')->andReturnUsing(static fn (string $text): string => $text); + $db->expects('prepare') + ->with( + '(post_content LIKE %s OR post_content LIKE %s OR post_content REGEXP %s OR post_content REGEXP %s)', + '%gcid-holz000001%', + '%gvid-radius0001%', + 'g[cv]id\\\\u002[dD]', + '\\\\u00[4-7][0-9a-fA-F]' + ) + ->andReturn('(prepared)'); + + $query = new ReferenceUsageQuery(['gcid-holz000001', 'gvid-radius0001', 'gcid-holz000001']); + + self::assertSame('(prepared)', $query->candidateCondition($db)); + } + + public function testNeedsAnId(): void + { + $this->expectException(InvalidArgumentException::class); + + new ReferenceUsageQuery(['']); + } + + public function testFindsAReferenceInTheAttributesOfAnyBlock(): void + { + $query = new ReferenceUsageQuery(['gcid-holz000001', 'gvid-radius0001']); + + self::assertTrue($query->matches('')); + self::assertTrue($query->matches('')); + self::assertFalse($query->matches('')); + self::assertFalse($query->matches('')); + self::assertFalse($query->matches('

Keine Verweise

')); + } + + public function testFindsAnIdWrittenWithJsonEscapesAlsoInsideGlobalModules(): void + { + $query = new ReferenceUsageQuery(['gcid-holz000001']); + + self::assertTrue($query->matches('')); + self::assertTrue($query->matches('' + . '')); + self::assertFalse($query->matches('')); + } + + public function testUnreadableMarkupAndDivi4ContentCount(): void + { + $query = new ReferenceUsageQuery(['gcid-holz000001']); + + self::assertTrue($query->matches(''), 'never closed'); + self::assertTrue($query->matches(''), 'attrs no JSON'); + self::assertTrue($query->matches('[et_pb_text background_color="gcid-holz000001"]x[/et_pb_text]'), 'Divi 4'); + } + + public function testMentionsLooksIntoNestedStrings(): void + { + self::assertTrue(ReferenceUsageQuery::mentions(['a' => ['b' => 'x gvid-radius0001 y']], ['gvid-radius0001'])); + self::assertFalse(ReferenceUsageQuery::mentions(['gvid-radius0001' => 1], ['gvid-radius0001']), 'keys'); + self::assertFalse(ReferenceUsageQuery::mentions(12, ['gvid-radius0001'])); + } +}