(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 '
';
}
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 '' . 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 'post_status ? ' disabled' : '' ) . '>' . esc_html( $complete ? __( 'Mark as to do', 'accessible-forms-by-accessible-org' ) : __( 'I checked this', 'accessible-forms-by-accessible-org' ) ) . ': ' . esc_html( $label ) . ' ';
}
echo ' ';
}
self::$setup_forms['af-setup-dismiss'] = array( 'post' => $post->ID, 'step' => 'dismissed', 'complete' => empty( $progress['dismissed'] ) );
echo ' ' . esc_html( empty( $progress['dismissed'] ) ? __( 'Keep this checklist collapsed', 'accessible-forms-by-accessible-org' ) : __( 'Keep this checklist open', 'accessible-forms-by-accessible-org' ) ) . '
';
}
/** 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 '';
}
}
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 );
}
}