` elements driven by the {@see ProductListQuery} * closed-set whitelists. * - Search is a free-form text input. * * Pagination: * * - Phase-8 minimum: textual `Page X of Y` plus prev / next links * that preserve the current filter state via * {@see self::buildPageUrl()}. * - When the result spans a single page the renderer emits the * `Page X of Y` text without prev / next anchors. * * Escaping invariants: * * - Every visible text emission flows through `esc_html()`. * - Every attribute value flows through `esc_attr()`. * - Every URL flows through `esc_url()`. * * @package Affilun\Presentation\Admin\Page */ declare(strict_types=1); namespace Affilun\Presentation\Admin\Page; use Affilun\Application\Ai\AiSuggestionException; use Affilun\Application\Product\Csv\ProductCsvImportResult; use Affilun\Application\Product\Csv\ProductCsvImportRow; use Affilun\Application\Cloud\CloudProductArticleDraftClient; use Affilun\Application\Cloud\CloudUsageClient; use Affilun\Application\LinkHealth\LinkHealthStatusClassifier; use Affilun\Application\LinkHealth\ProductLinkHealthStatusStore; use Affilun\Application\Product\Csv\ProductCsvImportUseCase; use Affilun\Application\Product\ProductManualTaxonomy; use Affilun\Application\Product\UseCase\BulkUpdateProductsResult; use Affilun\Application\Product\UseCase\BulkUpdateProductsUseCase; use Affilun\Application\Product\UseCase\ListProductsUseCase; use Affilun\Application\Settings\CloudSettings; use Affilun\Application\Settings\SettingsRepository; use Affilun\Infrastructure\Persistence\ProductListQuery; use Affilun\Infrastructure\Persistence\ProductMetaRepository; use Affilun\Infrastructure\Persistence\ProductRecord; use Affilun\Infrastructure\Persistence\ProductRepository; use Affilun\Presentation\Admin\AdminPriceFormatter; use Affilun\Presentation\Admin\AdminTimeFormatter; use Affilun\Presentation\Admin\ArticleDraftNotice; use Affilun\Presentation\Rest\Mapper\ProductSummaryMapper; /** * Renders the §6 Products list admin page to stdout. * * See the file-level docblock for the Phase-8 minimum scope, the * filter / pagination behaviour, and the escaping invariants. */ final class ProductsListPageRenderer { /** * Admin menu slug the filter form submits back to. */ private const MENU_SLUG = 'affilun-products'; /** * Safety ceiling for scanning saved products when rendering the focused panel. */ private const LINK_HEALTH_PROBLEM_SCAN_LIMIT = 500; /** * Number of link-health problem products shown per focused-panel page. */ private const LINK_HEALTH_PROBLEM_PER_PAGE = 20; /** * Query parameter used by the link-health problem panel pagination. */ private const LINK_HEALTH_PROBLEM_PAGE_PARAM = 'affilun_link_health_problem_paged'; /** * Query parameter used by the link-health problem panel page-size selector. */ private const LINK_HEALTH_PROBLEM_PER_PAGE_PARAM = 'affilun_link_health_problem_per_page'; /** * Page-size choices exposed on admin tables. * * @var list */ private const PER_PAGE_OPTIONS = array( 10, 20, 50 ); /** * Closed list of providers offered in the filter dropdown. * * Keyed by the wire token; values are the human-readable label. * * @var array */ private const PROVIDERS = array( 'amazon' => 'Amazon', 'rakuten' => 'Rakuten', 'yahoo_shopping' => 'Yahoo Shopping', 'mercari' => 'Mercari', ); /** * Article-draft tone labels accepted by the Cloud endpoint. * * @var array */ private const ARTICLE_DRAFT_TONES = array( 'professional_blog' => 'プロらしいブログ調', 'friendly' => '親しみやすい紹介調', 'review_comparison' => '比較レビュー調', 'concise' => '簡潔な要点整理', ); /** * Article-draft target length labels accepted by the Cloud endpoint. * * @var array */ private const ARTICLE_DRAFT_LENGTHS = array( 'short' => '短め', 'medium' => '標準', 'long' => '長め', ); /** * Bulk preview/apply action. */ private const BULK_ACTION = 'affilun_bulk_update_products'; /** * Bulk nonce field. */ private const BULK_NONCE_FIELD = '_affilun_bulk_update_products_nonce'; /** * CSV export action. */ private const CSV_EXPORT_ACTION = 'affilun_export_products_csv'; /** * CSV export nonce field. */ private const CSV_EXPORT_NONCE_FIELD = '_affilun_export_products_csv_nonce'; /** * CSV import action. */ private const CSV_IMPORT_ACTION = 'affilun_import_products_csv'; /** * CSV import nonce field. */ private const CSV_IMPORT_NONCE_FIELD = '_affilun_import_products_csv_nonce'; /** * CSV preview nonce action. */ private const CSV_PREVIEW_NONCE_ACTION = 'affilun_preview_products_csv'; /** * CSV preview nonce field. */ private const CSV_PREVIEW_NONCE_FIELD = '_affilun_preview_products_csv_nonce'; /** * §8.2 list-products use case. */ private ListProductsUseCase $listUseCase; /** * §6.1 row-shape mapper. */ private ProductSummaryMapper $summaryMapper; /** * Resolves post usage for products on the current page. */ private PostProductUsageResolver $usageResolver; /** * Bulk update use case. */ private ?BulkUpdateProductsUseCase $bulkUseCase; /** * CSV import use case. */ private ?ProductCsvImportUseCase $csvImportUseCase; /** * Embedded add-new product renderer. */ private AddNewProductPageRenderer $addNewRenderer; /** * Optional settings repository for affilun Cloud feature availability. */ private ?SettingsRepository $settingsRepository; /** * Optional product repository for selected-row article draft generation. */ private ?ProductRepository $productRepository; /** * Optional product meta repository for link-health status display. */ private ?ProductMetaRepository $productMetaRepository; /** * affilun Cloud article-draft client. */ private CloudProductArticleDraftClient $articleDraftClient; /** * affilun Cloud usage client. */ private CloudUsageClient $usageClient; /** * Optional Amazon discount shortcode configurator. */ private ?AmazonDiscountShortcodeCardRenderer $amazonDiscountCardRenderer; /** * Construct the renderer with explicit collaborators. * * @param ListProductsUseCase $listUseCase §8.2 list use case. * @param ProductSummaryMapper $summaryMapper §6.1 row mapper. * @param PostProductUsageResolver|null $usageResolver Optional product usage resolver. * @param BulkUpdateProductsUseCase|null $bulkUseCase Optional bulk update use case. * @param ProductCsvImportUseCase|null $csvImportUseCase Optional CSV import use case. * @param AddNewProductPageRenderer|null $addNewRenderer Optional add-new renderer. * @param SettingsRepository|null $settingsRepository Optional settings repository. * @param ProductRepository|null $productRepository Optional product repository. * @param CloudProductArticleDraftClient|null $articleDraftClient Optional Cloud article client. * @param CloudUsageClient|null $usageClient Optional Cloud usage client. * @param ProductMetaRepository|null $productMetaRepository Optional product meta repository. * @param AmazonDiscountShortcodeCardRenderer|null $amazonDiscountCardRenderer Optional discount shortcode card. */ public function __construct( ListProductsUseCase $listUseCase, ProductSummaryMapper $summaryMapper, ?PostProductUsageResolver $usageResolver = null, ?BulkUpdateProductsUseCase $bulkUseCase = null, ?ProductCsvImportUseCase $csvImportUseCase = null, ?AddNewProductPageRenderer $addNewRenderer = null, ?SettingsRepository $settingsRepository = null, ?ProductRepository $productRepository = null, ?CloudProductArticleDraftClient $articleDraftClient = null, ?CloudUsageClient $usageClient = null, ?ProductMetaRepository $productMetaRepository = null, ?AmazonDiscountShortcodeCardRenderer $amazonDiscountCardRenderer = null ) { $this->listUseCase = $listUseCase; $this->summaryMapper = $summaryMapper; $this->usageResolver = $usageResolver ?? new PostProductUsageResolver(); $this->bulkUseCase = $bulkUseCase; $this->csvImportUseCase = $csvImportUseCase; $this->addNewRenderer = $addNewRenderer ?? new AddNewProductPageRenderer(); $this->settingsRepository = $settingsRepository; $this->productRepository = $productRepository; $this->articleDraftClient = $articleDraftClient ?? new CloudProductArticleDraftClient(); $this->usageClient = $usageClient ?? new CloudUsageClient(); $this->productMetaRepository = $productMetaRepository; $this->amazonDiscountCardRenderer = $amazonDiscountCardRenderer; } /** * Echo the §6 Products list page HTML to stdout. * * @param ProductListQuery $query Already-validated list query. * @param array $requestData GET-like request data. */ public function render( ProductListQuery $query, array $requestData = array() ): void { $result = $this->listUseCase->execute( $query ); $logoUrl = defined( 'AFFILUN_PLUGIN_URL' ) ? AFFILUN_PLUGIN_URL . 'assets/img/brand/logo-landscape-transparent.png' : ''; echo '
'; echo '
'; echo '
'; if ( $logoUrl !== '' ) { echo ''; } echo '

' . esc_html( 'つなぐ・分析する・成長する' ) . '

'; echo '

' . esc_html( 'アフィリエイト管理' ) . '

'; echo '
'; echo ''; echo '
'; $cloudContracted = $this->isCloudContracted(); $cloudSettingsUrl = self::cloudSettingsUrl(); if ( $cloudContracted ) { $this->renderLinkHealthProblemPanel( $query, $requestData ); } $this->renderStatusNotice( $requestData ); $this->renderAddNewProductPanel(); $this->renderArticleDraftPanel( $requestData, $cloudContracted, $cloudSettingsUrl ); echo '
'; echo '

' . esc_html( '商品の一覧' ) . '

'; echo ''; if ( $result->isEmpty() ) { echo '
'; $this->renderFilterForm( $query ); $this->renderItemCount( $result->totalItems() ); echo '
'; $this->renderEmptyState(); echo '
'; $this->renderProductCsvPanel( $requestData ); $this->renderAmazonDiscountShortcodeCard( $requestData ); echo '
'; return; } $records = $result->items(); $productIds = self::recordIds( $records ); $usageByProduct = $this->usageResolver->resolveForProductIds( $productIds ); $this->renderDetachedBulkForm(); echo '
'; $this->renderBulkActionControls( $result->totalItems(), 'top' ); $this->renderFilterForm( $query ); $this->renderItemCount( $result->totalItems() ); echo '
'; echo ''; $this->renderTableHead(); echo ''; foreach ( $records as $record ) { $id = $record->id(); $usage = $id === null ? array() : ( $usageByProduct[ $id ] ?? array() ); $this->renderRow( $record, $usage, $cloudContracted, $cloudSettingsUrl ); } echo ''; echo '
'; echo '
'; $this->renderBulkActionControls( $result->totalItems(), 'bottom' ); $this->renderItemCount( $result->totalItems() ); echo '
'; echo '
'; $this->renderPagination( $query, $result->totalPages(), $result->totalItems() ); echo '
'; echo ''; if ( self::requestString( $requestData, 'affilun_bulk_action' ) === 'edit' ) { $this->renderBulkOperationsPanel( $requestData ); } $this->renderProductCsvPanel( $requestData ); $this->renderAmazonDiscountShortcodeCard( $requestData ); echo ''; } /** * @param array $requestData GET-like request data. */ private function renderAmazonDiscountShortcodeCard( array $requestData ): void { if ( $this->amazonDiscountCardRenderer !== null ) { $this->amazonDiscountCardRenderer->render( $requestData ); } } /** * Render embedded add-new product controls at the top of Product Management. */ private function renderAddNewProductPanel(): void { echo '
'; echo '

' . esc_html( '商品を追加' ) . '

'; $this->addNewRenderer->renderEmbedded(); echo '
'; } /** * Extract persisted ids from product records. * * @param list $records Product records. * * @return list */ private static function recordIds( array $records ): array { $ids = array(); foreach ( $records as $record ) { $id = $record->id(); if ( $id !== null ) { $ids[] = $id; } } return $ids; } /** * Render a small status notice for admin-post round-trips. * * @param array $requestData GET-like request data. */ private function renderStatusNotice( array $requestData ): void { $status = self::requestString( $requestData, 'affilun_status' ); $cloudError = $status !== null ? ArticleDraftNotice::errorMessage( $status, self::requestPositiveInt( $requestData, 'affilun_retry_after' ) ) : null; if ( $cloudError !== null && function_exists( 'esc_html' ) ) { echo '

' . esc_html( $cloudError ) . '

'; return; } $messages = array( 'bulk_updated' => '一括更新を適用しました。', 'bulk_none' => '条件に一致する商品はありませんでした。', 'bulk_error' => '一括更新に失敗しました。入力内容を確認してください。', 'bulk_trash_none' => 'ゴミ箱へ移動する商品を選択してください。', 'csv_imported' => 'CSV インポートを適用しました。', 'csv_import_none' => 'CSV に適用できる商品行はありませんでした。', 'csv_import_error' => 'CSV インポートに失敗しました。プレビュー内容を確認してください。', 'deleted' => '商品をゴミ箱へ移動しました。', 'article_draft_created' => 'WordPress投稿下書きを作成しました。', 'article_draft_error' => 'WordPress投稿下書きの作成に失敗しました。', 'purged' => '商品を完全に削除しました。', 'delete_error' => '商品の削除に失敗しました。', 'invalid_bulk' => '一括操作を選択してください。', 'invalid_nonce' => '操作を確認できませんでした。もう一度お試しください。', 'forbidden' => 'この操作を実行する権限がありません。', 'saved' => '商品を保存しました。', ); if ( $status === null || ! array_key_exists( $status, $messages ) ) { return; } echo '

' . esc_html( $messages[ $status ] ) . '

'; } /** * Render user-side state view links like the WordPress posts list. * * @param ProductListQuery $query Current query. */ private function renderStatusViews( ProductListQuery $query ): void { $views = array( '' => 'すべて', 'publish' => 'publish', 'unused' => 'unused', 'trash' => 'trash', ); echo '
    '; $first = true; foreach ( $views as $state => $label ) { $lifecycle = self::lifecycleFromUserState( $state ); $countQuery = $query->withLifecycleState( $lifecycle )->withPage( 1 )->withPerPage( 1 ); $count = $this->listUseCase->execute( $countQuery )->totalItems(); $url = self::buildFilteredUrl( $query->withLifecycleState( $lifecycle )->withPage( 1 ) ); $current = $query->lifecycleState() === $lifecycle; if ( ! $first ) { echo ' | '; } echo '
  • ' . esc_html( sprintf( '%s (%d)', $label, $count ) ) . '
  • '; $first = false; } echo '
'; } /** * Render the hidden POST form used by table checkboxes/buttons. */ private function renderDetachedBulkForm(): void { echo '
'; echo ''; self::renderNonceField( 'affilun_bulk_products_action', '_affilun_bulk_products_action_nonce' ); echo '
'; } /** * Render item count. * * @param int $totalItems Total matching items. */ private function renderItemCount( int $totalItems ): void { echo '' . esc_html( sprintf( '%d 個の項目', $totalItems ) ) . ''; } /** * Render bulk action controls above/below the table. * * @param int $totalItems Total matching items. * @param string $position Visual position token. */ private function renderBulkActionControls( int $totalItems, string $position ): void { unset( $totalItems ); echo '
'; echo ''; $name = $position === 'bottom' ? 'affilun_bulk_action_select2' : 'affilun_bulk_action_select'; echo ' '; echo ''; echo '
'; } /** * Render bulk preview/apply controls. * * @param array $requestData GET-like request data. */ private function renderBulkOperationsPanel( array $requestData ): void { if ( $this->bulkUseCase === null ) { return; } $filters = self::bulkFiltersFromRequest( $requestData ); $changes = self::bulkChangesFromRequest( $requestData ); echo '
'; echo '

' . esc_html( '一括編集' ) . '

'; echo '
'; echo ''; echo ''; $this->renderBulkFilterControls( $filters ); $this->renderBulkChangeControls( $changes ); echo '

'; echo '
'; if ( self::requestString( $requestData, 'affilun_bulk_preview' ) === '1' ) { $this->renderBulkPreview( $filters, $changes ); } echo '
'; } /** * Render CSV import/export controls. * * @param array $requestData Request data. */ private function renderProductCsvPanel( array $requestData ): void { echo '
'; echo '

' . esc_html( 'CSV インポート / エクスポート' ) . '

'; echo '

'; echo esc_html( '保存済み商品を CSV としてバックアップし、affilun からエクスポートした CSV 形式のみをプレビュー後に取り込めます。' ); echo '

'; $this->renderProductCsvExportForm(); $this->renderProductCsvPreviewForm( $requestData ); if ( self::rawRequestString( $requestData, 'affilun_csv_preview' ) === '1' ) { $this->renderProductCsvPreview( $requestData ); } echo '
'; } /** * Render CSV export form. */ private function renderProductCsvExportForm(): void { echo '
'; echo ''; self::renderNonceField( self::CSV_EXPORT_ACTION, self::CSV_EXPORT_NONCE_FIELD ); echo ''; echo '
'; } /** * Render CSV preview form. * * @param array $requestData Request data. */ private function renderProductCsvPreviewForm( array $requestData ): void { $csv = self::rawRequestString( $requestData, 'affilun_product_csv' ); echo '
'; echo ''; echo ''; self::renderNonceField( self::CSV_PREVIEW_NONCE_ACTION, self::CSV_PREVIEW_NONCE_FIELD ); echo '


'; echo '

'; echo '

'; echo '
'; } /** * Render CSV import preview. * * @param array $requestData Request data. */ private function renderProductCsvPreview( array $requestData ): void { if ( $this->csvImportUseCase === null ) { echo '

'; echo esc_html( 'CSV インポート機能を初期化できませんでした。' ); echo '

'; return; } $nonce = trim( self::rawRequestString( $requestData, self::CSV_PREVIEW_NONCE_FIELD ) ); if ( ! self::verifyNonce( $nonce, self::CSV_PREVIEW_NONCE_ACTION ) ) { echo '

'; echo esc_html( 'CSV プレビュー操作を確認できませんでした。' ); echo '

'; return; } $csv = self::rawRequestString( $requestData, 'affilun_product_csv' ); try { $result = $this->csvImportUseCase->preview( $csv ); } catch ( \Throwable $unused ) { unset( $unused ); echo '

'; echo esc_html( 'CSV を解析できませんでした。内容を確認してください。' ); echo '

'; return; } $this->renderProductCsvPreviewResult( $result, $csv ); } /** * Render parsed CSV preview result. * * @param ProductCsvImportResult $result Preview result. * @param string $csv Original CSV payload. */ private function renderProductCsvPreviewResult( ProductCsvImportResult $result, string $csv ): void { $noticeClass = $result->hasErrors() ? 'notice-error' : 'notice-warning'; echo '

'; echo esc_html( sprintf( '%d 件中 %d 件の CSV 行を取り込み可能です。適用前に内容を確認してください。', $result->totalRows(), $result->validRows() ) ); echo '

'; foreach ( $result->errors() as $error ) { echo '

' . esc_html( $error ) . '

'; } $this->renderProductCsvPreviewTable( $result ); if ( $result->hasErrors() || $result->validRows() < 1 ) { return; } // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Encodes previewed CSV into a hidden form field, not executable code. $encodedCsv = base64_encode( $csv ); echo '
'; echo ''; echo ''; self::renderNonceField( self::csvImportNonceAction( $csv ), self::CSV_IMPORT_NONCE_FIELD ); echo ''; echo '
'; } /** * Render CSV preview table. * * @param ProductCsvImportResult $result Preview result. */ private function renderProductCsvPreviewTable( ProductCsvImportResult $result ): void { if ( $result->rows() === array() ) { return; } echo ''; echo ''; foreach ( array( '行', '操作', 'プロバイダー', '商品ID', '商品名', '状態', 'カテゴリ', 'タグ', '検証' ) as $heading ) { echo ''; } echo ''; foreach ( $result->rows() as $row ) { $this->renderProductCsvPreviewRow( $row ); } echo '
' . esc_html( $heading ) . '
'; } /** * Render one CSV preview row. * * @param ProductCsvImportRow $row Preview row. */ private function renderProductCsvPreviewRow( ProductCsvImportRow $row ): void { $actionLabel = $row->action() === ProductCsvImportRow::ACTION_UPDATE ? '更新' : '作成'; $errors = $row->errors(); echo ''; echo '' . esc_html( (string) $row->rowNumber() ) . ''; echo '' . esc_html( $actionLabel ) . ''; echo '' . esc_html( $row->provider() ) . ''; echo '' . esc_html( $row->providerItemId() ) . ''; echo '' . esc_html( $row->title() ) . ''; echo '' . esc_html( $row->lifecycleState() ) . ''; echo '' . esc_html( ProductManualTaxonomy::termsToDisplayString( $row->categories() ) ) . ''; echo '' . esc_html( ProductManualTaxonomy::termsToDisplayString( $row->tags() ) ) . ''; echo '' . esc_html( $errors === array() ? '正常' : implode( ' / ', $errors ) ) . ''; echo ''; } /** * Render affilun Cloud article-draft preview panel when requested. * * @param array $requestData Request data. * @param bool $cloudContracted Whether affilun Cloud is contracted. * @param string $cloudSettingsUrl affilun Cloud settings URL. */ private function renderArticleDraftPanel( array $requestData, bool $cloudContracted, string $cloudSettingsUrl ): void { $productId = self::requestPositiveInt( $requestData, 'affilun_article_draft_product_id' ); if ( $productId === null ) { return; } echo '
'; echo '

' . esc_html( '記事下書き作成' ) . '

'; if ( ! $cloudContracted ) { echo '

' . esc_html( '記事下書き機能の利用にはaffilun cloudの契約が必要です。' ) . ' ' . esc_html( 'affilun Cloud設定へ' ) . '

'; echo '
'; return; } $nonce = self::rawRequestString( $requestData, '_wpnonce' ); if ( ! self::verifyNonce( $nonce, self::articleDraftNonceAction( $productId ) ) ) { echo '

' . esc_html( '記事下書き作成リンクが無効です。保存商品一覧からもう一度お試しください。' ) . '

'; echo ''; return; } if ( $this->productRepository === null || $this->settingsRepository === null ) { echo '

' . esc_html( '記事下書き作成機能を初期化できませんでした。' ) . '

'; echo ''; return; } try { $product = $this->productRepository->findById( $productId ); $cloud = $this->settingsRepository->load()->cloud(); } catch ( \Throwable $unused ) { unset( $unused ); echo '

' . esc_html( '対象の商品情報を読み込めませんでした。' ) . '

'; echo ''; return; } if ( $product === null ) { echo '

' . esc_html( '対象の商品が見つかりませんでした。' ) . '

'; echo ''; return; } echo '

' . esc_html( sprintf( '対象商品: #%d %s / %s', $productId, $product->provider(), $product->title() ) ) . '

'; $options = self::articleDraftOptionsFromRequest( $requestData, $product, $cloud ); $usageQuota = $this->articleDraftUsageQuota( $cloud ); $confirmed = self::requestString( $requestData, 'affilun_article_draft_confirm' ) === '1'; $this->renderArticleDraftOptionsForm( $product, $options, $confirmed, $usageQuota ); if ( ! $confirmed ) { echo '

' . esc_html( '生成設定を確認してから、affilun Cloud で記事下書きを生成してください。' ) . '

'; echo ''; return; } try { $draft = $this->articleDraftClient->generate( $cloud, self::articleDraftPayload( $product, $options ), time(), bin2hex( random_bytes( 12 ) ) ); } catch ( AiSuggestionException $error ) { $message = ArticleDraftNotice::errorMessage( $error->errorCode(), $error->details()['retry_after_seconds'] ?? null ) ?? $error->getMessage(); if ( function_exists( 'esc_html' ) ) { echo '

' . esc_html( $message ) . '

'; } return; } catch ( \Throwable $unused ) { unset( $unused ); echo '

' . esc_html( 'affilun Cloud の記事下書き作成に失敗しました。しばらく待ってからもう一度お試しください。' ) . '

'; echo ''; return; } $this->renderArticleDraftResult( $product, $draft ); echo ''; } /** * Render article-draft option controls for first generation or regeneration. * * @param ProductRecord $product Product record. * @param array $options Current generation options. * @param bool $confirmed Whether generation has been confirmed. * @param array|null $usageQuota Current article-draft usage/quota. */ private function renderArticleDraftOptionsForm( ProductRecord $product, array $options, bool $confirmed, ?array $usageQuota ): void { $id = $product->id(); if ( ! is_int( $id ) || $id <= 0 ) { return; } echo '
'; echo '

' . esc_html( '生成設定' ) . '

'; $this->renderArticleDraftUsageQuota( $usageQuota ); echo '
'; echo ''; echo ''; echo ''; $this->renderArticleDraftDefaultSummary( $options ); self::renderNonceField( self::articleDraftNonceAction( $id ), '_wpnonce' ); $buttonLabel = $confirmed ? 'この設定で再生成' : 'この設定で記事下書きを生成'; echo '

'; echo '
'; echo '
'; } /** * Render current article-draft quota status. * * @param array|null $usageQuota Current usage/quota. */ private function renderArticleDraftUsageQuota( ?array $usageQuota ): void { if ( $usageQuota === null ) { echo '

' . esc_html( '記事下書きの今月の利用状況は取得できませんでした。' ) . '

'; return; } $used = $usageQuota['used'] ?? null; $limit = $usageQuota['limit'] ?? null; $remaining = $usageQuota['remaining'] ?? null; if ( $limit === null ) { $message = sprintf( '記事下書き生成: 今月 %d 件利用済み / 上限なし', (int) $used ); } else { $message = sprintf( '記事下書き生成: 今月 %d / %d 件利用済み(残り %d 件)', (int) $used, (int) $limit, (int) max( 0, (int) $remaining ) ); } echo '

' . esc_html( $message ) . '

'; } /** * Resolve current article-draft Cloud usage/quota. * * @param CloudSettings $cloud Cloud settings. * * @return array|null */ private function articleDraftUsageQuota( CloudSettings $cloud ): ?array { try { $usage = $this->usageClient->current( $cloud, time(), bin2hex( random_bytes( 12 ) ) ); } catch ( \Throwable $unused ) { unset( $unused ); return null; } $metrics = is_array( $usage['metrics'] ?? null ) ? $usage['metrics'] : array(); foreach ( $metrics as $metric ) { if ( ! is_array( $metric ) || ( $metric['metric'] ?? null ) !== 'ai.article_draft' ) { continue; } return array( 'used' => isset( $metric['used'] ) && is_numeric( $metric['used'] ) ? (int) $metric['used'] : 0, 'limit' => isset( $metric['limit'] ) && is_numeric( $metric['limit'] ) ? (int) $metric['limit'] : null, 'remaining' => isset( $metric['remaining'] ) && is_numeric( $metric['remaining'] ) ? (int) $metric['remaining'] : null, ); } return null; } /** * Render generated article draft result. * * @param ProductRecord $product Product record. * @param array $draft Draft data. */ private function renderArticleDraftResult( ProductRecord $product, array $draft ): void { $title = is_scalar( $draft['title'] ?? null ) ? (string) $draft['title'] : '記事下書き'; $excerpt = is_scalar( $draft['excerpt'] ?? null ) ? (string) $draft['excerpt'] : ''; $body = is_scalar( $draft['body_markdown'] ?? null ) ? (string) $draft['body_markdown'] : ''; $provenance = CloudProductArticleDraftClient::provenance( $draft ); $notice = ArticleDraftNotice::provenanceMessage( $provenance['source'] ); $message = $notice ?? sprintf( '%s の記事下書きを作成しました。公開前に内容を確認・編集してください。', $product->title() ); if ( function_exists( 'esc_html' ) ) { echo '

' . esc_html( $message ) . '

'; } echo '
'; $this->renderCreatePostDraftForm( $product, $title, $excerpt, $body, $provenance ); $this->renderArticleDraftSources( $draft['sources'] ?? array() ); echo '
'; } /** * Render a form that creates a WordPress post draft from generated text. * * @param ProductRecord $product Product record. * @param string $title Draft title. * @param string $excerpt Draft excerpt. * @param string $body Draft Markdown body. * @param array $provenance Generator metadata. */ private function renderCreatePostDraftForm( ProductRecord $product, string $title, string $excerpt, string $body, array $provenance ): void { $id = $product->id(); if ( ! is_int( $id ) || $id <= 0 ) { return; } echo '
'; echo ''; echo ''; foreach ( $provenance as $key => $value ) { echo ''; } echo '

'; echo ''; echo '

'; echo ''; echo '

'; echo ''; echo '

' . esc_html( '必要に応じてタイトル・抜粋・本文を編集してから、WordPress投稿下書きとして保存してください。' ) . '

'; self::renderNonceField( self::createArticleDraftNonceAction( $id ), '_affilun_create_article_draft_nonce' ); echo ''; echo '
'; } /** * Render article-draft source status list. * * @param mixed $sources Sources payload. */ private function renderArticleDraftSources( mixed $sources ): void { if ( ! is_array( $sources ) || $sources === array() ) { return; } echo '
' . esc_html( '参照ソース' ) . '
    '; foreach ( $sources as $source ) { if ( ! is_array( $source ) ) { continue; } $url = is_scalar( $source['url'] ?? null ) ? (string) $source['url'] : ''; $status = is_scalar( $source['status'] ?? null ) ? (string) $source['status'] : ''; if ( $url === '' ) { continue; } echo '
  • ' . esc_html( $status ) . ' ' . esc_html( $url ) . '
  • '; } echo '
'; } /** * Render article-draft defaults used for generation. * * @param array $options Current generation options. */ private function renderArticleDraftDefaultSummary( array $options ): void { $toneLabels = self::ARTICLE_DRAFT_TONES; $lengths = self::ARTICLE_DRAFT_LENGTHS; $tone = is_scalar( $options['tone'] ?? null ) ? (string) $options['tone'] : 'professional_blog'; $length = is_scalar( $options['target_length'] ?? null ) ? (string) $options['target_length'] : 'medium'; $reviewUrls = is_array( $options['review_urls'] ?? null ) ? $options['review_urls'] : array(); echo '

' . esc_html( '文体・長さ・参考URLの既定値は affilun Cloud 設定タブで変更できます。' ) . '

'; echo '
    '; echo '
  • ' . esc_html( '文体: ' . ( $toneLabels[ $tone ] ?? $tone ) ) . '
  • '; echo '
  • ' . esc_html( '長さ: ' . ( $lengths[ $length ] ?? $length ) ) . '
  • '; echo '
  • ' . esc_html( '参考URL: ' . ( $reviewUrls === array() ? '送信しない' : '商品URLを送信' ) ) . '
  • '; echo '
'; } /** * Build article-draft Cloud payload from a product record. * * @param ProductRecord $product Product record. * @param array $options Generation options. * * @return array */ private static function articleDraftPayload( ProductRecord $product, array $options ): array { return array( 'provider' => $product->provider(), 'provider_item_id' => $product->providerItemId(), 'title' => $product->title(), 'brand' => $product->brand(), 'description' => $product->description(), 'detail_url' => $product->detailUrl(), 'affiliate_url' => $product->affiliateUrl(), 'review_urls' => $options['review_urls'], 'locale' => 'ja_JP', 'tone' => $options['tone'], 'target_length' => $options['target_length'], ); } /** * Build default article-draft generation options. * * @param ProductRecord $product Product record. * @param CloudSettings $cloud Cloud settings. * * @return array */ private static function defaultArticleDraftOptions( ProductRecord $product, CloudSettings $cloud ): array { $reviewUrls = $cloud->articleDraftReviewSourcePolicy() === 'product_url' ? array( $product->detailUrl() ) : array(); return array( 'tone' => $cloud->articleDraftTone(), 'target_length' => $cloud->articleDraftTargetLength(), 'review_urls' => $reviewUrls, ); } /** * Extract article-draft generation options from request data. * * @param array $requestData Request data. * @param ProductRecord $product Product record. * @param CloudSettings $cloud Cloud settings. * * @return array */ private static function articleDraftOptionsFromRequest( array $requestData, ProductRecord $product, CloudSettings $cloud ): array { $defaults = self::defaultArticleDraftOptions( $product, $cloud ); $tone = self::requestString( $requestData, 'affilun_article_draft_tone' ) ?? (string) $defaults['tone']; $length = self::requestString( $requestData, 'affilun_article_draft_target_length' ) ?? (string) $defaults['target_length']; if ( ! array_key_exists( $tone, self::ARTICLE_DRAFT_TONES ) ) { $tone = (string) $defaults['tone']; } if ( ! array_key_exists( $length, self::ARTICLE_DRAFT_LENGTHS ) ) { $length = (string) $defaults['target_length']; } return array( 'tone' => $tone, 'target_length' => $length, 'review_urls' => self::articleDraftReviewUrlsFromRequest( $requestData, $product, $defaults ), ); } /** * Extract safe review-source URLs from request data. * * @param array $requestData Request data. * @param ProductRecord $product Product record. * @param array $defaults Default generation options. * * @return list */ private static function articleDraftReviewUrlsFromRequest( array $requestData, ProductRecord $product, array $defaults ): array { $raw = self::rawRequestString( $requestData, 'affilun_article_draft_review_urls' ); if ( trim( $raw ) === '' ) { $defaultUrls = is_array( $defaults['review_urls'] ?? null ) ? $defaults['review_urls'] : array(); $raw = implode( "\n", array_map( 'strval', $defaultUrls ) ); } $lines = preg_split( '/\R/u', $raw ); if ( ! is_array( $lines ) ) { $lines = array( $raw ); } $urls = array(); foreach ( $lines as $line ) { $url = trim( (string) $line ); if ( $url === '' || ! self::isArticleDraftHttpUrl( $url ) || in_array( $url, $urls, true ) ) { continue; } $urls[] = $url; if ( count( $urls ) >= 5 ) { break; } } if ( $urls !== array() ) { return $urls; } return is_array( $defaults['review_urls'] ?? null ) ? $defaults['review_urls'] : array(); } /** * Whether an article-draft source URL is absolute HTTP(S). * * @param string $url Candidate URL. */ private static function isArticleDraftHttpUrl( string $url ): bool { if ( preg_match( '/[\x00-\x1F\x7F\s]/u', $url ) === 1 ) { return false; } $parts = function_exists( 'wp_parse_url' ) ? wp_parse_url( $url ) : call_user_func( 'parse_url', $url ); if ( ! is_array( $parts ) ) { return false; } $scheme = strtolower( is_string( $parts['scheme'] ?? null ) ? $parts['scheme'] : '' ); $host = is_string( $parts['host'] ?? null ) ? trim( $parts['host'] ) : ''; return $host !== '' && in_array( $scheme, array( 'http', 'https' ), true ); } /** * Read a positive integer request parameter. * * @param array $requestData Request data. * @param string $key Request key. */ private static function requestPositiveInt( array $requestData, string $key ): ?int { $value = self::requestString( $requestData, $key ); if ( $value === null || ! ctype_digit( $value ) ) { return null; } $parsed = (int) $value; return $parsed > 0 ? $parsed : null; } /** * Read a page-size request parameter restricted to the exposed choices. * * @param array $requestData Request data. * @param string $key Request key. * @param int $fallback Fallback page size. */ private static function requestPerPage( array $requestData, string $key, int $fallback ): int { $value = self::requestPositiveInt( $requestData, $key ); if ( $value !== null && in_array( $value, self::PER_PAGE_OPTIONS, true ) ) { return $value; } return $fallback; } /** * Build nonce action for article-draft generation. * * @param int $productId Product id. */ private static function articleDraftNonceAction( int $productId ): string { return 'affilun_article_draft_' . (string) $productId; } /** * Build nonce action for creating a WordPress article draft. * * @param int $productId Product id. */ public static function createArticleDraftNonceAction( int $productId ): string { return 'affilun_create_article_draft_' . (string) $productId; } /** * Build CSV import nonce action for the exact previewed payload. * * @param string $csv CSV payload. */ private static function csvImportNonceAction( string $csv ): string { return self::CSV_IMPORT_ACTION . '_' . hash( 'sha256', $csv ); } /** * Verify a nonce when WordPress nonce helpers are available. * * @param string $nonce Nonce. * @param string $action Action. */ private static function verifyNonce( string $nonce, string $action ): bool { if ( $nonce === '' || ! function_exists( 'wp_verify_nonce' ) ) { return false; } if ( function_exists( 'sanitize_text_field' ) ) { $nonce = (string) sanitize_text_field( $nonce ); } return (bool) wp_verify_nonce( $nonce, $action ); } /** * Extract a raw scalar string from request data. * * @param array $requestData Request data. * @param string $key Request key. */ private static function rawRequestString( array $requestData, string $key ): string { if ( ! array_key_exists( $key, $requestData ) || ! is_scalar( $requestData[ $key ] ) ) { return ''; } return (string) $requestData[ $key ]; } /** * Render bulk filter controls. * * @param array $filters Current filters. */ private function renderBulkFilterControls( array $filters ): void { echo '
' . esc_html( '対象条件' ) . ''; echo ' '; echo ' '; echo '
'; } /** * Render bulk change controls. * * @param array $changes Current changes. */ private function renderBulkChangeControls( array $changes ): void { echo '
' . esc_html( '適用内容' ) . ''; echo '
'; echo '
'; } /** * Render bulk preview and confirmation form. * * @param array $filters Filters. * @param array $changes Changes. */ private function renderBulkPreview( array $filters, array $changes ): void { if ( $this->bulkUseCase === null ) { return; } try { $result = $this->bulkUseCase->preview( $filters, $changes ); } catch ( \Throwable $unused ) { unset( $unused ); echo '

'; echo esc_html( '一括編集の入力内容を確認してください。' ); echo '

'; return; } echo '

'; echo esc_html( sprintf( '%d 件の商品が対象です。内容を確認してから適用してください。', $result->matchedCount() ) ); echo '

'; $this->renderBulkPreviewList( $result ); if ( $result->matchedCount() < 1 ) { return; } echo '
'; echo ''; self::renderHiddenBulkFields( $filters, $changes ); self::renderNonceField( self::bulkNonceAction( $filters, $changes ), self::BULK_NONCE_FIELD ); echo ''; echo '
'; } /** * Render preview sample list. * * @param BulkUpdateProductsResult $result Preview result. */ private function renderBulkPreviewList( BulkUpdateProductsResult $result ): void { $records = $result->previewRecords(); if ( $records === array() ) { return; } echo '
    '; foreach ( $records as $record ) { $id = $record->id(); $label = sprintf( '#%s %s', $id === null ? '-' : (string) $id, $record->title() ); echo '
  • ' . esc_html( $label ) . '
  • '; } echo '
'; } /** * Build the nonce action for a previewed bulk payload. * * @param array $filters Filters. * @param array $changes Changes. */ private static function bulkNonceAction( array $filters, array $changes ): string { $payload = array( 'provider' => $filters['provider'] ?? '', 'lifecycle_filter' => $filters['lifecycle_state'] ?? '', 'lifecycle_change' => is_scalar( $changes['lifecycle_state'] ?? null ) ? (string) $changes['lifecycle_state'] : '', ); $encoded = wp_json_encode( $payload ); return self::BULK_ACTION . '_' . hash( 'sha256', is_string( $encoded ) ? $encoded : '' ); } /** * Render hidden bulk fields for confirmation. * * @param array $filters Filters. * @param array $changes Changes. */ private static function renderHiddenBulkFields( array $filters, array $changes ): void { if ( ! function_exists( 'esc_attr' ) ) { return; } $hidden = array( 'affilun_bulk_provider' => $filters['provider'] ?? '', 'affilun_bulk_lifecycle_filter' => $filters['lifecycle_state'] ?? '', 'affilun_bulk_lifecycle_change' => is_scalar( $changes['lifecycle_state'] ?? null ) ? (string) $changes['lifecycle_state'] : '', ); foreach ( $hidden as $name => $value ) { echo ''; } } /** * Extract bulk filters from GET/POST-like data. * * @param array $requestData Request data. * * @return array */ private static function bulkFiltersFromRequest( array $requestData ): array { return array( 'provider' => self::requestString( $requestData, 'affilun_bulk_provider' ) ?? '', 'lifecycle_state' => self::requestString( $requestData, 'affilun_bulk_lifecycle_filter' ) ?? '', ); } /** * Extract bulk changes from GET/POST-like data. * * @param array $requestData Request data. * * @return array */ private static function bulkChangesFromRequest( array $requestData ): array { $changes = array(); $lifecycle = self::requestString( $requestData, 'affilun_bulk_lifecycle_change' ); if ( $lifecycle !== null ) { $changes['lifecycle_state'] = $lifecycle; } return $changes; } /** * Extract a sanitized scalar string from request data. * * @param array $requestData Request data. * @param string $key Request key. */ private static function requestString( array $requestData, string $key ): ?string { if ( ! array_key_exists( $key, $requestData ) || ! is_scalar( $requestData[ $key ] ) ) { return null; } $value = (string) $requestData[ $key ]; if ( function_exists( 'sanitize_text_field' ) ) { $value = (string) sanitize_text_field( $value ); } $value = trim( $value ); return $value === '' ? null : $value; } /** * Render search form. * * @param ProductListQuery $query Current query. */ private function renderSearchForm( ProductListQuery $query ): void { echo '
'; echo ''; $this->renderFilterHiddenInputs( $query, false ); echo ''; echo ' '; echo ''; echo '
'; } /** * Render the §6.4 filter form. * * @param ProductListQuery $query Current query. */ private function renderFilterForm( ProductListQuery $query ): void { echo '
'; echo ''; $this->renderFilterHiddenInputs( $query, true ); echo '
'; echo ' '; $this->renderProviderSelect( $query->provider() ); echo ' '; echo ' '; $this->renderSortSelect( $query->sortKey() ); echo ' '; echo ' '; $this->renderOrderSelect( $query->sortOrder() ); echo ' '; echo ''; echo '

'; echo '
'; } /** * Render hidden inputs that preserve query state. * * @param ProductListQuery $query Current query. * @param bool $includeSearch Whether to include search. */ private function renderFilterHiddenInputs( ProductListQuery $query, bool $includeSearch ): void { $userState = self::userStateFromLifecycle( $query->lifecycleState() ); if ( $userState !== null ) { echo ''; } if ( $query->syncState() !== null ) { echo ''; } if ( $query->perPage() !== 20 ) { echo ''; } if ( $includeSearch && $query->search() !== null ) { echo ''; } } /** * Render provider filter select. * * @param string|null $current Current provider filter. */ private function renderProviderSelect( ?string $current ): void { if ( ! function_exists( 'esc_attr' ) ) { return; } echo ''; } /** * Render sort select. * * @param string $current Current sort key. */ private function renderSortSelect( string $current ): void { $labels = array( 'updated_at' => '更新日時', 'created_at' => '作成日時', 'title' => '商品名', 'last_synced_at' => '最終同期日時', ); echo ''; } /** * Render order select. * * @param string $current Current sort order. */ private function renderOrderSelect( string $current ): void { $labels = array( 'desc' => '降順', 'asc' => '昇順', ); echo ''; } /** * Render the §6.2 results-table ``. * * Phase-8 minimum: ID / Provider / Title / Brand / Price / * Used Posts / Sync State / Last Synced / Updated At / Article Draft / Actions. * */ private function renderTableHead(): void { echo ''; echo ''; $headings = array( 'ID', 'プロバイダー', '商品名', '価格', ); $headings = array_merge( $headings, array( '利用ポスト', '同期状態', '最終同期日時', '更新日時', '記事下書き', '操作', ) ); foreach ( $headings as $heading ) { echo '' . esc_html( $heading ) . ''; } echo ''; } /** * Render a single product row by routing the record through * {@see ProductSummaryMapper::map()} for shape normalization * and escaping every visible field. * * @param ProductRecord $record Persisted record. * @param array $usagePosts Posts using this product. * @param bool $cloudContracted Whether affilun Cloud is configured. * @param string $cloudSettingsUrl affilun Cloud settings URL. * * @phpstan-param list $usagePosts */ private function renderRow( ProductRecord $record, array $usagePosts, bool $cloudContracted, string $cloudSettingsUrl ): void { $row = $this->summaryMapper->map( $record, null ); $id = $row['id'] ?? null; $provider = (string) ( $row['provider'] ?? '' ); $title = (string) ( $row['title'] ?? '' ); $syncState = self::syncStateLabel( (string) ( $row['sync_state'] ?? '' ) ); $lastSyncedAt = $row['last_synced_at'] ?? null; $imageUrl = $record->imagePrimaryUrl(); $isDeleted = $record->lifecycleState() === ProductListQuery::LIFECYCLE_STATE_DELETED_PENDING; $productUrl = $record->affiliateUrl() ?? $record->detailUrl(); $priceLabel = AdminPriceFormatter::formatProductRecord( $record ); $titleDisplay = self::truncateText( $title, 64 ); $updatedAt = AdminTimeFormatter::format( gmdate( 'Y-m-d\TH:i:s\Z', $record->updatedAt()->getTimestamp() ) ); $lastSyncedAtDisplay = AdminTimeFormatter::format( $lastSyncedAt === null ? null : (string) $lastSyncedAt ); $idDisplay = $id === null ? '' : (string) $id; if ( $idDisplay !== '' && $isDeleted ) { $idDisplay .= '(削除済み)'; } echo ''; echo ''; if ( is_int( $id ) && $id > 0 && ! $isDeleted ) { echo ''; } else { echo ' '; } echo ''; echo '' . esc_html( $idDisplay ) . ''; echo '' . esc_html( $provider ) . ''; echo ''; if ( $imageUrl !== null && $imageUrl !== '' ) { echo ''; } echo '' . esc_html( $titleDisplay ) . ''; echo ''; echo '' . esc_html( $priceLabel ) . ''; echo ''; $this->renderUsagePosts( $usagePosts ); echo ''; echo '' . esc_html( $syncState ) . ''; echo '' . esc_html( $lastSyncedAtDisplay ) . ''; echo '' . esc_html( $updatedAt ) . ''; echo ''; $this->renderArticleDraftAction( $record, $cloudContracted, $cloudSettingsUrl ); echo ''; echo ''; $this->renderEditAction( $id ); echo ' '; $this->renderDeleteAction( $id, $isDeleted ); echo ''; echo ''; } /** * Render products whose latest link-health result needs attention. * * @param ProductListQuery $query Current product-list query. * @param array $requestData Request data. */ private function renderLinkHealthProblemPanel( ProductListQuery $query, array $requestData ): void { if ( $this->productRepository === null || $this->productMetaRepository === null ) { return; } $entries = $this->linkHealthProblemEntries(); if ( $entries === array() ) { return; } $totalEntries = count( $entries ); $perPage = self::requestPerPage( $requestData, self::LINK_HEALTH_PROBLEM_PER_PAGE_PARAM, self::LINK_HEALTH_PROBLEM_PER_PAGE ); $totalPages = max( 1, (int) ceil( $totalEntries / $perPage ) ); $currentPage = self::requestPositiveInt( $requestData, self::LINK_HEALTH_PROBLEM_PAGE_PARAM ) ?? 1; $currentPage = min( max( 1, $currentPage ), $totalPages ); $pageEntries = array_slice( $entries, ( $currentPage - 1 ) * $perPage, $perPage ); echo ''; } /** * Render pagination for the link-health problem panel. * * @param ProductListQuery $query Current product-list query. * @param array $requestData Request data. * @param int $currentPage Current problem-panel page. * @param int $totalPages Total problem-panel pages. * @param int $totalEntries Total problem entries. * @param int $perPage Current page size. */ private function renderLinkHealthProblemPagination( ProductListQuery $query, array $requestData, int $currentPage, int $totalPages, int $totalEntries, int $perPage ): void { $firstItem = ( ( $currentPage - 1 ) * $perPage ) + 1; $lastItem = min( $totalEntries, $currentPage * $perPage ); echo '
'; echo ''; echo esc_html( sprintf( '%d件中 %d-%d 件', $totalEntries, $firstItem, $lastItem ) ); echo ''; $this->renderPerPageLinks( $perPage, static fn ( int $option ): string => self::buildLinkHealthProblemPageUrlWithPerPage( 1, $option, $query, $requestData ) ); if ( $totalPages > 1 ) { echo ' '; if ( $currentPage > 1 ) { echo '' . esc_html( '« 前へ' ) . ' '; } echo '' . esc_html( sprintf( '%d / %d', $currentPage, $totalPages ) ) . ' '; if ( $currentPage < $totalPages ) { echo '' . esc_html( '次へ »' ) . ''; } echo ''; } else { echo ' ' . esc_html( sprintf( '%d / %d', $currentPage, $totalPages ) ) . ''; } echo '
'; } /** * Render 10/20/50 page-size links. * * @param int $current Current page size. * @param callable(int):string $urlBuilder URL builder for an option. */ private function renderPerPageLinks( int $current, callable $urlBuilder ): void { echo ' '; echo esc_html( '表示件数: ' ); $first = true; foreach ( self::PER_PAGE_OPTIONS as $option ) { if ( ! $first ) { echo ' / '; } if ( $option === $current ) { echo '' . esc_html( sprintf( '%d件', $option ) ) . ''; } else { echo '' . esc_html( sprintf( '%d件', $option ) ) . ''; } $first = false; } echo ''; } /** * Truncate visible table text without changing the link target/title. * * @param string $text Text. * @param int $limit Maximum visible length. */ private static function truncateText( string $text, int $limit ): string { if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' ) ) { return mb_strlen( $text, 'UTF-8' ) > $limit ? mb_substr( $text, 0, $limit, 'UTF-8' ) . '…' : $text; } return strlen( $text ) > $limit ? substr( $text, 0, $limit ) . '...' : $text; } /** * Find active saved products whose latest link-health status needs attention. * * @return list}> */ private function linkHealthProblemEntries(): array { if ( $this->productRepository === null || $this->productMetaRepository === null ) { return array(); } $store = new ProductLinkHealthStatusStore( $this->productMetaRepository ); $query = ProductListQuery::defaults() ->withLifecycleState( ProductListQuery::LIFECYCLE_STATE_ACTIVE ) ->withPerPage( 50 ); $page = 1; $scanned = 0; $entries = array(); do { $result = $this->productRepository->findPaginated( $query->withPage( $page ) ); foreach ( $result->items() as $record ) { ++$scanned; $id = $record->id(); if ( $id === null ) { continue; } $data = $store->readForProduct( $id ); if ( $data === null ) { continue; } $bucket = self::linkHealthStatusClassFromData( $data ); if ( $bucket === 'broken' || $bucket === 'warning' ) { $entries[] = array( 'record' => $record, 'data' => $data, ); } } ++$page; } while ( $page <= $result->totalPages() && $scanned < self::LINK_HEALTH_PROBLEM_SCAN_LIMIT ); return $entries; } /** * Render a link-health status badge from a persisted payload. * * @param array $data Link-health data. */ private function renderLinkHealthStatusPayload( array $data ): void { $status = is_scalar( $data['status'] ?? null ) ? (string) $data['status'] : 'unknown'; $httpStatus = isset( $data['http_status'] ) && is_numeric( $data['http_status'] ) ? (int) $data['http_status'] : 0; $label = $httpStatus > 0 ? sprintf( '%s / HTTP %d', $status, $httpStatus ) : $status; echo '' . esc_html( $label ) . ''; } /** * Format the checked_at value from a link-health payload. * * @param array $data Link-health data. */ private static function linkHealthCheckedAtLabel( array $data ): string { $checkedAt = is_scalar( $data['checked_at'] ?? null ) ? (string) $data['checked_at'] : ''; return $checkedAt === '' ? '' : AdminTimeFormatter::format( $checkedAt ); } /** * Resolve CSS class suffix for link-health data. * * @param array $data Link-health data. */ private static function linkHealthStatusClassFromData( array $data ): string { $status = is_scalar( $data['status'] ?? null ) ? (string) $data['status'] : ''; $httpStatus = isset( $data['http_status'] ) && is_numeric( $data['http_status'] ) ? (int) $data['http_status'] : 0; return LinkHealthStatusClassifier::classify( $status, $httpStatus ); } /** * Render posts that use the product. * * @param array $usagePosts Posts using this product. * * @phpstan-param list $usagePosts */ private function renderUsagePosts( array $usagePosts ): void { if ( $usagePosts === array() ) { echo '—'; return; } echo '
    '; foreach ( $usagePosts as $post ) { $label = sprintf( '%s (#%d)', $post['title'], $post['id'] ); echo '
  • ' . esc_html( $label ) . ''; if ( $post['status'] !== '' ) { echo ' ' . esc_html( $post['status'] ) . ''; } echo '
  • '; } echo '
'; } /** * Render the affilun Cloud article-draft action for one row. * * @param ProductRecord $record Product record. * @param bool $cloudContracted Whether affilun Cloud is contracted. * @param string $cloudSettingsUrl affilun Cloud settings URL. */ private function renderArticleDraftAction( ProductRecord $record, bool $cloudContracted, string $cloudSettingsUrl ): void { $id = $record->id(); if ( ! is_int( $id ) || $id <= 0 ) { echo '—'; return; } if ( ! $cloudContracted ) { echo ''; echo '

' . esc_html( '記事下書き機能の利用にはaffilun cloudの契約が必要です。' ) . '
' . esc_html( 'affilun Cloud設定へ' ) . '

'; return; } echo '
'; echo ''; echo ''; self::renderNonceField( self::createArticleDraftNonceAction( $id ), '_affilun_generate_article_draft_nonce' ); echo ''; echo '
'; } /** * Render edit action for one row. * * @param mixed $id Product id from the mapped row. */ private function renderEditAction( mixed $id ): void { if ( ! is_int( $id ) || $id <= 0 ) { echo '—'; return; } $url = self::productEditUrl( $id ); echo '' . esc_html( '編集' ) . ''; } /** * Product edit admin URL with the read-only routing nonce. * * @param int $productId Product id. */ private static function productEditUrl( int $productId ): string { $url = self::resolveAdminUrl( 'admin.php?page=affilun-edit-product&product_id=' . rawurlencode( (string) $productId ) ); if ( function_exists( 'add_query_arg' ) && function_exists( 'wp_create_nonce' ) ) { $url = add_query_arg( array( '_affilun_edit_product_nonce' => wp_create_nonce( 'affilun_edit_product_' . $productId ), ), $url ); } return $url; } /** * Render the logical-delete action for one row. * * @param mixed $id Product id from the mapped row. * @param bool $isDeleted Whether the row is already logically deleted. */ private function renderDeleteAction( mixed $id, bool $isDeleted ): void { if ( ! is_int( $id ) || $id <= 0 ) { echo '—'; return; } if ( $isDeleted ) { echo ''; return; } echo ''; } /** * 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 ''; } /** * Render the §6.7 empty-state message. * * Phase-8 minimum carries no "Add New Product" CTA — the * Add New page lands in slice 8e or Phase 9. */ private function renderEmptyState(): void { echo '
'; echo '

' . esc_html( '商品が見つかりませんでした。' ) . '

'; echo '

' . esc_html( '最初の商品を追加' ) . '

'; echo '
'; } /** * Render the textual `Page X of Y` pagination nav with prev / * next anchors that preserve the current filter state. * * @param ProductListQuery $query Originating query. * @param int $totalPages Total page count. * @param int $totalItems Total item count. */ private function renderPagination( ProductListQuery $query, int $totalPages, int $totalItems ): void { $current = $query->page(); echo '
'; echo ''; echo esc_html( sprintf( '%d件', $totalItems ) ); echo ''; $this->renderPerPageLinks( $query->perPage(), static fn ( int $option ): string => self::buildPageUrl( 1, $query->withPerPage( $option ) ) ); if ( $totalPages > 1 ) { echo ' '; if ( $current > 1 ) { $prevUrl = self::buildPageUrl( $current - 1, $query ); echo '' . esc_html( '« 前へ' ) . ' '; } echo '' . esc_html( sprintf( '%d / %d', $current, max( 1, $totalPages ) ) ) . ' '; if ( $current < $totalPages ) { $nextUrl = self::buildPageUrl( $current + 1, $query ); echo '' . esc_html( '次へ »' ) . ''; } echo ''; } else { echo ' ' . esc_html( sprintf( '%d / %d', $current, max( 1, $totalPages ) ) ) . ''; } echo '
'; } /** * Build a products-list URL for a query. * * @param ProductListQuery $query Query to serialize. */ private static function buildFilteredUrl( ProductListQuery $query ): string { $args = array( 'page' => self::MENU_SLUG, ProductsListQueryParser::KEY_PAGE => $query->page(), ); if ( $query->provider() !== null ) { $args[ ProductsListQueryParser::KEY_PROVIDER ] = $query->provider(); } if ( $query->syncState() !== null ) { $args[ ProductsListQueryParser::KEY_SYNC_STATE ] = $query->syncState(); } if ( $query->search() !== null ) { $args[ ProductsListQueryParser::KEY_SEARCH ] = $query->search(); } $args[ ProductsListQueryParser::KEY_SORT ] = $query->sortKey(); $args[ ProductsListQueryParser::KEY_ORDER ] = $query->sortOrder(); if ( $query->perPage() !== 20 ) { $args[ ProductsListQueryParser::KEY_PER_PAGE ] = $query->perPage(); } $userState = self::userStateFromLifecycle( $query->lifecycleState() ); if ( $userState !== null ) { $args[ ProductsListQueryParser::KEY_USER_STATE ] = $userState; } $base = self::resolveAdminUrl( 'admin.php' ); if ( function_exists( 'add_query_arg' ) ) { return (string) add_query_arg( $args, $base ); } return $base . ( str_contains( $base, '?' ) ? '&' : '?' ) . http_build_query( $args ); } /** * Build the per-page URL preserving the current filter state. * * Prefers `add_query_arg()` (the WordPress helper handles * existing query-string fragments correctly) and falls back to * manual `http_build_query()` concatenation in pure-unit * harnesses where WordPress is not loaded. * * @param int $page Target page (>= 1). * @param ProductListQuery $query Current query state. */ private static function buildPageUrl( int $page, ProductListQuery $query ): string { $args = array( 'page' => self::MENU_SLUG, ProductsListQueryParser::KEY_PAGE => $page, ); if ( $query->provider() !== null ) { $args[ ProductsListQueryParser::KEY_PROVIDER ] = $query->provider(); } if ( $query->syncState() !== null ) { $args[ ProductsListQueryParser::KEY_SYNC_STATE ] = $query->syncState(); } if ( $query->search() !== null ) { $args[ ProductsListQueryParser::KEY_SEARCH ] = $query->search(); } $args[ ProductsListQueryParser::KEY_SORT ] = $query->sortKey(); $args[ ProductsListQueryParser::KEY_ORDER ] = $query->sortOrder(); if ( $query->perPage() !== 20 ) { $args[ ProductsListQueryParser::KEY_PER_PAGE ] = $query->perPage(); } $userState = self::userStateFromLifecycle( $query->lifecycleState() ); if ( $userState !== null ) { $args[ ProductsListQueryParser::KEY_USER_STATE ] = $userState; } $base = self::resolveAdminUrl( 'admin.php' ); if ( function_exists( 'add_query_arg' ) ) { return (string) add_query_arg( $args, $base ); } $separator = str_contains( $base, '?' ) ? '&' : '?'; return $base . $separator . http_build_query( $args ); } /** * Build a link-health problem panel page URL while preserving product-list filters. * * @param int $page Target problem-panel page. * @param ProductListQuery $query Current product-list query. * @param array $requestData Request data. */ private static function buildLinkHealthProblemPageUrl( int $page, ProductListQuery $query, array $requestData ): string { $args = array( self::LINK_HEALTH_PROBLEM_PAGE_PARAM => $page, ); $perPage = self::requestPerPage( $requestData, self::LINK_HEALTH_PROBLEM_PER_PAGE_PARAM, self::LINK_HEALTH_PROBLEM_PER_PAGE ); if ( $perPage !== self::LINK_HEALTH_PROBLEM_PER_PAGE ) { $args[ self::LINK_HEALTH_PROBLEM_PER_PAGE_PARAM ] = $perPage; } if ( self::requestString( $requestData, 'affilun_link_health_filter' ) === 'problem' ) { $args['affilun_link_health_filter'] = 'problem'; } $base = self::buildFilteredUrl( $query ); if ( function_exists( 'add_query_arg' ) ) { return (string) add_query_arg( $args, $base ) . '#affilun-link-health-problems'; } $separator = str_contains( $base, '?' ) ? '&' : '?'; return $base . $separator . http_build_query( $args ) . '#affilun-link-health-problems'; } /** * Build a link-health problem panel URL for a changed page size. * * @param int $page Target problem-panel page. * @param int $perPage Target page size. * @param ProductListQuery $query Current product-list query. * @param array $requestData Request data. */ private static function buildLinkHealthProblemPageUrlWithPerPage( int $page, int $perPage, ProductListQuery $query, array $requestData ): string { $args = array( self::LINK_HEALTH_PROBLEM_PAGE_PARAM => $page, ); if ( $perPage !== self::LINK_HEALTH_PROBLEM_PER_PAGE ) { $args[ self::LINK_HEALTH_PROBLEM_PER_PAGE_PARAM ] = $perPage; } if ( self::requestString( $requestData, 'affilun_link_health_filter' ) === 'problem' ) { $args['affilun_link_health_filter'] = 'problem'; } $base = self::buildFilteredUrl( $query ); if ( function_exists( 'add_query_arg' ) ) { return (string) add_query_arg( $args, $base ) . '#affilun-link-health-problems'; } $separator = str_contains( $base, '?' ) ? '&' : '?'; return $base . $separator . http_build_query( $args ) . '#affilun-link-health-problems'; } /** * Translate provider-side sync state for admin display. * * @param string $syncState Provider-side sync state. */ private static function syncStateLabel( string $syncState ): string { return match ( $syncState ) { ProductListQuery::SYNC_STATE_FRESH => '最新', ProductListQuery::SYNC_STATE_STALE => '要確認', ProductListQuery::SYNC_STATE_EXPIRED => '期限切れ', ProductListQuery::SYNC_STATE_BROKEN => 'リンク切れ', default => $syncState, }; } /** * Convert a user-side state token into the stored lifecycle state. * * @param string $state User-side state token. */ private static function lifecycleFromUserState( string $state ): ?string { return match ( $state ) { 'publish' => ProductListQuery::LIFECYCLE_STATE_ACTIVE, 'unused' => ProductListQuery::LIFECYCLE_STATE_ARCHIVED, 'trash' => ProductListQuery::LIFECYCLE_STATE_DELETED_PENDING, default => null, }; } /** * Convert a stored lifecycle state into a user-side state token. * * @param string|null $lifecycleState Stored lifecycle state. */ private static function userStateFromLifecycle( ?string $lifecycleState ): ?string { return match ( $lifecycleState ) { ProductListQuery::LIFECYCLE_STATE_ACTIVE => 'publish', ProductListQuery::LIFECYCLE_STATE_ARCHIVED => 'unused', ProductListQuery::LIFECYCLE_STATE_DELETED_PENDING => 'trash', default => null, }; } /** * Whether affilun Cloud article-draft features should be enabled. */ private function isCloudContracted(): bool { if ( $this->settingsRepository === null ) { return false; } try { $cloud = $this->settingsRepository->load()->cloud(); } catch ( \Throwable $unused ) { unset( $unused ); return false; } return $cloud->isConfigured() && in_array( strtolower( $cloud->plan() ), array( 'personal', 'growth', 'agency' ), true ); } /** * URL to the affilun Cloud settings tab. */ private static function cloudSettingsUrl(): string { return self::resolveAdminUrl( 'admin.php?page=affilun-settings#affilun-settings-tab-cloud' ); } /** * Products list admin page URL. */ private static function productsListAdminUrl(): string { return self::resolveAdminUrl( 'admin.php?page=' . rawurlencode( self::MENU_SLUG ) ); } /** * Resolve an admin URL via `admin_url()` when available, * falling back to a synthetic absolute URL in pure-unit * harnesses where WordPress is not loaded so `esc_url()` does * not strip the value. * * @param string $path Admin path (e.g. `'admin.php'`). */ private static function resolveAdminUrl( string $path ): string { if ( function_exists( 'admin_url' ) ) { return (string) admin_url( $path ); } return 'http://example.test/wp-admin/' . ltrim( $path, '/' ); } }