): list> */ private $postRowsProvider; /** * Builds the edit URL for a post id. * * @var callable(int): string */ private $editUrlResolver; /** * @param callable|null $postRowsProvider Optional `(list): list>` provider. * @param callable|null $editUrlResolver Optional `(int): string` edit URL resolver. * * @phpstan-param (callable(list): list>)|null $postRowsProvider * @phpstan-param (callable(int): string)|null $editUrlResolver */ public function __construct( ?callable $postRowsProvider = null, ?callable $editUrlResolver = null ) { $this->postRowsProvider = $postRowsProvider ?? self::defaultPostRowsProvider(); $this->editUrlResolver = $editUrlResolver ?? self::defaultEditUrlResolver(); } /** * Resolve post usage grouped by product id. * * @param list $productIds Product ids on the current products page. * * @return array> */ public function resolveForProductIds( array $productIds ): array { $ids = self::normalizeIds( $productIds ); if ( $ids === array() ) { return array(); } $result = array(); foreach ( $ids as $id ) { $result[ $id ] = array(); } $wanted = array_fill_keys( $ids, true ); $seen = array(); $rows = ( $this->postRowsProvider )( $ids ); foreach ( $rows as $row ) { $postId = self::positiveInt( $row['ID'] ?? $row['id'] ?? null ); if ( $postId === null ) { continue; } $content = is_string( $row['post_content'] ?? null ) ? $row['post_content'] : ''; if ( $content === '' ) { continue; } $referenced = self::normalizeIds( self::extractProductIdsFromContent( $content ) ); if ( $referenced === array() ) { continue; } $title = is_string( $row['post_title'] ?? null ) ? $row['post_title'] : ''; $type = is_string( $row['post_type'] ?? null ) ? $row['post_type'] : ''; $status = is_string( $row['post_status'] ?? null ) ? $row['post_status'] : ''; $url = ( $this->editUrlResolver )( $postId ); foreach ( $referenced as $productId ) { if ( ! isset( $wanted[ $productId ] ) ) { continue; } $seenKey = $productId . ':' . $postId; if ( isset( $seen[ $seenKey ] ) ) { continue; } $seen[ $seenKey ] = true; $result[ $productId ][] = array( 'id' => $postId, 'title' => $title !== '' ? $title : '(タイトルなし)', 'url' => $url, 'type' => $type, 'status' => $status, ); } } return $result; } /** * Extract product ids from affilun block content. * * @param string $content Post content. * * @return list */ private static function extractProductIdsFromContent( string $content ): array { $fromParser = self::extractProductIdsWithBlockParser( $content ); if ( $fromParser !== array() ) { return $fromParser; } return self::extractProductIdsWithRegex( $content ); } /** * Use WordPress block parser when available. * * @param string $content Post content. * * @return list */ private static function extractProductIdsWithBlockParser( string $content ): array { if ( ! function_exists( 'parse_blocks' ) ) { return array(); } $blocks = call_user_func( 'parse_blocks', $content ); if ( ! is_array( $blocks ) ) { return array(); } return self::extractProductIdsFromBlocks( $blocks ); } /** * Walk parsed blocks recursively. * * @param array $blocks Parsed block list. * * @return list */ private static function extractProductIdsFromBlocks( array $blocks ): array { $ids = array(); foreach ( $blocks as $block ) { if ( ! is_array( $block ) ) { continue; } $name = is_string( $block['blockName'] ?? null ) ? $block['blockName'] : ''; if ( $name === 'affilun/product-card' ) { $attrs = is_array( $block['attrs'] ?? null ) ? $block['attrs'] : array(); $ids = array_merge( $ids, self::extractProductIdsFromAttributes( $attrs ) ); } $inner = is_array( $block['innerBlocks'] ?? null ) ? $block['innerBlocks'] : array(); if ( $inner !== array() ) { $ids = array_merge( $ids, self::extractProductIdsFromBlocks( $inner ) ); } } return self::normalizeIds( $ids ); } /** * Fallback parser for serialized Gutenberg comments. * * @param string $content Post content. * * @return list */ private static function extractProductIdsWithRegex( string $content ): array { $ids = array(); if ( preg_match_all( '//s', $content, $matches ) !== false ) { foreach ( $matches[1] as $json ) { $decoded = json_decode( (string) $json, true ); if ( is_array( $decoded ) ) { $ids = array_merge( $ids, self::extractProductIdsFromAttributes( $decoded ) ); } } } return self::normalizeIds( $ids ); } /** * Extract product ids from block attributes. * * @param array $attrs Block attributes. * * @return list */ private static function extractProductIdsFromAttributes( array $attrs ): array { $ids = array(); if ( array_key_exists( 'productId', $attrs ) ) { $id = self::positiveInt( $attrs['productId'] ); if ( $id !== null ) { $ids[] = $id; } } if ( isset( $attrs['productIds'] ) && is_array( $attrs['productIds'] ) ) { foreach ( $attrs['productIds'] as $raw ) { $id = self::positiveInt( $raw ); if ( $id !== null ) { $ids[] = $id; } } } return self::normalizeIds( $ids ); } /** * Normalize ids to unique positive integers in input order. * * @param list $ids Raw ids. * * @return list */ private static function normalizeIds( array $ids ): array { $normalized = array(); $seen = array(); foreach ( $ids as $raw ) { $id = self::positiveInt( $raw ); if ( $id === null || isset( $seen[ $id ] ) ) { continue; } $seen[ $id ] = true; $normalized[] = $id; } return $normalized; } /** * Convert a scalar-ish value to a positive integer. * * @param mixed $value Raw value. */ private static function positiveInt( mixed $value ): ?int { if ( is_int( $value ) && $value > 0 ) { return $value; } if ( is_string( $value ) && trim( $value ) !== '' && ctype_digit( trim( $value ) ) ) { $id = (int) trim( $value ); return $id > 0 ? $id : null; } return null; } /** * Default WordPress-backed post row provider. * * @return callable(list): list> */ private static function defaultPostRowsProvider(): callable { return static function ( array $productIds ): array { if ( $productIds === array() ) { return array(); } global $wpdb; if ( ! isset( $wpdb ) || ! is_object( $wpdb ) || ! isset( $wpdb->posts ) ) { return array(); } if ( (string) $wpdb->posts === '' || ! method_exists( $wpdb, 'get_results' ) ) { return array(); } $like = '%' . self::escLike( $wpdb, 'wp:affilun/product-' ) . '%'; if ( ! method_exists( $wpdb, 'prepare' ) ) { return array(); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Read-only admin usage lookup scoped to post-content block comments. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT ID, post_title, post_type, post_status, post_content FROM %i WHERE post_content LIKE %s AND post_status NOT IN ('auto-draft', 'trash') AND post_type NOT IN ('revision', 'nav_menu_item', 'attachment') ORDER BY post_modified_gmt DESC, ID DESC", (string) $wpdb->posts, $like ), 'ARRAY_A' ); if ( ! is_array( $rows ) ) { return array(); } $result = array(); foreach ( $rows as $row ) { if ( is_array( $row ) ) { /** @var array $row */ $result[] = $row; } } return $result; }; } /** * Escape a LIKE fragment through `$wpdb->esc_like()` when available. * * @param object $wpdb WordPress DB object. * @param string $value Raw LIKE fragment. */ private static function escLike( object $wpdb, string $value ): string { if ( method_exists( $wpdb, 'esc_like' ) ) { return (string) $wpdb->esc_like( $value ); } return addcslashes( $value, '_%\\' ); } /** * Default edit URL resolver. * * @return callable(int): string */ private static function defaultEditUrlResolver(): callable { return static function ( int $postId ): string { if ( function_exists( 'get_edit_post_link' ) ) { $url = call_user_func( 'get_edit_post_link', $postId, '' ); if ( is_string( $url ) && $url !== '' ) { return $url; } } $path = 'post.php?post=' . $postId . '&action=edit'; if ( function_exists( 'admin_url' ) ) { return (string) admin_url( $path ); } return 'http://example.test/wp-admin/' . $path; }; } }