post_type ); if ( ! isset( $cols[ $column ] ) ) { // Allow ad-hoc meta:KEY columns. if ( ! str_starts_with( $column, 'meta:' ) ) { return new \WP_Error( 'rafgrid_bad_column', __( 'Unknown column.', 'rafgrid' ) ); } } elseif ( empty( $cols[ $column ]['editable'] ) ) { return new \WP_Error( 'rafgrid_readonly', __( 'This column is read-only.', 'rafgrid' ) ); } if ( 'ID' === $column ) { return new \WP_Error( 'rafgrid_readonly', __( 'This column is read-only.', 'rafgrid' ) ); } $lock = get_transient( 'rafgrid_lock_' . $post_id ); if ( is_array( $lock ) && (int) ( $lock['user'] ?? 0 ) !== get_current_user_id() && ( ( $lock['time'] ?? 0 ) + 60 ) > time() ) { $owner = get_user_by( 'id', (int) $lock['user'] ); /* translators: %s: display name of the user who is editing the row */ return new \WP_Error( 'rafgrid_locked', sprintf( __( 'Locked by %s.', 'rafgrid' ), $owner ? $owner->display_name : __( 'another user', 'rafgrid' ) ) ); } if ( ! Access::can_edit_column( $column ) ) { return new \WP_Error( 'rafgrid_protected', __( 'This column is protected by the administrator.', 'rafgrid' ) ); } $allowed = self::check_core_caps( $post, $column, $value ); if ( is_wp_error( $allowed ) ) { return $allowed; } $old = Columns::get_value( $post_id, $column ); $res = Columns::save_value( $post_id, $column, $value ); if ( ! is_wp_error( $res ) ) { Audit::log( $post_id, $column, $old, $value ); } return $res; } /** * Capabilities WordPress itself requires for some columns: publishing * (publish, schedule, private), handing an item to another author and * writing protected custom fields. * * @param \WP_Post $post Post. * @param string $column Column key. * @param mixed $value New value. * @return true|\WP_Error */ private static function check_core_caps( $post, $column, $value ) { if ( 'post_status' === $column && in_array( sanitize_key( (string) $value ), array( 'publish', 'future', 'private' ), true ) && ! Access::can_publish_type( $post->post_type ) ) { return new \WP_Error( 'rafgrid_cannot_publish', __( 'You are not allowed to publish items of this type.', 'rafgrid' ) ); } if ( 'post_author' === $column && absint( $value ) !== (int) $post->post_author && ! Access::can_edit_others( $post->post_type ) ) { return new \WP_Error( 'rafgrid_cannot_reassign', __( 'You are not allowed to assign items to other authors.', 'rafgrid' ) ); } if ( str_starts_with( $column, 'meta:' ) && ! Access::can_edit_meta( $post->ID, substr( $column, 5 ) ) ) { return new \WP_Error( 'rafgrid_protected_meta', __( 'This custom field is protected: only administrators can edit it.', 'rafgrid' ) ); } return true; } /** * Apply bulk operation to many posts. * * Modes: set, replace, append, prepend, increase_percent, increase_number, decrease_percent, decrease_number, clear * * @param int[] $ids Post IDs. * @param string $column Column key. * @param string $mode Mode. * @param mixed $value Value. * @param mixed $value2 Second value (for replace: search/replace). * @return array {updated, failed, errors} */ public static function bulk( $ids, $column, $mode, $value = '', $value2 = '' ) { $ids = array_values( array_unique( array_map( 'absint', (array) $ids ) ) ); $updated = 0; $failed = 0; $errors = array(); // Snapshot for undo. $snapshot = array(); foreach ( $ids as $id ) { if ( ! Access::can_edit_post( $id ) ) { ++$failed; continue; } $snapshot[ $id ] = Columns::get_value( $id, $column ); } $history_id = History::push( $column . '|' . $mode, $snapshot ); foreach ( $ids as $id ) { if ( ! array_key_exists( $id, $snapshot ) ) { continue; } $current = $snapshot[ $id ]; $new = ( BulkMode::Formula->value === $mode ) ? self::formula_eval( (string) $value, $id ) : self::transform( $current, $mode, $value, $value2 ); if ( is_wp_error( $new ) ) { ++$failed; $errors[ $id ] = $new->get_error_message(); continue; } // save_cell applies every per-item rule and writes the audit entry. $res = self::save_cell( $id, $column, $new ); if ( is_wp_error( $res ) ) { ++$failed; $errors[ $id ] = $res->get_error_message(); } else { ++$updated; } } $settings = new Settings(); $webhook = (string) $settings->get( 'webhook_url', '' ); if ( '' !== $webhook && $updated > 0 ) { wp_remote_post( $webhook, array( 'blocking' => false, 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => wp_json_encode( array( 'text' => 'Rafgrid bulk: ' . $column . '|' . $mode . ' updated=' . $updated . ' failed=' . $failed . ' by ' . wp_get_current_user()->display_name ) ), ) ); } return array( 'updated' => $updated, 'failed' => $failed, 'errors' => $errors, 'history_id' => $history_id, ); } /** * Transform value per mode. * * @param mixed $current Current. * @param string $mode Mode. * @param mixed $value Value. * @param mixed $value2 Value2. * @return mixed */ public static function transform( $current, $mode, $value, $value2 = '' ) { if ( is_array( $current ) && isset( $current['id'] ) ) { $current = $current['id']; // image helper. } if ( is_array( $current ) ) { // taxonomy ids: only set/clear make sense. if ( 'clear' === $mode ) { return array(); } return $value; } $cur = (string) $current; $val = is_string( $value ) ? wp_unslash( $value ) : $value; $val2 = is_string( $value2 ) ? wp_unslash( $value2 ) : $value2; $m = BulkMode::tryFrom( (string) $mode ) ?? BulkMode::Set; return match ( $m ) { BulkMode::Set => $val, BulkMode::Clear => '', BulkMode::Replace => str_replace( (string) $val, (string) $val2, $cur ), BulkMode::Append => $cur . (string) $val, BulkMode::Prepend => (string) $val . $cur, BulkMode::IncreaseNumber => (float) $cur + (float) $val, BulkMode::DecreaseNumber => (float) $cur - (float) $val, BulkMode::IncreasePercent => round( (float) $cur * ( 1 + (float) $val / 100 ), 4 ), BulkMode::DecreasePercent => round( (float) $cur * ( 1 - (float) $val / 100 ), 4 ), BulkMode::Round99 => (float) $cur > 0 ? floor( (float) $cur ) + 0.99 : $cur, }; } /** * Safe formula evaluation: [column] placeholders, + - * /, parentheses, * round(), min(), max(). Returns float or WP_Error. No eval(). * * @param string $expr Expression. * @param int $post_id Post ID. * @return float|\WP_Error */ public static function formula_eval( string $expr, int $post_id ) { $resolved = preg_replace_callback( '/\[([A-Za-z0-9_:\-]+)\]/', static function ( $m ) use ( $post_id ) { $v = Columns::get_value( $post_id, $m[1] ); return is_numeric( $v ) ? (string) (float) $v : '0'; }, $expr ); if ( ! preg_match( '/^[0-9+\-*\/.()\s,a-z]*$/i', (string) $resolved ) ) { return new \WP_Error( 'rafgrid_bad_formula', __( 'Formula contains unsupported characters.', 'rafgrid' ) ); } $tokens = self::formula_tokens( (string) $resolved ); if ( is_wp_error( $tokens ) ) { return $tokens; } $pos = 0; $out = self::formula_expr( $tokens, $pos ); if ( is_wp_error( $out ) ) { return $out; } return (float) $out; } private static function formula_tokens( string $s ) { $tokens = array(); $i = 0; $len = strlen( $s ); while ( $i < $len ) { $ch = $s[ $i ]; if ( ctype_space( $ch ) ) { ++$i; continue; } if ( str_contains( '+-*/(),', $ch ) ) { $tokens[] = $ch; ++$i; continue; } if ( ctype_digit( $ch ) || '.' === $ch ) { $num = ''; while ( $i < $len && ( ctype_digit( $s[ $i ] ) || '.' === $s[ $i ] ) ) { $num .= $s[ $i ]; ++$i; } $tokens[] = (float) $num; continue; } if ( ctype_alpha( $ch ) ) { $fn = ''; while ( $i < $len && ctype_alpha( $s[ $i ] ) ) { $fn .= $s[ $i ]; ++$i; } $fn = strtolower( $fn ); if ( ! in_array( $fn, array( 'round', 'min', 'max' ), true ) ) { return new \WP_Error( 'rafgrid_bad_formula', __( 'Unknown function in formula.', 'rafgrid' ) ); } $tokens[] = $fn; continue; } return new \WP_Error( 'rafgrid_bad_formula', __( 'Bad character in formula.', 'rafgrid' ) ); } return $tokens; } private static function formula_expr( array $tokens, int &$pos ) { $left = self::formula_term( $tokens, $pos ); if ( is_wp_error( $left ) ) { return $left; } while ( isset( $tokens[ $pos ] ) && in_array( $tokens[ $pos ], array( '+', '-' ), true ) ) { $op = $tokens[ $pos++ ]; $right = self::formula_term( $tokens, $pos ); if ( is_wp_error( $right ) ) { return $right; } $left = '+' === $op ? $left + $right : $left - $right; } return $left; } private static function formula_term( array $tokens, int &$pos ) { $left = self::formula_factor( $tokens, $pos ); if ( is_wp_error( $left ) ) { return $left; } while ( isset( $tokens[ $pos ] ) && in_array( $tokens[ $pos ], array( '*', '/' ), true ) ) { $op = $tokens[ $pos++ ]; $right = self::formula_factor( $tokens, $pos ); if ( is_wp_error( $right ) ) { return $right; } if ( '/' === $op && 0.0 === (float) $right ) { return new \WP_Error( 'rafgrid_div_zero', __( 'Division by zero in formula.', 'rafgrid' ) ); } $left = '*' === $op ? $left * $right : $left / $right; } return $left; } private static function formula_factor( array $tokens, int &$pos ) { $tok = $tokens[ $pos ] ?? null; if ( null === $tok ) { return new \WP_Error( 'rafgrid_bad_formula', __( 'Unexpected end of formula.', 'rafgrid' ) ); } if ( '-' === $tok ) { ++$pos; $v = self::formula_factor( $tokens, $pos ); return is_wp_error( $v ) ? $v : -$v; } if ( '(' === $tok ) { ++$pos; $v = self::formula_expr( $tokens, $pos ); if ( is_wp_error( $v ) ) { return $v; } if ( ( $tokens[ $pos ] ?? null ) !== ')' ) { return new \WP_Error( 'rafgrid_bad_formula', __( 'Missing closing parenthesis.', 'rafgrid' ) ); } ++$pos; return $v; } if ( is_string( $tok ) && in_array( $tok, array( 'round', 'min', 'max' ), true ) ) { $fn = $tok; ++$pos; if ( ( $tokens[ $pos ] ?? null ) !== '(' ) { return new \WP_Error( 'rafgrid_bad_formula', __( 'Bad function call.', 'rafgrid' ) ); } ++$pos; $args = array(); $arg = self::formula_expr( $tokens, $pos ); if ( is_wp_error( $arg ) ) { return $arg; } $args[] = $arg; while ( ( $tokens[ $pos ] ?? null ) === ',' ) { ++$pos; $arg = self::formula_expr( $tokens, $pos ); if ( is_wp_error( $arg ) ) { return $arg; } $args[] = $arg; } if ( ( $tokens[ $pos ] ?? null ) !== ')' ) { return new \WP_Error( 'rafgrid_bad_formula', __( 'Missing closing parenthesis.', 'rafgrid' ) ); } ++$pos; return match ( $fn ) { 'round' => round( (float) $args[0], (int) ( $args[1] ?? 0 ) ), 'min' => min( array_map( 'floatval', $args ) ), 'max' => max( array_map( 'floatval', $args ) ), }; } if ( is_float( $tok ) || is_int( $tok ) ) { ++$pos; return (float) $tok; } return new \WP_Error( 'rafgrid_bad_formula', __( 'Unexpected token in formula.', 'rafgrid' ) ); } /** * Dry-run preview of a bulk operation (no writes). * * @param int[] $ids IDs. * @param string $column Column. * @param string $mode Mode. * @param mixed $value Value. * @param mixed $value2 Value2. * @return array list of {id,title,old,new} */ public static function preview( $ids, $column, $mode, $value = '', $value2 = '' ) { $out = array(); foreach ( array_slice( array_map( 'absint', (array) $ids ), 0, 200 ) as $id ) { $post = get_post( $id ); if ( ! $post || ! Access::can_edit_post( $id ) ) { continue; } $old = Columns::get_value( $id, $column ); $new = self::transform( $old, $mode, $value, $value2 ); $out[] = array( 'id' => $id, 'title' => $post->post_title, 'old' => Audit::stringify( $old ), 'new' => Audit::stringify( $new ), ); } return $out; } /** * Resolve IDs matching grid filters (for "apply to all filtered"). * * @param string $post_type Post type. * @param array $params Filter params. * @param int $cap Safety cap. * @return int[] */ public static function filtered_ids( $post_type, $params, $cap = 2000 ) { $params['page'] = 1; $params['per_page'] = 1; $total = Query::fetch( $post_type, $params )['total']; if ( $total > $cap ) { return array(); } $params['per_page'] = max( 1, $total ); $data = Query::fetch( $post_type, $params ); return array_map( static fn( $r ) => (int) $r['ID'], $data['rows'] ); } /** * Apply pasted cells: list of {id, column, value} (Excel/TSV paste). * * @param array $cells Cells. * @return array {updated, failed, errors} */ public static function apply_cells( $cells ) { $updated = 0; $failed = 0; $errors = array(); foreach ( array_slice( (array) $cells, 0, 1000 ) as $cell ) { if ( ! is_array( $cell ) || empty( $cell['id'] ) || empty( $cell['column'] ) ) { ++$failed; continue; } $res = self::save_cell( absint( $cell['id'] ), sanitize_text_field( $cell['column'] ), $cell['value'] ?? '' ); if ( is_wp_error( $res ) ) { ++$failed; $errors[] = '#' . absint( $cell['id'] ) . ' ' . $cell['column'] . ': ' . $res->get_error_message(); } else { ++$updated; } } return array( 'updated' => $updated, 'failed' => $failed, 'errors' => array_slice( $errors, 0, 20 ) ); } /** * Duplicate a post (simple copy, no variations logic in v1 beyond meta copy). * * @param int $post_id Post ID. * @return int|\WP_Error new ID. */ public static function duplicate( $post_id ) { $post = get_post( absint( $post_id ) ); $type = $post ? get_post_type_object( $post->post_type ) : null; if ( ! $post || ! $type || ! Access::can_edit_post( $post->ID ) || ! current_user_can( $type->cap->create_posts ) ) { return new \WP_Error( 'rafgrid_forbidden', __( 'You cannot duplicate this item.', 'rafgrid' ) ); } $new_id = wp_insert_post( array( 'post_title' => $post->post_title . ' — ' . __( 'Copy', 'rafgrid' ), 'post_content' => $post->post_content, 'post_excerpt' => $post->post_excerpt, 'post_status' => 'draft', 'post_type' => $post->post_type, 'post_author' => get_current_user_id(), 'post_parent' => $post->post_parent, 'menu_order' => $post->menu_order, ), true ); if ( is_wp_error( $new_id ) ) { return $new_id; } $meta = get_post_meta( $post->ID ); foreach ( $meta as $k => $vals ) { if ( in_array( $k, array( '_edit_lock', '_edit_last' ), true ) ) { continue; } foreach ( (array) $vals as $v ) { add_post_meta( $new_id, $k, maybe_unserialize( $v ) ); } } // New SKU must be empty to avoid duplicates. delete_post_meta( $new_id, '_sku' ); $taxs = get_object_taxonomies( $post->post_type ); foreach ( $taxs as $tax ) { $terms = wp_get_post_terms( $post->ID, $tax, array( 'fields' => 'slugs' ) ); if ( ! is_wp_error( $terms ) ) { wp_set_post_terms( $new_id, $terms, $tax, false ); } } return (int) $new_id; } }