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:
2026-09-26 18:28:52 +02:00
co-authored by Claude Opus 5.5
parent 7963aa19bd
commit 4cb18d17c3
3 changed files with 233 additions and 163 deletions
@@ -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';
}
}
+12 -163
View File
@@ -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