count( self::product_ids() ), 'reusable' => count( self::read_saved() ), 'active' => self::active(), ); wp_cache_set( 'detect', $result, 'prodtabs_yikes', MINUTE_IN_SECONDS ); return $result; } /** Whether YIKES is running. */ public static function active(): bool { return class_exists( 'YIKES_Custom_Product_Tabs_Display' ) || class_exists( 'YIKES_Custom_Product_Tabs_Custom_Tabs' ); } /** * Products that have YIKES tabs, trashed ones excepted. * * @return int[] */ public static function product_ids(): array { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one query over all products. $ids = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT pm.post_id FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE pm.meta_key = %s AND pm.meta_value NOT IN ('', 'a:0:{}', 's:0:\"\";') AND p.post_type = 'product' AND p.post_status NOT IN ('trash', 'auto-draft') ORDER BY pm.post_id", self::META_PRODUCT ) ); return array_map( 'intval', $ids ); } /** * Products of a list that already have Prodtabs tabs: imported, or made by hand. * * @param int[] $ids Product ids. * @return int[] Those already imported, and those with tabs of their own. */ private static function with_prodtabs( array $ids ): array { global $wpdb; if ( ! $ids ) { return array( 'imported' => array(), 'tabs' => array(), ); } $out = array(); foreach ( array( 'imported' => self::META_IMPORTED, 'tabs' => Tabs::META_LAYOUT ) as $name => $key ) { $found = array(); foreach ( array_chunk( $ids, 500 ) as $chunk ) { $placeholders = implode( ',', array_fill( 0, count( $chunk ), '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one query per chunk. $rows = $wpdb->get_col( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- placeholders built above. "SELECT DISTINCT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s AND post_id IN ($placeholders)", array_merge( array( $key ), $chunk ) ) ); $found = array_merge( $found, array_map( 'intval', $rows ) ); } $out[ $name ] = $found; } $out['tabs'] = array_values( array_diff( $out['tabs'], $out['imported'] ) ); foreach ( array_chunk( $out['tabs'], 500 ) as $chunk ) { update_meta_cache( 'post', $chunk ); } $out['tabs'] = array_values( array_filter( $out['tabs'], array( __CLASS__, 'has_custom_tabs' ) ) ); return $out; } /** * Whether a product shows tabs of Prodtabs: its own list, or the one of its source for a translation. A list that * only hides or orders the WooCommerce tabs does not count. * * @param int $product_id Product id. */ public static function has_custom_tabs( int $product_id ): bool { return Tabs::has_custom( Tabs::normalize( Product_Tabs::layout( $product_id ) ) ); } /* Reading -------------------------------------------------------------------------------------------------- */ /** * Tabs of a product as YIKES stored them. * * @param int $product_id Product id. * @return array tabs (list of title, id, content), status (ok, repaired, unreadable, empty). */ public static function read_product( int $product_id ): array { return self::decode( get_post_meta( $product_id, self::META_PRODUCT, true ) ); } /** * Decodes the meta value of YIKES. After a WooCommerce CSV import it is a serialized string inside the stored * value; when that string was edited by hand (translations in a spreadsheet) its lengths are wrong and get repaired. * Objects are never instantiated. * * @param mixed $value Meta value, already unserialized once by WordPress. */ public static function decode( $value ): array { $status = 'ok'; if ( is_string( $value ) && '' !== trim( $value ) ) { $decoded = self::unserialize( $value ); if ( null === $decoded ) { $decoded = self::unserialize( self::repair( $value ) ); $status = null === $decoded ? 'unreadable' : 'repaired'; } $value = $decoded; } $tabs = array(); foreach ( is_array( $value ) ? $value : array() as $tab ) { if ( ! is_array( $tab ) ) { continue; } $tabs[] = array( 'title' => isset( $tab['title'] ) && is_scalar( $tab['title'] ) ? (string) $tab['title'] : '', 'id' => isset( $tab['id'] ) && is_scalar( $tab['id'] ) ? (string) $tab['id'] : '', 'content' => isset( $tab['content'] ) && is_scalar( $tab['content'] ) ? (string) $tab['content'] : '', ); } if ( ! $tabs && 'unreadable' !== $status ) { $status = 'empty'; } return array( 'tabs' => $tabs, 'status' => $status, ); } /** * Unserializes data written by YIKES (a serialized string inside the meta after a CSV import) without ever creating * an object: allowed_classes is false, which is the protection YIKES itself added in its version 1.8.6. * * @param string $value Serialized value. * @return mixed|null Null when the value is not valid. */ private static function unserialize( string $value ) { $value = trim( $value ); if ( ! is_serialized( $value ) ) { return null; } // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize, WordPress.PHP.NoSilencedErrors.Discouraged -- third-party data, objects disabled; the notice of a damaged value is expected and handled. $result = @unserialize( $value, array( 'allowed_classes' => false ) ); return ( false === $result && 'b:0;' !== $value ) ? null : $result; } /** * Recomputes the string lengths of a serialized value. * * @param string $value Serialized value. */ private static function repair( string $value ): string { return (string) preg_replace_callback( '/s:\d+:"(.*?)";(?=\}|[isabdN]:|N;)/s', static function ( $match ) { return 's:' . strlen( $match[1] ) . ':"' . $match[1] . '";'; }, $value ); } /** * Saved tabs of YIKES, cleaned. * * @return array[] By YIKES number: id, title, name, content, slug, global, taxonomies. */ public static function read_saved(): array { $option = get_option( self::OPTION_SAVED, array() ); if ( is_string( $option ) ) { $option = self::unserialize( $option ); } $out = array(); foreach ( is_array( $option ) ? $option : array() as $key => $tab ) { if ( ! is_array( $tab ) ) { continue; } $id = isset( $tab['tab_id'] ) ? absint( $tab['tab_id'] ) : absint( $key ); if ( ! $id ) { continue; } $title = isset( $tab['tab_title'] ) ? (string) $tab['tab_title'] : ''; $out[ $id ] = array( 'id' => $id, 'title' => $title, // Content and name keep the slashes WordPress adds to requests: YIKES removes them on every read. 'name' => isset( $tab['tab_name'] ) ? stripslashes( (string) $tab['tab_name'] ) : '', 'content' => isset( $tab['tab_content'] ) ? stripslashes( (string) $tab['tab_content'] ) : '', 'slug' => ! empty( $tab['tab_slug'] ) ? (string) $tab['tab_slug'] : urldecode( sanitize_title( $title ) ), 'global' => ! empty( $tab['global_tab'] ) && 'false' !== $tab['global_tab'], 'taxonomies' => isset( $tab['taxonomies'] ) && is_array( $tab['taxonomies'] ) ? array_filter( $tab['taxonomies'] ) : array(), ); } return $out; } /** * Links between products and saved tabs. * * @return array[] [product id][saved tab number] = id of the tab in the product. */ public static function read_links(): array { $option = get_option( self::OPTION_LINKS, array() ); $out = array(); foreach ( is_array( $option ) ? $option : array() as $product_id => $links ) { foreach ( is_array( $links ) ? $links : array() as $saved_id => $link ) { if ( ! is_array( $link ) ) { continue; } $pid = absint( $link['post_id'] ?? $product_id ); $sid = absint( $link['reusable_tab_id'] ?? $saved_id ); $tid = isset( $link['tab_id'] ) && is_scalar( $link['tab_id'] ) ? (string) $link['tab_id'] : ''; if ( $pid && $sid && '' !== $tid ) { $out[ $pid ][ $sid ] = $tid; } } } return $out; } /** Whether YIKES formatted tab content with the full the_content filter (its default). */ public static function used_the_content(): bool { return 'true' !== get_option( self::OPTION_CONTENT_FILTER ); } /* Mapping -------------------------------------------------------------------------------------------------- */ /** * Prodtabs entries for one product read alone, without writing anything. The shop keeps the look it had with * YIKES: tabs after Description and Additional information, Reviews last; tabs that YIKES did not show (no title, * or a title shared with a later tab) come in hidden; a tab that replaced a WooCommerce tab hides it. * * @param array $tabs Tabs read from YIKES (read_product()['tabs']). * @param array $links Links of the product: saved tab number => tab id. * @param array $saved Saved tabs (read_saved()). * @param array $library Library tab id by saved tab number (the numbers themselves when nothing is imported yet). * @return array entries, notes (list of [code, title]). */ public static function plan( array $tabs, array $links, array $saved, array $library ): array { $notes = array(); if ( ! $tabs ) { return array( 'entries' => array(), 'notes' => $notes, ); } // The tab YIKES shows for each id: the last one that has a title. $shown = array(); foreach ( $tabs as $i => $tab ) { if ( '' !== self::title( $tab['title'] ) ) { $shown[ $tab['id'] ] = $i; } } // Anchors: the tabs YIKES showed keep theirs, the others get a free one. $taken = array_fill_keys( Tabs::NATIVE, true ); $anchor = array(); foreach ( $tabs as $i => $tab ) { if ( in_array( $i, $shown, true ) && ! isset( $taken[ urldecode( $tab['id'] ) ] ) ) { $anchor[ $i ] = Tabs::dedupe( Tabs::anchor( urldecode( $tab['id'] ), 'tab-' . ( $i + 1 ), false ), $taken ); $taken[ $anchor[ $i ] ] = true; } } foreach ( $tabs as $i => $tab ) { if ( ! isset( $anchor[ $i ] ) ) { $anchor[ $i ] = Tabs::dedupe( Tabs::anchor( '' !== $tab['id'] ? urldecode( $tab['id'] ) : $tab['title'], 'tab-' . ( $i + 1 ), false ), $taken ); $taken[ $anchor[ $i ] ] = true; } } $linked = array_flip( array_map( 'strval', $links ) ); // Tab id => saved tab number (the last link wins). $keys = array(); $entries = array(); $hide = array(); foreach ( $tabs as $i => $tab ) { $title = self::title( $tab['title'] ); $visible = '' !== $title && isset( $shown[ $tab['id'] ] ) && $shown[ $tab['id'] ] === $i; if ( '' === $title ) { $notes[] = array( 'untitled', '' ); } elseif ( ! $visible ) { $notes[] = array( 'duplicate', $title ); } $replaces = ''; if ( $visible && in_array( $tab['id'], Tabs::NATIVE, true ) ) { $hide[ $tab['id'] ] = true; $replaces = $tab['id']; $notes[] = array( 'replaces_native', $title ); } // The link follows the tab YIKES shows: a copy it hides under the same id stays a hidden tab of the product. $saved_id = $visible ? ( $linked[ $tab['id'] ] ?? 0 ) : 0; $origin = 0; if ( $saved_id && isset( $saved[ $saved_id ], $library[ $saved_id ] ) ) { if ( self::same( $tab, $saved[ $saved_id ] ) ) { $entries[] = array( 'type' => 'library', 'library_id' => (int) $library[ $saved_id ], 'anchor' => $anchor[ $i ], 'visible' => $visible, 'saved' => (int) $saved_id, 'replaces' => $replaces, ); continue; } $origin = (int) $library[ $saved_id ]; $notes[] = array( 'differs', $title ); } else { $origin = self::matching_saved( $tab, $saved, $library ); if ( $origin ) { $notes[] = array( 'looks_saved', $title ); } } $key = Tabs::key_from( '' !== $title ? $title : 'tab_' . ( $i + 1 ), $keys ); $keys[ $key ] = true; $entries[] = array( 'type' => 'local', 'key' => $key, 'anchor' => $anchor[ $i ], 'title' => $title, 'content' => $tab['content'], 'origin' => $origin, 'visible' => $visible, 'replaces' => $replaces, ); } $native = static function ( string $key ) use ( $hide ) { return array( 'type' => 'native', 'key' => $key, 'visible' => isset( $hide[ $key ] ) ? false : null, ); }; return array( 'entries' => array_merge( array( $native( 'description' ), $native( 'additional_information' ) ), $entries, array( $native( 'reviews' ) ) ), 'notes' => $notes, ); } /** * Products imported together: a product and its translations that have YIKES tabs, the one in the default * language first. Without a translation plugin, the product alone. * * @param int $product_id Product id. * @param array $with_tabs Ids of the products that have YIKES tabs, as keys. * @return int[] */ public static function group( int $product_id, array $with_tabs ): array { $members = array_values( array_filter( Multilingual::translation_ids( $product_id, 'product' ), static function ( $id ) use ( $with_tabs ) { return isset( $with_tabs[ $id ] ); } ) ); if ( ! in_array( $product_id, $members, true ) ) { $members[] = $product_id; } $default = Multilingual::default_language(); usort( $members, static function ( $a, $b ) use ( $default ) { $da = Multilingual::post_language( $a ) === $default ? 0 : 1; $db = Multilingual::post_language( $b ) === $default ? 0 : 1; return $da === $db ? $a <=> $b : $da <=> $db; } ); return $members; } /** * Entries of each product of a group. The structure is the one of the first readable product (the default language * first); the tabs of the others are aligned by position to give the texts of their language. A shared tab that * another language customized becomes a customized tab for all; tabs missing from the first product are added at * the end, aligned by position between the languages that have them, and shown in those languages only. * * @param int[] $members Products of the group. * @param array $links Links, by product. * @param array $saved Saved tabs. * @param array $library Library tab id by saved tab number. * @return array[] By product id: entries, notes, status. */ public static function plan_group( array $members, array $links, array $saved, array $library ): array { $read = array(); $plans = array(); $result = array(); $source = 0; foreach ( $members as $id ) { $read[ $id ] = self::read_product( $id ); $plans[ $id ] = self::plan( $read[ $id ]['tabs'], $links[ $id ] ?? array(), $saved, $library ); $result[ $id ] = array( 'entries' => array(), 'notes' => $plans[ $id ]['notes'], 'status' => $read[ $id ]['status'], ); if ( ! $source && 'unreadable' !== $read[ $id ]['status'] && $plans[ $id ]['entries'] ) { $source = (int) $id; } } if ( ! $source ) { return $result; } $others = array_values( array_diff( $members, array( $source ) ) ); $custom = static function ( array $entries ): array { return array_values( array_filter( $entries, static function ( $entry ) { return 'native' !== $entry['type']; } ) ); }; $natives = array_values( array_filter( $plans[ $source ]['entries'], static function ( $entry ) { return 'native' === $entry['type']; } ) ); $layout = $custom( $plans[ $source ]['entries'] ); $by_member = array(); foreach ( $members as $id ) { $by_member[ $id ] = 'unreadable' === $read[ $id ]['status'] ? array() : $custom( $plans[ $id ]['entries'] ); } // Shared library tabs that another language replaced by its own text become customized tabs. $keys = array(); foreach ( $layout as $entry ) { if ( 'local' === $entry['type'] ) { $keys[ $entry['key'] ] = true; } } foreach ( $layout as $i => $entry ) { if ( 'library' !== $entry['type'] ) { continue; } foreach ( $others as $id ) { $theirs = $by_member[ $id ][ $i ] ?? null; if ( null === $theirs || ( 'library' === $theirs['type'] && self::same_saved( (int) $entry['saved'], (int) $theirs['saved'], $links ) ) ) { continue; } $text = self::saved_text( (int) $entry['saved'], $saved ); $key = Tabs::key_from( $text['title'], $keys ); $keys[ $key ] = true; $layout[ $i ] = array( 'type' => 'local', 'key' => $key, 'anchor' => $entry['anchor'], 'title' => $text['title'], 'content' => $text['content'], 'origin' => (int) $entry['library_id'], 'visible' => $entry['visible'], 'replaces' => $entry['replaces'], ); $result[ $source ]['notes'][] = array( 'differs', $text['title'] ); break; } } // Tabs missing from the first product, aligned by position between the other languages. $base = count( $layout ); $anchors = array_fill_keys( Tabs::NATIVE, true ); foreach ( $layout as $entry ) { $anchors[ $entry['anchor'] ] = true; } $extras = array(); foreach ( $others as $id ) { foreach ( array_slice( $by_member[ $id ], $base ) as $position => $theirs ) { $extras[ $position ][ $id ] = $theirs; } } ksort( $extras ); foreach ( $extras as $by_id ) { $first = reset( $by_id ); $texts = array(); foreach ( $by_id as $id => $theirs ) { $texts[ $id ] = self::entry_text( $theirs, $saved ); $result[ $id ]['notes'][] = array( 'not_in_source', $texts[ $id ]['title'] ); } $key = Tabs::key_from( (string) reset( $texts )['title'], $keys ); $keys[ $key ] = true; $anchor = Tabs::dedupe( $first['anchor'], $anchors ); $anchors[ $anchor ] = true; // No origin: the languages without a text must not show it through a library tab. $layout[] = array( 'type' => 'local', 'key' => $key, 'anchor' => $anchor, 'title' => '', 'content' => '', 'origin' => 0, 'visible' => $first['visible'], 'texts' => $texts, ); } // The choices of the merchant on the WooCommerce tabs stay: shown or hidden, and their order when the list held // nothing else, the tabs of YIKES then going where the shop showed them. $merchant = self::merchant_layout( $members, $source ); $natives = self::native_choices( $natives, $merchant ); $order = $merchant && ! Tabs::has_custom( $merchant ) ? self::shop_order( $source, $merchant, $read[ $source ]['tabs'], $layout ) : array(); // Entries of each product: the shared structure, with the texts of its language. $empty = array( 'title' => '', 'content' => '', ); foreach ( $members as $id ) { if ( 'unreadable' === $read[ $id ]['status'] ) { continue; } $entries = array(); foreach ( $layout as $i => $entry ) { if ( 'local' === $entry['type'] ) { if ( isset( $entry['texts'] ) ) { $text = $entry['texts'][ $id ] ?? $empty; if ( ! isset( $entry['texts'][ $id ] ) ) { $entry['note'] = 'absent'; // For the preview only: not stored. } } elseif ( $id === $source ) { $text = array( 'title' => $entry['title'], 'content' => $entry['content'], ); } else { $theirs = $by_member[ $id ][ $i ] ?? null; $text = $theirs ? self::entry_text( $theirs, $saved ) : $empty; if ( ! $theirs ) { $entry['note'] = 'fallback'; $result[ $id ]['notes'][] = array( 'missing_translation', $entry['title'] ); } } unset( $entry['texts'] ); $entry['title'] = $text['title']; $entry['content'] = $text['content']; } else { if ( $id !== $source && ! isset( $by_member[ $id ][ $i ] ) ) { $result[ $id ]['notes'][] = array( 'missing_translation', self::saved_text( (int) $entry['saved'], $saved )['title'] ); } unset( $entry['saved'] ); } $entries[] = $entry; } $result[ $id ]['entries'] = $order ? self::in_order( $natives, $entries, $order ) : self::in_place( $natives, $entries ); } return $result; } /** * List of the source product as the merchant left it before the first import of its group: the backup taken first * in the group, or the list it has when the group was never imported. * * @param int[] $members Products of the group. * @param int $source Source product of the group. * @return array[] Normalised entries. */ private static function merchant_layout( array $members, int $source ): array { $first = self::first_backup( $members ); return Tabs::normalize( null !== $first ? $first['layout'] : Product_Tabs::layout( $source ) ); } /** * WooCommerce entries of a plan with the choice of the merchant (shown, hidden, or as the settings say), except for * a WooCommerce tab that a tab of YIKES replaces. * * @param array[] $natives WooCommerce entries of the plan. * @param array[] $merchant Normalised list of the merchant. * @return array[] */ private static function native_choices( array $natives, array $merchant ): array { $chosen = array(); foreach ( $merchant as $entry ) { if ( 'native' === $entry['type'] ) { $chosen[ $entry['key'] ] = $entry['visible']; } } foreach ( $natives as $i => $native ) { if ( false !== $native['visible'] && array_key_exists( $native['key'], $chosen ) ) { $natives[ $i ]['visible'] = $chosen[ $native['key'] ]; } } return $natives; } /** * Order in which the shop showed the source product when its list only hid or ordered the WooCommerce tabs: those * tabs with the priorities Render gives them, the shared tabs added by their "Show on" setting likewise, the tabs of * YIKES with the priorities YIKES gives them (25, 26 and so on), sorted as WooCommerce sorts tabs (by priority, the * WooCommerce tabs first, then those of YIKES, then those of Prodtabs). A tab the shop did not show keeps its place * next to its neighbours in its list. * * @param int $source Source product of the group. * @param array[] $merchant Normalised list of the merchant, WooCommerce tabs only. * @param array $tabs Tabs read from YIKES for the source product. * @param array[] $layout Tabs of the plan: one per tab of YIKES, in its order, then the tabs of the other languages. * @return array[] Items in order: [ 'native', key ], [ 'custom', index in $layout ] or [ 'auto', entry ]. */ private static function shop_order( int $source, array $merchant, array $tabs, array $layout ): array { $settings = Settings::all(); $product = wc_get_product( $source ); $list = Tabs::with_natives( $merchant, $settings['native_order'] ); $list = array_values( $product ? Product_Tabs::with_auto( $list, $product ) : $list ); $shown = $product ? self::natives_shown( $product ) : array_fill_keys( Tabs::NATIVE, true ); // The tabs of the list the shop showed, with the priorities Render gave them. $keys = array(); foreach ( $list as $i => $entry ) { if ( 'native' === $entry['type'] ) { $visible = null === $entry['visible'] ? $settings['natives'][ $entry['key'] ]['visible'] : $entry['visible']; if ( $visible && isset( $shown[ $entry['key'] ] ) ) { $keys[ $i ] = $entry['key']; } } elseif ( 'library' === $entry['type'] && $entry['visible'] ) { $library = Library::resolve( (int) $entry['library_id'] ); if ( $library && ! Tabs::is_blank( $library['content'] ) ) { $keys[ $i ] = 'auto_' . $i; } } } $priorities = Render::priorities( array_values( $keys ), $settings['native_order'] ); // Every tab of the list gets a place: its priority, or the one of the closest tab shown before it (after it // for those at the start), so that it keeps its rank in the list. $slots = array(); $known = array(); foreach ( $list as $i => $entry ) { if ( isset( $keys[ $i ] ) ) { $known[ $i ] = (float) $priorities[ $keys[ $i ] ]; } } foreach ( $list as $i => $entry ) { if ( isset( $known[ $i ] ) ) { $slots[ $i ] = $known[ $i ]; } elseif ( $i > 0 && isset( $slots[ $i - 1 ] ) ) { $slots[ $i ] = $slots[ $i - 1 ] + 0.001; } else { $next = null; $distance = 0; foreach ( $known as $j => $priority ) { if ( $j > $i ) { $next = $priority; $distance = $j - $i; break; } } $slots[ $i ] = null === $next ? 10.0 * ( $i + 1 ) : $next - 0.001 * $distance; } } $items = array(); $place = array(); // WooCommerce key => place, for the tabs of YIKES that replace one. foreach ( $list as $i => $entry ) { if ( 'native' === $entry['type'] ) { $items[] = array( $slots[ $i ], 0, $i, array( 'native', $entry['key'] ) ); $place[ $entry['key'] ] = $slots[ $i ]; } elseif ( ! empty( $entry['auto'] ) ) { $items[] = array( $slots[ $i ], 2, $i, array( 'auto', $entry ) ); } } // YIKES gives 25 to its first tab with a title and counts on; it leaves out the tabs without one. $count = count( $tabs ); $titled = 0; foreach ( $layout as $j => $entry ) { $priority = 25 + $titled + max( 0, $j - $count ); if ( $j < $count && ! empty( $tabs[ $j ]['title'] ) ) { ++$titled; } $replaces = $entry['replaces'] ?? ''; if ( '' !== $replaces && isset( $place[ $replaces ] ) ) { $items[] = array( $place[ $replaces ] + 0.0001, 0, count( $list ) + $j, array( 'custom', $j ) ); } else { $items[] = array( (float) $priority, 1, $j, array( 'custom', $j ) ); } } usort( $items, static function ( $a, $b ) { return array( $a[0], $a[1], $a[2] ) <=> array( $b[0], $b[1], $b[2] ); } ); return array_column( $items, 3 ); } /** * WooCommerce tabs of a product as Render finds them: those WooCommerce shows (a description, attributes, open * reviews), with what the theme and the other plugins changed before Render. * * @param \WC_Product $product Product. * @return array Keys of the WooCommerce tabs shown, as keys. */ private static function natives_shown( \WC_Product $product ): array { $found = array(); $capture = static function ( $tabs ) use ( &$found ) { $found = is_array( $tabs ) ? array_keys( $tabs ) : array(); return $tabs; }; // Render places the tabs at 98: the list just before it. add_filter( 'woocommerce_product_tabs', $capture, 97 ); $previous = array( 'post' => $GLOBALS['post'] ?? null, 'product' => $GLOBALS['product'] ?? null, ); $GLOBALS['post'] = get_post( $product->get_id() ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- globals of WordPress and WooCommerce, put back below. $GLOBALS['product'] = $product; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited, WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- globals of WordPress and WooCommerce, put back below. apply_filters( 'woocommerce_product_tabs', array() ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- hook of WooCommerce. $GLOBALS['post'] = $previous['post']; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- as it was. $GLOBALS['product'] = $previous['product']; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited, WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- as it was. remove_filter( 'woocommerce_product_tabs', $capture, 97 ); return array_fill_keys( array_intersect( $found, Tabs::NATIVE ), true ); } /** * Entries of a product in a given order. * * @param array[] $natives WooCommerce entries. * @param array[] $entries Tabs of the product, in the order of the plan. * @param array[] $order Items from shop_order(). * @return array[] */ private static function in_order( array $natives, array $entries, array $order ): array { $by_key = array_column( $natives, null, 'key' ); $out = array(); foreach ( $order as $item ) { if ( 'native' === $item[0] && isset( $by_key[ $item[1] ] ) ) { $out[] = $by_key[ $item[1] ]; } elseif ( 'custom' === $item[0] && isset( $entries[ $item[1] ] ) ) { $out[] = $entries[ $item[1] ]; } elseif ( 'auto' === $item[0] ) { $out[] = $item[1]; } } return $out; } /** * Entries in the order YIKES showed them: Description and Additional information, the tabs, Reviews last; a tab * that replaced a WooCommerce tab takes the place of that tab. * * @param array[] $natives Native entries: description, additional_information, reviews. * @param array[] $entries Tabs of the product, in order. * @return array[] */ private static function in_place( array $natives, array $entries ): array { $replacing = array(); foreach ( $entries as $i => $entry ) { $native = $entry['replaces'] ?? ''; if ( '' !== $native && ! isset( $replacing[ $native ] ) ) { $replacing[ $native ] = $entry; unset( $entries[ $i ] ); } } $out = array(); foreach ( $natives as $native ) { if ( 'reviews' === $native['key'] ) { $out = array_merge( $out, array_values( $entries ) ); } $out[] = $native; if ( isset( $replacing[ $native['key'] ] ) ) { $out[] = $replacing[ $native['key'] ]; } } return $out; } /** * Title and content of a planned entry: its own for a tab of the product, the saved tab's for a library tab. * * @param array $entry Planned entry. * @param array $saved Saved tabs. */ private static function entry_text( array $entry, array $saved ): array { if ( 'library' === $entry['type'] ) { return self::saved_text( (int) $entry['saved'], $saved ); } return array( 'title' => $entry['title'], 'content' => $entry['content'], ); } /** * Title and content of a saved tab. * * @param int $saved_id Saved tab number. * @param array $saved Saved tabs. */ private static function saved_text( int $saved_id, array $saved ): array { return array( 'title' => isset( $saved[ $saved_id ] ) ? self::title( $saved[ $saved_id ]['title'] ) : '', 'content' => $saved[ $saved_id ]['content'] ?? '', ); } /** * Whether two saved tabs are the same tab, or versions of one tab in two languages. * * @param int $a Saved tab number. * @param int $b Saved tab number. * @param array $links Links. */ private static function same_saved( int $a, int $b, array $links ): bool { static $group = null; if ( $a === $b ) { return true; } if ( null === $group ) { $group = array(); foreach ( self::saved_groups( $links ) as $index => $members ) { foreach ( $members as $saved_id ) { $group[ $saved_id ] = $index; } } } return isset( $group[ $a ], $group[ $b ] ) && $group[ $a ] === $group[ $b ]; } /** * Title as plain text. * * @param string $title Title stored by YIKES (HTML allowed there). */ private static function title( string $title ): string { return trim( sanitize_text_field( wp_strip_all_tags( $title ) ) ); } /** * Whether a product tab still matches its saved tab. * * @param array $tab Product tab. * @param array $saved Saved tab. */ private static function same( array $tab, array $saved ): bool { return self::title( $tab['title'] ) === self::title( $saved['title'] ) && self::text( $tab['content'] ) === self::text( $saved['content'] ); } /** * Content reduced for comparison: paragraphs added, spaces collapsed, backslashes left out (YIKES loses one level * of them when it copies a saved tab into a product, and keeps them in the saved tab). Computed once per text. * * @param string $html Content. */ private static function text( string $html ): string { static $cache = array(); $hash = md5( $html ); if ( ! isset( $cache[ $hash ] ) ) { if ( count( $cache ) > 2000 ) { $cache = array(); } $cache[ $hash ] = trim( (string) preg_replace( '/\s+/', ' ', wpautop( trim( str_replace( '\\', '', $html ) ) ) ) ); } return $cache[ $hash ]; } /** * Library tab whose saved tab has the same title and content as a tab that is not linked: an override that kept * the shared text, or a link YIKES lost. * * @param array $tab Product tab. * @param array $saved Saved tabs. * @param array $library Library tab id by saved tab number. */ private static function matching_saved( array $tab, array $saved, array $library ): int { $title = self::title( $tab['title'] ); foreach ( $saved as $id => $candidate ) { if ( isset( $library[ $id ] ) && self::title( $candidate['title'] ) === $title && self::text( $tab['content'] ) === self::text( $candidate['content'] ) ) { return (int) $library[ $id ]; } } return 0; } /** * Plans of every product, by group, as the import will write them. * * @param int[] $ids Products to plan. * @param array|null $library Library tab id by saved tab number; null before the import (saved tab numbers used). * @return array[] By product id: entries, notes, status, group (source product id). */ public static function plans( array $ids, ?array $library = null ): array { static $data = null; if ( null === $data ) { $data = array( 'saved' => self::read_saved(), 'links' => self::read_links(), 'with' => array_fill_keys( self::product_ids(), true ), ); } if ( null === $library ) { $numbers = array_keys( $data['saved'] ); $library = array_combine( $numbers, $numbers ); } $out = array(); foreach ( $ids as $id ) { if ( isset( $out[ $id ] ) ) { continue; } $members = self::group( (int) $id, $data['with'] ); foreach ( self::plan_group( $members, $data['links'], $data['saved'], $library ) as $member => $plan ) { $out[ $member ] = $plan + array( 'group' => (int) $members[0] ); } } return $out; } /* Analysis ------------------------------------------------------------------------------------------------- */ /** * Everything the assistant shows before importing. * * @return array Counts, warnings and options. */ public static function analyze(): array { global $shortcode_tags; $saved = self::read_saved(); $ids = self::product_ids(); $existing = self::with_prodtabs( $ids ); $analysis = array( 'products' => count( $ids ), 'tabs' => 0, 'saved' => count( $saved ), 'linked' => 0, 'untitled' => 0, 'duplicate' => 0, 'replaces_native' => 0, 'differs' => 0, 'looks_saved' => 0, 'not_in_source' => 0, 'missing_translation' => 0, 'unreadable' => array(), 'repaired' => array(), 'imported' => count( $existing['imported'] ), 'has_tabs' => count( $existing['tabs'] ), 'shortcodes' => array(), 'pro' => 0, 'languages' => array(), 'groups' => 0, 'the_content' => self::used_the_content(), 'active' => self::active(), ); foreach ( array_chunk( $ids, 200 ) as $chunk ) { update_meta_cache( 'post', $chunk ); foreach ( self::plans( $chunk ) as $id => $plan ) { if ( ! in_array( $id, $chunk, true ) ) { continue; // Counted with its own chunk. } if ( 'unreadable' === $plan['status'] ) { $analysis['unreadable'][] = $id; continue; } if ( 'repaired' === $plan['status'] ) { $analysis['repaired'][] = $id; } if ( $plan['group'] === $id ) { ++$analysis['groups']; } foreach ( $plan['entries'] as $entry ) { if ( 'native' === $entry['type'] ) { continue; } ++$analysis['tabs']; if ( 'library' === $entry['type'] ) { ++$analysis['linked']; } } foreach ( $plan['notes'] as $note ) { if ( isset( $analysis[ $note[0] ] ) && is_int( $analysis[ $note[0] ] ) ) { ++$analysis[ $note[0] ]; } } foreach ( self::read_product( $id )['tabs'] as $tab ) { if ( preg_match_all( '/\[([a-zA-Z0-9_-]+)[\s\]\/]/', $tab['content'], $matches ) ) { foreach ( $matches[1] as $tag ) { if ( ! isset( $shortcode_tags[ $tag ] ) ) { $analysis['shortcodes'][ $tag ] = ( $analysis['shortcodes'][ $tag ] ?? 0 ) + 1; } } } } $lang = Multilingual::post_language( $id ); if ( '' !== $lang ) { $analysis['languages'][ $lang ] = ( $analysis['languages'][ $lang ] ?? 0 ) + 1; } } } foreach ( $saved as $tab ) { if ( $tab['global'] || $tab['taxonomies'] ) { ++$analysis['pro']; } } return $analysis; } /** * One page of the preview: products with the tabs they will get. * * @param int $page Page number, from 1. * @param int $per_page Products per page. * @return array total, rows, saved, the_content, protected (products already with Prodtabs tabs), first_run. */ public static function preview( int $page, int $per_page = 20 ): array { $ids = self::product_ids(); $slice = array_slice( $ids, ( max( 1, $page ) - 1 ) * $per_page, $per_page ); $plans = self::plans( $slice ); $existing = self::with_prodtabs( $ids ); $rows = array(); foreach ( $slice as $id ) { $rows[] = array( 'id' => $id, 'title' => get_the_title( $id ), 'language' => Multilingual::post_language( $id ), 'imported' => in_array( $id, $existing['imported'], true ), 'has_tabs' => in_array( $id, $existing['tabs'], true ), 'status' => $plans[ $id ]['status'], 'entries' => $plans[ $id ]['entries'], 'notes' => $plans[ $id ]['notes'], ); } return array( 'total' => count( $ids ), 'rows' => $rows, 'saved' => self::read_saved(), 'the_content' => self::used_the_content(), 'protected' => count( $existing['imported'] ) + count( $existing['tabs'] ), 'first_run' => ! $existing['imported'], ); } /* Import --------------------------------------------------------------------------------------------------- */ /** * Whether the content of YIKES may go in as it is: it was never filtered by YIKES, so it is copied unfiltered only * for a user allowed to post unfiltered HTML, or from the command line. */ private static function may_trust(): bool { return ( defined( 'WP_CLI' ) && WP_CLI ) || current_user_can( 'unfiltered_html' ); } /** * Starts an import: the library first, then the products in batches (step()). Choosing a product imports its * translations with it, since they share one structure. * * @param array $args products (ids, empty for all); overwrite (import again the products that already have * Prodtabs tabs); the_content (null to keep the setting); settings (first: change the settings * only on the first import of the site; always). * @return array State. */ public static function start( array $args ): array { $args = wp_parse_args( $args, array( 'products' => array(), 'overwrite' => false, 'the_content' => null, 'settings' => 'first', ) ); $all = self::product_ids(); $first_run = ! self::with_prodtabs( $all )['imported']; $saved = self::read_saved(); $links = self::read_links(); $restore = self::may_trust() ? self::without_kses() : static function () {}; $library = self::import_library( $saved, $links, (bool) $args['overwrite'] ); $restore(); $ids = $all; if ( $args['products'] ) { $with_tabs = array_fill_keys( $all, true ); $ids = array(); foreach ( array_intersect( $all, array_map( 'absint', (array) $args['products'] ) ) as $id ) { $ids = array_merge( $ids, self::group( (int) $id, $with_tabs ) ); } $ids = array_values( array_unique( $ids ) ); } // Settings follow YIKES on the first import only: a later run does not undo what the merchant changed since. if ( $first_run || 'always' === $args['settings'] ) { self::follow_yikes_settings( null === $args['the_content'] ? null : (bool) $args['the_content'] ); } $state = array( 'status' => 'running', 'started' => time(), 'overwrite' => (bool) $args['overwrite'], 'library' => $library['map'], 'queue' => $ids, 'current' => array(), 'total' => count( $ids ), 'done' => 0, 'counts' => array( 'imported' => 0, 'skipped' => 0, 'empty' => 0, 'errors' => 0, 'tabs' => 0, 'linked' => 0, 'hidden' => 0, 'library' => $library['created'], ), 'notes' => array(), 'errors' => $library['errors'], 'first' => 0, ); update_option( self::STATE, $state, false ); wp_cache_delete( 'detect', 'prodtabs_yikes' ); return $state; } /** * Settings of an import: the filters of YIKES on, and the formatting of YIKES when it is given. * * @param bool|null $the_content Full the_content filter; null keeps the setting. */ private static function follow_yikes_settings( ?bool $the_content ): void { $settings = Settings::all(); $changes = array( 'yikes_compat' => true ); if ( null !== $the_content ) { $changes['the_content'] = $the_content; } // For the undo: each value as it was before the first import, and as the last import set it. $saved = get_option( self::SETTINGS_BEFORE, array() ); $saved = is_array( $saved ) ? $saved : array(); foreach ( $changes as $key => $value ) { $saved[ $key ] = array( 'before' => $saved[ $key ]['before'] ?? $settings[ $key ], 'after' => $value, ); $settings[ $key ] = $value; } update_option( self::SETTINGS_BEFORE, $saved, false ); update_option( Settings::OPTION, $settings ); } /** Puts back the settings the import changed, except those the merchant changed since. */ private static function restore_settings(): void { $saved = get_option( self::SETTINGS_BEFORE ); if ( is_array( $saved ) ) { $settings = Settings::all(); foreach ( $saved as $key => $values ) { if ( is_array( $values ) && array_key_exists( 'before', $values ) && isset( $settings[ $key ] ) && ( $values['after'] ?? null ) === $settings[ $key ] ) { $settings[ $key ] = $values['before']; } } update_option( Settings::OPTION, $settings ); } delete_option( self::SETTINGS_BEFORE ); } /** * Imports the next groups of products. The state is saved before each group is written, so that a request that * dies (time limit, error of another plugin) never leaves a group half imported and retried forever. * * @param int $size Products per batch, at least. * @return array State. */ public static function step( int $size = 20 ): array { $state = get_option( self::STATE ); if ( ! is_array( $state ) || 'running' !== ( $state['status'] ?? '' ) ) { return is_array( $state ) ? $state : array(); } $saved = self::read_saved(); $links = self::read_links(); $with_tabs = array_fill_keys( self::product_ids(), true ); $trusted = self::may_trust(); $restore = $trusted ? self::without_kses() : static function () {}; // A group that was being written when the previous request died: reported, not retried. if ( ! empty( $state['current'] ) ) { foreach ( (array) $state['current'] as $id ) { ++$state['counts']['errors']; ++$state['done']; self::add_error( $state, (int) $id, __( 'The import stopped while this product was being imported (time limit of the server, or an error of another plugin). Run the import again for it, with "Import again" checked.', 'prodtabs' ) ); } $state['current'] = array(); update_option( self::STATE, $state, false ); } $count = 0; while ( $state['queue'] && $count < max( 1, $size ) ) { $first = (int) array_shift( $state['queue'] ); $members = self::group( $first, $with_tabs ); $state['queue'] = array_values( array_diff( $state['queue'], $members ) ); $state['current'] = $members; update_option( self::STATE, $state, false ); foreach ( self::import_group( $members, $state['library'], $saved, $links, (bool) $state['overwrite'], $trusted ) as $id => $result ) { ++$state['counts'][ $result['status'] ]; ++$state['done']; ++$count; if ( 'imported' === $result['status'] ) { $state['counts']['tabs'] += $result['tabs']; $state['counts']['linked'] += $result['linked']; $state['counts']['hidden'] += $result['hidden']; if ( ! $state['first'] ) { $state['first'] = (int) $id; } } elseif ( 'errors' === $result['status'] ) { self::add_error( $state, (int) $id, $result['message'] ); } foreach ( $result['notes'] ?? array() as $note ) { if ( count( $state['notes'] ) < 300 ) { $state['notes'][] = array( (int) $id, $note[0], $note[1] ); } } } $state['current'] = array(); $state['total'] = max( $state['total'], $state['done'] + count( $state['queue'] ) ); update_option( self::STATE, $state, false ); } $restore(); if ( ! $state['queue'] ) { $state['status'] = 'done'; $state['finished'] = time(); } update_option( self::STATE, $state, false ); return $state; } /** * Adds an error to the state, up to 300. * * @param array $state Import state. * @param int $id Product id. * @param string $message Message. */ private static function add_error( array &$state, int $id, string $message ): void { if ( count( $state['errors'] ) < 300 ) { $state['errors'][] = array( $id, $message ); } } /** Stops a running import; what was imported stays, the report shows it. */ public static function stop(): void { $state = self::state(); if ( 'running' !== ( $state['status'] ?? '' ) ) { return; } $state['status'] = 'done'; $state['stopped'] = true; $state['finished'] = time(); $state['queue'] = array(); $state['current'] = array(); update_option( self::STATE, $state, false ); } /** * Imports a product and its translations. A group in which a product already has Prodtabs tabs (imported, made by * hand, or received from its source) is left as it is unless $overwrite: writing it would replace the shared * structure of every language. * * @param int[] $members Products of the group. * @param array $library Library tab id by saved tab number. * @param array $saved Saved tabs. * @param array $links Links, by product. * @param bool $overwrite Replace the Prodtabs tabs of products that have some. * @param bool $trusted Write the content of YIKES unfiltered. * @return array[] By product id: status (imported, skipped, empty, errors), notes and counts. */ public static function import_group( array $members, array $library, array $saved, array $links, bool $overwrite, bool $trusted = true ): array { $out = array(); if ( ! $overwrite ) { $protected = false; foreach ( $members as $id ) { if ( get_post_meta( $id, self::META_IMPORTED, true ) || self::has_custom_tabs( (int) $id ) ) { $protected = true; break; } } if ( $protected ) { foreach ( $members as $id ) { $out[ $id ] = array( 'status' => 'skipped', 'notes' => get_post_meta( $id, self::META_IMPORTED, true ) ? array() : array( array( 'has_tabs', '' ) ), ); } return $out; } } // What each product has before the import, read before anything is written: with Polylang, writing a product // copies its structure to its translations at once. $before = array(); foreach ( $members as $id ) { $before[ $id ] = array( 'layout' => get_post_meta( $id, Tabs::META_LAYOUT, true ), 'texts' => get_post_meta( $id, Tabs::META_TEXTS, true ), ); } // A language imported by an earlier run gave its imported structure to the others through Polylang: the // structure the group had before its first import is the one of the backup taken then. $earlier = self::first_backup( $members ); foreach ( self::plan_group( $members, $links, $saved, $library ) as $id => $plan ) { try { if ( ! wc_get_product( $id ) ) { $out[ $id ] = array( 'status' => 'errors', 'message' => __( 'Product not found.', 'prodtabs' ), ); continue; } if ( 'unreadable' === $plan['status'] ) { $out[ $id ] = array( 'status' => 'errors', 'message' => __( 'The tabs saved by YIKES for this product cannot be read.', 'prodtabs' ), ); continue; } if ( ! Tabs::has_custom( $plan['entries'] ) ) { $out[ $id ] = array( 'status' => 'empty' ); continue; } if ( 'repaired' === $plan['status'] ) { $plan['notes'][] = array( 'repaired', '' ); } $counts = array( 'tabs' => 0, 'linked' => 0, 'hidden' => 0, ); foreach ( $plan['entries'] as $entry ) { // WooCommerce tabs, shared tabs added by their "Show on" setting, tabs without any text: not imported. if ( 'native' === $entry['type'] || ! empty( $entry['auto'] ) || ( 'local' === $entry['type'] && '' === $entry['title'] && '' === trim( $entry['content'] ) ) ) { continue; } ++$counts['tabs']; $counts['linked'] += 'library' === $entry['type'] ? 1 : 0; $counts['hidden'] += $entry['visible'] ? 0 : 1; } // What the product had before its first import, put back by the undo (empty when it had nothing). if ( ! metadata_exists( 'post', $id, self::META_BACKUP ) ) { $backup = $before[ $id ]; if ( $earlier ) { $backup['layout'] = $earlier['layout']; } update_post_meta( $id, self::META_BACKUP, wp_slash( $backup ) ); } $write = static function () use ( $id, $plan ) { Product_Tabs::write( (int) $id, $plan['entries'] ); update_post_meta( $id, self::META_IMPORTED, array( 'time' => time(), 'source' => md5( (string) maybe_serialize( get_post_meta( $id, self::META_PRODUCT, true ) ) ), 'version' => PRODTABS_VERSION, ) ); }; if ( $trusted ) { Product_Tabs::trusted( $write ); } else { $write(); } $out[ $id ] = array_merge( array( 'status' => 'imported', 'notes' => $plan['notes'], ), $counts ); } catch ( \Throwable $error ) { $out[ $id ] = array( 'status' => 'errors', 'message' => $error->getMessage(), ); } } return $out; } /** * Backup taken first among products: what their group had before its first import. * * @param int[] $ids Products of a group. * @return array|null order, layout, texts; null when none of them has a backup. */ private static function first_backup( array $ids ): ?array { $first = null; foreach ( self::backups( $ids ) as $backup ) { if ( null === $first || $backup['order'] < $first['order'] ) { $first = $backup; } } return $first; } /** * Backups of products, in one query, with the order in which they were taken. * * @param int[] $ids Product ids. * @return array[] By product id: order, layout, texts. */ private static function backups( array $ids ): array { global $wpdb; $out = array(); foreach ( array_chunk( array_map( 'intval', $ids ), 500 ) as $chunk ) { $placeholders = implode( ',', array_fill( 0, count( $chunk ), '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one query per chunk. $rows = $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- placeholders built above. "SELECT post_id, meta_id, meta_value FROM {$wpdb->postmeta} WHERE meta_key = %s AND post_id IN ($placeholders) ORDER BY meta_id", array_merge( array( self::META_BACKUP ), $chunk ) ) ); foreach ( $rows as $row ) { if ( isset( $out[ (int) $row->post_id ] ) ) { continue; // The first value, as get_post_meta() reads it. } $backup = maybe_unserialize( $row->meta_value ); $out[ (int) $row->post_id ] = array( 'order' => (int) $row->meta_id, 'layout' => is_array( $backup ) && ! empty( $backup['layout'] ) ? $backup['layout'] : array(), 'texts' => is_array( $backup ) && ! empty( $backup['texts'] ) ? $backup['texts'] : array(), ); } } return $out; } /** * Creates the library tabs from the saved tabs. A saved tab already imported keeps its library tab, which is * rewritten only when $overwrite is true. The language of a library tab is the language of the products that use * the saved tab when they share one; saved tabs written once per language become translations of one another. * * @param array $saved Saved tabs. * @param array $links Links. * @param bool $overwrite Rewrite library tabs already imported. * @return array map (library tab id by saved tab number), created (count), errors. */ public static function import_library( array $saved, array $links, bool $overwrite ): array { $existing = self::library_sources(); $langs = self::saved_languages( $links ); $default = Multilingual::default_language(); $map = array(); $created = array(); $errors = array(); foreach ( $saved as $id => $tab ) { $post_id = $existing[ $id ] ?? 0; if ( $post_id && ! $overwrite ) { $map[ $id ] = $post_id; continue; } $title = self::title( $tab['title'] ); $name = self::title( $tab['name'] ); $data = array( 'post_type' => Library::POST_TYPE, 'post_status' => 'publish', 'post_title' => '' !== $name ? $name : $title, 'post_content' => $tab['content'], 'meta_input' => array( Library::META_SHOP_TITLE => ( '' !== $name && $name !== $title ) ? $title : '', self::META_SOURCE => $id, ), ); if ( $post_id ) { $data['ID'] = $post_id; $result = wp_update_post( wp_slash( $data ), true ); } else { $result = wp_insert_post( wp_slash( $data ), true ); } if ( is_wp_error( $result ) ) { $errors[] = array( 0, $title . ': ' . $result->get_error_message() ); continue; } $map[ $id ] = (int) $result; if ( ! $post_id ) { $created[ $id ] = (int) $result; Multilingual::set_language( (int) $result, Library::POST_TYPE, $langs[ $id ] ?? $default ); } } self::link_translations( $map, $created, $links, $langs ); return array( 'map' => $map, 'created' => count( $created ), 'errors' => $errors, ); } /** * Library tabs imported from YIKES. * * @return int[] Library tab id by saved tab number. */ private static function library_sources(): array { $posts = get_posts( array( 'post_type' => Library::POST_TYPE, 'post_status' => array( 'publish', 'draft', 'pending', 'private', 'future' ), 'posts_per_page' => -1, 'lang' => '', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- small post type, one query. 'meta_key' => self::META_SOURCE, ) ); $out = array(); foreach ( $posts as $post ) { $out[ (int) get_post_meta( $post->ID, self::META_SOURCE, true ) ] = (int) $post->ID; } unset( $out[0] ); return $out; } /** * Language of each saved tab, when all the products it is applied to share one. * * @param array $links Links. * @return string[] Language by saved tab number. */ private static function saved_languages( array $links ): array { $seen = array(); foreach ( $links as $product_id => $by_saved ) { $lang = Multilingual::post_language( (int) $product_id ); if ( '' === $lang ) { continue; } foreach ( array_keys( $by_saved ) as $saved_id ) { $seen[ $saved_id ][ $lang ] = true; } } $out = array(); foreach ( $seen as $saved_id => $languages ) { if ( 1 === count( $languages ) ) { $out[ $saved_id ] = (string) array_key_first( $languages ); } } return $out; } /** * Links as translations the library tabs created by this import from saved tabs used in the same place by * translations of a product. Groups without a new library tab are left alone: a translation added by hand since a * previous import stays linked. * * @param int[] $map Library tab id by saved tab number. * @param int[] $created Library tabs created by this import, by saved tab number. * @param array $links Links. * @param string[] $langs Language by saved tab number. */ private static function link_translations( array $map, array $created, array $links, array $langs ): void { if ( ! $created ) { return; } $default = Multilingual::default_language(); foreach ( self::saved_groups( $links, $langs ) as $group ) { if ( ! array_intersect( $group, array_keys( $created ) ) ) { continue; } $by_lang = array(); if ( isset( $group[ $default ], $map[ $group[ $default ] ] ) ) { $by_lang[ $default ] = $map[ $group[ $default ] ]; } foreach ( $group as $lang => $saved_id ) { if ( isset( $map[ $saved_id ] ) && ! isset( $by_lang[ $lang ] ) ) { $by_lang[ $lang ] = $map[ $saved_id ]; } } Multilingual::link_translations( $by_lang, Library::POST_TYPE ); } } /** * Saved tabs written once per language: those used in the same place by translations of a product (the first * saved tab of a French product and the first saved tab of its Spanish translation, and so on). * * @param array $links Links. * @param string[]|null $langs Language by saved tab number, computed when null. * @return array[] Groups: saved tab number by language. */ public static function saved_groups( array $links, ?array $langs = null ): array { $languages = Multilingual::languages(); if ( count( $languages ) < 2 ) { return array(); } $langs = null === $langs ? self::saved_languages( $links ) : $langs; $votes = array(); foreach ( $links as $product_id => $by_saved ) { $lang = Multilingual::post_language( (int) $product_id ); $mine = self::linked_in_order( (int) $product_id, $by_saved ); foreach ( $languages as $other ) { if ( $other === $lang ) { continue; } $translation = Multilingual::translation( (int) $product_id, 'product', $other ); if ( ! $translation || ! isset( $links[ $translation ] ) ) { continue; } $theirs = self::linked_in_order( $translation, $links[ $translation ] ); if ( count( $mine ) !== count( $theirs ) ) { continue; } foreach ( $mine as $i => $saved_id ) { $pair = array( $saved_id, $theirs[ $i ] ); sort( $pair ); if ( $pair[0] !== $pair[1] && isset( $langs[ $pair[0] ], $langs[ $pair[1] ] ) && $langs[ $pair[0] ] !== $langs[ $pair[1] ] ) { $key = $pair[0] . ':' . $pair[1]; $votes[ $key ] = ( $votes[ $key ] ?? 0 ) + 1; } } } } arsort( $votes ); // Groups of saved tabs, at most one per language. $group_of = array(); $groups = array(); foreach ( array_keys( $votes ) as $key ) { list( $a, $b ) = array_map( 'intval', explode( ':', $key ) ); $ga = $group_of[ $a ] ?? null; $gb = $group_of[ $b ] ?? null; if ( null === $ga && null === $gb ) { $groups[] = array( $langs[ $a ] => $a, $langs[ $b ] => $b, ); $group_of[ $a ] = count( $groups ) - 1; $group_of[ $b ] = count( $groups ) - 1; } elseif ( null !== $ga && null === $gb && ! isset( $groups[ $ga ][ $langs[ $b ] ] ) ) { $groups[ $ga ][ $langs[ $b ] ] = $b; $group_of[ $b ] = $ga; } elseif ( null === $ga && null !== $gb && ! isset( $groups[ $gb ][ $langs[ $a ] ] ) ) { $groups[ $gb ][ $langs[ $a ] ] = $a; $group_of[ $a ] = $gb; } } return $groups; } /** * Saved tabs linked to a product, in the order of its tabs. * * @param int $product_id Product id. * @param array $by_saved Links of the product. * @return int[] */ private static function linked_in_order( int $product_id, array $by_saved ): array { $position = array(); foreach ( self::read_product( $product_id )['tabs'] as $i => $tab ) { if ( ! isset( $position[ $tab['id'] ] ) ) { $position[ $tab['id'] ] = $i; } } $order = array(); foreach ( $by_saved as $saved_id => $tab_id ) { if ( isset( $position[ $tab_id ] ) ) { $order[ $position[ $tab_id ] ] = (int) $saved_id; } } ksort( $order ); return array_values( $order ); } /** * Turns off the HTML filtering of post content while importing for a trusted user: the content of YIKES is copied * as it was saved, embeds included. * * @return callable Restores the filtering. */ private static function without_kses(): callable { $active = false !== has_filter( 'content_save_pre', 'wp_filter_post_kses' ); if ( $active ) { kses_remove_filters(); } return static function () use ( $active ) { if ( $active ) { kses_init_filters(); } }; } /** State of the last import, empty when there was none. */ public static function state(): array { $state = get_option( self::STATE ); return is_array( $state ) ? $state : array(); } /** Forgets the state of the last import (the imported tabs stay). */ public static function reset(): void { delete_option( self::STATE ); } /** * Library tabs created by the import that the undo keeps: products without import mark use them, or the imported * products get them back with the tabs they had before. * * @return int Number of such library tabs. */ public static function library_in_use(): int { return self::in_use( self::library_sources(), self::imported_ids() ); } /** * Library tabs of a list that the undo keeps. * * @param int[] $library Library tabs created by the import. * @param int[] $imported Imported products. * @return int Number of such library tabs. */ private static function in_use( array $library, array $imported ): int { $restored = array(); foreach ( self::restore_plan( $imported ) as $backup ) { foreach ( Tabs::library_ids( Tabs::normalize( $backup['layout'] ) ) as $library_id ) { $restored[ $library_id ] = true; } } $kept = 0; foreach ( $library as $post_id ) { $family = array_flip( Multilingual::translation_ids( (int) $post_id, Library::POST_TYPE ) ); if ( array_intersect_key( $restored, $family ) || self::used_without_import( (int) $post_id ) ) { ++$kept; } } return $kept; } /** * Whether a product without import mark uses a library tab or one of its translations. * * @param int $post_id Library tab id. */ private static function used_without_import( int $post_id ): bool { $args = array( 'post_type' => 'product', 'post_status' => array( 'publish', 'draft', 'pending', 'private', 'future' ), 'fields' => 'ids', 'posts_per_page' => 1, 'no_found_rows' => true, 'update_post_term_cache' => false, 'update_post_meta_cache' => false, 'lang' => '', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- report of the import only. 'meta_query' => array( 'relation' => 'AND', Library::usage_meta_query( $post_id ), array( 'key' => self::META_IMPORTED, 'compare' => 'NOT EXISTS', ), ), ); return (bool) Multilingual::all_languages( static function () use ( $args ) { return ( new \WP_Query( $args ) )->posts; } ); } /** * Undoes the import: the imported products get back the Prodtabs tabs they had before (usually none), the library * tabs created from the saved tabs of YIKES go to the trash, except those that products still use then, and the * settings the import changed are put back. Changes made since the import on the imported products are lost; the * data of YIKES was never changed, so YIKES shows the products as before as long as it is active. * * @return array products (cleaned), restored (tabs put back), library (trashed), kept (still used, kept). */ public static function undo(): array { // One request for the whole catalog: no time limit, where the server allows it. wc_set_time_limit( 0 ); $products = self::imported_ids(); $restored = self::restore( $products ); $library = self::trash_unused( self::library_sources() ); self::restore_settings(); self::reset(); wp_cache_delete( 'detect', 'prodtabs_yikes' ); return array( 'products' => count( $products ), 'restored' => $restored, 'library' => $library['trashed'], 'kept' => $library['kept'], ); } /** * Products that carry the mark of an import. * * @return int[] */ private static function imported_ids(): array { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one-off cleanup. return array_map( 'intval', $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s", self::META_IMPORTED ) ) ); } /** * Gives imported products back the Prodtabs tabs they had before the import, and removes the marks of the import. * * @param int[] $products Imported products. * @return int Products that got tabs back. */ private static function restore( array $products ): int { $restored = 0; foreach ( self::restore_plan( $products ) as $id => $backup ) { $layout = $backup['layout']; $texts = $backup['texts']; if ( $layout ) { update_post_meta( $id, Tabs::META_LAYOUT, wp_slash( $layout ) ); ++$restored; } else { delete_post_meta( $id, Tabs::META_LAYOUT ); } if ( $texts ) { Product_Tabs::trusted( static function () use ( $id, $texts ) { update_post_meta( $id, Tabs::META_TEXTS, wp_slash( $texts ) ); } ); } else { delete_post_meta( $id, Tabs::META_TEXTS ); } delete_post_meta( $id, self::META_IMPORTED ); delete_post_meta( $id, self::META_BACKUP ); clean_post_cache( $id ); wc_delete_product_transients( $id ); } return $restored; } /** * What the undo puts back on each imported product: its own texts, and the structure of the backup taken first in * its group of translations. With Polylang, a language imported after another one had received that import's * structure in the meantime, so its own backup would bring the import back. * * @param int[] $products Imported products. * @return array[] By product id: layout, texts. */ private static function restore_plan( array $products ): array { $backups = self::backups( $products ); $in = array_fill_keys( $products, true ); $plan = array(); foreach ( $products as $id ) { if ( isset( $plan[ $id ] ) ) { continue; } $members = array_values( array_filter( Multilingual::translation_ids( (int) $id, 'product' ), static function ( $member ) use ( $in ) { return isset( $in[ $member ] ); } ) ); if ( ! in_array( $id, $members, true ) ) { $members[] = $id; } $first = 0; foreach ( $members as $member ) { if ( isset( $backups[ $member ] ) && ( ! $first || $backups[ $member ]['order'] < $backups[ $first ]['order'] ) ) { $first = $member; } } foreach ( $members as $member ) { $plan[ $member ] = array( 'layout' => $first ? $backups[ $first ]['layout'] : array(), 'texts' => $backups[ $member ]['texts'] ?? array(), ); } } return $plan; } /** * Moves to the trash the library tabs created by the import that no product uses once the imported products have * their earlier tabs back. * * @param int[] $library Library tabs created by the import. * @return array trashed, kept (counts). */ private static function trash_unused( array $library ): array { $trashed = 0; $kept = 0; foreach ( $library as $post_id ) { if ( Library::products_using( (int) $post_id, 1 ) ) { ++$kept; continue; } wp_trash_post( $post_id ); ++$trashed; } return array( 'trashed' => $trashed, 'kept' => $kept, ); } }