get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- One query instead of one per image. "SELECT p.ID, p.post_title, f.meta_value AS file FROM {$wpdb->posts} p LEFT JOIN {$wpdb->postmeta} a ON a.post_id = p.ID AND a.meta_key = '_wp_attachment_image_alt' LEFT JOIN {$wpdb->postmeta} f ON f.post_id = p.ID AND f.meta_key = '_wp_attached_file' WHERE p.post_type = 'attachment' AND p.post_mime_type LIKE 'image/%' AND ( a.meta_value IS NULL OR TRIM( a.meta_value ) = '' ) ORDER BY p.ID DESC", ARRAY_A ); $out = array(); foreach ( $rows as $row ) { $out[] = array( 'id' => (int) $row['ID'], 'title' => (string) $row['post_title'], 'file' => (string) $row['file'], ); } return $out; } /** * Save alternative text for several images. * * @param array $alts Attachment ID => text. * @return array { saved: int, skipped: int } */ public static function save_library( array $alts ) { $saved = 0; $skipped = 0; foreach ( $alts as $id => $alt ) { $id = (int) $id; $alt = trim( sanitize_text_field( (string) $alt ) ); if ( '' === $alt ) { continue; } if ( 'attachment' !== get_post_type( $id ) || ! current_user_can( 'edit_post', $id ) ) { ++$skipped; continue; } update_post_meta( $id, '_wp_attachment_image_alt', $alt ); ++$saved; } return array( 'saved' => $saved, 'skipped' => $skipped, ); } /** * Posts with content images that have no alternative text. * * @return array List of { id, title, type, status, fixable, missing }: * fixable images have text in the Media Library; missing * ones need text added there first. */ public static function content_gaps() { global $wpdb; $types = Accesswell_Settings::post_types(); $holders = implode( ',', array_fill( 0, count( $types ), '%s' ) ); $posts = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Content search across post types. $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- One %s per type plus the limit, passed as an array. "SELECT ID, post_title, post_type, post_status, post_content FROM {$wpdb->posts} WHERE post_type IN ($holders) AND post_status IN ( 'publish', 'future', 'draft', 'pending', 'private' ) AND post_content LIKE %s ORDER BY post_modified DESC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQL.InterpolatedNotPrepared array_merge( $types, array( '%' . $wpdb->esc_like( ' (int) $post['ID'], 'title' => '' !== $post['post_title'] ? $post['post_title'] : __( '(no title)', 'accesswell' ), 'type' => $post['post_type'], 'status' => $post['post_status'], 'fixable' => $fixable, 'missing' => $missing, ); } } return $out; } /** * Images in content with no alternative text. * * Images marked decorative on purpose (role="presentation" or * aria-hidden) are skipped. An empty alt="" usually means the text was * never filled in, since the block editor writes alt="" by default. * * @param string $content Content. * @return array List of { tag, id, library_alt }. */ public static function images_without_alt( $content ) { if ( ! preg_match_all( '/]*>/i', (string) $content, $tags ) ) { return array(); } $out = array(); foreach ( $tags[0] as $tag ) { if ( preg_match( '/\brole\s*=\s*["\']?(presentation|none)/i', $tag ) || preg_match( '/\baria-hidden\s*=\s*["\']?true/i', $tag ) ) { continue; } if ( preg_match( '/\balt\s*=\s*(["\'])(.*?)\1/is', $tag, $alt ) && '' !== trim( $alt[2] ) ) { continue; } $id = preg_match( '/\bwp-image-(\d+)\b/', $tag, $m ) ? (int) $m[1] : 0; $out[] = array( 'tag' => $tag, 'id' => $id, 'library_alt' => $id ? trim( (string) get_post_meta( $id, '_wp_attachment_image_alt', true ) ) : '', ); } return $out; } /** * Copy Media Library alternative text into a post's images that have * none. Saved as a revision. * * @param int $post_id Post ID. * @return int|WP_Error Number of images changed. */ public static function fix_post( $post_id ) { $post = get_post( $post_id ); if ( ! $post ) { return new WP_Error( 'accesswell_missing', __( 'That content no longer exists.', 'accesswell' ) ); } if ( ! current_user_can( 'edit_post', $post_id ) ) { return new WP_Error( 'accesswell_cap', __( 'You are not allowed to edit that content.', 'accesswell' ) ); } $changed = 0; $content = preg_replace_callback( '/]*>/i', function ( $m ) use ( &$changed ) { $tag = $m[0]; foreach ( self::images_without_alt( $tag ) as $image ) { if ( '' === $image['library_alt'] ) { return $tag; } $value = 'alt="' . esc_attr( $image['library_alt'] ) . '"'; if ( preg_match( '/\balt\s*=\s*(["\'])(.*?)\1/is', $tag ) ) { $tag = preg_replace( '/\balt\s*=\s*(["\'])(.*?)\1/is', $value, $tag, 1 ); } elseif ( preg_match( '/\balt(?=[\s\/>])/i', $tag ) ) { $tag = preg_replace( '/\balt(?=[\s\/>])/i', $value, $tag, 1 ); // A bare "alt" attribute. } else { $tag = preg_replace( '/^post_content ); if ( ! $changed || null === $content ) { return 0; } $result = wp_update_post( array( 'ID' => $post_id, 'post_content' => wp_slash( $content ), ), true ); return is_wp_error( $result ) ? $result : $changed; } }