"Fill empty alt text and titles from file * names" on, a new image whose alt text is empty gets one made from its file name ("red-summer_dress-2.jpg" becomes * "Red summer dress 2"), and its title too when WordPress copied the bare file name into it. Text a person wrote is * never replaced; names that say nothing (camera names such as IMG_1234, screenshots, numbers, hashes) give no text. * Tools > "Fill empty alt text now" does the same for images already in the library (alt text only). * * @package AffStackDevImageManager */ namespace AffStackDev\ImageManager; if ( ! defined( 'ABSPATH' ) ) { exit; } /** Text for images from their file names. */ final class Auto_Text { const BATCH = 300; const RETRY = Hooks::AUTO_TEXT_RETRY; const CURSOR = 'afsd_im_auto_text_cursor'; /** * Words from a file name, or '' when the name says nothing. * * @param string $file A file name or path, or a title WordPress copied from one. * @return string */ public static function from_file( string $file ): string { $name = wp_basename( $file ); $name = (string) preg_replace( '/\.[a-z0-9]{2,5}$/i', '', $name ); // WordPress's own additions to a file name: the scaled and rotated copies, edits and size suffixes. $name = (string) preg_replace( '/-(?:scaled|rotated|e\d{10,13}|\d+x\d+)$/i', '', $name ); $name = trim( (string) preg_replace( '/[\s._-]+/u', ' ', $name ) ); if ( '' === $name || preg_match( '/^(?:img|image|dsc|dscn|dscf|pxl|mvimg|photo|pic|picture|vid|whatsapp image|signal)\b[\s\d]*$/i', $name ) || preg_match( '/^(?:screenshot|screen shot)\b/i', $name ) || preg_match( '/^[\d\s]+$/', $name ) || preg_match( '/^[a-f0-9]{16,}$/i', str_replace( ' ', '', $name ) ) ) { return ''; } return mb_strtoupper( mb_substr( $name, 0, 1 ) ) . mb_substr( $name, 1 ); } /** * Whether a title is the bare file name WordPress copied at upload (also when WordPress added "-1" to the file * name because one with that name was already there). * * @param int $id Attachment. * @param string $title Its title. * @return bool */ public static function copied_title( int $id, string $title ): bool { if ( '' === $title || str_contains( $title, ' ' ) ) { return false; } $base = (string) preg_replace( '/\.[a-z0-9]{2,5}$/i', '', wp_basename( (string) get_attached_file( $id ) ) ); $base = sanitize_title( (string) preg_replace( '/-(?:scaled|rotated)$/i', '', $base ) ); $name = sanitize_title( $title ); return $name === $base || 1 === preg_match( '/^' . preg_quote( $name, '/' ) . '-\d+$/', $base ); } /** * A new upload (or its retry): fill its empty alt text and its copied title. Copies Image Manager makes itself * (edited copies, PDF pages, restored backups) keep the text they were given: they are added while this request * holds the owner lock, as is an upload through Image Manager's own Upload media to a folder form, which is told * apart by the upload receipt that form gives the file. Inside an approved change nothing is filled. While another * Image Manager change is being written, the upload is not held up: the fill is tried again in a minute, then two * and four. * * @param int $id Attachment. * @return void */ public static function on_upload( $id ): void { $id = (int) $id; if ( empty( Settings::get()['auto_text'] ) || '' !== Reviewed_Store::execution_marker() ) { return; } if ( Reviewed_Store::holding() && '' === (string) get_post_meta( $id, '_afsd_im_upload_receipt', true ) ) { return; } try { $lock = Reviewed_Store::enter(); } catch ( \RuntimeException $e ) { $attempt = (int) get_post_meta( $id, '_afsd_im_auto_text_retry', true ); if ( Reviewed_Store::busy( $e ) && $attempt < 3 && ! wp_next_scheduled( self::RETRY, array( $id ) ) ) { update_post_meta( $id, '_afsd_im_auto_text_retry', $attempt + 1 ); wp_schedule_single_event( time() + 60 * ( 2 ** $attempt ), self::RETRY, array( $id ) ); } return; } try { self::fill( $id, true ); Reviewed_Store::delete_post_meta( $id, '_afsd_im_auto_text_retry' ); } finally { Reviewed_Store::leave( $lock ); } } /** * Fill one image's empty alt text (and, on upload, a copied title) from its file name. The caller holds the lock. * * @param int $id Attachment. * @param bool $titles Also the title, when WordPress copied the bare file name into it. * @return array|null The fields filled (alt, title), or null when the file name gives no text. */ private static function fill( int $id, bool $titles ): ?array { $post = get_post( $id ); if ( ! $post || 'attachment' !== $post->post_type || ! str_starts_with( (string) $post->post_mime_type, 'image/' ) ) { return array(); } $copied = self::copied_title( $id, (string) $post->post_title ); $text = sanitize_text_field( self::from_file( $copied ? $post->post_title : (string) get_attached_file( $id ) ) ); if ( '' === $text ) { return null; } $filled = array(); if ( '' === trim( (string) get_post_meta( $id, '_wp_attachment_image_alt', true ) ) ) { Reviewed_Store::update_post_meta( $id, '_wp_attachment_image_alt', $text ); $filled[] = 'alt'; } if ( $titles && $copied ) { $done = Reviewed_Store::wp_update_post( wp_slash( array( 'ID' => $id, 'post_title' => $text, ) ), true ); if ( ! is_wp_error( $done ) ) { $filled[] = 'title'; } } return $filled; } /** * Images in the library without alt text, oldest first, after a given ID. * * @param int $limit At most this many. * @param int $after Only IDs above this one (the cursor of the previous batch). * @return array Attachment IDs. */ public static function missing( int $limit, int $after = 0 ): array { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- one bounded batch the administrator starts on Tools; it must read the current rows. $ids = $wpdb->get_col( $wpdb->prepare( "SELECT p.ID FROM {$wpdb->posts} p LEFT JOIN {$wpdb->postmeta} m ON m.post_id = p.ID AND m.meta_key = '_wp_attachment_image_alt' WHERE p.post_type = 'attachment' AND p.post_status = 'inherit' AND p.post_mime_type LIKE %s AND p.ID > %d AND ( m.meta_id IS NULL OR TRIM( m.meta_value ) = '' ) ORDER BY p.ID ASC LIMIT %d", 'image/%', $after, $limit ) ); return array_map( 'intval', (array) $ids ); } /** * Tools > Fill empty alt text now: the next bounded batch of the library (alt text only; titles are left as they * are). A cursor remembers where the batch ended, so the next click goes on from there. * * @return array Counts: checked, filled, no_usable_name, skipped (images this user may not edit), more (true while * the library has more to check), busy (another Image Manager change is being written; nothing checked). * @throws \RuntimeException When the owner lock fails for a reason other than another writer. */ public static function fill_missing(): array { $out = array( 'checked' => 0, 'filled' => 0, 'no_usable_name' => 0, 'skipped' => 0, 'more' => false, 'busy' => false, ); try { $lock = Reviewed_Store::enter( 8 ); } catch ( \RuntimeException $e ) { if ( ! Reviewed_Store::busy( $e ) ) { throw $e; } $out['busy'] = true; return $out; } try { $ids = self::missing( self::BATCH, (int) get_option( self::CURSOR, 0 ) ); foreach ( $ids as $id ) { if ( ! current_user_can( 'edit_post', $id ) ) { ++$out['skipped']; continue; } $filled = self::fill( $id, false ); if ( null === $filled ) { ++$out['no_usable_name']; } elseif ( in_array( 'alt', $filled, true ) ) { ++$out['filled']; } } $out['checked'] = count( $ids ); $out['more'] = count( $ids ) === self::BATCH; Reviewed_Store::update_option( self::CURSOR, $out['more'] ? (int) end( $ids ) : 0, false ); } finally { Reviewed_Store::leave( $lock ); } return $out; } }