diff --git a/assets/css/model-picker.css b/assets/css/model-picker.css
new file mode 100644
index 0000000..b4feb85
--- /dev/null
+++ b/assets/css/model-picker.css
@@ -0,0 +1,206 @@
+/**
+ * Model picker popup styles for PRAutoBlogger admin.
+ *
+ * Renders a modal overlay with a searchable list of AI models.
+ * Uses the same design tokens as admin.css (--ab-* CSS variables).
+ *
+ * @see assets/js/model-picker.js — JS that builds and manages the popup DOM.
+ * @see assets/css/admin.css — Parent design system variables.
+ */
+
+/* ── Overlay ────────────────────────────────────────────────────────── */
+.ab-mp-overlay {
+ position: fixed; inset: 0; z-index: 100100;
+ background: rgba(0, 0, 0, 0.45);
+ display: flex; align-items: center; justify-content: center;
+ animation: ab-mp-fade-in 0.15s ease;
+}
+
+@keyframes ab-mp-fade-in {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+/* ── Popup container ────────────────────────────────────────────────── */
+.ab-mp-popup {
+ background: var(--ab-card-bg, #fff);
+ border-radius: 12px;
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
+ width: 640px; max-width: 90vw;
+ max-height: 80vh;
+ display: flex; flex-direction: column;
+ animation: ab-mp-slide-up 0.2s ease;
+}
+
+@keyframes ab-mp-slide-up {
+ from { opacity: 0; transform: translateY(12px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+/* ── Header ─────────────────────────────────────────────────────────── */
+.ab-mp-header {
+ display: flex; align-items: center; justify-content: space-between;
+ padding: 16px 20px; border-bottom: 1px solid var(--ab-border, #e2e8f0);
+}
+
+.ab-mp-title {
+ font-size: 16px; font-weight: 700;
+ color: var(--ab-text, #1e293b);
+}
+
+.ab-mp-close {
+ background: none; border: none; cursor: pointer;
+ font-size: 22px; line-height: 1; color: var(--ab-text-muted, #64748b);
+ padding: 4px 8px; border-radius: 4px;
+}
+.ab-mp-close:hover {
+ background: var(--ab-bg, #f8fafc);
+ color: var(--ab-text, #1e293b);
+}
+
+/* ── Search ─────────────────────────────────────────────────────────── */
+.ab-mp-search-wrap {
+ padding: 12px 20px;
+ border-bottom: 1px solid var(--ab-border, #e2e8f0);
+}
+
+.ab-mp-search {
+ width: 100%; box-sizing: border-box;
+ padding: 10px 14px; font-size: 14px;
+ border: 1px solid var(--ab-border, #e2e8f0);
+ border-radius: 8px; outline: none;
+ color: var(--ab-text, #1e293b);
+ background: var(--ab-bg, #f8fafc);
+}
+.ab-mp-search:focus {
+ border-color: var(--ab-primary, #4f46e5);
+ box-shadow: 0 0 0 3px var(--ab-primary-light, #eef2ff);
+ background: var(--ab-card-bg, #fff);
+}
+
+/* ── Model list ─────────────────────────────────────────────────────── */
+.ab-mp-list {
+ flex: 1; overflow-y: auto;
+ padding: 4px 0;
+ min-height: 200px; max-height: 50vh;
+}
+
+.ab-mp-loading, .ab-mp-empty {
+ padding: 40px 20px; text-align: center;
+ color: var(--ab-text-muted, #64748b); font-size: 14px;
+}
+
+/* ── Model row ──────────────────────────────────────────────────────── */
+.ab-mp-row {
+ display: flex; align-items: center; justify-content: space-between;
+ padding: 10px 20px; cursor: pointer;
+ transition: background 0.1s ease;
+ gap: 12px;
+}
+.ab-mp-row:hover {
+ background: var(--ab-primary-light, #eef2ff);
+}
+
+.ab-mp-row-main {
+ display: flex; align-items: center; gap: 8px;
+ flex: 1; min-width: 0;
+}
+
+.ab-mp-name {
+ font-size: 13px; font-weight: 600;
+ color: var(--ab-text, #1e293b);
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+}
+
+.ab-mp-provider {
+ font-size: 11px; font-weight: 500;
+ color: var(--ab-primary, #4f46e5);
+ background: var(--ab-primary-light, #eef2ff);
+ padding: 1px 6px; border-radius: 4px;
+ white-space: nowrap; flex-shrink: 0;
+}
+
+.ab-mp-dep-badge {
+ font-size: 10px; font-weight: 600;
+ color: var(--ab-warning, #d97706);
+ background: #fffbeb;
+ padding: 1px 6px; border-radius: 4px;
+ white-space: nowrap; flex-shrink: 0;
+}
+
+.ab-mp-deprecated {
+ opacity: 0.55;
+}
+
+.ab-mp-row-meta {
+ display: flex; align-items: center; gap: 16px;
+ flex-shrink: 0;
+}
+
+.ab-mp-price {
+ font-size: 12px; font-weight: 500;
+ color: var(--ab-text-muted, #64748b);
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+
+.ab-mp-ctx {
+ font-size: 11px; font-weight: 600;
+ color: var(--ab-text-muted, #64748b);
+ background: var(--ab-bg, #f8fafc);
+ padding: 2px 6px; border-radius: 4px;
+ white-space: nowrap; min-width: 36px; text-align: center;
+}
+
+/* ── Footer ─────────────────────────────────────────────────────────── */
+.ab-mp-footer {
+ display: flex; align-items: center; justify-content: space-between;
+ padding: 10px 20px;
+ border-top: 1px solid var(--ab-border, #e2e8f0);
+ font-size: 12px; color: var(--ab-text-muted, #64748b);
+}
+
+/* ── Trigger button (replaces text input) ───────────────────────────── */
+.ab-mp-trigger {
+ display: inline-flex; align-items: center; gap: 10px;
+ padding: 8px 14px;
+ background: var(--ab-card-bg, #fff);
+ border: 1px solid var(--ab-border, #e2e8f0);
+ border-radius: 6px; cursor: pointer;
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
+ max-width: 420px; width: 100%; box-sizing: border-box;
+ text-align: left;
+}
+.ab-mp-trigger:hover {
+ border-color: var(--ab-primary, #4f46e5);
+}
+.ab-mp-trigger:focus {
+ border-color: var(--ab-primary, #4f46e5);
+ box-shadow: 0 0 0 3px var(--ab-primary-light, #eef2ff);
+ outline: none;
+}
+
+.ab-mp-display-name {
+ font-size: 13px; font-weight: 600;
+ color: var(--ab-text, #1e293b);
+ flex: 1; min-width: 0;
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+}
+
+.ab-mp-display-price {
+ font-size: 11px; font-weight: 500;
+ color: var(--ab-text-muted, #64748b);
+ white-space: nowrap; flex-shrink: 0;
+}
+
+.ab-mp-display-arrow {
+ color: var(--ab-text-muted, #64748b);
+ font-size: 14px; flex-shrink: 0;
+}
+
+/* ── Responsive ─────────────────────────────────────────────────────── */
+@media screen and (max-width: 680px) {
+ .ab-mp-popup { width: 95vw; max-height: 85vh; }
+ .ab-mp-row { padding: 10px 14px; }
+ .ab-mp-row-meta { gap: 8px; }
+}
diff --git a/assets/js/model-picker.js b/assets/js/model-picker.js
new file mode 100644
index 0000000..ad25e2c
--- /dev/null
+++ b/assets/js/model-picker.js
@@ -0,0 +1,262 @@
+/**
+ * Model picker popup for PRAutoBlogger admin settings.
+ *
+ * Fetches the OpenRouter model registry via AJAX, then renders a searchable
+ * popup overlay when the user clicks a model-select trigger button. Shows
+ * name, provider, input/output cost, and context window per model.
+ *
+ * @see admin/class-admin-page.php — Renders model_select fields and localizes config.
+ * @see services/class-open-router-model-registry.php — Server-side registry source.
+ */
+(function ($) {
+ 'use strict';
+
+ var config = window.prautobloggerAdmin || {};
+ var cachedModels = null;
+ var fetchInProgress = null;
+
+ /**
+ * Fetch models from the server (cached after first call).
+ *
+ * @param {string} capability Filter capability (e.g. 'text→text').
+ * @return {jQuery.Deferred} Resolves with model array.
+ */
+ function fetchModels(capability) {
+ if (cachedModels) {
+ return $.Deferred().resolve(filterModels(cachedModels, capability));
+ }
+
+ if (fetchInProgress) {
+ return fetchInProgress.then(function () {
+ return filterModels(cachedModels, capability);
+ });
+ }
+
+ fetchInProgress = $.ajax({
+ url: config.ajaxUrl,
+ method: 'POST',
+ data: {
+ action: 'prautoblogger_get_models',
+ nonce: config.modelsNonce
+ },
+ timeout: 15000
+ }).then(function (response) {
+ fetchInProgress = null;
+ if (response.success && Array.isArray(response.data)) {
+ cachedModels = response.data;
+ return filterModels(cachedModels, capability);
+ }
+ cachedModels = [];
+ return [];
+ }, function () {
+ fetchInProgress = null;
+ cachedModels = [];
+ return [];
+ });
+
+ return fetchInProgress;
+ }
+
+ /**
+ * Filter model array by capability.
+ *
+ * @param {Array} models Full model list.
+ * @param {string} capability Capability string to match.
+ * @return {Array} Filtered models.
+ */
+ function filterModels(models, capability) {
+ if (!capability) return models;
+ return models.filter(function (m) {
+ return m.capabilities && m.capabilities.indexOf(capability) !== -1;
+ });
+ }
+
+ /**
+ * Format a price-per-million-tokens value for display.
+ *
+ * @param {number} price Price per 1M tokens.
+ * @return {string} Formatted string like "$0.25" or "Free".
+ */
+ function formatPrice(price) {
+ if (!price || price <= 0) return 'Free';
+ if (price < 0.01) return '$' + price.toFixed(4);
+ if (price < 1) return '$' + price.toFixed(2);
+ return '$' + price.toFixed(2);
+ }
+
+ /**
+ * Format context length for display.
+ *
+ * @param {number} ctx Context length in tokens.
+ * @return {string} Formatted string like "128K" or "1M".
+ */
+ function formatContext(ctx) {
+ if (!ctx) return '—';
+ if (ctx >= 1000000) return (ctx / 1000000).toFixed(0) + 'M';
+ if (ctx >= 1000) return Math.round(ctx / 1000) + 'K';
+ return ctx.toString();
+ }
+
+ /**
+ * Build HTML for a single model row in the picker list.
+ *
+ * @param {Object} model Normalized model record.
+ * @return {string} HTML string.
+ */
+ function renderModelRow(model) {
+ var deprecated = model.deprecated ? ' ab-mp-deprecated' : '';
+ return (
+ '
' +
+ '
' +
+ '' + escHtml(model.name) + ' ' +
+ '' + escHtml(model.provider) + ' ' +
+ (model.deprecated ? 'deprecated ' : '') +
+ '
' +
+ '
' +
+ '' +
+ formatPrice(model.input_price_per_m) + ' / ' + formatPrice(model.output_price_per_m) +
+ ' ' +
+ '' + formatContext(model.context_length) + ' ' +
+ '
' +
+ '
'
+ );
+ }
+
+ /** Minimal HTML escaping for display. */
+ function escHtml(str) {
+ if (!str) return '';
+ return str.replace(/&/g, '&').replace(//g, '>');
+ }
+
+ /** Escape a string for use in an HTML attribute. */
+ function escAttr(str) {
+ return escHtml(str).replace(/"/g, '"');
+ }
+
+ /**
+ * Open the model picker popup for a given trigger button.
+ *
+ * @param {jQuery} $trigger The .ab-mp-trigger button that was clicked.
+ */
+ function openPicker($trigger) {
+ var fieldId = $trigger.data('field-id');
+ var capability = $trigger.data('capability') || '';
+
+ // Build overlay + popup structure.
+ var $overlay = $('
');
+ var $popup = $(
+ ''
+ );
+
+ $overlay.append($popup);
+ $('body').append($overlay);
+
+ var $search = $popup.find('.ab-mp-search');
+ var $list = $popup.find('.ab-mp-list');
+ var $count = $popup.find('.ab-mp-count');
+ var allModels = [];
+
+ // Focus search after render.
+ setTimeout(function () { $search.focus(); }, 50);
+
+ // Fetch and render model list.
+ fetchModels(capability).then(function (models) {
+ allModels = models;
+ renderList(models, '');
+ });
+
+ /** Render filtered model list. */
+ function renderList(models, query) {
+ var filtered = models;
+ if (query) {
+ var q = query.toLowerCase();
+ filtered = models.filter(function (m) {
+ return (m.name && m.name.toLowerCase().indexOf(q) !== -1) ||
+ (m.id && m.id.toLowerCase().indexOf(q) !== -1) ||
+ (m.provider && m.provider.toLowerCase().indexOf(q) !== -1);
+ });
+ }
+
+ // Sort: non-deprecated first, then by provider + name.
+ filtered.sort(function (a, b) {
+ if (a.deprecated !== b.deprecated) return a.deprecated ? 1 : -1;
+ var pa = (a.provider || '').toLowerCase();
+ var pb = (b.provider || '').toLowerCase();
+ if (pa !== pb) return pa < pb ? -1 : 1;
+ return (a.name || '').toLowerCase().localeCompare((b.name || '').toLowerCase());
+ });
+
+ if (filtered.length === 0) {
+ $list.html('No models found.
');
+ } else {
+ var html = '';
+ for (var i = 0; i < filtered.length; i++) {
+ html += renderModelRow(filtered[i]);
+ }
+ $list.html(html);
+ }
+ $count.text(filtered.length + ' model' + (filtered.length !== 1 ? 's' : ''));
+ }
+
+ // Search input handler (debounced).
+ var searchTimer = null;
+ $search.on('input', function () {
+ var q = $(this).val();
+ clearTimeout(searchTimer);
+ searchTimer = setTimeout(function () {
+ renderList(allModels, q);
+ }, 150);
+ });
+
+ // Select a model.
+ $list.on('click', '.ab-mp-row', function () {
+ var id = $(this).data('model-id');
+ var model = allModels.find(function (m) { return m.id === id; });
+ if (!model) return;
+
+ // Update hidden input + display.
+ $('#' + fieldId).val(model.id);
+ $trigger.find('.ab-mp-display-name').text(model.name || model.id);
+ $trigger.find('.ab-mp-display-price').text(
+ formatPrice(model.input_price_per_m) + ' / ' + formatPrice(model.output_price_per_m)
+ );
+ closePicker();
+ });
+
+ // Close handlers.
+ function closePicker() {
+ $overlay.remove();
+ }
+
+ $popup.find('.ab-mp-close').on('click', closePicker);
+ $overlay.on('click', function (e) {
+ if ($(e.target).is('.ab-mp-overlay')) closePicker();
+ });
+ $(document).on('keydown.abModelPicker', function (e) {
+ if (e.key === 'Escape') {
+ closePicker();
+ $(document).off('keydown.abModelPicker');
+ }
+ });
+ }
+
+ // Bind click on all model picker triggers.
+ $(document).on('click', '.ab-mp-trigger', function (e) {
+ e.preventDefault();
+ openPicker($(this));
+ });
+
+})(jQuery);
diff --git a/includes/admin/class-admin-page.php b/includes/admin/class-admin-page.php
index 0f157e1..f2bd518 100755
--- a/includes/admin/class-admin-page.php
+++ b/includes/admin/class-admin-page.php
@@ -65,13 +65,16 @@ class PRAutoBlogger_Admin_Page {
}
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' ),
@@ -152,6 +155,10 @@ class PRAutoBlogger_Admin_Page {
}
break;
+ case 'model_select':
+ $this->render_model_select_field( $id, (string) $value, $args );
+ break;
+
case 'source_status':
$this->render_source_status_field();
break;
@@ -174,58 +181,25 @@ class PRAutoBlogger_Admin_Page {
}
/**
- * Render the Reddit source status indicator.
+ * Delegate source status rendering to the dedicated field class.
*
- * Shows Reddit RSS (primary) and .json (fallback) availability
- * with live status checks via transient cache and rate limit info.
- *
- * Side effects: Reads transients for cached availability status.
- *
- * @return void
+ * @see admin/fields/class-source-status-field.php
*/
private function render_source_status_field(): void {
- $last_collection = get_option( 'prautoblogger_last_collection_time', '' );
- $last_source_used = get_option( 'prautoblogger_last_source_used', '' );
+ PRAutoBlogger_Source_Status_Field::render();
+ }
- echo '';
-
- // Reddit RSS — primary source.
- printf(
- '
'
- . ' '
- . '%s %s '
- . '%s '
- . '
',
- esc_html__( 'Reddit RSS', 'prautoblogger' ),
- esc_html__( 'Primary', 'prautoblogger' ),
- esc_html__( 'Reliable from all IPs', 'prautoblogger' )
- );
-
- // Reddit .json — fallback source.
- printf(
- '
'
- . ' '
- . '%s %s '
- . '%s '
- . '
',
- esc_html__( 'Reddit .json', 'prautoblogger' ),
- esc_html__( 'Fallback + Comments', 'prautoblogger' ),
- esc_html__( 'May be IP-blocked on some hosts', 'prautoblogger' )
- );
-
- // Last collection info.
- if ( '' !== $last_collection ) {
- $time_ago = human_time_diff( (int) $last_collection, time() );
- printf(
- '
%s %s %s
',
- esc_html__( 'Last collection:', 'prautoblogger' ),
- /* translators: %s is a human-readable time difference like "2 hours" */
- esc_html( sprintf( __( '%s ago', 'prautoblogger' ), $time_ago ) ),
- '' !== $last_source_used ? esc_html( sprintf( __( 'via %s', 'prautoblogger' ), $last_source_used ) ) : ''
- );
- }
-
- echo '
';
+ /**
+ * 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 );
}
/**
diff --git a/includes/admin/class-settings-fields.php b/includes/admin/class-settings-fields.php
index 8bc55d9..3b059dd 100755
--- a/includes/admin/class-settings-fields.php
+++ b/includes/admin/class-settings-fields.php
@@ -125,27 +125,30 @@ class PRAutoBlogger_Settings_Fields {
[
'id' => 'prautoblogger_analysis_model',
'label' => __( 'Analysis Model', 'prautoblogger' ),
- 'type' => 'text',
+ 'type' => 'model_select',
'section' => 'prautoblogger_models',
'default' => PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL,
+ 'capability' => 'text→text',
'description' => __( 'Used for source analysis. Pick a cheap, fast model.', 'prautoblogger' ),
'badge' => __( 'Low cost', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_writing_model',
'label' => __( 'Writing Model', 'prautoblogger' ),
- 'type' => 'text',
+ 'type' => 'model_select',
'section' => 'prautoblogger_models',
'default' => PRAUTOBLOGGER_DEFAULT_WRITING_MODEL,
+ 'capability' => 'text→text',
'description' => __( 'Used for article generation. Quality matters here.', 'prautoblogger' ),
'badge' => __( 'Quality', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_editor_model',
'label' => __( 'Editor Model', 'prautoblogger' ),
- 'type' => 'text',
+ 'type' => 'model_select',
'section' => 'prautoblogger_models',
'default' => PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL,
+ 'capability' => 'text→text',
'description' => __( 'Used for the chief editor review pass.', 'prautoblogger' ),
'badge' => __( 'Quality', 'prautoblogger' ),
],
diff --git a/includes/admin/fields/class-open-router-model-field.php b/includes/admin/fields/class-open-router-model-field.php
new file mode 100644
index 0000000..b3d9fbb
--- /dev/null
+++ b/includes/admin/fields/class-open-router-model-field.php
@@ -0,0 +1,78 @@
+ $args Field definition from settings fields.
+ */
+ public static function render( string $id, string $value, array $args ): void {
+ $capability = $args['capability'] ?? 'text→text';
+ $display_name = $value ?: __( '— Select a model —', 'prautoblogger' );
+ $display_price = '';
+
+ // Try to resolve display name + price from the cached registry.
+ $registry = prautoblogger()->get_executor()->get_model_registry();
+ $model = $registry->find_model( $value );
+ if ( null !== $model ) {
+ $display_name = $model['name'] ?? $value;
+ $in = (float) ( $model['input_price_per_m'] ?? 0 );
+ $out = (float) ( $model['output_price_per_m'] ?? 0 );
+ $display_price = self::format_price( $in ) . ' / ' . self::format_price( $out );
+ }
+
+ // Hidden input carries the actual value for the form submission.
+ printf(
+ ' ',
+ esc_attr( $id ),
+ esc_attr( $id ),
+ esc_attr( $value )
+ );
+
+ // Trigger button — opens the model picker popup via JS.
+ printf(
+ ''
+ . '%s '
+ . '%s '
+ . '▾ '
+ . ' ',
+ esc_attr( $id ),
+ esc_attr( $capability ),
+ esc_html( $display_name ),
+ esc_html( $display_price )
+ );
+ }
+
+ /**
+ * Format a price-per-million-tokens value for display.
+ *
+ * @param float $price Price per 1M tokens.
+ * @return string Formatted price string like "$0.25" or "Free".
+ */
+ private static function format_price( float $price ): string {
+ if ( $price <= 0 ) {
+ return __( 'Free', 'prautoblogger' );
+ }
+ if ( $price < 0.01 ) {
+ return '$' . number_format( $price, 4 );
+ }
+ return '$' . number_format( $price, 2 );
+ }
+}
diff --git a/includes/admin/fields/class-source-status-field.php b/includes/admin/fields/class-source-status-field.php
new file mode 100644
index 0000000..5c0a62f
--- /dev/null
+++ b/includes/admin/fields/class-source-status-field.php
@@ -0,0 +1,66 @@
+';
+
+ // Reddit RSS — primary source.
+ printf(
+ ''
+ . ' '
+ . '%s %s '
+ . '%s '
+ . '
',
+ esc_html__( 'Reddit RSS', 'prautoblogger' ),
+ esc_html__( 'Primary', 'prautoblogger' ),
+ esc_html__( 'Reliable from all IPs', 'prautoblogger' )
+ );
+
+ // Reddit .json — fallback source.
+ printf(
+ ''
+ . ' '
+ . '%s %s '
+ . '%s '
+ . '
',
+ esc_html__( 'Reddit .json', 'prautoblogger' ),
+ esc_html__( 'Fallback + Comments', 'prautoblogger' ),
+ esc_html__( 'May be IP-blocked on some hosts', 'prautoblogger' )
+ );
+
+ // Last collection info.
+ if ( '' !== $last_collection ) {
+ $time_ago = human_time_diff( (int) $last_collection, time() );
+ printf(
+ '%s %s %s
',
+ esc_html__( 'Last collection:', 'prautoblogger' ),
+ /* translators: %s is a human-readable time difference like "2 hours" */
+ esc_html( sprintf( __( '%s ago', 'prautoblogger' ), $time_ago ) ),
+ '' !== $last_source_used ? esc_html( sprintf( __( 'via %s', 'prautoblogger' ), $last_source_used ) ) : ''
+ );
+ }
+
+ echo '';
+ }
+}
diff --git a/includes/class-autoloader.php b/includes/class-autoloader.php
index 8543373..471d363 100755
--- a/includes/class-autoloader.php
+++ b/includes/class-autoloader.php
@@ -22,6 +22,7 @@ class PRAutoBlogger_Autoloader {
private static array $directories = [
'', // includes/ root
'admin/',
+ 'admin/fields/',
'core/',
'providers/',
'models/',
diff --git a/includes/class-executor.php b/includes/class-executor.php
index 2bd1a53..deb7b02 100644
--- a/includes/class-executor.php
+++ b/includes/class-executor.php
@@ -152,6 +152,34 @@ class PRAutoBlogger_Executor {
}
}
+ /**
+ * AJAX handler: return the model registry for the admin model picker.
+ *
+ * Returns the full cached model list. If the registry is empty, triggers
+ * a refresh first. Called by the model-picker.js popup.
+ *
+ * Side effects: may trigger an OpenRouter API call if registry is empty.
+ */
+ public function on_ajax_get_models(): void {
+ check_ajax_referer( 'prautoblogger_get_models', 'nonce' );
+
+ if ( ! current_user_can( 'manage_options' ) ) {
+ wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ], 403 );
+ return;
+ }
+
+ $registry = $this->get_model_registry();
+ $models = $registry->get_models();
+
+ // Auto-refresh if the registry has never been populated.
+ if ( empty( $models ) ) {
+ $registry->refresh( true );
+ $models = $registry->get_models();
+ }
+
+ wp_send_json_success( $models );
+ }
+
/** AJAX handler: test API connections (OpenRouter, Reddit). */
public function on_ajax_test_connection(): void {
check_ajax_referer( 'prautoblogger_test_connection', 'nonce' );
diff --git a/includes/class-prautoblogger.php b/includes/class-prautoblogger.php
index 4a045db..31c1d9b 100644
--- a/includes/class-prautoblogger.php
+++ b/includes/class-prautoblogger.php
@@ -94,6 +94,7 @@ class PRAutoBlogger {
add_action( 'wp_ajax_prautoblogger_generate_now', [ $this->executor, 'on_ajax_generate_now' ] );
add_action( 'wp_ajax_prautoblogger_generate_image', [ $this->executor, 'on_ajax_generate_image' ] );
add_action( 'wp_ajax_prautoblogger_test_connection', [ $this->executor, 'on_ajax_test_connection' ] );
+ add_action( 'wp_ajax_prautoblogger_get_models', [ $this->executor, 'on_ajax_get_models' ] );
$review_queue = new PRAutoBlogger_Review_Queue();
add_action( 'wp_ajax_prautoblogger_approve_post', [ $review_queue, 'on_ajax_approve_post' ] );