PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.1.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.1.1
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.1.1, at includes/seo/class-image-seo-manager.php

808 lines 30.9 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 '%title%' => $post_id > 0 ? get_the_title($post_id) : get_bloginfo('name'),
460 '%count%' => (string) $count,
461 '%filename%' => '',
462 '%image_title%' => '',
463 '%image_caption%' => '',
464 ];
465
466 // Get filename from src
467 if ($src) {
468 $filename = pathinfo($src, PATHINFO_FILENAME);
469 $replacements['%filename%'] = str_replace(['-', '_'], ' ', $filename);
470 }
471
472 // Get attachment data if ID exists
473 if ($attachment_id) {
474 $attachment = get_post($attachment_id);
475 if ($attachment) {
476 $replacements['%image_title%'] = $attachment->post_title;
477 $replacements['%image_caption%'] = $attachment->post_excerpt;
478 }
479 }
480
481 // Apply replacements for every token except the separator.
482 $value = str_replace(array_keys($replacements), array_values($replacements), $format);
483
484 // Split on the separator tokens, drop segments that resolved to empty, then
485 // re-join with the separator symbol. This prevents orphaned/leading/trailing
486 // separators such as "| Site Name" when a token (e.g. %filename%) is empty.
487 $segments = preg_split('/%sep(?:arator)?%/', $value);
488 $segments = array_filter(
489 array_map('trim', $segments),
490 static function ($segment) {
491 return $segment !== '';
492 }
493 );
494 $value = implode(' ' . $this->get_separator() . ' ', $segments);
495
496 // Clean up double spaces if any
497 $value = preg_replace('/\s+/', ' ', $value);
498
499 return trim($value);
500 }
501
502 /**
503 * Get site separator
504 *
505 * @since 1.0.0
506 * @return string
507 */
508 private function get_separator(): string {
509 if ($this->separator === null) {
510 $this->separator = Site_Identity_Manager::get_active_separator_symbol();
511 }
512 return $this->separator;
513 }
514
515 // ─────────────────────────────────────────────────────────────────────
516 // Media Library alt-text persistence (writes _wp_attachment_image_alt)
517 // ─────────────────────────────────────────────────────────────────────
518
519 /**
520 * Generate and save alt text onto a single attachment's Media Library record.
521 *
522 * Uses the same `alt_format` token pipeline as output injection, so the value
523 * matches what the front-end filter would have produced. In this context
524 * `%title%` and `%image_title%` resolve to the attachment's own title.
525 *
526 * @since 1.19.1
527 * @param int $attachment_id The attachment ID.
528 * @param bool $overwrite When false, images that already have alt text are left untouched.
529 * @return bool True when the attachment now has the generated alt text; false when skipped or on failure.
530 */
531 public function fill_attachment_alt(int $attachment_id, bool $overwrite = false): bool {
532 if (!wp_attachment_is_image($attachment_id)) {
533 return false;
534 }
535
536 $existing = (string) get_post_meta($attachment_id, '_wp_attachment_image_alt', true);
537
538 // Non-destructive by default: never clobber hand-written alt text.
539 if (!$overwrite && trim($existing) !== '') {
540 return false;
541 }
542
543 $settings = $this->get_settings('site');
544 $format = $settings['alt_format'] ?? '%filename%';
545 $src = (string) wp_get_attachment_url($attachment_id);
546
547 $value = '';
548
549 // AI describes the picture; the template can only rewrite its filename.
550 // Falls back to the template on any failure so a provider outage
551 // degrades to the old behaviour instead of leaving images bare.
552 if ('ai' === ($settings['alt_source'] ?? 'template')) {
553 $value = $this->generate_ai_alt($attachment_id);
554 }
555
556 if ('' === $value) {
557 // Pass the attachment ID as the post context so %title% falls back to the
558 // attachment's own title (there is no surrounding post here).
559 $value = sanitize_text_field(
560 $this->generate_attribute_value($format, $attachment_id, $attachment_id, 0, $src)
561 );
562 }
563
564 if ($value === '') {
565 return false;
566 }
567
568 if ($existing === $value) {
569 // Already correct — treat as success without a redundant write.
570 return true;
571 }
572
573 return update_post_meta($attachment_id, '_wp_attachment_image_alt', $value) !== false;
574 }
575
576 /**
577 * Describe an attachment with the vision model.
578 *
579 * Never throws: alt text generation runs in batches over a whole media
580 * library, and one unreadable image or a rate-limit blip must not abort
581 * the run. Returns '' so the caller falls back to the template.
582 *
583 * @since 1.28.0
584 * @param int $attachment_id Attachment to describe.
585 * @return string Alt text, or '' when unavailable.
586 */
587 private function generate_ai_alt(int $attachment_id): string {
588 try {
589 $vision = new \ThinkRank\AI\Vision_Client();
590
591 if (!$vision->is_available()) {
592 return '';
593 }
594
595 // The parent post's title disambiguates images that are visually
596 // ambiguous on their own (a generic chart, a product on white).
597 $context = '';
598 $parent = (int) get_post_field('post_parent', $attachment_id);
599 if ($parent > 0) {
600 $context = (string) get_the_title($parent);
601 }
602
603 return sanitize_text_field($vision->describe_attachment($attachment_id, $context));
604 } catch (\Throwable $e) {
605 return '';
606 }
607 }
608
609 /**
610 * Fill alt text across the Media Library in a single batch.
611 *
612 * Iterates images by ascending ID using offset/limit so callers can page
613 * through large libraries without exhausting memory or hitting timeouts.
614 *
615 * @since 1.19.1
616 * @param array $args {
617 * @type int $offset Starting offset into the image set. Default 0.
618 * @type int $limit Batch size (clamped 1–200). Default 50.
619 * @type bool $overwrite Overwrite existing alt text. Default false.
620 * }
621 * @return array {
622 * @type int $total Total images in the library.
623 * @type int $processed Images looked at in this batch.
624 * @type int $updated Images whose alt text was written.
625 * @type int $skipped Images left unchanged (already had alt / no value).
626 * @type int $offset The offset this batch started at.
627 * @type int $next_offset The offset to pass for the next batch.
628 * @type int $remaining Images still to process after this batch.
629 * @type bool $done True when the whole library has been processed.
630 * }
631 */
632 public function bulk_fill_missing_alt(array $args = []): array {
633 $offset = max(0, (int) ($args['offset'] ?? 0));
634 $limit = min(200, max(1, (int) ($args['limit'] ?? 50)));
635 $overwrite = !empty($args['overwrite']);
636
637 // In AI mode every image is a paid provider call that takes seconds,
638 // so a 200-image batch would both surprise the user's bill and blow
639 // past max_execution_time. Cap the batch and let the caller page —
640 // `remaining` already drives that loop.
641 if ('ai' === ($this->get_settings('site')['alt_source'] ?? 'template')) {
642 $limit = min($limit, self::AI_BATCH_LIMIT);
643 }
644
645 $total = $this->count_images();
646
647 $ids = get_posts([
648 'post_type' => 'attachment',
649 'post_mime_type' => 'image',
650 // Must cover the same set count_images() counts, or the pager can
651 // never reach the total. 'inherit' alone excluded private-status
652 // attachments — which media-protection and membership plugins do
653 // create — while count_images() still counted them (#322).
654 'post_status' => ['inherit', 'private', 'publish', 'draft', 'pending', 'future'],
655 'numberposts' => $limit,
656 'offset' => $offset,
657 'fields' => 'ids',
658 'orderby' => 'ID',
659 'order' => 'ASC',
660 // 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).
661 'suppress_filters' => true,
662 ]);
663
664 $updated = 0;
665 $skipped = 0;
666 $processed = 0;
667
668 foreach ($ids as $id) {
669 $processed++;
670 if ($this->fill_attachment_alt((int) $id, $overwrite)) {
671 $updated++;
672 } else {
673 $skipped++;
674 }
675 }
676
677 $next_offset = $offset + count($ids);
678
679 // An empty batch means there is nothing left to walk, whatever the
680 // total claims. Deriving `done` from the count alone let any drift
681 // between the two queries strand the caller on a batch that could
682 // never advance the offset, and the admin UI answers that by
683 // re-requesting up to 10,000 times.
684 $exhausted = empty($ids);
685 $remaining = $exhausted ? 0 : max(0, $total - $next_offset);
686
687 // Bulk writes change the Site SEO Analyzer's "images have alt text" coverage.
688 if ($updated > 0) {
689 $this->flush_analyzer_cache();
690 }
691
692 return [
693 'total' => $total,
694 'processed' => $processed,
695 'updated' => $updated,
696 'skipped' => $skipped,
697 'offset' => $offset,
698 'next_offset' => $next_offset,
699 'remaining' => $remaining,
700 'done' => $exhausted || $next_offset >= $total,
701 ];
702 }
703
704 /**
705 * Media Library alt-text coverage stats for the settings UI.
706 *
707 * @since 1.19.1
708 * @return array{total:int,with_alt:int,missing:int}
709 */
710 public function get_media_alt_stats(): array {
711 $total = $this->count_images();
712 $with_alt = $this->count_images_with_alt();
713
714 return [
715 'total' => $total,
716 'with_alt' => $with_alt,
717 'missing' => max(0, $total - $with_alt),
718 ];
719 }
720
721 /**
722 * Auto-fill hook target — save alt text for a freshly uploaded image.
723 *
724 * Gated by the `save_alt_to_media` + `auto_fill_on_upload` settings so it is a
725 * no-op unless the feature is enabled. Respects the overwrite preference.
726 *
727 * @since 1.19.1
728 * @param int $attachment_id The newly created attachment ID.
729 * @return void
730 */
731 public function maybe_auto_fill_on_upload(int $attachment_id): void {
732 $settings = $this->get_settings('site');
733
734 if (empty($settings['save_alt_to_media']) || empty($settings['auto_fill_on_upload'])) {
735 return;
736 }
737
738 if (!wp_attachment_is_image($attachment_id)) {
739 return;
740 }
741
742 $this->fill_attachment_alt($attachment_id, !empty($settings['media_alt_overwrite']));
743 }
744
745 /**
746 * Total number of image attachments in the library.
747 *
748 * Counted with an explicit `post_status != 'trash'` rather than through
749 * wp_count_attachments(). The helper applies that filter internally, which
750 * looked equivalent — but it left the two halves of get_media_alt_stats()
751 * with different notions of which images exist, and only one of them said
752 * so out loud. Spelling the filter out here keeps this query and
753 * count_images_with_alt() visibly in step (#321).
754 *
755 * @since 1.19.1
756 * @return int
757 */
758 private function count_images(): int {
759 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- indexed COUNT; short-lived admin action
760 return (int) $this->wpdb->get_var(
761 "SELECT COUNT(*) FROM {$this->wpdb->posts}
762 WHERE post_type = 'attachment'
763 AND post_mime_type LIKE 'image/%'
764 AND post_status != 'trash'"
765 );
766 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
767 }
768
769 /**
770 * Number of image attachments that already have non-empty alt text.
771 *
772 * Carries the same `post_status != 'trash'` filter as count_images(), so a
773 * trashed image can never be counted as covered against a total it is not
774 * part of. Matches the Site SEO Analyzer's alt-text check, which applies
775 * the same filter to both of its counts.
776 *
777 * @since 1.19.1
778 * @return int
779 */
780 private function count_images_with_alt(): int {
781 // 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
782 return (int) $this->wpdb->get_var(
783 "SELECT COUNT(DISTINCT p.ID) FROM {$this->wpdb->posts} p
784 INNER JOIN {$this->wpdb->postmeta} pm
785 ON pm.post_id = p.ID
786 AND pm.meta_key = '_wp_attachment_image_alt'
787 AND pm.meta_value != ''
788 WHERE p.post_type = 'attachment'
789 AND p.post_mime_type LIKE 'image/%'
790 AND p.post_status != 'trash'"
791 );
792 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
793 }
794
795 /**
796 * Bust the Site SEO Analyzer's cached result so its alt-text coverage refreshes.
797 *
798 * Uses the analyzer's transient key directly to avoid instantiating it here.
799 *
800 * @since 1.19.1
801 * @return void
802 */
803 private function flush_analyzer_cache(): void {
804 // Matches SEO_Analyzer::CACHE_KEY.
805 delete_transient('thinkrank_site_seo_analysis');
806 }
807 }
808