id on this site of the shared tabs imported, by site of origin (md5 of its address). */ const OPTION_MAP = 'prodtabs_import_map'; /** Transient: number of shared tabs left out of imported products, for the notice. */ const NOTICE = 'prodtabs_import_missing'; /** * Shared tabs of the current WordPress import: former id => id on this site. * * @var int[] */ private static $map = array(); /** * Products of the current WordPress import that received tabs, as keys. * * @var true[] */ private static $products = array(); /** Shared tabs left out during this request. */ private static $missing = 0; /** * Address of the site the current WordPress import comes from, noted at its start; null outside an import. * * @var string|null */ private static $base = null; /** Hooks. */ public static function init(): void { add_action( 'import_start', array( __CLASS__, 'import_start' ) ); add_action( 'wp_import_insert_post', array( __CLASS__, 'imported_post' ), 10, 2 ); add_filter( 'wp_import_existing_post', array( __CLASS__, 'existing_post' ), 10, 2 ); add_action( 'import_post_meta', array( __CLASS__, 'imported_meta' ), 10, 2 ); add_action( 'import_end', array( __CLASS__, 'import_end' ) ); add_action( 'shutdown', array( __CLASS__, 'remember_missing' ) ); if ( is_admin() ) { add_action( 'admin_notices', array( __CLASS__, 'notice' ) ); } } /** * Reference of a shared tab that holds on another site: its GUID and its slug. * * @param int $id Shared tab. * @return string[] ref and slug; empty when the post is not a shared tab. */ public static function reference( int $id ): array { $post = $id ? get_post( $id ) : null; if ( ! $post || Library::POST_TYPE !== $post->post_type ) { return array(); } return array( 'ref' => self::guid( (string) $post->guid ), 'slug' => (string) $post->post_name, ); } /** * GUID with its ampersands as such: WordPress stores it with & or & depending on how the post was created * (by hand, by the importer, from an escaped export). * * @param string $guid GUID. */ public static function guid( string $guid ): string { return str_replace( '&', '&', str_replace( '&', '&', $guid ) ); } /** * Shared tab of this site for a reference: the same GUID, or else the same slug. * * @param string $guid GUID on the site of origin. * @param string $slug Slug on the site of origin. * @return int 0 when none. */ public static function find( string $guid, string $slug ): int { global $wpdb; // A published tab first, then the oldest: the same file imported twice gives two posts with the same GUID. The // GUID is compared with its ampersands as such on both sides (see guid()). Posts of every language. if ( '' !== $guid ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- lookup during an import. $id = (int) $wpdb->get_var( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s AND REPLACE(REPLACE(guid, '&', '&'), '&', '&') = %s AND post_status NOT IN ('trash', 'auto-draft') ORDER BY post_status = 'publish' DESC, ID ASC LIMIT 1", Library::POST_TYPE, self::guid( $guid ) ) ); if ( $id ) { return $id; } } if ( '' !== $slug ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- lookup during an import. $id = (int) $wpdb->get_var( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s AND post_name = %s AND post_status NOT IN ('trash', 'auto-draft') ORDER BY post_status = 'publish' DESC, ID ASC LIMIT 1", Library::POST_TYPE, $slug ) ); if ( $id ) { return $id; } } return 0; } /** * Stored structure of a product, with the reference of each shared tab it uses, for an export. * * @param array $layout Stored structure. * @return array */ public static function export_layout( array $layout ): array { foreach ( $layout as $i => $entry ) { if ( ! is_array( $entry ) ) { continue; } if ( 'library' === ( $entry['type'] ?? '' ) ) { $ref = self::reference( absint( $entry['library_id'] ?? 0 ) ); if ( $ref ) { $layout[ $i ]['ref'] = $ref['ref']; $layout[ $i ]['ref_slug'] = $ref['slug']; } } elseif ( 'local' === ( $entry['type'] ?? '' ) && ! empty( $entry['origin'] ) ) { $ref = self::reference( absint( $entry['origin'] ) ); if ( $ref ) { $layout[ $i ]['origin_ref'] = $ref['ref']; $layout[ $i ]['origin_slug'] = $ref['slug']; } } } return $layout; } /** * Imported structure pointed to the shared tabs of this site, by their reference. Entries without a reference (a * file made before ProdTabs 0.4) keep their ids. * * @param array $layout Imported structure. * @return array */ public static function import_layout( array $layout ): array { $out = array(); foreach ( $layout as $entry ) { if ( ! is_array( $entry ) ) { continue; } $type = $entry['type'] ?? ''; if ( 'library' === $type && ( isset( $entry['ref'] ) || isset( $entry['ref_slug'] ) ) ) { $id = self::find( (string) ( $entry['ref'] ?? '' ), (string) ( $entry['ref_slug'] ?? '' ) ); if ( ! $id ) { ++self::$missing; continue; } $entry['library_id'] = $id; $entry['key'] = 'lib_' . $id; } elseif ( 'local' === $type && ( isset( $entry['origin_ref'] ) || isset( $entry['origin_slug'] ) ) ) { // Customized from a shared tab: the text is the product's own, only the link to the original is lost. $entry['origin'] = self::find( (string) ( $entry['origin_ref'] ?? '' ), (string) ( $entry['origin_slug'] ?? '' ) ); } unset( $entry['ref'], $entry['ref_slug'], $entry['origin_ref'], $entry['origin_slug'] ); $out[] = $entry; } return $out; } /** WordPress Importer: the file is read. Its site is noted now: Tools > Import deletes the upload before the end. */ public static function import_start(): void { self::$base = self::import_base(); } /** * WordPress Importer: a shared tab was created. * * @param int $post_id New id. * @param int $original_id Id on the site of origin. */ public static function imported_post( $post_id, $original_id ): void { if ( Library::POST_TYPE === get_post_type( (int) $post_id ) ) { self::$map[ (int) $original_id ] = (int) $post_id; } } /** * WordPress Importer: a shared tab of the file already exists here (the file imported again). * * @param int $exists Id of the existing post, 0 when none. * @param array $post Post of the file. * @return int */ public static function existing_post( $exists, $post ) { if ( $exists && is_array( $post ) && Library::POST_TYPE === ( $post['post_type'] ?? '' ) && ! empty( $post['post_id'] ) ) { self::$map[ (int) $post['post_id'] ] = (int) $exists; } return $exists; } /** * WordPress Importer: a meta was written; the products whose tabs came with the file are linked at the end. * * @param int $post_id Post id. * @param string $key Meta key. */ public static function imported_meta( $post_id, $key ): void { if ( Tabs::META_LAYOUT === $key ) { self::$products[ (int) $post_id ] = true; } } /** WordPress Importer: end of the import. Remembers the shared tabs, then links the products. */ public static function import_end(): void { $base = null !== self::$base ? self::$base : self::import_base(); self::$base = null; $maps = get_option( self::OPTION_MAP, array() ); $maps = is_array( $maps ) ? $maps : array(); $site = md5( $base ); $map = array_replace( isset( $maps[ $site ] ) && is_array( $maps[ $site ] ) ? $maps[ $site ] : array(), self::$map ); // An unknown origin is never this site: its ids would point to other tabs of this site. $same = '' !== $base && in_array( untrailingslashit( $base ), array( untrailingslashit( home_url() ), untrailingslashit( site_url() ) ), true ); $products = array_keys( self::$products ); if ( self::$map && '' !== $base ) { $maps[ $site ] = $map; update_option( self::OPTION_MAP, $maps, false ); } self::$map = array(); self::$products = array(); foreach ( $products as $product_id ) { $layout = get_post_meta( $product_id, Tabs::META_LAYOUT, true ); if ( ! is_array( $layout ) ) { continue; } $linked = self::link_ids( $layout, $map, $same ); if ( $linked !== $layout ) { update_post_meta( $product_id, Tabs::META_LAYOUT, wp_slash( $linked ) ); clean_post_cache( $product_id ); } } } /** * A structure imported by the WordPress Importer pointed to the shared tabs of this site, by their former id. * * @param array $layout Stored structure. * @param int[] $map Former id => id on this site. * @param bool $same Whether the file comes from this very site: ids not in the map are then kept. * @return array */ public static function link_ids( array $layout, array $map, bool $same ): array { $out = array(); foreach ( $layout as $entry ) { if ( ! is_array( $entry ) ) { continue; } $type = $entry['type'] ?? ''; if ( 'library' === $type ) { $old = absint( $entry['library_id'] ?? 0 ); $new = $map[ $old ] ?? ( $same ? $old : 0 ); if ( ! $new ) { ++self::$missing; continue; } $entry['library_id'] = $new; $entry['key'] = 'lib_' . $new; } elseif ( 'local' === $type && ! empty( $entry['origin'] ) ) { $old = absint( $entry['origin'] ); $entry['origin'] = $map[ $old ] ?? ( $same ? $old : 0 ); } $out[] = $entry; } return $out; } /** * Address of the site the WordPress Importer reads the file of, empty when unknown: the site itself (wp:base_blog_url * of the file), which on a multisite differs from the address of its network (wp:base_site_url, the only one the * importer keeps, used when the file cannot be read). */ private static function import_base(): string { $importer = self::importer(); if ( ! $importer ) { return ''; } $file = ! empty( $importer->id ) ? (string) get_attached_file( (int) $importer->id ) : ''; if ( '' !== $file && is_readable( $file ) ) { // The header of the file comes before its first item. $head = (string) file_get_contents( $file, false, null, 0, 65536 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local file of the import. if ( preg_match( '#\s*([^<\s]+)\s*#', $head, $match ) ) { return esc_url_raw( $match[1] ); } } return isset( $importer->base_url ) && is_string( $importer->base_url ) ? $importer->base_url : ''; } /** The WordPress Importer that runs: its global under Tools > Import, its own meta key filter under WP-CLI. */ private static function importer(): ?object { $importer = $GLOBALS['wp_import'] ?? null; if ( is_object( $importer ) ) { return $importer; } global $wp_filter; if ( isset( $wp_filter['import_post_meta_key'] ) ) { foreach ( $wp_filter['import_post_meta_key']->callbacks as $callbacks ) { foreach ( $callbacks as $callback ) { if ( is_array( $callback['function'] ) && $callback['function'][0] instanceof \WP_Import ) { return $callback['function'][0]; } } } } return null; } /** End of a request that left shared tabs out: kept for the notice of the next admin screen. */ public static function remember_missing(): void { if ( self::$missing ) { set_transient( self::NOTICE, (int) get_transient( self::NOTICE ) + self::$missing, DAY_IN_SECONDS ); self::$missing = 0; } } /** Notice once, to the users who can import. */ public static function notice(): void { $count = (int) get_transient( self::NOTICE ); if ( ! $count || ! current_user_can( 'import' ) ) { return; } delete_transient( self::NOTICE ); $text = sprintf( /* translators: %d: number of shared tabs. */ _n( '%d shared tab of the imported products was not found on this site, and was left out of them. Import the shared tabs first (Tools > Import > WordPress, with the export of the shared tabs of the other site), then import the products again.', '%d shared tabs of the imported products were not found on this site, and were left out of them. Import the shared tabs first (Tools > Import > WordPress, with the export of the shared tabs of the other site), then import the products again.', $count, 'prodtabs' ), $count ); echo '

' . esc_html( $text ) . '

'; } }