PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.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 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 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.7.0, at includes/seo/class-image-seo-manager.php

816 lines 31.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 * Constructor
56 *
57 * @since 1.0.0
58 */
59 public function __construct() {
60 parent::__construct('image_seo');
61 }
62
63 /**
64 * Validate SEO settings (implements interface)
65 *
66 * @since 1.0.0
67 *
68 * @param array $settings Settings array to validate
69 * @return array Validation results
70 */
71 public function validate_settings(array $settings): array {
72 $validation = [
73 'valid' => true,
74 'errors' => [],
75 'warnings' => [],
76 'suggestions' => [],
77 'score' => 100
78 ];
79
80 $boolean_fields = ['add_missing_alt', 'add_missing_title', 'save_alt_to_media', 'auto_fill_on_upload', 'media_alt_overwrite'];
81 foreach ($boolean_fields as $field) {
82 if (isset($settings[$field]) && !is_bool($settings[$field])) {
83 $validation['errors'][] = sprintf('%s must be a boolean value', $field);
84 $validation['valid'] = false;
85 }
86 }
87
88 $string_fields = ['alt_format', 'title_format'];
89 foreach ($string_fields as $field) {
90 if (isset($settings[$field]) && !is_string($settings[$field])) {
91 $validation['errors'][] = sprintf('%s must be a string', $field);
92 $validation['valid'] = false;
93 }
94 }
95
96 // The schema declares alt_source as an enum but nothing used to check
97 // it, so any string persisted. The consumer falls back to the template
98 // path on an unknown value, which hid the drift rather than surfacing
99 // it — the settings screen just had no option to select (#323).
100 if (isset($settings['alt_source']) && !in_array($settings['alt_source'], self::ALT_SOURCES, true)) {
101 $validation['errors'][] = sprintf(
102 'alt_source must be one of: %s',
103 implode(', ', self::ALT_SOURCES)
104 );
105 $validation['valid'] = false;
106 }
107
108 return $validation;
109 }
110
111 /**
112 * Get output data for frontend rendering (implements interface)
113 *
114 * @since 1.0.0
115 *
116 * @param string $context_type The context type
117 * @param int|null $context_id Optional. Context ID
118 * @return array Output data ready for frontend rendering
119 */
120 public function get_output_data(string $context_type, ?int $context_id): array {
121 return $this->get_settings($context_type, $context_id);
122 }
123
124 /**
125 * Get default settings for a context type (implements interface)
126 *
127 * @since 1.0.0
128 *
129 * @param string $context_type The context type to get defaults for
130 * @return array Default settings array
131 */
132 /**
133 * Images per batch when alt text comes from the vision model.
134 *
135 * Each one is a paid call of a few seconds; 10 keeps a batch inside a
136 * normal PHP timeout and keeps the spend per click predictable.
137 *
138 * @since 1.28.0
139 * @var int
140 */
141 private const AI_BATCH_LIMIT = 10;
142
143 public function get_default_settings(string $context_type): array {
144 return [
145 'add_missing_alt' => false,
146 'alt_format' => '%filename%',
147 'add_missing_title' => false,
148 'title_format' => '%title% %separator% %sitename%',
149 // Media Library alt persistence (writes _wp_attachment_image_alt)
150 'save_alt_to_media' => false,
151 'auto_fill_on_upload' => false,
152 'media_alt_overwrite' => false,
153 // 'template' rewrites the filename; 'ai' looks at the picture.
154 // Defaults to template because AI costs the user money per image.
155 'alt_source' => 'template',
156 ];
157 }
158
159 /**
160 * Get settings schema definition (implements interface)
161 *
162 * @since 1.0.0
163 *
164 * @param string $context_type The context type to get schema for
165 * @return array Settings schema definition
166 */
167 public function get_settings_schema(string $context_type): array {
168 return [
169 'add_missing_alt' => [
170 'type' => 'boolean',
171 'title' => __('Add Missing Alt Attributes', 'thinkrank'),
172 'description' => __('Automatically add ALT attributes to images if they are missing.', 'thinkrank'),
173 'default' => false
174 ],
175 'alt_format' => [
176 'type' => 'string',
177 'title' => __('Alt attribute format', 'thinkrank'),
178 'description' => __('The format to use for automatically generated ALT attributes.', 'thinkrank'),
179 'default' => '%filename%'
180 ],
181 'add_missing_title' => [
182 'type' => 'boolean',
183 'title' => __('Add Missing Title Attributes', 'thinkrank'),
184 'description' => __('Automatically add TITLE attributes to images if they are missing.', 'thinkrank'),
185 'default' => false
186 ],
187 'title_format' => [
188 'type' => 'string',
189 'title' => __('Title attribute format', 'thinkrank'),
190 'description' => __('The format to use for automatically generated TITLE attributes.', 'thinkrank'),
191 'default' => '%title% %separator% %sitename%'
192 ],
193 'alt_source' => [
194 'type' => 'string',
195 'title' => __('Alt text source', 'thinkrank'),
196 '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'),
197 'default' => 'template',
198 'enum' => self::ALT_SOURCES
199 ],
200 'save_alt_to_media' => [
201 'type' => 'boolean',
202 'title' => __('Save alt text to the Media Library', 'thinkrank'),
203 'description' => __('Persist generated alt text onto the attachment record so it works everywhere, not just in rendered content.', 'thinkrank'),
204 'default' => false
205 ],
206 'auto_fill_on_upload' => [
207 'type' => 'boolean',
208 'title' => __('Fill alt text on upload', 'thinkrank'),
209 'description' => __('When a new image is uploaded, automatically save generated alt text to the Media Library.', 'thinkrank'),
210 'default' => false
211 ],
212 'media_alt_overwrite' => [
213 'type' => 'boolean',
214 'title' => __('Overwrite existing alt text', 'thinkrank'),
215 'description' => __('Replace alt text that is already set, instead of only filling images that are missing it.', 'thinkrank'),
216 'default' => false
217 ]
218 ];
219 }
220
221 /**
222 * Process content and inject missing image attributes
223 *
224 * @since 1.0.0
225 * @param string $content The content to process
226 * @return string Processed content
227 */
228 public function process_content(string $content, $post_id = null): string {
229 $settings = $this->get_settings('site');
230
231 if (empty($settings['add_missing_alt']) && empty($settings['add_missing_title'])) {
232 return $content;
233 }
234
235 static $count = 0;
236 $post_id ??= get_the_ID();
237 $id_to_pass = is_int($post_id) ? $post_id : 0;
238
239 // Use regex for high performance, but careful with HTML structure
240 return preg_replace_callback('/<img([^>]+)>/i', function ($matches) use ($settings, &$count, $id_to_pass) {
241 $count++;
242 $img_tag = $matches[0];
243 $attributes_str = $matches[1];
244
245 // Parse attributes (keys lower-cased; quoted and unquoted values supported)
246 $attributes = $this->parse_attributes($attributes_str);
247
248 $alt_missing = !empty($settings['add_missing_alt']) && trim((string) ($attributes['alt'] ?? '')) === '';
249 $title_missing = !empty($settings['add_missing_title']) && trim((string) ($attributes['title'] ?? '')) === '';
250
251 // Nothing to inject on this image — skip before any source/attachment work.
252 if (!$alt_missing && !$title_missing) {
253 return $img_tag;
254 }
255
256 // Resolve the real image source. Lazy-load markup keeps the true URL in a
257 // data-* attribute while `src` is empty or a placeholder/data-URI.
258 $src = $this->resolve_image_src($attributes);
259
260 // No resolvable source (spacers, tracking pixels, pure placeholders) — nothing
261 // meaningful to describe, and nothing to derive %filename% from either.
262 if ($src === '') {
263 return $img_tag;
264 }
265
266 // Resolve the attachment ID only when a format that we're about to apply
267 // actually references attachment metadata (%image_title% / %image_caption%).
268 // attachment_url_to_postid() is a DB query, so avoid it for the common
269 // filename-based formats and for images that need no injection.
270 $needs_attachment =
271 ($alt_missing && $this->format_uses_attachment($settings['alt_format'] ?? '')) ||
272 ($title_missing && $this->format_uses_attachment($settings['title_format'] ?? ''));
273 $attachment_id = $needs_attachment ? $this->url_to_attachment_id($src) : 0;
274
275 // Handle ALT attribute
276 if ($alt_missing) {
277 $alt_val = $this->generate_attribute_value($settings['alt_format'] ?? '', $attachment_id, $id_to_pass, $count, $src);
278 if ($alt_val !== '') {
279 $img_tag = $this->inject_attribute($img_tag, 'alt', $alt_val, isset($attributes['alt']));
280 }
281 }
282
283 // Handle TITLE attribute
284 if ($title_missing) {
285 $title_val = $this->generate_attribute_value($settings['title_format'] ?? '', $attachment_id, $id_to_pass, $count, $src);
286 if ($title_val !== '') {
287 $img_tag = $this->inject_attribute($img_tag, 'title', $title_val, isset($attributes['title']));
288 }
289 }
290
291 return $img_tag;
292 }, $content);
293 }
294
295 /**
296 * Parse an <img> attribute string into a lower-cased key => value map.
297 *
298 * Handles double-quoted, single-quoted and unquoted attribute values so that
299 * existing attributes (e.g. an unquoted `alt=Something`) are correctly detected
300 * and not duplicated. Attribute names are normalised to lower-case so uppercase
301 * markup (`SRC=`, `ALT=`) is recognised.
302 *
303 * @since 1.19.1
304 * @param string $attributes_str The raw attribute portion of the tag.
305 * @return array<string,string> Lower-cased attribute name => value.
306 */
307 private function parse_attributes(string $attributes_str): array {
308 $matched = preg_match_all(
309 '/([a-zA-Z][a-zA-Z0-9:-]*)\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s"\'>]+))/',
310 $attributes_str,
311 $matches,
312 PREG_SET_ORDER
313 );
314
315 if (!$matched) {
316 return [];
317 }
318
319 $attributes = [];
320 foreach ($matches as $m) {
321 $key = strtolower($m[1]);
322
323 if (isset($m[2]) && $m[2] !== '') {
324 $value = $m[2];
325 } elseif (isset($m[3]) && $m[3] !== '') {
326 $value = $m[3];
327 } elseif (isset($m[4]) && $m[4] !== '') {
328 $value = $m[4];
329 } else {
330 $value = '';
331 }
332
333 $attributes[$key] = $value;
334 }
335
336 return $attributes;
337 }
338
339 /**
340 * Resolve a usable image source from the parsed attributes.
341 *
342 * Prefers `src`, but falls back to common lazy-load attributes when `src` is
343 * empty or a `data:` URI placeholder, so generated alt/title reflect the real
344 * image rather than a base64 blob.
345 *
346 * @since 1.19.1
347 * @param array<string,string> $attributes Parsed attributes.
348 * @return string The resolved source URL, or '' if none is usable.
349 */
350 private function resolve_image_src(array $attributes): string {
351 $candidates = ['src', 'data-src', 'data-lazy-src', 'data-original', 'data-lazy'];
352
353 foreach ($candidates as $attr) {
354 $value = trim((string) ($attributes[$attr] ?? ''));
355
356 if ($value === '' || stripos($value, 'data:') === 0) {
357 continue;
358 }
359
360 return $value;
361 }
362
363 return '';
364 }
365
366 /**
367 * Whether a format string references attachment-only metadata tokens.
368 *
369 * Used to decide if an attachment lookup (a DB query) is actually needed;
370 * filename/site/title/count tokens do not require the attachment record.
371 *
372 * @since 1.19.1
373 * @param string $format The format string.
374 * @return bool
375 */
376 private function format_uses_attachment(string $format): bool {
377 return strpos($format, '%image_title%') !== false
378 || strpos($format, '%image_caption%') !== false;
379 }
380
381 /**
382 * Resolve an attachment ID from a source URL, memoized per request.
383 *
384 * `attachment_url_to_postid()` issues its own DB query, so repeated identical
385 * URLs on a page (galleries, duplicated images) are cached here.
386 *
387 * @since 1.19.1
388 * @param string $src Source URL.
389 * @return int Attachment ID, or 0 if not a media-library image.
390 */
391 private function url_to_attachment_id(string $src): int {
392 if ($src === '') {
393 return 0;
394 }
395
396 static $cache = [];
397 if (!array_key_exists($src, $cache)) {
398 $cache[$src] = attachment_url_to_postid($src);
399 }
400
401 return $cache[$src];
402 }
403
404 /**
405 * Inject (or replace an empty) alt/title attribute on a single <img> tag.
406 *
407 * When replacing, the pattern is anchored to a whitespace/tag boundary and
408 * limited to one occurrence so it can never clobber a `data-alt`/`data-title`
409 * (or any `*-alt`/`*-title`) attribute. A callback is used for the replacement
410 * so `$` / `\` in the value are never treated as backreferences. Insertion is
411 * case-insensitive on the tag opener so uppercase `<IMG>` is handled.
412 *
413 * @since 1.19.1
414 * @param string $img_tag The full <img> tag.
415 * @param string $name Attribute name ('alt' or 'title').
416 * @param string $value Unescaped attribute value.
417 * @param bool $replace Whether an (empty) attribute already exists to replace.
418 * @return string The modified tag.
419 */
420 private function inject_attribute(string $img_tag, string $name, string $value, bool $replace): string {
421 $attr = $name . '="' . esc_attr($value) . '"';
422
423 if ($replace) {
424 return preg_replace_callback(
425 '/(^|\s)' . preg_quote($name, '/') . '\s*=\s*(["\'])[^"\']*\2/i',
426 static function ($m) use ($attr) {
427 return $m[1] . $attr;
428 },
429 $img_tag,
430 1
431 );
432 }
433
434 return preg_replace_callback(
435 '/<img\b/i',
436 static function ($m) use ($attr) {
437 return $m[0] . ' ' . $attr;
438 },
439 $img_tag,
440 1
441 );
442 }
443
444 /**
445 * Generate attribute value based on format and context
446 *
447 * @since 1.0.0
448 * @param string $format The format string
449 * @param int $attachment_id Attachment ID
450 * @param int $post_id Current Post ID
451 * @param int $count Image counter
452 * @param string $src Image source URL
453 * @return string Generated value
454 */
455 private function generate_attribute_value(string $format, int $attachment_id, int $post_id, int $count, string $src): string {
456 $replacements = [
457 '%site_title%' => get_bloginfo('name'),
458 '%sitename%' => get_bloginfo('name'),
459 // Empty, not the site name. The segment collapsing below drops an
460 // unresolved token together with its separator, and the default
461 // title_format already ends in %sitename% — substituting the site
462 // name here printed it twice ("Site Name | Site Name") on every
463 // image processed outside the loop (widgets, page builders, FSE).
464 '%title%' => $post_id > 0 ? get_the_title($post_id) : '',
465 '%count%' => (string) $count,
466 '%filename%' => '',
467 '%image_title%' => '',
468 '%image_caption%' => '',
469 ];
470
471 // Get filename from src
472 if ($src) {
473 $filename = pathinfo($src, PATHINFO_FILENAME);
474 $replacements['%filename%'] = str_replace(['-', '_'], ' ', $filename);
475 }
476
477 // Get attachment data if ID exists
478 if ($attachment_id) {
479 $attachment = get_post($attachment_id);
480 if ($attachment) {
481 $replacements['%image_title%'] = $attachment->post_title;
482 $replacements['%image_caption%'] = $attachment->post_excerpt;
483 }
484 }
485
486 // Apply replacements for every token except the separator.
487 $value = str_replace(array_keys($replacements), array_values($replacements), $format);
488
489 // Split on the separator tokens, drop segments that resolved to empty, then
490 // re-join with the separator symbol. This prevents orphaned/leading/trailing
491 // separators such as "| Site Name" when a token (e.g. %filename%) is empty.
492 $segments = preg_split('/%sep(?:arator)?%/', $value);
493 $segments = array_filter(
494 array_map('trim', $segments),
495 static function ($segment) {
496 return $segment !== '';
497 }
498 );
499 $value = implode(' ' . $this->get_separator() . ' ', $segments);
500
501 // Clean up double spaces if any
502 $value = preg_replace('/\s+/', ' ', $value);
503
504 return trim($value);
505 }
506
507 /**
508 * Get site separator
509 *
510 * @since 1.0.0
511 * @return string
512 */
513 private function get_separator(): string {
514 if ($this->separator === null) {
515 $this->separator = Site_Identity_Manager::get_active_separator_symbol();
516 }
517 return $this->separator;
518 }
519
520 // ─────────────────────────────────────────────────────────────────────
521 // Media Library alt-text persistence (writes _wp_attachment_image_alt)
522 // ─────────────────────────────────────────────────────────────────────
523
524 /**
525 * Generate and save alt text onto a single attachment's Media Library record.
526 *
527 * Uses the same `alt_format` token pipeline as output injection, so the value
528 * matches what the front-end filter would have produced. In this context
529 * `%title%` and `%image_title%` resolve to the attachment's own title.
530 *
531 * @since 1.19.1
532 * @param int $attachment_id The attachment ID.
533 * @param bool $overwrite When false, images that already have alt text are left untouched.
534 * @return bool True when the attachment now has the generated alt text; false when skipped or on failure.
535 */
536 public function fill_attachment_alt(int $attachment_id, bool $overwrite = false): bool {
537 if (!wp_attachment_is_image($attachment_id)) {
538 return false;
539 }
540
541 $existing = (string) get_post_meta($attachment_id, '_wp_attachment_image_alt', true);
542
543 // Non-destructive by default: never clobber hand-written alt text.
544 if (!$overwrite && trim($existing) !== '') {
545 return false;
546 }
547
548 $settings = $this->get_settings('site');
549 $format = $settings['alt_format'] ?? '%filename%';
550 $src = (string) wp_get_attachment_url($attachment_id);
551
552 $value = '';
553
554 // AI describes the picture; the template can only rewrite its filename.
555 // Falls back to the template on any failure so a provider outage
556 // degrades to the old behaviour instead of leaving images bare.
557 if ('ai' === ($settings['alt_source'] ?? 'template')) {
558 $value = $this->generate_ai_alt($attachment_id);
559 }
560
561 if ('' === $value) {
562 // Pass the attachment ID as the post context so %title% falls back to the
563 // attachment's own title (there is no surrounding post here).
564 $value = sanitize_text_field(
565 $this->generate_attribute_value($format, $attachment_id, $attachment_id, 0, $src)
566 );
567 }
568
569 if ($value === '') {
570 return false;
571 }
572
573 if ($existing === $value) {
574 // Already correct — treat as success without a redundant write.
575 return true;
576 }
577
578 return update_post_meta($attachment_id, '_wp_attachment_image_alt', $value) !== false;
579 }
580
581 /**
582 * Describe an attachment with the vision model.
583 *
584 * Never throws: alt text generation runs in batches over a whole media
585 * library, and one unreadable image or a rate-limit blip must not abort
586 * the run. Returns '' so the caller falls back to the template.
587 *
588 * @since 1.28.0
589 * @param int $attachment_id Attachment to describe.
590 * @return string Alt text, or '' when unavailable.
591 */
592 private function generate_ai_alt(int $attachment_id): string {
593 try {
594 $vision = new \ThinkRank\AI\Vision_Client();
595
596 if (!$vision->is_available()) {
597 return '';
598 }
599
600 // The parent post's title disambiguates images that are visually
601 // ambiguous on their own (a generic chart, a product on white).
602 $context = '';
603 $parent = (int) get_post_field('post_parent', $attachment_id);
604 if ($parent > 0) {
605 $context = (string) get_the_title($parent);
606 }
607
608 return sanitize_text_field($vision->describe_attachment($attachment_id, $context));
609 } catch (\Throwable $e) {
610 return '';
611 }
612 }
613
614 /**
615 * Fill alt text across the Media Library in a single batch.
616 *
617 * Iterates images by ascending ID using offset/limit so callers can page
618 * through large libraries without exhausting memory or hitting timeouts.
619 *
620 * @since 1.19.1
621 * @param array $args {
622 * @type int $offset Starting offset into the image set. Default 0.
623 * @type int $limit Batch size (clamped 1–200). Default 50.
624 * @type bool $overwrite Overwrite existing alt text. Default false.
625 * }
626 * @return array {
627 * @type int $total Total images in the library.
628 * @type int $processed Images looked at in this batch.
629 * @type int $updated Images whose alt text was written.
630 * @type int $skipped Images left unchanged (already had alt / no value).
631 * @type int $offset The offset this batch started at.
632 * @type int $next_offset The offset to pass for the next batch.
633 * @type int $remaining Images still to process after this batch.
634 * @type bool $done True when the whole library has been processed.
635 * }
636 */
637 public function bulk_fill_missing_alt(array $args = []): array {
638 $offset = max(0, (int) ($args['offset'] ?? 0));
639 $limit = min(200, max(1, (int) ($args['limit'] ?? 50)));
640 $overwrite = !empty($args['overwrite']);
641
642 // In AI mode every image is a paid provider call that takes seconds,
643 // so a 200-image batch would both surprise the user's bill and blow
644 // past max_execution_time. Cap the batch and let the caller page —
645 // `remaining` already drives that loop.
646 if ('ai' === ($this->get_settings('site')['alt_source'] ?? 'template')) {
647 $limit = min($limit, self::AI_BATCH_LIMIT);
648 }
649
650 $total = $this->count_images();
651
652 $ids = get_posts([
653 'post_type' => 'attachment',
654 'post_mime_type' => 'image',
655 // Must cover the same set count_images() counts, or the pager can
656 // never reach the total. 'inherit' alone excluded private-status
657 // attachments — which media-protection and membership plugins do
658 // create — while count_images() still counted them (#322).
659 'post_status' => ['inherit', 'private', 'publish', 'draft', 'pending', 'future'],
660 'numberposts' => $limit,
661 'offset' => $offset,
662 'fields' => 'ids',
663 'orderby' => 'ID',
664 'order' => 'ASC',
665 // 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).
666 'suppress_filters' => true,
667 ]);
668
669 $updated = 0;
670 $skipped = 0;
671 $processed = 0;
672
673 foreach ($ids as $id) {
674 $processed++;
675 if ($this->fill_attachment_alt((int) $id, $overwrite)) {
676 $updated++;
677 } else {
678 $skipped++;
679 }
680 }
681
682 $next_offset = $offset + count($ids);
683
684 // An empty batch means there is nothing left to walk, whatever the
685 // total claims. Deriving `done` from the count alone let any drift
686 // between the two queries strand the caller on a batch that could
687 // never advance the offset, and the admin UI answers that by
688 // re-requesting up to 10,000 times.
689 $exhausted = empty($ids);
690 $remaining = $exhausted ? 0 : max(0, $total - $next_offset);
691
692 // Bulk writes change the Site SEO Analyzer's "images have alt text" coverage.
693 if ($updated > 0) {
694 $this->flush_analyzer_cache();
695 }
696
697 return [
698 'total' => $total,
699 'processed' => $processed,
700 'updated' => $updated,
701 'skipped' => $skipped,
702 'offset' => $offset,
703 'next_offset' => $next_offset,
704 'remaining' => $remaining,
705 'done' => $exhausted || $next_offset >= $total,
706 ];
707 }
708
709 /**
710 * Media Library alt-text coverage stats for the settings UI.
711 *
712 * @since 1.19.1
713 * @return array{total:int,with_alt:int,missing:int}
714 */
715 public function get_media_alt_stats(): array {
716 $total = $this->count_images();
717 $with_alt = $this->count_images_with_alt();
718
719 return [
720 'total' => $total,
721 'with_alt' => $with_alt,
722 'missing' => max(0, $total - $with_alt),
723 ];
724 }
725
726 /**
727 * Auto-fill hook target — save alt text for a freshly uploaded image.
728 *
729 * Gated by the `save_alt_to_media` + `auto_fill_on_upload` settings so it is a
730 * no-op unless the feature is enabled. Respects the overwrite preference.
731 *
732 * @since 1.19.1
733 * @param int $attachment_id The newly created attachment ID.
734 * @return void
735 */
736 public function maybe_auto_fill_on_upload(int $attachment_id): void {
737 $settings = $this->get_settings('site');
738
739 if (empty($settings['save_alt_to_media']) || empty($settings['auto_fill_on_upload'])) {
740 return;
741 }
742
743 if (!wp_attachment_is_image($attachment_id)) {
744 return;
745 }
746
747 $this->fill_attachment_alt($attachment_id, !empty($settings['media_alt_overwrite']));
748 }
749
750 /**
751 * Total number of image attachments in the library.
752 *
753 * Counted with an explicit `post_status != 'trash'` rather than through
754 * wp_count_attachments(). The helper applies that filter internally, which
755 * looked equivalent — but it left the two halves of get_media_alt_stats()
756 * with different notions of which images exist, and only one of them said
757 * so out loud. Spelling the filter out here keeps this query and
758 * count_images_with_alt() visibly in step (#321).
759 *
760 * @since 1.19.1
761 * @return int
762 */
763 private function count_images(): int {
764 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- indexed COUNT; short-lived admin action
765 return (int) $this->wpdb->get_var(
766 "SELECT COUNT(*) FROM {$this->wpdb->posts}
767 WHERE post_type = 'attachment'
768 AND post_mime_type LIKE 'image/%'
769 AND post_status != 'trash'"
770 );
771 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
772 }
773
774 /**
775 * Number of image attachments that already have non-empty alt text.
776 *
777 * Carries the same `post_status != 'trash'` filter as count_images(), so a
778 * trashed image can never be counted as covered against a total it is not
779 * part of. Matches the Site SEO Analyzer's alt-text check, which applies
780 * the same filter to both of its counts.
781 *
782 * @since 1.19.1
783 * @return int
784 */
785 private function count_images_with_alt(): int {
786 // 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
787 return (int) $this->wpdb->get_var(
788 "SELECT COUNT(DISTINCT p.ID) FROM {$this->wpdb->posts} p
789 INNER JOIN {$this->wpdb->postmeta} pm
790 ON pm.post_id = p.ID
791 AND pm.meta_key = '_wp_attachment_image_alt'
792 AND pm.meta_value != ''
793 WHERE p.post_type = 'attachment'
794 AND p.post_mime_type LIKE 'image/%'
795 AND p.post_status != 'trash'"
796 );
797 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
798 }
799
800 /**
801 * Bust the Site SEO Analyzer's cached result so its alt-text coverage refreshes.
802 *
803 * Uses the analyzer's transient key directly to avoid instantiating it here.
804 *
805 * @since 1.19.1
806 * @return void
807 */
808 private function flush_analyzer_cache(): void {
809 // Ask the analyzer rather than duplicating its transient key here — the
810 // literal drifted out of sync the moment anyone renamed it.
811 if (class_exists('ThinkRank\\SEO\\SEO_Analyzer')) {
812 (new SEO_Analyzer())->flush_cache();
813 }
814 }
815 }
816