*/ private array $providerCredentials; /** * §13.5 Rendering tab settings. */ private RenderingDefaults $rendering; /** * §13.6 Links tab settings. */ private LinkDefaults $links; /** * §13.7 Sync tab settings. */ private SyncSettings $sync; /** * §13.8 Cache tab settings. */ private CacheSettings $cache; /** * §13.9 Advanced "Debug Mode" toggle. */ private bool $debugMode; /** * 0.4.0 OpenAI extraction settings. */ private OpenAiSettings $openAi; /** * affilun Cloud settings. */ private CloudSettings $cloud; /** * Construct a plugin-settings snapshot with explicit values * for every field. * * @param array $providerCredentials Provider credentials map. * @param RenderingDefaults $rendering §13.5 Rendering tab settings. * @param LinkDefaults $links §13.6 Links tab settings. * @param SyncSettings $sync §13.7 Sync tab settings. * @param CacheSettings $cache §13.8 Cache tab settings. * @param bool $debugMode §13.9 Debug Mode toggle. * @param OpenAiSettings|null $openAi 0.4.0 OpenAI settings. * @param CloudSettings|null $cloud affilun Cloud settings. * * @throws InvalidArgumentException When the * `$providerCredentials` * map carries a non-string * key, a non-{@see ProviderCredentials} * value, or a key that * disagrees with its * entry's * {@see ProviderCredentials::providerKey()}. */ public function __construct( array $providerCredentials, RenderingDefaults $rendering, LinkDefaults $links, SyncSettings $sync, CacheSettings $cache, bool $debugMode, ?OpenAiSettings $openAi = null, ?CloudSettings $cloud = null ) { $this->providerCredentials = self::normalizeProviderCredentials( $providerCredentials ); $this->rendering = $rendering; $this->links = $links; $this->sync = $sync; $this->cache = $cache; $this->debugMode = $debugMode; $this->openAi = $openAi ?? OpenAiSettings::defaults(); $this->cloud = $cloud ?? CloudSettings::defaults(); } /** * Construct a plugin-settings snapshot with the canonical * §13 baseline values: empty provider-credentials map and * defaults for every sub-component. */ public static function defaults(): self { return new self( array(), RenderingDefaults::defaults(), LinkDefaults::defaults(), SyncSettings::defaults(), CacheSettings::defaults(), false, OpenAiSettings::defaults(), CloudSettings::defaults() ); } /** * Provider credentials map, keyed by provider key. * * @return array */ public function providerCredentials(): array { return $this->providerCredentials; } /** * Look up a single provider's credentials by key. * * Returns null when the key is absent so callers can * distinguish "not configured" from "configured with empty * secrets". * * @param string $key Provider key. */ public function provider( string $key ): ?ProviderCredentials { if ( ! array_key_exists( $key, $this->providerCredentials ) ) { return null; } return $this->providerCredentials[ $key ]; } /** * §13.5 Rendering tab settings. */ public function rendering(): RenderingDefaults { return $this->rendering; } /** * §13.6 Links tab settings. */ public function links(): LinkDefaults { return $this->links; } /** * §13.7 Sync tab settings. */ public function sync(): SyncSettings { return $this->sync; } /** * §13.8 Cache tab settings. */ public function cache(): CacheSettings { return $this->cache; } /** * §13.9 Debug Mode toggle. */ public function debugMode(): bool { return $this->debugMode; } /** * 0.4.0 OpenAI extraction settings. */ public function openAi(): OpenAiSettings { return $this->openAi; } /** * affilun Cloud settings. */ public function cloud(): CloudSettings { return $this->cloud; } /** * Produce a copy with the supplied provider's credentials * inserted or replaced. * * The map is keyed by * {@see ProviderCredentials::providerKey()}, so calling * this with an entry whose key already exists overwrites * the previous value. * * @param ProviderCredentials $credentials Credentials to insert. */ public function withProviderCredentials( ProviderCredentials $credentials ): self { $clone = clone $this; $clone->providerCredentials[ $credentials->providerKey() ] = $credentials; return $clone; } /** * Produce a copy with the supplied provider removed from * the credentials map. * * Removing an absent key is a no-op (the resulting copy * equals the receiver under {@see self::equals()}). * * @param string $key Provider key to remove. */ public function withoutProvider( string $key ): self { $clone = clone $this; unset( $clone->providerCredentials[ $key ] ); return $clone; } /** * Produce a copy with the §13.5 Rendering tab settings * replaced. * * @param RenderingDefaults $rendering New rendering settings. */ public function withRendering( RenderingDefaults $rendering ): self { $clone = clone $this; $clone->rendering = $rendering; return $clone; } /** * Produce a copy with the §13.6 Links tab settings * replaced. * * @param LinkDefaults $links New link settings. */ public function withLinks( LinkDefaults $links ): self { $clone = clone $this; $clone->links = $links; return $clone; } /** * Produce a copy with the §13.7 Sync tab settings replaced. * * @param SyncSettings $sync New sync settings. */ public function withSync( SyncSettings $sync ): self { $clone = clone $this; $clone->sync = $sync; return $clone; } /** * Produce a copy with the §13.8 Cache tab settings * replaced. * * @param CacheSettings $cache New cache settings. */ public function withCache( CacheSettings $cache ): self { $clone = clone $this; $clone->cache = $cache; return $clone; } /** * Produce a copy with the §13.9 Debug Mode toggle replaced. * * @param bool $debugMode New toggle. */ public function withDebugMode( bool $debugMode ): self { $clone = clone $this; $clone->debugMode = $debugMode; return $clone; } /** * Produce a copy with the OpenAI settings replaced. * * @param OpenAiSettings $openAi New OpenAI settings. */ public function withOpenAi( OpenAiSettings $openAi ): self { $clone = clone $this; $clone->openAi = $openAi; return $clone; } /** * Produce a copy with affilun Cloud settings replaced. * * @param CloudSettings $cloud New Cloud settings. */ public function withCloud( CloudSettings $cloud ): self { $clone = clone $this; $clone->cloud = $cloud; return $clone; } /** * Report whether this snapshot equals the supplied one. * * Equality is structural: every sub-component must compare * equal under its own `equals()` and the provider * credentials map must agree on keys + per-entry equality. * * @param self $other Other snapshot to compare against. */ public function equals( self $other ): bool { if ( $this->debugMode !== $other->debugMode ) { return false; } if ( ! $this->rendering->equals( $other->rendering ) ) { return false; } if ( ! $this->links->equals( $other->links ) ) { return false; } if ( ! $this->sync->equals( $other->sync ) ) { return false; } if ( ! $this->cache->equals( $other->cache ) ) { return false; } if ( ! $this->openAi->equals( $other->openAi ) ) { return false; } if ( ! $this->cloud->equals( $other->cloud ) ) { return false; } if ( count( $this->providerCredentials ) !== count( $other->providerCredentials ) ) { return false; } foreach ( $this->providerCredentials as $key => $credentials ) { if ( ! array_key_exists( $key, $other->providerCredentials ) ) { return false; } if ( ! $credentials->equals( $other->providerCredentials[ $key ] ) ) { return false; } } return true; } /** * Serialize this snapshot for OptionStore persistence. * * The provider-credentials sub-array carries the raw secret * values; UI / REST surfaces MUST use * {@see self::toMaskedArray()} instead. * * @return array */ public function toArray(): array { $providers = array(); foreach ( $this->providerCredentials as $key => $credentials ) { $providers[ $key ] = $credentials->toArray(); } return array( 'provider_credentials' => $providers, 'rendering' => $this->rendering->toArray(), 'links' => $this->links->toArray(), 'sync' => $this->sync->toArray(), 'cache' => $this->cache->toArray(), 'debug_mode' => $this->debugMode, 'openai' => $this->openAi->toArray(), 'cloud' => $this->cloud->toArray(), ); } /** * Serialize this snapshot for UI / REST surfaces. * * Identical to {@see self::toArray()} except that every * provider-credentials entry's `secrets` map is replaced by * its §6.3-masked form. Non-credential fields are emitted * verbatim because they do not carry secret material. * * @return array */ public function toMaskedArray(): array { $providers = array(); foreach ( $this->providerCredentials as $key => $credentials ) { $providers[ $key ] = array( 'provider_key' => $credentials->providerKey(), 'enabled' => $credentials->enabled(), 'secrets' => $credentials->masked(), ); } return array( 'provider_credentials' => $providers, 'rendering' => $this->rendering->toArray(), 'links' => $this->links->toArray(), 'sync' => $this->sync->toArray(), 'cache' => $this->cache->toArray(), 'debug_mode' => $this->debugMode, 'openai' => array( 'configured' => $this->openAi->isConfigured(), 'api_key_masked' => $this->openAi->maskedApiKey(), 'model' => $this->openAi->model(), ), 'cloud' => array( 'enabled' => $this->cloud->enabled(), 'configured' => $this->cloud->isConfigured(), 'endpoint' => $this->cloud->endpoint(), 'site_id' => $this->cloud->siteId(), 'api_key_masked' => $this->cloud->maskedApiKey(), 'plan' => $this->cloud->plan(), 'article_draft' => array( 'tone' => $this->cloud->articleDraftTone(), 'target_length' => $this->cloud->articleDraftTargetLength(), 'review_source_policy' => $this->cloud->articleDraftReviewSourcePolicy(), ), ), ); } /** * Construct a snapshot from a {@see self::toArray()} payload. * * Performs full structural validation: missing keys, wrong * types, and the same per-field rules each sub-component's * `fromArray()` enforces all surface as * {@see InvalidArgumentException}. * * @param array $data Persisted payload. * * @throws InvalidArgumentException When `$data` is malformed. */ public static function fromArray( array $data ): self { if ( ! array_key_exists( 'provider_credentials', $data ) || ! is_array( $data['provider_credentials'] ) ) { $message = 'PluginSettings payload must carry an array `provider_credentials`.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( ! array_key_exists( 'rendering', $data ) || ! is_array( $data['rendering'] ) ) { $message = 'PluginSettings payload must carry an array `rendering`.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( ! array_key_exists( 'links', $data ) || ! is_array( $data['links'] ) ) { $message = 'PluginSettings payload must carry an array `links`.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( ! array_key_exists( 'sync', $data ) || ! is_array( $data['sync'] ) ) { $message = 'PluginSettings payload must carry an array `sync`.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( ! array_key_exists( 'cache', $data ) || ! is_array( $data['cache'] ) ) { $message = 'PluginSettings payload must carry an array `cache`.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( ! array_key_exists( 'debug_mode', $data ) || ! is_bool( $data['debug_mode'] ) ) { $message = 'PluginSettings payload must carry a bool `debug_mode`.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $openAi = OpenAiSettings::defaults(); if ( array_key_exists( 'openai', $data ) && is_array( $data['openai'] ) ) { $openAi = OpenAiSettings::fromArray( $data['openai'] ); } $cloud = CloudSettings::defaults(); if ( array_key_exists( 'cloud', $data ) && is_array( $data['cloud'] ) ) { $cloud = CloudSettings::fromArray( $data['cloud'] ); } $providers = array(); foreach ( $data['provider_credentials'] as $key => $entry ) { if ( ! is_string( $key ) ) { $message = 'PluginSettings provider_credentials keys must be strings.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( ! is_array( $entry ) ) { $message = sprintf( 'PluginSettings provider_credentials entry `%s` must be an array.', $key ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $providers[ $key ] = ProviderCredentials::fromArray( $entry ); } return new self( $providers, RenderingDefaults::fromArray( $data['rendering'] ), LinkDefaults::fromArray( $data['links'] ), SyncSettings::fromArray( $data['sync'] ), CacheSettings::fromArray( $data['cache'] ), $data['debug_mode'], $openAi, $cloud ); } /** * Validate and normalize the `providerCredentials` map at * construction. * * Keys must be strings that match the * {@see ProviderCredentials::providerKey()} of their entry; * entries must be {@see ProviderCredentials} instances. * * @param array $providerCredentials Raw map. * * @return array * * @throws InvalidArgumentException When any per-entry rule * is violated. */ private static function normalizeProviderCredentials( array $providerCredentials ): array { $result = array(); foreach ( $providerCredentials as $key => $value ) { if ( ! is_string( $key ) ) { $message = 'PluginSettings providerCredentials keys must be strings.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( ! $value instanceof ProviderCredentials ) { $message = sprintf( 'PluginSettings providerCredentials entry `%s` must be a ProviderCredentials instance.', $key ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $key !== $value->providerKey() ) { $message = sprintf( 'PluginSettings providerCredentials key `%s` must match entry providerKey `%s`.', $key, $value->providerKey() ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $result[ $key ] = $value; } return $result; } }