*/ private static array $url_cache = []; public function register(): void { // The parser landed in WP 6.2; the plugin still supports 6.0. if (!class_exists('WP_HTML_Tag_Processor')) { return; } foreach ($this->target_post_types() as $type) { add_filter("bulk_actions-edit-{$type}", [$this, 'add_bulk_action']); add_filter("handle_bulk_actions-edit-{$type}", [$this, 'handle_bulk_action'], 10, 3); } add_action('admin_notices', [$this, 'render_notice']); } /** * Public post types that hold editable content. * * Attachments are excluded: their alt text is the source these actions read from. */ private function target_post_types(): array { $types = get_post_types(['public' => true], 'names'); unset($types['attachment']); return $types; } /** * @param array $actions Bulk actions registered for the current list screen. * @return array */ public function add_bulk_action($actions) { // Two entries rather than a checkbox plus a dialog, because a bulk action // cannot confirm. The label carries the warning, and the safe one is first. // Wording stays neutral: these register on every public post type, so // "post content" would read wrong on Pages and on any custom type. $actions[self::ACTION] = __('Save AiGude alt text into the content', 'aigude-tools'); $actions[self::ACTION_OVERWRITE] = __('Save AiGude alt text into the content (replace existing)', 'aigude-tools'); return $actions; } /** * @param string $redirect Redirect URL the list screen will send the user to. * @param string $action Selected bulk action. * @param array $post_ids Selected post IDs. * @return string */ public function handle_bulk_action($redirect, $action, $post_ids) { if ($action !== self::ACTION && $action !== self::ACTION_OVERWRITE) { return $redirect; } $overwrite = ($action === self::ACTION_OVERWRITE); $posts = 0; $images = 0; $skipped = 0; foreach ((array) $post_ids as $post_id) { $post_id = (int) $post_id; if (!current_user_can('edit_post', $post_id)) { continue; } $result = $this->apply_to_post($post_id, $overwrite); $skipped += $result['skipped']; if ($result['images'] > 0) { $posts++; $images += $result['images']; } } return add_query_arg([ 'aigude_alt_posts' => $posts, 'aigude_alt_images' => $images, 'aigude_alt_skipped' => $skipped, 'aigude_alt_mode' => $overwrite ? 'overwrite' : 'safe', ], $redirect); } /** * Write alt text into one post's stored content. * * Without $overwrite the operation only fills blanks, so it is non-destructive. * With $overwrite it replaces existing alt text, including text an editor wrote, * matching the TYPO3 extension's unticked "Skip existing" behaviour. Both modes * skip images whose alt already equals the stored value, so both are idempotent. * * @return array{images:int,skipped:int} Images updated, and images a mode * decision left alone on purpose. */ private function apply_to_post(int $post_id, bool $overwrite = false): array { $result = ['images' => 0, 'skipped' => 0]; $post = get_post($post_id); if (!$post || $post->post_content === '' || stripos($post->post_content, 'post_content); // Pass 1: bookmark images that need an alt and collect their attachment IDs, // so the meta for all of them can be primed in a single query. Images safe // mode refuses to touch are collected without a bookmark: they are never // written to, and bookmarks are a limited resource. $pending = []; $refused = []; while ($tags->next_tag('img')) { $current_alt = (string) $tags->get_attribute('alt'); if (preg_match('/wp-image-(\d+)/i', (string) $tags->get_attribute('class'), $m)) { $attachment_id = (int) $m[1]; } else { // No class to read, so ask the uploads directory. Note that such an // image still has no class afterwards, so core keeps handing the render // filter an attachment id of 0 for it on every future render: writing // into the content is the only thing that will ever give it alt text. $attachment_id = self::resolve_from_src((string) $tags->get_attribute('src')); } if ($attachment_id <= 0) { continue; } if (!$overwrite && !empty($current_alt)) { // Whether this counts as skipped depends on the stored value, which // only pass two knows, so defer the decision until the meta is primed. $refused[] = [$attachment_id, $current_alt]; continue; } $bookmark = 'aigude_' . count($pending); if ($tags->set_bookmark($bookmark)) { $pending[$bookmark] = $attachment_id; } } if (!$pending && !$refused) { return $result; } update_postmeta_cache(array_values(array_unique(array_merge( array_values($pending), array_column($refused, 0) )))); // An image whose alt already equals the stored value needed nothing done, so // it is not reported as skipped in either mode. foreach ($refused as [$attachment_id, $current_alt]) { $alt = $this->stored_alt($attachment_id); if ($alt !== '' && $alt !== $current_alt) { $result['skipped']++; } } // Pass 2: seek back to each bookmark and set the attribute. $filled = 0; foreach ($pending as $bookmark => $attachment_id) { $alt = $this->stored_alt($attachment_id); if ($alt === '') { $tags->release_bookmark($bookmark); $result['skipped']++; continue; } $tags->seek($bookmark); // Skip when the value is already correct, so a second run of either mode // reports zero changes and writes nothing. if ((string) $tags->get_attribute('alt') === $alt) { $tags->release_bookmark($bookmark); continue; } $tags->set_attribute('alt', $alt); $tags->release_bookmark($bookmark); $filled++; } if ($filled === 0) { return $result; } // wp_update_post() unslashes what it receives, so the content must be slashed // on the way in or literal backslashes are stripped from the markup. wp_update_post([ 'ID' => $post_id, 'post_content' => wp_slash($tags->get_updated_html()), ]); $result['images'] = $filled; return $result; } /** * Resolve an to an attachment ID. * * Core's attachment_url_to_postid() matches _wp_attached_file exactly, so a * resized URL such as photo-1024x768.jpg never matches on its own. Strip the * size suffix first, then try the -scaled variant WordPress creates for big * uploads. External URLs are out of scope and return 0. */ private static function resolve_from_src(string $src): int { if ($src === '') { return 0; } if (isset(self::$url_cache[$src])) { return self::$url_cache[$src]; } $url = (string) strtok($src, '?'); // Hand-written markup, which is the whole reason this fallback exists, often // uses a protocol-relative or root-relative src. Core's attachment_url_to_postid() // only strips an absolute uploads base URL, so neither form would ever match. // Make the URL absolute against the site before the gate and the lookup. if (strpos($url, '//') === 0) { $url = (string) wp_parse_url(home_url(), PHP_URL_SCHEME) . ':' . $url; } elseif (strpos($url, '/') === 0) { $url = home_url($url); } $base = wp_get_upload_dir()['baseurl'] ?? ''; // Compare with the scheme stripped, so an http src still matches an https // site URL and the other way round. $probe = $base !== '' ? preg_replace('#^https?:#', '', $base) : ''; if ($probe === '' || strpos((string) preg_replace('#^https?:#', '', $url), $probe) !== 0) { return self::$url_cache[$src] = 0; } $unsized = (string) preg_replace('/-\d+x\d+(?=\.[A-Za-z0-9]{2,5}$)/', '', $url); $id = (int) attachment_url_to_postid($unsized); if ($id === 0) { $scaled = (string) preg_replace('/(\.[A-Za-z0-9]{2,5})$/', '-scaled$1', $unsized); $id = (int) attachment_url_to_postid($scaled); } return self::$url_cache[$src] = $id; } /** * The alt text stored on an attachment, normalised the way content expects it. */ private function stored_alt(int $attachment_id): string { return trim(wp_strip_all_tags((string) get_post_meta($attachment_id, '_wp_attachment_image_alt', true))); } /** * Report the result of a bulk run on the list screen the user lands back on. */ public function render_notice(): void { if (!isset($_GET['aigude_alt_posts'], $_GET['aigude_alt_images'])) { return; } $posts = (int) $_GET['aigude_alt_posts']; $images = (int) $_GET['aigude_alt_images']; $skipped = isset($_GET['aigude_alt_skipped']) ? (int) $_GET['aigude_alt_skipped'] : 0; $overwrite = (isset($_GET['aigude_alt_mode']) && $_GET['aigude_alt_mode'] === 'overwrite'); if ($images === 0) { // The two modes reach zero for opposite reasons, so they cannot share a // sentence: the safe one left existing alt text alone, the overwrite one // found nothing left to replace. $message = $overwrite ? __('Nothing to update. These images already carry the alt text stored in the Media Library.', 'aigude-tools') : __('No images needed alt text. Images that already have alt text were left unchanged.', 'aigude-tools'); } else { // Two counts pluralise independently: three images can sit in one entry. // A single _n() keyed on the image count gets "3 images across 1 entries". $image_count = sprintf( /* translators: %d: number of images updated. */ _n('%d image', '%d images', $images, 'aigude-tools'), $images ); $entry_count = sprintf( /* translators: %d: number of posts or pages updated. */ _n('%d entry', '%d entries', $posts, 'aigude-tools'), $posts ); $message = sprintf( /* translators: 1: image count with unit, e.g. "3 images". 2: entry count with unit, e.g. "1 entry". */ __('Saved alt text into %1$s across %2$s.', 'aigude-tools'), $image_count, $entry_count ); if ($skipped > 0) { $message .= ' ' . sprintf( /* translators: %d: number of images left unchanged. */ _n('%d image was skipped.', '%d images were skipped.', $skipped, 'aigude-tools'), $skipped ); } $message .= ' ' . __('The alt text is now part of the content itself, so it shows in the editor and stays if the plugin is deactivated.', 'aigude-tools'); } printf('

%s

', esc_html($message)); } }