| @@ -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. |
| @@ -40,8 +51,33 @@ | ||
| 40 | 51 | */ |
| 41 | 52 | private ?string $separator = null; |
| 42 | 53 | |
| 43 | 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 | + /** | |
| 44 | 80 | * Constructor |
| 45 | 81 | * |
| 46 | 82 | * @since 1.0.0 |
| 47 | 83 | */ |
| @@ -81,8 +117,20 @@ | ||
| 81 | 117 | $validation['valid'] = false; |
| 82 | 118 | } |
| 83 | 119 | } |
| 84 | 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 | + | |
| 85 | 133 | return $validation; |
| 86 | 134 | } |
| 87 | 135 | |
| 88 | 136 | /** |
| @@ -105,8 +153,19 @@ | ||
| 105 | 153 | * |
| 106 | 154 | * @param string $context_type The context type to get defaults for |
| 107 | 155 | * @return array Default settings array |
| 108 | 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 | + | |
| 109 | 168 | public function get_default_settings(string $context_type): array { |
| 110 | 169 | return [ |
| 111 | 170 | 'add_missing_alt' => false, |
| 112 | 171 | 'alt_format' => '%filename%', |
| @@ -115,8 +174,11 @@ | ||
| 115 | 174 | // Media Library alt persistence (writes _wp_attachment_image_alt) |
| 116 | 175 | 'save_alt_to_media' => false, |
| 117 | 176 | 'auto_fill_on_upload' => false, |
| 118 | 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', | |
| 119 | 181 | ]; |
| 120 | 182 | } |
| 121 | 183 | |
| 122 | 184 | /** |
| @@ -152,8 +214,15 @@ | ||
| 152 | 214 | 'title' => __('Title attribute format', 'thinkrank'), |
| 153 | 215 | 'description' => __('The format to use for automatically generated TITLE attributes.', 'thinkrank'), |
| 154 | 216 | 'default' => '%title% %separator% %sitename%' |
| 155 | 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 | + ], | |
| 156 | 225 | 'save_alt_to_media' => [ |
| 157 | 226 | 'type' => 'boolean', |
| 158 | 227 | 'title' => __('Save alt text to the Media Library', 'thinkrank'), |
| 159 | 228 | 'description' => __('Persist generated alt text onto the attachment record so it works everywhere, not just in rendered content.', 'thinkrank'), |
| @@ -225,9 +294,11 @@ | ||
| 225 | 294 | // filename-based formats and for images that need no injection. |
| 226 | 295 | $needs_attachment = |
| 227 | 296 | ($alt_missing && $this->format_uses_attachment($settings['alt_format'] ?? '')) || |
| 228 | 297 | ($title_missing && $this->format_uses_attachment($settings['title_format'] ?? '')); |
| 229 | - $attachment_id = $needs_attachment ? $this->url_to_attachment_id($src) : 0; | |
| 298 | + $attachment_id = $needs_attachment | |
| 299 | + ? $this->url_to_attachment_id($src, (string) ($attributes['class'] ?? '')) | |
| 300 | + : 0; | |
| 230 | 301 | |
| 231 | 302 | // Handle ALT attribute |
| 232 | 303 | if ($alt_missing) { |
| 233 | 304 | $alt_val = $this->generate_attribute_value($settings['alt_format'] ?? '', $attachment_id, $id_to_pass, $count, $src); |
| @@ -334,28 +405,23 @@ | ||
| 334 | 405 | || strpos($format, '%image_caption%') !== false; |
| 335 | 406 | } |
| 336 | 407 | |
| 337 | 408 | /** |
| 338 | - * Resolve an attachment ID from a source URL, memoized per request. | |
| 409 | + * Resolve an attachment ID from a source URL. | |
| 339 | 410 | * |
| 340 | - * `attachment_url_to_postid()` issues its own DB query, so repeated identical | |
| 341 | - * URLs on a page (galleries, duplicated images) are cached here. | |
| 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). | |
| 342 | 416 | * |
| 343 | 417 | * @since 1.19.1 |
| 344 | - * @param string $src Source URL. | |
| 418 | + * @param string $src Source URL. | |
| 419 | + * @param string $classes The image's class attribute, for its wp-image-{ID}. | |
| 345 | 420 | * @return int Attachment ID, or 0 if not a media-library image. |
| 346 | 421 | */ |
| 347 | - private function url_to_attachment_id(string $src): int { | |
| 348 | - if ($src === '') { | |
| 349 | - return 0; | |
| 350 | - } | |
| 351 | - | |
| 352 | - static $cache = []; | |
| 353 | - if (!array_key_exists($src, $cache)) { | |
| 354 | - $cache[$src] = attachment_url_to_postid($src); | |
| 355 | - } | |
| 356 | - | |
| 357 | - return $cache[$src]; | |
| 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)); | |
| 358 | 424 | } |
| 359 | 425 | |
| 360 | 426 | /** |
| 361 | 427 | * Inject (or replace an empty) alt/title attribute on a single <img> tag. |
| @@ -411,9 +477,14 @@ | ||
| 411 | 477 | private function generate_attribute_value(string $format, int $attachment_id, int $post_id, int $count, string $src): string { |
| 412 | 478 | $replacements = [ |
| 413 | 479 | '%site_title%' => get_bloginfo('name'), |
| 414 | 480 | '%sitename%' => get_bloginfo('name'), |
| 415 | - '%title%' => $post_id > 0 ? get_the_title($post_id) : 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) : '', | |
| 416 | 487 | '%count%' => (string) $count, |
| 417 | 488 | '%filename%' => '', |
| 418 | 489 | '%image_title%' => '', |
| 419 | 490 | '%image_caption%' => '', |
| @@ -481,11 +552,13 @@ | ||
| 481 | 552 | * |
| 482 | 553 | * @since 1.19.1 |
| 483 | 554 | * @param int $attachment_id The attachment ID. |
| 484 | 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. | |
| 485 | 558 | * @return bool True when the attachment now has the generated alt text; false when skipped or on failure. |
| 486 | 559 | */ |
| 487 | - public function fill_attachment_alt(int $attachment_id, bool $overwrite = false): bool { | |
| 560 | + public function fill_attachment_alt(int $attachment_id, bool $overwrite = false, bool $purge = true): bool { | |
| 488 | 561 | if (!wp_attachment_is_image($attachment_id)) { |
| 489 | 562 | return false; |
| 490 | 563 | } |
| 491 | 564 | |
| @@ -499,14 +572,25 @@ | ||
| 499 | 572 | $settings = $this->get_settings('site'); |
| 500 | 573 | $format = $settings['alt_format'] ?? '%filename%'; |
| 501 | 574 | $src = (string) wp_get_attachment_url($attachment_id); |
| 502 | 575 | |
| 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 | - ); | |
| 576 | + $value = ''; | |
| 508 | 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 | + | |
| 509 | 593 | if ($value === '') { |
| 510 | 594 | return false; |
| 511 | 595 | } |
| 512 | 596 | |
| @@ -514,12 +598,139 @@ | ||
| 514 | 598 | // Already correct — treat as success without a redundant write. |
| 515 | 599 | return true; |
| 516 | 600 | } |
| 517 | 601 | |
| 518 | - return update_post_meta($attachment_id, '_wp_attachment_image_alt', $value) !== false; | |
| 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; | |
| 519 | 609 | } |
| 520 | 610 | |
| 521 | 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 | + /** | |
| 522 | 733 | * Fill alt text across the Media Library in a single batch. |
| 523 | 734 | * |
| 524 | 735 | * Iterates images by ascending ID using offset/limit so callers can page |
| 525 | 736 | * through large libraries without exhausting memory or hitting timeouts. |
| @@ -545,38 +756,72 @@ | ||
| 545 | 756 | $offset = max(0, (int) ($args['offset'] ?? 0)); |
| 546 | 757 | $limit = min(200, max(1, (int) ($args['limit'] ?? 50))); |
| 547 | 758 | $overwrite = !empty($args['overwrite']); |
| 548 | 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 | + | |
| 549 | 768 | $total = $this->count_images(); |
| 550 | 769 | |
| 551 | 770 | $ids = get_posts([ |
| 552 | - 'post_type' => 'attachment', | |
| 553 | - 'post_mime_type' => 'image', | |
| 554 | - 'post_status' => 'inherit', | |
| 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'], | |
| 555 | 778 | 'numberposts' => $limit, |
| 556 | 779 | 'offset' => $offset, |
| 557 | 780 | 'fields' => 'ids', |
| 558 | 781 | 'orderby' => 'ID', |
| 559 | 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). | |
| 560 | 784 | 'suppress_filters' => true, |
| 561 | 785 | ]); |
| 562 | 786 | |
| 563 | - $updated = 0; | |
| 564 | - $skipped = 0; | |
| 565 | 787 | $processed = 0; |
| 566 | 788 | |
| 567 | - foreach ($ids as $id) { | |
| 568 | - $processed++; | |
| 569 | - if ($this->fill_attachment_alt((int) $id, $overwrite)) { | |
| 570 | - $updated++; | |
| 571 | - } else { | |
| 572 | - $skipped++; | |
| 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); | |
| 573 | 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); | |
| 574 | 801 | } |
| 575 | 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 | + | |
| 576 | 814 | $next_offset = $offset + count($ids); |
| 577 | - $remaining = max(0, $total - $next_offset); | |
| 578 | 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 | + | |
| 579 | 824 | // Bulk writes change the Site SEO Analyzer's "images have alt text" coverage. |
| 580 | 825 | if ($updated > 0) { |
| 581 | 826 | $this->flush_analyzer_cache(); |
| 582 | 827 | } |
| @@ -588,9 +833,13 @@ | ||
| 588 | 833 | 'skipped' => $skipped, |
| 589 | 834 | 'offset' => $offset, |
| 590 | 835 | 'next_offset' => $next_offset, |
| 591 | 836 | 'remaining' => $remaining, |
| 592 | - 'done' => $next_offset >= $total, | |
| 837 | + 'done' => $exhausted || $next_offset >= $total, | |
| 838 | + 'cache' => [ | |
| 839 | + 'posts_purged' => count($cache['posts']), | |
| 840 | + 'warnings' => $cache['warnings'], | |
| 841 | + ], | |
| 593 | 842 | ]; |
| 594 | 843 | } |
| 595 | 844 | |
| 596 | 845 | /** |
| @@ -630,41 +879,51 @@ | ||
| 630 | 879 | if (!wp_attachment_is_image($attachment_id)) { |
| 631 | 880 | return; |
| 632 | 881 | } |
| 633 | 882 | |
| 634 | - $this->fill_attachment_alt($attachment_id, !empty($settings['media_alt_overwrite'])); | |
| 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); | |
| 635 | 887 | } |
| 636 | 888 | |
| 637 | 889 | /** |
| 638 | 890 | * Total number of image attachments in the library. |
| 639 | 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 | + * | |
| 640 | 899 | * @since 1.19.1 |
| 641 | 900 | * @return int |
| 642 | 901 | */ |
| 643 | 902 | 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; | |
| 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 | |
| 654 | 911 | } |
| 655 | 912 | |
| 656 | 913 | /** |
| 657 | 914 | * Number of image attachments that already have non-empty alt text. |
| 658 | 915 | * |
| 659 | - * Mirrors the query used by the Site SEO Analyzer's alt-text check so the two | |
| 660 | - * features report consistent coverage. | |
| 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. | |
| 661 | 920 | * |
| 662 | 921 | * @since 1.19.1 |
| 663 | 922 | * @return int |
| 664 | 923 | */ |
| 665 | 924 | 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 | |
| 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 | |
| 667 | 926 | return (int) $this->wpdb->get_var( |
| 668 | 927 | "SELECT COUNT(DISTINCT p.ID) FROM {$this->wpdb->posts} p |
| 669 | 928 | INNER JOIN {$this->wpdb->postmeta} pm |
| 670 | 929 | ON pm.post_id = p.ID |
| @@ -669,10 +928,13 @@ | ||
| 669 | 928 | INNER JOIN {$this->wpdb->postmeta} pm |
| 670 | 929 | ON pm.post_id = p.ID |
| 671 | 930 | AND pm.meta_key = '_wp_attachment_image_alt' |
| 672 | 931 | AND pm.meta_value != '' |
| 673 | - WHERE p.post_type = 'attachment' AND p.post_mime_type LIKE 'image/%'" | |
| 932 | + WHERE p.post_type = 'attachment' | |
| 933 | + AND p.post_mime_type LIKE 'image/%' | |
| 934 | + AND p.post_status != 'trash'" | |
| 674 | 935 | ); |
| 936 | + // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared | |
| 675 | 937 | } |
| 676 | 938 | |
| 677 | 939 | /** |
| 678 | 940 | * Bust the Site SEO Analyzer's cached result so its alt-text coverage refreshes. |
| @@ -682,8 +944,11 @@ | ||
| 682 | 944 | * @since 1.19.1 |
| 683 | 945 | * @return void |
| 684 | 946 | */ |
| 685 | 947 | private function flush_analyzer_cache(): void { |
| 686 | - // Matches SEO_Analyzer::CACHE_KEY. | |
| 687 | - delete_transient('thinkrank_site_seo_analysis'); | |
| 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 | + } | |
| 688 | 953 | } |
| 689 | 954 | } |