*/ private $postProvider; /** * @var callable(int,string): bool */ private $postUpdater; /** * @param OptionStore $options Option storage. * @param callable|null $postProvider Optional post provider. * @param LegacyProductLinkScanner|null $legacyScanner Optional legacy scanner. * @param mixed|null $opportunityScanner Deprecated opportunity scanner. * @param LegacyProductLinkImporter|null $legacyImporter Optional legacy importer. * @param callable|null $postUpdater Optional post updater. * * @phpstan-param (callable(int,int): list)|null $postProvider * @phpstan-param (callable(int,string): bool)|null $postUpdater */ public function __construct( OptionStore $options, ?callable $postProvider = null, ?LegacyProductLinkScanner $legacyScanner = null, mixed $opportunityScanner = null, ?LegacyProductLinkImporter $legacyImporter = null, ?callable $postUpdater = null ) { $this->options = $options; $this->postProvider = $postProvider ?? self::defaultPostProvider(); $this->legacyScanner = $legacyScanner ?? new LegacyProductLinkScanner(); unset( $opportunityScanner ); $this->legacyImporter = $legacyImporter ?? new LegacyProductLinkImporter(); $this->postUpdater = $postUpdater ?? self::defaultPostUpdater(); } /** * Render the page or export payload. * * @param array $query Query parameters. */ public function render( array $query = array() ): void { $action = self::queryString( $query, self::KEY_ACTION ); $offset = self::queryInt( $query, self::KEY_OFFSET, 0 ); $limit = self::queryInt( $query, self::KEY_LIMIT, 10 ); echo '
'; echo '

' . esc_html( '移行 / スキャン' ) . '

'; echo '

' . esc_html( '既存記事からaffilun以外の商品リンクを検出し移行します。' ) . '

'; $this->renderEmbedded( $query, self::MENU_SLUG, '' ); echo '
'; } /** * Render migration controls/results without the outer admin wrapper. * * @param array $query Query/request parameters. * @param string $menuSlug Admin page slug for embedded controls. * @param string $anchor Optional URL fragment to preserve tab focus. */ public function renderEmbedded( array $query = array(), string $menuSlug = self::MENU_SLUG, string $anchor = '' ): void { $action = self::queryString( $query, self::KEY_ACTION ); $offset = self::queryInt( $query, self::KEY_OFFSET, 0 ); $limit = self::queryInt( $query, self::KEY_LIMIT, 10 ); if ( $action === self::ACTION_DRY_RUN ) { $this->renderControls( $offset, $limit, $menuSlug, $anchor ); $this->renderDryRunReport( $offset, $limit, $menuSlug, $anchor ); } elseif ( $action === self::ACTION_IMPORT_PREVIEW ) { $job = $this->createPreviewJob( $offset, $limit ); $this->renderControls( $offset, $limit, $menuSlug, $anchor, $job ); $this->renderMigrationJob( $job ); } elseif ( $action === self::ACTION_BACKUP ) { $job = $this->backupLatestJob( $query ); $this->renderControls( $offset, $limit, $menuSlug, $anchor, $job ); $this->renderMigrationJob( $job ); } elseif ( $action === self::ACTION_IMPORT_APPLY ) { $job = $this->rewriteLatestJob( $query ); $this->renderControls( $offset, $limit, $menuSlug, $anchor, $job ); $this->renderMigrationJob( $job ); } else { $job = $this->latestJob(); $this->renderControls( $offset, $limit, $menuSlug, $anchor, $job ); if ( $job !== null ) { $this->renderMigrationJob( $job ); } } } /** * Render workflow controls. * * @param int $offset Offset. * @param int $limit Batch limit. * @param string $menuSlug Admin page slug for embedded controls. * @param string $anchor Optional URL fragment to preserve tab focus. * @param array|null $job Latest migration job. */ private function renderControls( int $offset, int $limit, string $menuSlug = self::MENU_SLUG, string $anchor = '', ?array $job = null ): void { if ( $menuSlug !== self::MENU_SLUG ) { $this->renderWorkflowButtons( $offset, $limit, $menuSlug, $anchor, $job ); return; } echo '

' . esc_html( 'プレビュー → バックアップ → リライト' ) . '

'; $this->renderWorkflowButtons( $offset, $limit, $menuSlug, $anchor, $job ); } /** * Render stateful migration workflow buttons. * * @param int $offset Offset. * @param int $limit Batch limit. * @param string $menuSlug Admin page slug. * @param string $anchor Optional anchor. * @param array|null $job Latest migration job. */ private function renderWorkflowButtons( int $offset, int $limit, string $menuSlug, string $anchor, ?array $job ): void { $status = self::jobStatus( $job ); $hasChanges = $job !== null && self::jobChanges( $job ) !== array(); $previewDone = in_array( $status, array( 'previewed', 'backed_up', 'rewritten' ), true ); $backupDone = in_array( $status, array( 'backed_up', 'rewritten' ), true ); $rewriteDone = $status === 'rewritten'; $backupReady = $previewDone && ! $backupDone && $hasChanges; $rewriteReady = $backupDone && ! $rewriteDone && $hasChanges; echo '
'; $this->renderWorkflowButton( 'プレビュー', self::ACTION_IMPORT_PREVIEW, $offset, $limit, $menuSlug, $anchor, $previewDone, null, null ); echo ''; $this->renderWorkflowButton( 'バックアップ', self::ACTION_BACKUP, $offset, $limit, $menuSlug, $anchor, ! $backupReady, self::BACKUP_NONCE_ACTION, self::BACKUP_NONCE_FIELD ); echo ''; $this->renderWorkflowButton( 'リライト', self::ACTION_IMPORT_APPLY, $offset, $limit, $menuSlug, $anchor, ! $rewriteReady, self::IMPORT_APPLY_NONCE_ACTION, self::IMPORT_APPLY_NONCE_FIELD ); echo '
'; if ( $rewriteDone ) { echo '

' . esc_html( 'リライトは完了しています。' ) . '

'; } } /** * Render a workflow button as GET or POST form. * * @param string $label Label. * @param string $action Action. * @param int $offset Offset. * @param int $limit Batch limit. * @param string $menuSlug Admin page slug. * @param string $anchor Optional anchor. * @param bool $disabled Whether disabled. * @param string|null $nonceAction Nonce action for POST buttons. * @param string|null $nonceField Nonce field for POST buttons. */ private function renderWorkflowButton( string $label, string $action, int $offset, int $limit, string $menuSlug, string $anchor, bool $disabled, ?string $nonceAction, ?string $nonceField ): void { $method = $nonceAction === null ? 'get' : 'post'; $url = self::adminPageUrl( array( 'page' => $menuSlug ) ) . $anchor; echo '
'; echo ''; echo ''; echo ''; echo ''; if ( $nonceAction !== null && $nonceField !== null ) { self::renderNonceField( $nonceAction, $nonceField ); } $classes = $action === self::ACTION_IMPORT_APPLY ? 'button button-primary' : 'button'; echo ''; echo '
'; } /** * Render one action link. * * @param string $label Label. * @param string $action Action. * @param int $offset Offset. * @param int $limit Limit. * @param string $menuSlug Admin page slug for generated links. * @param string $anchor Optional URL fragment to preserve tab focus. */ private function renderActionLink( string $label, string $action, int $offset, int $limit, string $menuSlug = self::MENU_SLUG, string $anchor = '' ): void { $url = self::adminPageUrl( array( 'page' => $menuSlug, self::KEY_ACTION => $action, self::KEY_OFFSET => (string) max( 0, $offset ), self::KEY_LIMIT => (string) max( 1, min( 25, $limit ) ), ) ) . $anchor; echo '' . esc_html( $label ) . ''; } /** * Render legacy dry-run report. * * @param int $offset Offset. * @param int $limit Limit. * @param string $menuSlug Admin page slug for generated links. * @param string $anchor Optional URL fragment to preserve tab focus. */ private function renderDryRunReport( int $offset, int $limit, string $menuSlug = self::MENU_SLUG, string $anchor = '' ): void { $provider = $this->postProvider; $posts = $provider( $offset, max( 1, min( 25, $limit ) ) ); $report = $this->legacyScanner->dryRun( $posts ); echo '

' . esc_html( '移行候補ドライラン' ) . '

'; echo '

' . esc_html( 'スキャン記事数: ' . (string) $report['posts_scanned'] ) . '

'; $this->renderLegacyMatchesTable( $report['matches'] ); if ( count( $posts ) >= $limit ) { echo '

'; $this->renderActionLink( '次のバッチ', self::ACTION_DRY_RUN, $offset + $limit, $limit, $menuSlug, $anchor ); echo '

'; } } /** * Create and persist a preview job. * * @param int $offset Offset. * @param int $limit Batch limit. * * @return array */ private function createPreviewJob( int $offset, int $limit ): array { $provider = $this->postProvider; $posts = $provider( $offset, max( 1, min( 25, $limit ) ) ); $preview = $this->legacyImporter->preview( $posts ); $job = array( 'id' => self::newJobId(), 'status' => 'previewed', 'offset' => max( 0, $offset ), 'limit' => max( 1, min( 25, $limit ) ), 'created_at' => gmdate( DATE_ATOM ), 'previewed_at' => gmdate( DATE_ATOM ), 'posts_scanned' => $preview['posts_scanned'], 'changes' => $preview['changes'], 'backups' => array(), 'result' => array(), ); $this->saveJob( $job ); return $job; } /** * Back up current post content for the latest preview job. * * @param array $query Request data. * * @return array|null */ private function backupLatestJob( array $query ): ?array { $job = $this->latestJob(); if ( $job === null || self::jobStatus( $job ) !== 'previewed' ) { return $job; } if ( ! self::verifyNonce( self::queryString( $query, self::BACKUP_NONCE_FIELD ), self::BACKUP_NONCE_ACTION ) ) { $job['error'] = 'backup_nonce'; return $job; } $postsById = $this->postsById( self::jobOffset( $job ), self::jobLimit( $job ) ); $backups = array(); foreach ( self::changedPostIds( self::jobChanges( $job ) ) as $postId ) { $post = $postsById[ $postId ] ?? null; if ( ! $post instanceof PostScanItem ) { continue; } $backups[] = array( 'post_id' => $post->id(), 'post_title' => $post->title(), 'content' => $post->content(), 'content_sha256' => hash( 'sha256', $post->content() ), 'created_at' => gmdate( DATE_ATOM ), ); } $job['status'] = $backups === array() ? 'previewed' : 'backed_up'; $job['backed_up_at'] = gmdate( DATE_ATOM ); $job['backups'] = $backups; $this->saveJob( $job ); return $job; } /** * Rewrite posts from the latest backed-up job. * * @param array $query Request data. * * @return array|null */ private function rewriteLatestJob( array $query ): ?array { $job = $this->latestJob(); if ( $job === null || self::jobStatus( $job ) !== 'backed_up' ) { return $job; } if ( ! self::verifyNonce( self::queryString( $query, self::IMPORT_APPLY_NONCE_FIELD ), self::IMPORT_APPLY_NONCE_ACTION ) ) { $job['error'] = 'rewrite_nonce'; return $job; } $postsById = $this->postsById( self::jobOffset( $job ), self::jobLimit( $job ) ); $changesByPost = self::changesByPost( self::jobChanges( $job ) ); $updated = 0; $skipped = 0; $failed = 0; foreach ( self::jobBackups( $job ) as $backup ) { $postId = self::positiveInt( $backup['post_id'] ?? null ); $post = $postId !== null ? ( $postsById[ $postId ] ?? null ) : null; if ( ! $post instanceof PostScanItem || $postId === null ) { ++$skipped; continue; } $expectedHash = self::fieldString( $backup, 'content_sha256' ); if ( $expectedHash === '' || hash( 'sha256', $post->content() ) !== $expectedHash ) { ++$skipped; continue; } $newContent = $this->legacyImporter->rewriteContent( $post->content(), $changesByPost[ $postId ] ?? array() ); if ( $newContent === $post->content() ) { ++$skipped; continue; } $updater = $this->postUpdater; if ( $updater( $postId, $newContent ) ) { ++$updated; } else { ++$failed; } } $job['status'] = 'rewritten'; $job['rewritten_at'] = gmdate( DATE_ATOM ); $job['result'] = array( 'updated_posts' => $updated, 'skipped_posts' => $skipped, 'failed_posts' => $failed, ); $this->saveJob( $job ); return $job; } /** * Render current migration job. * * @param array|null $job Migration job. */ private function renderMigrationJob( ?array $job ): void { if ( $job === null ) { return; } if ( self::fieldString( $job, 'error' ) !== '' ) { echo '

' . esc_html( '操作を確認できませんでした。画面を再読み込みしてもう一度実行してください。' ) . '

'; } $status = self::jobStatus( $job ); if ( $status === 'previewed' ) { echo '

' . esc_html( 'プレビュー結果' ) . '

'; echo '

' . esc_html( 'この段階では記事本文を変更していません。候補を確認してからバックアップを実行してください。' ) . '

'; } elseif ( $status === 'backed_up' ) { echo '

' . esc_html( 'バックアップ済み' ) . '

'; echo '

' . esc_html( '対象投稿の本文をWordPressデータベースに保存しました。内容を確認してからリライトを実行してください。' ) . '

'; } elseif ( $status === 'rewritten' ) { $result = self::jobResult( $job ); echo '

' . esc_html( 'リライト結果' ) . '

'; echo '

' . esc_html( sprintf( '更新: %d件 / スキップ: %d件 / 失敗: %d件', (int) ( $result['updated_posts'] ?? 0 ), (int) ( $result['skipped_posts'] ?? 0 ), (int) ( $result['failed_posts'] ?? 0 ) ) ) . '

'; } $this->renderImportChangesTable( self::jobChanges( $job ) ); } /** * Load the latest migration job from WordPress options. * * @return array|null */ private function latestJob(): ?array { $value = $this->options->get( self::MIGRATION_JOB_OPTION, null ); return is_array( $value ) ? $value : null; } /** * Persist the latest migration job. * * @param array $job Migration job. */ private function saveJob( array $job ): void { $this->options->updateNonAutoload( self::MIGRATION_JOB_OPTION, $job ); } /** * Load current batch posts keyed by ID. * * @param int $offset Offset. * @param int $limit Batch limit. * * @return array */ private function postsById( int $offset, int $limit ): array { $provider = $this->postProvider; $posts = $provider( $offset, max( 1, min( 25, $limit ) ) ); $byId = array(); foreach ( $posts as $post ) { $byId[ $post->id() ] = $post; } return $byId; } /** * Build a new job id. */ private static function newJobId(): string { if ( function_exists( 'wp_generate_uuid4' ) ) { return (string) wp_generate_uuid4(); } return gmdate( 'YmdHis' ) . '-' . bin2hex( random_bytes( 4 ) ); } /** * @param array|null $job Migration job. */ private static function jobStatus( ?array $job ): string { $status = is_array( $job ) ? self::fieldString( $job, 'status' ) : ''; return in_array( $status, array( 'previewed', 'backed_up', 'rewritten' ), true ) ? $status : ''; } /** * @param array $job Migration job. * * @return list> */ private static function jobChanges( array $job ): array { $changes = $job['changes'] ?? array(); if ( ! is_array( $changes ) ) { return array(); } $normalized = array(); foreach ( $changes as $change ) { if ( is_array( $change ) ) { $normalized[] = $change; } } return $normalized; } /** * @param array $job Migration job. * * @return list> */ private static function jobBackups( array $job ): array { $backups = $job['backups'] ?? array(); if ( ! is_array( $backups ) ) { return array(); } $normalized = array(); foreach ( $backups as $backup ) { if ( is_array( $backup ) ) { $normalized[] = $backup; } } return $normalized; } /** * @param array $job Migration job. * * @return array */ private static function jobResult( array $job ): array { $result = $job['result'] ?? array(); return is_array( $result ) ? $result : array(); } /** * @param array $job Migration job. */ private static function jobOffset( array $job ): int { return self::positiveInt( $job['offset'] ?? null ) ?? 0; } /** * @param array $job Migration job. */ private static function jobLimit( array $job ): int { return self::positiveInt( $job['limit'] ?? null ) ?? 10; } /** * @param list> $changes Changes. * * @return list */ private static function changedPostIds( array $changes ): array { $ids = array(); foreach ( $changes as $change ) { $postId = self::positiveInt( $change['post_id'] ?? null ); if ( $postId !== null && ! in_array( $postId, $ids, true ) ) { $ids[] = $postId; } } return $ids; } /** * @param list> $changes Changes. * * @return array>> */ private static function changesByPost( array $changes ): array { $byPost = array(); foreach ( $changes as $change ) { $postId = self::positiveInt( $change['post_id'] ?? null ); if ( $postId === null ) { continue; } if ( ! array_key_exists( $postId, $byPost ) ) { $byPost[ $postId ] = array(); } $byPost[ $postId ][] = $change; } return $byPost; } /** * Render import changes. * * @param list> $changes Changes. */ private function renderImportChangesTable( array $changes ): void { if ( $changes === array() ) { echo '

' . esc_html( '適用可能な移行候補は見つかりませんでした。' ) . '

'; return; } echo ''; foreach ( array( '投稿 ID', 'タイトル', '種類', '検出元', '信頼度', '変換前', '変換後' ) as $heading ) { echo ''; } echo ''; foreach ( $changes as $change ) { echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; $originalPreview = self::shortPreview( self::fieldString( $change, 'original' ) ); $replacementPreview = self::shortPreview( self::fieldString( $change, 'replacement' ) ); echo ''; echo ''; echo ''; } echo '
' . esc_html( $heading ) . '
' . esc_html( self::fieldString( $change, 'post_id' ) ) . '' . esc_html( self::fieldString( $change, 'post_title' ) ) . '' . esc_html( self::fieldString( $change, 'kind' ) ) . '' . esc_html( self::fieldString( $change, 'source' ) ) . '' . esc_html( self::fieldString( $change, 'confidence' ) ) . '' . esc_html( $originalPreview ) . '' . esc_html( $replacementPreview ) . '
'; } /** * Render legacy matches. * * @param list> $matches Matches. */ private function renderLegacyMatchesTable( array $matches ): void { if ( $matches === array() ) { echo '

' . esc_html( '移行候補は見つかりませんでした。' ) . '

'; return; } echo ''; foreach ( array( '投稿 ID', 'タイトル', '種類', '検出元', '信頼度', 'プレビュー' ) as $heading ) { echo ''; } echo ''; foreach ( $matches as $match ) { echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; } echo '
' . esc_html( $heading ) . '
' . esc_html( self::fieldString( $match, 'post_id' ) ) . '' . esc_html( self::fieldString( $match, 'post_title' ) ) . '' . esc_html( self::fieldString( $match, 'kind' ) ) . '' . esc_html( self::fieldString( $match, 'source' ) ) . '' . esc_html( self::fieldString( $match, 'confidence' ) ) . '' . esc_html( self::fieldString( $match, 'preview' ) ) . '
'; } /** * Emit a WordPress nonce field when available. * * @param string $action Nonce action. * @param string $name Nonce field name. */ private static function renderNonceField( string $action, string $name ): void { if ( function_exists( 'wp_nonce_field' ) ) { wp_nonce_field( $action, $name ); return; } echo ''; } /** * Verify a nonce when WordPress nonce helpers are available. * * @param string $nonce Nonce. * @param string $action Nonce action. */ private static function verifyNonce( string $nonce, string $action ): bool { if ( $nonce === '' || ! function_exists( 'wp_verify_nonce' ) ) { return false; } return (bool) wp_verify_nonce( $nonce, $action ); } /** * Shorten preview text for admin table cells. * * @param string $value Value. */ private static function shortPreview( string $value ): string { $value = trim( preg_replace( '/\s+/', ' ', $value ) ?? $value ); if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' ) ) { return mb_strlen( $value ) > 140 ? mb_substr( $value, 0, 137 ) . '...' : $value; } return strlen( $value ) > 140 ? substr( $value, 0, 137 ) . '...' : $value; } /** * Read a report field as string. * * @param array $row Row. * @param string $key Key. */ private static function fieldString( array $row, string $key ): string { $value = $row[ $key ] ?? ''; return is_scalar( $value ) ? (string) $value : ''; } /** * Query string helper. * * @param array $query Query. * @param string $key Key. */ private static function queryString( array $query, string $key ): string { $value = $query[ $key ] ?? ''; return is_scalar( $value ) ? trim( (string) $value ) : ''; } /** * Query int helper. * * @param array $query Query. * @param string $key Key. * @param int $fallback Fallback. */ private static function queryInt( array $query, string $key, int $fallback ): int { $value = $query[ $key ] ?? null; if ( is_int( $value ) ) { return max( 0, $value ); } if ( is_string( $value ) && ctype_digit( $value ) ) { return max( 0, (int) $value ); } return $fallback; } /** * Normalize a positive integer. * * @param mixed $value Candidate value. */ private static function positiveInt( mixed $value ): ?int { if ( is_int( $value ) ) { return $value > 0 ? $value : null; } if ( is_string( $value ) && ctype_digit( $value ) ) { $intValue = (int) $value; return $intValue > 0 ? $intValue : null; } return null; } /** * Build an admin URL. * * @param array $args Args. */ private static function adminPageUrl( array $args ): string { $url = function_exists( 'admin_url' ) ? (string) admin_url( 'admin.php' ) : 'admin.php'; if ( function_exists( 'add_query_arg' ) ) { return (string) add_query_arg( $args, $url ); } return $url . '?' . http_build_query( $args ); } /** * Default WordPress post updater. * * @phpstan-return callable(int,string): bool */ private static function defaultPostUpdater(): callable { return static function ( int $postId, string $content ): bool { if ( ! function_exists( 'wp_update_post' ) ) { return false; } $result = wp_update_post( array( 'ID' => $postId, 'post_content' => $content, ), true ); return ! is_wp_error( $result ); }; } /** * Default WordPress post provider. * * @phpstan-return callable(int,int): list */ private static function defaultPostProvider(): callable { return static function ( int $offset, int $limit ): array { if ( ! function_exists( 'get_posts' ) ) { return array(); } $rawPosts = get_posts( array( 'post_type' => 'post', 'post_status' => array( 'publish', 'draft', 'pending', 'future', 'private' ), 'posts_per_page' => max( 1, min( 25, $limit ) ), 'offset' => max( 0, $offset ), 'orderby' => 'ID', 'order' => 'ASC', ) ); $items = array(); foreach ( is_array( $rawPosts ) ? $rawPosts : array() as $post ) { $id = is_object( $post ) && property_exists( $post, 'ID' ) ? (int) $post->ID : 0; $title = is_object( $post ) && property_exists( $post, 'post_title' ) ? (string) $post->post_title : ''; $content = is_object( $post ) && property_exists( $post, 'post_content' ) ? (string) $post->post_content : ''; if ( $id > 0 ) { $items[] = new PostScanItem( $id, $title, $content ); } } return $items; }; } }