diff --git a/includes/admin/class-admin-page.php b/includes/admin/class-admin-page.php index 61881cb..f954999 100755 --- a/includes/admin/class-admin-page.php +++ b/includes/admin/class-admin-page.php @@ -279,7 +279,21 @@ class PRAutoBlogger_Admin_Page { if ( in_array( $option_name, $numeric, true ) ) { return is_numeric( $value ) ? $value : 0; } - + if ( 'prautoblogger_image_model' === $option_name ) { + return $this->sanitize_image_model( (string) $value ); + } return sanitize_text_field( (string) $value ); } + + /** Validate model id against the registry and persist the derived provider. */ + private function sanitize_image_model( string $value ): string { + $candidate = sanitize_text_field( $value ); + $provider = PRAutoBlogger_Image_Model_Registry::provider_for( $candidate ); + if ( '' !== $provider ) { + update_option( 'prautoblogger_image_provider', $provider ); + return $candidate; + } + add_settings_error( 'prautoblogger_image_model', 'prautoblogger_image_model_unknown', sprintf( esc_html__( 'Image model "%s" is not in the registry. Keeping the previous selection.', 'prautoblogger' ), esc_html( $candidate ) ) ); + return (string) get_option( 'prautoblogger_image_model', PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL ); + } } diff --git a/includes/admin/class-image-model-registry.php b/includes/admin/class-image-model-registry.php index 92401e2..cd37bd8 100644 --- a/includes/admin/class-image-model-registry.php +++ b/includes/admin/class-image-model-registry.php @@ -80,4 +80,20 @@ class PRAutoBlogger_Image_Model_Registry { ], ]; } + + /** + * Return the provider id for a known model, or empty string if the + * model id is not in the registry. + * + * @param string $model_id Model slug from the admin UI. + * @return string Provider id ('openrouter' | 'cloudflare' | ''). + */ + public static function provider_for( string $model_id ): string { + foreach ( self::get_models() as $model ) { + if ( ( $model['id'] ?? '' ) === $model_id ) { + return (string) ( $model['provider'] ?? '' ); + } + } + return ''; + } } diff --git a/includes/admin/class-settings-fields-extended.php b/includes/admin/class-settings-fields-extended.php index 390bfce..8431457 100644 --- a/includes/admin/class-settings-fields-extended.php +++ b/includes/admin/class-settings-fields-extended.php @@ -210,18 +210,6 @@ class PRAutoBlogger_Settings_Fields_Extended { 'default' => '1', 'description' => __( 'Generate a second image from source data for A/B testing. Disabling saves one image generation + one LLM prompt rewrite per article.', 'prautoblogger' ), ], - [ - 'id' => 'prautoblogger_image_provider', - 'label' => __( 'Image Provider', 'prautoblogger' ), - 'type' => 'select', - 'section' => 'prautoblogger_images', - 'default' => 'openrouter', - 'options' => [ - 'openrouter' => __( 'OpenRouter (multiple models available)', 'prautoblogger' ), - 'cloudflare' => __( 'Cloudflare Workers AI', 'prautoblogger' ), - ], - 'description' => __( 'OpenRouter reuses your existing API key and offers higher-quality models. Cloudflare is cheaper but lower quality.', 'prautoblogger' ), - ], [ 'id' => 'prautoblogger_image_model', 'label' => __( 'Image Model', 'prautoblogger' ), @@ -229,7 +217,7 @@ class PRAutoBlogger_Settings_Fields_Extended { 'section' => 'prautoblogger_images', 'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL, 'capability' => 'image_generation', - 'description' => __( 'Pick a model from your selected provider.', 'prautoblogger' ), + 'description' => __( 'Pick an image model. The provider (OpenRouter or Cloudflare Workers AI) is derived from the model registry on save, so mismatched pairs are no longer possible.', 'prautoblogger' ), 'badge' => __( 'Quality', 'prautoblogger' ), ], [ diff --git a/includes/class-prautoblogger.php b/includes/class-prautoblogger.php index 8a9a98a..2a26c16 100644 --- a/includes/class-prautoblogger.php +++ b/includes/class-prautoblogger.php @@ -160,6 +160,20 @@ class PRAutoBlogger { update_option( 'prautoblogger_migrated_gemini_flash_lite', '1' ); } + // One-time migration (v0.8.0): the admin no longer has an independent + // Image Provider dropdown; provider is derived from the image model on + // save. Auto-heal any existing site where the saved provider doesn't + // match the saved model's provider (the root cause of posts 650/657 + // silently missing their featured image on 2026-04-20). Runs once. + if ( ! get_option( 'prautoblogger_migrated_image_provider_v080' ) ) { + $saved_model = (string) get_option( 'prautoblogger_image_model', '' ); + $provider = PRAutoBlogger_Image_Model_Registry::provider_for( $saved_model ); + if ( '' !== $provider ) { + update_option( 'prautoblogger_image_provider', $provider ); + } + update_option( 'prautoblogger_migrated_image_provider_v080', '1' ); + } + // One-time migration (v3): switch to single-panel newspaper comic style. // Replaces both the old infomercial pastiche and the short-lived premium // photography style. Force-update unless the user has a truly custom value. diff --git a/tests/unit/Services/ImageModelRegistryTest.php b/tests/unit/Services/ImageModelRegistryTest.php new file mode 100644 index 0000000..f6235c1 --- /dev/null +++ b/tests/unit/Services/ImageModelRegistryTest.php @@ -0,0 +1,69 @@ +assertNotEmpty( $models ); + + foreach ( $models as $model ) { + $this->assertArrayHasKey( 'id', $model ); + $this->assertNotEmpty( $model['id'] ); + $this->assertArrayHasKey( 'provider', $model ); + $this->assertContains( + $model['provider'], + [ 'openrouter', 'cloudflare' ], + sprintf( 'Unexpected provider %s for model %s', (string) $model['provider'], (string) $model['id'] ) + ); + } + } + + /** + * provider_for() returns the paired provider for a known model id. + * This is the core of the v0.8.0 collapsed-dropdown contract. + */ + public function test_provider_for_returns_paired_provider_for_known_model(): void { + $this->assertSame( + 'cloudflare', + \PRAutoBlogger_Image_Model_Registry::provider_for( 'flux-1-schnell' ) + ); + $this->assertSame( + 'openrouter', + \PRAutoBlogger_Image_Model_Registry::provider_for( 'google/gemini-2.5-flash-image' ) + ); + } + + /** + * Unknown model ids return an empty string so callers can distinguish + * "not in registry" from a valid provider and show a settings error. + */ + public function test_provider_for_returns_empty_for_unknown_model(): void { + $this->assertSame( + '', + \PRAutoBlogger_Image_Model_Registry::provider_for( 'nonexistent/model' ) + ); + $this->assertSame( + '', + \PRAutoBlogger_Image_Model_Registry::provider_for( '' ) + ); + } +}