$section ) { add_settings_section( $section_id, $section['title'], '__return_empty_string', self::PAGE_SLUG ); } foreach ( $fields as $field ) { $section = $field['section'] ?? 'prautoblogger_api'; register_setting( self::OPTION_GROUP, $field['id'], [ 'type' => $field['wp_type'] ?? 'string', 'sanitize_callback' => [ $this, 'sanitize_field' ], 'default' => $field['default'] ?? '', ] ); add_settings_field( $field['id'], $field['label'], [ $this, 'render_field' ], self::PAGE_SLUG, $section, $field ); } } /** Enqueue admin CSS and JS on all PRAutoBlogger admin pages. */ public function on_enqueue_assets( string $hook_suffix ): void { $pages = [ 'toplevel_page_' . self::PAGE_SLUG, 'prautoblogger_page_prautoblogger-metrics', 'prautoblogger_page_prautoblogger-review-queue', 'prautoblogger_page_prautoblogger-logs', ]; if ( ! in_array( $hook_suffix, $pages, true ) ) { return; } wp_enqueue_style( 'prautoblogger-admin', PRAUTOBLOGGER_PLUGIN_URL . 'assets/css/admin.css', [], PRAUTOBLOGGER_VERSION ); wp_enqueue_style( 'prautoblogger-model-picker', PRAUTOBLOGGER_PLUGIN_URL . 'assets/css/model-picker.css', [ 'prautoblogger-admin' ], PRAUTOBLOGGER_VERSION ); wp_enqueue_script( 'prautoblogger-admin', PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/admin.js', [ 'jquery' ], PRAUTOBLOGGER_VERSION, true ); wp_enqueue_script( 'prautoblogger-model-picker', PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/model-picker.js', [ 'jquery', 'prautoblogger-admin' ], PRAUTOBLOGGER_VERSION, true ); wp_localize_script( 'prautoblogger-admin', 'prautobloggerAdmin', [ 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'generateNonce' => wp_create_nonce( 'prautoblogger_generate_now' ), 'imageNonce' => wp_create_nonce( 'prautoblogger_generate_image' ), 'testNonce' => wp_create_nonce( 'prautoblogger_test_connection' ), 'modelsNonce' => wp_create_nonce( 'prautoblogger_get_models' ), 'reviewNonce' => wp_create_nonce( 'prautoblogger_review_queue' ), 'generatingText' => __( 'Generating...', 'prautoblogger' ), 'generateText' => __( 'Generate Now', 'prautoblogger' ), 'testingText' => __( 'Testing...', 'prautoblogger' ), 'testText' => __( 'Test Connections', 'prautoblogger' ), // Static Cloudflare image models for the picker (no registry API for these). // FLUX.1 [dev] removed from Cloudflare Workers AI in April 2026. 'cloudflareModels' => [ [ 'id' => 'flux-1-schnell', 'name' => 'FLUX.1 [schnell]', 'provider' => 'cloudflare', 'context_length' => 0, 'input_price_per_m' => 0, 'output_price_per_m' => 0, 'cost_per_image' => 0.0007, 'capabilities' => [ 'cloudflare_image' ], 'deprecated' => false, 'description' => __( 'Fast + cheap (4 steps). Default choice.', 'prautoblogger' ), ], ], ] ); } /** Render the settings page (delegates to template). */ public function render_page(): void { if ( ! current_user_can( 'manage_options' ) ) { return; } $sections = PRAutoBlogger_Settings_Fields::get_sections(); $fields = PRAutoBlogger_Settings_Fields::get_fields(); include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/settings-page.php'; } /** * Render a single settings field based on its type. * * @param array $args Field definition. */ public function render_field( array $args ): void { $id = esc_attr( $args['id'] ); $type = $args['type'] ?? 'text'; $default = $args['default'] ?? ''; $desc = $args['description'] ?? ''; $value = get_option( $args['id'], $default ); if ( 'password' === $type && '' !== $value ) { $decrypted = PRAutoBlogger_Encryption::decrypt( $value ); $value = '' !== $decrypted ? '••••••••' : ''; } switch ( $type ) { case 'text': case 'number': case 'url': case 'time': $attrs = ''; if ( isset( $args['min'] ) ) { $attrs .= ' min="' . esc_attr( (string) $args['min'] ) . '"'; } if ( isset( $args['max'] ) ) { $attrs .= ' max="' . esc_attr( (string) $args['max'] ) . '"'; } if ( isset( $args['step'] ) ) { $attrs .= ' step="' . esc_attr( (string) $args['step'] ) . '"'; } printf( '', esc_attr( $type ), $id, $id, esc_attr( (string) $value ), $attrs ); break; case 'password': printf( '', $id, $id, '' !== $value ? esc_attr__( 'Saved (enter new value to change)', 'prautoblogger' ) : '' ); break; case 'textarea': printf( '', $id, $id, esc_textarea( (string) $value ) ); break; case 'select': printf( ''; break; case 'toggle': $checked = in_array( $value, [ '1', 'yes', true ], true ); printf( '', $id, $id, checked( $checked, true, false ) ); break; case 'checkboxes': $current = json_decode( (string) $value, true ) ?: []; foreach ( ( $args['options'] ?? [] ) as $v => $label ) { $is_checked = in_array( $v, $current, true ); $disabled = strpos( $label, 'coming soon' ) !== false ? ' disabled' : ''; printf( '', $id, esc_attr( $v ), checked( $is_checked, true, false ), $disabled, esc_html( $label ) ); } break; case 'model_select': $this->render_model_select_field( $id, (string) $value, $args ); break; case 'source_status': $this->render_source_status_field(); break; case 'author_select': wp_dropdown_users( [ 'name' => $id, 'id' => $id, 'selected' => absint( $value ), 'show_option_none' => __( '— Auto (first admin) —', 'prautoblogger' ), 'option_none_value' => '0', 'class' => 'ab-select' ] ); break; case 'category_select': wp_dropdown_categories( [ 'name' => $id, 'id' => $id, 'selected' => absint( $value ), 'show_option_none' => __( '— Auto-assign by type —', 'prautoblogger' ), 'option_none_value' => '0', 'class' => 'ab-select', 'hide_empty' => false ] ); break; } if ( isset( $args['badge'] ) ) { printf( ' %s', esc_html( $args['badge'] ) ); } if ( '' !== $desc ) { printf( '

%s

', esc_html( $desc ) ); } } /** * Delegate source status rendering to the dedicated field class. * * @see admin/fields/class-source-status-field.php */ private function render_source_status_field(): void { PRAutoBlogger_Source_Status_Field::render(); } /** * Delegate model picker rendering to the dedicated field class. * * @param string $id Field HTML id/name. * @param string $value Current model id. * @param array $args Field definition. * * @see admin/fields/class-openrouter-model-field.php */ private function render_model_select_field( string $id, string $value, array $args ): void { PRAutoBlogger_OpenRouter_Model_Field::render( $id, $value, $args ); } /** * Sanitize a settings field value. * * @param mixed $value The submitted value. * @return mixed Sanitized value. */ public function sanitize_field( $value ) { $option_name = ''; $filter = current_filter(); if ( 0 === strpos( $filter, 'sanitize_option_' ) ) { $option_name = substr( $filter, strlen( 'sanitize_option_' ) ); } $encrypted = [ 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json', 'prautoblogger_cloudflare_ai_token' ]; if ( in_array( $option_name, $encrypted, true ) ) { // Empty value means password field wasn't touched — keep existing. if ( '' === $value ) { return get_option( $option_name, '' ); } // Already encrypted (has "enc:" prefix) — return as-is. // This is the primary defence against double-encryption: // PRAutoBlogger_Encryption::encrypt() also checks for this prefix, // so even if this callback is called multiple times, the value // is encrypted exactly once and never re-encrypted. if ( PRAutoBlogger_Encryption::is_encrypted( $value ) ) { return $value; } // New plaintext value — encrypt it (adds "enc:" prefix). return PRAutoBlogger_Encryption::encrypt( sanitize_text_field( $value ) ); } $json_fields = [ 'prautoblogger_target_subreddits', 'prautoblogger_topic_exclusions', 'prautoblogger_enabled_sources' ]; if ( in_array( $option_name, $json_fields, true ) ) { // PHP array from checkboxes — sanitize each item and re-encode. if ( is_array( $value ) ) { return wp_json_encode( array_values( array_map( 'sanitize_text_field', $value ) ) ); } $trimmed = trim( (string) $value ); // Already JSON-encoded array — decode, sanitize each item, re-encode. // This prevents sanitize_text_field() from mangling the JSON string, // which caused json_decode() on read to return null → empty subreddits. if ( '[' === substr( $trimmed, 0, 1 ) ) { $decoded = json_decode( $trimmed, true ); if ( is_array( $decoded ) ) { return wp_json_encode( array_values( array_map( 'sanitize_text_field', $decoded ) ) ); } } // Comma-separated plain text — split, sanitize, encode as JSON array. $items = array_filter( array_map( 'trim', explode( ',', $trimmed ) ) ); return wp_json_encode( array_values( array_map( 'sanitize_text_field', $items ) ) ); } $numeric = [ 'prautoblogger_daily_article_target', 'prautoblogger_monthly_budget_usd', 'prautoblogger_min_word_count', 'prautoblogger_max_word_count', 'prautoblogger_default_author', 'prautoblogger_default_category', 'prautoblogger_pullpush_cache_ttl', 'prautoblogger_reddit_posts_per_subreddit' ]; if ( in_array( $option_name, $numeric, true ) ) { return is_numeric( $value ) ? $value : 0; } return sanitize_text_field( (string) $value ); } }