'ID', 'label' => __( 'ID', 'rafgrid' ), 'type' => 'readonly', 'group' => __( 'General', 'rafgrid' ), 'editable' => false, 'sortable' => true, 'width' => 80, ); $cols['post_title'] = array( 'key' => 'post_title', 'label' => __( 'Title', 'rafgrid' ), 'type' => 'text', 'group' => __( 'General', 'rafgrid' ), 'editable' => true, 'sortable' => true, 'width' => 220, ); $cols['post_name'] = array( 'key' => 'post_name', 'label' => __( 'Slug', 'rafgrid' ), 'type' => 'text', 'group' => __( 'General', 'rafgrid' ), 'editable' => true, 'sortable' => false, 'width' => 160, ); $cols['post_status'] = array( 'key' => 'post_status', 'label' => __( 'Status', 'rafgrid' ), 'type' => 'select', 'group' => __( 'General', 'rafgrid' ), 'editable' => true, 'sortable' => false, 'options' => self::status_options( $post_type ), 'width' => 130, ); $cols['post_author'] = array( 'key' => 'post_author', 'label' => __( 'Author', 'rafgrid' ), 'type' => 'select', 'group' => __( 'General', 'rafgrid' ), 'editable' => true, 'sortable' => true, 'options' => self::author_options(), 'width' => 150, ); $cols['post_date'] = array( 'key' => 'post_date', 'label' => __( 'Date', 'rafgrid' ), 'type' => 'date', 'group' => __( 'General', 'rafgrid' ), 'editable' => true, 'sortable' => true, 'width' => 150, ); $cols['post_modified'] = array( 'key' => 'post_modified', 'label' => __( 'Modified', 'rafgrid' ), 'type' => 'readonly', 'group' => __( 'General', 'rafgrid' ), 'editable' => false, 'sortable' => true, 'width' => 150, ); $cols['post_excerpt'] = array( 'key' => 'post_excerpt', 'label' => __( 'Excerpt', 'rafgrid' ), 'type' => 'textarea', 'group' => __( 'Content', 'rafgrid' ), 'editable' => true, 'sortable' => false, 'width' => 220, ); $cols['post_content'] = array( 'key' => 'post_content', 'label' => __( 'Content', 'rafgrid' ), 'type' => 'textarea', 'group' => __( 'Content', 'rafgrid' ), 'editable' => true, 'sortable' => false, 'width' => 240, ); if ( is_post_type_hierarchical( $post_type ) ) { $cols['post_parent'] = array( 'key' => 'post_parent', 'label' => __( 'Parent ID', 'rafgrid' ), 'type' => 'number', 'group' => __( 'General', 'rafgrid' ), 'editable' => true, 'sortable' => false, 'width' => 100, ); $cols['menu_order'] = array( 'key' => 'menu_order', 'label' => __( 'Order', 'rafgrid' ), 'type' => 'number', 'group' => __( 'General', 'rafgrid' ), 'editable' => true, 'sortable' => true, 'width' => 90, ); } $cols['_thumbnail_id'] = array( 'key' => '_thumbnail_id', 'label' => __( 'Featured image', 'rafgrid' ), 'type' => 'image', 'group' => __( 'Content', 'rafgrid' ), 'editable' => true, 'sortable' => false, 'width' => 140, ); // Taxonomies. foreach ( self::taxonomies_for( $post_type ) as $tax => $tax_obj ) { $cols[ 'tax:' . $tax ] = array( 'key' => 'tax:' . $tax, 'label' => $tax_obj->labels->singular_name ? $tax_obj->label : $tax, 'type' => 'taxonomy', 'taxonomy' => $tax, 'group' => __( 'Taxonomies', 'rafgrid' ), 'editable' => true, 'sortable' => false, 'width' => 180, ); } // WooCommerce product columns. if ( in_array( $post_type, array( 'product', 'product_variation' ), true ) && Plugin::has_woo() ) { foreach ( self::woo_columns() as $key => $def ) { $cols[ $key ] = $def; } } // Comment count readonly (useful). $cols['comment_count'] = array( 'key' => 'comment_count', 'label' => __( 'Comments', 'rafgrid' ), 'type' => 'readonly', 'group' => __( 'General', 'rafgrid' ), 'editable' => false, 'sortable' => true, 'width' => 90, ); // ACF fields (type-aware where cheap: select/checkbox/date/number/image). if ( function_exists( 'acf_get_field_groups' ) && function_exists( 'acf_get_fields' ) ) { $groups = acf_get_field_groups( array( 'post_type' => $post_type ) ); foreach ( (array) $groups as $group ) { $fields = acf_get_fields( $group ); foreach ( (array) $fields as $field ) { if ( empty( $field['name'] ) || in_array( $field['type'], array( 'repeater', 'flexible_content', 'group', 'tab', 'message' ), true ) ) { continue; } $key = 'meta:' . $field['name']; if ( isset( $cols[ $key ] ) ) { continue; } $type_map = array( 'textarea' => 'textarea', 'number' => 'number', 'true_false' => 'checkbox', 'date_picker' => 'date', 'select' => 'select', 'image' => 'image' ); $def = array( 'key' => $key, 'label' => ( $field['label'] ?? $field['name'] ) . ' (ACF)', 'type' => $type_map[ $field['type'] ] ?? 'text', 'group' => 'ACF', 'editable' => true, 'sortable' => false, 'width' => 160, ); if ( 'select' === $def['type'] && ! empty( $field['choices'] ) && is_array( $field['choices'] ) ) { $def['options'] = $field['choices']; } $cols[ $key ] = $def; } } } // SEO plugin fields. if ( defined( 'WPSEO_VERSION' ) ) { $cols['meta:_yoast_wpseo_title'] = array( 'key' => 'meta:_yoast_wpseo_title', 'label' => __( 'SEO title (Yoast)', 'rafgrid' ), 'type' => 'text', 'group' => 'SEO', 'editable' => true, 'sortable' => false, 'width' => 200 ); $cols['meta:_yoast_wpseo_metadesc'] = array( 'key' => 'meta:_yoast_wpseo_metadesc', 'label' => __( 'SEO description (Yoast)', 'rafgrid' ), 'type' => 'textarea', 'group' => 'SEO', 'editable' => true, 'sortable' => false, 'width' => 220 ); } if ( class_exists( 'RankMath', false ) || function_exists( 'rank_math' ) ) { $cols['meta:rank_math_title'] = array( 'key' => 'meta:rank_math_title', 'label' => __( 'SEO title (Rank Math)', 'rafgrid' ), 'type' => 'text', 'group' => 'SEO', 'editable' => true, 'sortable' => false, 'width' => 200 ); $cols['meta:rank_math_description'] = array( 'key' => 'meta:rank_math_description', 'label' => __( 'SEO description (Rank Math)', 'rafgrid' ), 'type' => 'textarea', 'group' => 'SEO', 'editable' => true, 'sortable' => false, 'width' => 220 ); } // Polylang / WPML language (read-only). if ( function_exists( 'pll_get_post_language' ) || defined( 'ICL_LANGUAGE_CODE' ) ) { $cols['_language'] = array( 'key' => '_language', 'label' => __( 'Language', 'rafgrid' ), 'type' => 'readonly', 'group' => __( 'General', 'rafgrid' ), 'editable' => false, 'sortable' => false, 'width' => 90 ); } $cols['_seo_score'] = array( 'key' => '_seo_score', 'label' => __( 'SEO score', 'rafgrid' ), 'type' => 'readonly', 'group' => 'SEO', 'editable' => false, 'sortable' => false, 'width' => 90, ); /** * Filter columns. Add custom fields as meta:KEY columns here. * * @param array $cols Columns. * @param string $post_type Post type. */ $cache[ $post_type ] = apply_filters( 'rafgrid/columns', $cols, $post_type ); return $cache[ $post_type ]; } /** * Taxonomies for type. * * @param string $post_type Post type. * @return object[] */ public static function taxonomies_for( $post_type ) { $out = array(); $taxs = get_object_taxonomies( $post_type, 'objects' ); foreach ( $taxs as $slug => $obj ) { if ( empty( $obj->show_ui ) ) { continue; } $out[ $slug ] = $obj; } return $out; } /** * Woo columns. * * @return array */ public static function woo_columns() { $g = __( 'WooCommerce', 'rafgrid' ); return array( '_sku' => array( 'key' => '_sku', 'label' => __( 'SKU', 'rafgrid' ), 'type' => 'text', 'group' => $g, 'editable' => true, 'sortable' => false, 'width' => 130, ), '_product_type' => array( 'key' => '_product_type', 'label' => __( 'Product type', 'rafgrid' ), 'type' => 'select', 'group' => $g, 'editable' => true, 'sortable' => false, 'width' => 130, 'options' => array( 'simple' => __( 'Simple', 'rafgrid' ), 'grouped' => __( 'Grouped', 'rafgrid' ), 'external' => __( 'External', 'rafgrid' ), 'variable' => __( 'Variable', 'rafgrid' ), ), ), '_regular_price' => array( 'key' => '_regular_price', 'label' => __( 'Regular price', 'rafgrid' ), 'type' => 'number', 'group' => $g, 'editable' => true, 'sortable' => true, 'width' => 120, ), '_sale_price' => array( 'key' => '_sale_price', 'label' => __( 'Sale price', 'rafgrid' ), 'type' => 'number', 'group' => $g, 'editable' => true, 'sortable' => true, 'width' => 110, ), '_price' => array( 'key' => '_price', 'label' => __( 'Active price (read-only)', 'rafgrid' ), 'type' => 'readonly', 'group' => $g, 'editable' => false, 'sortable' => true, 'width' => 120, ), '_stock_status' => array( 'key' => '_stock_status', 'label' => __( 'Stock status', 'rafgrid' ), 'type' => 'select', 'group' => $g, 'editable' => true, 'sortable' => false, 'width' => 130, 'options' => array( 'instock' => __( 'In stock', 'rafgrid' ), 'outofstock' => __( 'Out of stock', 'rafgrid' ), 'onbackorder' => __( 'On backorder', 'rafgrid' ), ), ), '_stock' => array( 'key' => '_stock', 'label' => __( 'Stock qty', 'rafgrid' ), 'type' => 'number', 'group' => $g, 'editable' => true, 'sortable' => true, 'width' => 100, ), '_manage_stock' => array( 'key' => '_manage_stock', 'label' => __( 'Manage stock', 'rafgrid' ), 'type' => 'checkbox', 'group' => $g, 'editable' => true, 'sortable' => false, 'width' => 110, ), '_featured' => array( 'key' => '_featured', 'label' => __( 'Featured', 'rafgrid' ), 'type' => 'checkbox', 'group' => $g, 'editable' => true, 'sortable' => false, 'width' => 100, ), '_visibility' => array( 'key' => '_visibility', 'label' => __( 'Catalog visibility', 'rafgrid' ), 'type' => 'select', 'group' => $g, 'editable' => true, 'sortable' => false, 'width' => 140, 'options' => array( 'visible' => __( 'Visible', 'rafgrid' ), 'catalog' => __( 'Catalog', 'rafgrid' ), 'search' => __( 'Search', 'rafgrid' ), 'hidden' => __( 'Hidden', 'rafgrid' ), ), ), '_weight' => array( 'key' => '_weight', 'label' => __( 'Weight', 'rafgrid' ), 'type' => 'number', 'group' => $g, 'editable' => true, 'sortable' => false, 'width' => 100, ), '_length' => array( 'key' => '_length', 'label' => __( 'Length', 'rafgrid' ), 'type' => 'number', 'group' => $g, 'editable' => true, 'sortable' => false, 'width' => 100, ), '_width' => array( 'key' => '_width', 'label' => __( 'Width', 'rafgrid' ), 'type' => 'number', 'group' => $g, 'editable' => true, 'sortable' => false, 'width' => 100, ), '_height' => array( 'key' => '_height', 'label' => __( 'Height', 'rafgrid' ), 'type' => 'number', 'group' => $g, 'editable' => true, 'sortable' => false, 'width' => 100, ), '_tax_status' => array( 'key' => '_tax_status', 'label' => __( 'Tax status', 'rafgrid' ), 'type' => 'select', 'group' => $g, 'editable' => true, 'sortable' => false, 'width' => 120, 'options' => array( 'taxable' => __( 'Taxable', 'rafgrid' ), 'shipping' => __( 'Shipping only', 'rafgrid' ), 'none' => __( 'None', 'rafgrid' ), ), ), '_sale_price_dates_from' => array( 'key' => '_sale_price_dates_from', 'label' => __( 'Sale from', 'rafgrid' ), 'type' => 'date', 'group' => $g, 'editable' => true, 'sortable' => false, 'width' => 120, ), '_sale_price_dates_to' => array( 'key' => '_sale_price_dates_to', 'label' => __( 'Sale to', 'rafgrid' ), 'type' => 'date', 'group' => $g, 'editable' => true, 'sortable' => false, 'width' => 120, ), '_tax_class' => array( 'key' => '_tax_class', 'label' => __( 'Tax class', 'rafgrid' ), 'type' => 'select', 'group' => $g, 'editable' => true, 'sortable' => false, 'width' => 120, 'options' => array( '' => __( 'Standard', 'rafgrid' ), 'reduced-rate' => __( 'Reduced rate', 'rafgrid' ), 'zero-rate' => __( 'Zero rate', 'rafgrid' ) ), ), '_virtual' => array( 'key' => '_virtual', 'label' => __( 'Virtual', 'rafgrid' ), 'type' => 'checkbox', 'group' => $g, 'editable' => true, 'sortable' => false, 'width' => 90, ), '_downloadable' => array( 'key' => '_downloadable', 'label' => __( 'Downloadable', 'rafgrid' ), 'type' => 'checkbox', 'group' => $g, 'editable' => true, 'sortable' => false, 'width' => 110, ), '_sold_individually' => array( 'key' => '_sold_individually', 'label' => __( 'Sold individually', 'rafgrid' ), 'type' => 'checkbox', 'group' => $g, 'editable' => true, 'sortable' => false, 'width' => 120, ), '_backorders' => array( 'key' => '_backorders', 'label' => __( 'Backorders', 'rafgrid' ), 'type' => 'select', 'group' => $g, 'editable' => true, 'sortable' => false, 'width' => 120, 'options' => array( 'no' => __( 'No', 'rafgrid' ), 'notify' => __( 'Notify', 'rafgrid' ), 'yes' => __( 'Yes', 'rafgrid' ) ), ), '_low_stock_amount' => array( 'key' => '_low_stock_amount', 'label' => __( 'Low stock threshold', 'rafgrid' ), 'type' => 'number', 'group' => $g, 'editable' => true, 'sortable' => false, 'width' => 130, ), '_global_unique_id' => array( 'key' => '_global_unique_id', 'label' => __( 'GTIN / MPN', 'rafgrid' ), 'type' => 'text', 'group' => $g, 'editable' => true, 'sortable' => false, 'width' => 130, ), '_product_image_gallery' => array( 'key' => '_product_image_gallery', 'label' => __( 'Gallery image IDs', 'rafgrid' ), 'type' => 'text', 'group' => $g, 'editable' => true, 'sortable' => false, 'width' => 150, ), '_cost_price' => array( 'key' => '_cost_price', 'label' => __( 'Cost price', 'rafgrid' ), 'type' => 'number', 'group' => $g, 'editable' => true, 'sortable' => false, 'width' => 110, ), '_margin' => array( 'key' => '_margin', 'label' => __( 'Margin %', 'rafgrid' ), 'type' => 'readonly', 'group' => $g, 'editable' => false, 'sortable' => false, 'width' => 90, ), '_stock_forecast' => array( 'key' => '_stock_forecast', 'label' => __( 'Stock days left', 'rafgrid' ), 'type' => 'readonly', 'group' => $g, 'editable' => false, 'sortable' => false, 'width' => 110, ), '_upsell_ids' => array( 'key' => '_upsell_ids', 'label' => __( 'Upsell IDs', 'rafgrid' ), 'type' => 'text', 'group' => $g, 'editable' => true, 'sortable' => false, 'width' => 130, ), ); } /** * Status options. * * @param string $post_type Post type. * @return array */ public static function status_options( $post_type ) { $statuses = get_post_stati( array(), 'objects' ); $out = array(); foreach ( $statuses as $slug => $obj ) { if ( 'attachment' === $slug ) { continue; } $out[ $slug ] = $obj->label; } if ( ! isset( $out['publish'] ) ) { $out['publish'] = __( 'Published', 'rafgrid' ); } return $out; } /** * Authors for select (limited to 100 for performance). * * @return array */ public static function author_options() { $users = get_users( array( 'capability__in' => array( 'edit_posts', 'edit_products' ), 'number' => 100, 'fields' => array( 'ID', 'display_name' ), ) ); $out = array(); foreach ( $users as $u ) { $out[ (string) $u->ID ] = $u->display_name . ' (#' . $u->ID . ')'; } return $out; } /** * Get a cell value. * * @param int $post_id Post ID. * @param string $key Column key. * @return mixed scalar|array for taxonomy/image helpers. */ public static function get_value( $post_id, $key ) { $post = get_post( $post_id ); if ( ! $post ) { return null; } // Meta column. if ( str_starts_with( $key, 'meta:' ) ) { $mkey = substr( $key, 5 ); $val = get_post_meta( $post_id, $mkey, true ); return is_scalar( $val ) || is_null( $val ) ? $val : wp_json_encode( $val ); } // Taxonomy. if ( str_starts_with( $key, 'tax:' ) ) { $tax = substr( $key, 4 ); $terms = wp_get_post_terms( $post_id, $tax, array( 'fields' => 'ids' ) ); return is_wp_error( $terms ) ? array() : array_map( 'intval', $terms ); } switch ( $key ) { case 'ID': return (int) $post_id; case 'post_name': // Slugs with non-latin chars are stored percent-encoded; show them readable. return urldecode( $post->post_name ); case 'post_title': case 'post_status': case 'post_excerpt': case 'post_content': case 'post_parent': case 'menu_order': case 'comment_count': return $post->{$key}; case 'post_author': return (string) $post->post_author; case 'post_date': case 'post_modified': return $post->{$key}; case '_seo_score': $score = 0; if ( strlen( (string) $post->post_title ) >= 10 ) { $score += 25; } if ( (int) get_post_meta( $post_id, '_thumbnail_id', true ) > 0 ) { $score += 25; } if ( str_word_count( wp_strip_all_tags( (string) $post->post_content ) ) > 50 ) { $score += 25; } if ( '' !== (string) get_post_meta( $post_id, '_yoast_wpseo_metadesc', true ) || '' !== (string) get_post_meta( $post_id, 'rank_math_description', true ) || '' !== trim( (string) $post->post_excerpt ) ) { $score += 25; } return $score; case '_language': if ( function_exists( 'pll_get_post_language' ) ) { return pll_get_post_language( $post_id ) ?: ''; } return apply_filters( 'wpml_post_language_details', null, $post_id )['language_code'] ?? ( defined( 'ICL_LANGUAGE_CODE' ) ? ICL_LANGUAGE_CODE : '' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WPML's public API filter. case '_thumbnail_id': $tid = (int) get_post_meta( $post_id, '_thumbnail_id', true ); if ( ! $tid ) { return null; } return array( 'id' => $tid, 'url' => wp_get_attachment_image_url( $tid, 'thumbnail' ), ); default: // Woo + generic meta fallthrough. if ( in_array( $post->post_type, array( 'product', 'product_variation' ), true ) ) { $woo = self::get_woo_value( $post_id, $key ); if ( null !== $woo || array_key_exists( $key, self::woo_columns() ) ) { return $woo; } } $meta = get_post_meta( $post_id, $key, true ); return is_scalar( $meta ) || is_null( $meta ) ? $meta : wp_json_encode( $meta ); } } /** * Woo getter. * * @param int $post_id Post ID. * @param string $key Key. * @return mixed */ private static function get_woo_value( $post_id, $key ) { if ( ! function_exists( 'wc_get_product' ) ) { // Fallback to raw meta when WC not loaded (e.g. CLI without full load is still fine). switch ( $key ) { case '_product_type': $terms = wp_get_post_terms( $post_id, 'product_type', array( 'fields' => 'slugs' ) ); return ( ! is_wp_error( $terms ) && ! empty( $terms ) ) ? $terms[0] : 'simple'; case '_featured': $terms = wp_get_post_terms( $post_id, 'product_visibility', array( 'fields' => 'slugs' ) ); return ( ! is_wp_error( $terms ) && in_array( 'featured', $terms, true ) ) ? 1 : 0; case '_visibility': $terms = wp_get_post_terms( $post_id, 'product_visibility', array( 'fields' => 'slugs' ) ); if ( is_wp_error( $terms ) ) { return 'visible'; } if ( in_array( 'exclude-from-search', $terms, true ) && in_array( 'exclude-from-catalog', $terms, true ) ) { return 'hidden'; } if ( in_array( 'exclude-from-search', $terms, true ) ) { return 'catalog'; } if ( in_array( 'exclude-from-catalog', $terms, true ) ) { return 'search'; } return 'visible'; default: $v = get_post_meta( $post_id, $key, true ); if ( '_manage_stock' === $key ) { return 'yes' === $v ? 1 : 0; } return $v; } } $product = wc_get_product( $post_id ); if ( ! $product ) { return get_post_meta( $post_id, $key, true ); } switch ( $key ) { case '_sku': return $product->get_sku(); case '_product_type': return $product->get_type(); case '_regular_price': return $product->get_regular_price(); case '_sale_price': return $product->get_sale_price(); case '_price': return $product->get_price(); case '_stock_status': return $product->get_stock_status(); case '_stock': return $product->get_stock_quantity(); case '_manage_stock': return method_exists( $product, 'get_manage_stock' ) && $product->get_manage_stock() ? 1 : 0; case '_featured': return $product->is_featured() ? 1 : 0; case '_visibility': return method_exists( $product, 'get_catalog_visibility' ) ? $product->get_catalog_visibility() : 'visible'; case '_weight': return $product->get_weight(); case '_length': return $product->get_length(); case '_width': return $product->get_width(); case '_height': return $product->get_height(); case '_tax_status': return method_exists( $product, 'get_tax_status' ) ? $product->get_tax_status() : get_post_meta( $post_id, '_tax_status', true ); case '_sale_price_dates_from': $d = method_exists( $product, 'get_date_on_sale_from' ) ? $product->get_date_on_sale_from() : null; return $d ? $d->format( 'Y-m-d' ) : ''; case '_sale_price_dates_to': $d = method_exists( $product, 'get_date_on_sale_to' ) ? $product->get_date_on_sale_to() : null; return $d ? $d->format( 'Y-m-d' ) : ''; case '_tax_class': return method_exists( $product, 'get_tax_class' ) ? $product->get_tax_class() : get_post_meta( $post_id, '_tax_class', true ); case '_virtual': return method_exists( $product, 'is_virtual' ) && $product->is_virtual() ? 1 : 0; case '_downloadable': return method_exists( $product, 'is_downloadable' ) && $product->is_downloadable() ? 1 : 0; case '_sold_individually': return method_exists( $product, 'is_sold_individually' ) && $product->is_sold_individually() ? 1 : 0; case '_backorders': return method_exists( $product, 'get_backorders' ) ? $product->get_backorders() : get_post_meta( $post_id, '_backorders', true ); case '_low_stock_amount': return method_exists( $product, 'get_low_stock_amount' ) ? $product->get_low_stock_amount() : get_post_meta( $post_id, '_low_stock_amount', true ); case '_global_unique_id': return method_exists( $product, 'get_global_unique_id' ) ? $product->get_global_unique_id() : get_post_meta( $post_id, '_global_unique_id', true ); case '_product_image_gallery': return method_exists( $product, 'get_gallery_image_ids' ) ? implode( ',', $product->get_gallery_image_ids() ) : get_post_meta( $post_id, '_product_image_gallery', true ); case '_upsell_ids': return method_exists( $product, 'get_upsell_ids' ) ? implode( ',', $product->get_upsell_ids() ) : ''; case '_cost_price': return get_post_meta( $post_id, '_cost_price', true ); case '_margin': $price = (float) $product->get_price(); $cost = get_post_meta( $post_id, '_cost_price', true ); if ( $price <= 0 || '' === $cost || ! is_numeric( $cost ) ) { return ''; } return round( ( $price - (float) $cost ) / $price * 100, 1 ); case '_stock_forecast': return self::forecast_days( $post_id, $product ); default: return get_post_meta( $post_id, $key, true ); } } /** * Days of stock left from 30-day sales velocity (Woo order product lookup). * * @param int $post_id Post ID. * @param mixed $product WC product. * @return int|string days or '—'. */ private static function forecast_days( $post_id, $product ) { global $wpdb; static $has_table = null; if ( null === $has_table ) { $table = $wpdb->prefix . 'wc_order_product_lookup'; $has_table = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Checked once per request (static). } if ( ! $has_table || ! is_object( $product ) || ! method_exists( $product, 'get_stock_quantity' ) ) { return '—'; } // Units sold in the last 30 days; an hour-old figure is precise enough for a forecast. $qty30 = wp_cache_get( 'sold30_' . (int) $post_id, 'rafgrid' ); if ( false === $qty30 ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- WooCommerce analytics lookup table, cached below. $qty30 = (float) $wpdb->get_var( $wpdb->prepare( 'SELECT SUM(product_qty) FROM %i WHERE product_id = %d AND date_created >= DATE_SUB(NOW(), INTERVAL 30 DAY)', $wpdb->prefix . 'wc_order_product_lookup', $post_id ) ); wp_cache_set( 'sold30_' . (int) $post_id, $qty30, 'rafgrid', HOUR_IN_SECONDS ); } $qty30 = (float) $qty30; if ( $qty30 <= 0 ) { return '—'; } $stock = $product->get_stock_quantity(); if ( null === $stock ) { return '—'; } return (int) floor( (float) $stock / ( $qty30 / 30 ) ); } /** * SEO plugin fields written by the SEO columns, AI generation and the MCP * rafgrid_set_seo tool (Yoast SEO + Rank Math). */ public const array SEO_META_KEYS = array( '_yoast_wpseo_title', '_yoast_wpseo_metadesc', '_yoast_wpseo_focuskw', '_yoast_wpseo_canonical', 'rank_math_title', 'rank_math_description', 'rank_math_focus_keyword', 'rank_math_canonical_url' ); /** * Sanitize a custom field value. Plain text is kept exactly as typed; * strings containing HTML tags keep only the tags allowed in post content; * arrays and objects are cleaned recursively, other scalars pass through. * * @param mixed $value Unslashed value. * @return mixed */ public static function sanitize_meta_value( $value ) { return map_deep( $value, static function ( $v ) { if ( ! is_string( $v ) ) { return is_scalar( $v ) || null === $v ? $v : ''; } $v = wp_check_invalid_utf8( $v ); // Only strings that contain a tag go through kses, so plain text such as "A & B <3" stays exact. return preg_match( '/<[a-zA-Z\/!?]/', $v ) ? wp_kses_post( $v ) : $v; } ); } /** * Save a cell value. Returns WP_Error|true. * * @param int $post_id Post ID. * @param string $key Column key. * @param mixed $value Value. * @return true|\WP_Error */ public static function save_value( $post_id, $key, $value ) { // Capability checks happen in Saving::save_cell(); this method only validates and writes. $post = get_post( $post_id ); if ( ! $post ) { return new \WP_Error( 'rafgrid_not_found', __( 'Post not found.', 'rafgrid' ) ); } if ( str_starts_with( $key, 'meta:' ) ) { $mkey = substr( $key, 5 ); if ( ! preg_match( '/^[A-Za-z0-9_\-]+$/', $mkey ) ) { return new \WP_Error( 'rafgrid_bad_key', __( 'Invalid meta key.', 'rafgrid' ) ); } update_post_meta( $post_id, $mkey, self::sanitize_meta_value( wp_unslash( $value ) ) ); return true; } if ( str_starts_with( $key, 'tax:' ) ) { $tax = substr( $key, 4 ); $ids = array(); if ( is_string( $value ) ) { // Accept "12,34" or "name1, name2". $parts = array_filter( array_map( 'trim', explode( ',', $value ) ) ); foreach ( $parts as $p ) { if ( is_numeric( $p ) ) { $ids[] = (int) $p; } else { $term = get_term_by( 'name', $p, $tax ) ?: get_term_by( 'slug', sanitize_title( $p ), $tax ); if ( $term ) { $ids[] = (int) $term->term_id; } else { $new = wp_insert_term( $p, $tax ); if ( ! is_wp_error( $new ) ) { $ids[] = (int) $new['term_id']; } } } } } elseif ( is_array( $value ) ) { $ids = array_map( 'intval', $value ); } $res = wp_set_post_terms( $post_id, $ids, $tax, false ); return is_wp_error( $res ) ? $res : true; } switch ( $key ) { case 'post_title': $title = sanitize_text_field( wp_unslash( (string) $value ) ); if ( '' === $title ) { return new \WP_Error( 'rafgrid_empty_title', __( 'Title cannot be empty.', 'rafgrid' ) ); } wp_update_post( array( 'ID' => $post_id, 'post_title' => $title, ) ); return true; case 'post_name': $old_link = get_permalink( $post_id ); wp_update_post( array( 'ID' => $post_id, 'post_name' => sanitize_title( wp_unslash( (string) $value ) ), ) ); $new_link = get_permalink( $post_id ); if ( $old_link && $new_link && $old_link !== $new_link ) { $map = get_option( 'rafgrid_redirects', array() ); if ( ! is_array( $map ) ) { $map = array(); } $map[ trim( (string) wp_parse_url( $old_link, PHP_URL_PATH ), '/' ) ] = trim( (string) wp_parse_url( $new_link, PHP_URL_PATH ), '/' ); update_option( 'rafgrid_redirects', array_slice( $map, -500, 500, true ), false ); } return true; case 'post_status': $allowed = array_keys( self::status_options( $post->post_type ) ); $st = sanitize_key( (string) $value ); if ( ! in_array( $st, $allowed, true ) ) { return new \WP_Error( 'rafgrid_bad_status', __( 'Invalid status.', 'rafgrid' ) ); } wp_update_post( array( 'ID' => $post_id, 'post_status' => $st, ) ); return true; case 'post_author': $uid = absint( $value ); if ( 0 === $uid ) { return new \WP_Error( 'rafgrid_no_author', __( 'Please choose an author from the list.', 'rafgrid' ) ); } if ( ! get_user_by( 'id', $uid ) ) { return new \WP_Error( 'rafgrid_bad_author', __( 'Unknown author.', 'rafgrid' ) ); } wp_update_post( array( 'ID' => $post_id, 'post_author' => $uid, ) ); return true; case 'post_date': $date = sanitize_text_field( (string) $value ); $ts = strtotime( $date ); if ( false === $ts ) { return new \WP_Error( 'rafgrid_bad_date', __( 'Invalid date.', 'rafgrid' ) ); } wp_update_post( array( 'ID' => $post_id, 'post_date' => gmdate( 'Y-m-d H:i:s', $ts ), 'post_date_gmt' => gmdate( 'Y-m-d H:i:s', $ts ), ) ); return true; case 'post_excerpt': wp_update_post( array( 'ID' => $post_id, 'post_excerpt' => sanitize_textarea_field( wp_unslash( (string) $value ) ), ) ); return true; case 'post_content': wp_update_post( array( 'ID' => $post_id, 'post_content' => wp_kses_post( wp_unslash( (string) $value ) ), ) ); return true; case 'post_parent': case 'menu_order': wp_update_post( array( 'ID' => $post_id, $key => absint( $value ), ) ); return true; case '_seo_score': $score = 0; if ( strlen( (string) $post->post_title ) >= 10 ) { $score += 25; } if ( (int) get_post_meta( $post_id, '_thumbnail_id', true ) > 0 ) { $score += 25; } if ( str_word_count( wp_strip_all_tags( (string) $post->post_content ) ) > 50 ) { $score += 25; } if ( '' !== (string) get_post_meta( $post_id, '_yoast_wpseo_metadesc', true ) || '' !== (string) get_post_meta( $post_id, 'rank_math_description', true ) || '' !== trim( (string) $post->post_excerpt ) ) { $score += 25; } return $score; case '_language': if ( function_exists( 'pll_get_post_language' ) ) { return pll_get_post_language( $post_id ) ?: ''; } return apply_filters( 'wpml_post_language_details', null, $post_id )['language_code'] ?? ( defined( 'ICL_LANGUAGE_CODE' ) ? ICL_LANGUAGE_CODE : '' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WPML's public API filter. case '_thumbnail_id': $tid = absint( is_array( $value ) && isset( $value['id'] ) ? $value['id'] : $value ); if ( 0 === $tid ) { delete_post_meta( $post_id, '_thumbnail_id' ); return true; } if ( 'attachment' !== get_post_type( $tid ) ) { return new \WP_Error( 'rafgrid_bad_image', __( 'Not an attachment.', 'rafgrid' ) ); } update_post_meta( $post_id, '_thumbnail_id', $tid ); return true; default: if ( in_array( $post->post_type, array( 'product', 'product_variation' ), true ) && array_key_exists( $key, self::woo_columns() ) ) { return self::save_woo_value( $post_id, $key, $value ); } update_post_meta( $post_id, $key, self::sanitize_meta_value( wp_unslash( $value ) ) ); return true; } } /** * Woo saver. * * @param int $post_id Post ID. * @param string $key Key. * @param mixed $value Value. * @return true|\WP_Error */ private static function save_woo_value( $post_id, $key, $value ) { if ( function_exists( 'wc_get_product' ) ) { $product = wc_get_product( $post_id ); if ( $product ) { switch ( $key ) { case '_sku': $sku = wc_clean( wp_unslash( (string) $value ) ); if ( '' !== $sku ) { $other = wc_get_product_id_by_sku( $sku ); if ( $other && (int) $other !== (int) $post_id ) { return new \WP_Error( 'rafgrid_dup_sku', __( 'SKU already exists.', 'rafgrid' ) ); } } $product->set_sku( $sku ); $product->save(); return true; case '_product_type': $type = sanitize_key( (string) $value ); if ( ! in_array( $type, array( 'simple', 'grouped', 'external', 'variable' ), true ) ) { return new \WP_Error( 'rafgrid_bad_type', __( 'Invalid product type.', 'rafgrid' ) ); } wp_set_post_terms( $post_id, array( $type ), 'product_type', false ); return true; case '_regular_price': case '_sale_price': $price = '' === $value || null === $value ? '' : wc_format_decimal( wp_unslash( (string) $value ) ); if ( '' !== $price && ! is_numeric( $price ) ) { return new \WP_Error( 'rafgrid_bad_price', __( 'Invalid price.', 'rafgrid' ) ); } if ( '_regular_price' === $key ) { $product->set_regular_price( $price ); } else { $product->set_sale_price( $price ); } $product->save(); return true; case '_stock_status': $st = sanitize_key( (string) $value ); if ( ! in_array( $st, array( 'instock', 'outofstock', 'onbackorder' ), true ) ) { return new \WP_Error( 'rafgrid_bad_stock', __( 'Invalid stock status.', 'rafgrid' ) ); } $product->set_stock_status( $st ); $product->save(); return true; case '_stock': if ( '' === $value || null === $value ) { $product->set_manage_stock( false ); $product->set_stock_quantity( null ); } else { $product->set_manage_stock( true ); $product->set_stock_quantity( (int) $value ); } $product->save(); return true; case '_manage_stock': $product->set_manage_stock( ! empty( $value ) && '0' !== (string) $value && 'no' !== (string) $value ); $product->save(); return true; case '_featured': $product->set_featured( ! empty( $value ) && '0' !== (string) $value ); $product->save(); return true; case '_visibility': if ( method_exists( $product, 'set_catalog_visibility' ) ) { $product->set_catalog_visibility( sanitize_key( (string) $value ) ); $product->save(); return true; } break; case '_weight': case '_length': case '_width': case '_height': $setter = 'set' . $key; if ( method_exists( $product, $setter ) ) { $product->{$setter}( wc_clean( wp_unslash( (string) $value ) ) ); $product->save(); return true; } break; case '_sale_price_dates_from': if ( method_exists( $product, 'set_date_on_sale_from' ) ) { $product->set_date_on_sale_from( '' === $value || null === $value ? '' : strtotime( (string) $value ) ); $product->save(); return true; } break; case '_sale_price_dates_to': if ( method_exists( $product, 'set_date_on_sale_to' ) ) { $product->set_date_on_sale_to( '' === $value || null === $value ? '' : strtotime( (string) $value ) ); $product->save(); return true; } break; case '_tax_class': if ( method_exists( $product, 'set_tax_class' ) ) { $product->set_tax_class( sanitize_text_field( wp_unslash( (string) $value ) ) ); $product->save(); return true; } break; case '_virtual': case '_downloadable': case '_sold_individually': $setter = 'set' . $key; if ( method_exists( $product, $setter ) ) { $product->{$setter}( ! empty( $value ) && '0' !== (string) $value ); $product->save(); return true; } break; case '_backorders': if ( method_exists( $product, 'set_backorders' ) ) { $product->set_backorders( sanitize_key( (string) $value ) ); $product->save(); return true; } break; case '_low_stock_amount': if ( method_exists( $product, 'set_low_stock_amount' ) ) { $product->set_low_stock_amount( '' === $value || null === $value ? '' : absint( $value ) ); $product->save(); return true; } break; case '_global_unique_id': if ( method_exists( $product, 'set_global_unique_id' ) ) { $product->set_global_unique_id( sanitize_text_field( wp_unslash( (string) $value ) ) ); $product->save(); return true; } break; case '_product_image_gallery': if ( method_exists( $product, 'set_gallery_image_ids' ) ) { $ids = array_filter( array_map( 'absint', explode( ',', (string) $value ) ) ); $product->set_gallery_image_ids( array_values( $ids ) ); $product->save(); return true; } break; case '_upsell_ids': if ( method_exists( $product, 'set_upsell_ids' ) ) { $ids = array_filter( array_map( 'absint', explode( ',', (string) $value ) ) ); $product->set_upsell_ids( array_values( $ids ) ); $product->save(); return true; } break; case '_tax_status': if ( method_exists( $product, 'set_tax_status' ) ) { $product->set_tax_status( sanitize_key( (string) $value ) ); $product->save(); return true; } break; } } } // Raw fallback. if ( '_manage_stock' === $key ) { update_post_meta( $post_id, '_manage_stock', ( ! empty( $value ) && '0' !== (string) $value ) ? 'yes' : 'no' ); return true; } if ( '_featured' === $key ) { $on = ! empty( $value ) && '0' !== (string) $value; if ( taxonomy_exists( 'product_visibility' ) ) { $terms = wp_get_post_terms( $post_id, 'product_visibility', array( 'fields' => 'slugs' ) ); $terms = is_wp_error( $terms ) ? array() : $terms; if ( $on && ! in_array( 'featured', $terms, true ) ) { $terms[] = 'featured'; } elseif ( ! $on ) { $terms = array_diff( $terms, array( 'featured' ) ); } wp_set_post_terms( $post_id, array_values( $terms ), 'product_visibility', false ); } return true; } update_post_meta( $post_id, $key, wc_clean( wp_unslash( (string) $value ) ) ); return true; } }