*/
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 '';
return;
}
if ( $warning > 0 ) {
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() );
}
}