feat: media usage finder and media-get tool
Usage search distinguishes certain (ID referenced) and possible (file URL only) usages, hides titles the user cannot read, and keeps queries bounded. MediaGuard checks per-attachment capabilities and refuses trashed media unless the caller opts in. 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:
@@ -25,7 +25,7 @@ Hinweisen immer so geschrieben):
|
|||||||
Status (`lit/post-unpublish`, `lit/post-schedule`, `lit/post-trash`), Kopien (`lit/post-duplicate`,
|
Status (`lit/post-unpublish`, `lit/post-schedule`, `lit/post-trash`), Kopien (`lit/post-duplicate`,
|
||||||
`lit/post-replace-with-copy`), Revisionen (`lit/revisions-list`, `lit/revision-restore`), Menüs
|
`lit/post-replace-with-copy`), Revisionen (`lit/revisions-list`, `lit/revision-restore`), Menüs
|
||||||
(`lit/menu-list`, `lit/menu-get`, `lit/menu-create`, `lit/menu-set-items`, `lit/menu-assign-location`),
|
(`lit/menu-list`, `lit/menu-get`, `lit/menu-create`, `lit/menu-set-items`, `lit/menu-assign-location`),
|
||||||
Einstellungen (`lit/wp-settings-get`, `lit/wp-settings-update`).
|
Einstellungen (`lit/wp-settings-get`, `lit/wp-settings-update`), Medien (`lit/media-get`).
|
||||||
|
|
||||||
Weitere Themen: `lit-guide` mit `topic` = `content-types` oder `workflows`.
|
Weitere Themen: `lit-guide` mit `topic` = `content-types` oder `workflows`.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Media;
|
||||||
|
|
||||||
|
use LIT\MCP\Abilities\Ability;
|
||||||
|
use WP_Error;
|
||||||
|
|
||||||
|
final class MediaGetAbility implements Ability
|
||||||
|
{
|
||||||
|
public function __construct(private MediaPresenter $presenter, private MediaUsageFinder $finder)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'lit/media-get';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return 'Medium ansehen';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return 'Liefert Metadaten eines Mediums: Datei, Maße, Größe, alle Bildgrößen mit URL, Alt-Text, '
|
||||||
|
. '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)". '
|
||||||
|
. 'Vorschaubild über lit-media-preview.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function inputSchema(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => ['id' => ['type' => 'integer', 'minimum' => 1]],
|
||||||
|
'required' => ['id'],
|
||||||
|
'additionalProperties' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function outputSchema(): ?array
|
||||||
|
{
|
||||||
|
return MediaPresenter::fullSchema();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function annotations(): array
|
||||||
|
{
|
||||||
|
return ['readonly' => true, 'destructive' => false, 'idempotent' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isDirect(): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function permission(array $input): bool|WP_Error
|
||||||
|
{
|
||||||
|
$attachment = MediaGuard::attachment($input['id'] ?? null, 'edit_post', true);
|
||||||
|
|
||||||
|
return $attachment instanceof WP_Error ? $attachment : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function execute(array $input): mixed
|
||||||
|
{
|
||||||
|
$attachment = MediaGuard::attachment($input['id'] ?? null, 'edit_post', true);
|
||||||
|
if ($attachment instanceof WP_Error) {
|
||||||
|
return $attachment;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->presenter->full($attachment, $this->finder->usages((int) $attachment->ID));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Media;
|
||||||
|
|
||||||
|
use LIT\MCP\Abilities\Errors;
|
||||||
|
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||||||
|
use WP_Error;
|
||||||
|
use WP_Post;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads an attachment for abilities and checks the capability on that attachment
|
||||||
|
* (spec §4.5). Every error is ready to be returned from permission()/execute().
|
||||||
|
*/
|
||||||
|
final class MediaGuard
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Trashed attachments (only possible with MEDIA_TRASH) are refused unless $allowTrashed:
|
||||||
|
* readers pass it, writers must not (a change or a delete there would be surprising or permanent).
|
||||||
|
*
|
||||||
|
* @param string $capability Meta capability checked on the attachment: edit_post, delete_post or read_post.
|
||||||
|
*/
|
||||||
|
public static function attachment(
|
||||||
|
mixed $id,
|
||||||
|
string $capability = 'edit_post',
|
||||||
|
bool $allowTrashed = false
|
||||||
|
): WP_Post|WP_Error {
|
||||||
|
$attachmentId = PostGuard::id($id);
|
||||||
|
if ($attachmentId === null) {
|
||||||
|
return Errors::invalidInput('id fehlt oder ist ungültig.', ['id' => 'positive Ganzzahl erwartet']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$attachment = get_post($attachmentId);
|
||||||
|
if (! $attachment instanceof WP_Post || $attachment->post_type !== 'attachment') {
|
||||||
|
return Errors::notFound(
|
||||||
|
sprintf('Medium %d nicht gefunden.', $attachmentId),
|
||||||
|
'ID prüfen; Medien über lit-media-search suchen.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! current_user_can($capability, $attachmentId)) {
|
||||||
|
return Errors::forbidden(sprintf('Keine Berechtigung für Medium %d.', $attachmentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $allowTrashed && $attachment->post_status === 'trash') {
|
||||||
|
return Errors::invalidInput(
|
||||||
|
sprintf('Medium %d liegt im Papierkorb.', $attachmentId),
|
||||||
|
['id' => 'Medium liegt im Papierkorb'],
|
||||||
|
'Medium zuerst in der WordPress-Mediathek wiederherstellen.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $attachment;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Media;
|
||||||
|
|
||||||
|
use LIT\MCP\ChangeLog\ChangeLog;
|
||||||
|
use LIT\MCP\Integrations\Integration;
|
||||||
|
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||||
|
use LIT\MCP\Limits;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Media library tools (spec §6.3). Always available. $log and $limits are used by the
|
||||||
|
* write tools that follow (upload, update, delete).
|
||||||
|
*/
|
||||||
|
final class MediaIntegration implements Integration
|
||||||
|
{
|
||||||
|
public function __construct(private ContentSchema $schema, private ChangeLog $log, private Limits $limits)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function id(): string
|
||||||
|
{
|
||||||
|
return 'media';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isAvailable(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function abilities(): array
|
||||||
|
{
|
||||||
|
$presenter = new MediaPresenter();
|
||||||
|
$finder = new MediaUsageFinder(fn (): array => $this->schema->mediaFieldKeys());
|
||||||
|
|
||||||
|
return [
|
||||||
|
new MediaGetAbility($presenter, $finder),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Media;
|
||||||
|
|
||||||
|
use LIT\MCP\Abilities\JsonMap;
|
||||||
|
use WP_Post;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Output format of an attachment. Not final so abilities can be tested with a mock.
|
||||||
|
*/
|
||||||
|
class MediaPresenter
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function summary(WP_Post $attachment): array
|
||||||
|
{
|
||||||
|
$id = (int) $attachment->ID;
|
||||||
|
$meta = $this->metadata($attachment);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $id,
|
||||||
|
'title' => (string) $attachment->post_title,
|
||||||
|
'filename' => wp_basename((string) get_attached_file($id)),
|
||||||
|
'mime_type' => (string) $attachment->post_mime_type,
|
||||||
|
'url' => (string) wp_get_attachment_url($id),
|
||||||
|
'alt' => (string) get_post_meta($id, '_wp_attachment_image_alt', true),
|
||||||
|
'width' => isset($meta['width']) ? (int) $meta['width'] : null,
|
||||||
|
'height' => isset($meta['height']) ? (int) $meta['height'] : null,
|
||||||
|
'filesize' => isset($meta['filesize']) ? (int) $meta['filesize'] : null,
|
||||||
|
'date' => (string) $attachment->post_date,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $usages From MediaUsageFinder::usages().
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function full(WP_Post $attachment, array $usages): array
|
||||||
|
{
|
||||||
|
$id = (int) $attachment->ID;
|
||||||
|
$sizes = [];
|
||||||
|
foreach ((array) ($this->metadata($attachment)['sizes'] ?? []) as $name => $size) {
|
||||||
|
$source = wp_get_attachment_image_src($id, (string) $name);
|
||||||
|
if (is_array($source)) {
|
||||||
|
$sizes[(string) $name] = [
|
||||||
|
'url' => (string) $source[0],
|
||||||
|
'width' => (int) $source[1],
|
||||||
|
'height' => (int) $source[2],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_merge($this->summary($attachment), [
|
||||||
|
'caption' => (string) $attachment->post_excerpt,
|
||||||
|
'description' => (string) $attachment->post_content,
|
||||||
|
'parent' => (int) $attachment->post_parent,
|
||||||
|
'sizes' => JsonMap::of($sizes),
|
||||||
|
'usages' => $usages,
|
||||||
|
'edit_url' => (string) get_edit_post_link($id, 'raw'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public static function summarySchema(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => [
|
||||||
|
'id' => ['type' => 'integer'],
|
||||||
|
'title' => ['type' => 'string'],
|
||||||
|
'filename' => ['type' => 'string'],
|
||||||
|
'mime_type' => ['type' => 'string'],
|
||||||
|
'url' => ['type' => 'string'],
|
||||||
|
'alt' => ['type' => 'string'],
|
||||||
|
'width' => ['type' => ['integer', 'null']],
|
||||||
|
'height' => ['type' => ['integer', 'null']],
|
||||||
|
'filesize' => ['type' => ['integer', 'null']],
|
||||||
|
'date' => ['type' => 'string'],
|
||||||
|
],
|
||||||
|
'required' => ['id', 'title', 'mime_type', 'url', 'alt'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public static function fullSchema(): array
|
||||||
|
{
|
||||||
|
$schema = self::summarySchema();
|
||||||
|
$schema['properties'] = array_merge($schema['properties'], [
|
||||||
|
'caption' => ['type' => 'string'],
|
||||||
|
'description' => ['type' => 'string'],
|
||||||
|
'parent' => ['type' => 'integer'],
|
||||||
|
'sizes' => ['type' => 'object'],
|
||||||
|
'usages' => ['type' => 'array', 'items' => MediaUsageFinder::usageSchema()],
|
||||||
|
'edit_url' => ['type' => 'string'],
|
||||||
|
]);
|
||||||
|
$schema['required'] = array_merge($schema['required'], ['sizes', 'usages']);
|
||||||
|
|
||||||
|
return $schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function metadata(WP_Post $attachment): array
|
||||||
|
{
|
||||||
|
$meta = wp_get_attachment_metadata((int) $attachment->ID);
|
||||||
|
|
||||||
|
return is_array($meta) ? $meta : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Media;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds where an attachment is used (spec §6.3). Deliberately errs on the side of "used":
|
||||||
|
* 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").
|
||||||
|
* - 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.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
* Not final so abilities can be tested with a mock.
|
||||||
|
*/
|
||||||
|
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'];
|
||||||
|
|
||||||
|
/** Max. entries whose content is inspected per call. */
|
||||||
|
private const CANDIDATE_LIMIT = 200;
|
||||||
|
|
||||||
|
/** Entries whose content is loaded per query (keeps memory low with large Divi pages). */
|
||||||
|
private const CONTENT_BATCH = 10;
|
||||||
|
|
||||||
|
private const CORE_MEDIA_BLOCKS = ['image', 'gallery', 'cover', 'media-text', 'file', 'video', 'audio'];
|
||||||
|
|
||||||
|
/** 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';
|
||||||
|
|
||||||
|
private const EXCLUDED_STATUSES = "'auto-draft', 'trash', 'inherit'";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Closure(): list<string> $mediaMetaKeys Keys of file/image fields (ContentSchema::mediaFieldKeys()).
|
||||||
|
*/
|
||||||
|
public function __construct(private Closure $mediaMetaKeys, private ?\wpdb $db = null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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).
|
||||||
|
*/
|
||||||
|
public function usages(int $attachmentId, int $max = 20): array
|
||||||
|
{
|
||||||
|
$wpdb = $this->db ?? $GLOBALS['wpdb'];
|
||||||
|
$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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 array<string, mixed> JSON Schema of one entry of usages(). */
|
||||||
|
public static function usageSchema(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => [
|
||||||
|
'post_id' => ['type' => 'integer'],
|
||||||
|
'title' => ['type' => 'string'],
|
||||||
|
'post_type' => ['type' => 'string'],
|
||||||
|
'where' => ['type' => 'string', 'enum' => self::WHERE],
|
||||||
|
'certainty' => ['type' => 'string', 'enum' => [self::CERTAIN, self::POSSIBLE]],
|
||||||
|
],
|
||||||
|
'required' => ['post_id', 'title', 'post_type', 'where', 'certainty'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<object> */
|
||||||
|
private function featured(\wpdb $wpdb, int $attachmentId, int $max): array
|
||||||
|
{
|
||||||
|
$statuses = self::EXCLUDED_STATUSES;
|
||||||
|
|
||||||
|
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- only table names and a constant.
|
||||||
|
return (array) $wpdb->get_results($wpdb->prepare(
|
||||||
|
"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",
|
||||||
|
(string) $attachmentId,
|
||||||
|
$max
|
||||||
|
));
|
||||||
|
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<object> */
|
||||||
|
private function fields(\wpdb $wpdb, int $attachmentId, int $max): array
|
||||||
|
{
|
||||||
|
$keys = array_values(array_map('strval', ($this->mediaMetaKeys)()));
|
||||||
|
if ($keys === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$placeholders = implode(', ', array_fill(0, count($keys), '%s'));
|
||||||
|
$statuses = self::EXCLUDED_STATUSES;
|
||||||
|
|
||||||
|
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $placeholders holds one %s per key.
|
||||||
|
return (array) $wpdb->get_results($wpdb->prepare(
|
||||||
|
"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])
|
||||||
|
));
|
||||||
|
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, array<string, mixed>> $found
|
||||||
|
*/
|
||||||
|
private function addSiteUsages(array &$found, int $attachmentId, ?string $fileRegex): void
|
||||||
|
{
|
||||||
|
if ((int) get_option('site_icon') === $attachmentId) {
|
||||||
|
$found['site:site_icon'] = self::siteUsage('Website-Icon', 'option', 'site_icon', self::CERTAIN);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) get_theme_mod('custom_logo') === $attachmentId) {
|
||||||
|
$found['site:custom_logo'] = self::siteUsage(
|
||||||
|
'Logo (Customizer)',
|
||||||
|
'theme_mod',
|
||||||
|
'custom_logo',
|
||||||
|
self::CERTAIN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$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) {
|
||||||
|
$found['site:divi_logo'] = self::siteUsage('Divi-Logo', 'option', 'divi_logo', self::POSSIBLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, array<string, mixed>> $found
|
||||||
|
*/
|
||||||
|
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) {
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coarse pre-filter in SQL (a superset of what contentCertainty() accepts); IDs and titles only.
|
||||||
|
*
|
||||||
|
* @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]
|
||||||
|
);
|
||||||
|
// ID lists: [gallery ids="…"], gallery_ids="…", "ids":[…], Divi 5 "galleryIds":{…}.
|
||||||
|
foreach (['ids=', '"ids":[', 'galleryIds":'] as $prefix) {
|
||||||
|
$likes[] = '%' . $wpdb->esc_like($prefix) . '%' . $id . '%';
|
||||||
|
}
|
||||||
|
if ($stem !== '') {
|
||||||
|
// File URL, plain and with JSON-escaped slashes (foto.jpg, foto-300x200.jpg, foto-scaled.jpg).
|
||||||
|
$likes[] = '%' . $wpdb->esc_like('/' . $stem) . '%';
|
||||||
|
$likes[] = '%' . $wpdb->esc_like('\/' . str_replace('/', '\/', $stem)) . '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
$conditions = implode(' OR ', array_fill(0, count($likes), 'p.post_content LIKE %s'));
|
||||||
|
$statuses = self::EXCLUDED_STATUSES;
|
||||||
|
|
||||||
|
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- only placeholders, table names, a constant.
|
||||||
|
return (array) $wpdb->get_results($wpdb->prepare(
|
||||||
|
"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",
|
||||||
|
...array_merge($likes, [self::CANDIDATE_LIMIT])
|
||||||
|
));
|
||||||
|
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<int> $ids
|
||||||
|
* @return array<int, string> ID => post_content
|
||||||
|
*/
|
||||||
|
private function contents(\wpdb $wpdb, array $ids): array
|
||||||
|
{
|
||||||
|
$placeholders = implode(', ', array_fill(0, count($ids), '%d'));
|
||||||
|
|
||||||
|
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $placeholders holds one %d per ID.
|
||||||
|
$rows = (array) $wpdb->get_results($wpdb->prepare(
|
||||||
|
"SELECT p.ID, p.post_content FROM {$wpdb->posts} p WHERE p.ID IN ({$placeholders})",
|
||||||
|
...$ids
|
||||||
|
));
|
||||||
|
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
|
||||||
|
|
||||||
|
$contents = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$contents[(int) $row->ID] = (string) $row->post_content;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function contentCertainty(string $content, int $attachmentId, ?string $fileRegex): ?string
|
||||||
|
{
|
||||||
|
if (self::referencesId($content, $attachmentId)) {
|
||||||
|
return self::CERTAIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fileRegex !== null && preg_match($fileRegex, $content) === 1 ? self::POSSIBLE : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function referencesId(string $content, int $attachmentId): bool
|
||||||
|
{
|
||||||
|
if (preg_match('/\bwp-image-' . $attachmentId . '\b/', $content) === 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match_all(self::ID_LIST_PATTERN, $content, $lists) > 0 && self::inList($lists[1], $attachmentId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @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".
|
||||||
|
*
|
||||||
|
* @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> */
|
||||||
|
private static function stringLeaves(mixed $value): array
|
||||||
|
{
|
||||||
|
if (is_string($value)) {
|
||||||
|
return [$value];
|
||||||
|
}
|
||||||
|
|
||||||
|
$leaves = [];
|
||||||
|
if (is_array($value)) {
|
||||||
|
array_walk_recursive($value, static function ($leaf) use (&$leaves): void {
|
||||||
|
if (is_string($leaf)) {
|
||||||
|
$leaves[] = $leaf;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return $leaves;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<string> $lists Comma-separated ID lists. */
|
||||||
|
private static function inList(array $lists, int $attachmentId): bool
|
||||||
|
{
|
||||||
|
foreach ($lists as $list) {
|
||||||
|
foreach (explode(',', $list) as $item) {
|
||||||
|
$item = trim($item);
|
||||||
|
if ($item !== '' && ctype_digit($item) && (int) $item === $attachmentId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload-relative path without extension and without WordPress' suffixes for big,
|
||||||
|
* rotated or edited images: 2026/09/foto-scaled.jpg → 2026/09/foto.
|
||||||
|
*/
|
||||||
|
private function fileStem(int $attachmentId): string
|
||||||
|
{
|
||||||
|
$file = ltrim((string) get_post_meta($attachmentId, '_wp_attached_file', true), '/');
|
||||||
|
$stem = (string) preg_replace('/\.[^.\/]+$/', '', $file);
|
||||||
|
|
||||||
|
return (string) preg_replace('/(?:-scaled|-rotated|-e\d{10,})+$/', '', $stem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Original or a generated size of the file, with plain or JSON-escaped slashes. */
|
||||||
|
private static function fileRegex(string $stem): string
|
||||||
|
{
|
||||||
|
$separator = '(?:/|\\\\/)';
|
||||||
|
$parts = array_map(static fn (string $part): string => preg_quote($part, '~'), explode('/', $stem));
|
||||||
|
|
||||||
|
return '~' . $separator . implode($separator, $parts)
|
||||||
|
. '(?:-\d+x\d+|-scaled|-rotated|-e\d{10,})*\.[a-z0-9]{2,5}(?![a-z0-9])~i';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, array<string, mixed>> $found
|
||||||
|
* @param list<object> $rows
|
||||||
|
*/
|
||||||
|
private function addRows(array &$found, array $rows, string $where): void
|
||||||
|
{
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$this->add($found, $row, $where, self::CERTAIN);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds one usage per entry. Only content matches can be "possible" and content is inspected
|
||||||
|
* last, so the first usage found for an entry is always the most certain one.
|
||||||
|
*
|
||||||
|
* @param array<string, array<string, mixed>> $found
|
||||||
|
*/
|
||||||
|
private function add(array &$found, object $row, string $where, string $certainty): void
|
||||||
|
{
|
||||||
|
$postId = (int) $row->ID;
|
||||||
|
$key = 'post:' . $postId;
|
||||||
|
if (isset($found[$key])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$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,
|
||||||
|
'where' => $where,
|
||||||
|
'certainty' => $certainty,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{post_id: int, title: string, post_type: string, where: string, certainty: string} */
|
||||||
|
private static function siteUsage(string $title, string $type, string $where, string $certainty): array
|
||||||
|
{
|
||||||
|
return ['post_id' => 0, 'title' => $title, 'post_type' => $type, 'where' => $where, 'certainty' => $certainty];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -130,6 +130,29 @@ class ContentSchema
|
|||||||
throw new LogicException(sprintf('No field source "%s" registered.', $field->source));
|
throw new LogicException(sprintf('No field source "%s" registered.', $field->source));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keys of fields that store attachment IDs (file fields, relations to media) over all
|
||||||
|
* managed post types, sorted – used by the media usage search.
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function mediaFieldKeys(): array
|
||||||
|
{
|
||||||
|
$keys = [];
|
||||||
|
foreach ($this->postTypeNames() as $postType) {
|
||||||
|
foreach ($this->fields($postType) as $field) {
|
||||||
|
if ($field->type === 'file' || ($field->related['object_type'] ?? null) === 'media') {
|
||||||
|
$keys[$field->key] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$keys = array_map('strval', array_keys($keys));
|
||||||
|
sort($keys);
|
||||||
|
|
||||||
|
return $keys;
|
||||||
|
}
|
||||||
|
|
||||||
/** @return list<array{name: string, label: string, hierarchical: bool}> */
|
/** @return list<array{name: string, label: string, hierarchical: bool}> */
|
||||||
public function taxonomies(string $postType): array
|
public function taxonomies(string $postType): array
|
||||||
{
|
{
|
||||||
|
|||||||
+5
-2
@@ -11,6 +11,7 @@ use LIT\MCP\ChangeLog\ChangeLogInstaller;
|
|||||||
use LIT\MCP\Guides\InstructionsBuilder;
|
use LIT\MCP\Guides\InstructionsBuilder;
|
||||||
use LIT\MCP\Integrations\Core\CoreIntegration;
|
use LIT\MCP\Integrations\Core\CoreIntegration;
|
||||||
use LIT\MCP\Integrations\IntegrationRegistry;
|
use LIT\MCP\Integrations\IntegrationRegistry;
|
||||||
|
use LIT\MCP\Integrations\Media\MediaIntegration;
|
||||||
use LIT\MCP\Integrations\Pods\PodsFieldSource;
|
use LIT\MCP\Integrations\Pods\PodsFieldSource;
|
||||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||||
use LIT\MCP\Integrations\WordPress\Schema\RegisteredMetaSource;
|
use LIT\MCP\Integrations\WordPress\Schema\RegisteredMetaSource;
|
||||||
@@ -41,14 +42,16 @@ final class Plugin
|
|||||||
(new Access())->register();
|
(new Access())->register();
|
||||||
(new RequestContext())->register();
|
(new RequestContext())->register();
|
||||||
|
|
||||||
|
$limits = new Limits();
|
||||||
$changeLog = new ChangeLog();
|
$changeLog = new ChangeLog();
|
||||||
(new ChangeLogInstaller($changeLog))->maybeInstall();
|
(new ChangeLogInstaller($changeLog))->maybeInstall();
|
||||||
(new ChangeLogCleanup($changeLog, new Limits()))->register();
|
(new ChangeLogCleanup($changeLog, $limits))->register();
|
||||||
|
|
||||||
$schema = self::contentSchema();
|
$schema = self::contentSchema();
|
||||||
$registry = new IntegrationRegistry(
|
$registry = new IntegrationRegistry(
|
||||||
new CoreIntegration(self::pluginDir() . '/docs/guides'),
|
new CoreIntegration(self::pluginDir() . '/docs/guides'),
|
||||||
new WordPressIntegration($schema, $changeLog)
|
new WordPressIntegration($schema, $changeLog),
|
||||||
|
new MediaIntegration($schema, $changeLog, $limits)
|
||||||
);
|
);
|
||||||
|
|
||||||
(new AbilityRegistrar($registry))->register();
|
(new AbilityRegistrar($registry))->register();
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Media;
|
||||||
|
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use LIT\MCP\Integrations\Media\MediaGetAbility;
|
||||||
|
use LIT\MCP\Integrations\Media\MediaPresenter;
|
||||||
|
use LIT\MCP\Integrations\Media\MediaUsageFinder;
|
||||||
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
|
use Mockery;
|
||||||
|
use WP_Post;
|
||||||
|
|
||||||
|
final class MediaGetAbilityTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testReturnsMetadataWithUsages(): void
|
||||||
|
{
|
||||||
|
$attachment = new WP_Post(['ID' => 33, 'post_type' => 'attachment', 'post_status' => 'inherit']);
|
||||||
|
Functions\when('get_post')->justReturn($attachment);
|
||||||
|
Functions\when('current_user_can')->justReturn(true);
|
||||||
|
$finder = Mockery::mock(MediaUsageFinder::class);
|
||||||
|
$finder->expects('usages')->with(33)->andReturn([]);
|
||||||
|
$presenter = Mockery::mock(MediaPresenter::class);
|
||||||
|
$presenter->expects('full')->with($attachment, [])->andReturn(['id' => 33]);
|
||||||
|
|
||||||
|
self::assertSame(['id' => 33], (new MediaGetAbility($presenter, $finder))->execute(['id' => 33]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTrashedAttachmentsCanBeRead(): void
|
||||||
|
{
|
||||||
|
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 33, 'post_type' => 'attachment', 'post_status' => 'trash']));
|
||||||
|
Functions\when('current_user_can')->justReturn(true);
|
||||||
|
$ability = new MediaGetAbility(Mockery::mock(MediaPresenter::class), Mockery::mock(MediaUsageFinder::class));
|
||||||
|
|
||||||
|
self::assertTrue($ability->permission(['id' => 33]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNonAttachmentIsNotFound(): void
|
||||||
|
{
|
||||||
|
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 5, 'post_type' => 'page']));
|
||||||
|
$ability = new MediaGetAbility(Mockery::mock(MediaPresenter::class), Mockery::mock(MediaUsageFinder::class));
|
||||||
|
|
||||||
|
self::assertSame('lit_not_found', $ability->permission(['id' => 5])->get_error_code());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsIndirectAndReadonly(): void
|
||||||
|
{
|
||||||
|
$ability = new MediaGetAbility(Mockery::mock(MediaPresenter::class), Mockery::mock(MediaUsageFinder::class));
|
||||||
|
|
||||||
|
self::assertSame('lit/media-get', $ability->name());
|
||||||
|
self::assertFalse($ability->isDirect());
|
||||||
|
self::assertTrue($ability->annotations()['readonly']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Media;
|
||||||
|
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use LIT\MCP\Integrations\Media\MediaGuard;
|
||||||
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
|
use WP_Post;
|
||||||
|
|
||||||
|
final class MediaGuardTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testInvalidIdIsAValidationError(): void
|
||||||
|
{
|
||||||
|
self::assertSame('lit_validation_failed', MediaGuard::attachment('abc')->get_error_code());
|
||||||
|
self::assertSame('lit_validation_failed', MediaGuard::attachment(0)->get_error_code());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMissingOrNonAttachmentIsNotFound(): void
|
||||||
|
{
|
||||||
|
Functions\when('get_post')->justReturn(null);
|
||||||
|
self::assertSame('lit_not_found', MediaGuard::attachment(33)->get_error_code());
|
||||||
|
|
||||||
|
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 33, 'post_type' => 'page']));
|
||||||
|
self::assertSame('lit_not_found', MediaGuard::attachment(33)->get_error_code());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testChecksTheRequestedCapabilityOnTheAttachment(): void
|
||||||
|
{
|
||||||
|
$attachment = new WP_Post(['ID' => 33, 'post_type' => 'attachment', 'post_status' => 'inherit']);
|
||||||
|
Functions\when('get_post')->justReturn($attachment);
|
||||||
|
Functions\expect('current_user_can')->once()->with('delete_post', 33)->andReturn(false);
|
||||||
|
|
||||||
|
self::assertSame('lit_forbidden', MediaGuard::attachment('33', 'delete_post')->get_error_code());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRefusesTrashedAttachmentsUnlessAllowed(): void
|
||||||
|
{
|
||||||
|
$attachment = new WP_Post(['ID' => 33, 'post_type' => 'attachment', 'post_status' => 'trash']);
|
||||||
|
Functions\when('get_post')->justReturn($attachment);
|
||||||
|
Functions\when('current_user_can')->justReturn(true);
|
||||||
|
|
||||||
|
$error = MediaGuard::attachment(33);
|
||||||
|
self::assertSame('lit_validation_failed', $error->get_error_code());
|
||||||
|
self::assertStringContainsString('Papierkorb', $error->get_error_message());
|
||||||
|
self::assertSame($attachment, MediaGuard::attachment(33, 'read_post', true));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Media;
|
||||||
|
|
||||||
|
use LIT\MCP\Abilities\Ability;
|
||||||
|
use LIT\MCP\ChangeLog\ChangeLog;
|
||||||
|
use LIT\MCP\Integrations\Media\MediaIntegration;
|
||||||
|
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||||
|
use LIT\MCP\Limits;
|
||||||
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
|
use Mockery;
|
||||||
|
|
||||||
|
final class MediaIntegrationTest extends TestCase
|
||||||
|
{
|
||||||
|
/** Grows with Tasks 2–5 – keep in the order of abilities(). */
|
||||||
|
private const EXPECTED = [
|
||||||
|
'lit/media-get',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function testProvidesTheMediaAbilities(): void
|
||||||
|
{
|
||||||
|
$integration = new MediaIntegration(Mockery::mock(ContentSchema::class), Mockery::mock(ChangeLog::class), new Limits());
|
||||||
|
|
||||||
|
self::assertSame('media', $integration->id());
|
||||||
|
self::assertTrue($integration->isAvailable());
|
||||||
|
self::assertSame(self::EXPECTED, array_map(static fn (Ability $a): string => $a->name(), $integration->abilities()));
|
||||||
|
|
||||||
|
foreach ($integration->abilities() as $ability) {
|
||||||
|
self::assertFalse($ability->inputSchema()['additionalProperties'], $ability->name());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Media;
|
||||||
|
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use LIT\MCP\Integrations\Media\MediaPresenter;
|
||||||
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
|
use stdClass;
|
||||||
|
use WP_Post;
|
||||||
|
|
||||||
|
final class MediaPresenterTest extends TestCase
|
||||||
|
{
|
||||||
|
private function attachment(): WP_Post
|
||||||
|
{
|
||||||
|
return new WP_Post([
|
||||||
|
'ID' => 33,
|
||||||
|
'post_type' => 'attachment',
|
||||||
|
'post_title' => 'Foto',
|
||||||
|
'post_mime_type' => 'image/jpeg',
|
||||||
|
'post_excerpt' => 'Bildunterschrift',
|
||||||
|
'post_content' => 'Beschreibung',
|
||||||
|
'post_parent' => 5,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stubCommon(array $meta): void
|
||||||
|
{
|
||||||
|
Functions\when('wp_get_attachment_metadata')->justReturn($meta);
|
||||||
|
Functions\when('get_attached_file')->justReturn('/var/www/html/wp-content/uploads/2026/09/foto.jpg');
|
||||||
|
Functions\when('wp_basename')->alias('basename');
|
||||||
|
Functions\when('wp_get_attachment_url')->justReturn('https://kunde.de/wp-content/uploads/2026/09/foto.jpg');
|
||||||
|
Functions\when('get_post_meta')->justReturn('Porträt');
|
||||||
|
Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=33&action=edit');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSummaryHasFileDataAndAlt(): void
|
||||||
|
{
|
||||||
|
$this->stubCommon(['width' => 1600, 'height' => 900, 'filesize' => 123456]);
|
||||||
|
|
||||||
|
$summary = (new MediaPresenter())->summary($this->attachment());
|
||||||
|
|
||||||
|
self::assertSame('foto.jpg', $summary['filename']);
|
||||||
|
self::assertSame('Porträt', $summary['alt']);
|
||||||
|
self::assertSame(1600, $summary['width']);
|
||||||
|
self::assertSame(123456, $summary['filesize']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFullListsSizesAndUsagesAndCopesWithMissingMetadata(): void
|
||||||
|
{
|
||||||
|
$this->stubCommon(['sizes' => ['medium' => ['width' => 300, 'height' => 169]]]);
|
||||||
|
Functions\when('wp_get_attachment_image_src')->justReturn(['https://kunde.de/foto-300x169.jpg', 300, 169, true]);
|
||||||
|
$usages = [['post_id' => 5, 'title' => 'Team', 'post_type' => 'page', 'where' => 'featured', 'certainty' => 'sicher']];
|
||||||
|
|
||||||
|
$full = (new MediaPresenter())->full($this->attachment(), $usages);
|
||||||
|
|
||||||
|
self::assertSame(['medium' => ['url' => 'https://kunde.de/foto-300x169.jpg', 'width' => 300, 'height' => 169]], $full['sizes']);
|
||||||
|
self::assertSame($usages, $full['usages']);
|
||||||
|
self::assertSame('Bildunterschrift', $full['caption']);
|
||||||
|
self::assertSame(5, $full['parent']);
|
||||||
|
self::assertSame('https://kunde.de/wp-admin/post.php?post=33&action=edit', $full['edit_url']);
|
||||||
|
self::assertNull($full['width']);
|
||||||
|
|
||||||
|
Functions\when('wp_get_attachment_metadata')->justReturn(false);
|
||||||
|
self::assertInstanceOf(stdClass::class, (new MediaPresenter())->full($this->attachment(), [])['sizes']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFullSchemaRequiresSizesAndUsagesAndDescribesUsages(): void
|
||||||
|
{
|
||||||
|
$schema = MediaPresenter::fullSchema();
|
||||||
|
|
||||||
|
self::assertContains('sizes', $schema['required']);
|
||||||
|
self::assertContains('usages', $schema['required']);
|
||||||
|
self::assertSame(['sicher', 'möglicherweise'], $schema['properties']['usages']['items']['properties']['certainty']['enum']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Media;
|
||||||
|
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use LIT\MCP\Integrations\Media\MediaUsageFinder;
|
||||||
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
|
use Mockery;
|
||||||
|
use Mockery\MockInterface;
|
||||||
|
|
||||||
|
final class MediaUsageFinderTest extends TestCase
|
||||||
|
{
|
||||||
|
/** @var list<string> */
|
||||||
|
private array $queries = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $options
|
||||||
|
* @param array<int, string> $contents Candidate post ID => post_content.
|
||||||
|
* @param list<object> $featured
|
||||||
|
* @param list<object> $fields
|
||||||
|
*/
|
||||||
|
private function finder(
|
||||||
|
array $contents = [],
|
||||||
|
array $featured = [],
|
||||||
|
array $fields = [],
|
||||||
|
array $options = [],
|
||||||
|
int $customLogo = 0,
|
||||||
|
array $mediaKeys = ['photo'],
|
||||||
|
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('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")) {
|
||||||
|
return $featured;
|
||||||
|
}
|
||||||
|
if (str_contains($sql, 'pm.meta_key IN')) {
|
||||||
|
return $fields;
|
||||||
|
}
|
||||||
|
if (str_contains($sql, 'post_content LIKE')) {
|
||||||
|
return array_map(
|
||||||
|
static fn (int $id): object => (object) ['ID' => $id, 'post_title' => 'Seite ' . $id, 'post_type' => 'page'],
|
||||||
|
array_keys($contents)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (str_contains($sql, 'p.ID IN')) {
|
||||||
|
$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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
return new MediaUsageFinder(static fn (): array => $mediaKeys, $db);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int, string> post_id => certainty */
|
||||||
|
private static function certainties(array $usages): array
|
||||||
|
{
|
||||||
|
$result = [];
|
||||||
|
foreach ($usages as $usage) {
|
||||||
|
$result[$usage['post_id']] = $usage['certainty'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCombinesFeaturedFieldContentAndSiteUsages(): void
|
||||||
|
{
|
||||||
|
$usages = $this->finder(
|
||||||
|
[5 => '<img class="wp-image-33">', 9 => '<img class="wp-image-33 size-full">'],
|
||||||
|
[(object) ['ID' => 5, 'post_title' => 'Team', 'post_type' => 'page']],
|
||||||
|
[(object) ['ID' => 12, 'post_title' => 'Anna', 'post_type' => 'lit_person']],
|
||||||
|
['site_icon' => '33'],
|
||||||
|
33
|
||||||
|
)->usages(33);
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
[
|
||||||
|
['post_id' => 5, 'title' => 'Team', 'post_type' => 'page', 'where' => 'featured', 'certainty' => 'sicher'],
|
||||||
|
['post_id' => 12, 'title' => 'Anna', 'post_type' => 'lit_person', 'where' => 'field', 'certainty' => 'sicher'],
|
||||||
|
['post_id' => 0, 'title' => 'Website-Icon', 'post_type' => 'option', 'where' => 'site_icon', 'certainty' => 'sicher'],
|
||||||
|
['post_id' => 0, 'title' => 'Logo (Customizer)', 'post_type' => 'theme_mod', 'where' => 'custom_logo', 'certainty' => 'sicher'],
|
||||||
|
['post_id' => 9, 'title' => 'Seite 9', 'post_type' => 'page', 'where' => 'content', 'certainty' => 'sicher'],
|
||||||
|
],
|
||||||
|
$usages
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testQueriesArePreparedAndBounded(): void
|
||||||
|
{
|
||||||
|
$this->finder([5 => 'x'])->usages(33, 7);
|
||||||
|
|
||||||
|
[$featured, $fields, $candidates] = $this->queries;
|
||||||
|
self::assertStringContainsString("pm.meta_value = '33'", $featured);
|
||||||
|
self::assertStringContainsString('LIMIT 7', $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("'" . $like . "'", $candidates, $like);
|
||||||
|
}
|
||||||
|
self::assertStringContainsString('LIMIT 200', $candidates);
|
||||||
|
self::assertStringNotContainsString('post_content,', $candidates, 'candidates must not load content');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCertainContentReferencesByAttachmentId(): void
|
||||||
|
{
|
||||||
|
$contents = [
|
||||||
|
1 => '[gallery ids="4,33,7"]',
|
||||||
|
2 => '[et_pb_gallery gallery_ids="33,8"][/et_pb_gallery]',
|
||||||
|
3 => '<!-- wp:image {"id":33,"sizeSlug":"large"} --><figure></figure><!-- /wp:image -->',
|
||||||
|
4 => '<!-- wp:gallery {"ids":[2,33]} /-->',
|
||||||
|
5 => '<!-- wp:media-text {"mediaId":33,"mediaType":"image"} --><div></div><!-- /wp:media-text -->',
|
||||||
|
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"}}}}} /-->',
|
||||||
|
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]"}}}} /-->',
|
||||||
|
];
|
||||||
|
|
||||||
|
$usages = $this->finder($contents, [], [], [], 0, [])->usages(33);
|
||||||
|
|
||||||
|
self::assertSame(array_fill_keys(range(1, 9), 'sicher'), self::certainties($usages));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFileUrlMatchesAreOnlyPossibleUsages(): void
|
||||||
|
{
|
||||||
|
$contents = [
|
||||||
|
1 => '<img src="https://k.de/wp-content/uploads/2026/09/foto-300x200.jpg">',
|
||||||
|
2 => '[et_pb_image src="https://k.de/wp-content/uploads/2026/09/foto-scaled.jpg"]',
|
||||||
|
3 => '<!-- wp:divi/image {"image":{"innerContent":{"desktop":{"value":{"src":"https:\/\/k.de\/wp-content\/uploads\/2026\/09\/foto.webp"}}}}} /-->',
|
||||||
|
];
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
[1 => 'möglicherweise', 2 => 'möglicherweise', 3 => 'möglicherweise'],
|
||||||
|
self::certainties($this->finder($contents, [], [], [], 0, [])->usages(33))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLookalikesAreNoUsage(): void
|
||||||
|
{
|
||||||
|
$contents = [
|
||||||
|
1 => '<img class="wp-image-330">',
|
||||||
|
2 => '<!-- wp:image {"id":330} /--> <!-- wp:gallery {"ids":[133,334]} /-->',
|
||||||
|
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',
|
||||||
|
];
|
||||||
|
|
||||||
|
self::assertSame([], $this->finder($contents, [], [], [], 0, [])->usages(33));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnreadableEntriesHideTheirTitleAndDiviLogoIsPossible(): void
|
||||||
|
{
|
||||||
|
$usages = $this->finder(
|
||||||
|
[13 => '<img class="wp-image-33">'],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
['et_divi' => ['divi_logo' => 'https://k.de/wp-content/uploads/2026/09/foto.jpg']],
|
||||||
|
0,
|
||||||
|
[]
|
||||||
|
)->usages(33);
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
[
|
||||||
|
['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'],
|
||||||
|
],
|
||||||
|
$usages
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIdReferenceWinsOverAFileMatchInTheSameContent(): void
|
||||||
|
{
|
||||||
|
$usages = $this->finder([5 => '/2026/09/foto.jpg <img class="wp-image-33">'], [], [], [], 0, [])->usages(33);
|
||||||
|
|
||||||
|
self::assertSame([5 => 'sicher'], self::certainties($usages));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNoFieldQueryWithoutMediaFieldsAndUnusedMedia(): void
|
||||||
|
{
|
||||||
|
self::assertFalse($this->finder([], [], [], [], 0, [])->isUsed(33));
|
||||||
|
self::assertCount(2, $this->queries);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsUsedStopsBeforeTheContentScanWhenAlreadyUsed(): void
|
||||||
|
{
|
||||||
|
$finder = $this->finder([5 => 'wp-image-33'], [(object) ['ID' => 5, 'post_title' => 'Team', 'post_type' => 'page']]);
|
||||||
|
|
||||||
|
self::assertTrue($finder->isUsed(33));
|
||||||
|
self::assertCount(1, $this->queries);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWithoutAttachedFileNoFilePatterns(): void
|
||||||
|
{
|
||||||
|
$this->finder([], [], [], [], 0, [], '')->usages(33);
|
||||||
|
|
||||||
|
self::assertStringNotContainsString('foto', $this->queries[1]);
|
||||||
|
self::assertStringNotContainsString("'%/%'", $this->queries[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,4 +94,23 @@ final class ContentSchemaTest extends TestCase
|
|||||||
self::assertSame('footnotes', $withFields['fields'][0]['key']);
|
self::assertSame('footnotes', $withFields['fields'][0]['key']);
|
||||||
self::assertNull($schema->describe('et_pb_layout', false));
|
self::assertNull($schema->describe('et_pb_layout', false));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testMediaFieldKeysCoverFileFieldsAndMediaRelations(): void
|
||||||
|
{
|
||||||
|
Functions\when('get_post_types')->justReturn(['page' => 'page', 'lit_person' => 'lit_person']);
|
||||||
|
$source = Mockery::mock(FieldSource::class);
|
||||||
|
$source->allows('id')->andReturn('pods');
|
||||||
|
$source->allows('fieldsFor')->andReturnUsing(static fn (string $type): array => $type === 'lit_person'
|
||||||
|
? [
|
||||||
|
new FieldDefinition('photo', 'Foto', 'file', 'pods', 'writable'),
|
||||||
|
new FieldDefinition('logo', 'Logo', 'relation', 'pods', 'writable', false, false, null, [
|
||||||
|
'object_type' => 'media',
|
||||||
|
'name' => null,
|
||||||
|
]),
|
||||||
|
new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable'),
|
||||||
|
]
|
||||||
|
: [new FieldDefinition('photo', 'Foto', 'file', 'pods', 'writable')]);
|
||||||
|
|
||||||
|
self::assertSame(['logo', 'photo'], (new ContentSchema([$source]))->mediaFieldKeys());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user