$entry ) { if ( '' === $entry['key'] ) { $out[ $i ]['key'] = self::key_from( $entry['title'], $seen ); $seen[ $out[ $i ]['key'] ] = true; } } return self::unique_anchors( $out ); } /** * Cleans one entry. * * @param array $raw Raw entry. * @return array|null Null when the entry is unusable. */ private static function normalize_entry( array $raw ): ?array { $type = isset( $raw['type'] ) ? (string) $raw['type'] : ''; if ( 'native' === $type ) { $key = isset( $raw['key'] ) ? (string) $raw['key'] : ''; if ( ! in_array( $key, self::NATIVE, true ) ) { return null; } return array( 'type' => 'native', 'key' => $key, 'visible' => self::tri_state( $raw['visible'] ?? null ), ); } if ( 'local' === $type ) { $title = isset( $raw['title'] ) && is_scalar( $raw['title'] ) ? trim( (string) $raw['title'] ) : ''; $content = isset( $raw['content'] ) && is_scalar( $raw['content'] ) ? (string) $raw['content'] : ''; $key = isset( $raw['key'] ) && is_scalar( $raw['key'] ) ? (string) $raw['key'] : ''; // A new tab without any text is a slip; an existing key without text may be translated later. if ( '' === $key && '' === $title && '' === trim( $content ) ) { return null; } return array( 'type' => 'local', 'key' => self::valid_key( $key ) ? $key : '', 'anchor' => isset( $raw['anchor'] ) && is_scalar( $raw['anchor'] ) ? (string) $raw['anchor'] : '', 'title' => $title, 'content' => $content, 'origin' => isset( $raw['origin'] ) ? absint( $raw['origin'] ) : 0, 'visible' => self::to_bool( $raw['visible'] ?? true ), // False when the language of the product has no text yet for this tab. 'translated' => isset( $raw['translated'] ) ? (bool) $raw['translated'] : true, ); } if ( 'library' === $type ) { $id = isset( $raw['library_id'] ) ? absint( $raw['library_id'] ) : 0; if ( ! $id ) { return null; } $entry = array( 'type' => 'library', 'key' => 'lib_' . $id, 'library_id' => $id, 'anchor' => isset( $raw['anchor'] ) && is_scalar( $raw['anchor'] ) ? (string) $raw['anchor'] : '', 'visible' => self::to_bool( $raw['visible'] ?? true ), ); // Added by the "Show on" setting of the shared tab, not by hand: it goes away with that setting. if ( ! empty( $raw['auto'] ) && self::to_bool( $raw['auto'] ) ) { $entry['auto'] = true; } return $entry; } return null; } /** * Gives every local tab an anchor, and keeps the anchors of library tabs, valid in a CSS selector and unique within * the product. The anchor is set once, from the title in the language where the tab was created, and is then the * same in every language. * * @param array[] $entries Normalised entries. * @return array[] */ private static function unique_anchors( array $entries ): array { $taken = array_fill_keys( self::NATIVE, true ); foreach ( $entries as $i => $entry ) { if ( 'native' === $entry['type'] ) { continue; } $given = '' !== $entry['anchor']; $wanted = $given ? $entry['anchor'] : ( 'local' === $entry['type'] ? $entry['title'] : '' ); if ( '' === $wanted && 'library' === $entry['type'] ) { continue; // Derived from the library tab when the product is displayed. } // An anchor already set (imported from YIKES, links already shared) is kept whole; one made from a title is shortened. $anchor = self::dedupe( self::anchor( $wanted, $entry['key'], ! $given ), $taken ); $taken[ $anchor ] = true; $entries[ $i ]['anchor'] = $anchor; } return $entries; } /** * Anchor of a tab from a text: the slug of the title, decoded, so that a title in Cyrillic or Chinese keeps a * readable anchor (#tab-доставка), as with Custom Product Tabs. WooCommerce's tab script puts the anchor in a jQuery * selector: percent-encoded characters would break it, letters of any script do not. * * @param string $text Title or wanted anchor. * @param string $fallback Value used when the text gives nothing usable. * @param bool $shorten Cut to 60 characters (anchors made from a title). */ public static function anchor( string $text, string $fallback, bool $shorten = true ): string { $slug = urldecode( sanitize_title( $text ) ); if ( '' === $slug || ! preg_match( '/^[a-z0-9_\-\x{00A0}-\x{10FFFF}]+$/u', $slug ) ) { $slug = str_replace( '_', '-', sanitize_key( $fallback ) ); } // A number would become an integer array key, which the accordion of the Product Details block also uses. if ( ctype_digit( $slug ) ) { $slug = 'tab-' . $slug; } if ( ! $shorten ) { return $slug; } return function_exists( 'mb_substr' ) ? mb_substr( $slug, 0, 60 ) : substr( $slug, 0, 60 ); } /** * Adds -2, -3… to an anchor already in use. * * @param string $slug Anchor. * @param array $taken Anchors in use, as keys. */ public static function dedupe( string $slug, array $taken ): string { if ( ! isset( $taken[ $slug ] ) ) { return $slug; } $n = 2; while ( isset( $taken[ $slug . '-' . $n ] ) ) { ++$n; } return $slug . '-' . $n; } /** * Adds the WooCommerce tabs missing from a list, in the order of the settings: those up to Description go before * the list, the others after it. Custom tabs therefore come right after Description, as in most tab plugins. * * @param array[] $entries Normalised entries. * @param array $native_order Native keys in the order of the settings. * @return array[] */ public static function with_natives( array $entries, array $native_order = self::NATIVE ): array { $present = array(); foreach ( $entries as $entry ) { if ( 'native' === $entry['type'] ) { $present[ $entry['key'] ] = true; } } $head = array(); $tail = array(); $before = true; foreach ( $native_order as $key ) { if ( ! isset( $present[ $key ] ) ) { $native = array( 'type' => 'native', 'key' => $key, 'visible' => null, ); if ( $before ) { $head[] = $native; } else { $tail[] = $native; } } if ( 'description' === $key ) { $before = false; } } return array_merge( $head, $entries, $tail ); } /** * Inserts an entry after the last tab of the plugin, or after Description when there is none: where a tab added * in the panel goes. * * @param array[] $entries Normalised entries. * @param array $entry Entry to insert. * @return array[] */ public static function insert( array $entries, array $entry ): array { $after = null; foreach ( array_values( $entries ) as $index => $item ) { if ( 'native' !== $item['type'] || ( null === $after && 'description' === $item['key'] ) ) { $after = $index; } } $entries = array_values( $entries ); array_splice( $entries, null === $after ? count( $entries ) : $after + 1, 0, array( $entry ) ); return $entries; } /** * Splits entries into the two stored metas. * * @param array[] $entries Normalised entries. * @return array layout (list without texts), texts (title and content by key). */ public static function split( array $entries ): array { $layout = array(); $texts = array(); foreach ( $entries as $entry ) { if ( 'local' === $entry['type'] ) { // No text at all: not translated in this language, the display falls back on the original. if ( '' !== $entry['title'] || '' !== trim( $entry['content'] ) ) { $texts[ $entry['key'] ] = array( 'title' => $entry['title'], 'content' => $entry['content'], ); } unset( $entry['title'], $entry['content'], $entry['translated'] ); } $layout[] = $entry; } return array( 'layout' => $layout, 'texts' => $texts, ); } /** * Puts the texts back into the local entries of a layout. * * @param array $layout Stored layout. * @param mixed $texts Stored texts, by key. * @return array[] Entries; a local entry without text in this language has an empty title and 'translated' false. */ public static function join( $layout, $texts ): array { $texts = is_array( $texts ) ? $texts : array(); $entries = array(); foreach ( is_array( $layout ) ? $layout : array() as $entry ) { if ( ! is_array( $entry ) ) { continue; } if ( 'local' === ( $entry['type'] ?? '' ) ) { $key = isset( $entry['key'] ) && is_scalar( $entry['key'] ) ? (string) $entry['key'] : ''; $text = isset( $texts[ $key ] ) && is_array( $texts[ $key ] ) ? $texts[ $key ] : null; $entry['title'] = $text && isset( $text['title'] ) && is_scalar( $text['title'] ) ? (string) $text['title'] : ''; $entry['content'] = $text && isset( $text['content'] ) && is_scalar( $text['content'] ) ? (string) $text['content'] : ''; $entry['translated'] = null !== $text; } $entries[] = $entry; } return $entries; } /** * Fingerprint of the structure of a list (order, types, keys, anchors, library tabs, visibility), texts left out: * tells whether the structure changed between two moments. * * @param mixed $entries Entries or stored layout. */ public static function signature( $entries ): string { $parts = array(); foreach ( self::normalize( $entries ) as $entry ) { $parts[] = array( $entry['type'], $entry['key'], $entry['library_id'] ?? 0, $entry['anchor'] ?? '', $entry['origin'] ?? 0, $entry['visible'], ); } return md5( (string) wp_json_encode( $parts ) ); } /** * Whether a list holds tabs of its own or library tabs. * * @param array[] $entries Normalised entries. */ public static function has_custom( array $entries ): bool { foreach ( $entries as $entry ) { if ( 'native' !== $entry['type'] ) { return true; } } return false; } /** * Whether the content of a tab would show nothing: no text, and no image, video, table, shortcode or block other * than paragraphs. Such a tab is left out of the shop instead of opening on an empty panel. * * @param string $content Raw content. */ public static function is_blank( string $content ): bool { if ( false !== strpos( $content, '[' ) || preg_match( '//s', '', $content ); if ( preg_match( '/<(?!\/?(?:p|br|div|span|strong|b|em|i)\b)[a-z]/i', $content ) ) { return false; // An image, a video, a table… } $text = html_entity_decode( wp_strip_all_tags( $content ), ENT_QUOTES, 'UTF-8' ); return '' === (string) preg_replace( '/[\s\x{00A0}]+/u', '', $text ); } /** * Library tab ids used by a list. * * @param array[] $entries Normalised entries. * @return int[] */ public static function library_ids( array $entries ): array { $ids = array(); foreach ( $entries as $entry ) { if ( 'library' === $entry['type'] ) { $ids[] = (int) $entry['library_id']; } } return array_values( array_unique( $ids ) ); } /** * Key of a local tab: lowercase ASCII letters, digits and underscores. Translation plugins use it in the names of * their fields, where the dash is a separator. * * @param string $key Key. */ public static function valid_key( string $key ): bool { return (bool) preg_match( '/^[a-z0-9_]{1,32}$/', $key ) && ! in_array( $key, self::NATIVE, true ) && 0 !== strpos( $key, 'lib_' ); } /** New key for a tab of the product. */ public static function new_key(): string { return 'lt_' . strtolower( wp_generate_password( 6, false, false ) ); } /** * Readable key from a title (livraison, notre_avis), unique among the keys given; a random key when the title has * no Latin letters. Translators see it in the field names of WPML. * * @param string $title Title. * @param array $taken Keys in use, as keys. */ public static function key_from( string $title, array $taken ): string { $slug = sanitize_title( $title ); $key = false === strpos( $slug, '%' ) ? substr( str_replace( '-', '_', $slug ), 0, 28 ) : ''; if ( ! self::valid_key( $key ) ) { $key = self::new_key(); } $base = $key; $n = 2; while ( isset( $taken[ $key ] ) ) { $key = $base . '_' . $n; ++$n; } return $key; } /** * Visibility that can follow the settings: null (follow), true or false. * * @param mixed $value Raw value. */ public static function tri_state( $value ): ?bool { if ( null === $value || '' === $value ) { return null; } return self::to_bool( $value ); } /** * Boolean from a form or stored value. * * @param mixed $value Raw value. */ public static function to_bool( $value ): bool { if ( is_bool( $value ) ) { return $value; } return ! in_array( strtolower( (string) $value ), array( '', '0', 'no', 'false', 'off' ), true ); } }