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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-26 02:02:21 +02:00
co-authored by Claude Opus 5.5
parent a2a2da1ae7
commit b197d8171f
4 changed files with 382 additions and 103 deletions
+14 -2
View File
@@ -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'],
]);
}
}
+215 -60
View File
@@ -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-<id>
* 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-<id> 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+wp:(?P<namespace>[a-z][a-z0-9_-]*\/)?(?P<name>[a-z][a-z0-9_-]*)\s+'
. '(?P<attrs>{(?:(?:[^}]+|}+(?=})|(?!}\s+\/?-->).)*+)?}\s+)?\/?-->/s';
@@ -54,34 +68,63 @@ class MediaUsageFinder
/**
* @return list<array{post_id: int, title: string, post_type: string, where: string, certainty: string}>
* 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<array{post_id: int, title: string, post_type: string, where: string,
* certainty: string}>, 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<array{post_id: int, title: string, post_type: string, where: string,
* certainty: string}>, 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<string, mixed> JSON Schema of one entry of usages(). */
@@ -101,7 +144,7 @@ class MediaUsageFinder
}
/** @return list<object> */
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<object> */
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<string, array<string, mixed>> $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<string, array<string, mixed>> $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<int|string, array<string, mixed>> 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<string, array<string, mixed>> $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<object>
*/
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<mixed> $attrs */
private static function coreBlockReferences(array $attrs, int $attachmentId): bool
{
@@ -303,7 +453,8 @@ class MediaUsageFinder
}
/**
* Divi 5 image modules store {"src": url, "id": "<ID>"}, galleries "galleryIds" as "1,2,3".
* 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
*/
@@ -326,18 +477,18 @@ class MediaUsageFinder
return false;
}
/** @return list<string> */
/** @return list<string> 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,
];