> */ private const PROVIDER_SECRET_FIELDS = array( 'amazon' => array( 'credential_id', 'credential_secret', 'version', 'partner_tag', 'button_color', 'button_text_color' ), 'rakuten' => array( 'application_id', 'access_key', 'affiliate_id', 'button_color', 'button_text_color' ), 'yahoo_shopping' => array( 'app_id', 'affiliate_sid', 'affiliate_pid', 'button_color', 'button_text_color' ), 'mercari' => array( 'afid', 'button_color', 'button_text_color' ), ); /** * Convert the supplied POST-shaped array into a * {@see PluginSettings} instance. * * `$postData` MUST already be rooted at the * {@see SettingsFormToken::FORM_FIELD_PREFIX} key (i.e. * `$_POST['affilun_settings'] ?? array()`); the parser does not * unwrap the prefix itself. * * @param array $postData Form payload rooted at the form-field prefix. * @param PluginSettings $current Currently persisted snapshot used as fallback. */ public function parse( array $postData, PluginSettings $current ): PluginSettings { $rendering = $this->parseRendering( $this->subArray( $postData, 'rendering' ), $current->rendering() ); $links = $this->parseLinks( $this->subArray( $postData, 'links' ), $current->links() ); $sync = $this->parseSync( $this->subArray( $postData, 'sync' ), $current->sync() ); $cache = $this->parseCache( $this->subArray( $postData, 'cache' ), $current->cache() ); $debugMode = $this->readBool( $postData, 'debug_mode' ); $openAi = OpenAiSettings::defaults(); $cloud = $this->parseCloud( $this->subArray( $postData, 'cloud' ), $current->cloud() ); $providers = $this->parseProviders( $this->subArray( $postData, 'providers' ), $current->providerCredentials() ); try { return new PluginSettings( $providers, $rendering, $links, $sync, $cache, $debugMode, $openAi, $cloud ); } catch ( Throwable $unused ) { unset( $unused ); return $current; } } /** * Build a {@see RenderingDefaults} from a section sub-array, * falling back to `$current` on construction failure. * * @param array $section Section sub-array. * @param RenderingDefaults $current Current snapshot. */ private function parseRendering( array $section, RenderingDefaults $current ): RenderingDefaults { try { return new RenderingDefaults( $this->readString( $section, 'default_template', $current->defaultTemplate() ), $this->readBool( $section, 'theme_override_enabled' ), $this->readBool( $section, 'show_provider_badge_by_default' ), $this->readBool( $section, 'show_disclaimer_by_default' ), $this->readBool( $section, 'lazy_load_images' ), $this->readString( $section, 'placeholder_image_policy', $current->placeholderImagePolicy() ), $this->readString( $section, 'default_design_preset', $current->defaultDesignPreset() ), $this->readString( $section, 'default_button_layout', $current->defaultButtonLayout() ), $this->readString( $section, 'default_button_shape', $current->defaultButtonShape() ), 'brand', $this->readString( $section, 'default_color_scheme', $current->defaultColorScheme() ) ); } catch ( Throwable $unused ) { unset( $unused ); return $current; } } /** * Build a {@see LinkDefaults} from a section sub-array, * falling back to `$current` on construction failure. * * @param array $section Section sub-array. * @param LinkDefaults $current Current snapshot. */ private function parseLinks( array $section, LinkDefaults $current ): LinkDefaults { try { return new LinkDefaults( $this->readBool( $section, 'open_in_new_tab_default' ), $this->readBool( $section, 'add_nofollow' ), $this->readBool( $section, 'add_sponsored' ), $this->readBool( $section, 'add_noopener' ), $this->readBool( $section, 'add_noreferrer' ), $this->readBool( $section, 'tracking_enabled_default' ) ); } catch ( Throwable $unused ) { unset( $unused ); return $current; } } /** * Build a {@see SyncSettings} from a section sub-array, * falling back to `$current` on construction failure. * * @param array $section Section sub-array. * @param SyncSettings $current Current snapshot. */ private function parseSync( array $section, SyncSettings $current ): SyncSettings { try { return new SyncSettings( $this->readBool( $section, 'enabled' ), $this->readBool( $section, 'scheduled_sync_enabled' ), $this->readInt( $section, 'fresh_ttl_seconds', $current->freshTtlSeconds() ), $this->readInt( $section, 'stale_ttl_seconds', $current->staleTtlSeconds() ), $this->readInt( $section, 'expired_ttl_seconds', $current->expiredTtlSeconds() ), $this->readInt( $section, 'max_concurrent_jobs', $current->maxConcurrentJobs() ), $this->readInt( $section, 'batch_size', $current->batchSize() ), $this->readString( $section, 'retry_policy_preset', $current->retryPolicyPreset() ) ); } catch ( Throwable $unused ) { unset( $unused ); return $current; } } /** * Build a {@see CacheSettings} from a section sub-array, * falling back to `$current` on construction failure. * * @param array $section Section sub-array. * @param CacheSettings $current Current snapshot. */ private function parseCache( array $section, CacheSettings $current ): CacheSettings { try { return new CacheSettings( $this->readBool( $section, 'search_cache_enabled' ), $this->readInt( $section, 'search_cache_ttl_seconds', $current->searchCacheTtlSeconds() ), $this->readInt( $section, 'lookup_cache_ttl_seconds', $current->lookupCacheTtlSeconds() ), $this->readBool( $section, 'render_cache_enabled' ), $this->readInt( $section, 'render_cache_ttl_seconds', $current->renderCacheTtlSeconds() ), $this->readInt( $section, 'provider_health_cache_ttl_seconds', $current->providerHealthCacheTtlSeconds() ) ); } catch ( Throwable $unused ) { unset( $unused ); return $current; } } /** * Parse the `cloud` sub-array into affilun Cloud settings. * * @param array $section Section sub-array. * @param CloudSettings $current Current snapshot. */ private function parseCloud( array $section, CloudSettings $current ): CloudSettings { try { return new CloudSettings( $this->readBool( $section, 'enabled' ), $this->readString( $section, 'endpoint', $current->endpoint() ), $this->readString( $section, 'site_id', $current->siteId() ), $this->readString( $section, 'api_key', '' ), $this->readString( $section, 'plan', $current->plan() ), $this->readString( $section, 'article_draft_tone', $current->articleDraftTone() ), $this->readString( $section, 'article_draft_target_length', $current->articleDraftTargetLength() ), $this->readString( $section, 'article_draft_review_source_policy', $current->articleDraftReviewSourcePolicy() ) ); } catch ( Throwable $unused ) { unset( $unused ); return $current; } } /** * Parse the `providers` sub-array into the * {@see PluginSettings::providerCredentials()} map. * * Per §6.3 / §6.4 the secret values are passed verbatim; * the rotation rule is applied downstream in the * SaveSettings use case. * * Falls back to `$current` for any provider whose section is * malformed or whose {@see ProviderCredentials} construction * throws. * * @param array $section Section sub-array. * @param array $current Current credentials map. * * @return array */ private function parseProviders( array $section, array $current ): array { $result = $current; foreach ( self::PROVIDER_SECRET_FIELDS as $providerKey => $secretKeys ) { $entry = $this->subArray( $section, $providerKey ); $enabled = $this->readBool( $entry, 'enabled' ); $secrets = array(); foreach ( $secretKeys as $secretKey ) { $value = $this->readString( $entry, $secretKey, '' ); if ( trim( $value ) === '' && array_key_exists( $providerKey, $current ) ) { $currentValue = (string) $current[ $providerKey ]->secret( $secretKey ); if ( trim( $currentValue ) !== '' ) { $value = $currentValue; } } $secrets[ $secretKey ] = $value; } $currentButtonColor = ''; if ( array_key_exists( $providerKey, $current ) ) { $currentButtonColor = (string) $current[ $providerKey ]->secret( 'button_color' ); } $rawButtonColor = $currentButtonColor; if ( trim( $secrets['button_color'] ?? '' ) !== '' ) { $rawButtonColor = (string) $secrets['button_color']; } $secrets['button_color'] = ProviderCredentials::normalizeButtonColor( $providerKey, $rawButtonColor ); $currentButtonTextColor = ''; if ( array_key_exists( $providerKey, $current ) ) { $currentButtonTextColor = (string) $current[ $providerKey ]->secret( 'button_text_color' ); } $rawButtonTextColor = trim( $secrets['button_text_color'] ?? '' ) !== '' ? (string) $secrets['button_text_color'] : $currentButtonTextColor; $secrets['button_text_color'] = ProviderCredentials::normalizeButtonTextColor( $rawButtonTextColor ); if ( $providerKey === 'amazon' ) { $currentVersion = array_key_exists( $providerKey, $current ) ? (string) $current[ $providerKey ]->secret( 'version' ) : ''; if ( trim( $secrets['version'] ?? '' ) === '' && trim( $currentVersion ) === '' ) { $secrets['version'] = ProviderCredentials::AMAZON_DEFAULT_VERSION; } $secrets['marketplace'] = ProviderCredentials::AMAZON_MARKETPLACE; } try { $result[ $providerKey ] = new ProviderCredentials( $providerKey, $enabled, $secrets ); } catch ( Throwable $unused ) { unset( $unused ); if ( array_key_exists( $providerKey, $current ) ) { $result[ $providerKey ] = $current[ $providerKey ]; } } } return $result; } /** * Return the array stored under `$key` in `$source`, or an * empty array when the key is absent or non-array. * * @param array $source Outer array. * @param string $key Sub-array key. * * @return array */ private function subArray( array $source, string $key ): array { if ( ! array_key_exists( $key, $source ) || ! is_array( $source[ $key ] ) ) { return array(); } /** @var array $result */ $result = $source[ $key ]; return $result; } /** * Read a checkbox-style boolean from `$source`. * * Per WordPress checkbox semantics: presence under `$key` * (regardless of value) is treated as `true`; absence is * `false`. Explicitly `''` / `'0'` / `false` values are * treated as `false` so a renderer-supplied hidden default * input can still force the off-state when needed. * * @param array $source Source array. * @param string $key Field name. */ private function readBool( array $source, string $key ): bool { if ( ! array_key_exists( $key, $source ) ) { return false; } $value = $source[ $key ]; if ( $value === '' || $value === '0' || $value === false || $value === 0 || $value === null ) { return false; } return true; } /** * Read a string field from `$source`, falling back to * `$fallback` when missing or non-scalar. * * @param array $source Source array. * @param string $key Field name. * @param string $fallback Fallback value. */ private function readString( array $source, string $key, string $fallback ): string { if ( ! array_key_exists( $key, $source ) ) { return $fallback; } $value = $source[ $key ]; if ( ! is_scalar( $value ) ) { return $fallback; } return self::sanitizeString( (string) $value ); } /** * Read a numeric field from `$source` and coerce to int via * `FILTER_VALIDATE_INT`, falling back to `$fallback` on * parse failure. * * @param array $source Source array. * @param string $key Field name. * @param int $fallback Fallback value. */ private function readInt( array $source, string $key, int $fallback ): int { if ( ! array_key_exists( $key, $source ) ) { return $fallback; } $value = $source[ $key ]; if ( is_int( $value ) ) { return $value; } if ( is_string( $value ) || is_float( $value ) ) { $coerced = filter_var( (string) $value, FILTER_VALIDATE_INT ); if ( $coerced !== false ) { return $coerced; } } return $fallback; } /** * Sanitize a scalar text value from settings POST data. * * @param string $value Raw value. */ private static function sanitizeString( string $value ): string { $value = function_exists( 'wp_unslash' ) ? (string) wp_unslash( $value ) : $value; if ( function_exists( 'sanitize_text_field' ) ) { return (string) sanitize_text_field( $value ); } $value = preg_replace( '/<[^>]*>/', '', $value ) ?? $value; return trim( (string) preg_replace( '/\s+/', ' ', $value ) ); } }