Files
WP-LIT-MCP/src/Integrations/Media/ContentMediaReferences.php
T
janniclas.loosenandClaude Opus 5.5 4cb18d17c3 refactor: ContentMediaReferences holds the pure media matcher (M8)
The block/ID/file-URL matching moves out of MediaUsageFinder into a static
class; the finder keeps queries, limits and result building. Behaviour
unchanged; direct tests for the matcher added.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2026-09-26 18:28:52 +02:00

172 lines
6.1 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Media;
/**
* The pure part of MediaUsageFinder: whether a piece of stored content (post content or a
* block widget) references an attachment – by ID (wp-image-<id>, [gallery ids], Divi 4
* gallery_ids, core media blocks, Divi 5 "id"/"galleryIds") or only by its file URL. A regex
* that fails (PCRE limit) is undecided and counts as a possible usage. No database access.
*/
final class ContentMediaReferences
{
private const CORE_MEDIA_BLOCKS = ['image', 'gallery', 'cover', 'media-text', 'file', 'video', 'audio'];
/** Block delimiter pattern of WordPress' block parser (WP_Block_Parser::next_token()). */
private const BLOCK_PATTERN = '/<!--\s+wp:(?P<namespace>[a-z][a-z0-9_-]*\/)?(?P<name>[a-z][a-z0-9_-]*)\s+'
. '(?P<attrs>{(?:(?:[^}]+|}+(?=})|(?!}\s+\/?-->).)*+)?}\s+)?\/?-->/s';
/** [gallery ids="…"] and Divi 4 gallery_ids="…" – quotes also JSON-escaped inside Divi 5 blocks. */
private const ID_LIST_PATTERN = '/(?:\[gallery\b[^\]]*?\bids|\bgallery_ids)=(?:"|\'|\\\\u0022)?([\d,\s]+)/i';
/** Content a regex could not check (PCRE limit) counts as a possible usage. */
public static function certainty(string $content, int $attachmentId, ?string $fileRegex): ?string
{
$references = self::referencesId($content, $attachmentId);
if ($references !== false) {
return $references === true ? MediaUsageFinder::CERTAIN : MediaUsageFinder::POSSIBLE;
}
return self::matches($fileRegex, $content) === false ? null : MediaUsageFinder::POSSIBLE;
}
/** @return bool|null Null when a regex failed (e.g. backtrack limit) – undecided. */
private static function referencesId(string $content, int $attachmentId): ?bool
{
$wpImage = self::matches('/\bwp-image-' . $attachmentId . '\b/', $content);
if ($wpImage !== false) {
return $wpImage;
}
$listCount = preg_match_all(self::ID_LIST_PATTERN, $content, $lists);
if ($listCount === false) {
return null;
}
if ($listCount > 0 && self::inList($lists[1], $attachmentId)) {
return true;
}
if (preg_match_all(self::BLOCK_PATTERN, $content, $blocks, PREG_SET_ORDER) === false) {
return null;
}
foreach ($blocks as $block) {
$attrs = json_decode(trim((string) ($block['attrs'] ?? '')), true);
if (! is_array($attrs)) {
continue;
}
$namespace = rtrim((string) $block['namespace'], '/');
$isCoreMedia = in_array($namespace, ['', 'core'], true)
&& in_array($block['name'], self::CORE_MEDIA_BLOCKS, true);
if (
($isCoreMedia && self::coreBlockReferences($attrs, $attachmentId))
|| ($namespace === 'divi' && self::diviAttrsReference($attrs, $attachmentId))
) {
return true;
}
}
return false;
}
/**
* preg_match() as ?bool: null when the regex failed (backtrack/recursion limit), false
* without a pattern.
*/
public static function matches(?string $pattern, string $subject): ?bool
{
if ($pattern === null) {
return false;
}
$result = preg_match($pattern, $subject);
return $result === false ? null : $result === 1;
}
/** @param array<mixed> $attrs */
private static function coreBlockReferences(array $attrs, int $attachmentId): bool
{
foreach (['id', 'mediaId'] as $key) {
if (isset($attrs[$key]) && is_numeric($attrs[$key]) && (int) $attrs[$key] === $attachmentId) {
return true;
}
}
$ids = is_array($attrs['ids'] ?? null) ? array_filter($attrs['ids'], 'is_numeric') : [];
return in_array($attachmentId, array_map('intval', $ids), true);
}
/**
* Divi 5 image modules store {"src": url, "id": "<ID>"}, galleries "galleryIds" as "1,2,3"
* or as an array of IDs.
*
* @param array<mixed> $attrs
*/
private static function diviAttrsReference(array $attrs, int $attachmentId): bool
{
foreach ($attrs as $key => $value) {
if ($key === 'id' && (is_int($value) || is_string($value)) && (string) $value === (string) $attachmentId) {
return true;
}
if ($key === 'galleryIds' && self::inList(self::stringLeaves($value), $attachmentId)) {
return true;
}
if (is_array($value) && self::diviAttrsReference($value, $attachmentId)) {
return true;
}
}
return false;
}
/** @return list<string> String and integer leaves, integers as strings. */
public static function stringLeaves(mixed $value): array
{
if (is_string($value) || is_int($value)) {
return [(string) $value];
}
$leaves = [];
if (is_array($value)) {
array_walk_recursive($value, static function ($leaf) use (&$leaves): void {
if (is_string($leaf) || is_int($leaf)) {
$leaves[] = (string) $leaf;
}
});
}
return $leaves;
}
/** @param list<string> $lists Comma-separated ID lists. */
public static function inList(array $lists, int $attachmentId): bool
{
foreach ($lists as $list) {
foreach (explode(',', $list) as $item) {
$item = trim($item);
if ($item !== '' && ctype_digit($item) && (int) $item === $attachmentId) {
return true;
}
}
}
return false;
}
/** Original or a generated size of the file, with plain or JSON-escaped slashes. */
public static function fileRegex(string $stem): string
{
$separator = '(?:/|\\\\/)';
$parts = array_map(static fn (string $part): string => preg_quote($part, '~'), explode('/', $stem));
return '~' . $separator . implode($separator, $parts)
. '(?:-\d+x\d+|-scaled|-rotated|-e\d{10,})*\.[a-z0-9]{2,5}(?![a-z0-9])~i';
}
}