diff --git a/includes/class-psa-admin-page.php b/includes/class-psa-admin-page.php
new file mode 100644
index 0000000..4250995
--- /dev/null
+++ b/includes/class-psa-admin-page.php
@@ -0,0 +1,221 @@
+ admin_url( 'admin-ajax.php' ),
+ 'nonce' => wp_create_nonce( 'psa_batch_enrich' ),
+ ) );
+ }
+
+ // ── Field renderers ─────────────────────────────────────────────────
+
+ public static function render_section_description(): void {
+ printf(
+ '
' . __( 'This plugin uses OpenRouter to access AI models.', 'peptide-search-ai' ) . '
',
+ esc_url( 'https://openrouter.ai/' )
+ );
+ }
+
+ public static function render_api_key_field(): void {
+ if ( defined( 'PSA_OPENROUTER_KEY' ) && PSA_OPENROUTER_KEY ) {
+ echo ' ';
+ echo '' . esc_html__( 'Configured via wp-config.php', 'peptide-search-ai' ) . '';
+ echo '' . esc_html__( 'The API key is securely set using the PSA_OPENROUTER_KEY constant.', 'peptide-search-ai' ) . '
';
+ } else {
+ $value = PSA_Admin::get_setting( 'api_key', '' );
+ $display_value = ! empty( $value ) ? '••••••••' : '';
+ printf(
+ '',
+ esc_attr( $display_value )
+ );
+ printf(
+ '%s %s.
',
+ esc_html__( 'Your OpenRouter API key.', 'peptide-search-ai' ),
+ esc_html__( 'Get one here', 'peptide-search-ai' )
+ );
+ }
+ }
+
+ public static function render_model_field(): void {
+ $value = PSA_Admin::get_setting( 'ai_model', '' );
+ printf(
+ '',
+ esc_attr( $value ),
+ esc_attr__( 'Leave blank for default', 'peptide-search-ai' )
+ );
+ echo '' . esc_html__( 'Model for full content generation. Default: google/gemini-2.5-flash', 'peptide-search-ai' ) . '
';
+ }
+
+ public static function render_validation_model_field(): void {
+ $value = PSA_Admin::get_setting( 'validation_model', '' );
+ printf(
+ '',
+ esc_attr( $value ),
+ esc_attr__( 'Leave blank for default', 'peptide-search-ai' )
+ );
+ echo '' . esc_html__( 'Lighter model for name validation. Default: google/gemini-2.0-flash-001', 'peptide-search-ai' ) . '
';
+ }
+
+ public static function render_publish_field(): void {
+ $value = PSA_Admin::get_setting( 'auto_publish', 'draft' );
+ echo '';
+ }
+
+ public static function render_pubchem_field(): void {
+ $value = PSA_Admin::get_setting( 'use_pubchem', '1' );
+ printf(
+ '',
+ checked( $value, '1', false ),
+ esc_html__( 'Cross-reference with PubChem for verified molecular data', 'peptide-search-ai' )
+ );
+ }
+
+ public static function render_budget_field(): void {
+ $value = PSA_Admin::get_setting( 'monthly_budget', PSA_Config::DEFAULT_MONTHLY_BUDGET );
+ printf(
+ '',
+ esc_attr( $value )
+ );
+ echo '' . esc_html__( 'Set to 0 for unlimited.', 'peptide-search-ai' ) . '
';
+ }
+
+ // ── Settings page ───────────────────────────────────────────────────
+
+ public static function render_settings_page(): void {
+ $migrate_url = wp_nonce_url( admin_url( 'options-general.php?page=peptide-search-ai&psa_action=migrate_categories' ), 'psa_migrate_categories' );
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
[peptide_search] —
+
[peptide_directory] —
+
+
+
+
GET /wp-json/peptides/v1/search?q=BPC-157 —
+
GET /wp-json/peptide-search-ai/v1/compounds?search=&category=&page=1 —
+
+
+
+
+
+ $
+ 0 ) : ?>
+ ⚠ includes estimates
+
+
+
+
+
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+
+
+
+
+ token_source ) && 'estimated' === $log->token_source ); ?>
+ >
+ | created_at ); ?> |
+ model ); ?> |
+ ~' : ''; ?>total_tokens ) ); ?>' : ''; ?> |
+ ~$' : '$'; ?>estimated_cost_usd, 4 ) ); ?>' : ''; ?> |
+ request_type ); ?> |
+ peptide_name ); ?> |
+ success ) ? '✓' : '✗'; ?> |
+
+
+
+
+ array( __CLASS__, 'sanitize_settings' ),
- )
- );
+ register_setting( 'psa_settings_group', 'psa_settings', array(
+ 'sanitize_callback' => array( __CLASS__, 'sanitize_settings' ),
+ ) );
- // OpenRouter Section.
- add_settings_section( 'psa_ai_section', __( 'OpenRouter API Settings', 'peptide-search-ai' ), array( __CLASS__, 'render_section_description' ), 'peptide-search-ai' );
- add_settings_field( 'api_key', __( 'API Key', 'peptide-search-ai' ), array( __CLASS__, 'render_api_key_field' ), 'peptide-search-ai', 'psa_ai_section' );
- add_settings_field( 'ai_model', __( 'Generation Model', 'peptide-search-ai' ), array( __CLASS__, 'render_model_field' ), 'peptide-search-ai', 'psa_ai_section' );
- add_settings_field( 'validation_model', __( 'Validation Model', 'peptide-search-ai' ), array( __CLASS__, 'render_validation_model_field' ), 'peptide-search-ai', 'psa_ai_section' );
+ add_settings_section( 'psa_ai_section', __( 'OpenRouter API Settings', 'peptide-search-ai' ), array( 'PSA_Admin_Page', 'render_section_description' ), 'peptide-search-ai' );
+ add_settings_field( 'api_key', __( 'API Key', 'peptide-search-ai' ), array( 'PSA_Admin_Page', 'render_api_key_field' ), 'peptide-search-ai', 'psa_ai_section' );
+ add_settings_field( 'ai_model', __( 'Generation Model', 'peptide-search-ai' ), array( 'PSA_Admin_Page', 'render_model_field' ), 'peptide-search-ai', 'psa_ai_section' );
+ add_settings_field( 'validation_model', __( 'Validation Model', 'peptide-search-ai' ), array( 'PSA_Admin_Page', 'render_validation_model_field' ), 'peptide-search-ai', 'psa_ai_section' );
- // Behavior Section.
add_settings_section( 'psa_behavior_section', __( 'Behavior Settings', 'peptide-search-ai' ), null, 'peptide-search-ai' );
- add_settings_field( 'auto_publish', __( 'Auto-Publish', 'peptide-search-ai' ), array( __CLASS__, 'render_publish_field' ), 'peptide-search-ai', 'psa_behavior_section' );
- add_settings_field( 'use_pubchem', __( 'PubChem Enrichment', 'peptide-search-ai' ), array( __CLASS__, 'render_pubchem_field' ), 'peptide-search-ai', 'psa_behavior_section' );
- add_settings_field( 'monthly_budget', __( 'Monthly API Budget (USD)', 'peptide-search-ai' ), array( __CLASS__, 'render_budget_field' ), 'peptide-search-ai', 'psa_behavior_section' );
+ add_settings_field( 'auto_publish', __( 'Auto-Publish', 'peptide-search-ai' ), array( 'PSA_Admin_Page', 'render_publish_field' ), 'peptide-search-ai', 'psa_behavior_section' );
+ add_settings_field( 'use_pubchem', __( 'PubChem Enrichment', 'peptide-search-ai' ), array( 'PSA_Admin_Page', 'render_pubchem_field' ), 'peptide-search-ai', 'psa_behavior_section' );
+ add_settings_field( 'monthly_budget', __( 'Monthly API Budget (USD)', 'peptide-search-ai' ), array( 'PSA_Admin_Page', 'render_budget_field' ), 'peptide-search-ai', 'psa_behavior_section' );
}
/**
* Sanitize settings input.
*
- * @param array $input Raw input from the form.
+ * @param array $input Raw form input.
* @return array Sanitized settings.
*/
public static function sanitize_settings( array $input ): array {
@@ -79,23 +70,20 @@ class PSA_Admin {
$encrypted = PSA_Encryption::encrypt( $sanitized );
$api_key_value = ( false !== $encrypted ) ? $encrypted : '';
} else {
- $existing = self::get_setting( 'api_key', '' );
- $api_key_value = $existing;
+ $api_key_value = self::get_setting( 'api_key', '' );
}
return array(
- 'api_key' => $api_key_value,
- 'ai_model' => sanitize_text_field( $input['ai_model'] ?? '' ),
- 'validation_model' => sanitize_text_field( $input['validation_model'] ?? '' ),
- 'auto_publish' => sanitize_text_field( $input['auto_publish'] ?? 'draft' ),
- 'use_pubchem' => ! empty( $input['use_pubchem'] ) ? '1' : '0',
- 'monthly_budget' => max( 0, floatval( $input['monthly_budget'] ?? PSA_Config::DEFAULT_MONTHLY_BUDGET ) ),
+ 'api_key' => $api_key_value,
+ 'ai_model' => sanitize_text_field( $input['ai_model'] ?? '' ),
+ 'validation_model' => sanitize_text_field( $input['validation_model'] ?? '' ),
+ 'auto_publish' => sanitize_text_field( $input['auto_publish'] ?? 'draft' ),
+ 'use_pubchem' => ! empty( $input['use_pubchem'] ) ? '1' : '0',
+ 'monthly_budget' => max( 0, floatval( $input['monthly_budget'] ?? PSA_Config::DEFAULT_MONTHLY_BUDGET ) ),
);
}
- /**
- * Handle admin actions (migration, re-enrich) via GET params with nonce verification.
- */
+ /** Handle admin actions (migration, re-enrich) via GET params with nonce verification. */
public static function handle_admin_actions(): void {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( ! isset( $_GET['psa_action'] ) || ! current_user_can( 'manage_options' ) ) {
@@ -107,53 +95,36 @@ class PSA_Admin {
if ( 'migrate_categories' === $action && wp_verify_nonce( $nonce, 'psa_migrate_categories' ) ) {
self::run_category_migration();
- add_action(
- 'admin_notices',
- function () {
- echo '' . esc_html__( 'Category migration completed.', 'peptide-search-ai' ) . '
';
- }
- );
+ add_action( 'admin_notices', function () {
+ echo '' . esc_html__( 'Category migration completed.', 'peptide-search-ai' ) . '
';
+ } );
}
if ( 'reenrich' === $action && wp_verify_nonce( $nonce, 'psa_reenrich' ) ) {
$queued = self::queue_reenrichment();
- add_action(
- 'admin_notices',
- function () use ( $queued ) {
- echo '' . esc_html(
- sprintf(
- /* translators: %d = number of peptides queued */
- __( '%d peptides queued for re-enrichment.', 'peptide-search-ai' ),
- $queued
- )
- ) . '
';
- }
- );
+ add_action( 'admin_notices', function () use ( $queued ) {
+ echo '' . esc_html( sprintf(
+ __( '%d peptides queued for re-enrichment.', 'peptide-search-ai' ), $queued
+ ) ) . '
';
+ } );
}
}
- // -------------------------------------------------------------------------
- // Migration: assign categories to existing peptides
- // -------------------------------------------------------------------------
+ // ── Category migration ──────────────────────────────────────────────
/**
- * One-time migration: assign peptide_category terms to existing peptide posts.
- *
- * Reads the free-text psa_category meta from each published peptide and maps
- * it to a taxonomy term. Safe to run multiple times (idempotent).
+ * Assign peptide_category terms to existing posts from free-text meta.
+ * Idempotent — safe to run multiple times.
*/
private static function run_category_migration(): void {
- $posts = get_posts(
- array(
- 'post_type' => 'peptide',
- 'post_status' => array( 'publish', 'draft' ),
- 'posts_per_page' => -1,
- 'fields' => 'ids',
- )
- );
+ $posts = get_posts( array(
+ 'post_type' => 'peptide',
+ 'post_status' => array( 'publish', 'draft' ),
+ 'posts_per_page' => -1,
+ 'fields' => 'ids',
+ ) );
foreach ( $posts as $post_id ) {
- // Skip posts that already have a category term assigned.
$terms = wp_get_object_terms( $post_id, 'peptide_category' );
if ( ! is_wp_error( $terms ) && ! empty( $terms ) ) {
continue;
@@ -167,7 +138,6 @@ class PSA_Admin {
continue;
}
- // Use the same mapping logic as the AI generator.
$slug = self::map_category_to_slug( $text );
if ( ! empty( $slug ) ) {
wp_set_object_terms( $post_id, $slug, 'peptide_category' );
@@ -210,7 +180,7 @@ class PSA_Admin {
return $map[ $lower ];
}
- // Fuzzy match.
+ // Fuzzy match against registered default categories.
foreach ( PSA_Post_Type::DEFAULT_CATEGORIES as $slug => $name ) {
if ( stripos( $text, $name ) !== false || stripos( $text, str_replace( '-', ' ', $slug ) ) !== false ) {
return $slug;
@@ -220,46 +190,32 @@ class PSA_Admin {
return '';
}
- // -------------------------------------------------------------------------
- // Re-enrichment: queue background regeneration for posts missing new fields
- // -------------------------------------------------------------------------
+ // ── Re-enrichment ───────────────────────────────────────────────────
/**
- * Queue re-enrichment for published peptides missing the new extended fields.
- *
- * Schedules WP-Cron events for each peptide that needs regeneration.
+ * Queue re-enrichment for published peptides missing extended fields.
* Respects the daily generation cap to avoid cost overruns.
*
* @return int Number of peptides queued.
*/
private static function queue_reenrichment(): int {
- $posts = get_posts(
- array(
- 'post_type' => 'peptide',
- 'post_status' => 'publish',
- 'posts_per_page' => -1,
- 'fields' => 'ids',
- // Only select posts missing the half_life field (proxy for "needs extended data").
- 'meta_query' => array(
- 'relation' => 'OR',
- array(
- 'key' => 'psa_half_life',
- 'compare' => 'NOT EXISTS',
- ),
- array(
- 'key' => 'psa_half_life',
- 'value' => '',
- ),
- ),
- )
- );
+ $posts = get_posts( array(
+ 'post_type' => 'peptide',
+ 'post_status' => 'publish',
+ 'posts_per_page' => -1,
+ 'fields' => 'ids',
+ 'meta_query' => array(
+ 'relation' => 'OR',
+ array( 'key' => 'psa_half_life', 'compare' => 'NOT EXISTS' ),
+ array( 'key' => 'psa_half_life', 'value' => '' ),
+ ),
+ ) );
$daily_key = 'psa_daily_gen_' . gmdate( 'Y-m-d' );
$daily_count = (int) get_transient( $daily_key );
$queued = 0;
foreach ( $posts as $post_id ) {
- // Respect daily cap.
if ( ( $daily_count + $queued ) >= PSA_Config::DAILY_GENERATION_CAP ) {
break;
}
@@ -269,29 +225,19 @@ class PSA_Admin {
continue;
}
- // Mark as pending so the generator picks it up, then schedule.
update_post_meta( $post_id, 'psa_source', 'pending' );
- wp_update_post(
- array(
- 'ID' => $post_id,
- 'post_status' => 'draft',
- )
- );
+ wp_update_post( array( 'ID' => $post_id, 'post_status' => 'draft' ) );
update_post_meta( $post_id, 'psa_generation_started', time() );
$args = array( $post_id, $post->post_title );
if ( ! wp_next_scheduled( 'psa_generate_peptide_background', $args ) ) {
- // Stagger jobs 30 seconds apart to avoid rate limit hammering.
+ // Stagger 30s apart to avoid rate-limit hammering.
wp_schedule_single_event( time() + ( $queued * 30 ), 'psa_generate_peptide_background', $args );
}
-
$queued++;
}
- // Bump daily counter.
set_transient( $daily_key, $daily_count + $queued, DAY_IN_SECONDS );
-
- // Kick off the cron runner.
if ( $queued > 0 ) {
spawn_cron();
}
@@ -299,262 +245,46 @@ class PSA_Admin {
return $queued;
}
- /**
- * Enqueue batch enrichment script on the PSA settings page only.
- *
- * @param string $hook The admin page hook suffix.
- */
- public static function enqueue_batch_scripts( string $hook ): void {
- if ( 'settings_page_peptide-search-ai' !== $hook ) {
- return;
- }
-
- wp_enqueue_script(
- 'psa-batch-enrich',
- PSA_PLUGIN_URL . 'assets/js/psa-batch-enrich.js',
- array( 'jquery' ),
- PSA_VERSION,
- true
- );
-
- wp_localize_script(
- 'psa-batch-enrich',
- 'psaBatchEnrich',
- array(
- 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
- 'nonce' => wp_create_nonce( 'psa_batch_enrich' ),
- )
- );
- }
-
- // -------------------------------------------------------------------------
- // Field renderers
- // -------------------------------------------------------------------------
-
- public static function render_section_description(): void {
- ?>
-
- OpenRouter to access AI models.', 'peptide-search-ai' ),
- esc_url( 'https://openrouter.ai/' )
- );
- ?>
-
-
-
-
-
-
-
-
-
- .
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
[peptide_search] —
-
[peptide_directory] —
-
-
-
-
GET /wp-json/peptides/v1/search?q=BPC-157 —
-
GET /wp-json/peptide-search-ai/v1/compounds?search=&category=&page=1 —
-
-
-
-
-
- $
- 0 ) : ?>
- ⚠ includes estimates
-
-
-
-
-
-
-
-
-
-
-
-
- |
- |
- |
- |
- |
- |
- |
-
-
-
-
- token_source ) && 'estimated' === $log->token_source ); ?>
- >
- | created_at ); ?> |
- model ); ?> |
-
-
- ~total_tokens ) ); ?>
-
- total_tokens ) ); ?>
-
- |
-
-
- ~$estimated_cost_usd, 4 ) ); ?>
-
- $estimated_cost_usd, 4 ) ); ?>
-
- |
- request_type ); ?> |
- peptide_name ); ?> |
- success ) ? '✓' : '✗'; ?> |
-
-
-
-
-
-