From b197d8171f352a3d76c92db224e527a06af86074 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 02:02:21 +0200 Subject: [PATCH] fix: media usage search never reports a capped scan as unused Adds widgets and header/background theme mods, integer galleryIds, wider pre-filter, regex failures as possible usages, newest-first candidates, and usages_complete/usages_truncated in media-get. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- src/Integrations/Media/MediaGetAbility.php | 16 +- src/Integrations/Media/MediaUsageFinder.php | 275 ++++++++++++++---- .../Media/MediaGetAbilityTest.php | 12 +- .../Media/MediaUsageFinderTest.php | 182 +++++++++--- 4 files changed, 382 insertions(+), 103 deletions(-) diff --git a/src/Integrations/Media/MediaGetAbility.php b/src/Integrations/Media/MediaGetAbility.php index 2bf3c12..2bbd10a 100644 --- a/src/Integrations/Media/MediaGetAbility.php +++ b/src/Integrations/Media/MediaGetAbility.php @@ -29,6 +29,8 @@ final class MediaGetAbility implements Ability . 'Bildunterschrift, Beschreibung und die Verwendungsorte (Beitragsbild, Inhalte inkl. Divi, ' . 'Bildfelder, Website-Icon, Logo). certainty "sicher" = per ID eingebunden, "möglicherweise" = nur ' . 'die Datei-URL gefunden. Einträge ohne Leserecht erscheinen als "(nicht lesbar)". ' + . 'usages_truncated: true = es gibt mehr Verwendungen als gelistet; usages_complete: false = die ' + . 'Inhaltssuche wurde begrenzt, weitere Verwendungen sind möglich. ' . 'Vorschaubild über lit-media-preview.'; } @@ -44,7 +46,12 @@ final class MediaGetAbility implements Ability public function outputSchema(): ?array { - return MediaPresenter::fullSchema(); + $schema = MediaPresenter::fullSchema(); + $schema['properties']['usages_complete'] = ['type' => 'boolean']; + $schema['properties']['usages_truncated'] = ['type' => 'boolean']; + $schema['required'] = array_merge($schema['required'], ['usages_complete', 'usages_truncated']); + + return $schema; } public function annotations(): array @@ -71,6 +78,11 @@ final class MediaGetAbility implements Ability return $attachment; } - return $this->presenter->full($attachment, $this->finder->usages((int) $attachment->ID)); + $scan = $this->finder->scan((int) $attachment->ID); + + return array_merge($this->presenter->full($attachment, $scan['usages']), [ + 'usages_complete' => $scan['complete'], + 'usages_truncated' => $scan['truncated'], + ]); } } diff --git a/src/Integrations/Media/MediaUsageFinder.php b/src/Integrations/Media/MediaUsageFinder.php index ccf3ff4..1042351 100644 --- a/src/Integrations/Media/MediaUsageFinder.php +++ b/src/Integrations/Media/MediaUsageFinder.php @@ -11,14 +11,21 @@ use Closure; * a false positive only asks for force: true, a false negative would break a page. * * - certain ("sicher"): the attachment ID is referenced – featured image, file/image fields - * (ContentSchema::mediaFieldKeys()), site icon, custom logo, and in content: wp-image- - * classes, [gallery ids="…"], Divi 4 gallery_ids="…", core media blocks ("id", "mediaId", - * "ids") and Divi 5 block attributes ("id", "galleryIds"). + * (ContentSchema::mediaFieldKeys()), site icon, custom logo, header image, media widgets + * (image/video/audio attachment_id, gallery ids), and in content and block widgets: + * wp-image- classes, [gallery ids="…"], Divi 4 gallery_ids="…", core media blocks + * ("id", "mediaId", "ids") and Divi 5 block attributes ("id", "galleryIds"). * - possible ("möglicherweise"): only the file URL (original or a generated size) appears – - * in content, Divi 4/5 image URLs or the Divi logo; it may belong to another copy of the file. + * in content, block widgets, Divi 4/5 image URLs, the Divi logo or the background image; it + * may belong to another copy of the file. Also possible: content that could not be checked + * (regex limit) and an incomplete content search (candidate cap reached). + * + * NOT covered: term meta, user meta, Pods table storage (pods_* tables, podsrel), ACF fields + * (unless registered as schema media fields and stored as a plain ID per meta row), serialized + * or comma-separated ID lists in meta, and options of other plugins. * * All queries are prepared and bounded; content is loaded in small batches and only until - * $max usages are found. Titles of entries the current user cannot read are hidden. + * enough usages are found. Titles of entries the current user cannot read are hidden. * Not final so abilities can be tested with a mock. */ class MediaUsageFinder @@ -26,7 +33,11 @@ class MediaUsageFinder public const CERTAIN = 'sicher'; public const POSSIBLE = 'möglicherweise'; public const UNREADABLE_TITLE = '(nicht lesbar)'; - public const WHERE = ['featured', 'field', 'content', 'site_icon', 'custom_logo', 'divi_logo']; + public const INCOMPLETE_TITLE = 'Inhaltssuche unvollständig – weitere Verwendungen möglich'; + public const WHERE = [ + 'featured', 'field', 'content', 'site_icon', 'custom_logo', 'divi_logo', 'header_image', + 'background_image', 'widget', + ]; /** Max. entries whose content is inspected per call. */ private const CANDIDATE_LIMIT = 200; @@ -36,6 +47,9 @@ class MediaUsageFinder 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'; @@ -54,34 +68,63 @@ class MediaUsageFinder /** * @return list - * post_id 0 for site-wide usages (site icon, logos). + * post_id 0 for site-wide usages (site icon, logos, widgets, incomplete search). */ public function usages(int $attachmentId, int $max = 20): array { - $wpdb = $this->db ?? $GLOBALS['wpdb']; + return $this->scan($attachmentId, $max)['usages']; + } + + /** + * usages() plus whether the content search covered every candidate (complete) and whether + * more than $max usages exist (truncated). + * + * @return array{usages: list, complete: bool, truncated: bool} + */ + public function scan(int $attachmentId, int $max = 20): array + { $max = max(1, $max); - $stem = $this->fileStem($attachmentId); - $fileRegex = $stem === '' ? null : self::fileRegex($stem); - $found = []; - $this->addRows($found, $this->featured($wpdb, $attachmentId, $max), 'featured'); - if (count($found) < $max) { - $this->addRows($found, $this->fields($wpdb, $attachmentId, $max), 'field'); - } - if (count($found) < $max) { - $this->addSiteUsages($found, $attachmentId, $fileRegex); - } - if (count($found) < $max) { - $this->addContentUsages($found, $wpdb, $attachmentId, $stem, $fileRegex, $max); - } - - return array_slice(array_values($found), 0, $max); + // One more than requested, to tell whether the list is truncated. + return $this->collect($attachmentId, $max, $max + 1); } /** Certain and possible usages both count – a possible one also blocks a delete without force. */ public function isUsed(int $attachmentId): bool { - return $this->usages($attachmentId, 1) !== []; + return $this->collect($attachmentId, 1, 1)['usages'] !== []; + } + + /** + * @param int $limit Stop searching once this many usages are found (>= $max). + * @return array{usages: list, complete: bool, truncated: bool} + */ + private function collect(int $attachmentId, int $max, int $limit): array + { + $wpdb = $this->db ?? $GLOBALS['wpdb']; + $stem = $this->fileStem($attachmentId); + $fileRegex = $stem === '' ? null : self::fileRegex($stem); + + $found = []; + $complete = true; + $this->addRows($found, $this->featured($wpdb, $attachmentId, $limit), 'featured'); + if (count($found) < $limit) { + $this->addRows($found, $this->fields($wpdb, $attachmentId, $limit), 'field'); + } + if (count($found) < $limit) { + $this->addSiteUsages($found, $attachmentId, $fileRegex); + } + if (count($found) < $limit) { + $complete = $this->addContentUsages($found, $wpdb, $attachmentId, $stem, $fileRegex, $limit); + } + + return [ + 'usages' => array_slice(array_values($found), 0, $max), + 'complete' => $complete, + 'truncated' => count($found) > $max, + ]; } /** @return array JSON Schema of one entry of usages(). */ @@ -101,7 +144,7 @@ class MediaUsageFinder } /** @return list */ - private function featured(\wpdb $wpdb, int $attachmentId, int $max): array + private function featured(\wpdb $wpdb, int $attachmentId, int $limit): array { $statuses = self::EXCLUDED_STATUSES; @@ -110,15 +153,15 @@ class MediaUsageFinder "SELECT p.ID, p.post_title, p.post_type FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE pm.meta_key = '_thumbnail_id' AND pm.meta_value = %s - AND p.post_status NOT IN ({$statuses}) ORDER BY p.ID LIMIT %d", + AND p.post_status NOT IN ({$statuses}) ORDER BY p.ID DESC LIMIT %d", (string) $attachmentId, - $max + $limit )); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared } /** @return list */ - private function fields(\wpdb $wpdb, int $attachmentId, int $max): array + private function fields(\wpdb $wpdb, int $attachmentId, int $limit): array { $keys = array_values(array_map('strval', ($this->mediaMetaKeys)())); if ($keys === []) { @@ -133,13 +176,15 @@ class MediaUsageFinder "SELECT DISTINCT p.ID, p.post_title, p.post_type FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE pm.meta_key IN ({$placeholders}) AND pm.meta_value = %s - AND p.post_status NOT IN ({$statuses}) ORDER BY p.ID LIMIT %d", - ...array_merge($keys, [(string) $attachmentId, $max]) + AND p.post_status NOT IN ({$statuses}) ORDER BY p.ID DESC LIMIT %d", + ...array_merge($keys, [(string) $attachmentId, $limit]) )); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber } /** + * Options and theme mods: site icon, logos, header/background image, widgets. + * * @param array> $found */ private function addSiteUsages(array &$found, int $attachmentId, ?string $fileRegex): void @@ -157,52 +202,132 @@ class MediaUsageFinder ); } + $header = get_theme_mod('header_image_data'); + $header = is_object($header) ? get_object_vars($header) : $header; + if (is_array($header) && (int) ($header['attachment_id'] ?? 0) === $attachmentId) { + $found['site:header_image'] = self::siteUsage('Header-Bild', 'theme_mod', 'header_image', self::CERTAIN); + } + + $background = get_theme_mod('background_image'); + if (is_string($background) && $background !== '' && self::matches($fileRegex, $background) !== false) { + $found['site:background_image'] = self::siteUsage( + 'Hintergrundbild', + 'theme_mod', + 'background_image', + self::POSSIBLE + ); + } + $divi = get_option('et_divi'); $diviLogo = is_array($divi) && is_string($divi['divi_logo'] ?? null) ? $divi['divi_logo'] : ''; - if ($fileRegex !== null && $diviLogo !== '' && preg_match($fileRegex, $diviLogo) === 1) { + if ($diviLogo !== '' && self::matches($fileRegex, $diviLogo) !== false) { $found['site:divi_logo'] = self::siteUsage('Divi-Logo', 'option', 'divi_logo', self::POSSIBLE); } + + $this->addWidgetUsages($found, $attachmentId, $fileRegex); } /** * @param array> $found */ + private function addWidgetUsages(array &$found, int $attachmentId, ?string $fileRegex): void + { + $widgets = []; + foreach (self::SINGLE_MEDIA_WIDGETS as $base) { + foreach (self::widgetInstances($base) as $number => $instance) { + if ((int) ($instance['attachment_id'] ?? 0) === $attachmentId) { + $widgets[$base . '-' . $number] = self::CERTAIN; + } + } + } + + foreach (self::widgetInstances('media_gallery') as $number => $instance) { + if (self::inList(self::stringLeaves($instance['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); + if ($certainty !== null) { + $widgets['block-' . $number] = $certainty; + } + } + + foreach ($widgets as $widgetId => $certainty) { + $found['widget:' . $widgetId] = self::siteUsage('Widget ' . $widgetId, 'widget', 'widget', $certainty); + } + } + + /** @return array> Widget number => instance settings. */ + private static function widgetInstances(string $base): array + { + $option = get_option('widget_' . $base); + if (! is_array($option)) { + return []; + } + + unset($option['_multiwidget']); + + return array_filter($option, 'is_array'); + } + + /** + * @param array> $found + * @return bool False when the candidate cap cut the content search short. + */ private function addContentUsages( array &$found, \wpdb $wpdb, int $attachmentId, string $stem, ?string $fileRegex, - int $max - ): void { - foreach (array_chunk($this->contentCandidates($wpdb, $attachmentId, $stem), self::CONTENT_BATCH) as $chunk) { + int $limit + ): bool { + $candidates = $this->contentCandidates($wpdb, $attachmentId, $stem); + 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); if ($certainty !== null) { $this->add($found, $row, 'content', $certainty); } - if (count($found) >= $max) { - return; + if (count($found) >= $limit) { + return true; } } } + + if (count($candidates) < self::CANDIDATE_LIMIT) { + return true; + } + + // Older entries beyond the cap were not checked: never report "unused" in that case. + $found['content:incomplete'] = self::siteUsage(self::INCOMPLETE_TITLE, 'content', 'content', self::POSSIBLE); + + return false; } /** - * Coarse pre-filter in SQL (a superset of what contentCertainty() accepts); IDs and titles only. + * Coarse pre-filter in SQL (a superset of what contentCertainty() accepts); IDs and titles + * only, newest first. * * @return list */ private function contentCandidates(\wpdb $wpdb, int $attachmentId, string $stem): array { $id = (string) $attachmentId; - $likes = array_map( - static fn (string $pattern): string => '%' . $wpdb->esc_like($pattern) . '%', - ['wp-image-' . $id, '"id":' . $id, '"id":"' . $id . '"', '"mediaId":' . $id] - ); + $patterns = ['wp-image-' . $id]; + foreach (['"id":', '"mediaId":'] as $key) { + foreach (['', ' '] as $space) { + $patterns[] = $key . $space . $id; + $patterns[] = $key . $space . '"' . $id . '"'; + } + } + $likes = array_map(static fn (string $pattern): string => '%' . $wpdb->esc_like($pattern) . '%', $patterns); // ID lists: [gallery ids="…"], gallery_ids="…", "ids":[…], Divi 5 "galleryIds":{…}. - foreach (['ids=', '"ids":[', 'galleryIds":'] as $prefix) { + foreach (['ids=', '"ids":', 'galleryIds":'] as $prefix) { $likes[] = '%' . $wpdb->esc_like($prefix) . '%' . $id . '%'; } if ($stem !== '') { @@ -219,7 +344,7 @@ class MediaUsageFinder "SELECT p.ID, p.post_title, p.post_type FROM {$wpdb->posts} p WHERE p.post_type NOT IN ('revision', 'attachment', 'nav_menu_item') AND p.post_status NOT IN ({$statuses}) - AND ({$conditions}) ORDER BY p.ID LIMIT %d", + AND ({$conditions}) ORDER BY p.ID DESC LIMIT %d", ...array_merge($likes, [self::CANDIDATE_LIMIT]) )); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared @@ -248,26 +373,36 @@ 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 { - if (self::referencesId($content, $attachmentId)) { - return self::CERTAIN; + $references = self::referencesId($content, $attachmentId); + if ($references !== false) { + return $references === true ? self::CERTAIN : self::POSSIBLE; } - return $fileRegex !== null && preg_match($fileRegex, $content) === 1 ? self::POSSIBLE : null; + return self::matches($fileRegex, $content) === false ? null : self::POSSIBLE; } - private static function referencesId(string $content, int $attachmentId): bool + /** @return bool|null Null when a regex failed (e.g. backtrack limit) – undecided. */ + private static function referencesId(string $content, int $attachmentId): ?bool { - if (preg_match('/\bwp-image-' . $attachmentId . '\b/', $content) === 1) { + $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::ID_LIST_PATTERN, $content, $lists) > 0 && self::inList($lists[1], $attachmentId)) { - return true; + if (preg_match_all(self::BLOCK_PATTERN, $content, $blocks, PREG_SET_ORDER) === false) { + return null; } - - preg_match_all(self::BLOCK_PATTERN, $content, $blocks, PREG_SET_ORDER); foreach ($blocks as $block) { $attrs = json_decode(trim((string) ($block['attrs'] ?? '')), true); if (! is_array($attrs)) { @@ -288,6 +423,21 @@ class MediaUsageFinder 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 { @@ -303,7 +453,8 @@ class MediaUsageFinder } /** - * Divi 5 image modules store {"src": url, "id": ""}, galleries "galleryIds" as "1,2,3". + * Divi 5 image modules store {"src": url, "id": ""}, galleries "galleryIds" as "1,2,3" + * or as an array of IDs. * * @param array $attrs */ @@ -326,18 +477,18 @@ class MediaUsageFinder return false; } - /** @return list */ + /** @return list String and integer leaves, integers as strings. */ private static function stringLeaves(mixed $value): array { - if (is_string($value)) { - return [$value]; + 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)) { - $leaves[] = $leaf; + if (is_string($leaf) || is_int($leaf)) { + $leaves[] = (string) $leaf; } }); } @@ -407,10 +558,14 @@ class MediaUsageFinder return; } + $postType = (string) $row->post_type; + // read_post on an unregistered post type triggers _doing_it_wrong – treat as unreadable. + $readable = post_type_exists($postType) && current_user_can('read_post', $postId); + $found[$key] = [ 'post_id' => $postId, - 'title' => current_user_can('read_post', $postId) ? (string) $row->post_title : self::UNREADABLE_TITLE, - 'post_type' => (string) $row->post_type, + 'title' => $readable ? (string) $row->post_title : self::UNREADABLE_TITLE, + 'post_type' => $postType, 'where' => $where, 'certainty' => $certainty, ]; diff --git a/tests/Unit/Integrations/Media/MediaGetAbilityTest.php b/tests/Unit/Integrations/Media/MediaGetAbilityTest.php index 0710046..91cfa1b 100644 --- a/tests/Unit/Integrations/Media/MediaGetAbilityTest.php +++ b/tests/Unit/Integrations/Media/MediaGetAbilityTest.php @@ -20,11 +20,15 @@ final class MediaGetAbilityTest extends TestCase Functions\when('get_post')->justReturn($attachment); Functions\when('current_user_can')->justReturn(true); $finder = Mockery::mock(MediaUsageFinder::class); - $finder->expects('usages')->with(33)->andReturn([]); + $usages = [['post_id' => 5, 'title' => 'Team', 'post_type' => 'page', 'where' => 'featured', 'certainty' => 'sicher']]; + $finder->expects('scan')->with(33)->andReturn(['usages' => $usages, 'complete' => false, 'truncated' => true]); $presenter = Mockery::mock(MediaPresenter::class); - $presenter->expects('full')->with($attachment, [])->andReturn(['id' => 33]); + $presenter->expects('full')->with($attachment, $usages)->andReturn(['id' => 33, 'usages' => $usages]); - self::assertSame(['id' => 33], (new MediaGetAbility($presenter, $finder))->execute(['id' => 33])); + self::assertSame( + ['id' => 33, 'usages' => $usages, 'usages_complete' => false, 'usages_truncated' => true], + (new MediaGetAbility($presenter, $finder))->execute(['id' => 33]) + ); } public function testTrashedAttachmentsCanBeRead(): void @@ -51,5 +55,7 @@ final class MediaGetAbilityTest extends TestCase self::assertSame('lit/media-get', $ability->name()); self::assertFalse($ability->isDirect()); self::assertTrue($ability->annotations()['readonly']); + self::assertContains('usages_complete', $ability->outputSchema()['required']); + self::assertContains('usages_truncated', $ability->outputSchema()['required']); } } diff --git a/tests/Unit/Integrations/Media/MediaUsageFinderTest.php b/tests/Unit/Integrations/Media/MediaUsageFinderTest.php index 763a340..979b388 100644 --- a/tests/Unit/Integrations/Media/MediaUsageFinderTest.php +++ b/tests/Unit/Integrations/Media/MediaUsageFinderTest.php @@ -16,30 +16,32 @@ final class MediaUsageFinderTest extends TestCase private array $queries = []; /** - * @param array $options - * @param array $contents Candidate post ID => post_content. - * @param list $featured - * @param list $fields + * @param array $contents Candidate post ID => post_content. + * @param list $featured + * @param list $fields + * @param array $options Option name => value. + * @param array $themeMods Theme mod name => value. */ private function finder( array $contents = [], array $featured = [], array $fields = [], array $options = [], - int $customLogo = 0, - array $mediaKeys = ['photo'], + array $themeMods = [], + array $mediaKeys = [], string $file = '2026/09/foto-scaled.jpg' ): MediaUsageFinder { Functions\when('get_post_meta')->justReturn($file); Functions\when('get_option')->alias(static fn (string $name) => $options[$name] ?? false); - Functions\when('get_theme_mod')->justReturn($customLogo); + Functions\when('get_theme_mod')->alias(static fn (string $name) => $themeMods[$name] ?? false); + Functions\when('post_type_exists')->alias(static fn (string $type): bool => $type !== 'ghost'); Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => $cap === 'read_post' && $id !== 13); /** @var MockInterface&\wpdb $db */ $db = Mockery::mock(\wpdb::class)->makePartial(); $db->allows('get_results')->andReturnUsing(function (string $sql) use ($contents, $featured, $fields): array { $this->queries[] = $sql; - if (str_contains($sql, "_thumbnail_id")) { + if (str_contains($sql, '_thumbnail_id')) { return $featured; } if (str_contains($sql, 'pm.meta_key IN')) { @@ -52,10 +54,13 @@ final class MediaUsageFinderTest extends TestCase ); } if (str_contains($sql, 'p.ID IN')) { + // No regex here: the PCRE limit test runs this with a backtrack limit of 1. + $list = substr($sql, (int) strpos($sql, 'p.ID IN (') + strlen('p.ID IN (')); + $ids = array_map('intval', explode(',', substr($list, 0, (int) strpos($list, ')')))); $rows = []; - foreach ($contents as $id => $content) { - if (preg_match('/\b' . $id . '\b/', substr($sql, (int) strpos($sql, 'p.ID IN')))) { - $rows[] = (object) ['ID' => $id, 'post_content' => $content]; + foreach ($ids as $id) { + if (isset($contents[$id])) { + $rows[] = (object) ['ID' => $id, 'post_content' => $contents[$id]]; } } @@ -68,25 +73,31 @@ final class MediaUsageFinderTest extends TestCase return new MediaUsageFinder(static fn (): array => $mediaKeys, $db); } - /** @return array post_id => certainty */ + /** @return array post_id (or title for site usages) => certainty */ private static function certainties(array $usages): array { $result = []; foreach ($usages as $usage) { - $result[$usage['post_id']] = $usage['certainty']; + $result[$usage['post_id'] === 0 ? $usage['title'] : $usage['post_id']] = $usage['certainty']; } return $result; } - public function testCombinesFeaturedFieldContentAndSiteUsages(): void + private static function post(int $id, string $title, string $type = 'page'): object + { + return (object) ['ID' => $id, 'post_title' => $title, 'post_type' => $type]; + } + + public function testCombinesFeaturedFieldSiteAndContentUsages(): void { $usages = $this->finder( [5 => '', 9 => ''], - [(object) ['ID' => 5, 'post_title' => 'Team', 'post_type' => 'page']], - [(object) ['ID' => 12, 'post_title' => 'Anna', 'post_type' => 'lit_person']], + [self::post(5, 'Team')], + [self::post(12, 'Anna', 'lit_person')], ['site_icon' => '33'], - 33 + ['custom_logo' => 33], + ['photo'] )->usages(33); self::assertSame( @@ -101,19 +112,24 @@ final class MediaUsageFinderTest extends TestCase ); } - public function testQueriesArePreparedAndBounded(): void + public function testQueriesArePreparedBoundedAndNewestFirst(): void { - $this->finder([5 => 'x'])->usages(33, 7); + $this->finder([5 => 'x'], [], [], [], [], ['photo'])->usages(33, 7); [$featured, $fields, $candidates] = $this->queries; self::assertStringContainsString("pm.meta_value = '33'", $featured); - self::assertStringContainsString('LIMIT 7', $featured); + self::assertStringContainsString('ORDER BY p.ID DESC LIMIT 8', $featured); self::assertStringContainsString("pm.meta_key IN ('photo')", $fields); - self::assertStringContainsString('LIMIT 7', $fields); - foreach (['%wp-image-33%', '%"id":33%', '%"id":"33"%', '%"mediaId":33%', '%ids=%33%', '%"ids":[%33%', '%galleryIds":%33%', '%/2026/09/foto%', '%\\\\/2026\\\\/09\\\\/foto%'] as $like) { + self::assertStringContainsString('ORDER BY p.ID DESC LIMIT 8', $fields); + $likes = [ + '%wp-image-33%', '%"id":33%', '%"id":"33"%', '%"id": 33%', '%"id": "33"%', '%"mediaId":33%', + '%"mediaId":"33"%', '%"mediaId": 33%', '%ids=%33%', '%"ids":%33%', '%galleryIds":%33%', + '%/2026/09/foto%', '%\\\\/2026\\\\/09\\\\/foto%', + ]; + foreach ($likes as $like) { self::assertStringContainsString("'" . $like . "'", $candidates, $like); } - self::assertStringContainsString('LIMIT 200', $candidates); + self::assertStringContainsString('ORDER BY p.ID DESC LIMIT 200', $candidates); self::assertStringNotContainsString('post_content,', $candidates, 'candidates must not load content'); } @@ -124,16 +140,17 @@ final class MediaUsageFinderTest extends TestCase 2 => '[et_pb_gallery gallery_ids="33,8"][/et_pb_gallery]', 3 => '
', 4 => '', - 5 => '
', - 6 => '
', + 5 => '
', + 6 => '
', 7 => '', 8 => '', 9 => '', + 10 => '', ]; - $usages = $this->finder($contents, [], [], [], 0, [])->usages(33); + $usages = $this->finder($contents)->usages(33); - self::assertSame(array_fill_keys(range(1, 9), 'sicher'), self::certainties($usages)); + self::assertSame(array_fill_keys(range(1, 10), 'sicher'), self::certainties($usages)); } public function testFileUrlMatchesAreOnlyPossibleUsages(): void @@ -146,7 +163,7 @@ final class MediaUsageFinderTest extends TestCase self::assertSame( [1 => 'möglicherweise', 2 => 'möglicherweise', 3 => 'möglicherweise'], - self::certainties($this->finder($contents, [], [], [], 0, [])->usages(33)) + self::certainties($this->finder($contents)->usages(33)) ); } @@ -157,24 +174,113 @@ final class MediaUsageFinderTest extends TestCase 2 => ' ', 3 => '[gallery ids="330,1334"] ', 4 => ' /2025/09/foto.jpg', + 5 => '', ]; - self::assertSame([], $this->finder($contents, [], [], [], 0, [])->usages(33)); + $finder = $this->finder($contents); + + self::assertSame([], $finder->usages(33)); + self::assertFalse($finder->isUsed(33)); + } + + public function testCappedContentSearchIsNeverReportedAsUnused(): void + { + $finder = $this->finder(array_fill_keys(range(1, 200), 'nichts')); + + self::assertTrue($finder->isUsed(33)); + + $scan = $finder->scan(33); + self::assertFalse($scan['complete']); + self::assertFalse($scan['truncated']); + self::assertSame( + [[ + 'post_id' => 0, + 'title' => 'Inhaltssuche unvollständig – weitere Verwendungen möglich', + 'post_type' => 'content', + 'where' => 'content', + 'certainty' => 'möglicherweise', + ]], + $scan['usages'] + ); + } + + public function testScanReportsTruncationBeyondMax(): void + { + $finder = $this->finder([], [self::post(1, 'A'), self::post(2, 'B'), self::post(3, 'C')]); + + $scan = $finder->scan(33, 2); + + self::assertCount(2, $scan['usages']); + self::assertTrue($scan['truncated']); + self::assertTrue($scan['complete']); + self::assertFalse($finder->scan(33, 3)['truncated']); + } + + public function testRegexFailureCountsAsPossibleUsage(): void + { + $jit = ini_get('pcre.jit'); + $backtrack = ini_get('pcre.backtrack_limit'); + $finder = $this->finder([7 => str_repeat('wp-image-3 ', 200)]); + ini_set('pcre.jit', '0'); + ini_set('pcre.backtrack_limit', '1'); + try { + $usages = $finder->usages(33); + } finally { + ini_set('pcre.jit', (string) $jit); + ini_set('pcre.backtrack_limit', (string) $backtrack); + } + + self::assertSame([7 => 'möglicherweise'], self::certainties($usages)); + } + + public function testWidgetsAndThemeMods(): void + { + $usages = $this->finder( + [], + [], + [], + [ + 'widget_media_image' => [3 => ['attachment_id' => 33, 'url' => 'x'], 4 => ['attachment_id' => 330], '_multiwidget' => 1], + 'widget_media_gallery' => [2 => ['ids' => [5, 33]], 3 => ['ids' => [330]], '_multiwidget' => 1], + 'widget_block' => [ + 4 => ['content' => '
'], + 5 => ['content' => ''], + 6 => ['content' => '

