| @@ -51,8 +51,33 @@ | ||
| 51 | 51 | */ |
| 52 | 52 | private ?string $separator = null; |
| 53 | 53 | |
| 54 | 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 | + /** | |
| 55 | 80 | * Constructor |
| 56 | 81 | * |
| 57 | 82 | * @since 1.0.0 |
| 58 | 83 | */ |
| @@ -192,9 +217,9 @@ | ||
| 192 | 217 | ], |
| 193 | 218 | 'alt_source' => [ |
| 194 | 219 | 'type' => 'string', |
| 195 | 220 | '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'), | |
| 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'), | |
| 197 | 222 | 'default' => 'template', |
| 198 | 223 | 'enum' => self::ALT_SOURCES |
| 199 | 224 | ], |
| 200 | 225 | 'save_alt_to_media' => [ |
| @@ -269,9 +294,11 @@ | ||
| 269 | 294 | // filename-based formats and for images that need no injection. |
| 270 | 295 | $needs_attachment = |
| 271 | 296 | ($alt_missing && $this->format_uses_attachment($settings['alt_format'] ?? '')) || |
| 272 | 297 | ($title_missing && $this->format_uses_attachment($settings['title_format'] ?? '')); |
| 273 | - $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; | |
| 274 | 301 | |
| 275 | 302 | // Handle ALT attribute |
| 276 | 303 | if ($alt_missing) { |
| 277 | 304 | $alt_val = $this->generate_attribute_value($settings['alt_format'] ?? '', $attachment_id, $id_to_pass, $count, $src); |
| @@ -378,28 +405,23 @@ | ||
| 378 | 405 | || strpos($format, '%image_caption%') !== false; |
| 379 | 406 | } |
| 380 | 407 | |
| 381 | 408 | /** |
| 382 | - * Resolve an attachment ID from a source URL, memoized per request. | |
| 409 | + * Resolve an attachment ID from a source URL. | |
| 383 | 410 | * |
| 384 | - * `attachment_url_to_postid()` issues its own DB query, so repeated identical | |
| 385 | - * 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). | |
| 386 | 416 | * |
| 387 | 417 | * @since 1.19.1 |
| 388 | - * @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}. | |
| 389 | 420 | * @return int Attachment ID, or 0 if not a media-library image. |
| 390 | 421 | */ |
| 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]; | |
| 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)); | |
| 402 | 424 | } |
| 403 | 425 | |
| 404 | 426 | /** |
| 405 | 427 | * Inject (or replace an empty) alt/title attribute on a single <img> tag. |
| @@ -455,9 +477,14 @@ | ||
| 455 | 477 | private function generate_attribute_value(string $format, int $attachment_id, int $post_id, int $count, string $src): string { |
| 456 | 478 | $replacements = [ |
| 457 | 479 | '%site_title%' => get_bloginfo('name'), |
| 458 | 480 | '%sitename%' => get_bloginfo('name'), |
| 459 | - '%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) : '', | |
| 460 | 487 | '%count%' => (string) $count, |
| 461 | 488 | '%filename%' => '', |
| 462 | 489 | '%image_title%' => '', |
| 463 | 490 | '%image_caption%' => '', |
| @@ -525,11 +552,13 @@ | ||
| 525 | 552 | * |
| 526 | 553 | * @since 1.19.1 |
| 527 | 554 | * @param int $attachment_id The attachment ID. |
| 528 | 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. | |
| 529 | 558 | * @return bool True when the attachment now has the generated alt text; false when skipped or on failure. |
| 530 | 559 | */ |
| 531 | - 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 { | |
| 532 | 561 | if (!wp_attachment_is_image($attachment_id)) { |
| 533 | 562 | return false; |
| 534 | 563 | } |
| 535 | 564 | |
| @@ -569,12 +598,106 @@ | ||
| 569 | 598 | // Already correct — treat as success without a redundant write. |
| 570 | 599 | return true; |
| 571 | 600 | } |
| 572 | 601 | |
| 573 | - 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; | |
| 574 | 609 | } |
| 575 | 610 | |
| 576 | 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 | + /** | |
| 577 | 700 | * Describe an attachment with the vision model. |
| 578 | 701 | * |
| 579 | 702 | * Never throws: alt text generation runs in batches over a whole media |
| 580 | 703 | * library, and one unreadable image or a rate-limit blip must not abort |
| @@ -656,24 +779,39 @@ | ||
| 656 | 779 | 'offset' => $offset, |
| 657 | 780 | 'fields' => 'ids', |
| 658 | 781 | 'orderby' => 'ID', |
| 659 | 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). | |
| 660 | 784 | 'suppress_filters' => true, |
| 661 | 785 | ]); |
| 662 | 786 | |
| 663 | - $updated = 0; | |
| 664 | - $skipped = 0; | |
| 665 | 787 | $processed = 0; |
| 666 | 788 | |
| 667 | - foreach ($ids as $id) { | |
| 668 | - $processed++; | |
| 669 | - if ($this->fill_attachment_alt((int) $id, $overwrite)) { | |
| 670 | - $updated++; | |
| 671 | - } else { | |
| 672 | - $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); | |
| 673 | 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); | |
| 674 | 801 | } |
| 675 | 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 | + | |
| 676 | 814 | $next_offset = $offset + count($ids); |
| 677 | 815 | |
| 678 | 816 | // An empty batch means there is nothing left to walk, whatever the |
| 679 | 817 | // total claims. Deriving `done` from the count alone let any drift |
| @@ -696,8 +834,12 @@ | ||
| 696 | 834 | 'offset' => $offset, |
| 697 | 835 | 'next_offset' => $next_offset, |
| 698 | 836 | 'remaining' => $remaining, |
| 699 | 837 | 'done' => $exhausted || $next_offset >= $total, |
| 838 | + 'cache' => [ | |
| 839 | + 'posts_purged' => count($cache['posts']), | |
| 840 | + 'warnings' => $cache['warnings'], | |
| 841 | + ], | |
| 700 | 842 | ]; |
| 701 | 843 | } |
| 702 | 844 | |
| 703 | 845 | /** |
| @@ -737,9 +879,12 @@ | ||
| 737 | 879 | if (!wp_attachment_is_image($attachment_id)) { |
| 738 | 880 | return; |
| 739 | 881 | } |
| 740 | 882 | |
| 741 | - $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); | |
| 742 | 887 | } |
| 743 | 888 | |
| 744 | 889 | /** |
| 745 | 890 | * Total number of image attachments in the library. |
| @@ -799,8 +944,11 @@ | ||
| 799 | 944 | * @since 1.19.1 |
| 800 | 945 | * @return void |
| 801 | 946 | */ |
| 802 | 947 | private function flush_analyzer_cache(): void { |
| 803 | - // Matches SEO_Analyzer::CACHE_KEY. | |
| 804 | - 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 | + } | |
| 805 | 953 | } |
| 806 | 954 | } |