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;
}
}