diff --git a/docs/guides/overview.md b/docs/guides/overview.md index 71ada6d..de7fe76 100644 --- a/docs/guides/overview.md +++ b/docs/guides/overview.md @@ -25,7 +25,7 @@ Hinweisen immer so geschrieben): 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/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`. diff --git a/src/Integrations/Media/MediaGetAbility.php b/src/Integrations/Media/MediaGetAbility.php new file mode 100644 index 0000000..2bf3c12 --- /dev/null +++ b/src/Integrations/Media/MediaGetAbility.php @@ -0,0 +1,76 @@ + '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)); + } +} diff --git a/src/Integrations/Media/MediaGuard.php b/src/Integrations/Media/MediaGuard.php new file mode 100644 index 0000000..51771fd --- /dev/null +++ b/src/Integrations/Media/MediaGuard.php @@ -0,0 +1,56 @@ + '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; + } +} diff --git a/src/Integrations/Media/MediaIntegration.php b/src/Integrations/Media/MediaIntegration.php new file mode 100644 index 0000000..0632ea9 --- /dev/null +++ b/src/Integrations/Media/MediaIntegration.php @@ -0,0 +1,41 @@ + $this->schema->mediaFieldKeys()); + + return [ + new MediaGetAbility($presenter, $finder), + ]; + } +} diff --git a/src/Integrations/Media/MediaPresenter.php b/src/Integrations/Media/MediaPresenter.php new file mode 100644 index 0000000..22705e5 --- /dev/null +++ b/src/Integrations/Media/MediaPresenter.php @@ -0,0 +1,109 @@ + */ + 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> $usages From MediaUsageFinder::usages(). + * @return array + */ + 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 */ + 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 */ + 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 */ + private function metadata(WP_Post $attachment): array + { + $meta = wp_get_attachment_metadata((int) $attachment->ID); + + return is_array($meta) ? $meta : []; + } +} diff --git a/src/Integrations/Media/MediaUsageFinder.php b/src/Integrations/Media/MediaUsageFinder.php new file mode 100644 index 0000000..ccf3ff4 --- /dev/null +++ b/src/Integrations/Media/MediaUsageFinder.php @@ -0,0 +1,424 @@ + + * 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+)?\/?-->/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 $mediaMetaKeys Keys of file/image fields (ContentSchema::mediaFieldKeys()). + */ + public function __construct(private Closure $mediaMetaKeys, private ?\wpdb $db = null) + { + } + + /** + * @return list + * 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 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 */ + 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 */ + 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> $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> $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 + */ + 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 $ids + * @return array 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 $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": ""}, galleries "galleryIds" as "1,2,3". + * + * @param array $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 */ + 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 $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> $found + * @param list $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> $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]; + } +} diff --git a/src/Integrations/WordPress/Schema/ContentSchema.php b/src/Integrations/WordPress/Schema/ContentSchema.php index 5741a96..506e49f 100644 --- a/src/Integrations/WordPress/Schema/ContentSchema.php +++ b/src/Integrations/WordPress/Schema/ContentSchema.php @@ -130,6 +130,29 @@ class ContentSchema 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 + */ + 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 */ public function taxonomies(string $postType): array { diff --git a/src/Plugin.php b/src/Plugin.php index 96b9aaf..f515a99 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -11,6 +11,7 @@ use LIT\MCP\ChangeLog\ChangeLogInstaller; use LIT\MCP\Guides\InstructionsBuilder; use LIT\MCP\Integrations\Core\CoreIntegration; use LIT\MCP\Integrations\IntegrationRegistry; +use LIT\MCP\Integrations\Media\MediaIntegration; use LIT\MCP\Integrations\Pods\PodsFieldSource; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Integrations\WordPress\Schema\RegisteredMetaSource; @@ -41,14 +42,16 @@ final class Plugin (new Access())->register(); (new RequestContext())->register(); + $limits = new Limits(); $changeLog = new ChangeLog(); (new ChangeLogInstaller($changeLog))->maybeInstall(); - (new ChangeLogCleanup($changeLog, new Limits()))->register(); + (new ChangeLogCleanup($changeLog, $limits))->register(); $schema = self::contentSchema(); $registry = new IntegrationRegistry( new CoreIntegration(self::pluginDir() . '/docs/guides'), - new WordPressIntegration($schema, $changeLog) + new WordPressIntegration($schema, $changeLog), + new MediaIntegration($schema, $changeLog, $limits) ); (new AbilityRegistrar($registry))->register(); diff --git a/tests/Unit/Integrations/Media/MediaGetAbilityTest.php b/tests/Unit/Integrations/Media/MediaGetAbilityTest.php new file mode 100644 index 0000000..0710046 --- /dev/null +++ b/tests/Unit/Integrations/Media/MediaGetAbilityTest.php @@ -0,0 +1,55 @@ + 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']); + } +} diff --git a/tests/Unit/Integrations/Media/MediaGuardTest.php b/tests/Unit/Integrations/Media/MediaGuardTest.php new file mode 100644 index 0000000..e8cacdc --- /dev/null +++ b/tests/Unit/Integrations/Media/MediaGuardTest.php @@ -0,0 +1,49 @@ +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)); + } +} diff --git a/tests/Unit/Integrations/Media/MediaIntegrationTest.php b/tests/Unit/Integrations/Media/MediaIntegrationTest.php new file mode 100644 index 0000000..eeb70b5 --- /dev/null +++ b/tests/Unit/Integrations/Media/MediaIntegrationTest.php @@ -0,0 +1,34 @@ +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()); + } + } +} diff --git a/tests/Unit/Integrations/Media/MediaPresenterTest.php b/tests/Unit/Integrations/Media/MediaPresenterTest.php new file mode 100644 index 0000000..1be6b89 --- /dev/null +++ b/tests/Unit/Integrations/Media/MediaPresenterTest.php @@ -0,0 +1,77 @@ + 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']); + } +} diff --git a/tests/Unit/Integrations/Media/MediaUsageFinderTest.php b/tests/Unit/Integrations/Media/MediaUsageFinderTest.php new file mode 100644 index 0000000..763a340 --- /dev/null +++ b/tests/Unit/Integrations/Media/MediaUsageFinderTest.php @@ -0,0 +1,213 @@ + */ + private array $queries = []; + + /** + * @param array $options + * @param array $contents Candidate post ID => post_content. + * @param list $featured + * @param list $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 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 => '', 9 => ''], + [(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 => '
', + 4 => '', + 5 => '
', + 6 => '
', + 7 => '', + 8 => '', + 9 => '', + ]; + + $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 => '', + 2 => '[et_pb_image src="https://k.de/wp-content/uploads/2026/09/foto-scaled.jpg"]', + 3 => '', + ]; + + 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 => '', + 2 => ' ', + 3 => '[gallery ids="330,1334"] ', + 4 => ' /2025/09/foto.jpg', + ]; + + self::assertSame([], $this->finder($contents, [], [], [], 0, [])->usages(33)); + } + + public function testUnreadableEntriesHideTheirTitleAndDiviLogoIsPossible(): void + { + $usages = $this->finder( + [13 => ''], + [], + [], + ['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 '], [], [], [], 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]); + } +} diff --git a/tests/Unit/Integrations/WordPress/Schema/ContentSchemaTest.php b/tests/Unit/Integrations/WordPress/Schema/ContentSchemaTest.php index 3cd768c..00fd25f 100644 --- a/tests/Unit/Integrations/WordPress/Schema/ContentSchemaTest.php +++ b/tests/Unit/Integrations/WordPress/Schema/ContentSchemaTest.php @@ -94,4 +94,23 @@ final class ContentSchemaTest extends TestCase self::assertSame('footnotes', $withFields['fields'][0]['key']); 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()); + } }