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