'object', 'single' => true, 'show_in_rest' => false, 'sanitize_callback' => array( __CLASS__, 'sanitize_texts' ), 'auth_callback' => static function ( $allowed, $meta_key, $post_id ) { return current_user_can( 'edit_post', $post_id ); }, ) ); } /** * Cleans the texts meta: titles as plain text, content filtered as for posts when the user may not post * unfiltered HTML (translators of WPML, REST API clients). * * @param mixed $value Texts by key. * @return array */ public static function sanitize_texts( $value ) { if ( ! is_array( $value ) ) { return array(); } $unfiltered = self::$trusted || current_user_can( 'unfiltered_html' ); $out = array(); foreach ( $value as $key => $text ) { if ( ! is_array( $text ) || ! Tabs::valid_key( (string) $key ) ) { continue; } $content = isset( $text['content'] ) && is_scalar( $text['content'] ) ? (string) $text['content'] : ''; $out[ $key ] = array( 'title' => isset( $text['title'] ) && is_scalar( $text['title'] ) ? sanitize_text_field( (string) $text['title'] ) : '', 'content' => $unfiltered ? $content : wp_kses_post( $content ), ); } return $out; } /** * Runs a write of content that was already trusted where it came from (import of YIKES tabs). * * @param callable $callback Writes. * @return mixed What the callback returns. */ public static function trusted( callable $callback ) { $previous = self::$trusted; self::$trusted = true; try { return $callback(); } finally { self::$trusted = $previous; } } /** * Entries of a product as stored, for its edit screen: texts of its own language only. Empty when the product has * no list of its own. * * @param int $product_id Product id. * @return array[] */ public static function get( int $product_id ): array { $layout = self::layout( $product_id ); if ( ! $layout ) { return array(); } $entries = Tabs::normalize( Tabs::join( $layout, get_post_meta( $product_id, Tabs::META_TEXTS, true ) ) ); return $entries ? Tabs::with_natives( $entries, Settings::get( 'native_order' ) ) : array(); } /** * Stored structure of a product, or of its source when the translation has none yet (created by an import or an * API that copies no meta). Saving such a translation then writes the shared structure instead of spreading its * absence to every language through Polylang's synchronization. * * @param int $product_id Product id. * @return array Stored layout, empty when there is none. */ public static function layout( int $product_id ): array { $layout = get_post_meta( $product_id, Tabs::META_LAYOUT, true ); if ( ! $layout ) { $source = Multilingual::source_product( $product_id ); $layout = $source ? get_post_meta( $source, Tabs::META_LAYOUT, true ) : array(); } return is_array( $layout ) ? $layout : array(); } /** * Entries of a product for the shop. Texts missing in the language of the product come from the source product * (the original with WPML, the default language with Polylang), then from the library tab a tab was customized * from; a tab with no text anywhere is left out. A translation without a list of its own uses the list of its source. * * @param int $product_id Product id. * @return array[] */ public static function for_display( int $product_id ): array { $layout = get_post_meta( $product_id, Tabs::META_LAYOUT, true ); $source = null; if ( ! $layout ) { $source = Multilingual::source_product( $product_id ); $layout = $source ? get_post_meta( $source, Tabs::META_LAYOUT, true ) : array(); if ( ! $layout ) { return array(); } } $entries = Tabs::join( $layout, get_post_meta( $product_id, Tabs::META_TEXTS, true ) ); $source_texts = null; foreach ( $entries as $i => $entry ) { if ( 'local' !== ( $entry['type'] ?? '' ) || ! empty( $entry['translated'] ) ) { continue; } if ( null === $source_texts ) { $source = null === $source ? Multilingual::source_product( $product_id ) : $source; $source_texts = $source ? get_post_meta( $source, Tabs::META_TEXTS, true ) : array(); $source_texts = is_array( $source_texts ) ? $source_texts : array(); } $text = $source_texts[ $entry['key'] ] ?? null; if ( is_array( $text ) && '' !== ( $text['title'] ?? '' ) ) { $entries[ $i ]['title'] = (string) $text['title']; $entries[ $i ]['content'] = (string) ( $text['content'] ?? '' ); continue; } $library = ! empty( $entry['origin'] ) ? Library::resolve( (int) $entry['origin'] ) : null; if ( $library ) { $entries[ $i ]['title'] = $library['title']; $entries[ $i ]['content'] = $library['content']; } } $entries = Tabs::normalize( $entries ); return $entries ? Tabs::with_natives( $entries, Settings::get( 'native_order' ) ) : array(); } /** * Adds to a list the shared tabs that the "Show on" setting adds on their own, and drops those it no longer adds. * A tab already in the list (added by hand, customized, or hidden on this product) stays where it is. * * @param array[] $entries Normalised entries, empty when the product has no list of its own. * @param \WC_Product $product Product. * @return array[] The list unchanged when no shared tab is added on its own. */ public static function with_auto( array $entries, \WC_Product $product ): array { $auto = Library::auto_tabs( $product ); $family = array(); // Shared tab id => index of its group in $auto. foreach ( $auto as $i => $tab ) { $family += array_fill_keys( $tab['family'], $i ); } $present = array(); $kept = array(); $changed = false; foreach ( $entries as $entry ) { $id = 'library' === $entry['type'] ? (int) $entry['library_id'] : ( 'local' === $entry['type'] ? (int) $entry['origin'] : 0 ); if ( $id && isset( $family[ $id ] ) ) { $present[ $family[ $id ] ] = true; } elseif ( ! empty( $entry['auto'] ) ) { $changed = true; // No longer added by its setting. continue; } $kept[] = $entry; } foreach ( $auto as $i => $tab ) { if ( isset( $present[ $i ] ) ) { continue; } if ( ! $kept ) { $kept = Tabs::with_natives( array(), Settings::get( 'native_order' ) ); } $kept = Tabs::insert( $kept, array( 'type' => 'library', 'key' => 'lib_' . $tab['id'], 'library_id' => $tab['id'], 'anchor' => Tabs::anchor( $tab['anchor'], 'tab-' . $tab['id'], false ), 'visible' => true, 'auto' => true, ) ); $changed = true; } return $changed ? $kept : $entries; } /** * Sets the tabs of a product on the product object; the caller saves the product. * A list that only repeats the settings (WooCommerce tabs in the default order, nothing hidden) is removed. * * @param \WC_Product $product Product. * @param array $entries Entries, in order. * @param bool $texts_only Keep the stored structure, save the texts only (translation that follows its original). */ public static function set( \WC_Product $product, array $entries, bool $texts_only = false ): void { $entries = Tabs::normalize( self::library_anchors( Tabs::normalize( $entries ) ) ); $parts = Tabs::split( $entries ); if ( ! $texts_only ) { if ( self::is_default( $entries ) ) { $product->delete_meta_data( Tabs::META_LAYOUT ); $product->delete_meta_data( Tabs::META_TEXTS ); return; } self::put( $product, Tabs::META_LAYOUT, $parts['layout'] ); } if ( $parts['texts'] ) { self::put( $product, Tabs::META_TEXTS, $parts['texts'] ); } else { // No text left in this language: the texts of the source show again, as for the texts emptied one by one. $product->delete_meta_data( Tabs::META_TEXTS ); } } /** * Sets an array meta on a product. WooCommerce adds a new meta with add_metadata(), which removes one level of * backslashes from the strings of an array, and updates an existing one without: a new meta is slashed first, so * that a backslash typed in a tab (a Windows path, a formula) survives the first save. * * @param \WC_Product $product Product. * @param string $key Meta key. * @param array $value Value. */ private static function put( \WC_Product $product, string $key, array $value ): void { $product->update_meta_data( $key, $product->meta_exists( $key ) ? $value : wp_slash( $value ) ); } /** * Writes the metas of a product directly, without saving the product (no product update hooks, no webhooks): used * by the import and its undo. Polylang still synchronizes the structure, which is hooked to the meta functions; * WPML is asked to copy it to the translations. * * @param int $product_id Product id. * @param array $entries Entries, in order. */ public static function write( int $product_id, array $entries ): void { $entries = Tabs::normalize( self::library_anchors( Tabs::normalize( $entries ) ) ); if ( self::is_default( $entries ) ) { delete_post_meta( $product_id, Tabs::META_LAYOUT ); delete_post_meta( $product_id, Tabs::META_TEXTS ); } else { $parts = Tabs::split( $entries ); update_post_meta( $product_id, Tabs::META_LAYOUT, wp_slash( $parts['layout'] ) ); if ( $parts['texts'] ) { $texts = static function () use ( $product_id, $parts ) { update_post_meta( $product_id, Tabs::META_TEXTS, wp_slash( $parts['texts'] ) ); }; // Texts written back as they are stored (a change of the list only, a bulk action) are not filtered again: // for a user without unfiltered_html, the filter would strip the embeds an administrator saved. if ( self::stored_texts( $product_id, $parts['texts'] ) ) { self::trusted( $texts ); } else { $texts(); } } else { delete_post_meta( $product_id, Tabs::META_TEXTS ); } } // WPML copies the structure to the translations when the original is saved, not when its metas are written. if ( 'wpml' === Multilingual::plugin() && ! Multilingual::structure_locked( $product_id ) ) { do_action( 'wpml_sync_custom_field', $product_id, Tabs::META_LAYOUT ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WPML hook. } clean_post_cache( $product_id ); if ( function_exists( 'wc_delete_product_transients' ) ) { wc_delete_product_transients( $product_id ); } } /** * Whether every text is the one already stored under its key. * * @param int $product_id Product id. * @param array[] $texts Texts by key. */ private static function stored_texts( int $product_id, array $texts ): bool { $stored = get_post_meta( $product_id, Tabs::META_TEXTS, true ); if ( ! is_array( $stored ) ) { return false; } foreach ( $texts as $key => $text ) { if ( ! isset( $stored[ $key ] ) || $stored[ $key ] !== $text ) { return false; } } return true; } /** * Saves the tabs of a product right away (imports, command line). * * @param int $product_id Product id. * @param array $entries Entries, in order. */ public static function save( int $product_id, array $entries ): bool { $product = wc_get_product( $product_id ); if ( ! $product ) { return false; } self::set( $product, $entries ); $product->save(); return true; } /** * Fixes the anchor of the library tabs added without one, from the title of the library tab as it was chosen: * the anchor is then the same in every language. * * @param array[] $entries Normalised entries. * @return array[] */ private static function library_anchors( array $entries ): array { foreach ( $entries as $i => $entry ) { if ( 'library' !== $entry['type'] || '' !== $entry['anchor'] ) { continue; } $post = get_post( (int) $entry['library_id'] ); if ( $post && Library::POST_TYPE === $post->post_type ) { $entries[ $i ]['anchor'] = Library::shop_title( $post ); } } return $entries; } /** * Whether a list adds nothing to the settings. * * @param array[] $entries Normalised entries. */ private static function is_default( array $entries ): bool { if ( Tabs::has_custom( $entries ) ) { return false; } $keys = array(); foreach ( $entries as $entry ) { if ( null !== $entry['visible'] ) { return false; } $keys[] = $entry['key']; } $order = array_values( array_intersect( Settings::get( 'native_order' ), $keys ) ); return $keys === $order; } /** * Cleans what an editor typed in the product screen: titles as plain text, content filtered as for posts when the * user may not post unfiltered HTML. * * @param array[] $entries Normalised entries. * @return array[] */ public static function sanitize_input( array $entries ): array { $unfiltered = current_user_can( 'unfiltered_html' ); foreach ( $entries as $i => $entry ) { if ( 'local' !== $entry['type'] ) { continue; } $entries[ $i ]['title'] = sanitize_text_field( $entry['title'] ); $entries[ $i ]['content'] = $unfiltered ? $entry['content'] : wp_kses_post( $entry['content'] ); } return $entries; } }