PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.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 1.0.0 All 52 releases
← All changes | includes/seo/class-image-seo-manager.php +153 -27 1.25.0 → 2.10.0 View file →
@@ -29,8 +29,19 @@
29 29 */
30 30 class Image_SEO_Manager extends Abstract_SEO_Manager {
31 31
32 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 + /**
33 44 * Memoized site separator symbol.
34 45 *
35 46 * Resolved once per request rather than on every image processed during
36 47 * the_content, since the separator is a site-wide option.
@@ -81,8 +92,20 @@
81 92 $validation['valid'] = false;
82 93 }
83 94 }
84 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 +
85 108 return $validation;
86 109 }
87 110
88 111 /**
@@ -105,8 +128,19 @@
105 128 *
106 129 * @param string $context_type The context type to get defaults for
107 130 * @return array Default settings array
108 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 +
109 143 public function get_default_settings(string $context_type): array {
110 144 return [
111 145 'add_missing_alt' => false,
112 146 'alt_format' => '%filename%',
@@ -115,8 +149,11 @@
115 149 // Media Library alt persistence (writes _wp_attachment_image_alt)
116 150 'save_alt_to_media' => false,
117 151 'auto_fill_on_upload' => false,
118 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',
119 156 ];
120 157 }
121 158
122 159 /**
@@ -152,8 +189,15 @@
152 189 'title' => __('Title attribute format', 'thinkrank'),
153 190 'description' => __('The format to use for automatically generated TITLE attributes.', 'thinkrank'),
154 191 'default' => '%title% %separator% %sitename%'
155 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 + ],
156 200 'save_alt_to_media' => [
157 201 'type' => 'boolean',
158 202 'title' => __('Save alt text to the Media Library', 'thinkrank'),
159 203 'description' => __('Persist generated alt text onto the attachment record so it works everywhere, not just in rendered content.', 'thinkrank'),
@@ -411,9 +455,14 @@
411 455 private function generate_attribute_value(string $format, int $attachment_id, int $post_id, int $count, string $src): string {
412 456 $replacements = [
413 457 '%site_title%' => get_bloginfo('name'),
414 458 '%sitename%' => get_bloginfo('name'),
415 - '%title%' => $post_id > 0 ? get_the_title($post_id) : 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) : '',
416 465 '%count%' => (string) $count,
417 466 '%filename%' => '',
418 467 '%image_title%' => '',
419 468 '%image_caption%' => '',
@@ -499,14 +548,25 @@
499 548 $settings = $this->get_settings('site');
500 549 $format = $settings['alt_format'] ?? '%filename%';
501 550 $src = (string) wp_get_attachment_url($attachment_id);
502 551
503 - // Pass the attachment ID as the post context so %title% falls back to the
504 - // attachment's own title (there is no surrounding post here).
505 - $value = sanitize_text_field(
506 - $this->generate_attribute_value($format, $attachment_id, $attachment_id, 0, $src)
507 - );
552 + $value = '';
508 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 +
509 569 if ($value === '') {
510 570 return false;
511 571 }
512 572
@@ -518,8 +578,41 @@
518 578 return update_post_meta($attachment_id, '_wp_attachment_image_alt', $value) !== false;
519 579 }
520 580
521 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 + /**
522 615 * Fill alt text across the Media Library in a single batch.
523 616 *
524 617 * Iterates images by ascending ID using offset/limit so callers can page
525 618 * through large libraries without exhausting memory or hitting timeouts.
@@ -545,19 +638,32 @@
545 638 $offset = max(0, (int) ($args['offset'] ?? 0));
546 639 $limit = min(200, max(1, (int) ($args['limit'] ?? 50)));
547 640 $overwrite = !empty($args['overwrite']);
548 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 +
549 650 $total = $this->count_images();
550 651
551 652 $ids = get_posts([
552 - 'post_type' => 'attachment',
553 - 'post_mime_type' => 'image',
554 - 'post_status' => 'inherit',
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'],
555 660 'numberposts' => $limit,
556 661 'offset' => $offset,
557 662 'fields' => 'ids',
558 663 'orderby' => 'ID',
559 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).
560 666 'suppress_filters' => true,
561 667 ]);
562 668
563 669 $updated = 0;
@@ -573,10 +679,17 @@
573 679 }
574 680 }
575 681
576 682 $next_offset = $offset + count($ids);
577 - $remaining = max(0, $total - $next_offset);
578 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 +
579 692 // Bulk writes change the Site SEO Analyzer's "images have alt text" coverage.
580 693 if ($updated > 0) {
581 694 $this->flush_analyzer_cache();
582 695 }
@@ -588,9 +701,9 @@
588 701 'skipped' => $skipped,
589 702 'offset' => $offset,
590 703 'next_offset' => $next_offset,
591 704 'remaining' => $remaining,
592 - 'done' => $next_offset >= $total,
705 + 'done' => $exhausted || $next_offset >= $total,
593 706 ];
594 707 }
595 708
596 709 /**
@@ -636,35 +749,42 @@
636 749
637 750 /**
638 751 * Total number of image attachments in the library.
639 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 + *
640 760 * @since 1.19.1
641 761 * @return int
642 762 */
643 763 private function count_images(): int {
644 - $counts = wp_count_attachments();
645 - $total = 0;
646 -
647 - foreach ((array) $counts as $mime => $count) {
648 - if (strpos((string) $mime, 'image/') === 0) {
649 - $total += (int) $count;
650 - }
651 - }
652 -
653 - return $total;
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
654 772 }
655 773
656 774 /**
657 775 * Number of image attachments that already have non-empty alt text.
658 776 *
659 - * Mirrors the query used by the Site SEO Analyzer's alt-text check so the two
660 - * features report consistent coverage.
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.
661 781 *
662 782 * @since 1.19.1
663 783 * @return int
664 784 */
665 785 private function count_images_with_alt(): int {
666 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- indexed COUNT via postmeta meta_key index; short-lived admin action
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
667 787 return (int) $this->wpdb->get_var(
668 788 "SELECT COUNT(DISTINCT p.ID) FROM {$this->wpdb->posts} p
669 789 INNER JOIN {$this->wpdb->postmeta} pm
670 790 ON pm.post_id = p.ID
@@ -669,10 +789,13 @@
669 789 INNER JOIN {$this->wpdb->postmeta} pm
670 790 ON pm.post_id = p.ID
671 791 AND pm.meta_key = '_wp_attachment_image_alt'
672 792 AND pm.meta_value != ''
673 - WHERE p.post_type = 'attachment' AND p.post_mime_type LIKE 'image/%'"
793 + WHERE p.post_type = 'attachment'
794 + AND p.post_mime_type LIKE 'image/%'
795 + AND p.post_status != 'trash'"
674 796 );
797 + // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
675 798 }
676 799
677 800 /**
678 801 * Bust the Site SEO Analyzer's cached result so its alt-text coverage refreshes.
@@ -682,8 +805,11 @@
682 805 * @since 1.19.1
683 806 * @return void
684 807 */
685 808 private function flush_analyzer_cache(): void {
686 - // Matches SEO_Analyzer::CACHE_KEY.
687 - delete_transient('thinkrank_site_seo_analysis');
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 + }
688 814 }
689 815 }