'POST', 'callback' => array( __CLASS__, 'missing' ), 'permission_callback' => $auth ) );
register_rest_route( $ns, '/media/alt', array( 'methods' => 'POST', 'callback' => array( __CLASS__, 'save' ), 'permission_callback' => $auth ) );
register_rest_route( $ns, '/media/alt/undo', array( 'methods' => 'POST', 'callback' => array( __CLASS__, 'undo' ), 'permission_callback' => $auth ) );
register_rest_route( $ns, '/media/list', array( 'methods' => 'POST', 'callback' => array( __CLASS__, 'pictures' ), 'permission_callback' => $auth ) );
register_rest_route( $ns, '/media/upload', array( 'methods' => 'POST', 'callback' => array( __CLASS__, 'upload' ), 'permission_callback' => $auth ) );
}
/**
* Add an image Gralus made (an infographic) to the Media Library.
* Body: { filename, data (base64 PNG, JPEG or WebP, up to 5 MB), alt, title }. Returns { id, url, width, height }.
*/
public static function upload( WP_REST_Request $request ) {
$p = $request->get_json_params();
$data = isset( $p['data'] ) ? base64_decode( (string) $p['data'], true ) : false; // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions
if ( false === $data || strlen( $data ) < 100 || strlen( $data ) > 5 * MB_IN_BYTES ) {
return new WP_Error( 'gralus_image', 'The image is missing or too large.', array( 'status' => 400 ) );
}
$types = array( "\x89PNG" => array( 'png', 'image/png' ), "\xFF\xD8\xFF" => array( 'jpg', 'image/jpeg' ), 'RIFF' => array( 'webp', 'image/webp' ) );
$type = null;
foreach ( $types as $magic => $t ) {
if ( 0 === strncmp( $data, $magic, strlen( $magic ) ) ) {
$type = $t;
break;
}
}
if ( ! $type ) {
return new WP_Error( 'gralus_image', 'Only PNG, JPEG or WebP images can be added.', array( 'status' => 400 ) );
}
$base = sanitize_file_name( preg_replace( '/\.[a-z0-9]+$/i', '', (string) ( $p['filename'] ?? 'gralus-image' ) ) );
$up = wp_upload_bits( ( '' !== $base ? $base : 'gralus-image' ) . '.' . $type[0], null, $data );
if ( ! empty( $up['error'] ) ) {
return new WP_Error( 'gralus_image', $up['error'], array( 'status' => 500 ) );
}
$title = sanitize_text_field( (string) ( $p['title'] ?? '' ) );
$id = wp_insert_attachment(
array( 'post_mime_type' => $type[1], 'post_title' => '' !== $title ? $title : $base, 'post_status' => 'inherit' ),
$up['file']
);
if ( is_wp_error( $id ) || ! $id ) {
return new WP_Error( 'gralus_image', 'The image could not be added to the Media Library.', array( 'status' => 500 ) );
}
require_once ABSPATH . 'wp-admin/includes/image.php';
wp_update_attachment_metadata( $id, wp_generate_attachment_metadata( $id, $up['file'] ) );
update_post_meta( $id, '_wp_attachment_image_alt', sanitize_text_field( (string) ( $p['alt'] ?? '' ) ) );
update_post_meta( $id, '_gralus_asset', 1 );
$src = wp_get_attachment_image_src( $id, 'full' );
return array( 'id' => (int) $id, 'url' => $src ? $src[0] : $up['url'], 'width' => $src ? (int) $src[1] : 0, 'height' => $src ? (int) $src[2] : 0 );
}
/**
* Photos a landing page may use: images with alt text, at least 800 px wide, newest first, as large-size URLs.
* The alt text is what lets the writer judge whether a picture fits a section.
*/
public static function pictures( WP_REST_Request $request ) {
$p = $request->get_json_params();
$limit = isset( $p['limit'] ) ? max( 1, min( 80, (int) $p['limit'] ) ) : 40;
$ids = get_posts(
array(
'post_type' => 'attachment',
'post_mime_type' => array( 'image/jpeg', 'image/png', 'image/webp' ),
'post_status' => 'inherit',
'posts_per_page' => $limit * 3,
'fields' => 'ids',
'orderby' => 'date',
'order' => 'DESC',
'no_found_rows' => true,
)
);
$out = array();
foreach ( $ids as $id ) {
$alt = trim( (string) get_post_meta( $id, '_wp_attachment_image_alt', true ) );
$img = wp_get_attachment_image_src( $id, 'large' );
if ( '' === $alt || ! $img || (int) $img[1] < 800 ) {
continue;
}
$out[] = array( 'id' => (int) $id, 'src' => $img[0], 'width' => (int) $img[1], 'height' => (int) $img[2], 'alt' => $alt );
if ( count( $out ) >= $limit ) {
break;
}
}
return array( 'images' => $out );
}
/**
* Images in the Media Library with no alt text: featured images first, then images attached to a post or page,
* then the rest, newest first. `exclude` skips images that already have alt text waiting for approval.
*/
public static function missing( WP_REST_Request $request ) {
global $wpdb;
$p = $request->get_json_params();
$limit = isset( $p['limit'] ) ? max( 0, min( self::MAX, (int) $p['limit'] ) ) : 100;
$exclude = isset( $p['exclude'] ) && is_array( $p['exclude'] ) ? array_filter( array_map( 'intval', $p['exclude'] ) ) : array();
// Fixed SQL with core table names only; the ids and the limit go through prepare().
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- one %d per excluded id.
$total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->posts} p WHERE p.post_type = 'attachment' AND p.post_mime_type LIKE 'image/%'" );
$without = (int) $wpdb->get_var(
"SELECT COUNT(*) 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_mime_type LIKE 'image/%' AND COALESCE(TRIM(m.meta_value), '') = ''"
);
$images = array();
if ( $limit > 0 ) {
$skip = $exclude ? ' AND p.ID NOT IN (' . implode( ',', array_fill( 0, count( $exclude ), '%d' ) ) . ')' : '';
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT p.ID, p.post_title, p.post_excerpt, p.post_parent, (f.id IS NOT NULL) AS featured
FROM {$wpdb->posts} p
LEFT JOIN {$wpdb->postmeta} m ON m.post_id = p.ID AND m.meta_key = '_wp_attachment_image_alt'
LEFT JOIN (SELECT DISTINCT CAST(meta_value AS UNSIGNED) AS id FROM {$wpdb->postmeta} WHERE meta_key = '_thumbnail_id') f ON f.id = p.ID
WHERE p.post_type = 'attachment' AND p.post_mime_type LIKE %s AND COALESCE(TRIM(m.meta_value), '') = ''{$skip}
ORDER BY featured DESC, (p.post_parent > 0) DESC, p.ID DESC
LIMIT %d",
array_merge( array( $wpdb->esc_like( 'image/' ) . '%' ), array_values( $exclude ), array( $limit ) )
)
);
// phpcs:enable
foreach ( (array) $rows as $r ) {
$id = (int) $r->ID;
$src = wp_get_attachment_image_url( $id, 'medium' );
$file = get_attached_file( $id );
if ( ! $src ) {
continue;
}
$images[] = array(
'id' => $id,
'src' => (string) $src,
'file' => $file ? wp_basename( $file ) : wp_basename( (string) $src ),
'title' => (string) $r->post_title,
'caption' => (string) $r->post_excerpt,
'parentTitle' => $r->post_parent ? (string) get_the_title( (int) $r->post_parent ) : '',
'featured' => (bool) $r->featured,
);
}
}
return array( 'ok' => true, 'total' => $total, 'withoutAlt' => $without, 'images' => $images );
}
/** Save approved alt text on images that still have none. Returns what was set, so Undo can take it off. */
public static function save( WP_REST_Request $request ) {
$p = $request->get_json_params();
$images = isset( $p['images'] ) && is_array( $p['images'] ) ? array_slice( $p['images'], 0, self::MAX ) : array();
$set = array();
$kept = 0;
$notfound = 0;
foreach ( $images as $im ) {
$id = isset( $im['id'] ) ? (int) $im['id'] : 0;
$alt = isset( $im['alt'] ) ? sanitize_text_field( (string) $im['alt'] ) : '';
if ( ! $id || '' === $alt ) {
continue;
}
if ( 'attachment' !== get_post_type( $id ) || ! wp_attachment_is_image( $id ) ) {
++$notfound;
continue;
}
// Never replace alt text someone wrote in the meantime.
if ( '' !== trim( (string) get_post_meta( $id, '_wp_attachment_image_alt', true ) ) ) {
++$kept;
continue;
}
update_post_meta( $id, '_wp_attachment_image_alt', $alt );
update_post_meta( $id, self::MARK, 1 );
$set[] = array( 'id' => $id, 'alt' => $alt );
}
return array(
'ok' => true,
'changed' => count( $set ),
'kept' => $kept,
'notFound' => $notfound,
'previous' => array( 'images' => $set ),
);
}
/** Take off alt text Gralus added, unless someone has changed it since. */
public static function undo( WP_REST_Request $request ) {
$p = $request->get_json_params();
$images = isset( $p['images'] ) && is_array( $p['images'] ) ? $p['images'] : array();
$undone = 0;
foreach ( $images as $im ) {
$id = isset( $im['id'] ) ? (int) $im['id'] : 0;
$alt = isset( $im['alt'] ) ? (string) $im['alt'] : '';
if ( $id && '' !== $alt && (string) get_post_meta( $id, '_wp_attachment_image_alt', true ) === $alt ) {
delete_post_meta( $id, '_wp_attachment_image_alt' );
delete_post_meta( $id, self::MARK );
++$undone;
}
}
return array( 'ok' => true, 'undone' => $undone );
}
/**
* Images inside posts keep the alt text they had when they were inserted. When that was empty and Gralus has
* since described the image in the Media Library, show that description on the page too.
*/
public static function filter_content_img( $html, $context = '', $attachment_id = 0 ) {
if ( ! $attachment_id || ! get_post_meta( $attachment_id, self::MARK, true ) ) {
return $html;
}
$alt = (string) get_post_meta( $attachment_id, '_wp_attachment_image_alt', true );
return '' === trim( $alt ) ? $html : self::fill_alt( $html, $alt );
}
/** Put alt text on an
tag whose alt is missing or empty; a tag with alt text is returned as it is. */
public static function fill_alt( $html, $alt ) {
$attr = 'alt="' . esc_attr( $alt ) . '"';
if ( preg_match( '/\salt\s*=\s*(["\'])\s*\1/i', $html ) ) {
return preg_replace( '/\salt\s*=\s*(["\'])\s*\1/i', ' ' . $attr, $html, 1 );
}
if ( ! preg_match( '/\salt\s*=/i', $html ) ) {
return preg_replace( '/^![]()