| @@ -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 | */ |
| @@ -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 | } |