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:
@@ -29,6 +29,8 @@ final class MediaGetAbility implements Ability
|
|||||||
. 'Bildunterschrift, Beschreibung und die Verwendungsorte (Beitragsbild, Inhalte inkl. Divi, '
|
. 'Bildunterschrift, Beschreibung und die Verwendungsorte (Beitragsbild, Inhalte inkl. Divi, '
|
||||||
. 'Bildfelder, Website-Icon, Logo). certainty "sicher" = per ID eingebunden, "möglicherweise" = nur '
|
. 'Bildfelder, Website-Icon, Logo). certainty "sicher" = per ID eingebunden, "möglicherweise" = nur '
|
||||||
. 'die Datei-URL gefunden. Einträge ohne Leserecht erscheinen als "(nicht lesbar)". '
|
. '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.';
|
. 'Vorschaubild über lit-media-preview.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +46,12 @@ final class MediaGetAbility implements Ability
|
|||||||
|
|
||||||
public function outputSchema(): ?array
|
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
|
public function annotations(): array
|
||||||
@@ -71,6 +78,11 @@ final class MediaGetAbility implements Ability
|
|||||||
return $attachment;
|
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'],
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,14 +11,21 @@ use Closure;
|
|||||||
* a false positive only asks for force: true, a false negative would break a page.
|
* 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
|
* - certain ("sicher"): the attachment ID is referenced – featured image, file/image fields
|
||||||
* (ContentSchema::mediaFieldKeys()), site icon, custom logo, and in content: wp-image-<id>
|
* (ContentSchema::mediaFieldKeys()), site icon, custom logo, header image, media widgets
|
||||||
* classes, [gallery ids="…"], Divi 4 gallery_ids="…", core media blocks ("id", "mediaId",
|
* (image/video/audio attachment_id, gallery ids), and in content and block widgets:
|
||||||
* "ids") and Divi 5 block attributes ("id", "galleryIds").
|
* 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 –
|
* - 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
|
* 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.
|
* Not final so abilities can be tested with a mock.
|
||||||
*/
|
*/
|
||||||
class MediaUsageFinder
|
class MediaUsageFinder
|
||||||
@@ -26,7 +33,11 @@ class MediaUsageFinder
|
|||||||
public const CERTAIN = 'sicher';
|
public const CERTAIN = 'sicher';
|
||||||
public const POSSIBLE = 'möglicherweise';
|
public const POSSIBLE = 'möglicherweise';
|
||||||
public const UNREADABLE_TITLE = '(nicht lesbar)';
|
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. */
|
/** Max. entries whose content is inspected per call. */
|
||||||
private const CANDIDATE_LIMIT = 200;
|
private const CANDIDATE_LIMIT = 200;
|
||||||
@@ -36,6 +47,9 @@ class MediaUsageFinder
|
|||||||
|
|
||||||
private const CORE_MEDIA_BLOCKS = ['image', 'gallery', 'cover', 'media-text', 'file', 'video', 'audio'];
|
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()). */
|
/** 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+'
|
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';
|
. '(?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}>
|
* @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
|
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);
|
$max = max(1, $max);
|
||||||
$stem = $this->fileStem($attachmentId);
|
|
||||||
$fileRegex = $stem === '' ? null : self::fileRegex($stem);
|
|
||||||
|
|
||||||
$found = [];
|
// One more than requested, to tell whether the list is truncated.
|
||||||
$this->addRows($found, $this->featured($wpdb, $attachmentId, $max), 'featured');
|
return $this->collect($attachmentId, $max, $max + 1);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Certain and possible usages both count – a possible one also blocks a delete without force. */
|
/** Certain and possible usages both count – a possible one also blocks a delete without force. */
|
||||||
public function isUsed(int $attachmentId): bool
|
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(). */
|
/** @return array<string, mixed> JSON Schema of one entry of usages(). */
|
||||||
@@ -101,7 +144,7 @@ class MediaUsageFinder
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @return list<object> */
|
/** @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;
|
$statuses = self::EXCLUDED_STATUSES;
|
||||||
|
|
||||||
@@ -110,15 +153,15 @@ class MediaUsageFinder
|
|||||||
"SELECT p.ID, p.post_title, p.post_type FROM {$wpdb->postmeta} pm
|
"SELECT p.ID, p.post_title, p.post_type FROM {$wpdb->postmeta} pm
|
||||||
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
|
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
|
||||||
WHERE pm.meta_key = '_thumbnail_id' AND pm.meta_value = %s
|
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,
|
(string) $attachmentId,
|
||||||
$max
|
$limit
|
||||||
));
|
));
|
||||||
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return list<object> */
|
/** @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)()));
|
$keys = array_values(array_map('strval', ($this->mediaMetaKeys)()));
|
||||||
if ($keys === []) {
|
if ($keys === []) {
|
||||||
@@ -133,13 +176,15 @@ class MediaUsageFinder
|
|||||||
"SELECT DISTINCT p.ID, p.post_title, p.post_type FROM {$wpdb->postmeta} pm
|
"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
|
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
|
||||||
WHERE pm.meta_key IN ({$placeholders}) AND pm.meta_value = %s
|
WHERE pm.meta_key IN ({$placeholders}) 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",
|
||||||
...array_merge($keys, [(string) $attachmentId, $max])
|
...array_merge($keys, [(string) $attachmentId, $limit])
|
||||||
));
|
));
|
||||||
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
|
// 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
|
* @param array<string, array<string, mixed>> $found
|
||||||
*/
|
*/
|
||||||
private function addSiteUsages(array &$found, int $attachmentId, ?string $fileRegex): void
|
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');
|
$divi = get_option('et_divi');
|
||||||
$diviLogo = is_array($divi) && is_string($divi['divi_logo'] ?? null) ? $divi['divi_logo'] : '';
|
$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);
|
$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
|
* @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(
|
private function addContentUsages(
|
||||||
array &$found,
|
array &$found,
|
||||||
\wpdb $wpdb,
|
\wpdb $wpdb,
|
||||||
int $attachmentId,
|
int $attachmentId,
|
||||||
string $stem,
|
string $stem,
|
||||||
?string $fileRegex,
|
?string $fileRegex,
|
||||||
int $max
|
int $limit
|
||||||
): void {
|
): bool {
|
||||||
foreach (array_chunk($this->contentCandidates($wpdb, $attachmentId, $stem), self::CONTENT_BATCH) as $chunk) {
|
$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));
|
$contents = $this->contents($wpdb, array_map(static fn (object $row): int => (int) $row->ID, $chunk));
|
||||||
foreach ($chunk as $row) {
|
foreach ($chunk as $row) {
|
||||||
$certainty = self::contentCertainty($contents[(int) $row->ID] ?? '', $attachmentId, $fileRegex);
|
$certainty = self::contentCertainty($contents[(int) $row->ID] ?? '', $attachmentId, $fileRegex);
|
||||||
if ($certainty !== null) {
|
if ($certainty !== null) {
|
||||||
$this->add($found, $row, 'content', $certainty);
|
$this->add($found, $row, 'content', $certainty);
|
||||||
}
|
}
|
||||||
if (count($found) >= $max) {
|
if (count($found) >= $limit) {
|
||||||
return;
|
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>
|
* @return list<object>
|
||||||
*/
|
*/
|
||||||
private function contentCandidates(\wpdb $wpdb, int $attachmentId, string $stem): array
|
private function contentCandidates(\wpdb $wpdb, int $attachmentId, string $stem): array
|
||||||
{
|
{
|
||||||
$id = (string) $attachmentId;
|
$id = (string) $attachmentId;
|
||||||
$likes = array_map(
|
$patterns = ['wp-image-' . $id];
|
||||||
static fn (string $pattern): string => '%' . $wpdb->esc_like($pattern) . '%',
|
foreach (['"id":', '"mediaId":'] as $key) {
|
||||||
['wp-image-' . $id, '"id":' . $id, '"id":"' . $id . '"', '"mediaId":' . $id]
|
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":{…}.
|
// 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 . '%';
|
$likes[] = '%' . $wpdb->esc_like($prefix) . '%' . $id . '%';
|
||||||
}
|
}
|
||||||
if ($stem !== '') {
|
if ($stem !== '') {
|
||||||
@@ -219,7 +344,7 @@ class MediaUsageFinder
|
|||||||
"SELECT p.ID, p.post_title, p.post_type FROM {$wpdb->posts} p
|
"SELECT p.ID, p.post_title, p.post_type FROM {$wpdb->posts} p
|
||||||
WHERE p.post_type NOT IN ('revision', 'attachment', 'nav_menu_item')
|
WHERE p.post_type NOT IN ('revision', 'attachment', 'nav_menu_item')
|
||||||
AND p.post_status NOT IN ({$statuses})
|
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])
|
...array_merge($likes, [self::CANDIDATE_LIMIT])
|
||||||
));
|
));
|
||||||
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
@@ -248,26 +373,36 @@ class MediaUsageFinder
|
|||||||
return $contents;
|
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
|
private static function contentCertainty(string $content, int $attachmentId, ?string $fileRegex): ?string
|
||||||
{
|
{
|
||||||
if (self::referencesId($content, $attachmentId)) {
|
$references = self::referencesId($content, $attachmentId);
|
||||||
return self::CERTAIN;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preg_match_all(self::ID_LIST_PATTERN, $content, $lists) > 0 && self::inList($lists[1], $attachmentId)) {
|
if (preg_match_all(self::BLOCK_PATTERN, $content, $blocks, PREG_SET_ORDER) === false) {
|
||||||
return true;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
preg_match_all(self::BLOCK_PATTERN, $content, $blocks, PREG_SET_ORDER);
|
|
||||||
foreach ($blocks as $block) {
|
foreach ($blocks as $block) {
|
||||||
$attrs = json_decode(trim((string) ($block['attrs'] ?? '')), true);
|
$attrs = json_decode(trim((string) ($block['attrs'] ?? '')), true);
|
||||||
if (! is_array($attrs)) {
|
if (! is_array($attrs)) {
|
||||||
@@ -288,6 +423,21 @@ class MediaUsageFinder
|
|||||||
return false;
|
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 */
|
/** @param array<mixed> $attrs */
|
||||||
private static function coreBlockReferences(array $attrs, int $attachmentId): bool
|
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
|
* @param array<mixed> $attrs
|
||||||
*/
|
*/
|
||||||
@@ -326,18 +477,18 @@ class MediaUsageFinder
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return list<string> */
|
/** @return list<string> String and integer leaves, integers as strings. */
|
||||||
private static function stringLeaves(mixed $value): array
|
private static function stringLeaves(mixed $value): array
|
||||||
{
|
{
|
||||||
if (is_string($value)) {
|
if (is_string($value) || is_int($value)) {
|
||||||
return [$value];
|
return [(string) $value];
|
||||||
}
|
}
|
||||||
|
|
||||||
$leaves = [];
|
$leaves = [];
|
||||||
if (is_array($value)) {
|
if (is_array($value)) {
|
||||||
array_walk_recursive($value, static function ($leaf) use (&$leaves): void {
|
array_walk_recursive($value, static function ($leaf) use (&$leaves): void {
|
||||||
if (is_string($leaf)) {
|
if (is_string($leaf) || is_int($leaf)) {
|
||||||
$leaves[] = $leaf;
|
$leaves[] = (string) $leaf;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -407,10 +558,14 @@ class MediaUsageFinder
|
|||||||
return;
|
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] = [
|
$found[$key] = [
|
||||||
'post_id' => $postId,
|
'post_id' => $postId,
|
||||||
'title' => current_user_can('read_post', $postId) ? (string) $row->post_title : self::UNREADABLE_TITLE,
|
'title' => $readable ? (string) $row->post_title : self::UNREADABLE_TITLE,
|
||||||
'post_type' => (string) $row->post_type,
|
'post_type' => $postType,
|
||||||
'where' => $where,
|
'where' => $where,
|
||||||
'certainty' => $certainty,
|
'certainty' => $certainty,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -20,11 +20,15 @@ final class MediaGetAbilityTest extends TestCase
|
|||||||
Functions\when('get_post')->justReturn($attachment);
|
Functions\when('get_post')->justReturn($attachment);
|
||||||
Functions\when('current_user_can')->justReturn(true);
|
Functions\when('current_user_can')->justReturn(true);
|
||||||
$finder = Mockery::mock(MediaUsageFinder::class);
|
$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 = 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
|
public function testTrashedAttachmentsCanBeRead(): void
|
||||||
@@ -51,5 +55,7 @@ final class MediaGetAbilityTest extends TestCase
|
|||||||
self::assertSame('lit/media-get', $ability->name());
|
self::assertSame('lit/media-get', $ability->name());
|
||||||
self::assertFalse($ability->isDirect());
|
self::assertFalse($ability->isDirect());
|
||||||
self::assertTrue($ability->annotations()['readonly']);
|
self::assertTrue($ability->annotations()['readonly']);
|
||||||
|
self::assertContains('usages_complete', $ability->outputSchema()['required']);
|
||||||
|
self::assertContains('usages_truncated', $ability->outputSchema()['required']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,30 +16,32 @@ final class MediaUsageFinderTest extends TestCase
|
|||||||
private array $queries = [];
|
private array $queries = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $options
|
|
||||||
* @param array<int, string> $contents Candidate post ID => post_content.
|
* @param array<int, string> $contents Candidate post ID => post_content.
|
||||||
* @param list<object> $featured
|
* @param list<object> $featured
|
||||||
* @param list<object> $fields
|
* @param list<object> $fields
|
||||||
|
* @param array<string, mixed> $options Option name => value.
|
||||||
|
* @param array<string, mixed> $themeMods Theme mod name => value.
|
||||||
*/
|
*/
|
||||||
private function finder(
|
private function finder(
|
||||||
array $contents = [],
|
array $contents = [],
|
||||||
array $featured = [],
|
array $featured = [],
|
||||||
array $fields = [],
|
array $fields = [],
|
||||||
array $options = [],
|
array $options = [],
|
||||||
int $customLogo = 0,
|
array $themeMods = [],
|
||||||
array $mediaKeys = ['photo'],
|
array $mediaKeys = [],
|
||||||
string $file = '2026/09/foto-scaled.jpg'
|
string $file = '2026/09/foto-scaled.jpg'
|
||||||
): MediaUsageFinder {
|
): MediaUsageFinder {
|
||||||
Functions\when('get_post_meta')->justReturn($file);
|
Functions\when('get_post_meta')->justReturn($file);
|
||||||
Functions\when('get_option')->alias(static fn (string $name) => $options[$name] ?? false);
|
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);
|
Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => $cap === 'read_post' && $id !== 13);
|
||||||
|
|
||||||
/** @var MockInterface&\wpdb $db */
|
/** @var MockInterface&\wpdb $db */
|
||||||
$db = Mockery::mock(\wpdb::class)->makePartial();
|
$db = Mockery::mock(\wpdb::class)->makePartial();
|
||||||
$db->allows('get_results')->andReturnUsing(function (string $sql) use ($contents, $featured, $fields): array {
|
$db->allows('get_results')->andReturnUsing(function (string $sql) use ($contents, $featured, $fields): array {
|
||||||
$this->queries[] = $sql;
|
$this->queries[] = $sql;
|
||||||
if (str_contains($sql, "_thumbnail_id")) {
|
if (str_contains($sql, '_thumbnail_id')) {
|
||||||
return $featured;
|
return $featured;
|
||||||
}
|
}
|
||||||
if (str_contains($sql, 'pm.meta_key IN')) {
|
if (str_contains($sql, 'pm.meta_key IN')) {
|
||||||
@@ -52,10 +54,13 @@ final class MediaUsageFinderTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (str_contains($sql, 'p.ID IN')) {
|
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 = [];
|
$rows = [];
|
||||||
foreach ($contents as $id => $content) {
|
foreach ($ids as $id) {
|
||||||
if (preg_match('/\b' . $id . '\b/', substr($sql, (int) strpos($sql, 'p.ID IN')))) {
|
if (isset($contents[$id])) {
|
||||||
$rows[] = (object) ['ID' => $id, 'post_content' => $content];
|
$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 new MediaUsageFinder(static fn (): array => $mediaKeys, $db);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array<int, string> post_id => certainty */
|
/** @return array<int|string, string> post_id (or title for site usages) => certainty */
|
||||||
private static function certainties(array $usages): array
|
private static function certainties(array $usages): array
|
||||||
{
|
{
|
||||||
$result = [];
|
$result = [];
|
||||||
foreach ($usages as $usage) {
|
foreach ($usages as $usage) {
|
||||||
$result[$usage['post_id']] = $usage['certainty'];
|
$result[$usage['post_id'] === 0 ? $usage['title'] : $usage['post_id']] = $usage['certainty'];
|
||||||
}
|
}
|
||||||
|
|
||||||
return $result;
|
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(
|
$usages = $this->finder(
|
||||||
[5 => '<img class="wp-image-33">', 9 => '<img class="wp-image-33 size-full">'],
|
[5 => '<img class="wp-image-33">', 9 => '<img class="wp-image-33 size-full">'],
|
||||||
[(object) ['ID' => 5, 'post_title' => 'Team', 'post_type' => 'page']],
|
[self::post(5, 'Team')],
|
||||||
[(object) ['ID' => 12, 'post_title' => 'Anna', 'post_type' => 'lit_person']],
|
[self::post(12, 'Anna', 'lit_person')],
|
||||||
['site_icon' => '33'],
|
['site_icon' => '33'],
|
||||||
33
|
['custom_logo' => 33],
|
||||||
|
['photo']
|
||||||
)->usages(33);
|
)->usages(33);
|
||||||
|
|
||||||
self::assertSame(
|
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;
|
[$featured, $fields, $candidates] = $this->queries;
|
||||||
self::assertStringContainsString("pm.meta_value = '33'", $featured);
|
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("pm.meta_key IN ('photo')", $fields);
|
||||||
self::assertStringContainsString('LIMIT 7', $fields);
|
self::assertStringContainsString('ORDER BY p.ID DESC LIMIT 8', $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) {
|
$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("'" . $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');
|
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]',
|
2 => '[et_pb_gallery gallery_ids="33,8"][/et_pb_gallery]',
|
||||||
3 => '<!-- wp:image {"id":33,"sizeSlug":"large"} --><figure></figure><!-- /wp:image -->',
|
3 => '<!-- wp:image {"id":33,"sizeSlug":"large"} --><figure></figure><!-- /wp:image -->',
|
||||||
4 => '<!-- wp:gallery {"ids":[2,33]} /-->',
|
4 => '<!-- wp:gallery {"ids":[2,33]} /-->',
|
||||||
5 => '<!-- wp:media-text {"mediaId":33,"mediaType":"image"} --><div></div><!-- /wp:media-text -->',
|
5 => '<!-- wp:media-text {"mediaId":"33","mediaType":"image"} --><div></div><!-- /wp:media-text -->',
|
||||||
6 => '<!-- wp:cover {"url":"x","id":33} --><div></div><!-- /wp:cover -->',
|
6 => '<!-- wp:cover {"url":"x", "id": 33} --><div></div><!-- /wp:cover -->',
|
||||||
7 => '<!-- wp:divi/image {"image":{"innerContent":{"desktop":{"value":{"src":"https://k.de/a.jpg","id":"33"}}}}} /-->',
|
7 => '<!-- wp:divi/image {"image":{"innerContent":{"desktop":{"value":{"src":"https://k.de/a.jpg","id":"33"}}}}} /-->',
|
||||||
8 => '<!-- wp:divi/gallery {"image":{"advanced":{"galleryIds":{"desktop":{"value":"12,33"}}}}} /-->',
|
8 => '<!-- wp:divi/gallery {"image":{"advanced":{"galleryIds":{"desktop":{"value":"12,33"}}}}} /-->',
|
||||||
9 => '<!-- wp:divi/text {"content":{"innerContent":{"desktop":{"value":"[et_pb_gallery gallery_ids=\\u002233\\u0022]"}}}} /-->',
|
9 => '<!-- wp:divi/text {"content":{"innerContent":{"desktop":{"value":"[et_pb_gallery gallery_ids=\\u002233\\u0022]"}}}} /-->',
|
||||||
|
10 => '<!-- wp:divi/gallery {"image":{"advanced":{"galleryIds":{"desktop":{"value":[12,33]}}}}} /-->',
|
||||||
];
|
];
|
||||||
|
|
||||||
$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
|
public function testFileUrlMatchesAreOnlyPossibleUsages(): void
|
||||||
@@ -146,7 +163,7 @@ final class MediaUsageFinderTest extends TestCase
|
|||||||
|
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
[1 => 'möglicherweise', 2 => 'möglicherweise', 3 => 'möglicherweise'],
|
[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 => '<!-- wp:image {"id":330} /--> <!-- wp:gallery {"ids":[133,334]} /-->',
|
2 => '<!-- wp:image {"id":330} /--> <!-- wp:gallery {"ids":[133,334]} /-->',
|
||||||
3 => '[gallery ids="330,1334"] <!-- wp:paragraph {"id":33} /-->',
|
3 => '[gallery ids="330,1334"] <!-- wp:paragraph {"id":33} /-->',
|
||||||
4 => '<img src="https://k.de/wp-content/uploads/2026/09/foto-2.jpg"> /2025/09/foto.jpg',
|
4 => '<img src="https://k.de/wp-content/uploads/2026/09/foto-2.jpg"> /2025/09/foto.jpg',
|
||||||
|
5 => '<!-- wp:divi/gallery {"image":{"advanced":{"galleryIds":{"desktop":{"value":[330,3]}}}}} /-->',
|
||||||
];
|
];
|
||||||
|
|
||||||
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' => '<!-- wp:image {"id":33} --><figure></figure><!-- /wp:image -->'],
|
||||||
|
5 => ['content' => '<img src="https://k.de/wp-content/uploads/2026/09/foto-150x150.jpg">'],
|
||||||
|
6 => ['content' => '<p>nichts</p>'],
|
||||||
|
'_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
|
public function testUnreadableEntriesHideTheirTitleAndDiviLogoIsPossible(): void
|
||||||
{
|
{
|
||||||
$usages = $this->finder(
|
$usages = $this->finder(
|
||||||
[13 => '<img class="wp-image-33">'],
|
[13 => '<img class="wp-image-33">'],
|
||||||
|
[self::post(20, 'Geist', 'ghost')],
|
||||||
[],
|
[],
|
||||||
[],
|
['et_divi' => ['divi_logo' => 'https://k.de/wp-content/uploads/2026/09/foto.jpg']]
|
||||||
['et_divi' => ['divi_logo' => 'https://k.de/wp-content/uploads/2026/09/foto.jpg']],
|
|
||||||
0,
|
|
||||||
[]
|
|
||||||
)->usages(33);
|
)->usages(33);
|
||||||
|
|
||||||
self::assertSame(
|
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' => 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'],
|
['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
|
public function testIdReferenceWinsOverAFileMatchInTheSameContent(): void
|
||||||
{
|
{
|
||||||
$usages = $this->finder([5 => '/2026/09/foto.jpg <img class="wp-image-33">'], [], [], [], 0, [])->usages(33);
|
$usages = $this->finder([5 => '/2026/09/foto.jpg <img class="wp-image-33">'])->usages(33);
|
||||||
|
|
||||||
self::assertSame([5 => 'sicher'], self::certainties($usages));
|
self::assertSame([5 => 'sicher'], self::certainties($usages));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testNoFieldQueryWithoutMediaFieldsAndUnusedMedia(): void
|
public function testNoFieldQueryWithoutMediaFieldsAndUnusedMedia(): void
|
||||||
{
|
{
|
||||||
self::assertFalse($this->finder([], [], [], [], 0, [])->isUsed(33));
|
self::assertFalse($this->finder()->isUsed(33));
|
||||||
self::assertCount(2, $this->queries);
|
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::assertTrue($finder->isUsed(33));
|
||||||
self::assertCount(1, $this->queries);
|
self::assertCount(1, $this->queries);
|
||||||
@@ -205,7 +311,7 @@ final class MediaUsageFinderTest extends TestCase
|
|||||||
|
|
||||||
public function testWithoutAttachedFileNoFilePatterns(): void
|
public function testWithoutAttachedFileNoFilePatterns(): void
|
||||||
{
|
{
|
||||||
$this->finder([], [], [], [], 0, [], '')->usages(33);
|
$this->finder([], [], [], [], [], [], '')->usages(33);
|
||||||
|
|
||||||
self::assertStringNotContainsString('foto', $this->queries[1]);
|
self::assertStringNotContainsString('foto', $this->queries[1]);
|
||||||
self::assertStringNotContainsString("'%/%'", $this->queries[1]);
|
self::assertStringNotContainsString("'%/%'", $this->queries[1]);
|
||||||
|
|||||||
Reference in New Issue
Block a user