'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( '/^