From 4cb18d17c312b527a8e07c3e5fee5d11f9b1243d Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 18:28:52 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- .../Media/ContentMediaReferences.php | 171 +++++++++++++++++ src/Integrations/Media/MediaUsageFinder.php | 175 ++---------------- .../Media/ContentMediaReferencesTest.php | 50 +++++ 3 files changed, 233 insertions(+), 163 deletions(-) create mode 100644 src/Integrations/Media/ContentMediaReferences.php create mode 100644 tests/Unit/Integrations/Media/ContentMediaReferencesTest.php diff --git a/src/Integrations/Media/ContentMediaReferences.php b/src/Integrations/Media/ContentMediaReferences.php new file mode 100644 index 0000000..f6556d5 --- /dev/null +++ b/src/Integrations/Media/ContentMediaReferences.php @@ -0,0 +1,171 @@ +, [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+)?\/?-->/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 $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": ""}, galleries "galleryIds" as "1,2,3" + * or as an array of IDs. + * + * @param array $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 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 $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'; + } +} diff --git a/src/Integrations/Media/MediaUsageFinder.php b/src/Integrations/Media/MediaUsageFinder.php index f368c0a..8f3cfaf 100644 --- a/src/Integrations/Media/MediaUsageFinder.php +++ b/src/Integrations/Media/MediaUsageFinder.php @@ -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+)?\/?-->/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 $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": ""}, galleries "galleryIds" as "1,2,3" - * or as an array of IDs. - * - * @param array $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 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 $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> $found * @param list $rows diff --git a/tests/Unit/Integrations/Media/ContentMediaReferencesTest.php b/tests/Unit/Integrations/Media/ContentMediaReferencesTest.php new file mode 100644 index 0000000..351e638 --- /dev/null +++ b/tests/Unit/Integrations/Media/ContentMediaReferencesTest.php @@ -0,0 +1,50 @@ + */ + public static function contents(): array + { + $certain = MediaUsageFinder::CERTAIN; + $possible = MediaUsageFinder::POSSIBLE; + + return [ + 'wp-image class' => ['', $certain], + 'other wp-image id' => ['', null], + 'gallery shortcode' => ['[gallery ids="1, 33,5"]', $certain], + 'divi 4 gallery_ids' => ['[et_pb_gallery gallery_ids="12,33"]', $certain], + 'core image block' => ['', $certain], + 'core gallery ids' => ['', $certain], + 'divi 5 image id' => ['', $certain], + 'divi 5 galleryIds' => ['', $certain], + 'id outside a media block' => ['', null], + 'file url only' => ['', $possible], + 'json-escaped file url' => ['{"src":"\/uploads\/2026\/09\/foto.jpg"}', $possible], + 'other file' => ['', 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')); + } +}