refactor: ContentMediaReferences holds the pure media matcher (M8)
The block/ID/file-URL matching moves out of MediaUsageFinder into a static class; the finder keeps queries, limits and result building. Behaviour unchanged; direct tests for the matcher added. 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:
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Media;
|
||||
|
||||
/**
|
||||
* The pure part of MediaUsageFinder: whether a piece of stored content (post content or a
|
||||
* block widget) references an attachment – by ID (wp-image-<id>, [gallery ids], Divi 4
|
||||
* gallery_ids, core media blocks, Divi 5 "id"/"galleryIds") or only by its file URL. A regex
|
||||
* that fails (PCRE limit) is undecided and counts as a possible usage. No database access.
|
||||
*/
|
||||
final class ContentMediaReferences
|
||||
{
|
||||
private const CORE_MEDIA_BLOCKS = ['image', 'gallery', 'cover', 'media-text', 'file', 'video', 'audio'];
|
||||
|
||||
/** Block delimiter pattern of WordPress' block parser (WP_Block_Parser::next_token()). */
|
||||
private const BLOCK_PATTERN = '/<!--\s+wp:(?P<namespace>[a-z][a-z0-9_-]*\/)?(?P<name>[a-z][a-z0-9_-]*)\s+'
|
||||
. '(?P<attrs>{(?:(?:[^}]+|}+(?=})|(?!}\s+\/?-->).)*+)?}\s+)?\/?-->/s';
|
||||
|
||||
/** [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';
|
||||
|
||||
/** Content a regex could not check (PCRE limit) counts as a possible usage. */
|
||||
public static function certainty(string $content, int $attachmentId, ?string $fileRegex): ?string
|
||||
{
|
||||
$references = self::referencesId($content, $attachmentId);
|
||||
if ($references !== false) {
|
||||
return $references === true ? MediaUsageFinder::CERTAIN : MediaUsageFinder::POSSIBLE;
|
||||
}
|
||||
|
||||
return self::matches($fileRegex, $content) === false ? null : MediaUsageFinder::POSSIBLE;
|
||||
}
|
||||
|
||||
/** @return bool|null Null when a regex failed (e.g. backtrack limit) – undecided. */
|
||||
private static function referencesId(string $content, int $attachmentId): ?bool
|
||||
{
|
||||
$wpImage = self::matches('/\bwp-image-' . $attachmentId . '\b/', $content);
|
||||
if ($wpImage !== false) {
|
||||
return $wpImage;
|
||||
}
|
||||
|
||||
$listCount = preg_match_all(self::ID_LIST_PATTERN, $content, $lists);
|
||||
if ($listCount === false) {
|
||||
return null;
|
||||
}
|
||||
if ($listCount > 0 && self::inList($lists[1], $attachmentId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (preg_match_all(self::BLOCK_PATTERN, $content, $blocks, PREG_SET_ORDER) === false) {
|
||||
return null;
|
||||
}
|
||||
foreach ($blocks as $block) {
|
||||
$attrs = json_decode(trim((string) ($block['attrs'] ?? '')), true);
|
||||
if (! is_array($attrs)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$namespace = rtrim((string) $block['namespace'], '/');
|
||||
$isCoreMedia = in_array($namespace, ['', 'core'], true)
|
||||
&& in_array($block['name'], self::CORE_MEDIA_BLOCKS, true);
|
||||
if (
|
||||
($isCoreMedia && self::coreBlockReferences($attrs, $attachmentId))
|
||||
|| ($namespace === 'divi' && self::diviAttrsReference($attrs, $attachmentId))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* preg_match() as ?bool: null when the regex failed (backtrack/recursion limit), false
|
||||
* without a pattern.
|
||||
*/
|
||||
public static function matches(?string $pattern, string $subject): ?bool
|
||||
{
|
||||
if ($pattern === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$result = preg_match($pattern, $subject);
|
||||
|
||||
return $result === false ? null : $result === 1;
|
||||
}
|
||||
|
||||
/** @param array<mixed> $attrs */
|
||||
private static function coreBlockReferences(array $attrs, int $attachmentId): bool
|
||||
{
|
||||
foreach (['id', 'mediaId'] as $key) {
|
||||
if (isset($attrs[$key]) && is_numeric($attrs[$key]) && (int) $attrs[$key] === $attachmentId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
$ids = is_array($attrs['ids'] ?? null) ? array_filter($attrs['ids'], 'is_numeric') : [];
|
||||
|
||||
return in_array($attachmentId, array_map('intval', $ids), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Divi 5 image modules store {"src": url, "id": "<ID>"}, galleries "galleryIds" as "1,2,3"
|
||||
* or as an array of IDs.
|
||||
*
|
||||
* @param array<mixed> $attrs
|
||||
*/
|
||||
private static function diviAttrsReference(array $attrs, int $attachmentId): bool
|
||||
{
|
||||
foreach ($attrs as $key => $value) {
|
||||
if ($key === 'id' && (is_int($value) || is_string($value)) && (string) $value === (string) $attachmentId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($key === 'galleryIds' && self::inList(self::stringLeaves($value), $attachmentId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (is_array($value) && self::diviAttrsReference($value, $attachmentId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @return list<string> String and integer leaves, integers as strings. */
|
||||
public static function stringLeaves(mixed $value): array
|
||||
{
|
||||
if (is_string($value) || is_int($value)) {
|
||||
return [(string) $value];
|
||||
}
|
||||
|
||||
$leaves = [];
|
||||
if (is_array($value)) {
|
||||
array_walk_recursive($value, static function ($leaf) use (&$leaves): void {
|
||||
if (is_string($leaf) || is_int($leaf)) {
|
||||
$leaves[] = (string) $leaf;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return $leaves;
|
||||
}
|
||||
|
||||
/** @param list<string> $lists Comma-separated ID lists. */
|
||||
public static function inList(array $lists, int $attachmentId): bool
|
||||
{
|
||||
foreach ($lists as $list) {
|
||||
foreach (explode(',', $list) as $item) {
|
||||
$item = trim($item);
|
||||
if ($item !== '' && ctype_digit($item) && (int) $item === $attachmentId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Original or a generated size of the file, with plain or JSON-escaped slashes. */
|
||||
public static function fileRegex(string $stem): string
|
||||
{
|
||||
$separator = '(?:/|\\\\/)';
|
||||
$parts = array_map(static fn (string $part): string => preg_quote($part, '~'), explode('/', $stem));
|
||||
|
||||
return '~' . $separator . implode($separator, $parts)
|
||||
. '(?:-\d+x\d+|-scaled|-rotated|-e\d{10,})*\.[a-z0-9]{2,5}(?![a-z0-9])~i';
|
||||
}
|
||||
}
|
||||
@@ -48,18 +48,9 @@ class MediaUsageFinder
|
||||
/** Entries whose content is loaded per query (keeps memory low with large Divi pages). */
|
||||
private const CONTENT_BATCH = 10;
|
||||
|
||||
private const CORE_MEDIA_BLOCKS = ['image', 'gallery', 'cover', 'media-text', 'file', 'video', 'audio'];
|
||||
|
||||
/** Media widgets that store one attachment ID. */
|
||||
private const SINGLE_MEDIA_WIDGETS = ['media_image', 'media_video', 'media_audio'];
|
||||
|
||||
/** Block delimiter pattern of WordPress' block parser (WP_Block_Parser::next_token()). */
|
||||
private const BLOCK_PATTERN = '/<!--\s+wp:(?P<namespace>[a-z][a-z0-9_-]*\/)?(?P<name>[a-z][a-z0-9_-]*)\s+'
|
||||
. '(?P<attrs>{(?:(?:[^}]+|}+(?=})|(?!}\s+\/?-->).)*+)?}\s+)?\/?-->/s';
|
||||
|
||||
/** [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';
|
||||
|
||||
/** Set by results() when a query of the current collect() run failed. */
|
||||
private bool $queryFailed = false;
|
||||
|
||||
@@ -109,7 +100,7 @@ class MediaUsageFinder
|
||||
{
|
||||
$wpdb = $this->db ?? $GLOBALS['wpdb'];
|
||||
$stem = $this->fileStem($attachmentId);
|
||||
$fileRegex = $stem === '' ? null : self::fileRegex($stem);
|
||||
$fileRegex = $stem === '' ? null : ContentMediaReferences::fileRegex($stem);
|
||||
|
||||
$found = [];
|
||||
$complete = true;
|
||||
@@ -252,7 +243,9 @@ class MediaUsageFinder
|
||||
}
|
||||
|
||||
$background = get_theme_mod('background_image');
|
||||
if (is_string($background) && $background !== '' && self::matches($fileRegex, $background) !== false) {
|
||||
$backgroundMatches = is_string($background) && $background !== ''
|
||||
&& ContentMediaReferences::matches($fileRegex, $background) !== false;
|
||||
if ($backgroundMatches) {
|
||||
$found['site:background_image'] = self::siteUsage(
|
||||
'Hintergrundbild',
|
||||
'theme_mod',
|
||||
@@ -263,7 +256,7 @@ class MediaUsageFinder
|
||||
|
||||
$divi = get_option('et_divi');
|
||||
$diviLogo = is_array($divi) && is_string($divi['divi_logo'] ?? null) ? $divi['divi_logo'] : '';
|
||||
if ($diviLogo !== '' && self::matches($fileRegex, $diviLogo) !== false) {
|
||||
if ($diviLogo !== '' && ContentMediaReferences::matches($fileRegex, $diviLogo) !== false) {
|
||||
$found['site:divi_logo'] = self::siteUsage('Divi-Logo', 'option', 'divi_logo', self::POSSIBLE);
|
||||
}
|
||||
|
||||
@@ -285,14 +278,17 @@ class MediaUsageFinder
|
||||
}
|
||||
|
||||
foreach (self::widgetInstances('media_gallery') as $number => $instance) {
|
||||
if (self::inList(self::stringLeaves($instance['ids'] ?? []), $attachmentId)) {
|
||||
$ids = ContentMediaReferences::stringLeaves($instance['ids'] ?? []);
|
||||
if (ContentMediaReferences::inList($ids, $attachmentId)) {
|
||||
$widgets['media_gallery-' . $number] = self::CERTAIN;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (self::widgetInstances('block') as $number => $instance) {
|
||||
$content = is_string($instance['content'] ?? null) ? $instance['content'] : '';
|
||||
$certainty = $content === '' ? null : self::contentCertainty($content, $attachmentId, $fileRegex);
|
||||
$certainty = $content === ''
|
||||
? null
|
||||
: ContentMediaReferences::certainty($content, $attachmentId, $fileRegex);
|
||||
if ($certainty !== null) {
|
||||
$widgets['block-' . $number] = $certainty;
|
||||
}
|
||||
@@ -332,7 +328,8 @@ class MediaUsageFinder
|
||||
foreach (array_chunk($candidates, self::CONTENT_BATCH) as $chunk) {
|
||||
$contents = $this->contents($wpdb, array_map(static fn (object $row): int => (int) $row->ID, $chunk));
|
||||
foreach ($chunk as $row) {
|
||||
$certainty = self::contentCertainty($contents[(int) $row->ID] ?? '', $attachmentId, $fileRegex);
|
||||
$content = $contents[(int) $row->ID] ?? '';
|
||||
$certainty = ContentMediaReferences::certainty($content, $attachmentId, $fileRegex);
|
||||
if ($certainty !== null) {
|
||||
$this->add($found, $row, 'content', $certainty);
|
||||
}
|
||||
@@ -416,144 +413,6 @@ class MediaUsageFinder
|
||||
return $contents;
|
||||
}
|
||||
|
||||
/** Content a regex could not check (PCRE limit) counts as a possible usage. */
|
||||
private static function contentCertainty(string $content, int $attachmentId, ?string $fileRegex): ?string
|
||||
{
|
||||
$references = self::referencesId($content, $attachmentId);
|
||||
if ($references !== false) {
|
||||
return $references === true ? self::CERTAIN : self::POSSIBLE;
|
||||
}
|
||||
|
||||
return self::matches($fileRegex, $content) === false ? null : self::POSSIBLE;
|
||||
}
|
||||
|
||||
/** @return bool|null Null when a regex failed (e.g. backtrack limit) – undecided. */
|
||||
private static function referencesId(string $content, int $attachmentId): ?bool
|
||||
{
|
||||
$wpImage = self::matches('/\bwp-image-' . $attachmentId . '\b/', $content);
|
||||
if ($wpImage !== false) {
|
||||
return $wpImage;
|
||||
}
|
||||
|
||||
$listCount = preg_match_all(self::ID_LIST_PATTERN, $content, $lists);
|
||||
if ($listCount === false) {
|
||||
return null;
|
||||
}
|
||||
if ($listCount > 0 && self::inList($lists[1], $attachmentId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (preg_match_all(self::BLOCK_PATTERN, $content, $blocks, PREG_SET_ORDER) === false) {
|
||||
return null;
|
||||
}
|
||||
foreach ($blocks as $block) {
|
||||
$attrs = json_decode(trim((string) ($block['attrs'] ?? '')), true);
|
||||
if (! is_array($attrs)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$namespace = rtrim((string) $block['namespace'], '/');
|
||||
$isCoreMedia = in_array($namespace, ['', 'core'], true)
|
||||
&& in_array($block['name'], self::CORE_MEDIA_BLOCKS, true);
|
||||
if (
|
||||
($isCoreMedia && self::coreBlockReferences($attrs, $attachmentId))
|
||||
|| ($namespace === 'divi' && self::diviAttrsReference($attrs, $attachmentId))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* preg_match() as ?bool: null when the regex failed (backtrack/recursion limit), false
|
||||
* without a pattern.
|
||||
*/
|
||||
private static function matches(?string $pattern, string $subject): ?bool
|
||||
{
|
||||
if ($pattern === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$result = preg_match($pattern, $subject);
|
||||
|
||||
return $result === false ? null : $result === 1;
|
||||
}
|
||||
|
||||
/** @param array<mixed> $attrs */
|
||||
private static function coreBlockReferences(array $attrs, int $attachmentId): bool
|
||||
{
|
||||
foreach (['id', 'mediaId'] as $key) {
|
||||
if (isset($attrs[$key]) && is_numeric($attrs[$key]) && (int) $attrs[$key] === $attachmentId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
$ids = is_array($attrs['ids'] ?? null) ? array_filter($attrs['ids'], 'is_numeric') : [];
|
||||
|
||||
return in_array($attachmentId, array_map('intval', $ids), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Divi 5 image modules store {"src": url, "id": "<ID>"}, galleries "galleryIds" as "1,2,3"
|
||||
* or as an array of IDs.
|
||||
*
|
||||
* @param array<mixed> $attrs
|
||||
*/
|
||||
private static function diviAttrsReference(array $attrs, int $attachmentId): bool
|
||||
{
|
||||
foreach ($attrs as $key => $value) {
|
||||
if ($key === 'id' && (is_int($value) || is_string($value)) && (string) $value === (string) $attachmentId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($key === 'galleryIds' && self::inList(self::stringLeaves($value), $attachmentId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (is_array($value) && self::diviAttrsReference($value, $attachmentId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @return list<string> String and integer leaves, integers as strings. */
|
||||
private static function stringLeaves(mixed $value): array
|
||||
{
|
||||
if (is_string($value) || is_int($value)) {
|
||||
return [(string) $value];
|
||||
}
|
||||
|
||||
$leaves = [];
|
||||
if (is_array($value)) {
|
||||
array_walk_recursive($value, static function ($leaf) use (&$leaves): void {
|
||||
if (is_string($leaf) || is_int($leaf)) {
|
||||
$leaves[] = (string) $leaf;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return $leaves;
|
||||
}
|
||||
|
||||
/** @param list<string> $lists Comma-separated ID lists. */
|
||||
private static function inList(array $lists, int $attachmentId): bool
|
||||
{
|
||||
foreach ($lists as $list) {
|
||||
foreach (explode(',', $list) as $item) {
|
||||
$item = trim($item);
|
||||
if ($item !== '' && ctype_digit($item) && (int) $item === $attachmentId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload-relative path without extension and without WordPress' suffixes for big,
|
||||
* rotated or edited images: 2026/09/foto-scaled.jpg → 2026/09/foto.
|
||||
@@ -566,16 +425,6 @@ class MediaUsageFinder
|
||||
return (string) preg_replace('/(?:-scaled|-rotated|-e\d{10,})+$/', '', $stem);
|
||||
}
|
||||
|
||||
/** Original or a generated size of the file, with plain or JSON-escaped slashes. */
|
||||
private static function fileRegex(string $stem): string
|
||||
{
|
||||
$separator = '(?:/|\\\\/)';
|
||||
$parts = array_map(static fn (string $part): string => preg_quote($part, '~'), explode('/', $stem));
|
||||
|
||||
return '~' . $separator . implode($separator, $parts)
|
||||
. '(?:-\d+x\d+|-scaled|-rotated|-e\d{10,})*\.[a-z0-9]{2,5}(?![a-z0-9])~i';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, mixed>> $found
|
||||
* @param list<object> $rows
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Media;
|
||||
|
||||
use LIT\MCP\Integrations\Media\ContentMediaReferences;
|
||||
use LIT\MCP\Integrations\Media\MediaUsageFinder;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ContentMediaReferencesTest extends TestCase
|
||||
{
|
||||
/** @return array<string, array{string, ?string}> */
|
||||
public static function contents(): array
|
||||
{
|
||||
$certain = MediaUsageFinder::CERTAIN;
|
||||
$possible = MediaUsageFinder::POSSIBLE;
|
||||
|
||||
return [
|
||||
'wp-image class' => ['<img class="wp-image-33" src="x">', $certain],
|
||||
'other wp-image id' => ['<img class="wp-image-334">', null],
|
||||
'gallery shortcode' => ['[gallery ids="1, 33,5"]', $certain],
|
||||
'divi 4 gallery_ids' => ['[et_pb_gallery gallery_ids="12,33"]', $certain],
|
||||
'core image block' => ['<!-- wp:image {"id":33} /-->', $certain],
|
||||
'core gallery ids' => ['<!-- wp:gallery {"ids":[4,33]} /-->', $certain],
|
||||
'divi 5 image id' => ['<!-- wp:divi/image {"image":{"innerContent":{"desktop":{"value":{"id":"33"}}}}} /-->', $certain],
|
||||
'divi 5 galleryIds' => ['<!-- wp:divi/gallery {"image":{"advanced":{"galleryIds":{"desktop":{"value":"7,33"}}}}} /-->', $certain],
|
||||
'id outside a media block' => ['<!-- wp:paragraph {"id":33} /-->', null],
|
||||
'file url only' => ['<img src="/wp-content/uploads/2026/09/foto-300x200.jpg">', $possible],
|
||||
'json-escaped file url' => ['{"src":"\/uploads\/2026\/09\/foto.jpg"}', $possible],
|
||||
'other file' => ['<img src="/uploads/2026/09/fotos.jpg">', null],
|
||||
];
|
||||
}
|
||||
|
||||
/** @dataProvider contents */
|
||||
public function testCertaintyOfStoredContent(string $content, ?string $expected): void
|
||||
{
|
||||
$fileRegex = ContentMediaReferences::fileRegex('2026/09/foto');
|
||||
|
||||
self::assertSame($expected, ContentMediaReferences::certainty($content, 33, $fileRegex));
|
||||
}
|
||||
|
||||
public function testListsAndLeaves(): void
|
||||
{
|
||||
self::assertTrue(ContentMediaReferences::inList(['1, 2', ' 33 '], 33));
|
||||
self::assertFalse(ContentMediaReferences::inList(['1,333', 'x33'], 33));
|
||||
self::assertSame(['1', '33', '7'], ContentMediaReferences::stringLeaves(['1', [33, ['7', null, 2.5]]]));
|
||||
self::assertFalse(ContentMediaReferences::matches(null, 'anything'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user