PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.12.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.12.0
2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk All 53 releases
thinkrank / includes / seo / class-image-seo-manager.php

class-image-seo-manager.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.12.0, at includes/seo/class-image-seo-manager.php

955 lines 37.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Image SEO Manager Class
5 *
6 * Manages image-specific SEO settings including automatic
7 * ALT and TITLE attribute management.
8 *
9 * @package ThinkRank
10 * @subpackage SEO
11 * @since 1.0.0
12 */
13
14 declare(strict_types=1);
15
16 namespace ThinkRank\SEO;
17
18 // Prevent direct access
19 if (!defined('ABSPATH')) {
20 exit;
21 }
22
23 /**
24 * Image SEO Manager Class
25 *
26 * Handles image attribute optimization and settings management.
27 *
28 * @since 1.0.0
29 */
30 class Image_SEO_Manager extends Abstract_SEO_Manager {
31
32 /**
33 * Accepted values for the `alt_source` setting.
34 *
35 * Single source of truth for the schema's enum, the REST arg constraint and
36 * validate_settings(), so the three cannot disagree about what is legal.
37 *
38 * @since 1.29.1
39 * @var string[]
40 */
41 public const ALT_SOURCES = ['template', 'ai'];
42
43 /**
44 * Memoized site separator symbol.
45 *
46 * Resolved once per request rather than on every image processed during
47 * the_content, since the separator is a site-wide option.
48 *
49 * @since 1.16.0
50 * @var string|null
51 */
52 private ?string $separator = null;
53
54 /**
55 * Attachment IDs whose cache purge is being held back, or null when not.
56 *
57 * A bulk fill writes alt text onto hundreds of attachments that between
58 * them appear on a handful of pages. Resolving usage per attachment would
59 * run one unindexed postmeta scan per image; holding the IDs and resolving
60 * the whole batch in one query at the end is the same answer for a
61 * fraction of the work.
62 *
63 * @since 2.12.0
64 * @var int[]|null
65 */
66 private ?array $deferred_alt_purge = null;
67
68 /**
69 * What the most recent purge_alt_caches() call did.
70 *
71 * Read by the MCP abilities, which have to report the cache outcome
72 * alongside the write and cannot see inside fill_attachment_alt().
73 *
74 * @since 2.12.0
75 * @var array{posts: int[], warnings: string[]}
76 */
77 private array $last_alt_cache_purge = ['posts' => [], 'warnings' => []];
78
79 /**
80 * Constructor
81 *
82 * @since 1.0.0
83 */
84 public function __construct() {
85 parent::__construct('image_seo');
86 }
87
88 /**
89 * Validate SEO settings (implements interface)
90 *
91 * @since 1.0.0
92 *
93 * @param array $settings Settings array to validate
94 * @return array Validation results
95 */
96 public function validate_settings(array $settings): array {
97 $validation = [
98 'valid' => true,
99 'errors' => [],
100 'warnings' => [],
101 'suggestions' => [],
102 'score' => 100
103 ];
104
105 $boolean_fields = ['add_missing_alt', 'add_missing_title', 'save_alt_to_media', 'auto_fill_on_upload', 'media_alt_overwrite'];
106 foreach ($boolean_fields as $field) {
107 if (isset($settings[$field]) && !is_bool($settings[$field])) {
108 $validation['errors'][] = sprintf('%s must be a boolean value', $field);
109 $validation['valid'] = false;
110 }
111 }
112
113 $string_fields = ['alt_format', 'title_format'];
114 foreach ($string_fields as $field) {
115 if (isset($settings[$field]) && !is_string($settings[$field])) {
116 $validation['errors'][] = sprintf('%s must be a string', $field);
117 $validation['valid'] = false;
118 }
119 }
120
121 // The schema declares alt_source as an enum but nothing used to check
122 // it, so any string persisted. The consumer falls back to the template
123 // path on an unknown value, which hid the drift rather than surfacing
124 // it — the settings screen just had no option to select (#323).
125 if (isset($settings['alt_source']) && !in_array($settings['alt_source'], self::ALT_SOURCES, true)) {
126 $validation['errors'][] = sprintf(
127 'alt_source must be one of: %s',
128 implode(', ', self::ALT_SOURCES)
129 );
130 $validation['valid'] = false;
131 }
132
133 return $validation;
134 }
135
136 /**
137 * Get output data for frontend rendering (implements interface)
138 *
139 * @since 1.0.0
140 *
141 * @param string $context_type The context type
142 * @param int|null $context_id Optional. Context ID
143 * @return array Output data ready for frontend rendering
144 */
145 public function get_output_data(string $context_type, ?int $context_id): array {
146 return $this->get_settings($context_type, $context_id);
147 }
148
149 /**
150 * Get default settings for a context type (implements interface)
151 *
152 * @since 1.0.0
153 *
154 * @param string $context_type The context type to get defaults for
155 * @return array Default settings array
156 */
157 /**
158 * Images per batch when alt text comes from the vision model.
159 *
160 * Each one is a paid call of a few seconds; 10 keeps a batch inside a
161 * normal PHP timeout and keeps the spend per click predictable.
162 *
163 * @since 1.28.0
164 * @var int
165 */
166 private const AI_BATCH_LIMIT = 10;
167
168 public function get_default_settings(string $context_type): array {
169 return [
170 'add_missing_alt' => false,
171 'alt_format' => '%filename%',
172 'add_missing_title' => false,
173 'title_format' => '%title% %separator% %sitename%',
174 // Media Library alt persistence (writes _wp_attachment_image_alt)
175 'save_alt_to_media' => false,
176 'auto_fill_on_upload' => false,
177 'media_alt_overwrite' => false,
178 // 'template' rewrites the filename; 'ai' looks at the picture.
179 // Defaults to template because AI costs the user money per image.
180 'alt_source' => 'template',
181 ];
182 }
183
184 /**
185 * Get settings schema definition (implements interface)
186 *
187 * @since 1.0.0
188 *
189 * @param string $context_type The context type to get schema for
190 * @return array Settings schema definition
191 */
192 public function get_settings_schema(string $context_type): array {
193 return [
194 'add_missing_alt' => [
195 'type' => 'boolean',
196 'title' => __('Add Missing Alt Attributes', 'thinkrank'),
197 'description' => __('Automatically add ALT attributes to images if they are missing.', 'thinkrank'),
198 'default' => false
199 ],
200 'alt_format' => [
201 'type' => 'string',
202 'title' => __('Alt attribute format', 'thinkrank'),
203 'description' => __('The format to use for automatically generated ALT attributes.', 'thinkrank'),
204 'default' => '%filename%'
205 ],
206 'add_missing_title' => [
207 'type' => 'boolean',
208 'title' => __('Add Missing Title Attributes', 'thinkrank'),
209 'description' => __('Automatically add TITLE attributes to images if they are missing.', 'thinkrank'),
210 'default' => false
211 ],
212 'title_format' => [
213 'type' => 'string',
214 'title' => __('Title attribute format', 'thinkrank'),
215 'description' => __('The format to use for automatically generated TITLE attributes.', 'thinkrank'),
216 'default' => '%title% %separator% %sitename%'
217 ],
218 'alt_source' => [
219 'type' => 'string',
220 'title' => __('Alt text source', 'thinkrank'),
221 'description' => __('“Template” builds alt text from the filename and title. “AI” looks at the image itself and describes what is in it — this uses your AI provider key and costs one call per image.', 'thinkrank'),
222 'default' => 'template',
223 'enum' => self::ALT_SOURCES
224 ],
225 'save_alt_to_media' => [
226 'type' => 'boolean',
227 'title' => __('Save alt text to the Media Library', 'thinkrank'),
228 'description' => __('Persist generated alt text onto the attachment record so it works everywhere, not just in rendered content.', 'thinkrank'),
229 'default' => false
230 ],
231 'auto_fill_on_upload' => [
232 'type' => 'boolean',
233 'title' => __('Fill alt text on upload', 'thinkrank'),
234 'description' => __('When a new image is uploaded, automatically save generated alt text to the Media Library.', 'thinkrank'),
235 'default' => false
236 ],
237 'media_alt_overwrite' => [
238 'type' => 'boolean',
239 'title' => __('Overwrite existing alt text', 'thinkrank'),
240 'description' => __('Replace alt text that is already set, instead of only filling images that are missing it.', 'thinkrank'),
241 'default' => false
242 ]
243 ];
244 }
245
246 /**
247 * Process content and inject missing image attributes
248 *
249 * @since 1.0.0
250 * @param string $content The content to process
251 * @return string Processed content
252 */
253 public function process_content(string $content, $post_id = null): string {
254 $settings = $this->get_settings('site');
255
256 if (empty($settings['add_missing_alt']) && empty($settings['add_missing_title'])) {
257 return $content;
258 }
259
260 static $count = 0;
261 $post_id ??= get_the_ID();
262 $id_to_pass = is_int($post_id) ? $post_id : 0;
263
264 // Use regex for high performance, but careful with HTML structure
265 return preg_replace_callback('/<img([^>]+)>/i', function ($matches) use ($settings, &$count, $id_to_pass) {
266 $count++;
267 $img_tag = $matches[0];
268 $attributes_str = $matches[1];
269
270 // Parse attributes (keys lower-cased; quoted and unquoted values supported)
271 $attributes = $this->parse_attributes($attributes_str);
272
273 $alt_missing = !empty($settings['add_missing_alt']) && trim((string) ($attributes['alt'] ?? '')) === '';
274 $title_missing = !empty($settings['add_missing_title']) && trim((string) ($attributes['title'] ?? '')) === '';
275
276 // Nothing to inject on this image — skip before any source/attachment work.
277 if (!$alt_missing && !$title_missing) {
278 return $img_tag;
279 }
280
281 // Resolve the real image source. Lazy-load markup keeps the true URL in a
282 // data-* attribute while `src` is empty or a placeholder/data-URI.
283 $src = $this->resolve_image_src($attributes);
284
285 // No resolvable source (spacers, tracking pixels, pure placeholders) — nothing
286 // meaningful to describe, and nothing to derive %filename% from either.
287 if ($src === '') {
288 return $img_tag;
289 }
290
291 // Resolve the attachment ID only when a format that we're about to apply
292 // actually references attachment metadata (%image_title% / %image_caption%).
293 // attachment_url_to_postid() is a DB query, so avoid it for the common
294 // filename-based formats and for images that need no injection.
295 $needs_attachment =
296 ($alt_missing && $this->format_uses_attachment($settings['alt_format'] ?? '')) ||
297 ($title_missing && $this->format_uses_attachment($settings['title_format'] ?? ''));
298 $attachment_id = $needs_attachment
299 ? $this->url_to_attachment_id($src, (string) ($attributes['class'] ?? ''))
300 : 0;
301
302 // Handle ALT attribute
303 if ($alt_missing) {
304 $alt_val = $this->generate_attribute_value($settings['alt_format'] ?? '', $attachment_id, $id_to_pass, $count, $src);
305 if ($alt_val !== '') {
306 $img_tag = $this->inject_attribute($img_tag, 'alt', $alt_val, isset($attributes['alt']));
307 }
308 }
309
310 // Handle TITLE attribute
311 if ($title_missing) {
312 $title_val = $this->generate_attribute_value($settings['title_format'] ?? '', $attachment_id, $id_to_pass, $count, $src);
313 if ($title_val !== '') {
314 $img_tag = $this->inject_attribute($img_tag, 'title', $title_val, isset($attributes['title']));
315 }
316 }
317
318 return $img_tag;
319 }, $content);
320 }
321
322 /**
323 * Parse an <img> attribute string into a lower-cased key => value map.
324 *
325 * Handles double-quoted, single-quoted and unquoted attribute values so that
326 * existing attributes (e.g. an unquoted `alt=Something`) are correctly detected
327 * and not duplicated. Attribute names are normalised to lower-case so uppercase
328 * markup (`SRC=`, `ALT=`) is recognised.
329 *
330 * @since 1.19.1
331 * @param string $attributes_str The raw attribute portion of the tag.
332 * @return array<string,string> Lower-cased attribute name => value.
333 */
334 private function parse_attributes(string $attributes_str): array {
335 $matched = preg_match_all(
336 '/([a-zA-Z][a-zA-Z0-9:-]*)\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s"\'>]+))/',
337 $attributes_str,
338 $matches,
339 PREG_SET_ORDER
340 );
341
342 if (!$matched) {
343 return [];
344 }
345
346 $attributes = [];
347 foreach ($matches as $m) {
348 $key = strtolower($m[1]);
349
350 if (isset($m[2]) && $m[2] !== '') {
351 $value = $m[2];
352 } elseif (isset($m[3]) && $m[3] !== '') {
353 $value = $m[3];
354 } elseif (isset($m[4]) && $m[4] !== '') {
355 $value = $m[4];
356 } else {
357 $value = '';
358 }
359
360 $attributes[$key] = $value;
361 }
362
363 return $attributes;
364 }
365
366 /**
367 * Resolve a usable image source from the parsed attributes.
368 *
369 * Prefers `src`, but falls back to common lazy-load attributes when `src` is
370 * empty or a `data:` URI placeholder, so generated alt/title reflect the real
371 * image rather than a base64 blob.
372 *
373 * @since 1.19.1
374 * @param array<string,string> $attributes Parsed attributes.
375 * @return string The resolved source URL, or '' if none is usable.
376 */
377 private function resolve_image_src(array $attributes): string {
378 $candidates = ['src', 'data-src', 'data-lazy-src', 'data-original', 'data-lazy'];
379
380 foreach ($candidates as $attr) {
381 $value = trim((string) ($attributes[$attr] ?? ''));
382
383 if ($value === '' || stripos($value, 'data:') === 0) {
384 continue;
385 }
386
387 return $value;
388 }
389
390 return '';
391 }
392
393 /**
394 * Whether a format string references attachment-only metadata tokens.
395 *
396 * Used to decide if an attachment lookup (a DB query) is actually needed;
397 * filename/site/title/count tokens do not require the attachment record.
398 *
399 * @since 1.19.1
400 * @param string $format The format string.
401 * @return bool
402 */
403 private function format_uses_attachment(string $format): bool {
404 return strpos($format, '%image_title%') !== false
405 || strpos($format, '%image_caption%') !== false;
406 }
407
408 /**
409 * Resolve an attachment ID from a source URL.
410 *
411 * Body images are almost always inserted at a generated size, which
412 * `attachment_url_to_postid()` cannot match, so %image_title% and
413 * %image_caption% resolved to nothing for them. Attachment_Lookup reads the
414 * `wp-image-{ID}` class the editor wrote first, and caches whatever still
415 * has to be asked of the database (#847).
416 *
417 * @since 1.19.1
418 * @param string $src Source URL.
419 * @param string $classes The image's class attribute, for its wp-image-{ID}.
420 * @return int Attachment ID, or 0 if not a media-library image.
421 */
422 private function url_to_attachment_id(string $src, string $classes = ''): int {
423 return Attachment_Lookup::id_from_url($src, Attachment_Lookup::hint_from_markup($classes));
424 }
425
426 /**
427 * Inject (or replace an empty) alt/title attribute on a single <img> tag.
428 *
429 * When replacing, the pattern is anchored to a whitespace/tag boundary and
430 * limited to one occurrence so it can never clobber a `data-alt`/`data-title`
431 * (or any `*-alt`/`*-title`) attribute. A callback is used for the replacement
432 * so `$` / `\` in the value are never treated as backreferences. Insertion is
433 * case-insensitive on the tag opener so uppercase `<IMG>` is handled.
434 *
435 * @since 1.19.1
436 * @param string $img_tag The full <img> tag.
437 * @param string $name Attribute name ('alt' or 'title').
438 * @param string $value Unescaped attribute value.
439 * @param bool $replace Whether an (empty) attribute already exists to replace.
440 * @return string The modified tag.
441 */
442 private function inject_attribute(string $img_tag, string $name, string $value, bool $replace): string {
443 $attr = $name . '="' . esc_attr($value) . '"';
444
445 if ($replace) {
446 return preg_replace_callback(
447 '/(^|\s)' . preg_quote($name, '/') . '\s*=\s*(["\'])[^"\']*\2/i',
448 static function ($m) use ($attr) {
449 return $m[1] . $attr;
450 },
451 $img_tag,
452 1
453 );
454 }
455
456 return preg_replace_callback(
457 '/<img\b/i',
458 static function ($m) use ($attr) {
459 return $m[0] . ' ' . $attr;
460 },
461 $img_tag,
462 1
463 );
464 }
465
466 /**
467 * Generate attribute value based on format and context
468 *
469 * @since 1.0.0
470 * @param string $format The format string
471 * @param int $attachment_id Attachment ID
472 * @param int $post_id Current Post ID
473 * @param int $count Image counter
474 * @param string $src Image source URL
475 * @return string Generated value
476 */
477 private function generate_attribute_value(string $format, int $attachment_id, int $post_id, int $count, string $src): string {
478 $replacements = [
479 '%site_title%' => get_bloginfo('name'),
480 '%sitename%' => get_bloginfo('name'),
481 // Empty, not the site name. The segment collapsing below drops an
482 // unresolved token together with its separator, and the default
483 // title_format already ends in %sitename% — substituting the site
484 // name here printed it twice ("Site Name | Site Name") on every
485 // image processed outside the loop (widgets, page builders, FSE).
486 '%title%' => $post_id > 0 ? get_the_title($post_id) : '',
487 '%count%' => (string) $count,
488 '%filename%' => '',
489 '%image_title%' => '',
490 '%image_caption%' => '',
491 ];
492
493 // Get filename from src
494 if ($src) {
495 $filename = pathinfo($src, PATHINFO_FILENAME);
496 $replacements['%filename%'] = str_replace(['-', '_'], ' ', $filename);
497 }
498
499 // Get attachment data if ID exists
500 if ($attachment_id) {
501 $attachment = get_post($attachment_id);
502 if ($attachment) {
503 $replacements['%image_title%'] = $attachment->post_title;
504 $replacements['%image_caption%'] = $attachment->post_excerpt;
505 }
506 }
507
508 // Apply replacements for every token except the separator.
509 $value = str_replace(array_keys($replacements), array_values($replacements), $format);
510
511 // Split on the separator tokens, drop segments that resolved to empty, then
512 // re-join with the separator symbol. This prevents orphaned/leading/trailing
513 // separators such as "| Site Name" when a token (e.g. %filename%) is empty.
514 $segments = preg_split('/%sep(?:arator)?%/', $value);
515 $segments = array_filter(
516 array_map('trim', $segments),
517 static function ($segment) {
518 return $segment !== '';
519 }
520 );
521 $value = implode(' ' . $this->get_separator() . ' ', $segments);
522
523 // Clean up double spaces if any
524 $value = preg_replace('/\s+/', ' ', $value);
525
526 return trim($value);
527 }
528
529 /**
530 * Get site separator
531 *
532 * @since 1.0.0
533 * @return string
534 */
535 private function get_separator(): string {
536 if ($this->separator === null) {
537 $this->separator = Site_Identity_Manager::get_active_separator_symbol();
538 }
539 return $this->separator;
540 }
541
542 // ─────────────────────────────────────────────────────────────────────
543 // Media Library alt-text persistence (writes _wp_attachment_image_alt)
544 // ─────────────────────────────────────────────────────────────────────
545
546 /**
547 * Generate and save alt text onto a single attachment's Media Library record.
548 *
549 * Uses the same `alt_format` token pipeline as output injection, so the value
550 * matches what the front-end filter would have produced. In this context
551 * `%title%` and `%image_title%` resolve to the attachment's own title.
552 *
553 * @since 1.19.1
554 * @param int $attachment_id The attachment ID.
555 * @param bool $overwrite When false, images that already have alt text are left untouched.
556 * @param bool $purge Clear the caches that already rendered this image. Pass false only
557 * when the attachment cannot yet appear on any page.
558 * @return bool True when the attachment now has the generated alt text; false when skipped or on failure.
559 */
560 public function fill_attachment_alt(int $attachment_id, bool $overwrite = false, bool $purge = true): bool {
561 if (!wp_attachment_is_image($attachment_id)) {
562 return false;
563 }
564
565 $existing = (string) get_post_meta($attachment_id, '_wp_attachment_image_alt', true);
566
567 // Non-destructive by default: never clobber hand-written alt text.
568 if (!$overwrite && trim($existing) !== '') {
569 return false;
570 }
571
572 $settings = $this->get_settings('site');
573 $format = $settings['alt_format'] ?? '%filename%';
574 $src = (string) wp_get_attachment_url($attachment_id);
575
576 $value = '';
577
578 // AI describes the picture; the template can only rewrite its filename.
579 // Falls back to the template on any failure so a provider outage
580 // degrades to the old behaviour instead of leaving images bare.
581 if ('ai' === ($settings['alt_source'] ?? 'template')) {
582 $value = $this->generate_ai_alt($attachment_id);
583 }
584
585 if ('' === $value) {
586 // Pass the attachment ID as the post context so %title% falls back to the
587 // attachment's own title (there is no surrounding post here).
588 $value = sanitize_text_field(
589 $this->generate_attribute_value($format, $attachment_id, $attachment_id, 0, $src)
590 );
591 }
592
593 if ($value === '') {
594 return false;
595 }
596
597 if ($existing === $value) {
598 // Already correct — treat as success without a redundant write.
599 return true;
600 }
601
602 $written = update_post_meta($attachment_id, '_wp_attachment_image_alt', $value) !== false;
603
604 if ($written) {
605 $this->record_alt_write($attachment_id, $purge);
606 }
607
608 return $written;
609 }
610
611 /**
612 * Note that an attachment's alt text changed, and clear what rendered it.
613 *
614 * @since 2.12.0
615 * @param int $attachment_id Attachment whose alt text was just written.
616 * @param bool $purge False when nothing can be displaying this attachment yet.
617 * @return void
618 */
619 protected function record_alt_write(int $attachment_id, bool $purge = true): void {
620 // Recorded during a bulk run whatever $purge says: the batch resolves
621 // the whole list at the end, and bulk_fill_missing_alt() counts its
622 // writes from this list.
623 if (null !== $this->deferred_alt_purge) {
624 $this->deferred_alt_purge[] = $attachment_id;
625
626 return;
627 }
628
629 // A fresh upload cannot be on any page, so there is nothing to clear
630 // and nothing for thinkrank_image_alt_updated to report. Listeners
631 // that care about new files already have core's add_attachment.
632 if (!$purge) {
633 return;
634 }
635
636 $this->purge_alt_caches([$attachment_id]);
637 }
638
639 /**
640 * Drop cached renderings of every page that shows these attachments.
641 *
642 * Alt text is written to post meta but read out of HTML that other
643 * software has already rendered and stored, so the write alone changes
644 * nothing a visitor sees. Elementor keeps rendered widgets for 24 hours by
645 * default and page caches keep whole documents, which is how an agent came
646 * to report a successful alt-text write against a page still showing the
647 * old words (#763).
648 *
649 * @since 2.12.0
650 * @param int[] $attachment_ids Attachments whose alt text changed.
651 * @return array{posts: int[], warnings: string[]} Posts purged, and caches left for the user to clear.
652 */
653 public function purge_alt_caches(array $attachment_ids): array {
654 $ids = array_values(array_unique(array_filter(array_map('intval', $attachment_ids))));
655
656 if ([] === $ids) {
657 $this->last_alt_cache_purge = ['posts' => [], 'warnings' => []];
658
659 return $this->last_alt_cache_purge;
660 }
661
662 $posts = Attachment_Usage::posts_using($ids);
663 $purged = Cache_Purger::purge_posts($posts);
664
665 foreach ($ids as $id) {
666 /**
667 * Fires after ThinkRank writes an image's alt text and clears the
668 * caches it knows about.
669 *
670 * Builders and cache layers ThinkRank does not handle can listen
671 * here to drop their own rendering of the affected posts.
672 *
673 * @since 2.12.0
674 *
675 * @param int $attachment_id The attachment whose alt text changed.
676 * @param int[] $post_ids Posts found to display that attachment.
677 */
678 do_action('thinkrank_image_alt_updated', $id, $posts);
679 }
680
681 $this->last_alt_cache_purge = [
682 'posts' => $purged,
683 'warnings' => Cache_Purger::warnings(),
684 ];
685
686 return $this->last_alt_cache_purge;
687 }
688
689 /**
690 * The outcome of the most recent alt-text cache purge in this request.
691 *
692 * @since 2.12.0
693 * @return array{posts: int[], warnings: string[]}
694 */
695 public function last_alt_cache_purge(): array {
696 return $this->last_alt_cache_purge;
697 }
698
699 /**
700 * Describe an attachment with the vision model.
701 *
702 * Never throws: alt text generation runs in batches over a whole media
703 * library, and one unreadable image or a rate-limit blip must not abort
704 * the run. Returns '' so the caller falls back to the template.
705 *
706 * @since 1.28.0
707 * @param int $attachment_id Attachment to describe.
708 * @return string Alt text, or '' when unavailable.
709 */
710 private function generate_ai_alt(int $attachment_id): string {
711 try {
712 $vision = new \ThinkRank\AI\Vision_Client();
713
714 if (!$vision->is_available()) {
715 return '';
716 }
717
718 // The parent post's title disambiguates images that are visually
719 // ambiguous on their own (a generic chart, a product on white).
720 $context = '';
721 $parent = (int) get_post_field('post_parent', $attachment_id);
722 if ($parent > 0) {
723 $context = (string) get_the_title($parent);
724 }
725
726 return sanitize_text_field($vision->describe_attachment($attachment_id, $context));
727 } catch (\Throwable $e) {
728 return '';
729 }
730 }
731
732 /**
733 * Fill alt text across the Media Library in a single batch.
734 *
735 * Iterates images by ascending ID using offset/limit so callers can page
736 * through large libraries without exhausting memory or hitting timeouts.
737 *
738 * @since 1.19.1
739 * @param array $args {
740 * @type int $offset Starting offset into the image set. Default 0.
741 * @type int $limit Batch size (clamped 1–200). Default 50.
742 * @type bool $overwrite Overwrite existing alt text. Default false.
743 * }
744 * @return array {
745 * @type int $total Total images in the library.
746 * @type int $processed Images looked at in this batch.
747 * @type int $updated Images whose alt text was written.
748 * @type int $skipped Images left unchanged (already had alt / no value).
749 * @type int $offset The offset this batch started at.
750 * @type int $next_offset The offset to pass for the next batch.
751 * @type int $remaining Images still to process after this batch.
752 * @type bool $done True when the whole library has been processed.
753 * }
754 */
755 public function bulk_fill_missing_alt(array $args = []): array {
756 $offset = max(0, (int) ($args['offset'] ?? 0));
757 $limit = min(200, max(1, (int) ($args['limit'] ?? 50)));
758 $overwrite = !empty($args['overwrite']);
759
760 // In AI mode every image is a paid provider call that takes seconds,
761 // so a 200-image batch would both surprise the user's bill and blow
762 // past max_execution_time. Cap the batch and let the caller page —
763 // `remaining` already drives that loop.
764 if ('ai' === ($this->get_settings('site')['alt_source'] ?? 'template')) {
765 $limit = min($limit, self::AI_BATCH_LIMIT);
766 }
767
768 $total = $this->count_images();
769
770 $ids = get_posts([
771 'post_type' => 'attachment',
772 'post_mime_type' => 'image',
773 // Must cover the same set count_images() counts, or the pager can
774 // never reach the total. 'inherit' alone excluded private-status
775 // attachments — which media-protection and membership plugins do
776 // create — while count_images() still counted them (#322).
777 'post_status' => ['inherit', 'private', 'publish', 'draft', 'pending', 'future'],
778 'numberposts' => $limit,
779 'offset' => $offset,
780 'fields' => 'ids',
781 'orderby' => 'ID',
782 'order' => 'ASC',
783 // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.SuppressFilters_suppress_filters -- The pager must walk the same unfiltered set count_images() counts, or it can never reach the total (#322).
784 'suppress_filters' => true,
785 ]);
786
787 $processed = 0;
788
789 // Hold every purge until the batch is done: see $deferred_alt_purge.
790 $this->deferred_alt_purge = [];
791
792 try {
793 foreach ($ids as $id) {
794 $processed++;
795 $this->fill_attachment_alt((int) $id, $overwrite);
796 }
797 } finally {
798 $written = array_values(array_unique($this->deferred_alt_purge ?? []));
799 $this->deferred_alt_purge = null;
800 $cache = $this->purge_alt_caches($written);
801 }
802
803 // Counted from the writes that actually happened, not from what
804 // fill_attachment_alt() returned. It answers true for an image whose
805 // stored alt already equals the generated value, which is the right
806 // answer to "does this image have its alt text?" and the wrong one to
807 // "how many did you change" — a re-run over a correct library reported
808 // every image as updated while writing nothing. `updated` and
809 // `skipped` have documented the write, not the return value, since
810 // this method was added.
811 $updated = count($written);
812 $skipped = max(0, $processed - $updated);
813
814 $next_offset = $offset + count($ids);
815
816 // An empty batch means there is nothing left to walk, whatever the
817 // total claims. Deriving `done` from the count alone let any drift
818 // between the two queries strand the caller on a batch that could
819 // never advance the offset, and the admin UI answers that by
820 // re-requesting up to 10,000 times.
821 $exhausted = empty($ids);
822 $remaining = $exhausted ? 0 : max(0, $total - $next_offset);
823
824 // Bulk writes change the Site SEO Analyzer's "images have alt text" coverage.
825 if ($updated > 0) {
826 $this->flush_analyzer_cache();
827 }
828
829 return [
830 'total' => $total,
831 'processed' => $processed,
832 'updated' => $updated,
833 'skipped' => $skipped,
834 'offset' => $offset,
835 'next_offset' => $next_offset,
836 'remaining' => $remaining,
837 'done' => $exhausted || $next_offset >= $total,
838 'cache' => [
839 'posts_purged' => count($cache['posts']),
840 'warnings' => $cache['warnings'],
841 ],
842 ];
843 }
844
845 /**
846 * Media Library alt-text coverage stats for the settings UI.
847 *
848 * @since 1.19.1
849 * @return array{total:int,with_alt:int,missing:int}
850 */
851 public function get_media_alt_stats(): array {
852 $total = $this->count_images();
853 $with_alt = $this->count_images_with_alt();
854
855 return [
856 'total' => $total,
857 'with_alt' => $with_alt,
858 'missing' => max(0, $total - $with_alt),
859 ];
860 }
861
862 /**
863 * Auto-fill hook target — save alt text for a freshly uploaded image.
864 *
865 * Gated by the `save_alt_to_media` + `auto_fill_on_upload` settings so it is a
866 * no-op unless the feature is enabled. Respects the overwrite preference.
867 *
868 * @since 1.19.1
869 * @param int $attachment_id The newly created attachment ID.
870 * @return void
871 */
872 public function maybe_auto_fill_on_upload(int $attachment_id): void {
873 $settings = $this->get_settings('site');
874
875 if (empty($settings['save_alt_to_media']) || empty($settings['auto_fill_on_upload'])) {
876 return;
877 }
878
879 if (!wp_attachment_is_image($attachment_id)) {
880 return;
881 }
882
883 // No cache purge: this fires on add_attachment, so the file was created
884 // seconds ago and no page can be displaying it yet. Skipping the lookup
885 // keeps a bulk media import off two unindexed postmeta scans per file.
886 $this->fill_attachment_alt($attachment_id, !empty($settings['media_alt_overwrite']), false);
887 }
888
889 /**
890 * Total number of image attachments in the library.
891 *
892 * Counted with an explicit `post_status != 'trash'` rather than through
893 * wp_count_attachments(). The helper applies that filter internally, which
894 * looked equivalent — but it left the two halves of get_media_alt_stats()
895 * with different notions of which images exist, and only one of them said
896 * so out loud. Spelling the filter out here keeps this query and
897 * count_images_with_alt() visibly in step (#321).
898 *
899 * @since 1.19.1
900 * @return int
901 */
902 private function count_images(): int {
903 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- indexed COUNT; short-lived admin action
904 return (int) $this->wpdb->get_var(
905 "SELECT COUNT(*) FROM {$this->wpdb->posts}
906 WHERE post_type = 'attachment'
907 AND post_mime_type LIKE 'image/%'
908 AND post_status != 'trash'"
909 );
910 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
911 }
912
913 /**
914 * Number of image attachments that already have non-empty alt text.
915 *
916 * Carries the same `post_status != 'trash'` filter as count_images(), so a
917 * trashed image can never be counted as covered against a total it is not
918 * part of. Matches the Site SEO Analyzer's alt-text check, which applies
919 * the same filter to both of its counts.
920 *
921 * @since 1.19.1
922 * @return int
923 */
924 private function count_images_with_alt(): int {
925 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- indexed COUNT via postmeta meta_key index; short-lived admin action
926 return (int) $this->wpdb->get_var(
927 "SELECT COUNT(DISTINCT p.ID) FROM {$this->wpdb->posts} p
928 INNER JOIN {$this->wpdb->postmeta} pm
929 ON pm.post_id = p.ID
930 AND pm.meta_key = '_wp_attachment_image_alt'
931 AND pm.meta_value != ''
932 WHERE p.post_type = 'attachment'
933 AND p.post_mime_type LIKE 'image/%'
934 AND p.post_status != 'trash'"
935 );
936 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
937 }
938
939 /**
940 * Bust the Site SEO Analyzer's cached result so its alt-text coverage refreshes.
941 *
942 * Uses the analyzer's transient key directly to avoid instantiating it here.
943 *
944 * @since 1.19.1
945 * @return void
946 */
947 private function flush_analyzer_cache(): void {
948 // Ask the analyzer rather than duplicating its transient key here — the
949 // literal drifted out of sync the moment anyone renamed it.
950 if (class_exists('ThinkRank\\SEO\\SEO_Analyzer')) {
951 (new SEO_Analyzer())->flush_cache();
952 }
953 }
954 }
955