diff --git a/admin/class-peptide-news-admin.php b/admin/class-peptide-news-admin.php
index 177dbd8..7ccffa8 100644
--- a/admin/class-peptide-news-admin.php
+++ b/admin/class-peptide-news-admin.php
@@ -568,6 +568,13 @@ class Peptide_News_Admin {
echo '';
echo '';
+ // Generate AI Summaries button.
+ echo '
';
+ echo '';
+ echo '';
+
?>
prefix . 'peptide_news_articles';
@@ -370,6 +374,63 @@ class Peptide_News_LLM {
}
}
+ /**
+ * AJAX handler: bulk-generate AI summaries for articles that are missing them.
+ *
+ * Processes up to 50 articles per request. Returns the count of articles
+ * processed and remaining so the admin UI can loop until done.
+ *
+ * @since 2.0.1
+ */
+ public static function ajax_generate_summaries() {
+ check_ajax_referer( 'peptide_news_admin', 'nonce' );
+
+ if ( ! current_user_can( 'manage_options' ) ) {
+ wp_send_json_error( 'Unauthorized', 403 );
+ }
+
+ if ( ! self::is_enabled() ) {
+ wp_send_json_error( 'AI Analysis is not enabled. Please enable it and set an OpenRouter API key in Peptide News → Settings.' );
+ }
+
+ global $wpdb;
+ $table = $wpdb->prefix . 'peptide_news_articles';
+
+ // Count how many still need processing.
+ $remaining = (int) $wpdb->get_var(
+ "SELECT COUNT(*) FROM {$table}
+ WHERE is_active = 1
+ AND ( ai_summary = '' OR ai_summary IS NULL )"
+ );
+
+ if ( $remaining < 1 ) {
+ wp_send_json_success( array(
+ 'processed' => 0,
+ 'remaining' => 0,
+ 'message' => 'All articles already have AI summaries.',
+ ) );
+ }
+
+ // Process a generous batch — the 120-second timeout guard inside
+ // process_unanalyzed() will stop early if needed. Override the
+ // per-cycle limit since this is a manual admin action.
+ $batch_size = min( 50, $remaining );
+ $processed = self::process_unanalyzed( $batch_size, true );
+
+ // Recount remaining after this batch.
+ $still_remaining = (int) $wpdb->get_var(
+ "SELECT COUNT(*) FROM {$table}
+ WHERE is_active = 1
+ AND ( ai_summary = '' OR ai_summary IS NULL )"
+ );
+
+ wp_send_json_success( array(
+ 'processed' => $processed,
+ 'remaining' => $still_remaining,
+ 'message' => sprintf( '%d article(s) summarized, %d remaining.', $processed, $still_remaining ),
+ ) );
+ }
+
/**
* Clear all article transient caches.
*/
diff --git a/includes/class-peptide-news.php b/includes/class-peptide-news.php
index 7dee51b..25a0cc3 100644
--- a/includes/class-peptide-news.php
+++ b/includes/class-peptide-news.php
@@ -81,6 +81,7 @@ class Peptide_News {
$this->loader->add_action( 'peptide_news_cron_fetch', $fetcher, 'fetch_all_sources' );
$this->loader->add_filter( 'cron_schedules', $fetcher, 'add_custom_cron_schedules' );
$this->loader->add_action( 'wp_ajax_peptide_news_backfill_sources', $fetcher, 'ajax_backfill_sources' );
+ $this->loader->add_action( 'wp_ajax_peptide_news_generate_summaries', 'Peptide_News_LLM', 'ajax_generate_summaries' );
}
/**
diff --git a/peptide-news-plugin/admin/class-peptide-news-admin.php b/peptide-news-plugin/admin/class-peptide-news-admin.php
index 5fb863e..7ccffa8 100644
--- a/peptide-news-plugin/admin/class-peptide-news-admin.php
+++ b/peptide-news-plugin/admin/class-peptide-news-admin.php
@@ -55,36 +55,698 @@ class Peptide_News_Admin {
wp_enqueue_script(
$this->plugin_name . '-admin',
PEPTIDE_NEWS_PLUGIN_URL . 'admin/js/admin-script.js',
- array( 'jquery', 'dashboard' ),
+ array( 'jquery', 'chartjs' ),
$this->version,
true
);
+
+ wp_localize_script( $this->plugin_name . '-admin', 'peptideNewsAdmin', array(
+ 'ajax_url' => admin_url( 'admin-ajax.php' ),
+ 'rest_url' => rest_url( 'peptide-news/v1/' ),
+ 'nonce' => wp_create_nonce( 'wp_rest' ),
+ 'admin_nonce' => wp_create_nonce( 'peptide_news_admin' ),
+ ) );
}
/**
- * Register the settings page.
+ * Add admin menu pages.
*/
- public function register_settings_page() {
- add_management_page(
- 'manage',
- 'Peptide News',
- 'Peptide News',
+ public function add_admin_menu() {
+ // Main menu — Analytics Dashboard.
+ add_menu_page(
+ __( 'Peptide News', 'peptide-news' ),
+ __( 'Peptide News', 'peptide-news' ),
'manage_options',
+ 'peptide-news-dashboard',
+ array( $this, 'render_dashboard_page' ),
+ 'dashicons-rss',
+ 30
+ );
+
+ // Sub-menu — Settings.
+ add_submenu_page(
+ 'peptide-news-dashboard',
+ __( 'Settings', 'peptide-news' ),
+ __( 'Settings', 'peptide-news' ),
+ 'manage_options',
+ 'peptide-news-settings',
array( $this, 'render_settings_page' )
);
+
+ // Sub-menu — Articles.
+ add_submenu_page(
+ 'peptide-news-dashboard',
+ __( 'Articles', 'peptide-news' ),
+ __( 'Articles', 'peptide-news' ),
+ 'manage_options',
+ 'peptide-news-articles',
+ array( $this, 'render_articles_page' )
+ );
}
/**
- * Render the settings page.
+ * Register all settings.
*/
- public function render_settings_page() {
- require PEPTIDE_NEWS_PLUGIN_DIR . 'admin/partials/settings-page.php';
+ public function register_settings() {
+
+ // --- General section ---
+ add_settings_section(
+ 'peptide_news_general',
+ __( 'General Settings', 'peptide-news' ),
+ function () {
+ echo '
' . esc_html__( 'Configure how and when peptide news articles are fetched.', 'peptide-news' ) . '
'; + }, + 'peptide-news-settings' + ); + + $this->add_setting( 'fetch_interval', __( 'Fetch Interval', 'peptide-news' ), 'render_fetch_interval_field', 'peptide_news_general' ); + $this->add_setting( 'articles_count', __( 'Articles to Display', 'peptide-news' ), 'render_articles_count_field', 'peptide_news_general' ); + $this->add_setting( 'search_keywords', __( 'Search Keywords', 'peptide-news' ), 'render_search_keywords_field', 'peptide_news_general' ); + + // --- RSS section --- + add_settings_section( + 'peptide_news_rss', + __( 'RSS Feed Sources', 'peptide-news' ), + function () { + echo '' . esc_html__( 'Add RSS feed URLs, one per line.', 'peptide-news' ) . '
'; + }, + 'peptide-news-settings' + ); + + $this->add_setting( 'rss_enabled', __( 'Enable RSS Feeds', 'peptide-news' ), 'render_rss_enabled_field', 'peptide_news_rss' ); + $this->add_setting( 'rss_feeds', __( 'RSS Feed URLs', 'peptide-news' ), 'render_rss_feeds_field', 'peptide_news_rss' ); + + // --- NewsAPI section --- + add_settings_section( + 'peptide_news_newsapi', + __( 'NewsAPI.org', 'peptide-news' ), + function () { + echo '' . esc_html__( 'Optional. Get a free API key at newsapi.org.', 'peptide-news' ) . '
'; + }, + 'peptide-news-settings' + ); + + $this->add_setting( 'newsapi_enabled', __( 'Enable NewsAPI', 'peptide-news' ), 'render_newsapi_enabled_field', 'peptide_news_newsapi' ); + $this->add_setting( 'newsapi_key', __( 'API Key', 'peptide-news' ), 'render_newsapi_key_field', 'peptide_news_newsapi' ); + + // --- Analytics section --- + add_settings_section( + 'peptide_news_analytics_section', + __( 'Analytics', 'peptide-news' ), + function () { + echo '' . esc_html__( 'Configure click tracking and data retention.', 'peptide-news' ) . '
'; + }, + 'peptide-news-settings' + ); + + $this->add_setting( 'article_retention', __( 'Article Retention (days)', 'peptide-news' ), 'render_article_retention_field', 'peptide_news_analytics_section' ); + $this->add_setting( 'analytics_retention', __( 'Analytics Data Retention (days)', 'peptide-news' ), 'render_retention_field', 'peptide_news_analytics_section' ); + $this->add_setting( 'anonymize_ip', __( 'Anonymize IPs', 'peptide-news' ), 'render_anonymize_ip_field', 'peptide_news_analytics_section' ); + + // --- Content Filter section --- + add_settings_section( + 'peptide_news_filter_section', + __( 'Content Filter', 'peptide-news' ), + function () { + echo '' . esc_html__( 'Filter out ads, press releases, and promotional content during fetch. Articles that match the rules are discarded before being saved.', 'peptide-news' ) . '
'; + $last_run = get_option( 'peptide_news_filter_last_run' ); + if ( $last_run && is_array( $last_run ) ) { + printf( + '%s: %s | %s: %d | %s: %d | %s: %d | %s: %d
', + esc_html__( 'Last filter run', 'peptide-news' ), + esc_html( $last_run['time'] ), + esc_html__( 'Evaluated', 'peptide-news' ), + absint( $last_run['total'] ), + esc_html__( 'Removed', 'peptide-news' ), + absint( $last_run['removed'] ), + esc_html__( 'LLM checked', 'peptide-news' ), + absint( $last_run['llm_checked'] ), + esc_html__( 'Passed', 'peptide-news' ), + absint( $last_run['passed'] ) + ); + } + }, + 'peptide-news-settings' + ); + + $this->add_setting( 'filter_enabled', __( 'Enable Content Filter', 'peptide-news' ), 'render_filter_enabled_field', 'peptide_news_filter_section' ); + $this->add_setting( 'filter_sensitivity', __( 'Filter Sensitivity', 'peptide-news' ), 'render_filter_sensitivity_field', 'peptide_news_filter_section' ); + $this->add_setting( 'filter_llm_enabled', __( 'LLM Classification', 'peptide-news' ), 'render_filter_llm_enabled_field', 'peptide_news_filter_section' ); + $this->add_setting( 'filter_llm_model', __( 'Filter LLM Model', 'peptide-news' ), 'render_filter_llm_model_field', 'peptide_news_filter_section' ); + $this->add_setting( 'filter_title_keywords', __( 'Title Keywords', 'peptide-news' ), 'render_filter_title_keywords_field', 'peptide_news_filter_section' ); + $this->add_setting( 'filter_body_keywords', __( 'Body Keywords', 'peptide-news' ), 'render_filter_body_keywords_field', 'peptide_news_filter_section' ); + $this->add_setting( 'filter_blocked_domains', __( 'Blocked Domains', 'peptide-news' ), 'render_filter_blocked_domains_field', 'peptide_news_filter_section' ); + + // --- AI / LLM section --- + add_settings_section( + 'peptide_news_llm_section', + __( 'AI Analysis (OpenRouter)', 'peptide-news' ), + function () { + echo '' . esc_html__( 'Configure LLM-powered keyword extraction and article summarization via OpenRouter.', 'peptide-news' ) . '
'; + }, + 'peptide-news-settings' + ); + + $this->add_setting( 'llm_enabled', __( 'Enable AI Analysis', 'peptide-news' ), 'render_llm_enabled_field', 'peptide_news_llm_section' ); + $this->add_setting( 'openrouter_api_key', __( 'OpenRouter API Key', 'peptide-news' ), 'render_openrouter_key_field', 'peptide_news_llm_section' ); + $this->add_setting( 'llm_keywords_model', __( 'Keywords Model', 'peptide-news' ), 'render_llm_keywords_model_field', 'peptide_news_llm_section' ); + $this->add_setting( 'llm_summary_model', __( 'Summary Model', 'peptide-news' ), 'render_llm_summary_model_field', 'peptide_news_llm_section' ); + + $this->add_setting( 'llm_max_articles', __( 'Max Articles per Cycle', 'peptide-news' ), 'render_llm_max_articles_field', 'peptide_news_llm_section' ); } /** - * Render the analytics dashboard. + * Helper: register a single setting with a field callback. */ - public function render_dashboard() { - require PEPTIDE_NEWS_PLUGIN_DIR . 'admin/partials/dashboard.php'; + private function add_setting( $key, $label, $callback, $section ) { + $option_name = "peptide_news_{$key}"; + + $sanitize_cb = $this->get_sanitize_callback( $key ); + + register_setting( 'peptide_news_settings_group', $option_name, array( + 'sanitize_callback' => $sanitize_cb, + ) ); + + add_settings_field( + $option_name, + $label, + array( $this, $callback ), + 'peptide-news-settings', + $section + ); + } + + /** + * Return the appropriate sanitize callback for a given setting key. + * + * @param string $key Setting key (without prefix). + * @return callable + */ + private function get_sanitize_callback( $key ) { + $callbacks = array( + 'fetch_interval' => 'sanitize_text_field', + 'articles_count' => 'absint', + 'search_keywords' => 'sanitize_text_field', + 'rss_enabled' => 'absint', + 'rss_feeds' => 'sanitize_textarea_field', + 'newsapi_enabled' => 'absint', + 'newsapi_key' => 'sanitize_text_field', + 'article_retention' => 'absint', + 'analytics_retention' => 'absint', + 'anonymize_ip' => 'absint', + 'filter_enabled' => 'absint', + 'filter_sensitivity' => 'sanitize_text_field', + 'filter_llm_enabled' => 'absint', + 'filter_llm_model' => array( $this, 'sanitize_model_id' ), + 'filter_title_keywords' => 'sanitize_textarea_field', + 'filter_body_keywords' => 'sanitize_textarea_field', + 'filter_blocked_domains' => 'sanitize_textarea_field', + 'llm_enabled' => 'absint', + 'openrouter_api_key' => 'sanitize_text_field', + 'llm_keywords_model' => array( $this, 'sanitize_model_id' ), + 'llm_summary_model' => array( $this, 'sanitize_model_id' ), + 'llm_max_articles' => 'absint', + ); + + return isset( $callbacks[ $key ] ) ? $callbacks[ $key ] : 'sanitize_text_field'; + } + + /** + * Sanitize and validate an OpenRouter model ID. + * + * @param string $value Raw input. + * @return string Sanitized model ID or default. + */ + public function sanitize_model_id( $value ) { + $value = sanitize_text_field( $value ); + if ( ! empty( $value ) && class_exists( 'Peptide_News_LLM' ) && ! Peptide_News_LLM::is_valid_model( $value ) ) { + add_settings_error( + 'peptide_news_settings_group', + 'invalid_model', + sprintf( + /* translators: %s: model ID */ + __( 'Invalid model ID format: %s. Expected format: provider/model-name', 'peptide-news' ), + esc_html( $value ) + ), + 'error' + ); + return 'google/gemini-2.0-flash-001'; + } + return $value; + } + + // --- Field renderers --- + + public function render_fetch_interval_field() { + $value = get_option( 'peptide_news_fetch_interval', 'twicedaily' ); + $options = array( + 'every_fifteen_minutes' => __( 'Every 15 minutes', 'peptide-news' ), + 'every_thirty_minutes' => __( 'Every 30 minutes', 'peptide-news' ), + 'hourly' => __( 'Hourly', 'peptide-news' ), + 'every_four_hours' => __( 'Every 4 hours', 'peptide-news' ), + 'every_six_hours' => __( 'Every 6 hours', 'peptide-news' ), + 'twicedaily' => __( 'Twice daily', 'peptide-news' ), + 'daily' => __( 'Daily', 'peptide-news' ), + ); + + echo ''; + echo '' . esc_html__( 'How often to check for new articles. After changing, deactivate and reactivate the plugin.', 'peptide-news' ) . '
'; + } + + public function render_articles_count_field() { + $value = get_option( 'peptide_news_articles_count', 10 ); + printf( + '', + absint( $value ) + ); + echo '' . esc_html__( 'Number of articles to display in the shortcode/widget.', 'peptide-news' ) . '
'; + } + + public function render_search_keywords_field() { + $value = get_option( 'peptide_news_search_keywords', '' ); + printf( + '', + esc_attr( $value ) + ); + echo '' . esc_html__( 'Comma-separated keywords for NewsAPI searches.', 'peptide-news' ) . '
'; + } + + public function render_rss_enabled_field() { + $value = get_option( 'peptide_news_rss_enabled', 1 ); + printf( + '', + checked( $value, 1, false ), + esc_html__( 'Fetch articles from RSS feeds', 'peptide-news' ) + ); + } + + public function render_rss_feeds_field() { + $value = get_option( 'peptide_news_rss_feeds', '' ); + printf( + '', + esc_textarea( $value ) + ); + } + + public function render_newsapi_enabled_field() { + $value = get_option( 'peptide_news_newsapi_enabled', 0 ); + printf( + '', + checked( $value, 1, false ), + esc_html__( 'Fetch articles from NewsAPI.org', 'peptide-news' ) + ); + } + + public function render_newsapi_key_field() { + $value = get_option( 'peptide_news_newsapi_key', '' ); + printf( + '', + esc_attr( $value ) + ); + } + + public function render_article_retention_field() { + $value = get_option( 'peptide_news_article_retention', 90 ); + printf( + '', + absint( $value ) + ); + echo '' . esc_html__( 'How long to keep fetched articles before deactivating them.', 'peptide-news' ) . '
'; + } + + public function render_retention_field() { + $value = get_option( 'peptide_news_analytics_retention', 365 ); + printf( + '', + absint( $value ) + ); + echo '' . esc_html__( 'How long to keep click analytics data.', 'peptide-news' ) . '
'; + } + + public function render_anonymize_ip_field() { + $value = get_option( 'peptide_news_anonymize_ip', 1 ); + printf( + '', + checked( $value, 1, false ), + esc_html__( 'Anonymize IP addresses for GDPR compliance', 'peptide-news' ) + ); + } + + // --- Content Filter field renderers --- + + public function render_filter_enabled_field() { + $value = get_option( 'peptide_news_filter_enabled', 1 ); + printf( + '', + checked( $value, 1, false ), + esc_html__( 'Filter out ads, press releases, and promotional content during fetch', 'peptide-news' ) + ); + } + + public function render_filter_sensitivity_field() { + $value = get_option( 'peptide_news_filter_sensitivity', 'moderate' ); + $options = array( + 'strict' => __( 'Strict — block aggressively (may catch some legitimate articles)', 'peptide-news' ), + 'moderate' => __( 'Moderate — balanced filtering (recommended)', 'peptide-news' ), + 'lenient' => __( 'Lenient — only block obvious promotional content', 'peptide-news' ), + ); + + echo ''; + } + + public function render_filter_llm_enabled_field() { + $value = get_option( 'peptide_news_filter_llm_enabled', 0 ); + printf( + '', + checked( $value, 1, false ), + esc_html__( 'Use LLM to classify borderline articles (requires AI Analysis to be enabled)', 'peptide-news' ) + ); + } + + public function render_filter_llm_model_field() { + $value = get_option( 'peptide_news_filter_llm_model', '' ); + printf( + '', + esc_attr( $value ) + ); + echo '' . esc_html__( 'OpenRouter model for content classification. Leave blank to use the Keywords Model above.', 'peptide-news' ) . '
'; + } + + public function render_filter_title_keywords_field() { + $value = get_option( 'peptide_news_filter_title_keywords', '' ); + $placeholder = class_exists( 'Peptide_News_Content_Filter' ) + ? Peptide_News_Content_Filter::get_default_title_keywords_text() + : ''; + printf( + '', + esc_attr( $placeholder ), + esc_textarea( $value ) + ); + echo '' . esc_html__( 'One keyword/phrase per line. Articles with these in the title are blocked. Leave empty to use built-in defaults.', 'peptide-news' ) . '
'; + } + + public function render_filter_body_keywords_field() { + $value = get_option( 'peptide_news_filter_body_keywords', '' ); + $placeholder = class_exists( 'Peptide_News_Content_Filter' ) + ? Peptide_News_Content_Filter::get_default_body_keywords_text() + : ''; + printf( + '', + esc_attr( $placeholder ), + esc_textarea( $value ) + ); + echo '' . esc_html__( 'One keyword/phrase per line. Multiple matches in article body trigger filtering (threshold depends on sensitivity). Leave empty to use built-in defaults.', 'peptide-news' ) . '
'; + } + + public function render_filter_blocked_domains_field() { + $value = get_option( 'peptide_news_filter_blocked_domains', '' ); + $placeholder = class_exists( 'Peptide_News_Content_Filter' ) + ? Peptide_News_Content_Filter::get_default_blocked_domains_text() + : ''; + printf( + '', + esc_attr( $placeholder ), + esc_textarea( $value ) + ); + echo '' . esc_html__( 'One domain per line. Articles from these sources are always blocked. Leave empty to use built-in defaults.', 'peptide-news' ) . '
'; + } + + // --- AI / LLM field renderers --- + + public function render_llm_enabled_field() { + $value = get_option( 'peptide_news_llm_enabled', 0 ); + printf( + '', + checked( $value, 1, false ), + esc_html__( 'Analyze articles with AI during each fetch cycle', 'peptide-news' ) + ); + } + + public function render_openrouter_key_field() { + $value = get_option( 'peptide_news_openrouter_api_key', '' ); + printf( + '', + esc_attr( $value ) + ); + echo '' . wp_kses( + __( 'Get an API key at openrouter.ai/keys.', 'peptide-news' ), + array( 'a' => array( 'href' => array(), 'target' => array(), 'rel' => array() ) ) + ) . '
'; + } + + public function render_llm_keywords_model_field() { + $value = get_option( 'peptide_news_llm_keywords_model', 'google/gemini-2.0-flash-001' ); + printf( + '', + esc_attr( $value ) + ); + echo '' . esc_html__( 'OpenRouter model ID for keyword extraction (e.g., google/gemini-2.0-flash-001, openai/gpt-4o-mini).', 'peptide-news' ) . '
'; + } + + public function render_llm_summary_model_field() { + $value = get_option( 'peptide_news_llm_summary_model', 'google/gemini-2.0-flash-001' ); + printf( + '', + esc_attr( $value ) + ); + echo '' . esc_html__( 'OpenRouter model ID for article summarization (e.g., google/gemini-2.0-flash-001, anthropic/claude-3.5-sonnet).', 'peptide-news' ) . '
'; + } + + public function render_llm_max_articles_field() { + $value = get_option( 'peptide_news_llm_max_articles', 10 ); + printf( + '', + absint( $value ) + ); + echo '' . esc_html__( 'Maximum number of articles to analyze with AI per fetch cycle. Controls API costs and prevents cron timeouts.', 'peptide-news' ) . '
'; + } + + // --- Page renderers --- + + public function render_settings_page() { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + + // Handle manual fetch trigger. + if ( isset( $_POST['peptide_news_fetch_now'] ) && check_admin_referer( 'peptide_news_fetch_now_action' ) ) { + $fetcher = new Peptide_News_Fetcher(); + $fetcher->fetch_all_sources(); + echo '' . esc_html__( 'Fetch completed!', 'peptide-news' ) . '
%s: %s | %s: %d | %s: %d%s
', + esc_html__( 'Last fetch', 'peptide-news' ), + esc_html( $last_fetch['time'] ), + esc_html__( 'Found', 'peptide-news' ), + $last_fetch['found'], + esc_html__( 'New stored', 'peptide-news' ), + $last_fetch['new_stored'], + $extra_info + ); + } + + // Settings form. + echo ''; + + // Shortcode usage info. + echo '' . esc_html__( 'Shortcode:', 'peptide-news' ) . ' [peptide_news] ' .
+ esc_html__( 'or', 'peptide-news' ) . ' [peptide_news count="5"]
' . esc_html__( 'Widget:', 'peptide-news' ) . ' ' . + esc_html__( 'Add "Peptide News" from Appearance > Widgets.', 'peptide-news' ) . '
'; + + echo '