register_ajax_handlers(); } /** * Register AJAX action handlers. * * @since 1.0.0 */ private function register_ajax_handlers() { add_action( 'wp_ajax_gatewell_scan_metadata', array( $this, 'handle_scan_request' ) ); add_action( 'wp_ajax_gatewell_save_image_metadata', array( $this, 'handle_save_metadata' ) ); } /** * Handle scan request via AJAX. * * @since 1.0.0 */ public function handle_scan_request() { // Verify nonce if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'gatewell_metadata_scanner' ) ) { wp_send_json_error( array( 'message' => __( 'Security check failed.', 'gatewell' ) ) ); } // Check capability if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission to perform this action.', 'gatewell' ) ) ); } // Get scan parameters $check_alt = isset( $_POST['check_alt'] ) && $_POST['check_alt'] === 'true'; $check_description = isset( $_POST['check_description'] ) && $_POST['check_description'] === 'true'; $check_caption = isset( $_POST['check_caption'] ) && $_POST['check_caption'] === 'true'; $page = isset( $_POST['page'] ) ? absint( $_POST['page'] ) : 1; // Validate that at least one check is selected if ( ! $check_alt && ! $check_description && ! $check_caption ) { wp_send_json_error( array( 'message' => __( 'Please select at least one option to scan for.', 'gatewell' ) ) ); } // Perform the scan $results = $this->query_attachments_with_missing_metadata( $check_alt, $check_description, $check_caption, $page ); wp_send_json_success( $results ); } /** * Handle metadata save via AJAX. * * @since 1.0.0 */ public function handle_save_metadata() { // Verify nonce if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'gatewell_metadata_scanner' ) ) { wp_send_json_error( array( 'message' => __( 'Security check failed.', 'gatewell' ) ) ); } // Check capability if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission to perform this action.', 'gatewell' ) ) ); } // Get attachment ID $attachment_id = isset( $_POST['attachment_id'] ) ? absint( $_POST['attachment_id'] ) : 0; if ( ! $attachment_id ) { wp_send_json_error( array( 'message' => __( 'Invalid attachment ID.', 'gatewell' ) ) ); } // Verify attachment exists $attachment = get_post( $attachment_id ); if ( ! $attachment || $attachment->post_type !== 'attachment' ) { wp_send_json_error( array( 'message' => __( 'Attachment not found.', 'gatewell' ) ) ); } // Get field minimums for validation $field_minimums = get_option( 'gatewell_field_minimums', array( 'alt_text' => 5 ) ); $min_alt = isset( $field_minimums['alt_text'] ) ? $field_minimums['alt_text'] : 5; // Update alt text if provided if ( isset( $_POST['alt_text'] ) ) { $alt_text = sanitize_text_field( wp_unslash( $_POST['alt_text'] ) ); $alt_text_trimmed = trim( $alt_text ); // Validate minimum character requirement if ( ! empty( $alt_text_trimmed ) && strlen( $alt_text_trimmed ) < $min_alt ) { wp_send_json_error( array( 'message' => sprintf( /* translators: %d: minimum character count */ __( 'Alt text must be at least %d characters.', 'gatewell' ), $min_alt ), ) ); } update_post_meta( $attachment_id, '_wp_attachment_image_alt', $alt_text_trimmed ); } // Update description if provided if ( isset( $_POST['description'] ) ) { $description = sanitize_textarea_field( wp_unslash( $_POST['description'] ) ); wp_update_post( array( 'ID' => $attachment_id, 'post_content' => $description, ) ); } // Update caption if provided if ( isset( $_POST['caption'] ) ) { $caption = sanitize_text_field( wp_unslash( $_POST['caption'] ) ); wp_update_post( array( 'ID' => $attachment_id, 'post_excerpt' => $caption, ) ); } wp_send_json_success( array( 'message' => __( 'Metadata saved successfully.', 'gatewell' ) ) ); } /** * Query attachments with missing or insufficient metadata. * * @param bool $check_alt Whether to check for missing/insufficient alt text. * @param bool $check_description Whether to check for missing description. * @param bool $check_caption Whether to check for missing caption. * @param int $page Page number (1-based). * @return array Results with attachments and pagination info. * @since 1.0.0 */ private function query_attachments_with_missing_metadata( $check_alt = true, $check_description = false, $check_caption = false, $page = 1 ) { global $wpdb; // Minimum alt-text length. This comes from a plugin option, never from // the request, and is bound through a %d placeholder either way. $field_minimums = get_option( 'gatewell_field_minimums', array( 'alt_text' => 5 ) ); $min_alt = isset( $field_minimums['alt_text'] ) ? (int) $field_minimums['alt_text'] : 5; // Pagination. $page is already absint()'d by handle_scan_request(). $items_per_page = 10; $page = max( 1, (int) $page ); $offset = ( $page - 1 ) * $items_per_page; // Which of the three checks are active used to be turned into SQL // fragments and concatenated onto the query. They are now bound as 1/0 // flags instead, so the statement below is a single fixed string literal // with a fixed number of placeholders: nothing about the SQL text varies // with the request, and every runtime value goes through prepare(). $want_alt = $check_alt ? 1 : 0; $want_description = $check_description ? 1 : 0; $want_caption = $check_caption ? 1 : 0; $image_mime = $wpdb->esc_like( 'image/' ) . '%'; // The media library has no core API that can filter attachments by the // character length of a meta value, so this screen has to query directly. // It is an explicit, admin-triggered scan behind a nonce and // manage_options (see handle_scan_request() above), and its whole point // is to report what is in the database right now, so the result is // deliberately not cached: a cached page would keep listing images whose // metadata the admin has just fixed in the same screen. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- See the note above: no core API covers this query, and caching it would make the scanner report stale results. $attachments = $wpdb->get_results( $wpdb->prepare( "SELECT p.ID, p.post_title, p.post_content, p.post_excerpt, pm.meta_value AS alt_text, COALESCE(CHAR_LENGTH(TRIM(pm.meta_value)), 0) AS alt_length, COALESCE(CHAR_LENGTH(TRIM(p.post_content)), 0) AS description_length, COALESCE(CHAR_LENGTH(TRIM(p.post_excerpt)), 0) AS caption_length FROM {$wpdb->posts} p LEFT JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id AND pm.meta_key = '_wp_attachment_image_alt' WHERE p.post_type = 'attachment' AND p.post_mime_type LIKE %s AND ( ( %d = 1 AND ( pm.meta_value IS NULL OR CHAR_LENGTH(TRIM(pm.meta_value)) < %d ) ) OR ( %d = 1 AND ( p.post_content = '' OR p.post_content IS NULL ) ) OR ( %d = 1 AND ( p.post_excerpt = '' OR p.post_excerpt IS NULL ) ) ) ORDER BY p.post_date DESC LIMIT %d OFFSET %d", $image_mime, $want_alt, $min_alt, $want_description, $want_caption, $items_per_page, $offset ) ); // Count total results. Same WHERE clause, same bound values. $total_count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} p LEFT JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id AND pm.meta_key = '_wp_attachment_image_alt' WHERE p.post_type = 'attachment' AND p.post_mime_type LIKE %s AND ( ( %d = 1 AND ( pm.meta_value IS NULL OR CHAR_LENGTH(TRIM(pm.meta_value)) < %d ) ) OR ( %d = 1 AND ( p.post_content = '' OR p.post_content IS NULL ) ) OR ( %d = 1 AND ( p.post_excerpt = '' OR p.post_excerpt IS NULL ) ) )", $image_mime, $want_alt, $min_alt, $want_description, $want_caption ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching // Build response data $items = array(); if ( $attachments ) { foreach ( $attachments as $attachment ) { // Get the attachment thumbnail $thumbnail_src = wp_get_attachment_image_src( $attachment->ID, 'thumbnail' ); $thumbnail_url = $thumbnail_src ? $thumbnail_src[0] : ''; $items[] = array( 'id' => $attachment->ID, 'title' => $attachment->post_title, 'alt_text' => $attachment->alt_text ? $attachment->alt_text : '', 'alt_length' => $attachment->alt_length, 'description' => $attachment->post_content ? $attachment->post_content : '', 'description_length' => $attachment->description_length, 'caption' => $attachment->post_excerpt ? $attachment->post_excerpt : '', 'caption_length' => $attachment->caption_length, 'thumbnail_url' => $thumbnail_url, ); } } $total_pages = ceil( $total_count / $items_per_page ); return array( 'items' => $items, 'current_page' => $page, 'total_pages' => $total_pages, 'total_count' => $total_count, 'items_per_page' => $items_per_page, 'min_alt_length' => $min_alt, ); } }