array( 'name' => __( 'Shared tabs', 'prodtabs' ), 'singular_name' => __( 'Shared tab', 'prodtabs' ), 'menu_name' => __( 'Shared tabs', 'prodtabs' ), 'all_items' => __( 'Shared tabs', 'prodtabs' ), 'add_new' => __( 'Add shared tab', 'prodtabs' ), 'add_new_item' => _x( 'Add shared tab', 'page title', 'prodtabs' ), 'edit_item' => __( 'Edit shared tab', 'prodtabs' ), 'new_item' => __( 'New shared tab', 'prodtabs' ), 'search_items' => __( 'Search shared tabs', 'prodtabs' ), 'not_found' => __( 'No shared tabs found.', 'prodtabs' ), 'not_found_in_trash' => __( 'No shared tabs found in Trash.', 'prodtabs' ), 'filter_items_list' => __( 'Filter shared tabs list', 'prodtabs' ), 'items_list_navigation' => __( 'Shared tabs list navigation', 'prodtabs' ), 'items_list' => __( 'Shared tabs list', 'prodtabs' ), 'item_published' => __( 'Shared tab published.', 'prodtabs' ), 'item_published_privately' => __( 'Shared tab published privately.', 'prodtabs' ), 'item_reverted_to_draft' => __( 'Shared tab reverted to draft.', 'prodtabs' ), 'item_scheduled' => __( 'Shared tab scheduled.', 'prodtabs' ), 'item_updated' => __( 'Shared tab updated.', 'prodtabs' ), ), 'description' => __( 'Product tabs shared between products.', 'prodtabs' ), 'public' => false, 'show_ui' => true, 'show_in_menu' => Admin_Hub::parent_slug(), 'show_in_admin_bar' => false, 'show_in_nav_menus' => false, 'show_in_rest' => true, 'rest_base' => 'prodtabs_tabs', 'rest_controller_class' => Library_Rest_Controller::class, 'supports' => array( 'title', 'editor', 'revisions', 'custom-fields' ), 'capability_type' => 'product', 'map_meta_cap' => true, 'rewrite' => false, 'query_var' => false, 'delete_with_user' => false, ) ); register_post_meta( self::POST_TYPE, self::META_SHOP_TITLE, array( 'type' => 'string', 'single' => true, 'default' => '', 'show_in_rest' => true, 'sanitize_callback' => 'sanitize_text_field', 'auth_callback' => static function ( $allowed, $meta_key, $post_id ) { return current_user_can( 'edit_post', $post_id ); }, ) ); register_post_meta( self::POST_TYPE, self::META_SCOPE, array( 'type' => 'string', 'single' => true, 'default' => '', 'show_in_rest' => true, // Any choice is kept, also one of an extension that is not running (Premium's "terms"), which then adds // the tab nowhere: saving the tab meanwhile would otherwise lose it. 'sanitize_callback' => static function ( $value ) { return is_scalar( $value ) ? sanitize_key( (string) $value ) : ''; }, 'auth_callback' => static function ( $allowed, $meta_key, $post_id ) { return current_user_can( 'edit_post', $post_id ); }, ) ); } /** * Choices of the "Show on" setting of a shared tab. * * @return string[] Label by value; the empty value is the default. */ public static function scopes(): array { /** * Choices of the "Show on" setting of a shared tab. An extension that adds one decides where it applies with the * prodtabs_shared_tab_applies filter. * * @param string[] $scopes Label by value. */ $scopes = (array) apply_filters( 'prodtabs_shared_tab_scopes', array( '' => __( 'Products where it is added', 'prodtabs' ), 'all' => __( 'All products', 'prodtabs' ), ) ); return array( '' => $scopes[''] ?? __( 'Products where it is added', 'prodtabs' ) ) + $scopes; } /** * Shared tabs added on their own to a product by their "Show on" setting, one per group of translations. * * @param \WC_Product $product Product. * @return array[] id (the tab in the default language when it is published), anchor (title it was created with), family (ids of the tab and its translations). */ public static function auto_tabs( \WC_Product $product ): array { $args = array( 'post_type' => self::POST_TYPE, 'post_status' => 'publish', 'fields' => 'ids', 'posts_per_page' => 100, 'orderby' => array( 'menu_order' => 'ASC', 'ID' => 'ASC', ), 'no_found_rows' => true, 'update_post_term_cache' => false, 'lang' => '', // Polylang: every language. // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- small post type, one key. 'meta_query' => array( array( 'key' => self::META_SCOPE, 'value' => '', 'compare' => '!=', ), ), ); $default = Multilingual::default_language(); $seen = array(); $groups = 0; $tabs = array(); // Every translation is a row: the limit counts groups of translations, read page after page. for ( $page = 1; $groups < 100; ++$page ) { $ids = get_posts( $args + array( 'paged' => $page ) ); foreach ( array_map( 'intval', $ids ) as $id ) { if ( isset( $seen[ $id ] ) ) { continue; } ++$groups; $family = Multilingual::translation_ids( $id, self::POST_TYPE ); $seen += array_fill_keys( $family, true ); // The tab in the default language when it is published; otherwise the first published one of the group. $main = '' !== $default ? Multilingual::translation( $id, self::POST_TYPE, $default ) : 0; $main = $main && in_array( $main, $family, true ) && 'publish' === get_post_status( $main ) ? $main : $id; $post = get_post( $main ); $scope = $post ? (string) get_post_meta( $main, self::META_SCOPE, true ) : ''; /** * Whether a shared tab is added on its own to a product. * * @param bool $applies True for the "All products" setting. * @param int $id Shared tab (in the default language when it has one). * @param \WC_Product $product Product. * @param string $scope Value of the "Show on" setting. */ if ( '' === $scope || ! apply_filters( 'prodtabs_shared_tab_applies', 'all' === $scope, $main, $product, $scope ) ) { continue; } $tabs[] = array( 'id' => $main, 'anchor' => self::shop_title( $post ), 'family' => $family, ); } if ( count( $ids ) < 100 ) { break; } } return $tabs; } /** * A published library tab, ready for the shop. * * @param int $id Post id. * @return array|null id, title (shop title), content (raw), anchor; null when missing or not published. */ public static function get_tab( int $id ): ?array { $post = get_post( $id ); if ( ! $post || self::POST_TYPE !== $post->post_type || 'publish' !== $post->post_status ) { return null; } return array( 'id' => $post->ID, 'title' => self::shop_title( $post ), 'content' => $post->post_content, 'anchor' => Tabs::anchor( self::shop_title( $post ), 'tab-' . $post->ID ), ); } /** * Library tab to show for a stored id, in the language of the page: its published translation, or the tab as it * was chosen while the translation is missing or not published yet (unless prodtabs_untranslated_library_tab * says otherwise). When the id is itself that translation (WPML points the ids of the shortcode to the language of * the page), the original of its group shows instead. * * @param int $id Library tab id stored in the product. * @return array|null Tab (see get_tab()), null when nothing can be shown. */ public static function resolve( int $id ): ?array { $translated = Multilingual::translate_post_id( $id, self::POST_TYPE ); $tab = $translated ? self::get_tab( $translated ) : null; if ( $tab || ! $translated ) { return $tab; } $fallback = $translated !== $id ? $id : Multilingual::original( $id, self::POST_TYPE ); if ( ! $fallback || $fallback === $translated ) { return null; } /** This filter is documented in includes/class-multilingual.php */ return apply_filters( 'prodtabs_untranslated_library_tab', ! Settings::get( 'hide_untranslated' ), $fallback, self::POST_TYPE ) ? self::get_tab( $fallback ) : null; } /** * Title shown in the shop: the shop title when set, the post title otherwise. * * @param \WP_Post $post Library tab. */ public static function shop_title( \WP_Post $post ): string { $title = (string) get_post_meta( $post->ID, self::META_SHOP_TITLE, true ); return '' !== trim( $title ) ? $title : $post->post_title; } /** * Meta query matching the products whose tabs use a library tab or one of its translations. A product stores the * library tab as it was chosen, whatever its language: the structure is the same in every language. * * @param int $id Library tab id. */ public static function usage_meta_query( int $id ): array { $query = array( 'relation' => 'OR' ); foreach ( Multilingual::translation_ids( $id, self::POST_TYPE ) as $translation ) { $query[] = array( 'key' => Tabs::META_LAYOUT, 'value' => '"library_id";i:' . $translation . ';', 'compare' => 'LIKE', ); } return $query; } /** * Number of products that use each library tab (or one of its translations), every language counted. * * @param int[] $ids Library tab ids. * @return int[] Count by id. */ public static function usage_counts( array $ids ): array { $counts = array(); foreach ( array_filter( array_map( 'absint', $ids ) ) as $id ) { $counts[ $id ] = (int) Multilingual::all_languages( static function () use ( $id ) { return ( new \WP_Query( self::usage_args( $id, 1 ) ) )->found_posts; } ); } return $counts; } /** * Products that use a library tab or one of its translations. * * @param int $id Library tab id. * @param int $limit Maximum number of ids. * @return int[] */ public static function products_using( int $id, int $limit = 50 ): array { $args = self::usage_args( $id, $limit ); $args['no_found_rows'] = true; return array_map( 'intval', Multilingual::all_languages( static function () use ( $args ) { return ( new \WP_Query( $args ) )->posts; } ) ); } /** * Query of the products that use a library tab. * * @param int $id Library tab id. * @param int $limit Products per page. */ private static function usage_args( int $id, int $limit ): array { return array( 'post_type' => 'product', 'post_status' => array( 'publish', 'draft', 'pending', 'private', 'future' ), 'fields' => 'ids', 'posts_per_page' => $limit, 'update_post_term_cache' => false, 'update_post_meta_cache' => false, 'lang' => '', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- one meta key, admin screens only. 'meta_query' => self::usage_meta_query( $id ), ); } }