PluginProbe
Imagify Image Optimization: Optimize Images | Compress & Convert to WebP/AVIF / trunk
Imagify Image Optimization: Optimize Images | Compress & Convert to WebP/AVIF vtrunk
2.3.4 2.3.3 2.3.2 2.3.1 2.3.0 2.2.9 2.2.8 trunk 1.10 1.3.3 1.3.4 1.3.5 1.3.5.1 1.3.5.2 1.3.6 1.3.6.1 1.4 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.4.7 1.5 All 103 releases
imagify / classes / Abilities / MediaResolver.php

MediaResolver.php in Imagify Image Optimization: Optimize Images | Compress & Convert to WebP/AVIF trunk, at classes/Abilities/MediaResolver.php

268 lines 8.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 declare(strict_types=1);
3
4 namespace Imagify\Abilities;
5
6 use WP_Error;
7
8 /**
9 * Resolves the media identifier accepted by the MCP media abilities.
10 *
11 * Callers driving an AI assistant know an image by its filename or URL far more
12 * often than by its attachment ID, so the media abilities accept any of:
13 *
14 * - `media_id` (int) attachment ID, used as-is.
15 * - `media_url` (string) absolute URL, resolved with `attachment_url_to_postid()`.
16 * - `media_filename` (string) file name, matched against the `_wp_attached_file` meta.
17 *
18 * Precedence is `media_id`, then `media_url`, then `media_filename`. A filename
19 * matching several attachments is reported as ambiguous, with the matching IDs,
20 * rather than silently acting on the first one.
21 *
22 * @since 2.3.3
23 */
24 final class MediaResolver {
25
26 /**
27 * Maximum number of attachments fetched when matching a filename.
28 *
29 * Bounds the query while leaving enough room to report a useful list of
30 * candidates when a filename is ambiguous.
31 *
32 * @var int
33 */
34 const MAX_CANDIDATES = 20;
35
36 /**
37 * Returns the input-schema properties describing the filename and URL inputs.
38 *
39 * Shared by every media ability so the three schemas stay in sync.
40 *
41 * @since 2.3.3
42 *
43 * @return array<string, array<string, string>>
44 */
45 public static function get_input_schema_properties(): array {
46 return [
47 'media_filename' => [
48 'type' => 'string',
49 'description' => __( 'The media file name, for example "hero-banner.jpg". Use instead of media_id when only the file name is known.', 'imagify' ),
50 ],
51 'media_url' => [
52 'type' => 'string',
53 'description' => __( 'The absolute URL of the media. Use instead of media_id when only the URL is known.', 'imagify' ),
54 ],
55 ];
56 }
57
58 /**
59 * Resolve the ability arguments into a single attachment ID.
60 *
61 * @since 2.3.3
62 *
63 * @param array $args Ability input arguments.
64 * @return int|WP_Error Attachment ID, or WP_Error when the identifier is
65 * missing, unknown, or ambiguous.
66 */
67 public static function resolve_id( array $args ) {
68 if ( isset( $args['media_id'] ) && (int) $args['media_id'] > 0 ) {
69 return (int) $args['media_id'];
70 }
71
72 if ( isset( $args['media_url'] ) && '' !== trim( (string) $args['media_url'] ) ) {
73 return self::resolve_by_url( trim( (string) $args['media_url'] ) );
74 }
75
76 if ( isset( $args['media_filename'] ) && '' !== trim( (string) $args['media_filename'] ) ) {
77 return self::resolve_by_filename( trim( (string) $args['media_filename'] ) );
78 }
79
80 return new WP_Error(
81 'imagify_missing_media_identifier',
82 __( 'Provide one of media_id, media_url, or media_filename to identify the media.', 'imagify' )
83 );
84 }
85
86 /**
87 * Resolve an attachment ID from an absolute URL.
88 *
89 * @since 2.3.3
90 *
91 * @param string $url Absolute URL of the media.
92 * @return int|WP_Error
93 */
94 private static function resolve_by_url( string $url ) {
95 $media_id = attachment_url_to_postid( $url );
96
97 if ( $media_id > 0 ) {
98 return (int) $media_id;
99 }
100
101 // Next-gen and resized URLs have no attachment of their own, so retry on
102 // the file name after peeling off their suffixes: `hero-300x200.jpg.webp`
103 // must still resolve to the `hero.jpg` attachment.
104 $path = (string) wp_parse_url( $url, PHP_URL_PATH );
105
106 if ( '' !== $path ) {
107 foreach ( self::get_filename_variants( basename( $path ) ) as $variant ) {
108 $by_filename = self::resolve_by_filename( $variant );
109
110 if ( ! is_wp_error( $by_filename ) ) {
111 return $by_filename;
112 }
113 }
114 }
115
116 return new WP_Error(
117 'imagify_media_not_found',
118 sprintf(
119 /* translators: %s: media URL provided by the caller. */
120 __( 'No media in the library matches the URL "%s".', 'imagify' ),
121 $url
122 )
123 );
124 }
125
126 /**
127 * Build the list of file names a URL may stand for, most specific first.
128 *
129 * A URL taken from the front end often points at a derivative of the
130 * original file rather than the original itself: a next-gen version
131 * (`hero.jpg.webp`) or a generated thumbnail (`hero-300x200.jpg`), or both
132 * at once. Each suffix is peeled off in turn so the caller can look for the
133 * attachment the derivative was made from.
134 *
135 * @since 2.3.3
136 *
137 * @param string $basename File name taken from the URL.
138 * @return string[] Unique file names to try, in order.
139 */
140 private static function get_filename_variants( string $basename ): array {
141 $variants = [ $basename ];
142
143 // Drop a trailing next-gen extension, leaving the original file name.
144 $without_nextgen = (string) preg_replace( '/\.(webp|avif)$/i', '', $basename );
145
146 if ( $without_nextgen !== $basename && '' !== $without_nextgen ) {
147 $variants[] = $without_nextgen;
148 }
149
150 // Drop a trailing thumbnail dimension suffix, on both names collected above.
151 foreach ( $variants as $variant ) {
152 $without_size = (string) preg_replace( '/-\d+x\d+(\.[^.]+)$/', '$1', $variant );
153
154 if ( $without_size !== $variant && '' !== $without_size ) {
155 $variants[] = $without_size;
156 }
157 }
158
159 return array_unique( $variants );
160 }
161
162 /**
163 * Resolve an attachment ID from a file name.
164 *
165 * The `_wp_attached_file` meta stores a path relative to the uploads
166 * directory (`2026/08/hero.jpg`). The match is anchored on the path
167 * separator so `hero.jpg` cannot be satisfied by `my-hero.jpg` or
168 * `hero.jpg.bak`, and callers may pass either the bare file name or a full
169 * relative path — supplying the directory narrows the search, which is how
170 * two same-named files in different month folders are told apart.
171 *
172 * One row beyond the cap is fetched so that "more candidates than we are
173 * willing to list" is reported as ambiguous rather than silently resolved
174 * to whichever match happened to fall inside the window.
175 *
176 * @since 2.3.3
177 *
178 * @global \wpdb $wpdb WordPress database abstraction object.
179 *
180 * @param string $filename File name, or uploads-relative path, of the media.
181 * @return int|WP_Error
182 */
183 private static function resolve_by_filename( string $filename ) {
184 global $wpdb;
185
186 $relative = ltrim( str_replace( '\\', '/', $filename ), '/' );
187 $basename = basename( $relative );
188
189 if ( '' === $basename ) {
190 return new WP_Error(
191 'imagify_invalid_media_filename',
192 __( 'The media_filename provided is not a valid file name.', 'imagify' )
193 );
194 }
195
196 // A caller-supplied directory is kept, so `2026/08/hero.jpg` does not
197 // collide with `2025/01/hero.jpg`.
198 $needle = ( false !== strpos( $relative, '/' ) ) ? $relative : $basename;
199
200 // Matching in SQL: either the stored path IS the needle (a file at the
201 // uploads root, or a full relative path), or it ends with `/` + needle.
202 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Meta LIKE lookup no core API offers; results are request-scoped.
203 $rows = $wpdb->get_results(
204 $wpdb->prepare(
205 "SELECT post_id, meta_value FROM {$wpdb->postmeta}
206 WHERE meta_key = '_wp_attached_file'
207 AND ( meta_value = %s OR meta_value LIKE %s )
208 ORDER BY post_id ASC
209 LIMIT %d",
210 $needle,
211 '%/' . $wpdb->esc_like( $needle ),
212 self::MAX_CANDIDATES + 1
213 )
214 );
215
216 // The comparison above follows the column collation, which is
217 // case-insensitive on a stock install but need not be. Confirm in PHP so
218 // the behaviour does not depend on how the database was created.
219 $matches = [];
220
221 foreach ( (array) $rows as $row ) {
222 $stored = (string) $row->meta_value;
223 $subject = ( $needle === $basename ) ? basename( $stored ) : $stored;
224
225 if ( 0 === strcasecmp( $subject, $needle ) ) {
226 $matches[] = (int) $row->post_id;
227 }
228 }
229
230 if ( ! $matches ) {
231 return new WP_Error(
232 'imagify_media_not_found',
233 sprintf(
234 /* translators: %s: media file name provided by the caller. */
235 __( 'No media in the library is named "%s".', 'imagify' ),
236 $basename
237 )
238 );
239 }
240
241 if ( count( $matches ) > self::MAX_CANDIDATES ) {
242 return new WP_Error(
243 'imagify_ambiguous_media_filename',
244 sprintf(
245 /* translators: 1: media file name provided by the caller, 2: number of attachments listed. */
246 __( 'More than %2$d media are named "%1$s". Retry with media_id, or with the full path such as "2026/08/%1$s".', 'imagify' ),
247 $basename,
248 self::MAX_CANDIDATES
249 )
250 );
251 }
252
253 if ( count( $matches ) > 1 ) {
254 return new WP_Error(
255 'imagify_ambiguous_media_filename',
256 sprintf(
257 /* translators: 1: media file name provided by the caller, 2: comma-separated list of attachment IDs. */
258 __( 'Several media are named "%1$s" (IDs: %2$s). Retry with media_id set to the one you want.', 'imagify' ),
259 $basename,
260 implode( ', ', $matches )
261 )
262 );
263 }
264
265 return $matches[0];
266 }
267 }
268