*/ private $groups_for_product = array(); /** * Memoized group index for this request. Null until first read. * * The per-product memo above already covers a single product page, but a * request can ask about several products — a grouped product adds each of * its children in one go — and there is no reason to re-read the transient * (or re-prime the meta cache) for each of them. * * @var array[]|null */ private $group_index = null; /** * Field type registry. * * @var Shapeoptions_Field_Registry */ public $fields; /** * Condition matcher. * * @var Shapeoptions_Conditions */ public $conditions; /** * Get the singleton. * * @return Shapeoptions_Plugin */ public static function instance() { if ( null === self::$instance ) { self::$instance = new self(); } return self::$instance; } /** * Load files and register hooks. */ private function __construct() { $this->includes(); $this->fields = new Shapeoptions_Field_Registry(); $this->conditions = new Shapeoptions_Conditions(); new Shapeoptions_Post_Type(); new Shapeoptions_Display( $this ); new Shapeoptions_Cart( $this ); new Shapeoptions_I18n(); new Shapeoptions_Uploads(); if ( is_admin() ) { $admin = new Shapeoptions_Admin( $this ); $templates = new Shapeoptions_Templates( $admin ); new Shapeoptions_Import_Export( $admin ); new Shapeoptions_Dashboard( $templates ); } new Shapeoptions_Style_Settings(); $this->register_cache_invalidation(); /** * Fires after ShapeOptions is fully loaded. Add-ons boot their modules here. * * @param Shapeoptions_Plugin $plugin The plugin container. */ do_action( 'shapeoptions/loaded', $this ); } /** * Require all component files. */ private function includes() { $base = SHAPEOPTIONS_PATH . 'includes/'; require_once $base . 'class-shapeoptions-post-type.php'; require_once $base . 'class-shapeoptions-conditions.php'; require_once $base . 'class-shapeoptions-uploads.php'; require_once $base . 'fields/abstract-shapeoptions-field.php'; require_once $base . 'fields/class-shapeoptions-field-registry.php'; require_once $base . 'fields/class-shapeoptions-field-input.php'; require_once $base . 'fields/class-shapeoptions-field-choice.php'; require_once $base . 'fields/class-shapeoptions-field-static.php'; require_once $base . 'fields/class-shapeoptions-field-file.php'; require_once $base . 'frontend/class-shapeoptions-display.php'; require_once $base . 'frontend/class-shapeoptions-cart.php'; require_once $base . 'class-shapeoptions-i18n.php'; require_once $base . 'admin/class-shapeoptions-style-settings.php'; if ( is_admin() ) { require_once $base . 'admin/class-shapeoptions-admin.php'; require_once $base . 'admin/class-shapeoptions-import-export.php'; require_once $base . 'admin/class-shapeoptions-templates.php'; require_once $base . 'admin/class-shapeoptions-dashboard.php'; } } /** * Hook up option group cache invalidation. * * Every write path goes through one of these: the builder and the importer * both end in wp_insert_post()/wp_update_post() (so `save_post_…` fires), * and trashing, untrashing or deleting a group fires its own action. A * status change alone is enough to change the index, because it only lists * published groups. */ private function register_cache_invalidation() { add_action( 'save_post_shapeoptions_group', array( $this, 'flush_index' ) ); add_action( 'deleted_post', array( $this, 'flush_index_for_post' ), 10, 2 ); add_action( 'trashed_post', array( $this, 'flush_index_for_post' ) ); add_action( 'untrashed_post', array( $this, 'flush_index_for_post' ) ); add_action( 'shapeoptions/group_saved', array( $this, 'flush_index' ) ); // The index caches conditions and placement, so a direct meta write // (an add-on, WP-CLI) must bust it too. add_action( 'added_post_meta', array( $this, 'flush_index_for_meta' ), 10, 3 ); add_action( 'updated_post_meta', array( $this, 'flush_index_for_meta' ), 10, 3 ); add_action( 'deleted_post_meta', array( $this, 'flush_index_for_meta' ), 10, 3 ); } /** * Drop the cached index when a meta value it caches changes. * * @param int|int[] $meta_id Meta ID(s). * @param int $object_id Post ID. * @param string $meta_key Meta key. */ public function flush_index_for_meta( $meta_id, $object_id, $meta_key ) { if ( '_shapeoptions_conditions' === $meta_key || '_shapeoptions_placement' === $meta_key ) { $this->flush_index(); } } /** * Drop the cached group index. */ public function flush_index() { delete_transient( self::INDEX_TRANSIENT ); $this->group_index = null; $this->groups_for_product = array(); } /** * Drop the cached index, but only when the post in question is an option * group. Hooked to actions that fire for every post type. * * @param int $post_id Post ID. * @param WP_Post|null $post Post object, when the action supplies one. */ public function flush_index_for_post( $post_id, $post = null ) { $post_type = $post instanceof WP_Post ? $post->post_type : get_post_type( $post_id ); if ( 'shapeoptions_group' === $post_type ) { $this->flush_index(); } } /** * The ordered index of published option groups, cached in a transient. * * Building it reads each group's conditions and placement; get_posts() * primes the meta cache for the batch, so that is one meta query per 100 * groups, and it only happens when the transient is rebuilt. * * @return array[] Each item: [ 'id' => int, 'title' => string, * 'conditions' => mixed, 'placement' => string ], in menu order. */ private function get_group_index() { if ( null !== $this->group_index ) { return $this->group_index; } $index = get_transient( self::INDEX_TRANSIENT ); if ( ! is_array( $index ) ) { $index = array(); $batch = 100; $offset = 0; do { $posts = get_posts( array( 'post_type' => 'shapeoptions_group', 'post_status' => 'publish', 'posts_per_page' => $batch, 'offset' => $offset, 'orderby' => array( 'menu_order' => 'ASC', 'ID' => 'ASC', ), 'no_found_rows' => true, // Groups carry no taxonomies; skip the term cache priming. 'update_post_term_cache' => false, ) ); foreach ( $posts as $post ) { $index[] = array( 'id' => (int) $post->ID, 'title' => $post->post_title, 'conditions' => get_post_meta( $post->ID, '_shapeoptions_conditions', true ), 'placement' => get_post_meta( $post->ID, '_shapeoptions_placement', true ) ?: 'before', ); } $offset += $batch; } while ( count( $posts ) === $batch ); set_transient( self::INDEX_TRANSIENT, $index, self::INDEX_TTL ); } $this->group_index = $index; return $index; } /** * Get all published option groups that apply to a product. * * Memoized per product for the duration of the request — see * $groups_for_product. * * @param int $product_id Product ID. * @return array[] Each item: [ 'id' => int, 'fields' => array, 'placement' => string ]. */ public function get_groups_for_product( $product_id ) { $product_id = (int) $product_id; if ( isset( $this->groups_for_product[ $product_id ] ) ) { return $this->groups_for_product[ $product_id ]; } $matched = array(); foreach ( $this->get_group_index() as $entry ) { $group_id = $entry['id']; $conditions = isset( $entry['conditions'] ) ? $entry['conditions'] : array(); $applies = $this->conditions->group_applies( $conditions, $product_id ); /** * Filter whether an option group applies to a product (add-ons * register extra targeting rules). * * @param bool $applies Current decision. * @param array $conditions Saved condition config. * @param int $product_id Product ID. * @param int $group_id Option group ID. */ if ( ! apply_filters( 'shapeoptions/group_applies', $applies, $conditions, $product_id, $group_id ) ) { continue; } $matched[] = $entry; } // Load field configs for the matching groups only, in one query. if ( $matched ) { update_postmeta_cache( wp_list_pluck( $matched, 'id' ) ); } $groups = array(); foreach ( $matched as $entry ) { $fields = get_post_meta( $entry['id'], '_shapeoptions_fields', true ); if ( empty( $fields ) || ! is_array( $fields ) ) { continue; } $groups[] = array( 'id' => $entry['id'], 'title' => $entry['title'], 'fields' => $fields, 'placement' => isset( $entry['placement'] ) ? $entry['placement'] : 'before', ); } $this->groups_for_product[ $product_id ] = $groups; return $groups; } } endif;