array( field => value ). * @param array $opts Options: label, token (batch token shared by chunks), record_undo (bool), * expect (id => field => expected current value; mismatches are skipped), * context (edit|bulk|undo|rollback|schedule). * @return array { * @type int[] $saved IDs saved. * @type array $errors id => array( field => message ). * @type array $changes Recorded changes. * @type int $conflicts Fields skipped because they changed meanwhile. * @type array $objects Saved objects. * } */ public static function apply( BDBE_Entity $entity, array $changes, array $opts = array() ) { $opts = array_merge( array( 'label' => __( 'Grid edit', 'gridsmith' ), 'token' => '', 'record_undo' => true, 'expect' => array(), 'context' => 'edit', ), $opts ); $result = array( 'saved' => array(), 'errors' => array(), 'changes' => array(), 'conflicts' => 0, 'objects' => array(), ); foreach ( $changes as $id => $fields ) { $id = absint( $id ); if ( ! $id || ! is_array( $fields ) || ! $fields ) { continue; } $object = $entity->load( $id ); if ( ! $object ) { $result['errors'][ $id ] = array( '_row' => __( 'This record no longer exists.', 'gridsmith' ) ); continue; } if ( ! $entity->can_edit( $object ) ) { $result['errors'][ $id ] = array( '_row' => __( 'You are not allowed to edit this record.', 'gridsmith' ) ); continue; } $row_errors = array(); $row_old = array(); foreach ( $fields as $field => $raw ) { $field = (string) $field; $def = $entity->field( $field ); if ( ! $def ) { $row_errors[ $field ] = __( 'Unknown column.', 'gridsmith' ); continue; } if ( ! BDBE_Fields::editable( $def, $object ) ) { $row_errors[ $field ] = __( 'This value cannot be edited for this record.', 'gridsmith' ); continue; } $value = BDBE_Fields::sanitize( $def, $raw ); if ( is_wp_error( $value ) ) { $row_errors[ $field ] = $value->get_error_message(); continue; } $current = (string) call_user_func( $def['get'], $object ); if ( isset( $opts['expect'][ $id ] ) && array_key_exists( $field, $opts['expect'][ $id ] ) && ! BDBE_Fields::same( $def, $current, $opts['expect'][ $id ][ $field ] ) ) { ++$result['conflicts']; continue; } if ( BDBE_Fields::same( $def, $current, $value ) ) { continue; } try { $set = call_user_func( $def['set'], $object, $value ); } catch ( Exception $e ) { $set = new WP_Error( 'bdbe_set', $e->getMessage() ); } if ( is_wp_error( $set ) ) { $row_errors[ $field ] = $set->get_error_message(); continue; } $row_old[ $field ] = $current; } if ( ! $row_errors && $row_old ) { $row_errors = $entity->validate( $object, array_keys( $row_old ) ); } if ( $row_errors ) { $result['errors'][ $id ] = $row_errors; continue; } if ( ! $row_old ) { continue; } try { $entity->save( $object ); } catch ( Exception $e ) { $result['errors'][ $id ] = array( '_row' => $e->getMessage() ); continue; } $result['saved'][] = $id; $result['objects'][] = $object; foreach ( $row_old as $field => $old ) { $def = $entity->field( $field ); $result['changes'][] = array( 't' => $entity->key(), 'i' => $id, 'f' => $field, 'o' => (string) $old, 'n' => (string) call_user_func( $def['get'], $object ), ); } } if ( $result['objects'] ) { $entity->after_save( $result['objects'] ); } if ( $result['changes'] ) { $token = '' !== $opts['token'] ? $opts['token'] : wp_generate_uuid4(); if ( $opts['record_undo'] ) { self::push_undo( $token, $opts['label'], $result['changes'] ); } /** * Fires after changes were saved (Pro writes them to the history table). * * @param string $token Batch token. * @param string $label Batch label. * @param array $changes Changes (t, i, f, o, n). * @param int $user_id User ID. * @param string $context edit|bulk|undo|rollback|schedule. */ do_action( 'bdbe_changes_recorded', $token, $opts['label'], $result['changes'], get_current_user_id(), $opts['context'] ); } return $result; } /** * Store (or extend) the current user's undo batch. * * @param string $token Batch token. * @param string $label Label. * @param array $changes Changes. */ public static function push_undo( $token, $label, array $changes ) { $user = get_current_user_id(); if ( ! $user ) { return; } $undo = self::undo_record( $user ); if ( ! $undo || $undo['token'] !== $token ) { $undo = array( 'token' => $token, 'label' => $label, 'time' => time(), 'changes' => array(), ); } $undo['changes'] = array_merge( $undo['changes'], $changes ); if ( count( $undo['changes'] ) > self::UNDO_LIMIT ) { delete_user_meta( $user, self::UNDO_META ); return; } update_user_meta( $user, self::UNDO_META, $undo ); } /** * The user's undo batch. * * @param int $user User ID (default current). * @return array|null */ public static function undo_record( $user = 0 ) { $user = $user ? $user : get_current_user_id(); $undo = get_user_meta( $user, self::UNDO_META, true ); if ( ! is_array( $undo ) || empty( $undo['token'] ) || empty( $undo['changes'] ) || ! is_array( $undo['changes'] ) ) { return null; } return $undo; } /** * Short description of the undo batch for the UI. * * @return array|null */ public static function undo_info() { $undo = self::undo_record(); if ( ! $undo ) { return null; } $ids = array(); foreach ( $undo['changes'] as $change ) { $ids[ $change['t'] . $change['i'] ] = true; } return array( 'label' => (string) $undo['label'], 'records' => count( $ids ), 'changes' => count( $undo['changes'] ), 'time' => human_time_diff( (int) $undo['time'], time() ), ); } /** * Revert a list of changes (newest first), skipping fields edited since. * * @param array $changes Changes (t, i, f, o, n). * @param string $label Label for the new batch. * @param string $context undo|rollback. * @return array { restored: int, conflicts: int, errors: array, token: string } */ public static function revert( array $changes, $label, $context ) { $grouped = array(); $expect = array(); foreach ( array_reverse( $changes ) as $change ) { if ( ! isset( $change['t'], $change['i'], $change['f'] ) ) { continue; } $type = (string) $change['t']; $id = absint( $change['i'] ); // The oldest value of a field wins when one batch changed it twice. $grouped[ $type ][ $id ][ $change['f'] ] = (string) $change['o']; if ( ! isset( $expect[ $type ][ $id ][ $change['f'] ] ) ) { $expect[ $type ][ $id ][ $change['f'] ] = (string) $change['n']; } } $token = wp_generate_uuid4(); $out = array( 'restored' => 0, 'conflicts' => 0, 'errors' => array(), 'token' => $token, ); foreach ( $grouped as $type => $rows ) { $entity = BDBE_Plugin::entity( $type ); if ( ! $entity ) { continue; } $result = self::apply( $entity, $rows, array( 'label' => $label, 'token' => $token, 'record_undo' => false, 'expect' => isset( $expect[ $type ] ) ? $expect[ $type ] : array(), 'context' => $context, ) ); $out['restored'] += count( $result['changes'] ); $out['conflicts'] += $result['conflicts']; foreach ( $result['errors'] as $id => $errors ) { $out['errors'][] = '#' . $id . ': ' . implode( ' ', $errors ); } } return $out; } /** * Undo the current user's last batch. * * @return array|WP_Error */ public static function undo() { $undo = self::undo_record(); if ( ! $undo ) { return new WP_Error( 'bdbe_no_undo', __( 'There is nothing to undo.', 'gridsmith' ), array( 'status' => 400 ) ); } delete_user_meta( get_current_user_id(), self::UNDO_META ); return self::revert( $undo['changes'], /* translators: %s: label of the batch being undone. */ sprintf( __( 'Undo: %s', 'gridsmith' ), $undo['label'] ), 'undo' ); } }