(int) $entry['is_read'], 'status' => $entry['status'], 'spam_reason' => $entry['spam_reason'] ?? null, 'trashed_at' => $entry['trashed_at'] ?? null ); } /** Compare and write only the state this action read; never overwrite a newer state. */ private static function write( $id, array $before, array $after ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned state; conditional update prevents stale undo and concurrent overwrites. return $wpdb->update( Entries::table_name(), $after, array( 'id' => (int) $id ) + $before, array( '%d', '%s', '%s', '%s' ), array( '%d', '%d', '%s', '%s', '%s' ) ); } /** Permission checked per entry; counts describe completed writes, not requested IDs. */ public static function bulk( $action, array $ids ) { if ( ! in_array( $action, array( 'read', 'unread', 'spam', 'ham', 'trash', 'delete' ), true ) ) { return false; } $ids = array_values( array_unique( array_filter( array_map( 'absint', $ids ) ) ) ); if ( count( $ids ) > 500 ) { wp_die( esc_html__( 'Select no more than 500 entries at a time.', 'accessible-forms-by-accessible-org' ), '', array( 'response' => 400 ) ); } $changed = 0; $unchanged = 0; $skipped = 0; $undo = array(); $cap = in_array( $action, array( 'trash', 'delete' ), true ) ? 'delete_accessible_form_entries' : 'manage_accessible_form_entries'; foreach ( $ids as $id ) { $entry = Entries::get( $id ); if ( ! $entry || ! Capabilities::entry( $entry, $cap ) ) { ++$skipped; continue; } if ( 'delete' === $action ) { if ( 'trash' !== $entry['status'] || 1 !== Entries::delete( array( $id ) ) ) { ++$skipped; continue; } ++$changed; continue; } $before = self::snapshot( $entry ); $after = $before; if ( in_array( $action, array( 'read', 'unread' ), true ) ) { $after['is_read'] = 'read' === $action ? 1 : 0; } else { $status = array( 'spam' => 'spam', 'ham' => 'received', 'trash' => 'trash' )[ $action ]; if ( $before['status'] === $status ) { ++$unchanged; continue; } $after['status'] = $status; $after['spam_reason'] = 'spam' === $status ? 'manual' : null; $after['trashed_at'] = 'trash' === $status ? gmdate( 'Y-m-d H:i:s' ) : null; } if ( $before === $after ) { ++$unchanged; continue; } if ( 1 !== self::write( $id, $before, $after ) ) { ++$skipped; continue; } ++$changed; Audit::record( 'bulk_' . $action, $entry['form_id'], $id ); // Pending sends stop; the attempt history stays with the entry, which Undo // or Restore can bring back. Only permanent deletion forgets it. if ( in_array( $action, array( 'trash', 'spam' ), true ) ) { Notifications::cancel( $id ); } if ( in_array( $action, array( 'read', 'unread', 'trash' ), true ) ) { $undo[ $id ] = array( 'before' => $before, 'after' => $after ); } } $messages = array( /* translators: %d: number of entries for this result. */ 'read' => _n( '%d entry marked as read.', '%d entries marked as read.', $changed, 'accessible-forms-by-accessible-org' ), /* translators: %d: number of entries for this result. */ 'unread' => _n( '%d entry marked as unread.', '%d entries marked as unread.', $changed, 'accessible-forms-by-accessible-org' ), /* translators: %d: number of entries for this result. */ 'spam' => _n( '%d entry marked as spam.', '%d entries marked as spam.', $changed, 'accessible-forms-by-accessible-org' ), /* translators: %d: number of entries for this result. */ 'ham' => _n( '%d entry accepted without sending email.', '%d entries accepted without sending email.', $changed, 'accessible-forms-by-accessible-org' ), /* translators: %d: number of entries for this result. */ 'trash' => _n( '%d entry moved to Trash.', '%d entries moved to Trash.', $changed, 'accessible-forms-by-accessible-org' ), /* translators: %d: number of entries for this result. */ 'delete' => _n( '%d entry deleted permanently.', '%d entries deleted permanently.', $changed, 'accessible-forms-by-accessible-org' ), ); $message = sprintf( $messages[ $action ], $changed ); /* translators: %d: number of entries for this result. */ if ( $unchanged ) { $message .= ' ' . sprintf( _n( '%d already had this state.', '%d already had this state.', $unchanged, 'accessible-forms-by-accessible-org' ), $unchanged ); } /* translators: %d: number of entries for this result. */ if ( $skipped ) { $message .= ' ' . sprintf( _n( '%d could not be changed; it may have changed or your access may have changed.', '%d could not be changed; they may have changed or your access may have changed.', $skipped, 'accessible-forms-by-accessible-org' ), $skipped ); } $token = ''; if ( $undo ) { $token = str_replace( '-', '', wp_generate_uuid4() ); set_transient( 'accessible_forms_undo_' . $token, array( 'user' => get_current_user_id(), 'site' => get_current_blog_id(), 'cap' => $cap, 'items' => $undo ), 10 * MINUTE_IN_SECONDS ); } self::feedback( $message, $skipped ? 'warning' : 'success', $token ); return 'updated'; } public static function feedback( $message, $type = 'success', $token = '' ) { set_transient( self::notice_key(), array( 'message' => $message, 'type' => $type, 'undo' => $token ), 10 * MINUTE_IN_SECONDS ); } public static function notice() { $data = get_transient( self::notice_key() ); if ( ! is_array( $data ) ) { return false; } delete_transient( self::notice_key() ); echo '

' . esc_html( $data['message'] ) . '

'; if ( ! empty( $data['undo'] ) && get_transient( 'accessible_forms_undo_' . $data['undo'] ) ) { echo '
'; wp_nonce_field( 'accessible_forms_undo_' . $data['undo'], 'af_undo_nonce', false ); echo '' . esc_html__( 'Available for 10 minutes. Restoring an entry does not send email.', 'accessible-forms-by-accessible-org' ) . '
'; } echo '
'; return true; } /** One-time undo with current permissions, expiry and an atomic state comparison. */ public static function restore( $token ) { $data = preg_match( '/^[a-f0-9]{32}$/', $token ) ? get_transient( 'accessible_forms_undo_' . $token ) : false; if ( ! is_array( $data ) || (int) $data['user'] !== get_current_user_id() || (int) $data['site'] !== get_current_blog_id() ) { return new \WP_Error( 'unavailable', __( 'This undo has expired or is not available to your account.', 'accessible-forms-by-accessible-org' ) ); } delete_transient( 'accessible_forms_undo_' . $token ); $count = 0; $skipped = 0; foreach ( $data['items'] as $id => $state ) { $entry = Entries::get( $id ); $held = $entry ? Entries::held_expiry( array_merge( $entry, $state['before'] ) ) : 0; if ( ( $held && $held <= time() ) || ! $entry || ! Capabilities::entry( $entry, $data['cap'] ) || ( ! empty( $entry['expires_at'] ) && $entry['expires_at'] <= gmdate( 'Y-m-d H:i:s' ) ) || self::snapshot( $entry ) !== $state['after'] || 1 !== self::write( $id, $state['after'], $state['before'] ) ) { ++$skipped; continue; } ++$count; Audit::record( 'entry_undo', $entry['form_id'], $id ); } /* translators: %d: number of entries for this result. */ $message = sprintf( _n( '%d entry restored to its previous state.', '%d entries restored to their previous state.', $count, 'accessible-forms-by-accessible-org' ), $count ); /* translators: %d: number of entries for this result. */ if ( $skipped ) { $message .= ' ' . sprintf( _n( '%d was left unchanged because its state or access changed.', '%d were left unchanged because their state or access changed.', $skipped, 'accessible-forms-by-accessible-org' ), $skipped ); } self::feedback( $message, $skipped ? 'warning' : 'success' ); return array( 'restored' => $count, 'skipped' => $skipped, 'message' => $message ); } public static function undo() { $token = isset( $_POST['token'] ) && is_string( $_POST['token'] ) ? sanitize_key( wp_unslash( $_POST['token'] ) ) : ''; check_admin_referer( 'accessible_forms_undo_' . $token, 'af_undo_nonce' ); $result = self::restore( $token ); if ( is_wp_error( $result ) ) { wp_die( esc_html( $result->get_error_message() ), '', array( 'response' => 403, 'back_link' => true ) ); } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Bounded JSON; Inbox::filters sanitizes and allowlists every decoded value. $json = isset( $_POST['filters'] ) && is_string( $_POST['filters'] ) ? wp_unslash( $_POST['filters'] ) : ''; $raw = strlen( $json ) <= 4000 ? json_decode( $json, true ) : array(); wp_safe_redirect( EntriesTable::url( Inbox::url_filters( Inbox::filters( is_array( $raw ) ? $raw : array() ) ) ) ); exit(); } private static function setup_key( $id ) { return '_accessible_forms_setup_' . get_current_blog_id() . '_' . (int) $id; } /** Saved configuration is verified; placement and a real submission are explicitly owner-confirmed. */ public static function setup( $post, array $schema ) { if ( 'auto-draft' === $post->post_status ) { echo '

' . esc_html__( 'Save your form to keep a setup checklist that you can return to later.', 'accessible-forms-by-accessible-org' ) . '

'; return; } $progress = get_user_meta( get_current_user_id(), self::setup_key( $post->ID ), true ); $progress = is_array( $progress ) ? $progress : array(); $recipients = (array) ( $schema['recipients'] ?? array() ); $delivery = $recipients && count( array_filter( $recipients, 'is_email' ) ) === count( $recipients ); $checks = array( 'build' => array( __( 'Create your form', 'accessible-forms-by-accessible-org' ), ! empty( $schema['fields'] ), '#af-step-build' ), 'email' => array( __( 'Configure notification recipients', 'accessible-forms-by-accessible-org' ), $delivery, '#af-step-delivery' ), 'publish' => array( __( 'Publish the form', 'accessible-forms-by-accessible-org' ), 'publish' === $post->post_status, '#af-step-review' ), 'placed' => array( __( 'Check the form on its published page', 'accessible-forms-by-accessible-org' ), ! empty( $progress['placed'] ), Placement::url( $post->ID ) ), 'tested' => array( __( 'Submit a test and check its entry and email', 'accessible-forms-by-accessible-org' ), ! empty( $progress['tested'] ), EntriesTable::url( array( 'form_id' => $post->ID ) ) ), ); $done = count( array_filter( $checks, static fn( $check ) => $check[1] ) ); /* translators: 1: completed setup steps, 2: total setup steps. */ echo '
' . esc_html( sprintf( __( 'Form setup · %1$d of %2$d complete', 'accessible-forms-by-accessible-org' ), $done, count( $checks ) ) ) . '

' . esc_html__( 'Saved configuration is checked automatically. Page placement and a real test are confirmed by you and remembered for your account.', 'accessible-forms-by-accessible-org' ) . '

    '; foreach ( $checks as $key => [$label, $complete, $url] ) { echo '
  1. ' . esc_html( $complete ? __( 'Complete', 'accessible-forms-by-accessible-org' ) : __( 'To do', 'accessible-forms-by-accessible-org' ) ) . '' . esc_html( $label ) . ''; if ( in_array( $key, array( 'placed', 'tested' ), true ) ) { $form_id = 'af-setup-' . $key; self::$setup_forms[ $form_id ] = array( 'post' => $post->ID, 'step' => $key, 'complete' => ! $complete ); echo ''; } echo '
  2. '; } self::$setup_forms['af-setup-dismiss'] = array( 'post' => $post->ID, 'step' => 'dismissed', 'complete' => empty( $progress['dismissed'] ) ); echo '

'; } /** External forms avoid nesting a second form inside WordPress's editor form. */ public static function setup_forms() { foreach ( self::$setup_forms as $id => $data ) { echo '
'; wp_nonce_field( 'accessible_forms_setup_' . $data['post'], $id . '-nonce', false ); echo '
'; } } public static function save_setup() { $id = isset( $_POST['form_id'] ) ? absint( $_POST['form_id'] ) : 0; $step = isset( $_POST['step'] ) ? sanitize_key( wp_unslash( $_POST['step'] ) ) : ''; check_admin_referer( 'accessible_forms_setup_' . $id, 'af-setup-' . ( 'dismissed' === $step ? 'dismiss' : $step ) . '-nonce' ); $result = self::update_setup( $id, $step, ! empty( $_POST['complete'] ) ); if ( is_wp_error( $result ) ) { wp_die( esc_html( $result->get_error_message() ), '', array( 'response' => (int) $result->get_error_data(), 'back_link' => true ) ); } wp_safe_redirect( add_query_arg( 'af_editor_step', 'review', get_edit_post_link( $id, 'raw' ) ) . '#af-setup-progress' ); exit(); } /** The same checks serve enhanced requests and the native no-script form. */ public static function update_setup( $id, $step, $complete ) { if ( 'accessible_form' !== get_post_type( $id ) || ! current_user_can( 'edit_post', $id ) || ! in_array( $step, array( 'placed', 'tested', 'dismissed' ), true ) ) { return new \WP_Error( 'af_setup_permission', __( 'You cannot change this form’s setup progress.', 'accessible-forms-by-accessible-org' ), 403 ); } $data = get_user_meta( get_current_user_id(), self::setup_key( $id ), true ); $data = is_array( $data ) ? $data : array(); if ( 'dismissed' !== $step && $complete && 'publish' !== get_post_status( $id ) ) { return new \WP_Error( 'af_setup_unpublished', __( 'Publish the form before confirming a page or submission test.', 'accessible-forms-by-accessible-org' ), 400 ); } $data[ $step ] = (bool) $complete; if ( false === update_user_meta( get_current_user_id(), self::setup_key( $id ), $data ) && get_user_meta( get_current_user_id(), self::setup_key( $id ), true ) !== $data ) { return new \WP_Error( 'af_setup_save', __( 'The checklist could not be saved. Your form edits are kept. Try again.', 'accessible-forms-by-accessible-org' ), 503 ); } return $data; } public static function ajax_setup() { $id = isset( $_POST['form_id'] ) ? absint( $_POST['form_id'] ) : 0; $step = isset( $_POST['step'] ) && is_string( $_POST['step'] ) ? sanitize_key( wp_unslash( $_POST['step'] ) ) : ''; $nonce = 'af-setup-' . ( 'dismissed' === $step ? 'dismiss' : $step ) . '-nonce'; if ( ! check_ajax_referer( 'accessible_forms_setup_' . $id, $nonce, false ) ) { wp_send_json_error( array( 'message' => __( 'Your checklist could not be saved. Refresh your sign-in before trying again; keep a copy of any unsaved form edits.', 'accessible-forms-by-accessible-org' ) ), 403 ); } $result = self::update_setup( $id, $step, ! empty( $_POST['complete'] ) ); if ( is_wp_error( $result ) ) { wp_send_json_error( array( 'message' => $result->get_error_message() ), (int) $result->get_error_data() ); } ob_start(); self::setup( get_post( $id ), Schema::get( $id ) ?: array() ); $html = ob_get_clean(); wp_send_json_success( array( 'html' => $html, 'forms' => self::$setup_forms, 'nonce' => wp_create_nonce( 'accessible_forms_setup_' . $id ), 'message' => __( 'Checklist saved.', 'accessible-forms-by-accessible-org' ) ) ); } public static function delete_setup( $id ) { if ( 'accessible_form' !== get_post_type( $id ) ) { return; } // The metadata API invalidates each affected user's cache as well as deleting rows. delete_metadata( 'user', 0, self::setup_key( $id ), '', true ); } }