| @@ -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,29 +749,36 @@ | ||
| 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 | */ |
| @@ -669,9 +789,11 @@ | ||
| 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 | ); |
| 675 | 797 | // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared |
| 676 | 798 | } |
| 677 | 799 | |
| @@ -683,8 +805,11 @@ | ||
| 683 | 805 | * @since 1.19.1 |
| 684 | 806 | * @return void |
| 685 | 807 | */ |
| 686 | 808 | private function flush_analyzer_cache(): void { |
| 687 | - // Matches SEO_Analyzer::CACHE_KEY. | |
| 688 | - 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 | + } | |
| 689 | 814 | } |
| 690 | 815 | } |