*/ private const VALID_STATUS_FLASHES = array( 'saved', 'error', 'forbidden', 'invalid_nonce', 'amazon_resync_queued', 'amazon_resync_none', 'amazon_resync_error', 'external_affiliate_imported', 'external_affiliate_import_none', 'external_affiliate_import_error', 'external_affiliate_notice_hidden', ); /** * Registry key under which the slice 8c-1 * {@see AdminMenuRegistrar} is bound. */ public const BINDING_ADMIN_MENU_REGISTRAR = 'affilun.admin.menu_registrar'; /** * Registry key under which the slice 8c-2 * {@see SettingsPageRenderer} is bound. */ public const BINDING_SETTINGS_RENDERER = 'affilun.admin.settings_renderer'; /** * Registry key under which the slice 8c-3 * {@see SaveSettingsHandler} is bound. */ public const BINDING_SAVE_SETTINGS_HANDLER = 'affilun.admin.save_settings_handler'; /** * Registry key under which the slice 8c-2 * {@see SettingsFormParser} is bound. */ public const BINDING_SETTINGS_FORM_PARSER = 'affilun.admin.settings_form_parser'; /** * Registry key under which the slice 8d * {@see ProductsListPageRenderer} is bound. */ public const BINDING_PRODUCTS_LIST_PAGE_RENDERER = 'affilun.admin.products_list_page_renderer'; /** * Saved-product CSV export action. */ private const CSV_EXPORT_ACTION = 'affilun_export_products_csv'; /** * Saved-product CSV export nonce field. */ private const CSV_EXPORT_NONCE_FIELD = '_affilun_export_products_csv_nonce'; /** * Saved-product CSV import action. */ private const CSV_IMPORT_ACTION = 'affilun_import_products_csv'; /** * Saved-product CSV import nonce field. */ private const CSV_IMPORT_NONCE_FIELD = '_affilun_import_products_csv_nonce'; /** * Registry key under which the slice 8d * {@see ProductsListQueryParser} is bound. */ public const BINDING_PRODUCTS_LIST_QUERY_PARSER = 'affilun.admin.products_list_query_parser'; /** * Registry key under which the Phase 11 slice 11f * {@see DiagnosticsPageRenderer} is bound. */ public const BINDING_DIAGNOSTICS_PAGE_RENDERER = 'affilun.admin.diagnostics_page_renderer'; /** * Registry key under which the Phase 11 slice 11f * {@see LogsPageRenderer} is bound. */ public const BINDING_LOGS_PAGE_RENDERER = 'affilun.admin.logs_page_renderer'; /** * Registry key under which the Phase 11 slice 11f * {@see SyncJobsPageRenderer} is bound. */ public const BINDING_SYNC_JOBS_PAGE_RENDERER = 'affilun.admin.sync_jobs_page_renderer'; /** * Registry key under which the Phase 4 migration / growth * {@see MigrationPageRenderer} is bound. */ public const BINDING_MIGRATION_PAGE_RENDERER = 'affilun.admin.migration_page_renderer'; /** * Registry key under which the Phase 3 click analytics * {@see ClicksDashboardPageRenderer} is bound. */ public const BINDING_CLICKS_DASHBOARD_RENDERER = 'affilun.admin.clicks_dashboard_renderer'; /** * Registry key under which the Phase 13 Cloud tools * {@see CloudToolsPageRenderer} is bound. */ public const BINDING_CLOUD_TOOLS_RENDERER = 'affilun.admin.cloud_tools_renderer'; /** * Registry key under which the Phase 2 KI-006 follow-up * {@see AuditPageRenderer} is bound. */ public const BINDING_AUDIT_PAGE_RENDERER = 'affilun.admin.audit_page_renderer'; /** * Registry key under which the strict-review fix #5 (KI-014) * {@see AddNewProductPageRenderer} is bound. */ public const BINDING_ADD_NEW_PAGE_RENDERER = 'affilun.admin.add_new_page_renderer'; /** * Slice 8c-1 admin-menu registrar override. */ private ?AdminMenuRegistrar $menuRegistrarOverride; /** * Slice 8c-2 settings-page renderer override. */ private ?SettingsPageRenderer $rendererOverride; /** * Slice 8c-3 save-settings handler override. */ private ?SaveSettingsHandler $saveHandlerOverride; /** * Slice 8d products-list-page renderer override. */ private ?ProductsListPageRenderer $productsListRendererOverride; /** * Slice 8d products-list-page query parser override. */ private ?ProductsListQueryParser $productsListQueryParserOverride; /** * Phase 11 slice 11f diagnostics-page renderer override. */ private ?DiagnosticsPageRenderer $diagnosticsRendererOverride; /** * Phase 11 slice 11f logs-page renderer override. */ private ?LogsPageRenderer $logsRendererOverride; /** * Phase 11 slice 11f sync-jobs-page renderer override. */ private ?SyncJobsPageRenderer $syncJobsRendererOverride; /** * Phase 4 migration / growth renderer override. */ private ?MigrationPageRenderer $migrationRendererOverride; /** * Phase 3 click analytics renderer override. */ private ?ClicksDashboardPageRenderer $clicksDashboardRendererOverride; /** * Phase 2 KI-006 follow-up audit-page renderer override. */ private ?AuditPageRenderer $auditRendererOverride; /** * Strict-review fix #5 (KI-014) Add New page renderer override. */ private ?AddNewProductPageRenderer $addNewRendererOverride; /** * Cached {@see AdminMenuRegistrar} bound into the registry. */ private ?AdminMenuRegistrar $resolvedMenuRegistrar = null; /** * Cached settings repository used by admin-post feature gates. */ private ?OptionStoreSettingsRepository $resolvedSettingsRepository = null; /** * Cached option store used by settings-page admin-post handlers. */ private ?OptionStore $resolvedOptionStore = null; /** * Cached {@see SettingsPageRenderer} bound into the registry. */ private ?SettingsPageRenderer $resolvedRenderer = null; /** * Cached importer for detected existing affiliate-link data. */ private ?ExternalAffiliateProductImportUseCase $resolvedExternalAffiliateImporter = null; /** * Cached {@see SaveSettingsHandler} bound into the registry. */ private ?SaveSettingsHandler $resolvedSaveHandler = null; /** * Cached {@see ProductsListPageRenderer} bound into the * registry. */ private ?ProductsListPageRenderer $resolvedProductsListRenderer = null; /** * Cached {@see ProductsListQueryParser} bound into the * registry. */ private ?ProductsListQueryParser $resolvedProductsListQueryParser = null; /** * Cached {@see DiagnosticsPageRenderer} bound into the registry. */ private ?DiagnosticsPageRenderer $resolvedDiagnosticsRenderer = null; /** * Cached {@see LogsPageRenderer} bound into the registry. */ private ?LogsPageRenderer $resolvedLogsRenderer = null; /** * Cached {@see SyncJobsPageRenderer} bound into the registry. */ private ?SyncJobsPageRenderer $resolvedSyncJobsRenderer = null; /** * Cached {@see MigrationPageRenderer} bound into the registry. */ private ?MigrationPageRenderer $resolvedMigrationRenderer = null; /** * Cached {@see ClicksDashboardPageRenderer} bound into the registry. */ private ?ClicksDashboardPageRenderer $resolvedClicksDashboardRenderer = null; /** * Cached {@see CloudToolsPageRenderer} bound into the registry. */ private ?CloudToolsPageRenderer $resolvedCloudToolsRenderer = null; /** * Cached Cloud AI post scan runner. */ private ?CloudAiPostScanRunner $resolvedCloudAiPostScanRunner = null; /** * Cached {@see AuditPageRenderer} bound into the registry. */ private ?AuditPageRenderer $resolvedAuditRenderer = null; /** * Cached {@see AddNewProductPageRenderer} bound into the * registry (strict-review fix #5, KI-014). Captured by the * page-render closure for the §7 Add New admin page. */ private ?AddNewProductPageRenderer $resolvedAddNewRenderer = null; /** * Cached product repository used by product admin-post handlers. */ private ?ProductRepository $resolvedProductRepository = null; /** * Cached product-meta repository used by product admin-post handlers. */ private ?ProductMetaRepository $resolvedProductMetaRepository = null; /** * Cached sync-job repository used by the Amazon bulk-resync * admin-post handler. */ private ?SyncJobRepository $resolvedSyncJobRepository = null; /** * Cached {@see LogsListQueryParser} (built inline during * register()) so the page-render closure for the Logs page can * route `$_GET` through it without re-resolving. */ private ?LogsListQueryParser $resolvedLogsListQueryParser = null; /** * Cached {@see SyncJobsListQueryParser} (built inline during * register()) so the page-render closure for the Sync Jobs page * can route `$_GET` through it without re-resolving. */ private ?SyncJobsListQueryParser $resolvedSyncJobsListQueryParser = null; /** * Cached {@see AuditListQueryParser} (built inline during * register()) so the page-render closure for the Audit page * can route `$_GET` through it without re-resolving. */ private ?AuditListQueryParser $resolvedAuditListQueryParser = null; /** * Construct an admin service provider with optional * overrides for each collaborator. * * Empty / no-arg construction is the production default; * tests pass overrides one axis at a time so the provider * graph can be exercised against synthetic collaborators * without standing up the persistence stack. * * @param AdminMenuRegistrar|null $menuRegistrarOverride Slice 8c-1 admin-menu * registrar override. * @param SettingsPageRenderer|null $rendererOverride Slice 8c-2 page renderer override. * @param SaveSettingsHandler|null $saveHandlerOverride Slice 8c-3 save handler override. * @param ProductsListPageRenderer|null $productsListRendererOverride Slice 8d products-list * renderer override. * @param ProductsListQueryParser|null $productsListQueryParserOverride Slice 8d query parser * override. * @param DiagnosticsPageRenderer|null $diagnosticsRendererOverride Phase 11 11f diagnostics * renderer override. * @param LogsPageRenderer|null $logsRendererOverride Phase 11 11f logs renderer override. * @param SyncJobsPageRenderer|null $syncJobsRendererOverride Phase 11 11f sync-jobs * renderer override. * @param AuditPageRenderer|null $auditRendererOverride Phase 2 KI-006 follow-up audit * admin-page renderer override. * @param AddNewProductPageRenderer|null $addNewRendererOverride Strict-review fix #5 (KI-014) * Add New admin-page renderer override. * @param ClicksDashboardPageRenderer|null $clicksDashboardRendererOverride Phase 3 click analytics * renderer override. * @param MigrationPageRenderer|null $migrationRendererOverride Phase 4 migration renderer * override. */ public function __construct( ?AdminMenuRegistrar $menuRegistrarOverride = null, ?SettingsPageRenderer $rendererOverride = null, ?SaveSettingsHandler $saveHandlerOverride = null, ?ProductsListPageRenderer $productsListRendererOverride = null, ?ProductsListQueryParser $productsListQueryParserOverride = null, ?DiagnosticsPageRenderer $diagnosticsRendererOverride = null, ?LogsPageRenderer $logsRendererOverride = null, ?SyncJobsPageRenderer $syncJobsRendererOverride = null, ?AuditPageRenderer $auditRendererOverride = null, ?AddNewProductPageRenderer $addNewRendererOverride = null, ?ClicksDashboardPageRenderer $clicksDashboardRendererOverride = null, ?MigrationPageRenderer $migrationRendererOverride = null ) { $this->menuRegistrarOverride = $menuRegistrarOverride; $this->rendererOverride = $rendererOverride; $this->saveHandlerOverride = $saveHandlerOverride; $this->productsListRendererOverride = $productsListRendererOverride; $this->productsListQueryParserOverride = $productsListQueryParserOverride; $this->diagnosticsRendererOverride = $diagnosticsRendererOverride; $this->logsRendererOverride = $logsRendererOverride; $this->syncJobsRendererOverride = $syncJobsRendererOverride; $this->auditRendererOverride = $auditRendererOverride; $this->addNewRendererOverride = $addNewRendererOverride; $this->clicksDashboardRendererOverride = $clicksDashboardRendererOverride; $this->migrationRendererOverride = $migrationRendererOverride; } /** * Bind the slice 8c-1 / 8c-2 / 8c-3 admin collaborators into * the registry, then append the §13 Settings page * {@see AdminPageDefinition} to the bound registrar. * * Resolution order: * * 1. Resolve {@see OptionStore} via * {@see PersistenceServiceProvider::BINDING_OPTION_STORE}. * Missing binding raises {@see LogicException}. * * 2. Build the §13 repository, the §13.10 save use case, * and the §13 form parser. * * 3. Build (or accept the override of) the §13 page * renderer and the §13.10 save handler. * * 4. Build (or accept the override of) the slice 8c-1 * admin-menu registrar. * * 5. Append the §13 Settings page definition to the * registrar. * * 6. Bind all four under the `BINDING_*` keys and cache * the registrar / renderer / save handler so the boot() * action callbacks can capture them. * * @param ServiceProviderRegistry $registry Registry driving * this bootstrap pass. * * @throws LogicException When the persistence provider has * not registered before this one. */ public function register( ServiceProviderRegistry $registry ): void { $optionStore = $this->resolveOptionStore( $registry ); $productRepository = $this->resolveProductRepository( $registry ); $productMetaRepository = $this->resolveProductMetaRepository( $registry ); $repository = new OptionStoreSettingsRepository( $optionStore ); $useCase = new SaveSettingsUseCase( $repository ); $parser = new SettingsFormParser(); $affiliateRoutingRepository = new OptionStoreAffiliateRoutingRepository( $optionStore ); $externalAffiliateImporter = new ExternalAffiliateProductImportUseCase( $productRepository, new SystemClock(), self::buildExternalAffiliatePostRowsProvider() ); $renderer = $this->rendererOverride ?? new SettingsPageRenderer( $repository, null, $affiliateRoutingRepository, $this->resolveOptionalProductCardViewModelFactory( $registry ), $this->resolveOptionalProductCardRenderer( $registry ), $externalAffiliateImporter, $optionStore ); $auditWriter = $this->resolveOptionalAuditEventWriter( $registry ); $saveHandler = $this->saveHandlerOverride ?? new SaveSettingsHandler( $repository, $useCase, $parser, null, null, null, null, $auditWriter, null, $affiliateRoutingRepository, $this->resolveOptionalRenderCache( $registry ) ); $menuRegistrar = $this->menuRegistrarOverride ?? new AdminMenuRegistrar(); $listUseCase = $this->resolveListProductsUseCase( $registry, $productRepository ); $productTaxonomy = new ProductManualTaxonomy( $productMetaRepository ); $bulkProductsUseCase = new BulkUpdateProductsUseCase( $productRepository, $productTaxonomy ); $productCsvImportUseCase = new ProductCsvImportUseCase( $productRepository, $productTaxonomy ); $addNewRenderer = $this->addNewRendererOverride ?? new AddNewProductPageRenderer(); $productsListRenderer = $this->productsListRendererOverride ?? new ProductsListPageRenderer( $listUseCase, new ProductSummaryMapper(), null, $bulkProductsUseCase, $productCsvImportUseCase, $addNewRenderer, $repository, $productRepository, productMetaRepository: $productMetaRepository, amazonDiscountCardRenderer: $this->resolveOptionalAmazonDiscountCardRenderer( $registry ) ); $productsListQueryParser = $this->productsListQueryParserOverride ?? new ProductsListQueryParser(); $logRepository = $this->resolveLogRepository( $registry ); $syncJobRepository = $this->resolveSyncJobRepository( $registry ); $schemaVersionRepository = $this->resolveSchemaVersionRepository( $registry ); $logMasker = new LogMasker(); $listLogsUseCase = new ListLogsUseCase( $logRepository, $logMasker ); $getLogsSummaryUseCase = new GetLogsSummaryUseCase( $logRepository, $logMasker ); $listSyncJobsUseCase = new ListSyncJobsUseCase( $syncJobRepository ); // Phase 12 slice 12c: the providers map is resolved from the // centralized ProviderRegistry binding published by // ProviderServiceProvider. When the binding is missing — // typically in tests that pre-seed the admin collaborators // without standing up the provider service provider — the // resolver falls back to an empty registry so the admin // diagnostics page renders an empty `providers` branch // rather than failing. $providerRegistry = $this->resolveProviderRegistry( $registry ); $diagnosticsUseCase = new GetDiagnosticsUseCase( $schemaVersionRepository, $syncJobRepository, $providerRegistry->all(), null, new SystemClock() ); $diagnosticsRenderer = $this->diagnosticsRendererOverride ?? new DiagnosticsPageRenderer( $diagnosticsUseCase ); $logsRenderer = $this->logsRendererOverride ?? new LogsPageRenderer( $listLogsUseCase, $getLogsSummaryUseCase ); $syncJobsRenderer = $this->syncJobsRendererOverride ?? new SyncJobsPageRenderer( $listSyncJobsUseCase ); $clicksDashboardRenderer = $this->clicksDashboardRendererOverride ?? new ClicksDashboardPageRenderer( $optionStore, $this->resolveOptionalClickEventRepository( $registry ), $productRepository ); $migrationRenderer = $this->migrationRendererOverride ?? new MigrationPageRenderer( $optionStore ); $topAffiliateCandidates = $this->resolveOptionalTopAffiliateCandidateRepository( $registry ); $cloudToolsRenderer = new CloudToolsPageRenderer( $repository, productRepository: $productRepository, productMetaRepository: $productMetaRepository, topAffiliateCandidateRepository: $topAffiliateCandidates, optionStore: $optionStore ); $cloudAiPostScanRunner = new CloudAiPostScanRunner( $repository, $optionStore, $providerRegistry, new SaveProductUseCase( $productRepository, new SystemClock() ) ); $this->resolvedSettingsRepository = $repository; $this->resolvedOptionStore = $optionStore; $this->resolvedProductRepository = $productRepository; $this->resolvedProductMetaRepository = $productMetaRepository; $this->resolvedExternalAffiliateImporter = $externalAffiliateImporter; $this->resolvedSyncJobRepository = $syncJobRepository; $this->resolvedMenuRegistrar = $menuRegistrar; $this->resolvedRenderer = $renderer; $this->resolvedSaveHandler = $saveHandler; $this->resolvedProductsListRenderer = $productsListRenderer; $this->resolvedProductsListQueryParser = $productsListQueryParser; $this->resolvedDiagnosticsRenderer = $diagnosticsRenderer; $this->resolvedLogsRenderer = $logsRenderer; $this->resolvedSyncJobsRenderer = $syncJobsRenderer; $this->resolvedClicksDashboardRenderer = $clicksDashboardRenderer; $this->resolvedMigrationRenderer = $migrationRenderer; $this->resolvedCloudToolsRenderer = $cloudToolsRenderer; $this->resolvedCloudAiPostScanRunner = $cloudAiPostScanRunner; $this->resolvedLogsListQueryParser = new LogsListQueryParser(); $this->resolvedSyncJobsListQueryParser = new SyncJobsListQueryParser(); $this->resolvedAddNewRenderer = $addNewRenderer; $currentSettings = $repository->load(); $menuRegistrar->add( $this->buildSettingsPageDefinition() ); $menuRegistrar->add( $this->buildProductsListPageDefinition() ); $menuRegistrar->add( $this->buildProductsListSubmenuOverrideDefinition() ); $menuRegistrar->add( $this->buildAddNewProductPageDefinition( true ) ); $menuRegistrar->add( $this->buildMigrationPageDefinition( true ) ); $menuRegistrar->add( $this->buildClicksDashboardPageDefinition() ); if ( $this->shouldShowCloudContractMenus( $currentSettings ) ) { $menuRegistrar->add( $this->buildCloudToolsPageDefinition() ); } $menuRegistrar->add( $this->buildDiagnosticsPageDefinition( true ) ); $menuRegistrar->add( $this->buildLogsPageDefinition( true ) ); $menuRegistrar->add( $this->buildSyncJobsPageDefinition( true ) ); $auditRenderer = $this->resolveAuditRenderer( $registry ); if ( $auditRenderer !== null ) { $this->resolvedAuditRenderer = $auditRenderer; $this->resolvedAuditListQueryParser = new AuditListQueryParser(); $menuRegistrar->add( $this->buildAuditPageDefinition( true ) ); } $renderer->configureOperationTools( $migrationRenderer, $diagnosticsRenderer, $logsRenderer, $syncJobsRenderer, $auditRenderer, $this->resolvedLogsListQueryParser, $this->resolvedSyncJobsListQueryParser, $this->resolvedAuditListQueryParser ); $registry->bind( self::BINDING_ADMIN_MENU_REGISTRAR, $menuRegistrar ); $registry->bind( self::BINDING_SETTINGS_RENDERER, $renderer ); $registry->bind( self::BINDING_SAVE_SETTINGS_HANDLER, $saveHandler ); $registry->bind( self::BINDING_SETTINGS_FORM_PARSER, $parser ); $registry->bind( self::BINDING_PRODUCTS_LIST_PAGE_RENDERER, $productsListRenderer ); $registry->bind( self::BINDING_PRODUCTS_LIST_QUERY_PARSER, $productsListQueryParser ); $registry->bind( self::BINDING_DIAGNOSTICS_PAGE_RENDERER, $diagnosticsRenderer ); $registry->bind( self::BINDING_LOGS_PAGE_RENDERER, $logsRenderer ); $registry->bind( self::BINDING_SYNC_JOBS_PAGE_RENDERER, $syncJobsRenderer ); $registry->bind( self::BINDING_CLICKS_DASHBOARD_RENDERER, $clicksDashboardRenderer ); $registry->bind( self::BINDING_MIGRATION_PAGE_RENDERER, $migrationRenderer ); $registry->bind( self::BINDING_CLOUD_TOOLS_RENDERER, $cloudToolsRenderer ); $registry->bind( self::BINDING_ADD_NEW_PAGE_RENDERER, $this->resolvedAddNewRenderer ); if ( $auditRenderer !== null ) { $registry->bind( self::BINDING_AUDIT_PAGE_RENDERER, $auditRenderer ); } } /** * Whether paid Cloud-only admin menu entries should be visible. * * @param PluginSettings $settings Current settings snapshot. */ private function shouldShowCloudContractMenus( PluginSettings $settings ): bool { return $settings->cloud()->isConfigured(); } /** * Resolve optional click event repository. * * @param ServiceProviderRegistry $registry Registry. */ private function resolveOptionalClickEventRepository( ServiceProviderRegistry $registry ): ?ClickEventRepository { $key = PersistenceServiceProvider::BINDING_CLICK_EVENT_REPOSITORY; if ( ! $registry->has( $key ) ) { return null; } $repository = $registry->get( $key ); return $repository instanceof ClickEventRepository ? $repository : null; } /** * Resolve optional top affiliate candidate repository. * * @param ServiceProviderRegistry $registry Registry. */ private function resolveOptionalTopAffiliateCandidateRepository( ServiceProviderRegistry $registry ): ?TopAffiliateCandidateRepository { $key = PersistenceServiceProvider::BINDING_TOP_AFFILIATE_CANDIDATE_REPOSITORY; if ( ! $registry->has( $key ) ) { return null; } $repository = $registry->get( $key ); return $repository instanceof TopAffiliateCandidateRepository ? $repository : null; } /** * Resolve the optional Amazon discount shortcode configurator. */ private function resolveOptionalAmazonDiscountCardRenderer( ServiceProviderRegistry $registry ): ?AmazonDiscountShortcodeCardRenderer { $key = AmazonDiscountServiceProvider::BINDING_ADMIN_CARD_RENDERER; if ( ! $registry->has( $key ) ) { return null; } $renderer = $registry->get( $key ); return $renderer instanceof AmazonDiscountShortcodeCardRenderer ? $renderer : null; } /** * Resolve the Phase 2 KI-006 follow-up {@see AuditPageRenderer}. * * Honours the constructor override first so tests that wire a * synthetic renderer keep working without binding the * persistence-layer audit-event repository. When no override * is supplied, soft-resolves * {@see PersistenceServiceProvider::BINDING_AUDIT_EVENT_REPOSITORY} * via the same skip-on-missing pattern as * {@see self::resolveOptionalAuditEventWriter()}; on miss * returns null so the audit page wiring is skipped entirely. * * @param ServiceProviderRegistry $registry Bootstrap registry. */ private function resolveAuditRenderer( ServiceProviderRegistry $registry ): ?AuditPageRenderer { if ( $this->auditRendererOverride !== null ) { return $this->auditRendererOverride; } $key = PersistenceServiceProvider::BINDING_AUDIT_EVENT_REPOSITORY; if ( ! $registry->has( $key ) ) { return null; } $auditEventRepo = $registry->get( $key ); if ( ! $auditEventRepo instanceof AuditEventRepository ) { return null; } $listAuditEventsUseCase = new ListAuditEventsUseCase( $auditEventRepo ); $getAuditEventsSummaryUseCase = new GetAuditEventsSummaryUseCase( $auditEventRepo ); return new AuditPageRenderer( $listAuditEventsUseCase, $getAuditEventsSummaryUseCase ); } /** * Subscribe the `admin_menu` and * `admin_post_affilun_save_settings` action callbacks. * * Both callbacks are public methods on this provider (rather * than closures) so the {@see HookRegistrar}'s introspection * surface carries stable, named callables that tests and * diagnostics can identify without unwrapping a closure. * * @param HookRegistrar $hooks Shared hook registrar. */ public function boot( HookRegistrar $hooks ): void { $hooks->addAction( 'admin_menu', array( $this, 'onAdminMenu' ) ); $hooks->addAction( 'admin_post_affilun_save_settings', array( $this, 'onSaveSettingsPost' ) ); $hooks->addAction( 'admin_post_affilun_delete_product', array( $this, 'onDeleteProductPost' ) ); $hooks->addAction( 'admin_post_affilun_bulk_products_action', array( $this, 'onBulkProductsActionPost' ) ); $hooks->addAction( 'admin_post_affilun_bulk_update_products', array( $this, 'onBulkUpdateProductsPost' ) ); $hooks->addAction( 'admin_post_affilun_create_article_draft', array( $this, 'onCreateArticleDraftPost' ) ); $hooks->addAction( 'admin_post_affilun_generate_article_draft', array( $this, 'onGenerateArticleDraftPost' ) ); $hooks->addAction( 'admin_post_' . CloudToolsPageRenderer::REVENUE_STATUS_ACTION, array( $this, 'onRevenueOpportunityStatusPost' ) ); $hooks->addAction( 'admin_post_' . CloudToolsPageRenderer::AI_SCAN_ACTION, array( $this, 'onCloudAiPostScanPost' ) ); $hooks->addAction( CloudAiPostScanRunner::CRON_HOOK, array( $this, 'onCloudAiPostScanCron' ) ); $hooks->addAction( 'init', array( $this, 'onCloudAiPostScanRecovery' ) ); $hooks->addAction( 'admin_post_' . self::CSV_EXPORT_ACTION, array( $this, 'onExportProductsCsvPost' ) ); $hooks->addAction( 'admin_post_' . self::CSV_IMPORT_ACTION, array( $this, 'onImportProductsCsvPost' ) ); $hooks->addAction( 'admin_post_affilun_resync_amazon_products', array( $this, 'onAmazonResyncPost' ) ); $hooks->addAction( 'admin_post_' . SettingsPageRenderer::EXTERNAL_AFFILIATE_IMPORT_ACTION, array( $this, 'onImportExternalAffiliateDataPost' ) ); $hooks->addAction( 'admin_post_' . SettingsPageRenderer::EXTERNAL_AFFILIATE_DISMISS_ACTION, array( $this, 'onDismissExternalAffiliateDataPost' ) ); $hooks->addAction( 'admin_notices', array( $this, 'onAdminNotices' ) ); $hooks->addAction( 'admin_enqueue_scripts', array( $this, 'onAdminEnqueueScripts' ) ); } /** * Render cross-page affilun admin notices. */ public function onAdminNotices(): void { $this->renderArticleDraftProvenanceNotice(); $this->renderLinkHealthAdminNotice(); } /** * Keep fallback provenance visible when editing the saved draft. */ private function renderArticleDraftProvenanceNotice(): void { $post = $GLOBALS['post'] ?? null; if ( ! $post instanceof \WP_Post ) { return; } if ( ! function_exists( 'get_current_screen' ) || get_current_screen()?->base !== 'post' ) { return; } if ( ! function_exists( 'current_user_can' ) || ! current_user_can( 'edit_post', $post->ID ) ) { return; } if ( ! function_exists( 'get_post_meta' ) || ! function_exists( 'esc_html' ) ) { return; } $source = get_post_meta( $post->ID, '_affilun_article_draft_source', true ); $notice = is_string( $source ) ? ArticleDraftNotice::provenanceMessage( $source ) : null; if ( $notice !== null ) { echo '

' . esc_html( $notice ) . '

'; } } /** * Render latest scheduled/manual link-health warnings on affilun admin pages. */ private function renderLinkHealthAdminNotice(): void { if ( $this->resolvedSettingsRepository === null || $this->resolvedProductRepository === null || $this->resolvedProductMetaRepository === null ) { return; } if ( ! self::isAffilunAdminPage() ) { return; } if ( function_exists( 'current_user_can' ) && ! current_user_can( AdminCapability::CAP_VIEW_PRODUCTS ) ) { return; } if ( ! $this->resolvedSettingsRepository->load()->cloud()->isConfigured() ) { return; } try { $result = $this->resolvedProductRepository->findPaginated( new ProductListQuery( page: 1, perPage: 50, lifecycleState: ProductListQuery::LIFECYCLE_STATE_ACTIVE ) ); } catch ( Throwable $unused ) { unset( $unused ); return; } $store = new ProductLinkHealthStatusStore( $this->resolvedProductMetaRepository ); $broken = 0; $warning = 0; foreach ( $result->items() as $record ) { $id = $record->id(); if ( $id === null ) { continue; } $data = $store->readForProduct( $id ); if ( $data === null ) { continue; } $status = is_scalar( $data['status'] ?? null ) ? (string) $data['status'] : ''; $httpStatus = isset( $data['http_status'] ) && is_numeric( $data['http_status'] ) ? (int) $data['http_status'] : 0; $bucket = LinkHealthStatusClassifier::classify( $status, $httpStatus ); if ( $bucket === 'broken' ) { ++$broken; } elseif ( $bucket === 'warning' ) { ++$warning; } } if ( $broken > 0 ) { echo '

'; echo esc_html( sprintf( '最新のリンク監視で問題のあるアフィリエイトリンクが %d 件見つかっています。', $broken ) ); echo ' ' . esc_html( '商品一覧で確認' ) . ''; echo '

'; return; } if ( $warning > 0 ) { echo '

'; echo esc_html( sprintf( '最新のリンク監視で確認が必要なアフィリエイトリンクが %d 件あります。', $warning ) ); echo ' ' . esc_html( '商品一覧で確認' ) . ''; echo '

'; } } /** * Whether the current request is for an affilun admin page. */ private static function isAffilunAdminPage(): bool { return str_starts_with( self::requestKey( self::currentGetData(), 'page' ), 'affilun-' ); } /** * `admin_enqueue_scripts` callback for small admin-only helpers. */ public function onAdminEnqueueScripts(): void { if ( function_exists( 'wp_enqueue_style' ) && defined( 'AFFILUN_PLUGIN_URL' ) ) { $pluginDir = defined( 'AFFILUN_PLUGIN_DIR' ) ? (string) constant( 'AFFILUN_PLUGIN_DIR' ) : dirname( __DIR__, 3 ) . '/'; $cardCssPath = $pluginDir . 'assets/css/affilun-card.css'; $cardVersion = file_exists( $cardCssPath ) ? (string) filemtime( $cardCssPath ) : ( defined( 'AFFILUN_VERSION' ) ? (string) constant( 'AFFILUN_VERSION' ) : '0' ); wp_enqueue_style( 'affilun-card-style', AFFILUN_PLUGIN_URL . 'assets/css/affilun-card.css', array(), $cardVersion ); $adminCssPath = $pluginDir . 'assets/css/affilun-admin.css'; $adminVersion = file_exists( $adminCssPath ) ? (string) filemtime( $adminCssPath ) : ( defined( 'AFFILUN_VERSION' ) ? (string) constant( 'AFFILUN_VERSION' ) : '0' ); wp_enqueue_style( 'affilun-admin-brand', AFFILUN_PLUGIN_URL . 'assets/css/affilun-admin.css', array( 'affilun-card-style' ), $adminVersion ); } if ( ! function_exists( 'wp_register_script' ) || ! function_exists( 'wp_enqueue_script' ) ) { return; } $handle = 'affilun-admin-settings'; wp_register_script( $handle, '', array( 'wp-api-fetch' ), '0.4.0', true ); wp_enqueue_script( $handle ); if ( ! function_exists( 'wp_add_inline_script' ) ) { return; } $script = <<<'JS' (function () { function toArray(nodes) { return Array.prototype.slice.call(nodes || []); } function initSettingsTabs() { var root = document.querySelector('[data-affilun-settings-tabs]'); if (!root) { return; } var tabs = toArray(root.querySelectorAll('[data-affilun-tab-target]')); var panels = toArray(root.querySelectorAll('[data-affilun-tab-panel]')); var submitRow = document.querySelector('[data-affilun-settings-submit-row]'); if (!tabs.length || !panels.length) { return; } function tabExists(tabKey) { return tabs.some(function (tab) { return tab.getAttribute('data-affilun-tab-target') === tabKey; }); } function showTab(tabKey, persist) { if (!tabExists(tabKey)) { tabKey = tabs[0].getAttribute('data-affilun-tab-target'); } tabs.forEach(function (tab) { var isActive = tab.getAttribute('data-affilun-tab-target') === tabKey; tab.classList.toggle('nav-tab-active', isActive); tab.setAttribute('aria-selected', isActive ? 'true' : 'false'); tab.setAttribute('tabindex', isActive ? '0' : '-1'); }); panels.forEach(function (panel) { var isActive = panel.getAttribute('data-affilun-tab-panel') === tabKey; panel.classList.toggle('affilun-settings-tab-panel-active', isActive); panel.hidden = !isActive; }); if (submitRow) { submitRow.hidden = tabKey === 'cloud' || tabKey === 'operations'; } if (persist && window.localStorage) { try { window.localStorage.setItem('affilun-settings-active-tab', tabKey); } catch (ignored) { // localStorage may be unavailable in privacy-restricted contexts. } } } function tabFromHash() { if (!window.location.hash) { return ''; } var hash = window.location.hash.replace(/^#/, ''); var panel = panels.filter(function (candidate) { return candidate.id === hash; })[0]; return panel ? panel.getAttribute('data-affilun-tab-panel') : ''; } function storedTab() { if (!window.localStorage) { return ''; } try { return window.localStorage.getItem('affilun-settings-active-tab') || ''; } catch (ignored) { return ''; } } tabs.forEach(function (tab, index) { tab.addEventListener('click', function (event) { event.preventDefault(); var tabKey = tab.getAttribute('data-affilun-tab-target'); showTab(tabKey, true); if (window.history && window.history.replaceState) { window.history.replaceState(null, '', tab.getAttribute('href')); } }); tab.addEventListener('keydown', function (event) { var nextIndex = index; if (event.key === 'ArrowRight' || event.key === 'ArrowDown') { nextIndex = (index + 1) % tabs.length; } else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') { nextIndex = (index - 1 + tabs.length) % tabs.length; } else if (event.key === 'Home') { nextIndex = 0; } else if (event.key === 'End') { nextIndex = tabs.length - 1; } else { return; } event.preventDefault(); tabs[nextIndex].focus(); showTab(tabs[nextIndex].getAttribute('data-affilun-tab-target'), true); }); }); showTab(tabFromHash() || storedTab() || tabs[0].getAttribute('data-affilun-tab-target'), false); } function initRenderingPreview() { var root = document.querySelector('[data-affilun-rendering-preview]'); var card = root ? root.querySelector('[data-affilun-preview-card]') : null; if (!root || !card) { return; } var fieldNames = { template: 'affilun_settings[rendering][default_template]', design: 'affilun_settings[rendering][default_design_preset]', colorScheme: 'affilun_settings[rendering][default_color_scheme]', layout: 'affilun_settings[rendering][default_button_layout]', shape: 'affilun_settings[rendering][default_button_shape]', tone: '' }; var templates = { 'single-default-card': '標準カード', 'single-compact-card': 'コンパクトカード', 'single-minimal-cta': 'ミニマルCTA' }; var designs = { default: '標準', flat: 'フラット', outlined: 'アウトライン', soft: 'ソフト' }; var colorSchemes = { light: 'ライト', dark: 'ダーク', system: 'OS設定に連動' }; var layouts = { wrap: '横並び / 折り返し', stacked: '縦積み', full: '全幅' }; var shapes = { square: '角丸なし', rounded: '標準角丸', pill: '丸型' }; var providers = { amazon: { cssToken: 'amazon', defaultColor: '#f6a306' }, rakuten: { cssToken: 'rakuten', defaultColor: '#cf4944' }, yahoo_shopping: { cssToken: 'yahoo', defaultColor: '#51a7e8' }, mercari: { cssToken: 'mercari', defaultColor: '#f2a1a1' } }; function findField(name) { return document.querySelector('[name="' + name + '"]'); } function allowedValue(control, allowed, fallback) { var value = control && control.value ? control.value : fallback; return Object.prototype.hasOwnProperty.call(allowed, value) ? value : fallback; } function setText(selector, value) { var target = root.querySelector(selector); if (target) { target.textContent = value; } } function providerField(provider, field) { return findField('affilun_settings[providers][' + provider + '][' + field + ']'); } function validHexColor(value) { return /^#[0-9a-fA-F]{6}$/.test(value || ''); } function relativeLuminance(color) { var red = parseInt(color.slice(1, 3), 16); var green = parseInt(color.slice(3, 5), 16); var blue = parseInt(color.slice(5, 7), 16); function channel(value) { value = value / 255; return value <= 0.04045 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4); } return 0.2126 * channel(red) + 0.7152 * channel(green) + 0.0722 * channel(blue); } function softClampLuminance(luminance) { var threshold = 0.022; return luminance >= threshold ? luminance : luminance + Math.pow(threshold - luminance, 1.414); } function apcaContrast(textColor, backgroundColor) { var textY = softClampLuminance(relativeLuminance(textColor)); var backgroundY = softClampLuminance(relativeLuminance(backgroundColor)); if (Math.abs(backgroundY - textY) < 0.0005) { return 0; } if (backgroundY > textY) { var darkTextContrast = (Math.pow(backgroundY, 0.56) - Math.pow(textY, 0.57)) * 1.14; return darkTextContrast < 0.1 ? 0 : (darkTextContrast - 0.027) * 100; } var lightTextContrast = (Math.pow(backgroundY, 0.65) - Math.pow(textY, 0.62)) * 1.14; return lightTextContrast > -0.1 ? 0 : (lightTextContrast + 0.027) * 100; } function automaticButtonTextColor(color) { if (!validHexColor(color)) { color = '#111827'; } return Math.abs(apcaContrast('#ffffff', color)) > Math.abs(apcaContrast('#000000', color)) ? '#ffffff' : '#000000'; } function resolveButtonTextColor(color, preference) { if (preference === 'white') { return '#ffffff'; } if (preference === 'black') { return '#000000'; } return automaticButtonTextColor(color); } var controls = { template: findField(fieldNames.template), design: findField(fieldNames.design), colorScheme: findField(fieldNames.colorScheme), layout: findField(fieldNames.layout), shape: findField(fieldNames.shape), tone: null }; function refreshProviders() { var visible = 0; Object.keys(providers).forEach(function (provider) { var enabled = providerField(provider, 'enabled'); var color = providerField(provider, 'button_color'); var textColor = providerField(provider, 'button_text_color'); var button = root.querySelector('[data-affilun-preview-provider="' + provider + '"]'); var isEnabled = enabled ? !!enabled.checked : false; var colorValue = color && validHexColor(color.value) ? color.value : providers[provider].defaultColor; var textColorValue = textColor && textColor.value ? textColor.value : 'auto'; card.style.setProperty('--affilun-color-' + providers[provider].cssToken, colorValue); card.style.setProperty('--affilun-color-' + providers[provider].cssToken + '-text', resolveButtonTextColor(colorValue, textColorValue)); if (button) { button.hidden = !isEnabled; } if (isEnabled) { visible += 1; } }); var empty = root.querySelector('[data-affilun-preview-no-providers]'); if (empty) { empty.hidden = visible > 0; } } function refreshPreview() { var template = allowedValue(controls.template, templates, 'single-default-card'); var design = allowedValue(controls.design, designs, 'default'); var colorScheme = allowedValue(controls.colorScheme, colorSchemes, 'light'); var layout = allowedValue(controls.layout, layouts, 'wrap'); var shape = allowedValue(controls.shape, shapes, 'rounded'); var classes = ['affilun-card', 'affilun-card-' + template, 'affilun-provider-amazon']; if (design !== 'default') { classes.push('affilun-design-' + design); } classes.push('affilun-color-scheme-' + colorScheme); if (layout !== 'wrap') { classes.push('affilun-buttons-' + layout); } if (shape !== 'rounded') { classes.push('affilun-button-shape-' + shape); } card.className = classes.join(' '); root.setAttribute('data-affilun-preview-template', template); setText('[data-affilun-preview-template-label]', templates[template] + ' (' + template + ')'); setText('[data-affilun-preview-design-label]', designs[design] + ' (' + design + ')'); setText('[data-affilun-preview-color-scheme-label]', colorSchemes[colorScheme] + ' (' + colorScheme + ')'); setText('[data-affilun-preview-button-label]', layouts[layout] + ' / ' + shapes[shape]); refreshProviders(); } toArray([controls.template, controls.design, controls.colorScheme, controls.layout, controls.shape, controls.tone]).forEach(function (control) { if (!control) { return; } control.addEventListener('change', refreshPreview); control.addEventListener('input', refreshPreview); }); Object.keys(providers).forEach(function (provider) { var enabled = providerField(provider, 'enabled'); var color = providerField(provider, 'button_color'); var textColor = providerField(provider, 'button_text_color'); toArray([enabled, color, textColor]).forEach(function (control) { if (!control) { return; } control.addEventListener('change', refreshPreview); control.addEventListener('input', refreshPreview); }); }); refreshPreview(); } function initCloudWizard() { var root = document.querySelector('[data-affilun-cloud-onboarding]'); var startButton = document.getElementById('affilun-cloud-start-button'); var accountConnectButton = document.getElementById('affilun-cloud-account-connect-button'); var startRow = document.getElementById('affilun-cloud-start-row'); var wizard = document.getElementById('affilun-cloud-wizard'); var noticeMessage = document.getElementById('affilun-cloud-onboarding-message'); var planSelect = document.getElementById('affilun-cloud-plan-select'); var featureSummary = document.getElementById('affilun-cloud-feature-summary'); var priceSummary = document.getElementById('affilun-cloud-price-summary'); var disclaimer = document.getElementById('affilun-cloud-disclaimer'); var freeTrialSummary = document.getElementById('affilun-cloud-free-trial-summary'); var purchaseButton = document.getElementById('affilun-cloud-purchase-button'); var status = document.getElementById('affilun-cloud-wizard-status'); if (!root || !wizard) { return; } if (root.getAttribute('data-affilun-cloud-configured') !== '1' && !startButton) { return; } var freeTrialCopy = 'Free は接続維持用です。Cloud AI、AIスキャン、記事下書きは有料またはトライアル中のプランで利用できます。'; var planCatalog = { personal: { features: ['1サイトまで', 'AIキーワード抽出', 'AIスキャン機能', 'AI記事下書き生成', 'キャンペーン提案', 'リンクヘルスチェック', '利用状況の確認'], prices: { monthly: { display: 'Stripe Checkout で確定' }, yearly: { display: 'Stripe Checkout で確定' } }, disclaimer: '購入前に金額、契約期間、提供機能、解約条件を確認してください。' }, growth: { features: ['5サイトまで', 'Personal の全機能', '分析レコメンド', '分析イベント同期', 'スケジュール型リンクヘルス'], prices: { monthly: { display: 'Stripe Checkout で確定' }, yearly: { display: 'Stripe Checkout で確定' } }, disclaimer: '購入前に金額、契約期間、提供機能、解約条件を確認してください。' }, agency: { features: ['サイト数制限なし', 'Growth の全機能', 'より高い利用枠', '複数サイト管理'], prices: { monthly: { display: 'Stripe Checkout で確定' }, yearly: { display: 'Stripe Checkout で確定' } }, disclaimer: '購入前に金額、契約期間、提供機能、解約条件を確認してください。' } }; var plansLoaded = false; function siteLimitFeature(siteLimit) { if (siteLimit === null) { return 'サイト数制限なし'; } if (siteLimit === 1) { return '1サイトまで'; } if (typeof siteLimit === 'number' && siteLimit > 1) { return siteLimit + 'サイトまで'; } return ''; } function hideOnboardingEntryPoint() { if (startRow) { startRow.hidden = true; } if (startButton) { startButton.hidden = true; startButton.setAttribute('aria-expanded', 'false'); } if (wizard) { wizard.hidden = true; } } function updateCloudHiddenFields(cloud) { var enabled = document.getElementById('affilun-cloud-enabled'); var siteId = document.getElementById('affilun-cloud-site-id'); var plan = document.getElementById('affilun-cloud-plan'); if (enabled) { enabled.value = '1'; } if (siteId) { siteId.value = cloud && cloud.site_id ? cloud.site_id : ''; } if (plan) { plan.value = cloud && cloud.plan ? cloud.plan : ''; } } function markCloudConfigured(cloud) { var planLabel = cloud && cloud.plan ? cloud.plan : '不明'; root.setAttribute('data-affilun-cloud-configured', '1'); updateCloudHiddenFields(cloud || {}); if (noticeMessage) { noticeMessage.textContent = 'affilun Cloud は設定済みです。現在のプラン: ' + planLabel; } if (status) { status.textContent = ''; } if (purchaseButton) { purchaseButton.disabled = true; } hideOnboardingEntryPoint(); } function markRemoteCloudAccess(data) { var planLabel = data && data.plan ? data.plan : '不明'; root.setAttribute('data-affilun-cloud-remote-access', '1'); if (noticeMessage) { noticeMessage.textContent = 'affilun Cloud の契約は確認済みです。プラグインから利用するには、メール認証してCloudアカウントに接続してください。現在のプラン: ' + planLabel; } if (purchaseButton) { purchaseButton.textContent = 'メール認証して既存契約に接続'; } if (freeTrialSummary) { freeTrialSummary.textContent = 'このサイトは既にCloud側で契約済みです。新規契約ではなく、メール認証後にCloudアカウントへログインして接続を完了してください。'; } } function clearCheckoutParams() { if (!window.history || !window.history.replaceState) { return; } var target = new URL(window.location.href); target.searchParams.delete('affilun_cloud_checkout'); target.searchParams.delete('session_id'); target.searchParams.delete('affilun_cloud_connection_token'); target.searchParams.delete('affilun_cloud_connection_state'); window.history.replaceState(null, '', target.toString()); } function cloudAccountBaseUrl() { var endpoint = document.getElementById('affilun-cloud-endpoint'); return endpoint && endpoint.value ? endpoint.value.replace(/\/api\/v1\/?$/, '') : 'https://api.affilun.st'; } function accountConnectionRequestData() { var siteUrlInput = document.getElementById('affilun-cloud-site-url'); var siteNameInput = document.getElementById('affilun-cloud-site-name'); var emailInput = document.getElementById('affilun-cloud-email'); var returnUrl = new URL(window.location.href); var state = 'wp_' + Math.random().toString(36).slice(2) + Date.now().toString(36); returnUrl.searchParams.delete('affilun_cloud_connection_token'); returnUrl.searchParams.delete('affilun_cloud_connection_state'); returnUrl.searchParams.set('affilun_cloud_connection_state', state); window.sessionStorage.setItem('affilun_cloud_connection_state', state); return { site_url: siteUrlInput && siteUrlInput.value ? siteUrlInput.value : window.location.origin, site_name: siteNameInput && siteNameInput.value ? siteNameInput.value : (document.title || window.location.hostname || 'affilun site'), email: emailInput && emailInput.value ? emailInput.value.trim() : '', return_url: returnUrl.toString(), state: state }; } function accountConnectUrl(data) { var target = new URL('/account/connect/', cloudAccountBaseUrl()); target.searchParams.set('site_url', data.site_url); target.searchParams.set('site_name', data.site_name); if (data.email) { target.searchParams.set('email', data.email); } target.searchParams.set('return_url', data.return_url); target.searchParams.set('state', data.state); return target.toString(); } if (root.getAttribute('data-affilun-cloud-configured') === '1') { wizard.hidden = true; } refreshCloudStatusOnLoad(); function selectedBillingPeriod() { var checked = document.querySelector('input[name="affilun_cloud_billing_period"]:checked'); return checked ? checked.value : 'monthly'; } function refreshSummary() { var plan = planSelect ? planSelect.value : 'personal'; var periodKey = selectedBillingPeriod(); var period = periodKey === 'yearly' ? '年単位' : '月単位'; var info = planCatalog[plan] || planCatalog.personal; var price = info.prices && info.prices[periodKey] && info.prices[periodKey].display ? info.prices[periodKey].display : 'Stripe Checkout で確定'; if (featureSummary) { featureSummary.textContent = (info.features || []).join('、'); } if (priceSummary) { priceSummary.textContent = plan + ' / ' + period + ' — ' + price; } if (disclaimer && info.disclaimer) { disclaimer.textContent = info.disclaimer; } if (freeTrialSummary) { freeTrialSummary.textContent = freeTrialCopy; } } function applyPlansResponse(data) { var plans = data && data.plans && Array.isArray(data.plans) ? data.plans : []; if (!plans.length) { return; } planCatalog = {}; if (planSelect) { planSelect.innerHTML = ''; } plans.forEach(function (plan) { if (!plan || !plan.code) { return; } var features = Array.isArray(plan.features) ? plan.features.slice() : []; var siteLimit = siteLimitFeature(plan.site_limit); if (siteLimit) { features.unshift(siteLimit); } planCatalog[plan.code] = { features: features, prices: plan.prices || {}, disclaimer: plan.disclaimer || data.price_disclaimer || '' }; if (planSelect) { var option = document.createElement('option'); option.value = plan.code; option.textContent = plan.name || plan.code; planSelect.appendChild(option); } }); if (data.free_plan && data.free_plan.message) { freeTrialCopy = data.free_plan.message; } if (data.trial_policy && data.trial_policy.message) { freeTrialCopy += ' ' + data.trial_policy.message; } if (planSelect && data.default_plan && planCatalog[data.default_plan]) { planSelect.value = data.default_plan; } } function loadPlans() { var url = root.getAttribute('data-affilun-cloud-plans-url') || ''; var nonce = root.getAttribute('data-affilun-rest-nonce') || ''; if (plansLoaded || !url || !window.fetch) { refreshSummary(); return Promise.resolve(); } plansLoaded = true; if (status) { status.textContent = 'プラン情報を読み込み中…'; } return window.fetch(url, { method: 'GET', credentials: 'same-origin', headers: nonce ? { 'X-WP-Nonce': nonce } : {} }).then(function (response) { return response.json().then(function (json) { return { ok: response.ok, json: json }; }); }).then(function (payload) { if (payload.ok && payload.json && payload.json.data) { applyPlansResponse(payload.json.data); if (status) { status.textContent = ''; } } else if (status) { status.textContent = 'プラン情報を取得できませんでした。表示中の内容で続行できます。'; } refreshSummary(); }).catch(function () { if (status) { status.textContent = 'プラン情報を取得できませんでした。表示中の内容で続行できます。'; } refreshSummary(); }); } function refreshCloudStatusOnLoad() { var url = root.getAttribute('data-affilun-cloud-status-url') || ''; var nonce = root.getAttribute('data-affilun-rest-nonce') || ''; if (root.getAttribute('data-affilun-cloud-configured') === '1' || !url || !window.fetch) { return; } window.fetch(url, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': nonce }, body: JSON.stringify({ site_url: (document.getElementById('affilun-cloud-site-url') || {}).value || window.location.origin }) }).then(function (response) { return response.json().then(function (json) { return { ok: response.ok, json: json }; }); }).then(function (payload) { var data = payload.json && payload.json.data ? payload.json.data : null; if (payload.ok && data && data.cloud_access && data.connection_required) { markRemoteCloudAccess(data); } }).catch(function () {}); } if (accountConnectButton) { accountConnectButton.addEventListener('click', function () { if (root.getAttribute('data-affilun-cloud-configured') === '1') { if (accountConnectButton.tagName.toLowerCase() === 'button') { window.open(new URL('/account/', cloudAccountBaseUrl()).toString(), '_blank', 'noopener,noreferrer'); } return; } window.location.href = accountConnectUrl(accountConnectionRequestData()); }); } if (startButton) { startButton.addEventListener('click', function () { wizard.hidden = false; startButton.setAttribute('aria-expanded', 'true'); loadPlans(); }); } if (planSelect) { planSelect.addEventListener('change', refreshSummary); } toArray(document.querySelectorAll('input[name="affilun_cloud_billing_period"]')).forEach(function (input) { input.addEventListener('change', refreshSummary); }); function withCheckoutParam(url, state) { var target = new URL(url, window.location.href); var sessionPlaceholder = '{CHECKOUT_SESSION_ID}'; target.searchParams.set('affilun_cloud_checkout', state); if (state === 'success') { target.searchParams.set('session_id', sessionPlaceholder); } return target.toString().replace(encodeURIComponent(sessionPlaceholder), sessionPlaceholder); } function exchangeConnectionTokenIfPresent() { var params = new URLSearchParams(window.location.search || ''); var token = params.get('affilun_cloud_connection_token') || ''; var state = params.get('affilun_cloud_connection_state') || ''; var expectedState = window.sessionStorage.getItem('affilun_cloud_connection_state') || ''; var url = root.getAttribute('data-affilun-cloud-connection-url') || ''; var nonce = root.getAttribute('data-affilun-rest-nonce') || ''; if (!token || !state || !url || !window.fetch) { return; } if (expectedState && expectedState !== state) { status.textContent = 'Cloud接続の確認に失敗しました。もう一度お試しください。'; return; } status.textContent = 'Cloudアカウント接続を確認しています…'; window.fetch(url, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': nonce }, body: JSON.stringify({ connection_token: token, state: state }) }).then(function (response) { return response.json().then(function (json) { return { ok: response.ok, json: json }; }); }).then(function (payload) { var cloud = payload.json && payload.json.data && payload.json.data.cloud ? payload.json.data.cloud : null; if (payload.ok && cloud && cloud.configured) { window.sessionStorage.removeItem('affilun_cloud_connection_state'); markCloudConfigured(cloud); clearCheckoutParams(); return; } status.textContent = 'Cloud接続トークンの交換に失敗しました。アカウントページからAPI Keyを再発行してください。'; }).catch(function () { status.textContent = 'Cloud接続確認中に通信エラーが発生しました。'; }); } function provisionAfterCheckout() { var params = new URLSearchParams(window.location.search || ''); var state = params.get('affilun_cloud_checkout') || ''; var sessionId = params.get('session_id') || ''; var url = root.getAttribute('data-affilun-cloud-provision-url') || ''; var nonce = root.getAttribute('data-affilun-rest-nonce') || ''; if (state !== 'success' || !sessionId || !url || !window.fetch) { return; } wizard.hidden = false; startButton.setAttribute('aria-expanded', 'true'); status.textContent = '支払い完了を確認しています…'; window.fetch(url, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': nonce }, body: JSON.stringify({ checkout_session_id: sessionId }) }).then(function (response) { return response.json().then(function (json) { return { ok: response.ok, json: json }; }); }).then(function (payload) { var cloud = payload.json && payload.json.data && payload.json.data.cloud ? payload.json.data.cloud : null; if (payload.ok && cloud && cloud.configured) { markCloudConfigured(cloud); clearCheckoutParams(); return; } status.textContent = '支払いは完了しましたが、API Key の自動設定に失敗しました。しばらく待って再読み込みしてください。'; }).catch(function () { status.textContent = '支払い確認中に通信エラーが発生しました。'; }); } exchangeConnectionTokenIfPresent(); provisionAfterCheckout(); if (purchaseButton && status) { purchaseButton.addEventListener('click', function () { var connectionEmailUrl = root.getAttribute('data-affilun-cloud-connection-email-url') || ''; var nonce = root.getAttribute('data-affilun-rest-nonce') || ''; var requestData = accountConnectionRequestData(); if (!requestData.email) { status.textContent = 'メールアドレスを入力してください。'; return; } status.textContent = '確認メールを送信しています…'; window.fetch(connectionEmailUrl, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': nonce }, body: JSON.stringify(requestData) }).then(function (response) { return response.json().then(function (json) { return { ok: response.ok, json: json }; }); }).then(function (payload) { if (payload.ok) { status.textContent = root.getAttribute('data-affilun-cloud-remote-access') === '1' ? 'メールを送信しました。メールをチェックしてCloudアカウントに接続してください。接続後にこのダッシュボードをリロードしてください。' : 'メールを送信しました。メールをチェックして契約へ進んでください。契約後にこのダッシュボードをリロードしてください。'; return; } status.textContent = '確認メールを送信できませんでした。しばらく待ってもう一度お試しください。'; }).catch(function () { status.textContent = '確認メール送信中に通信エラーが発生しました。'; }); return; var url = root.getAttribute('data-affilun-cloud-checkout-url') || ''; var nonce = root.getAttribute('data-affilun-rest-nonce') || ''; var siteUrlInput = document.getElementById('affilun-cloud-site-url'); var emailInput = document.getElementById('affilun-cloud-email'); var siteNameInput = document.getElementById('affilun-cloud-site-name'); var plan = planSelect ? planSelect.value : 'personal'; var period = selectedBillingPeriod(); refreshSummary(); var email = emailInput && emailInput.value ? emailInput.value.trim() : ''; var siteUrl = siteUrlInput && siteUrlInput.value ? siteUrlInput.value.trim() : window.location.origin; var siteName = siteNameInput && siteNameInput.value ? siteNameInput.value.trim() : (document.title || window.location.hostname || 'affilun site'); if (siteUrlInput && !siteUrlInput.value) { siteUrlInput.value = siteUrl; } if (siteNameInput && !siteNameInput.value) { siteNameInput.value = siteName; } if (!email) { status.textContent = 'メールアドレスを入力してください。'; return; } if (!url || !window.fetch) { status.textContent = 'Checkout API URL が見つかりません。'; return; } purchaseButton.disabled = true; status.textContent = 'Checkout セッションを作成中…'; window.fetch(url, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': nonce }, body: JSON.stringify({ email: email, site_url: siteUrl, site_name: siteName, plan: plan, billing_period: period, success_url: withCheckoutParam(window.location.href, 'success'), cancel_url: withCheckoutParam(window.location.href, 'cancel') }) }).then(function (response) { return response.json().then(function (json) { return { ok: response.ok, json: json }; }); }).then(function (payload) { var checkout = payload.json && payload.json.data ? payload.json.data : {}; if (payload.ok && checkout.url) { status.textContent = 'Stripe Checkout を開きます…'; window.location.href = checkout.url; return; } var message = payload.json && payload.json.error && payload.json.error.message ? payload.json.error.message : 'Checkout セッションを作成できませんでした。入力内容を確認してください。'; status.textContent = message; }).catch(function () { status.textContent = 'Checkout セッション作成中に通信エラーが発生しました。'; }).then(function () { purchaseButton.disabled = false; }); }); } } function initProviderColorReset() { toArray(document.querySelectorAll('[data-affilun-reset-color]')).forEach(function (button) { button.addEventListener('click', function () { var inputId = button.getAttribute('data-affilun-reset-color') || ''; var input = inputId ? document.getElementById(inputId) : null; if (!input) { return; } var defaultColor = input.getAttribute('data-affilun-default-color') || ''; if (defaultColor) { input.value = defaultColor; input.dispatchEvent(new Event('change', { bubbles: true })); } }); }); } initSettingsTabs(); initRenderingPreview(); initCloudWizard(); initProviderColorReset(); }()); JS; wp_add_inline_script( $handle, $script ); } /** * `admin_menu` action callback. * * Drives {@see AdminMenuRegistrar::register()} so every * {@see AdminPageDefinition} appended during register() is * forwarded to WordPress at the canonical registration * moment. */ public function onAdminMenu(): void { if ( $this->resolvedMenuRegistrar === null ) { return; } $this->resolvedMenuRegistrar->register(); } /** * `admin_post_affilun_save_settings` action callback. * * Forwards `$_POST` to the bound {@see SaveSettingsHandler}. * The handler verifies its own capability + nonce, applies * the §6.3 rotating-input rule via the * {@see SaveSettingsUseCase}, and redirects with a * `affilun_status` flash literal — so this seam can stay thin. */ public function onSaveSettingsPost(): void { if ( $this->resolvedSaveHandler === null ) { return; } if ( ! $this->isAuthorizedForPage( AdminCapability::PAGE_TOKEN_SETTINGS ) ) { $this->redirectSettingsPage( 'forbidden' ); return; } $postData = self::currentPostData(); $nonce = self::postString( $postData, SettingsFormToken::NONCE_FIELD ); if ( ! wp_verify_nonce( $nonce, SettingsFormToken::NONCE_ACTION ) ) { $this->redirectSettingsPage( 'invalid_nonce' ); return; } $this->resolvedSaveHandler->handle( $postData ); } /** * `admin_post_affilun_update_revenue_opportunity_status` action callback. */ public function onRevenueOpportunityStatusPost(): void { $postData = self::currentPostData(); $provider = self::postString( $postData, 'affilun_campaign_provider' ); $date = self::postString( $postData, 'affilun_campaign_date' ); $filter = self::postString( $postData, 'affilun_revenue_status' ); $severity = self::postString( $postData, 'affilun_revenue_severity' ); $type = self::postString( $postData, 'affilun_revenue_type' ); $redirect = array( 'affilun_campaign_provider' => self::cleanRevenueRedirectProvider( $provider ), 'affilun_campaign_date' => self::cleanRevenueRedirectDate( $date ), 'affilun_revenue_status' => self::cleanRevenueRedirectStatus( $filter ), 'affilun_revenue_severity' => self::cleanRevenueRedirectSeverity( $severity ), 'affilun_revenue_type' => self::cleanRevenueRedirectType( $type ), ); if ( ! $this->isAuthorizedForPage( AdminCapability::PAGE_TOKEN_CLICKS ) ) { $this->redirectCloudToolsWithArgs( $redirect + array( 'affilun_revenue_status_error' => '1' ) ); return; } if ( $this->resolvedSettingsRepository === null ) { $this->redirectCloudToolsWithArgs( $redirect + array( 'affilun_revenue_status_error' => '1' ) ); return; } $opportunityId = self::postString( $postData, 'affilun_revenue_opportunity_id' ); $status = self::postString( $postData, 'affilun_revenue_opportunity_status' ); $nonce = self::postString( $postData, CloudToolsPageRenderer::REVENUE_STATUS_NONCE_FIELD ); if ( $opportunityId === '' || ! in_array( $status, array( 'new', 'seen', 'applied', 'dismissed' ), true ) ) { $this->redirectCloudToolsWithArgs( $redirect + array( 'affilun_revenue_status_error' => '1' ) ); return; } if ( ! wp_verify_nonce( $nonce, CloudToolsPageRenderer::revenueOpportunityStatusNonceAction( $opportunityId, $status ) ) ) { $this->redirectCloudToolsWithArgs( $redirect + array( 'affilun_revenue_status_error' => '1' ) ); return; } try { $cloud = $this->resolvedSettingsRepository->load()->cloud(); ( new CloudCampaignFeedClient() )->updateRevenueOpportunityStatus( $cloud, $opportunityId, $status, time(), bin2hex( random_bytes( 8 ) ) ); $this->redirectCloudToolsWithArgs( $redirect + array( 'affilun_revenue_status_updated' => '1' ) ); } catch ( Throwable ) { $this->redirectCloudToolsWithArgs( $redirect + array( 'affilun_revenue_status_error' => '1' ) ); } } /** * `admin_post_affilun_cloud_ai_post_scan` action callback. */ public function onCloudAiPostScanPost(): void { $postData = self::currentPostData(); $filter = self::cleanCloudAiScanFilter( self::postString( $postData, 'affilun_ai_scan_filter' ) ); $redirect = array( 'affilun_ai_scan_filter' => $filter ); if ( ! $this->isAuthorizedForPage( AdminCapability::PAGE_TOKEN_CLICKS ) ) { $this->redirectCloudToolsWithArgs( $redirect + array( 'affilun_ai_scan_done' => '1', 'affilun_ai_scan_failed' => '1', ) ); return; } if ( $this->resolvedCloudAiPostScanRunner === null ) { $this->redirectCloudToolsWithArgs( $redirect + array( 'affilun_ai_scan_done' => '1', 'affilun_ai_scan_failed' => '1', ) ); return; } $nonce = self::postString( $postData, CloudToolsPageRenderer::AI_SCAN_NONCE_FIELD ); if ( ! wp_verify_nonce( $nonce, CloudToolsPageRenderer::AI_SCAN_NONCE_ACTION ) ) { $this->redirectCloudToolsWithArgs( $redirect + array( 'affilun_ai_scan_done' => '1', 'affilun_ai_scan_failed' => '1', ) ); return; } $postIds = self::postIntList( $postData, 'affilun_ai_scan_post_ids' ); $job = $this->resolvedCloudAiPostScanRunner->enqueue( $postIds ); $this->scheduleCloudAiPostScan(); $this->redirectCloudToolsWithArgs( $redirect + array( 'affilun_ai_scan_queued' => '1', 'affilun_ai_scan_job_id' => is_scalar( $job['id'] ?? null ) ? (string) $job['id'] : '', 'affilun_ai_scan_selected' => is_numeric( $job['progress_total'] ?? null ) ? (string) max( 0, (int) $job['progress_total'] ) : '0', ) ); } /** * Recover the cron trigger after a process dies during a claimed batch. */ public function onCloudAiPostScanRecovery(): void { if ( $this->resolvedCloudAiPostScanRunner !== null && $this->resolvedCloudAiPostScanRunner->hasPendingJobs() ) { $this->scheduleCloudAiPostScan( 60 ); } } /** * WP-Cron callback for background Cloud AI post scans. */ public function onCloudAiPostScanCron(): void { if ( $this->resolvedCloudAiPostScanRunner === null ) { return; } try { $this->resolvedCloudAiPostScanRunner->tick(); if ( $this->resolvedCloudAiPostScanRunner->hasPendingJobs() ) { $this->scheduleCloudAiPostScan(); } } catch ( Throwable $e ) { if ( function_exists( 'error_log' ) ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Cron failures need an operator-visible breadcrumb. error_log( 'AFFILUN CloudAiPostScanRunner tick failed: ' . $e->getMessage() ); } $this->scheduleCloudAiPostScan( 60 ); } } /** * Schedule the next background Cloud AI scan tick. * * @param int $delaySeconds Delay before running the next tick. */ private function scheduleCloudAiPostScan( int $delaySeconds = 5 ): void { if ( ! function_exists( 'wp_next_scheduled' ) || ! function_exists( 'wp_schedule_single_event' ) ) { return; } if ( wp_next_scheduled( CloudAiPostScanRunner::CRON_HOOK ) ) { return; } wp_schedule_single_event( time() + max( 1, $delaySeconds ), CloudAiPostScanRunner::CRON_HOOK ); } /** * `admin_post_affilun_delete_product` action callback. * * Marks a product as `deleted_pending` rather than physically * removing the row, so existing embeds can still resolve the saved * snapshot/fallback state and operators can see the product id with * a `(削除済み)` suffix on the products list. */ public function onDeleteProductPost(): void { if ( $this->resolvedProductRepository === null ) { return; } if ( ! $this->isAuthorizedForPage( AdminCapability::PAGE_TOKEN_PRODUCTS_LIST ) ) { if ( function_exists( 'wp_die' ) ) { wp_die( 'Insufficient capabilities.', 'affilun', array( 'response' => 403 ) ); } return; } $postData = self::currentPostData(); $productId = self::postInt( $postData, 'product_id' ); $nonce = self::postString( $postData, '_affilun_delete_product_nonce' ); if ( $productId === null ) { $this->redirectProductsList( 'invalid_nonce' ); return; } if ( ! wp_verify_nonce( $nonce, 'affilun_delete_product_' . $productId ) ) { $this->redirectProductsList( 'invalid_nonce' ); return; } try { $this->trashProductById( $productId ); $this->redirectProductsList( 'deleted' ); } catch ( Throwable ) { $this->redirectProductsList( 'delete_error' ); } } /** * Move one product to the logical trash. * * @param int $productId Product id. */ private function trashProductById( int $productId ): void { if ( $this->resolvedProductRepository === null ) { return; } $record = $this->resolvedProductRepository->findById( $productId ); if ( $record === null || $record->lifecycleState() === ProductListQuery::LIFECYCLE_STATE_DELETED_PENDING ) { return; } $now = new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) ); $row = $record->toArray(); $row['lifecycle_state'] = ProductListQuery::LIFECYCLE_STATE_DELETED_PENDING; $row['archived_at'] = $now; $row['updated_at'] = $now; $this->resolvedProductRepository->save( ProductRecord::fromArray( $row ) ); } /** * Permanently delete one product row. * * @param int $productId Product id. */ private function deleteProductById( int $productId ): void { if ( $this->resolvedProductRepository === null ) { return; } $this->resolvedProductRepository->delete( $productId ); } /** * `admin_post_affilun_bulk_products_action` action callback. */ public function onBulkProductsActionPost(): void { if ( $this->resolvedProductRepository === null ) { $this->redirectProductsList( 'bulk_error' ); return; } if ( ! $this->isAuthorizedForPage( AdminCapability::PAGE_TOKEN_PRODUCTS_LIST ) ) { $this->redirectProductsList( 'forbidden' ); return; } $postData = self::currentPostData(); $nonce = self::postString( $postData, '_affilun_bulk_products_action_nonce' ); if ( ! wp_verify_nonce( $nonce, 'affilun_bulk_products_action' ) ) { $this->redirectProductsList( 'invalid_nonce' ); return; } $singleDeleteId = self::postInt( $postData, 'single_delete_id' ); if ( $singleDeleteId !== null ) { try { $this->deleteProductById( $singleDeleteId ); } catch ( Throwable ) { $this->redirectProductsList( 'delete_error' ); return; } $this->redirectProductsList( 'purged' ); return; } $singleTrashId = self::postInt( $postData, 'single_trash_id' ); if ( $singleTrashId !== null ) { try { $this->trashProductById( $singleTrashId ); } catch ( Throwable ) { $this->redirectProductsList( 'delete_error' ); return; } $this->redirectProductsList( 'deleted' ); return; } $bulkAction = self::postString( $postData, 'affilun_bulk_action_select' ); if ( $bulkAction === '' ) { $bulkAction = self::postString( $postData, 'affilun_bulk_action_select2' ); } if ( $bulkAction === 'edit' ) { $this->redirectProductsListWithArgs( array( 'affilun_bulk_action' => 'edit' ) ); return; } if ( $bulkAction !== 'trash' ) { $this->redirectProductsList( 'invalid_bulk' ); return; } $productIds = self::postIntList( $postData, 'product_ids' ); if ( $productIds === array() ) { $this->redirectProductsList( 'bulk_trash_none' ); return; } try { foreach ( $productIds as $productId ) { $this->trashProductById( $productId ); } } catch ( Throwable ) { $this->redirectProductsList( 'delete_error' ); return; } $this->redirectProductsList( 'deleted' ); } /** * `admin_post_affilun_bulk_update_products` action callback. */ public function onBulkUpdateProductsPost(): void { if ( $this->resolvedProductRepository === null || $this->resolvedProductMetaRepository === null ) { $this->redirectProductsList( 'bulk_error' ); return; } if ( ! $this->isAuthorizedForPage( AdminCapability::PAGE_TOKEN_PRODUCTS_LIST ) ) { $this->redirectProductsList( 'forbidden' ); return; } $postData = self::currentPostData(); $nonce = self::postString( $postData, '_affilun_bulk_update_products_nonce' ); $filters = self::bulkFiltersFromPost( $postData ); $changes = self::bulkChangesFromPost( $postData ); if ( ! self::verifyBulkUpdateProductsNonce( $nonce, $filters, $changes ) ) { $this->redirectProductsList( 'invalid_nonce' ); return; } try { $result = ( new BulkUpdateProductsUseCase( $this->resolvedProductRepository, new ProductManualTaxonomy( $this->resolvedProductMetaRepository ) ) )->apply( $filters, $changes ); } catch ( Throwable ) { $this->redirectProductsList( 'bulk_error' ); return; } $this->redirectProductsList( $result->updatedCount() > 0 ? 'bulk_updated' : 'bulk_none' ); } /** * Extract bulk filters from POST data. * * @param array $postData POST data. * * @return array */ private static function bulkFiltersFromPost( array $postData ): array { return array( 'provider' => self::postString( $postData, 'affilun_bulk_provider' ), 'lifecycle_state' => self::postString( $postData, 'affilun_bulk_lifecycle_filter' ), ); } /** * Extract bulk changes from POST data. * * @param array $postData POST data. * * @return array */ private static function bulkChangesFromPost( array $postData ): array { $changes = array(); $lifecycle = self::postString( $postData, 'affilun_bulk_lifecycle_change' ); if ( $lifecycle !== '' ) { $changes['lifecycle_state'] = $lifecycle; } return $changes; } /** * `admin_post_affilun_create_article_draft` action callback. */ public function onCreateArticleDraftPost(): void { if ( ! $this->isAuthorizedForPage( AdminCapability::PAGE_TOKEN_PRODUCTS_LIST ) ) { $this->redirectProductsList( 'forbidden' ); return; } if ( ! function_exists( 'wp_insert_post' ) ) { $this->redirectProductsList( 'article_draft_error' ); return; } $postData = self::currentPostData(); $productId = self::postInt( $postData, 'product_id' ); $nonce = self::postString( $postData, '_affilun_create_article_draft_nonce' ); if ( $productId === null ) { $this->redirectProductsList( 'invalid_nonce' ); return; } if ( ! wp_verify_nonce( $nonce, ProductsListPageRenderer::createArticleDraftNonceAction( $productId ) ) ) { $this->redirectProductsList( 'invalid_nonce' ); return; } if ( ! $this->isArticleDraftCloudContracted() || ! $this->articleDraftProductExists( $productId ) ) { $this->redirectProductsList( 'article_draft_error' ); return; } $title = trim( self::postString( $postData, 'article_title' ) ); $excerpt = trim( self::postString( $postData, 'article_excerpt' ) ); $body = self::postTextareaString( $postData, 'article_body' ); if ( $title === '' || trim( $body ) === '' ) { $this->redirectProductsList( 'article_draft_error' ); return; } $postId = self::insertArticleDraftPost( $productId, $title, $excerpt, $body, array( 'source' => self::postString( $postData, 'article_source' ), 'model' => self::postString( $postData, 'article_model' ), 'provider' => self::postString( $postData, 'article_provider' ), ) ); if ( $postId === null ) { $this->redirectProductsList( 'article_draft_error' ); return; } $this->redirectToPostEditor( $postId ); } /** * `admin_post_affilun_generate_article_draft` action callback. */ public function onGenerateArticleDraftPost(): void { if ( ! $this->isAuthorizedForPage( AdminCapability::PAGE_TOKEN_PRODUCTS_LIST ) ) { $this->redirectProductsList( 'forbidden' ); return; } if ( ! function_exists( 'wp_insert_post' ) ) { $this->redirectProductsList( 'article_draft_error' ); return; } if ( $this->resolvedProductRepository === null || $this->resolvedSettingsRepository === null ) { $this->redirectProductsList( 'article_draft_error' ); return; } $postData = self::currentPostData(); $productId = self::postInt( $postData, 'product_id' ); $nonce = self::postString( $postData, '_affilun_generate_article_draft_nonce' ); if ( $productId === null ) { $this->redirectProductsList( 'invalid_nonce' ); return; } if ( ! wp_verify_nonce( $nonce, ProductsListPageRenderer::createArticleDraftNonceAction( $productId ) ) ) { $this->redirectProductsList( 'invalid_nonce' ); return; } try { $product = $this->resolvedProductRepository->findById( $productId ); $cloud = $this->resolvedSettingsRepository->load()->cloud(); } catch ( Throwable $unused ) { unset( $unused ); $this->redirectProductsList( 'article_draft_error' ); return; } if ( $product === null || ! $cloud->isConfigured() ) { $this->redirectProductsList( 'article_draft_error' ); return; } try { $options = self::defaultArticleDraftOptions( $product, $cloud ); $draft = ( new CloudProductArticleDraftClient() )->generate( $cloud, self::articleDraftCloudPayload( $product, $options ), time(), bin2hex( random_bytes( 12 ) ) ); } catch ( AiSuggestionException $error ) { $args = array( 'affilun_status' => $error->errorCode() ); $retryAfter = $error->details()['retry_after_seconds'] ?? null; if ( is_int( $retryAfter ) ) { $args['affilun_retry_after'] = (string) $retryAfter; } $this->redirectProductsListWithArgs( $args ); return; } catch ( Throwable $e ) { if ( function_exists( 'error_log' ) ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Admin-post failure surface; intentional. error_log( 'AFFILUN article draft generation failed: ' . $e->getMessage() ); } $this->redirectProductsList( 'article_draft_error' ); return; } $title = trim( is_scalar( $draft['title'] ?? null ) ? (string) $draft['title'] : '' ); $excerpt = trim( is_scalar( $draft['excerpt'] ?? null ) ? (string) $draft['excerpt'] : '' ); $body = is_scalar( $draft['body_markdown'] ?? null ) ? (string) $draft['body_markdown'] : ''; if ( $title === '' || trim( $body ) === '' ) { $this->redirectProductsList( 'article_draft_error' ); return; } $postId = self::insertArticleDraftPost( $productId, $title, $excerpt, $body, CloudProductArticleDraftClient::provenance( $draft ) ); if ( $postId === null ) { $this->redirectProductsList( 'article_draft_error' ); return; } $this->redirectToPostEditor( $postId ); } /** * Insert a WordPress draft post with generated article body and product card. * * @param int $productId Product id. * @param string $title Post title. * @param string $excerpt Post excerpt. * @param string $body Generated Markdown body. * @param array $provenance Generator metadata. */ private static function insertArticleDraftPost( int $productId, string $title, string $excerpt, string $body, array $provenance = array() ): ?int { $content = self::articleMarkdownToPostContent( $body, $productId ); $postId = wp_insert_post( array( 'post_title' => sanitize_text_field( $title ), 'post_excerpt' => sanitize_text_field( $excerpt ), 'post_content' => $content, 'post_status' => 'draft', 'post_type' => 'post', ), true ); if ( function_exists( 'is_wp_error' ) && is_wp_error( $postId ) ) { return null; } if ( ! is_numeric( $postId ) || (int) $postId <= 0 ) { return null; } if ( function_exists( 'update_post_meta' ) ) { $source = $provenance['source'] ?? ''; if ( ! in_array( $source, array( 'affilun_cloud', 'plugin_fallback', 'cloud_fallback', 'cloud_heuristic' ), true ) ) { $source = 'affilun_cloud'; } update_post_meta( (int) $postId, '_affilun_article_draft_product_id', $productId ); update_post_meta( (int) $postId, '_affilun_article_draft_source', $source ); update_post_meta( (int) $postId, '_affilun_article_draft_model', sanitize_text_field( $provenance['model'] ?? '' ) ); update_post_meta( (int) $postId, '_affilun_article_draft_provider', sanitize_text_field( $provenance['provider'] ?? '' ) ); } return (int) $postId; } /** * Redirect to the post editor when available, otherwise product list. * * @param int $postId Post id. */ private function redirectToPostEditor( int $postId ): void { $url = function_exists( 'get_edit_post_link' ) ? get_edit_post_link( $postId, 'raw' ) : ''; if ( is_string( $url ) && $url !== '' && function_exists( 'wp_safe_redirect' ) ) { wp_safe_redirect( $url ); exit; } $this->redirectProductsList( 'article_draft_created' ); } /** * Default Cloud article-draft options from product and settings. * * @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, ); } /** * Cloud article-draft payload. * * @param ProductRecord $product Product record. * @param array $options Generation options. * * @return array */ private static function articleDraftCloudPayload( 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'], ); } /** * `admin_post_affilun_export_products_csv` action callback. */ public function onExportProductsCsvPost(): void { if ( $this->resolvedProductRepository === null || $this->resolvedProductMetaRepository === null ) { $this->redirectProductsList( 'csv_import_error' ); return; } if ( ! $this->isAuthorizedForPage( AdminCapability::PAGE_TOKEN_PRODUCTS_LIST ) ) { $this->redirectProductsList( 'forbidden' ); return; } $postData = self::currentPostData(); $nonce = self::postString( $postData, self::CSV_EXPORT_NONCE_FIELD ); if ( ! wp_verify_nonce( $nonce, self::CSV_EXPORT_ACTION ) ) { $this->redirectProductsList( 'invalid_nonce' ); return; } try { $csv = ( new ProductCsvExporter( $this->resolvedProductRepository, new ProductManualTaxonomy( $this->resolvedProductMetaRepository ) ) )->exportAll(); } catch ( Throwable ) { $this->redirectProductsList( 'csv_import_error' ); return; } if ( ! headers_sent() ) { header( 'Content-Type: text/csv; charset=UTF-8' ); header( 'Content-Disposition: attachment; filename="affilun-products-' . gmdate( 'Ymd-His' ) . '.csv"' ); header( 'X-Content-Type-Options: nosniff' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Streaming a generated CSV download to the response body. $stream = fopen( 'php://output', 'wb' ); if ( is_resource( $stream ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite, WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_fwrite -- CSV cells are escaped by ProductCsvExporter and streamed as the response body. fwrite( $stream, "\xEF\xBB\xBF" . $csv ); } exit; } /** * `admin_post_affilun_import_products_csv` action callback. */ public function onImportProductsCsvPost(): void { if ( $this->resolvedProductRepository === null || $this->resolvedProductMetaRepository === null ) { $this->redirectProductsList( 'csv_import_error' ); return; } if ( ! $this->isAuthorizedForPage( AdminCapability::PAGE_TOKEN_PRODUCTS_LIST ) ) { $this->redirectProductsList( 'forbidden' ); return; } $postData = self::currentPostData(); $payload = self::postRawString( $postData, 'affilun_product_csv_payload' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Decodes previewed CSV from a hidden form field, not executable code. $csv = base64_decode( $payload, true ); if ( ! is_string( $csv ) ) { $this->redirectProductsList( 'csv_import_error' ); return; } $nonce = self::postString( $postData, self::CSV_IMPORT_NONCE_FIELD ); if ( ! wp_verify_nonce( $nonce, self::csvImportNonceAction( $csv ) ) ) { $this->redirectProductsList( 'invalid_nonce' ); return; } try { $result = ( new ProductCsvImportUseCase( $this->resolvedProductRepository, new ProductManualTaxonomy( $this->resolvedProductMetaRepository ) ) )->apply( $csv ); } catch ( Throwable ) { $this->redirectProductsList( 'csv_import_error' ); return; } $this->redirectProductsList( $result->mutatedCount() > 0 ? 'csv_imported' : 'csv_import_none' ); } /** * 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 ); } /** * `admin_post_affilun_resync_amazon_products` action callback. * * Enqueues urgent `sync_product` jobs for every active stored * Amazon product so existing link-only snapshots can be refreshed * through the currently configured Amazon provider (Creators API * after Eligibility is granted). */ public function onAmazonResyncPost(): void { if ( $this->resolvedProductRepository === null || $this->resolvedSyncJobRepository === null ) { $this->redirectSettingsPage( 'amazon_resync_error' ); return; } if ( ! $this->isAuthorizedForPage( AdminCapability::PAGE_TOKEN_SETTINGS ) ) { $this->redirectSettingsPage( 'forbidden' ); return; } $postData = self::currentPostData(); $nonce = self::postString( $postData, SettingsFormToken::AMAZON_RESYNC_NONCE_FIELD ); if ( ! wp_verify_nonce( $nonce, SettingsFormToken::AMAZON_RESYNC_NONCE_ACTION ) ) { $this->redirectSettingsPage( 'invalid_nonce' ); return; } try { $queued = $this->enqueueAmazonProductSyncJobs(); } catch ( Throwable ) { $this->redirectSettingsPage( 'amazon_resync_error' ); return; } $this->redirectSettingsPage( $queued > 0 ? 'amazon_resync_queued' : 'amazon_resync_none' ); } /** * `admin_post_affilun_import_external_affiliate_data` action callback. */ public function onImportExternalAffiliateDataPost(): void { if ( ! $this->isAuthorizedForPage( AdminCapability::PAGE_TOKEN_SETTINGS ) ) { $this->redirectSettingsPage( 'forbidden' ); return; } $postData = self::currentPostData(); $nonce = self::postString( $postData, SettingsPageRenderer::EXTERNAL_AFFILIATE_IMPORT_NONCE_FIELD ); if ( ! wp_verify_nonce( $nonce, SettingsPageRenderer::EXTERNAL_AFFILIATE_IMPORT_NONCE_ACTION ) ) { $this->redirectSettingsPage( 'invalid_nonce' ); return; } if ( $this->resolvedExternalAffiliateImporter === null ) { $this->redirectSettingsPage( 'external_affiliate_import_error' ); return; } try { $result = $this->resolvedExternalAffiliateImporter->import(); if ( $result->mutatedCount() > 0 && $this->resolvedOptionStore !== null ) { $this->resolvedOptionStore->updateNonAutoload( SettingsPageRenderer::EXTERNAL_AFFILIATE_NOTICE_HIDDEN_OPTION, true ); } $this->redirectSettingsPage( $result->mutatedCount() > 0 ? 'external_affiliate_imported' : 'external_affiliate_import_none' ); } catch ( Throwable ) { $this->redirectSettingsPage( 'external_affiliate_import_error' ); } } /** * `admin_post_affilun_dismiss_external_affiliate_data_notice` action callback. */ public function onDismissExternalAffiliateDataPost(): void { if ( ! $this->isAuthorizedForPage( AdminCapability::PAGE_TOKEN_SETTINGS ) ) { $this->redirectSettingsPage( 'forbidden' ); return; } $postData = self::currentPostData(); $nonce = self::postString( $postData, SettingsPageRenderer::EXTERNAL_AFFILIATE_DISMISS_NONCE_FIELD ); if ( ! wp_verify_nonce( $nonce, SettingsPageRenderer::EXTERNAL_AFFILIATE_DISMISS_NONCE_ACTION ) ) { $this->redirectSettingsPage( 'invalid_nonce' ); return; } if ( $this->resolvedOptionStore === null ) { $this->redirectSettingsPage( 'external_affiliate_import_error' ); return; } $this->resolvedOptionStore->updateNonAutoload( SettingsPageRenderer::EXTERNAL_AFFILIATE_NOTICE_HIDDEN_OPTION, true ); $this->redirectSettingsPage( 'external_affiliate_notice_hidden' ); } /** * Enqueue sync jobs for every active Amazon product row. */ private function enqueueAmazonProductSyncJobs(): int { if ( $this->resolvedProductRepository === null || $this->resolvedSyncJobRepository === null ) { return 0; } $dispatcher = new SyncDispatcher( $this->resolvedSyncJobRepository, new SystemClock(), RetryPolicy::forJobType( RetryPolicy::JOB_SYNC_PRODUCT ) ); $priority = SyncPriority::urgent(); $queued = 0; $page = 1; do { $result = $this->resolvedProductRepository->findPaginated( new ProductListQuery( $page, 50, 'amazon', null, null, null, ProductListQuery::LIFECYCLE_STATE_ACTIVE, ProductListQuery::SORT_KEY_UPDATED_AT, ProductListQuery::SORT_ORDER_ASC ) ); foreach ( $result->items() as $record ) { $id = $record->id(); if ( $id !== null ) { $dispatcher->enqueueProductSync( $id, $priority ); ++$queued; } } ++$page; } while ( $page <= $result->totalPages() ); return $queued; } /** * Verify the saved-products bulk update nonce against the preview payload. * * @param string $nonce Posted nonce value. * @param array $filters Filters. * @param array $changes Changes. */ private static function verifyBulkUpdateProductsNonce( string $nonce, array $filters, array $changes ): bool { if ( $nonce === '' || ! function_exists( 'wp_verify_nonce' ) ) { return false; } return (bool) call_user_func( 'wp_verify_nonce', $nonce, self::bulkUpdateProductsNonceAction( $filters, $changes ) ); } /** * Build the nonce action for a previewed bulk payload. * * @param array $filters Filters. * @param array $changes Changes. */ private static function bulkUpdateProductsNonceAction( array $filters, array $changes ): string { $payload = array( 'provider' => $filters['provider'] ?? '', 'lifecycle_filter' => $filters['lifecycle_state'] ?? '', 'category_filter' => $filters['category'] ?? '', 'lifecycle_change' => is_scalar( $changes['lifecycle_state'] ?? null ) ? (string) $changes['lifecycle_state'] : '', 'categories' => is_scalar( $changes['categories'] ?? null ) ? (string) $changes['categories'] : '', 'tags' => is_scalar( $changes['tags'] ?? null ) ? (string) $changes['tags'] : '', 'update_categories' => array_key_exists( 'categories', $changes ) ? '1' : '0', 'update_tags' => array_key_exists( 'tags', $changes ) ? '1' : '0', ); $encoded = wp_json_encode( $payload ); return 'affilun_bulk_update_products_' . hash( 'sha256', is_string( $encoded ) ? $encoded : '' ); } /** * Redirect back to the Settings page with a status token. * * @param string $status Status token for the `affilun_status` query parameter. */ private function redirectSettingsPage( string $status ): void { $url = self::resolveAdminPageUrl( 'affilun-settings' ); if ( function_exists( 'add_query_arg' ) ) { $url = (string) add_query_arg( array( 'affilun_status' => $status ), $url ); } else { $url .= ( str_contains( $url, '?' ) ? '&' : '?' ) . 'affilun_status=' . rawurlencode( $status ); } if ( function_exists( 'wp_safe_redirect' ) ) { call_user_func( 'wp_safe_redirect', $url ); exit; } } /** * Build the infrastructure callback that supplies post rows to the import use case. * * @return callable(): list */ private static function buildExternalAffiliatePostRowsProvider(): callable { return static function (): array { global $wpdb; /** @var \wpdb|null $wpdb */ if ( ! $wpdb instanceof \wpdb || $wpdb->posts === '' ) { return array(); } $likeAsin = '%' . $wpdb->esc_like( '"asin"' ) . '%'; $likeUrl = '%' . $wpdb->esc_like( 'amazon_affi_url' ) . '%'; $sql = $wpdb->prepare( "SELECT ID, post_title, post_content FROM {$wpdb->posts} WHERE post_status NOT IN ('trash', 'auto-draft', 'inherit') AND (post_content LIKE %s OR post_content LIKE %s) ORDER BY ID ASC LIMIT 500", $likeAsin, $likeUrl ); if ( ! is_string( $sql ) || $sql === '' ) { return array(); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- Read-only bounded detection query prepared above; no reusable cache key because settings page must reflect current post content. $rows = $wpdb->get_results( $sql, ARRAY_A ); if ( ! is_array( $rows ) ) { return array(); } $normalized = array(); foreach ( $rows as $row ) { if ( ! is_array( $row ) ) { continue; } $normalized[] = array( 'id' => $row['ID'] ?? '', 'title' => is_string( $row['post_title'] ?? null ) ? $row['post_title'] : '', 'content' => is_string( $row['post_content'] ?? null ) ? $row['post_content'] : '', ); } return $normalized; }; } /** * Whether affilun Cloud article-draft features are available. */ private function isArticleDraftCloudContracted(): bool { if ( $this->resolvedSettingsRepository === null ) { return false; } try { $cloud = $this->resolvedSettingsRepository->load()->cloud(); } catch ( Throwable ) { return false; } return $cloud->isConfigured() && in_array( strtolower( $cloud->plan() ), array( 'personal', 'growth', 'agency' ), true ); } /** * Whether the product targeted by article-draft creation exists. * * @param int $productId Product id. */ private function articleDraftProductExists( int $productId ): bool { if ( $this->resolvedProductRepository === null ) { return false; } try { return $this->resolvedProductRepository->findById( $productId ) !== null; } catch ( Throwable ) { return false; } } /** * Convert generated Markdown into WordPress block content. * * The Cloud draft endpoint intentionally returns plain Markdown * so the WordPress plugin can make the final block-shape decision * locally. This converter keeps the supported subset conservative: * headings, paragraphs, ordered/unordered lists, quotes, separators, * and a few safe inline marks are translated to core blocks. Unknown * Markdown remains escaped text instead of becoming raw HTML. * * @param string $markdown Generated Markdown. * @param int $productId Product id. */ private static function articleMarkdownToPostContent( string $markdown, int $productId ): string { $lines = preg_split( '/\R/u', $markdown ); $blocks = array(); if ( ! is_array( $lines ) ) { $lines = array( $markdown ); } /** @var list $listItems */ $listItems = array(); /** @var 'ordered'|'unordered'|null $listType */ $listType = null; /** @var list $paragraph */ $paragraph = array(); /** @var list $quote */ $quote = array(); $flushParagraph = static function () use ( &$blocks, &$paragraph ): void { if ( $paragraph === array() ) { return; } $text = implode( ' ', $paragraph ); $blocks[] = '

' . self::articleInlineMarkdownToHtml( $text ) . '

'; $paragraph = array(); }; $flushQuote = static function () use ( &$blocks, &$quote ): void { if ( $quote === array() ) { return; } $text = implode( ' ', $quote ); $blocks[] = '

' . self::articleInlineMarkdownToHtml( $text ) . '

'; $quote = array(); }; $flushList = static function () use ( &$blocks, &$listItems, &$listType ): void { if ( $listItems === array() ) { return; } $ordered = $listType === 'ordered'; $html = $ordered ? '
    ' : '
      '; foreach ( $listItems as $item ) { $html .= '
    • ' . self::articleInlineMarkdownToHtml( $item ) . '
    • '; } $html .= $ordered ? '
' : ''; $blocks[] = $html; $listItems = array(); $listType = null; }; $flushOpenBlocks = static function () use ( $flushParagraph, $flushList, $flushQuote ): void { $flushParagraph(); $flushList(); $flushQuote(); }; foreach ( $lines as $line ) { $line = trim( (string) $line ); if ( $line === '' ) { $flushOpenBlocks(); continue; } if ( preg_match( '/\A(?:[-*_]\s*){3,}\z/u', $line ) === 1 ) { $flushOpenBlocks(); $blocks[] = '
'; continue; } if ( preg_match( '/\A(#{1,6})\s+(.+?)(?:\s+#+)?\z/u', $line, $matches ) === 1 ) { $flushOpenBlocks(); $level = strlen( (string) $matches[1] ); $level = min( 6, max( 2, $level ) ); $heading = trim( (string) $matches[2] ); $blocks[] = '' . self::articleInlineMarkdownToHtml( $heading ) . ''; continue; } if ( preg_match( '/\A>\s?(.+)\z/u', $line, $matches ) === 1 ) { $flushParagraph(); $flushList(); $quote[] = trim( (string) $matches[1] ); continue; } if ( preg_match( '/\A([-*+])\s+(.+)\z/u', $line, $matches ) === 1 ) { $flushParagraph(); $flushQuote(); if ( $listType !== null && $listType !== 'unordered' ) { $flushList(); } $listType = 'unordered'; $listItems[] = trim( (string) $matches[2] ); continue; } if ( preg_match( '/\A\d+[.)]\s+(.+)\z/u', $line, $matches ) === 1 ) { $flushParagraph(); $flushQuote(); if ( $listType !== null && $listType !== 'ordered' ) { $flushList(); } $listType = 'ordered'; $listItems[] = trim( (string) $matches[1] ); continue; } $flushList(); $flushQuote(); $paragraph[] = $line; } $flushOpenBlocks(); $blocks[] = ''; return implode( "\n\n", $blocks ); } /** * Convert a safe inline Markdown subset into HTML. * * @param string $text Inline Markdown text. */ private static function articleInlineMarkdownToHtml( string $text ): string { $tokens = array(); $withCode = preg_replace_callback( '/`([^`\r\n]+)`/u', static function ( array $matches ) use ( &$tokens ): string { return self::articleMarkdownToken( $tokens, '' . esc_html( (string) $matches[1] ) . '' ); }, $text ); if ( is_string( $withCode ) ) { $text = $withCode; } $withLinks = preg_replace_callback( '/\[([^\]\r\n]+)\]\((https?:\/\/[^\s)]+)\)/iu', static function ( array $matches ) use ( &$tokens ): string { $label = trim( (string) $matches[1] ); $url = trim( html_entity_decode( (string) $matches[2], ENT_QUOTES | ENT_HTML5, 'UTF-8' ) ); if ( $label === '' || ! self::articleMarkdownIsHttpUrl( $url ) ) { return (string) $matches[0]; } $href = self::articleEscUrl( $url ); if ( $href === '' ) { return (string) $matches[0]; } return self::articleMarkdownToken( $tokens, '' . esc_html( $label ) . '' ); }, $text ); if ( is_string( $withLinks ) ) { $text = $withLinks; } $html = esc_html( $text ); $withStrong = preg_replace( '/\*\*([^*\r\n]+)\*\*/u', '$1', $html ); if ( is_string( $withStrong ) ) { $html = $withStrong; } $withEmphasis = preg_replace( '/(?$1', $html ); if ( is_string( $withEmphasis ) ) { $html = $withEmphasis; } return strtr( $html, $tokens ); } /** * Register an already-sanitized inline HTML token. * * @param array $tokens Token map by reference. * @param string $html Sanitized HTML fragment. */ private static function articleMarkdownToken( array &$tokens, string $html ): string { $index = count( $tokens ); $token = '%%AFLUN_ARTICLE_MD_TOKEN_' . (string) $index . '_' . substr( hash( 'sha256', $html . ':' . (string) $index ), 0, 12 ) . '%%'; $tokens[ $token ] = $html; return $token; } /** * Whether a Markdown link URL is a safe absolute http(s) URL. * * @param string $url Candidate URL. */ private static function articleMarkdownIsHttpUrl( 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 ); } /** * Escape an inline Markdown URL for href output. * * @param string $url Safe absolute http(s) URL. */ private static function articleEscUrl( string $url ): string { if ( function_exists( 'esc_url' ) ) { $escaped = esc_url( $url ); return is_string( $escaped ) ? $escaped : ''; } return htmlspecialchars( $url, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); } /** * Return sanitized POST data for admin request handlers. * * @return array */ private static function currentPostData(): array { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Callers verify the action-specific nonce before mutating state. $raw = $_POST; return self::sanitizeRequestData( $raw ); } /** * Return sanitized GET data for read-only admin rendering. * * @return array */ private static function currentGetData(): array { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only admin routing/filter input, sanitized before use. $raw = $_GET; return self::sanitizeRequestData( $raw ); } /** * Sanitize a GET/POST-like array recursively after unslashing. * * @param array $data Request data. * * @return array */ private static function sanitizeRequestData( array $data ): array { $unslashed = function_exists( 'wp_unslash' ) ? wp_unslash( $data ) : $data; if ( ! is_array( $unslashed ) ) { return array(); } $sanitized = array(); foreach ( $unslashed as $key => $value ) { if ( ! is_string( $key ) ) { continue; } $sanitized[ $key ] = self::sanitizeRequestValue( $key, $value ); } return $sanitized; } /** * Sanitize one request value using the most specific safe context available. * * @param string $key Request key. * @param mixed $value Request value. * * @return mixed */ private static function sanitizeRequestValue( string $key, $value ) { if ( is_array( $value ) ) { $result = array(); foreach ( $value as $childKey => $childValue ) { if ( is_string( $childKey ) ) { $result[ $childKey ] = self::sanitizeRequestValue( $childKey, $childValue ); continue; } if ( is_int( $childKey ) ) { $result[ $childKey ] = self::sanitizeRequestValue( $key, $childValue ); } } return $result; } if ( ! is_scalar( $value ) ) { return ''; } $string = (string) $value; if ( str_contains( $key, 'csv' ) || str_contains( $key, 'body' ) || str_contains( $key, 'review_urls' ) ) { if ( function_exists( 'sanitize_textarea_field' ) ) { return (string) sanitize_textarea_field( $string ); } $string = preg_replace( '/<[^>]*>/', '', $string ) ?? $string; return trim( (string) preg_replace( '/[ \t]+/', ' ', $string ) ); } return self::sanitizeTextField( $string ); } /** * Read a sanitize_key()-style token from request data. * * @param array $requestData Request data. * @param string $key Request key. */ private static function requestKey( array $requestData, string $key ): string { if ( ! array_key_exists( $key, $requestData ) || ! is_scalar( $requestData[ $key ] ) ) { return ''; } $value = (string) $requestData[ $key ]; return function_exists( 'sanitize_key' ) ? (string) sanitize_key( $value ) : (string) preg_replace( '/[^a-z0-9_\-]/i', '', $value ); } /** * Extract a positive integer from POST-like data. * * @param array $postData POST payload. * @param string $key Field key. */ private static function postInt( array $postData, string $key ): ?int { if ( ! array_key_exists( $key, $postData ) || ! is_scalar( $postData[ $key ] ) ) { return null; } $value = trim( (string) $postData[ $key ] ); if ( $value === '' || ! ctype_digit( $value ) ) { return null; } $id = (int) $value; return $id > 0 ? $id : null; } /** * Extract positive integer list from POST-like data. * * @param array $postData POST payload. * @param string $key Field key. * * @return list */ private static function postIntList( array $postData, string $key ): array { if ( ! array_key_exists( $key, $postData ) || ! is_array( $postData[ $key ] ) ) { return array(); } $result = array(); foreach ( $postData[ $key ] as $value ) { if ( ! is_scalar( $value ) ) { continue; } $string = trim( (string) $value ); if ( $string !== '' && ctype_digit( $string ) && (int) $string > 0 ) { $result[] = (int) $string; } } return array_values( array_unique( $result ) ); } /** * Extract a sanitized single-line scalar string from POST-like data. * * @param array $postData POST payload. * @param string $key Field key. */ private static function postString( array $postData, string $key ): string { return self::sanitizeTextField( self::postRawString( $postData, $key ) ); } /** * Extract a sanitized multi-line scalar string from POST-like data. * * @param array $postData POST payload. * @param string $key Field key. */ private static function postTextareaString( array $postData, string $key ): string { $value = self::postRawString( $postData, $key ); if ( function_exists( 'sanitize_textarea_field' ) ) { return (string) call_user_func( 'sanitize_textarea_field', $value ); } $value = preg_replace( '/<[^>]*>/', '', $value ) ?? $value; return trim( (string) preg_replace( '/[ \t]+/', ' ', $value ) ); } /** * Extract an unslashed scalar string from POST-like data. * * Use only for fields whose format is validated separately, such as * base64-encoded CSV payloads. * * @param array $postData POST payload. * @param string $key Field key. */ private static function postRawString( array $postData, string $key ): string { if ( ! array_key_exists( $key, $postData ) || ! is_scalar( $postData[ $key ] ) ) { return ''; } return (string) $postData[ $key ]; } /** * Sanitize a single-line text field. * * @param string $value Raw value. */ private static function sanitizeTextField( string $value ): string { if ( function_exists( 'sanitize_text_field' ) ) { return (string) call_user_func( 'sanitize_text_field', $value ); } $value = preg_replace( '/<[^>]*>/', '', $value ) ?? $value; return trim( (string) preg_replace( '/\s+/', ' ', $value ) ); } /** * Clean revenue opportunity redirect provider. * * @param string $provider Raw provider. */ private static function cleanRevenueRedirectProvider( string $provider ): string { $provider = preg_replace( '/[^a-z0-9_]/', '', strtolower( trim( $provider ) ) ) ?? ''; return $provider !== '' ? $provider : 'amazon'; } /** * Clean revenue opportunity redirect date. * * @param string $date Raw date. */ private static function cleanRevenueRedirectDate( string $date ): string { $date = trim( $date ); return preg_match( '/\A\d{4}-\d{2}-\d{2}\z/', $date ) === 1 ? $date : gmdate( 'Y-m-d' ); } /** * Clean revenue opportunity redirect status filter. * * @param string $status Raw status. */ private static function cleanRevenueRedirectStatus( string $status ): string { $status = strtolower( trim( $status ) ); return in_array( $status, array( 'all', 'new', 'seen', 'applied', 'dismissed' ), true ) ? $status : 'new'; } /** * Clean revenue opportunity redirect severity filter. * * @param string $severity Raw severity. */ private static function cleanRevenueRedirectSeverity( string $severity ): string { $severity = strtolower( trim( $severity ) ); return in_array( $severity, array( 'all', 'high', 'medium', 'low' ), true ) ? $severity : 'all'; } /** * Clean revenue opportunity redirect type filter. * * @param string $type Raw opportunity type. */ private static function cleanRevenueRedirectType( string $type ): string { $type = strtolower( trim( $type ) ); return in_array( $type, array( 'all', 'broken_link', 'out_of_stock', 'sale', 'provider_event', 'seasonal', 'unmonetized_keyword', 'high_click_stale_copy', ), true ) ? $type : 'all'; } /** * Clean Cloud AI scan status filter. * * @param string $filter Raw filter. */ private static function cleanCloudAiScanFilter( string $filter ): string { $filter = strtolower( trim( $filter ) ); return in_array( $filter, array( 'all', 'installed', 'missing', 'no_candidate' ), true ) ? $filter : 'missing'; } /** * Redirect back to the products list with a small status token. * * @param string $status Status token for the `affilun_status` query parameter. */ private function redirectProductsList( string $status ): void { $this->redirectProductsListWithArgs( array( 'affilun_status' => $status ) ); } /** * Redirect back to the products list with query args. * * @param array $args Query args. */ private function redirectProductsListWithArgs( array $args ): void { $url = self::resolveAdminPageUrl( 'affilun-products' ); if ( function_exists( 'add_query_arg' ) ) { $url = (string) add_query_arg( $args, $url ); } else { $url .= ( str_contains( $url, '?' ) ? '&' : '?' ) . http_build_query( $args ); } if ( function_exists( 'wp_safe_redirect' ) ) { call_user_func( 'wp_safe_redirect', $url ); exit; } } /** * Redirect back to the Cloud tools page with query args. * * @param array $args Query args. */ private function redirectCloudToolsWithArgs( array $args ): void { $url = self::resolveAdminPageUrl( 'affilun-cloud-tools' ); if ( function_exists( 'add_query_arg' ) ) { $url = (string) add_query_arg( $args, $url ); } else { $url .= ( str_contains( $url, '?' ) ? '&' : '?' ) . http_build_query( $args ); } if ( function_exists( 'wp_safe_redirect' ) ) { call_user_func( 'wp_safe_redirect', $url ); exit; } } /** * Resolve an admin page URL. * * @param string $pageSlug Admin page slug. */ private static function resolveAdminPageUrl( string $pageSlug ): string { $path = 'admin.php?page=' . rawurlencode( $pageSlug ); if ( function_exists( 'admin_url' ) ) { return (string) admin_url( $path ); } return 'http://example.test/wp-admin/' . $path; } /** * Resolve the products page focused on saved products with link-health issues. */ private static function linkHealthProblemProductsUrl(): string { $args = array( 'page' => 'affilun-products', 'affilun_link_health_filter' => 'problem', ); $base = self::resolveAdminPageUrl( 'affilun-products' ); if ( function_exists( 'add_query_arg' ) ) { return (string) add_query_arg( array( 'affilun_link_health_filter' => 'problem' ), $base ); } return 'http://example.test/wp-admin/admin.php?' . http_build_query( $args ); } /** * Build the §13 Settings page {@see AdminPageDefinition}. * * The render closure re-checks the §3 `manage_options` * capability via {@see AdminCapability::CAP_FALLBACK_MANAGE} * (the slice 8c-2 renderer is admin-only-by-contract; this * is the canonical capability seam for the page-render * dispatch path) and forwards the whitelisted `affilun_status` * query parameter to the cached renderer. */ private function buildSettingsPageDefinition(): AdminPageDefinition { return new AdminPageDefinition( 'options-general.php', 'affilun 設定', 'affilun', AdminCapability::CAP_FALLBACK_MANAGE, 'affilun-settings', function (): void { $this->renderSettingsPage(); }, '', 0, null ); } /** * Build the §6 Products list page {@see AdminPageDefinition}. * * Phase-8 minimum registers Products as a top-level admin * menu (matching `specifications/admin-ui-spec.md` §2.1 * "[AFFILUN] under WordPress admin menu — Dashboard / Products / * ..."); a future slice may consolidate Settings under the * same top-level AFFILUN menu. * * The render closure re-checks the §3 editor-tier capability * via {@see AdminCapability::CAP_VIEW_PRODUCTS} and forwards * `$_GET` (already unslashed) to * {@see ProductsListQueryParser::parse()} to construct a * {@see ProductListQuery}, then dispatches to the cached * {@see ProductsListPageRenderer}. */ private function buildProductsListPageDefinition(): AdminPageDefinition { return new AdminPageDefinition( '', 'affilun アフィリエイト管理', 'affilun', AdminCapability::CAP_VIEW_PRODUCTS, 'affilun-products', function (): void { $this->renderProductsListPage(); }, 'dashicons-cart', 30, null ); } /** * Override WordPress's auto-generated first submenu entry. * * `add_menu_page()` automatically duplicates the top-level * entry as a first submenu using the menu_title (`Commerce * Link Builder`). Registering an explicit `add_submenu_page()` * with the same parent + menu slug as the top-level page * replaces that auto-generated row with the supplied menu * title (`商品一覧`), eliminating the visible duplicate while * still routing to the same render closure. */ private function buildProductsListSubmenuOverrideDefinition(): AdminPageDefinition { return new AdminPageDefinition( 'affilun-products', 'affilun アフィリエイト管理', 'アフィリエイト管理', AdminCapability::CAP_VIEW_PRODUCTS, 'affilun-products', // No-op render: the top-level definition above already // registers the products-list render closure against the // same `toplevel_page_affilun-products` action hook. Wiring // the same closure here would make WordPress fire the // render callback twice and emit duplicate page HTML. static function (): void {}, '', 0, null ); } /** * Build the §7 Add New admin page {@see AdminPageDefinition} * (strict-review fix #5, KI-014). * * Registered as a visible submenu under the existing top-level * `affilun-products` AFFILUN menu so WordPress keeps the page routable * from `admin.php?page=affilun-add-new` and the empty-state CTA does * not trip core's "no access" guard. The editor-tier admin pages * (Products list + Add New) live side-by-side, mirroring the * WordPress core convention for `edit.php` + `post-new.php`. * Capability gating uses the editor-tier * {@see AdminCapability::CAP_VIEW_PRODUCTS} (`edit_posts`). * * @param bool $hidden Whether to register the page as hidden. */ private function buildAddNewProductPageDefinition( bool $hidden = false ): AdminPageDefinition { return new AdminPageDefinition( $hidden ? AdminPageDefinition::HIDDEN_PARENT_SLUG : 'affilun-products', 'affilun 商品を追加', '商品を追加', AdminCapability::CAP_VIEW_PRODUCTS, 'affilun-add-new', function (): void { $this->renderAddNewProductPage(); }, '', 0, null ); } /** * Build the Phase 4 migration / growth page {@see AdminPageDefinition}. * * @param bool $hidden Whether to register the page as hidden. */ private function buildMigrationPageDefinition( bool $hidden = false ): AdminPageDefinition { return new AdminPageDefinition( $hidden ? AdminPageDefinition::HIDDEN_PARENT_SLUG : 'affilun-products', 'affilun 移行 / スキャン', '移行 / スキャン', AdminCapability::CAP_VIEW_DIAGNOSTICS, 'affilun-migration', function (): void { $this->renderMigrationPage(); }, '', 0, null ); } /** * Build the Phase 3 click analytics page {@see AdminPageDefinition}. */ private function buildClicksDashboardPageDefinition(): AdminPageDefinition { return new AdminPageDefinition( 'affilun-products', 'affilun クリック分析', 'クリック分析', AdminCapability::CAP_VIEW_DIAGNOSTICS, 'affilun-clicks', function (): void { $this->renderClicksDashboardPage(); }, '', 0, null ); } /** * Build the Phase 13 Cloud tools page {@see AdminPageDefinition}. */ private function buildCloudToolsPageDefinition(): AdminPageDefinition { return new AdminPageDefinition( 'affilun-products', 'affilun Cloudツール', 'Cloudツール', AdminCapability::CAP_VIEW_DIAGNOSTICS, 'affilun-cloud-tools', function (): void { $this->renderCloudToolsPage(); }, '', 0, null ); } /** * Build the §13 Diagnostics admin page {@see AdminPageDefinition}. * * Registered as a submenu under the existing top-level * `affilun-products` AFFILUN menu so all Phase-11 admin pages share one * top-level entry. Capability gating uses * {@see AdminCapability::CAP_FALLBACK_MANAGE} (`manage_options`). * * @param bool $hidden Whether to register the page as hidden. */ private function buildDiagnosticsPageDefinition( bool $hidden = false ): AdminPageDefinition { return new AdminPageDefinition( $hidden ? AdminPageDefinition::HIDDEN_PARENT_SLUG : 'affilun-products', 'affilun 診断情報', '診断情報', AdminCapability::CAP_VIEW_DIAGNOSTICS, 'affilun-diagnostics', function (): void { $this->renderDiagnosticsPage(); }, '', 0, null ); } /** * Build the §6 Logs admin page {@see AdminPageDefinition}. * * Registered as a submenu under `affilun-products`. Capability * gating uses {@see AdminCapability::CAP_FALLBACK_MANAGE} * (`manage_options`) since logs may surface admin-tier * diagnostics context. * * @param bool $hidden Whether to register the page as hidden. */ private function buildLogsPageDefinition( bool $hidden = false ): AdminPageDefinition { return new AdminPageDefinition( $hidden ? AdminPageDefinition::HIDDEN_PARENT_SLUG : 'affilun-products', 'affilun ログ', 'ログ', AdminCapability::CAP_VIEW_LOGS, 'affilun-logs', function (): void { $this->renderLogsPage(); }, '', 0, null ); } /** * Build the §6 Sync Jobs admin page {@see AdminPageDefinition}. * * Registered as a submenu under `affilun-products`. Capability * gating uses {@see AdminCapability::CAP_FALLBACK_MANAGE} * (`manage_options`). * * @param bool $hidden Whether to register the page as hidden. */ private function buildSyncJobsPageDefinition( bool $hidden = false ): AdminPageDefinition { return new AdminPageDefinition( $hidden ? AdminPageDefinition::HIDDEN_PARENT_SLUG : 'affilun-products', 'affilun 同期ジョブ', '同期ジョブ', AdminCapability::CAP_RUN_SYNC, 'affilun-sync-jobs', function (): void { $this->renderSyncJobsPage(); }, '', 0, null ); } /** * Build the §10 Audit Events admin page {@see AdminPageDefinition}. * * Registered as a submenu under `affilun-products`. Capability * gating uses {@see AdminCapability::CAP_VIEW_DIAGNOSTICS} * (with {@see AdminCapability::CAP_FALLBACK_MANAGE} as the * OR-list fallback through {@see AdminCapability::PAGE_TOKEN_AUDIT}). * * @param bool $hidden Whether to register the page as hidden. */ private function buildAuditPageDefinition( bool $hidden = false ): AdminPageDefinition { return new AdminPageDefinition( $hidden ? AdminPageDefinition::HIDDEN_PARENT_SLUG : 'affilun-products', 'affilun 監査イベント', '監査イベント', AdminCapability::CAP_VIEW_DIAGNOSTICS, 'affilun-audit', function (): void { $this->renderAuditPage(); }, '', 0, null ); } /** * Page-render dispatch for the Phase 4 migration / growth page. */ private function renderMigrationPage(): void { if ( ! $this->isAuthorizedForPage( AdminCapability::PAGE_TOKEN_MIGRATION ) ) { if ( function_exists( 'wp_die' ) ) { wp_die( 'Insufficient capabilities.', 'affilun', array( 'response' => 403 ) ); } return; } if ( $this->resolvedMigrationRenderer === null ) { return; } $unslashedGet = self::currentGetData(); $unslashedPost = self::currentPostData(); $this->resolvedMigrationRenderer->render( array_merge( $unslashedGet, $unslashedPost ) ); } /** * Page-render dispatch for the Phase 3 click analytics page. */ private function renderClicksDashboardPage(): void { if ( ! $this->isAuthorizedForPage( AdminCapability::PAGE_TOKEN_CLICKS ) ) { if ( function_exists( 'wp_die' ) ) { wp_die( 'Insufficient capabilities.', 'affilun', array( 'response' => 403 ) ); } return; } if ( $this->resolvedClicksDashboardRenderer === null ) { return; } $this->resolvedClicksDashboardRenderer->render( self::currentGetData() ); } /** * Page-render dispatch for the Phase 13 Cloud tools page. */ private function renderCloudToolsPage(): void { if ( ! $this->isAuthorizedForPage( AdminCapability::PAGE_TOKEN_DIAGNOSTICS ) ) { if ( function_exists( 'wp_die' ) ) { wp_die( 'Insufficient capabilities.', 'affilun', array( 'response' => 403 ) ); } return; } if ( $this->resolvedCloudToolsRenderer === null ) { return; } $this->resolvedCloudToolsRenderer->render( self::currentGetData() ); } /** * Page-render dispatch for the §13 Diagnostics page. */ private function renderDiagnosticsPage(): void { if ( ! $this->isAuthorizedForPage( AdminCapability::PAGE_TOKEN_DIAGNOSTICS ) ) { if ( function_exists( 'wp_die' ) ) { wp_die( 'Insufficient capabilities.', 'affilun', array( 'response' => 403 ) ); } return; } if ( $this->resolvedDiagnosticsRenderer === null ) { return; } $this->resolvedDiagnosticsRenderer->render(); } /** * Page-render dispatch for the §6 Logs page. */ private function renderLogsPage(): void { if ( ! $this->isAuthorizedForPage( AdminCapability::PAGE_TOKEN_LOGS ) ) { if ( function_exists( 'wp_die' ) ) { wp_die( 'Insufficient capabilities.', 'affilun', array( 'response' => 403 ) ); } return; } if ( $this->resolvedLogsRenderer === null || $this->resolvedLogsListQueryParser === null ) { return; } $unslashed = self::currentGetData(); $query = $this->resolvedLogsListQueryParser->parse( $unslashed ); $this->resolvedLogsRenderer->render( $query ); } /** * Page-render dispatch for the §6 Sync Jobs page. */ private function renderSyncJobsPage(): void { if ( ! $this->isAuthorizedForPage( AdminCapability::PAGE_TOKEN_SYNC_JOBS ) ) { if ( function_exists( 'wp_die' ) ) { wp_die( 'Insufficient capabilities.', 'affilun', array( 'response' => 403 ) ); } return; } if ( $this->resolvedSyncJobsRenderer === null || $this->resolvedSyncJobsListQueryParser === null ) { return; } $unslashed = self::currentGetData(); $query = $this->resolvedSyncJobsListQueryParser->parse( $unslashed ); $this->resolvedSyncJobsRenderer->render( $query ); } /** * Page-render dispatch for the §10 Audit Events page. */ private function renderAuditPage(): void { if ( ! $this->isAuthorizedForPage( AdminCapability::PAGE_TOKEN_AUDIT ) ) { if ( function_exists( 'wp_die' ) ) { wp_die( 'Insufficient capabilities.', 'affilun', array( 'response' => 403 ) ); } return; } if ( $this->resolvedAuditRenderer === null || $this->resolvedAuditListQueryParser === null ) { return; } $unslashed = self::currentGetData(); $query = $this->resolvedAuditListQueryParser->parse( $unslashed ); $this->resolvedAuditRenderer->render( $query ); } /** * Page-render dispatch for the §6 Products list page. * * Re-checks the §3 editor-tier capability and routes the * `$_GET` array (unslashed via `wp_unslash()` when available) * through the cached {@see ProductsListQueryParser} so a * tampered URL falls back to {@see ProductListQuery::defaults()} * rather than reaching the renderer with a partially-validated * query. */ private function renderProductsListPage(): void { if ( ! function_exists( 'current_user_can' ) || ! current_user_can( AdminCapability::CAP_VIEW_PRODUCTS ) ) { if ( function_exists( 'wp_die' ) ) { wp_die( 'Insufficient capabilities.', 'affilun', array( 'response' => 403 ) ); } return; } if ( $this->resolvedProductsListRenderer === null || $this->resolvedProductsListQueryParser === null ) { return; } $unslashedGet = self::currentGetData(); $unslashedPost = self::currentPostData(); $requestData = array_merge( $unslashedGet, $unslashedPost ); $query = $this->resolvedProductsListQueryParser->parse( $unslashedGet ); $this->resolvedProductsListRenderer->render( $query, $requestData ); } /** * Page-render dispatch for the §7 Add New admin page * (strict-review fix #5, KI-014). * * Re-checks the editor-tier capability via * {@see AdminCapability::PAGE_TOKEN_PRODUCTS_NEW}'s OR-list * (`edit_posts` primary; no fallback configured because the * primary already maps to a built-in WordPress capability) * and forwards the whitelisted `affilun_status` query parameter * to the cached renderer. Unknown status values are dropped * silently rather than echoed back into the page. */ private function renderAddNewProductPage(): void { if ( ! $this->isAuthorizedForPage( AdminCapability::PAGE_TOKEN_PRODUCTS_NEW ) ) { if ( function_exists( 'wp_die' ) ) { wp_die( 'Insufficient capabilities.', 'affilun', array( 'response' => 403 ) ); } return; } if ( $this->resolvedAddNewRenderer === null ) { return; } $getData = self::currentGetData(); $rawStatus = self::requestKey( $getData, 'affilun_status' ); $flash = in_array( $rawStatus, AddNewProductPageRenderer::VALID_STATUS_FLASHES, true ) ? $rawStatus : null; $this->resolvedAddNewRenderer->render( $flash ); } /** * Page-render dispatch. * * Re-checks the §3 admin-tier capability and forwards the * whitelisted `affilun_status` query parameter to the cached * renderer. Unknown status values are dropped silently * rather than echoed back into the page so a malicious * `?affilun_status=...` value cannot reach the renderer. */ private function renderSettingsPage(): void { if ( ! function_exists( 'current_user_can' ) || ! current_user_can( AdminCapability::CAP_FALLBACK_MANAGE ) ) { if ( function_exists( 'wp_die' ) ) { wp_die( 'Insufficient capabilities.', 'affilun', array( 'response' => 403 ) ); } return; } if ( $this->resolvedRenderer === null ) { return; } $getData = self::currentGetData(); $rawStatus = self::requestKey( $getData, 'affilun_status' ); $flash = in_array( $rawStatus, self::VALID_STATUS_FLASHES, true ) ? $rawStatus : null; $this->resolvedRenderer->render( $flash, self::currentRequestParameters() ); } /** * Return request parameters for read-only operation tab rendering. * * @return array */ private static function currentRequestParameters(): array { return array_merge( self::currentGetData(), self::currentPostData() ); } /** * Resolve {@see OptionStore} from the registry. * * @param ServiceProviderRegistry $registry Bootstrap registry. * * @throws LogicException When the binding is missing or the * bound value is not an * {@see OptionStore}. */ private function resolveOptionStore( ServiceProviderRegistry $registry ): OptionStore { $key = PersistenceServiceProvider::BINDING_OPTION_STORE; if ( ! $registry->has( $key ) ) { $message = 'AdminServiceProvider requires PersistenceServiceProvider to register before it' . ' (missing OptionStore binding).'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } $optionStore = $registry->get( $key ); if ( ! $optionStore instanceof OptionStore ) { $message = sprintf( 'AdminServiceProvider expected OptionStore under "%s"; got %s.', $key, is_object( $optionStore ) ? get_class( $optionStore ) : gettype( $optionStore ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } return $optionStore; } /** * Resolve {@see ProductRepository} from the registry. * * @param ServiceProviderRegistry $registry Bootstrap registry. * * @throws LogicException When the binding is missing or the * bound value is not a * {@see ProductRepository}. */ private function resolveProductRepository( ServiceProviderRegistry $registry ): ProductRepository { $key = PersistenceServiceProvider::BINDING_PRODUCT_REPOSITORY; if ( ! $registry->has( $key ) ) { $message = 'AdminServiceProvider requires PersistenceServiceProvider to register before it' . ' (missing ProductRepository binding).'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } $repository = $registry->get( $key ); if ( ! $repository instanceof ProductRepository ) { $message = sprintf( 'AdminServiceProvider expected ProductRepository under "%s"; got %s.', $key, is_object( $repository ) ? get_class( $repository ) : gettype( $repository ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } return $repository; } /** * Resolve {@see ProductMetaRepository} from the registry. * * @param ServiceProviderRegistry $registry Bootstrap registry. * * @throws LogicException When the binding is missing or invalid. */ private function resolveProductMetaRepository( ServiceProviderRegistry $registry ): ProductMetaRepository { $key = PersistenceServiceProvider::BINDING_PRODUCT_META_REPOSITORY; if ( ! $registry->has( $key ) ) { $message = 'AdminServiceProvider requires PersistenceServiceProvider to register before it' . ' (missing ProductMetaRepository binding).'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } $repository = $registry->get( $key ); if ( ! $repository instanceof ProductMetaRepository ) { $message = sprintf( 'AdminServiceProvider expected ProductMetaRepository under "%s"; got %s.', $key, is_object( $repository ) ? get_class( $repository ) : gettype( $repository ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } return $repository; } /** * Resolve the §8.2 {@see ListProductsUseCase} from the * registry, falling back to a default-constructed instance * wrapping the supplied repository when the * {@see ProductsServiceProvider::BINDING_LIST_PRODUCTS_USE_CASE} * binding is absent. * * Preferring the bound use case when present keeps the * Phase-6 ProductsServiceProvider and the Phase-8 admin * surface loosely coupled; the default-construction branch * keeps the admin provider operable in test harnesses that * exercise the admin graph without booting the REST stack. * * @param ServiceProviderRegistry $registry Bootstrap registry. * @param ProductRepository $productRepository Repository for default construction. */ private function resolveListProductsUseCase( ServiceProviderRegistry $registry, ProductRepository $productRepository ): ListProductsUseCase { $key = ProductsServiceProvider::BINDING_LIST_PRODUCTS_USE_CASE; if ( $registry->has( $key ) ) { $bound = $registry->get( $key ); if ( $bound instanceof ListProductsUseCase ) { return $bound; } } return new ListProductsUseCase( $productRepository ); } /** * Resolve {@see LogRepository} from the registry. * * @param ServiceProviderRegistry $registry Bootstrap registry. * * @throws LogicException When the binding is missing or the * bound value is not a {@see LogRepository}. */ private function resolveLogRepository( ServiceProviderRegistry $registry ): LogRepository { $key = PersistenceServiceProvider::BINDING_LOG_REPOSITORY; if ( ! $registry->has( $key ) ) { $message = 'AdminServiceProvider requires PersistenceServiceProvider to register before it' . ' (missing LogRepository binding).'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } $repo = $registry->get( $key ); if ( ! $repo instanceof LogRepository ) { $message = sprintf( 'AdminServiceProvider expected LogRepository under "%s"; got %s.', $key, is_object( $repo ) ? get_class( $repo ) : gettype( $repo ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } return $repo; } /** * Resolve {@see SyncJobRepository} from the registry. * * @param ServiceProviderRegistry $registry Bootstrap registry. * * @throws LogicException When the binding is missing or the * bound value is not a * {@see SyncJobRepository}. */ private function resolveSyncJobRepository( ServiceProviderRegistry $registry ): SyncJobRepository { $key = PersistenceServiceProvider::BINDING_SYNC_JOB_REPOSITORY; if ( ! $registry->has( $key ) ) { $message = 'AdminServiceProvider requires PersistenceServiceProvider to register before it' . ' (missing SyncJobRepository binding).'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } $repo = $registry->get( $key ); if ( ! $repo instanceof SyncJobRepository ) { $message = sprintf( 'AdminServiceProvider expected SyncJobRepository under "%s"; got %s.', $key, is_object( $repo ) ? get_class( $repo ) : gettype( $repo ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } return $repo; } /** * Resolve {@see SchemaVersionRepository} from the registry. * * @param ServiceProviderRegistry $registry Bootstrap registry. * * @throws LogicException When the binding is missing or the * bound value is not a * {@see SchemaVersionRepository}. */ private function resolveSchemaVersionRepository( ServiceProviderRegistry $registry ): SchemaVersionRepository { $key = PersistenceServiceProvider::BINDING_SCHEMA_VERSION_REPOSITORY; if ( ! $registry->has( $key ) ) { $message = 'AdminServiceProvider requires PersistenceServiceProvider to register before it' . ' (missing SchemaVersionRepository binding).'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } $repo = $registry->get( $key ); if ( ! $repo instanceof SchemaVersionRepository ) { $message = sprintf( 'AdminServiceProvider expected SchemaVersionRepository under "%s"; got %s.', $key, is_object( $repo ) ? get_class( $repo ) : gettype( $repo ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } return $repo; } /** * Resolve the {@see ProviderRegistry} from the registry. * * Phase 12 slice 12c centralizes the §12.1 providers map under * {@see ProviderServiceProvider::BINDING_PROVIDER_REGISTRY}. * When the binding is missing — typically in tests that * pre-seed the admin collaborators without standing up the * provider service provider — this method returns a fresh * empty {@see ProviderRegistry} so the admin diagnostics page * renders an empty `providers` branch rather than failing * loudly. * * @param ServiceProviderRegistry $registry Bootstrap registry. * * @throws LogicException When the binding exists but is not a * {@see ProviderRegistry}. */ private function resolveProviderRegistry( ServiceProviderRegistry $registry ): ProviderRegistry { $key = ProviderServiceProvider::BINDING_PROVIDER_REGISTRY; if ( ! $registry->has( $key ) ) { return new ProviderRegistry( array() ); } $value = $registry->get( $key ); if ( ! $value instanceof ProviderRegistry ) { $message = sprintf( 'AdminServiceProvider expected ProviderRegistry under "%s"; got %s.', $key, is_object( $value ) ? get_class( $value ) : gettype( $value ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } return $value; } /** * Re-check page-render authorization through the §3.3 * {@see AdminCapability} OR-list (Phase 2 KI-008). * * Wraps `current_user_can()` in a closure compatible with * {@see AdminCapability::isAuthorized()} so the dispatch * path accepts either the dedicated `affilun_*` capability OR * the `manage_options` fallback. When `current_user_can()` * is unavailable (pure-unit test path without WordPress * loaded), the probe denies, matching the legacy * fail-closed behavior. * * @param string $pageToken Stable page token from * {@see AdminCapability}. */ private function isAuthorizedForPage( string $pageToken ): bool { $probe = static function ( string $cap ): bool { return function_exists( 'current_user_can' ) && (bool) current_user_can( $cap ); }; return AdminCapability::isAuthorized( $probe, $pageToken ); } /** * Soft-resolve the render cache so settings saves can invalidate * cached card HTML. * * @param ServiceProviderRegistry $registry Bootstrap registry. */ private function resolveOptionalRenderCache( ServiceProviderRegistry $registry ): ?RenderCache { $key = CacheServiceProvider::BINDING_RENDER_CACHE; if ( ! $registry->has( $key ) ) { return null; } $value = $registry->get( $key ); return $value instanceof RenderCache ? $value : null; } /** * Soft-resolve the canonical ProductCardViewModelFactory for the * Settings rendering preview. * * @param ServiceProviderRegistry $registry Bootstrap registry. */ private function resolveOptionalProductCardViewModelFactory( ServiceProviderRegistry $registry ): ?ProductCardViewModelFactory { $key = RenderServiceProvider::BINDING_PRODUCT_CARD_VIEW_MODEL_FACTORY; if ( ! $registry->has( $key ) ) { return null; } $value = $registry->get( $key ); return $value instanceof ProductCardViewModelFactory ? $value : null; } /** * Soft-resolve the canonical ProductCardRenderer for the Settings * rendering preview. * * @param ServiceProviderRegistry $registry Bootstrap registry. */ private function resolveOptionalProductCardRenderer( ServiceProviderRegistry $registry ): ?ProductCardRenderer { $key = RenderServiceProvider::BINDING_PRODUCT_CARD_RENDERER; if ( ! $registry->has( $key ) ) { return null; } $value = $registry->get( $key ); return $value instanceof ProductCardRenderer ? $value : null; } /** * Soft-resolve the {@see AuditEventRepository} from the * registry and wrap it in a {@see RepositoryAuditEventWriter} * so the §13 admin Settings save can emit §10.1 * `configuration_change` audit rows. * * Returns null when the binding is missing or carries an * unexpected value — pure-unit harnesses that don't bind the * audit repository (the test seed `buildPreparedRegistry()` * predates the audit-writer wiring) keep working without * modification, and the {@see SaveSettingsHandler} stays * silent on the audit channel rather than failing loudly. * * @param ServiceProviderRegistry $registry Bootstrap registry. */ private function resolveOptionalAuditEventWriter( ServiceProviderRegistry $registry ): ?RepositoryAuditEventWriter { $key = PersistenceServiceProvider::BINDING_AUDIT_EVENT_REPOSITORY; if ( ! $registry->has( $key ) ) { return null; } $events = $registry->get( $key ); if ( ! $events instanceof AuditEventRepository ) { return null; } return new RepositoryAuditEventWriter( $events, new SystemClock() ); } }