*/ private const SHORTCODE_TAGS = array( 'product_link', 'product-card', 'affiliate_product', 'affiliate-card', 'legacy_product_link', ); /** * Preview rewriteable legacy shortcode/block shapes. * * @param list $posts Posts. * * @return array{posts_scanned:int,changes:list} */ public function preview( array $posts ): array { $changes = array(); foreach ( $posts as $post ) { $changes = array_merge( $changes, $this->changesForPost( $post ) ); } return array( 'posts_scanned' => count( $posts ), 'changes' => $changes, ); } /** * Apply a preview plan and update changed posts. * * @param list $posts Posts. * @param callable $postUpdater Post updater callback. * * @phpstan-param callable(int,string): bool $postUpdater * * @return array{posts_scanned:int,changes:list,updated_posts:int} */ public function apply( array $posts, callable $postUpdater ): array { $preview = $this->preview( $posts ); $changesByPost = array(); foreach ( $preview['changes'] as $change ) { $postId = $change['post_id']; if ( ! array_key_exists( $postId, $changesByPost ) ) { $changesByPost[ $postId ] = array(); } $changesByPost[ $postId ][] = $change; } $updated = 0; foreach ( $posts as $post ) { $postChanges = $changesByPost[ $post->id() ] ?? array(); if ( $postChanges === array() ) { continue; } $newContent = $this->rewriteContent( $post->content(), $postChanges ); if ( $newContent === $post->content() ) { continue; } if ( $postUpdater( $post->id(), $newContent ) ) { ++$updated; } } return array( 'posts_scanned' => $preview['posts_scanned'], 'changes' => $preview['changes'], 'updated_posts' => $updated, ); } /** * Find rewriteable changes for one post. * * @param PostScanItem $post Post. * * @return list */ private function changesForPost( PostScanItem $post ): array { $changes = array(); foreach ( self::SHORTCODE_TAGS as $tag ) { $changes = array_merge( $changes, $this->shortcodeChangesForPost( $post, $tag ) ); } $changes = array_merge( $changes, $this->blockChangesForPost( $post ) ); usort( $changes, static fn ( array $a, array $b ): int => $a['offset'] <=> $b['offset'] ); return $changes; } /** * Find rewriteable legacy shortcode changes. * * @param PostScanItem $post Post. * @param string $tag Shortcode tag. * * @return list */ private function shortcodeChangesForPost( PostScanItem $post, string $tag ): array { $pattern = '/\[\s*' . preg_quote( $tag, '/' ) . '\b([^\]]*)\](?:.*?\[\/\s*' . preg_quote( $tag, '/' ) . '\s*\])?/is'; $changes = array(); if ( preg_match_all( $pattern, $post->content(), $found, PREG_OFFSET_CAPTURE ) === false ) { return array(); } foreach ( $found[0] as $index => $match ) { $attrs = self::parseAttributes( (string) ( $found[1][ $index ][0] ?? '' ) ); $replacement = $this->shortcodeReplacement( $attrs ); if ( $replacement === null ) { continue; } $changes[] = $this->change( $post, 'shortcode', $tag, (int) $match[1], (string) $match[0], $replacement, 95 ); } return $changes; } /** * Find rewriteable legacy block comment changes. * * @param PostScanItem $post Post. * * @return list */ private function blockChangesForPost( PostScanItem $post ): array { $pattern = '/|-->.*?)/is'; $changes = array(); if ( preg_match_all( $pattern, $post->content(), $found, PREG_OFFSET_CAPTURE ) === false ) { return array(); } foreach ( $found[0] as $index => $match ) { $shape = strtolower( (string) ( $found[1][ $index ][0] ?? '' ) ); $json = (string) ( $found[2][ $index ][0] ?? '' ); $replacement = $this->blockReplacement( $shape, $json ); if ( $replacement === null ) { continue; } $changes[] = $this->change( $post, 'block', 'legacy-product-link/' . $shape, (int) $match[1], (string) $match[0], $replacement, 98 ); } return $changes; } /** * Build a shortcode replacement from parsed attributes. * * @param array $attrs Attributes. */ private function shortcodeReplacement( array $attrs ): ?string { $ids = self::positiveIntList( $attrs['product_ids'] ?? $attrs['ids'] ?? '' ); if ( count( $ids ) > 1 ) { return implode( "\n", array_map( static fn ( int $id ): string => '[affilun_product_card product_id="' . (string) $id . '"]', $ids ) ); } $id = self::positiveInt( $attrs['affilun_product_id'] ?? $attrs['product_id'] ?? $attrs['id'] ?? null ); if ( $id === null && count( $ids ) === 1 ) { $id = $ids[0]; } if ( $id === null ) { return null; } return '[affilun_product_card product_id="' . (string) $id . '"]'; } /** * Build a block replacement from a legacy block JSON payload. * * @param string $shape Block shape. * @param string $json Attribute JSON. */ private function blockReplacement( string $shape, string $json ): ?string { $attrs = self::decodeJsonObject( $json ); if ( $shape === 'grid' ) { $ids = self::positiveIntList( $attrs['productIds'] ?? $attrs['product_ids'] ?? $attrs['ids'] ?? '' ); if ( $ids === array() ) { return null; } return implode( "\n", array_map( static fn ( int $id ): string => '', $ids ) ); } $id = self::positiveInt( $attrs['productId'] ?? $attrs['product_id'] ?? $attrs['id'] ?? null ); if ( $id === null ) { return null; } return ''; } /** * Rewrite content using non-overlapping changes from the end backward. * * @param string $content Content. * @param list> $changes Changes. */ public function rewriteContent( string $content, array $changes ): string { usort( $changes, static fn ( array $a, array $b ): int => (int) $b['offset'] <=> (int) $a['offset'] ); foreach ( $changes as $change ) { $offset = (int) $change['offset']; $original = is_string( $change['original'] ?? null ) ? $change['original'] : ''; $replacement = is_string( $change['replacement'] ?? null ) ? $change['replacement'] : ''; if ( $original === '' || substr( $content, $offset, strlen( $original ) ) !== $original ) { continue; } $content = substr_replace( $content, $replacement, $offset, strlen( $original ) ); } return $content; } /** * Build one change row. * * @param PostScanItem $post Post. * @param string $kind Kind. * @param string $source Source. * @param int $offset Offset. * @param string $original Original content. * @param string $replacement Replacement content. * @param int $confidence Confidence. * * @return array{post_id:int,post_title:string,kind:string,source:string,offset:int,original:string,replacement:string,confidence:int} */ private function change( PostScanItem $post, string $kind, string $source, int $offset, string $original, string $replacement, int $confidence ): array { return array( 'post_id' => $post->id(), 'post_title' => $post->title(), 'kind' => $kind, 'source' => $source, 'offset' => max( 0, $offset ), 'original' => $original, 'replacement' => $replacement, 'confidence' => max( 0, min( 100, $confidence ) ), ); } /** * Parse shortcode attributes. * * @param string $raw Raw attribute string. * * @return array */ private static function parseAttributes( string $raw ): array { $attrs = array(); $pattern = '/([a-zA-Z0-9_\-]+)\s*=\s*("[^"]*"|\'[^\']*\'|[^\s\]]+)/'; if ( preg_match_all( $pattern, $raw, $found, PREG_SET_ORDER ) === false ) { return array(); } foreach ( $found as $match ) { $key = strtolower( (string) $match[1] ); $value = trim( (string) $match[2], " \t\n\r\0\x0B\"'" ); if ( $key !== '' ) { $attrs[ $key ] = $value; } } return $attrs; } /** * Decode a JSON object. * * @param string $json JSON. * * @return array */ private static function decodeJsonObject( string $json ): array { if ( trim( $json ) === '' ) { return array(); } $decoded = json_decode( $json, true ); return is_array( $decoded ) ? $decoded : array(); } /** * Parse positive int. * * @param mixed $value Value. */ private static function positiveInt( mixed $value ): ?int { if ( is_int( $value ) && $value > 0 ) { return $value; } if ( ! is_scalar( $value ) ) { return null; } $value = trim( (string) $value ); return ctype_digit( $value ) && (int) $value > 0 ? (int) $value : null; } /** * Parse positive int list. * * @param mixed $value Value. * * @return list */ private static function positiveIntList( mixed $value ): array { $parts = is_array( $value ) ? $value : explode( ',', is_scalar( $value ) ? (string) $value : '' ); $ids = array(); foreach ( $parts as $part ) { $id = self::positiveInt( $part ); if ( $id !== null && ! in_array( $id, $ids, true ) ) { $ids[] = $id; } } return $ids; } }