nichts

'], + '_multiwidget' => 1, + ], + ], + [ + 'header_image_data' => (object) ['attachment_id' => 33, 'url' => 'x'], + 'background_image' => 'https://k.de/wp-content/uploads/2026/09/foto.jpg', + ] + )->usages(33); + + self::assertSame( + [ + 'Header-Bild' => 'sicher', + 'Hintergrundbild' => 'möglicherweise', + 'Widget media_image-3' => 'sicher', + 'Widget media_gallery-2' => 'sicher', + 'Widget block-4' => 'sicher', + 'Widget block-5' => 'möglicherweise', + ], + self::certainties($usages) + ); + self::assertSame(['header_image', 'background_image', 'widget', 'widget', 'widget', 'widget'], array_column($usages, 'where')); } public function testUnreadableEntriesHideTheirTitleAndDiviLogoIsPossible(): void { $usages = $this->finder( [13 => ''], + [self::post(20, 'Geist', 'ghost')], [], - [], - ['et_divi' => ['divi_logo' => 'https://k.de/wp-content/uploads/2026/09/foto.jpg']], - 0, - [] + ['et_divi' => ['divi_logo' => 'https://k.de/wp-content/uploads/2026/09/foto.jpg']] )->usages(33); self::assertSame( [ + ['post_id' => 20, 'title' => '(nicht lesbar)', 'post_type' => 'ghost', 'where' => 'featured', 'certainty' => 'sicher'], ['post_id' => 0, 'title' => 'Divi-Logo', 'post_type' => 'option', 'where' => 'divi_logo', 'certainty' => 'möglicherweise'], ['post_id' => 13, 'title' => '(nicht lesbar)', 'post_type' => 'page', 'where' => 'content', 'certainty' => 'sicher'], ], @@ -184,20 +290,20 @@ final class MediaUsageFinderTest extends TestCase public function testIdReferenceWinsOverAFileMatchInTheSameContent(): void { - $usages = $this->finder([5 => '/2026/09/foto.jpg '], [], [], [], 0, [])->usages(33); + $usages = $this->finder([5 => '/2026/09/foto.jpg '])->usages(33); self::assertSame([5 => 'sicher'], self::certainties($usages)); } public function testNoFieldQueryWithoutMediaFieldsAndUnusedMedia(): void { - self::assertFalse($this->finder([], [], [], [], 0, [])->isUsed(33)); + self::assertFalse($this->finder()->isUsed(33)); self::assertCount(2, $this->queries); } - public function testIsUsedStopsBeforeTheContentScanWhenAlreadyUsed(): void + public function testIsUsedStopsAfterTheFirstHit(): void { - $finder = $this->finder([5 => 'wp-image-33'], [(object) ['ID' => 5, 'post_title' => 'Team', 'post_type' => 'page']]); + $finder = $this->finder([5 => 'wp-image-33'], [self::post(5, 'Team')], [], [], [], ['photo']); self::assertTrue($finder->isUsed(33)); self::assertCount(1, $this->queries); @@ -205,7 +311,7 @@ final class MediaUsageFinderTest extends TestCase public function testWithoutAttachedFileNoFilePatterns(): void { - $this->finder([], [], [], [], 0, [], '')->usages(33); + $this->finder([], [], [], [], [], [], '')->usages(33); self::assertStringNotContainsString('foto', $this->queries[1]); self::assertStringNotContainsString("'%/%'", $this->queries[1]);