Introduces Peptide_News_Logger class with leveled logging (info, warning, error, debug) to a custom DB table, auto-pruning at 2000 rows. Adds a filterable, paginated log viewer to the admin settings page. Instruments the LLM and Fetcher classes with structured log calls for fetch cycles, AI processing, and error tracking. Version bumped to 2.1.0. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
899 lines
42 KiB
PHP
899 lines
42 KiB
PHP
<?php
|
|
/**
|
|
* Admin-specific functionality.
|
|
*
|
|
* Registers the settings page, analytics dashboard, and admin assets.
|
|
*
|
|
* @since 1.0.0
|
|
*/
|
|
class Peptide_News_Admin {
|
|
|
|
/** @var string */
|
|
private $plugin_name;
|
|
|
|
/** @var string */
|
|
private $version;
|
|
|
|
public function __construct( $plugin_name, $version ) {
|
|
$this->plugin_name = $plugin_name;
|
|
$this->version = $version;
|
|
}
|
|
|
|
/**
|
|
* Enqueue admin CSS.
|
|
*/
|
|
public function enqueue_styles( $hook ) {
|
|
if ( strpos( $hook, 'peptide-news' ) === false ) {
|
|
return;
|
|
}
|
|
wp_enqueue_style(
|
|
$this->plugin_name . '-admin',
|
|
PEPTIDE_NEWS_PLUGIN_URL . 'admin/css/admin-style.css',
|
|
array(),
|
|
$this->version,
|
|
'all'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Enqueue admin JS.
|
|
*/
|
|
public function enqueue_scripts( $hook ) {
|
|
if ( strpos( $hook, 'peptide-news' ) === false ) {
|
|
return;
|
|
}
|
|
|
|
// Chart.js for analytics dashboard.
|
|
wp_enqueue_script(
|
|
'chartjs',
|
|
'https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js',
|
|
array(),
|
|
'4.4.0',
|
|
true
|
|
);
|
|
|
|
wp_enqueue_script(
|
|
$this->plugin_name . '-admin',
|
|
PEPTIDE_NEWS_PLUGIN_URL . 'admin/js/admin-script.js',
|
|
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' ),
|
|
) );
|
|
}
|
|
|
|
/**
|
|
* Add admin menu pages.
|
|
*/
|
|
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' )
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Register all settings.
|
|
*/
|
|
public function register_settings() {
|
|
|
|
// --- General section ---
|
|
add_settings_section(
|
|
'peptide_news_general',
|
|
__( 'General Settings', 'peptide-news' ),
|
|
function () {
|
|
echo '<p>' . esc_html__( 'Configure how and when peptide news articles are fetched.', 'peptide-news' ) . '</p>';
|
|
},
|
|
'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 '<p>' . esc_html__( 'Add RSS feed URLs, one per line.', 'peptide-news' ) . '</p>';
|
|
},
|
|
'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 '<p>' . esc_html__( 'Optional. Get a free API key at newsapi.org.', 'peptide-news' ) . '</p>';
|
|
},
|
|
'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 '<p>' . esc_html__( 'Configure click tracking and data retention.', 'peptide-news' ) . '</p>';
|
|
},
|
|
'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 '<p>' . esc_html__( 'Filter out ads, press releases, and promotional content during fetch. Articles that match the rules are discarded before being saved.', 'peptide-news' ) . '</p>';
|
|
$last_run = get_option( 'peptide_news_filter_last_run' );
|
|
if ( $last_run && is_array( $last_run ) ) {
|
|
printf(
|
|
'<p class="description"><strong>%s:</strong> %s | %s: %d | %s: %d | %s: %d | %s: %d</p>',
|
|
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 '<p>' . esc_html__( 'Configure LLM-powered keyword extraction and article summarization via OpenRouter.', 'peptide-news' ) . '</p>';
|
|
},
|
|
'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' );
|
|
}
|
|
|
|
/**
|
|
* Helper: register a single setting with a field callback.
|
|
*/
|
|
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 '<select name="peptide_news_fetch_interval" id="peptide_news_fetch_interval">';
|
|
foreach ( $options as $key => $label ) {
|
|
printf(
|
|
'<option value="%s"%s>%s</option>',
|
|
esc_attr( $key ),
|
|
selected( $value, $key, false ),
|
|
esc_html( $label )
|
|
);
|
|
}
|
|
echo '</select>';
|
|
echo '<p class="description">' . esc_html__( 'How often to check for new articles. After changing, deactivate and reactivate the plugin.', 'peptide-news' ) . '</p>';
|
|
}
|
|
|
|
public function render_articles_count_field() {
|
|
$value = get_option( 'peptide_news_articles_count', 10 );
|
|
printf(
|
|
'<input type="number" name="peptide_news_articles_count" value="%d" min="1" max="100" class="small-text" />',
|
|
absint( $value )
|
|
);
|
|
echo '<p class="description">' . esc_html__( 'Number of articles to display in the shortcode/widget.', 'peptide-news' ) . '</p>';
|
|
}
|
|
|
|
public function render_search_keywords_field() {
|
|
$value = get_option( 'peptide_news_search_keywords', '' );
|
|
printf(
|
|
'<input type="text" name="peptide_news_search_keywords" value="%s" class="regular-text" />',
|
|
esc_attr( $value )
|
|
);
|
|
echo '<p class="description">' . esc_html__( 'Comma-separated keywords for NewsAPI searches.', 'peptide-news' ) . '</p>';
|
|
}
|
|
|
|
public function render_rss_enabled_field() {
|
|
$value = get_option( 'peptide_news_rss_enabled', 1 );
|
|
printf(
|
|
'<label><input type="checkbox" name="peptide_news_rss_enabled" value="1" %s /> %s</label>',
|
|
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(
|
|
'<textarea name="peptide_news_rss_feeds" rows="6" class="large-text code">%s</textarea>',
|
|
esc_textarea( $value )
|
|
);
|
|
}
|
|
|
|
public function render_newsapi_enabled_field() {
|
|
$value = get_option( 'peptide_news_newsapi_enabled', 0 );
|
|
printf(
|
|
'<label><input type="checkbox" name="peptide_news_newsapi_enabled" value="1" %s /> %s</label>',
|
|
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(
|
|
'<input type="password" name="peptide_news_newsapi_key" value="%s" class="regular-text" />',
|
|
esc_attr( $value )
|
|
);
|
|
}
|
|
|
|
public function render_article_retention_field() {
|
|
$value = get_option( 'peptide_news_article_retention', 90 );
|
|
printf(
|
|
'<input type="number" name="peptide_news_article_retention" value="%d" min="7" max="3650" class="small-text" />',
|
|
absint( $value )
|
|
);
|
|
echo '<p class="description">' . esc_html__( 'How long to keep fetched articles before deactivating them.', 'peptide-news' ) . '</p>';
|
|
}
|
|
|
|
public function render_retention_field() {
|
|
$value = get_option( 'peptide_news_analytics_retention', 365 );
|
|
printf(
|
|
'<input type="number" name="peptide_news_analytics_retention" value="%d" min="30" max="3650" class="small-text" />',
|
|
absint( $value )
|
|
);
|
|
echo '<p class="description">' . esc_html__( 'How long to keep click analytics data.', 'peptide-news' ) . '</p>';
|
|
}
|
|
|
|
public function render_anonymize_ip_field() {
|
|
$value = get_option( 'peptide_news_anonymize_ip', 1 );
|
|
printf(
|
|
'<label><input type="checkbox" name="peptide_news_anonymize_ip" value="1" %s /> %s</label>',
|
|
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(
|
|
'<label><input type="checkbox" name="peptide_news_filter_enabled" value="1" %s /> %s</label>',
|
|
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 '<select name="peptide_news_filter_sensitivity" id="peptide_news_filter_sensitivity">';
|
|
foreach ( $options as $key => $label ) {
|
|
printf(
|
|
'<option value="%s"%s>%s</option>',
|
|
esc_attr( $key ),
|
|
selected( $value, $key, false ),
|
|
esc_html( $label )
|
|
);
|
|
}
|
|
echo '</select>';
|
|
}
|
|
|
|
public function render_filter_llm_enabled_field() {
|
|
$value = get_option( 'peptide_news_filter_llm_enabled', 0 );
|
|
printf(
|
|
'<label><input type="checkbox" name="peptide_news_filter_llm_enabled" value="1" %s /> %s</label>',
|
|
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(
|
|
'<input type="text" name="peptide_news_filter_llm_model" value="%s" class="regular-text" />',
|
|
esc_attr( $value )
|
|
);
|
|
echo '<p class="description">' . esc_html__( 'OpenRouter model for content classification. Leave blank to use the Keywords Model above.', 'peptide-news' ) . '</p>';
|
|
}
|
|
|
|
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(
|
|
'<textarea name="peptide_news_filter_title_keywords" rows="8" class="large-text code" placeholder="%s">%s</textarea>',
|
|
esc_attr( $placeholder ),
|
|
esc_textarea( $value )
|
|
);
|
|
echo '<p class="description">' . esc_html__( 'One keyword/phrase per line. Articles with these in the title are blocked. Leave empty to use built-in defaults.', 'peptide-news' ) . '</p>';
|
|
}
|
|
|
|
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(
|
|
'<textarea name="peptide_news_filter_body_keywords" rows="8" class="large-text code" placeholder="%s">%s</textarea>',
|
|
esc_attr( $placeholder ),
|
|
esc_textarea( $value )
|
|
);
|
|
echo '<p class="description">' . 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' ) . '</p>';
|
|
}
|
|
|
|
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(
|
|
'<textarea name="peptide_news_filter_blocked_domains" rows="6" class="large-text code" placeholder="%s">%s</textarea>',
|
|
esc_attr( $placeholder ),
|
|
esc_textarea( $value )
|
|
);
|
|
echo '<p class="description">' . esc_html__( 'One domain per line. Articles from these sources are always blocked. Leave empty to use built-in defaults.', 'peptide-news' ) . '</p>';
|
|
}
|
|
|
|
// --- AI / LLM field renderers ---
|
|
|
|
public function render_llm_enabled_field() {
|
|
$value = get_option( 'peptide_news_llm_enabled', 0 );
|
|
printf(
|
|
'<label><input type="checkbox" name="peptide_news_llm_enabled" value="1" %s /> %s</label>',
|
|
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(
|
|
'<input type="password" name="peptide_news_openrouter_api_key" value="%s" class="regular-text" />',
|
|
esc_attr( $value )
|
|
);
|
|
echo '<p class="description">' . wp_kses(
|
|
__( 'Get an API key at <a href="https://openrouter.ai/keys" target="_blank" rel="noopener">openrouter.ai/keys</a>.', 'peptide-news' ),
|
|
array( 'a' => array( 'href' => array(), 'target' => array(), 'rel' => array() ) )
|
|
) . '</p>';
|
|
}
|
|
|
|
public function render_llm_keywords_model_field() {
|
|
$value = get_option( 'peptide_news_llm_keywords_model', 'google/gemini-2.0-flash-001' );
|
|
printf(
|
|
'<input type="text" name="peptide_news_llm_keywords_model" value="%s" class="regular-text" />',
|
|
esc_attr( $value )
|
|
);
|
|
echo '<p class="description">' . esc_html__( 'OpenRouter model ID for keyword extraction (e.g., google/gemini-2.0-flash-001, openai/gpt-4o-mini).', 'peptide-news' ) . '</p>';
|
|
}
|
|
|
|
public function render_llm_summary_model_field() {
|
|
$value = get_option( 'peptide_news_llm_summary_model', 'google/gemini-2.0-flash-001' );
|
|
printf(
|
|
'<input type="text" name="peptide_news_llm_summary_model" value="%s" class="regular-text" />',
|
|
esc_attr( $value )
|
|
);
|
|
echo '<p class="description">' . esc_html__( 'OpenRouter model ID for article summarization (e.g., google/gemini-2.0-flash-001, anthropic/claude-3.5-sonnet).', 'peptide-news' ) . '</p>';
|
|
}
|
|
|
|
public function render_llm_max_articles_field() {
|
|
$value = get_option( 'peptide_news_llm_max_articles', 10 );
|
|
printf(
|
|
'<input type="number" name="peptide_news_llm_max_articles" value="%d" min="1" max="50" class="small-text" />',
|
|
absint( $value )
|
|
);
|
|
echo '<p class="description">' . esc_html__( 'Maximum number of articles to analyze with AI per fetch cycle. Controls API costs and prevents cron timeouts.', 'peptide-news' ) . '</p>';
|
|
}
|
|
|
|
// --- 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 '<div class="notice notice-success"><p>' . esc_html__( 'Fetch completed!', 'peptide-news' ) . '</p></div>';
|
|
}
|
|
|
|
echo '<div class="wrap">';
|
|
echo '<h1>' . esc_html( get_admin_page_title() ) . '</h1>';
|
|
|
|
// Fetch Now button.
|
|
echo '<form method="post" style="display:inline-block;margin-right:12px;">';
|
|
wp_nonce_field( 'peptide_news_fetch_now_action' );
|
|
submit_button( __( 'Fetch Articles Now', 'peptide-news' ), 'secondary', 'peptide_news_fetch_now', false );
|
|
echo '</form>';
|
|
|
|
// Backfill Sources button (fixes "news.google.com" source labels).
|
|
echo '<button type="button" id="peptide-backfill-sources" class="button button-secondary">';
|
|
echo esc_html__( 'Fix Article Sources', 'peptide-news' );
|
|
echo '</button>';
|
|
echo '<span id="peptide-backfill-result" style="margin-left:10px;"></span>';
|
|
|
|
// Generate AI Summaries button.
|
|
echo '<br style="margin-bottom:8px;">';
|
|
echo '<button type="button" id="peptide-generate-summaries" class="button button-primary" style="margin-top:8px;">';
|
|
echo esc_html__( 'Generate AI Summaries', 'peptide-news' );
|
|
echo '</button>';
|
|
echo '<span id="peptide-summary-result" style="margin-left:10px;"></span>';
|
|
|
|
?>
|
|
<script>
|
|
(function($) {
|
|
$('#peptide-backfill-sources').on('click', function() {
|
|
var $btn = $(this), $result = $('#peptide-backfill-result');
|
|
$btn.prop('disabled', true).text('<?php echo esc_js( __( 'Fixing...', 'peptide-news' ) ); ?>');
|
|
$result.text('');
|
|
$.post(peptideNewsAdmin.ajax_url, {
|
|
action: 'peptide_news_backfill_sources',
|
|
nonce: peptideNewsAdmin.admin_nonce
|
|
}, function(response) {
|
|
$btn.prop('disabled', false).text('<?php echo esc_js( __( 'Fix Article Sources', 'peptide-news' ) ); ?>');
|
|
if (response.success) {
|
|
$result.text(response.data.updated + ' article(s) updated.');
|
|
} else {
|
|
$result.text('Error: ' + (response.data || 'Unknown error'));
|
|
}
|
|
}).fail(function() {
|
|
$btn.prop('disabled', false).text('<?php echo esc_js( __( 'Fix Article Sources', 'peptide-news' ) ); ?>');
|
|
$result.text('Request failed.');
|
|
});
|
|
});
|
|
|
|
// Generate AI Summaries — loops in batches until all articles are done.
|
|
// Uses a 3-second delay between batches to respect free-tier rate limits,
|
|
// and retries up to 3 times when a batch fails before giving up.
|
|
$('#peptide-generate-summaries').on('click', function() {
|
|
var $btn = $(this), $result = $('#peptide-summary-result');
|
|
var totalProcessed = 0;
|
|
var retries = 0;
|
|
var maxRetries = 3;
|
|
|
|
$btn.prop('disabled', true);
|
|
$result.text('<?php echo esc_js( __( 'Starting...', 'peptide-news' ) ); ?>');
|
|
|
|
function runBatch() {
|
|
$btn.text('<?php echo esc_js( __( 'Generating...', 'peptide-news' ) ); ?>');
|
|
$.post(peptideNewsAdmin.ajax_url, {
|
|
action: 'peptide_news_generate_summaries',
|
|
nonce: peptideNewsAdmin.admin_nonce
|
|
}, function(response) {
|
|
if (response.success) {
|
|
totalProcessed += response.data.processed;
|
|
var remaining = response.data.remaining;
|
|
$result.text(totalProcessed + ' summarized, ' + remaining + ' remaining...');
|
|
|
|
if (remaining > 0 && response.data.processed > 0) {
|
|
// Success — reset retry counter and continue after a delay
|
|
// to respect the free-tier rate limit (20 req/min).
|
|
retries = 0;
|
|
setTimeout(runBatch, 3000);
|
|
} else if (remaining > 0 && retries < maxRetries) {
|
|
// No progress — likely rate limited. Wait longer and retry.
|
|
retries++;
|
|
$result.text(totalProcessed + ' summarized, ' + remaining + ' remaining... (rate limited, retrying in 10s, attempt ' + retries + '/' + maxRetries + ')');
|
|
setTimeout(runBatch, 10000);
|
|
} else {
|
|
// Done — either all processed or exhausted retries.
|
|
$btn.prop('disabled', false).text('<?php echo esc_js( __( 'Generate AI Summaries', 'peptide-news' ) ); ?>');
|
|
if (remaining === 0) {
|
|
$result.text('Done! ' + totalProcessed + ' article(s) summarized.');
|
|
} else {
|
|
var errMsg = totalProcessed + ' summarized. ' + remaining + ' could not be processed.';
|
|
if (response.data.errors && response.data.errors.length > 0) {
|
|
errMsg += ' Error: ' + response.data.errors[0];
|
|
}
|
|
$result.text(errMsg);
|
|
}
|
|
}
|
|
} else {
|
|
$btn.prop('disabled', false).text('<?php echo esc_js( __( 'Generate AI Summaries', 'peptide-news' ) ); ?>');
|
|
$result.text('Error: ' + (response.data || 'Unknown error'));
|
|
}
|
|
}).fail(function() {
|
|
if (retries < maxRetries) {
|
|
retries++;
|
|
$result.text(totalProcessed + ' completed. Request failed, retrying in 10s... (attempt ' + retries + '/' + maxRetries + ')');
|
|
setTimeout(runBatch, 10000);
|
|
} else {
|
|
$btn.prop('disabled', false).text('<?php echo esc_js( __( 'Generate AI Summaries', 'peptide-news' ) ); ?>');
|
|
$result.text('Request failed. ' + totalProcessed + ' completed before failure.');
|
|
}
|
|
});
|
|
}
|
|
|
|
runBatch();
|
|
});
|
|
|
|
})(jQuery);
|
|
</script>
|
|
<?php
|
|
|
|
// Last fetch info.
|
|
$last_fetch = get_option( 'peptide_news_last_fetch' );
|
|
if ( $last_fetch ) {
|
|
$extra_info = '';
|
|
if ( isset( $last_fetch['filtered_out'] ) && $last_fetch['filtered_out'] > 0 ) {
|
|
$extra_info .= sprintf( ' | %s: %d',
|
|
esc_html__( 'Filtered out', 'peptide-news' ),
|
|
$last_fetch['filtered_out']
|
|
);
|
|
}
|
|
if ( isset( $last_fetch['ai_processed'] ) ) {
|
|
$extra_info .= sprintf( ' | %s: %d',
|
|
esc_html__( 'AI analyzed', 'peptide-news' ),
|
|
$last_fetch['ai_processed']
|
|
);
|
|
}
|
|
printf(
|
|
'<p class="description">%s: %s | %s: %d | %s: %d%s</p>',
|
|
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 '<form method="post" action="options.php">';
|
|
settings_fields( 'peptide_news_settings_group' );
|
|
do_settings_sections( 'peptide-news-settings' );
|
|
submit_button();
|
|
echo '</form>';
|
|
|
|
// Shortcode usage info.
|
|
echo '<h2>' . esc_html__( 'Usage', 'peptide-news' ) . '</h2>';
|
|
echo '<p><strong>' . esc_html__( 'Shortcode:', 'peptide-news' ) . '</strong> <code>[peptide_news]</code> ' .
|
|
esc_html__( 'or', 'peptide-news' ) . ' <code>[peptide_news count="5"]</code></p>';
|
|
echo '<p><strong>' . esc_html__( 'Widget:', 'peptide-news' ) . '</strong> ' .
|
|
esc_html__( 'Add "Peptide News" from Appearance > Widgets.', 'peptide-news' ) . '</p>';
|
|
|
|
// ── Plugin Log Viewer ──────────────────────────────────────────
|
|
echo '<h2>' . esc_html__( 'Plugin Log', 'peptide-news' ) . '</h2>';
|
|
echo '<p class="description">' . esc_html__( 'Recent plugin activity — fetches, AI processing, errors, and admin actions.', 'peptide-news' ) . '</p>';
|
|
|
|
// Filters row.
|
|
echo '<div style="margin:10px 0;display:flex;gap:8px;align-items:center;">';
|
|
echo '<select id="peptide-log-level">';
|
|
echo '<option value="">' . esc_html__( 'All Levels', 'peptide-news' ) . '</option>';
|
|
echo '<option value="info">Info</option>';
|
|
echo '<option value="warning">Warning</option>';
|
|
echo '<option value="error">Error</option>';
|
|
echo '<option value="debug">Debug</option>';
|
|
echo '</select>';
|
|
echo '<select id="peptide-log-context">';
|
|
echo '<option value="">' . esc_html__( 'All Contexts', 'peptide-news' ) . '</option>';
|
|
echo '<option value="fetch">Fetch</option>';
|
|
echo '<option value="llm">LLM / AI</option>';
|
|
echo '<option value="cron">Cron</option>';
|
|
echo '<option value="admin">Admin</option>';
|
|
echo '<option value="general">General</option>';
|
|
echo '</select>';
|
|
echo '<button type="button" id="peptide-log-refresh" class="button">' . esc_html__( 'Refresh', 'peptide-news' ) . '</button>';
|
|
echo '<button type="button" id="peptide-log-clear" class="button" style="color:#a00;">' . esc_html__( 'Clear Log', 'peptide-news' ) . '</button>';
|
|
echo '<span id="peptide-log-status" style="margin-left:8px;color:#666;"></span>';
|
|
echo '</div>';
|
|
|
|
// Log table.
|
|
echo '<div id="peptide-log-container" style="max-height:400px;overflow-y:auto;border:1px solid #c3c4c7;background:#fff;">';
|
|
echo '<table class="widefat striped" style="margin:0;">';
|
|
echo '<thead><tr>';
|
|
echo '<th style="width:140px;">' . esc_html__( 'Time', 'peptide-news' ) . '</th>';
|
|
echo '<th style="width:70px;">' . esc_html__( 'Level', 'peptide-news' ) . '</th>';
|
|
echo '<th style="width:70px;">' . esc_html__( 'Context', 'peptide-news' ) . '</th>';
|
|
echo '<th>' . esc_html__( 'Message', 'peptide-news' ) . '</th>';
|
|
echo '</tr></thead>';
|
|
echo '<tbody id="peptide-log-body"><tr><td colspan="4" style="text-align:center;color:#999;">' . esc_html__( 'Loading...', 'peptide-news' ) . '</td></tr></tbody>';
|
|
echo '</table>';
|
|
echo '</div>';
|
|
|
|
// Pagination.
|
|
echo '<div id="peptide-log-pagination" style="margin:8px 0;display:flex;gap:8px;align-items:center;"></div>';
|
|
|
|
?>
|
|
<script>
|
|
(function($) {
|
|
var currentPage = 1;
|
|
|
|
function levelBadge(level) {
|
|
var colors = { info: '#2271b1', warning: '#dba617', error: '#d63638', debug: '#8c8f94' };
|
|
var bg = { info: '#f0f6fc', warning: '#fcf9e8', error: '#fcf0f1', debug: '#f0f0f1' };
|
|
return '<span style="display:inline-block;padding:2px 8px;border-radius:3px;font-size:12px;font-weight:500;color:' +
|
|
(colors[level] || '#666') + ';background:' + (bg[level] || '#f0f0f1') + ';">' + level + '</span>';
|
|
}
|
|
|
|
function loadLogs(page) {
|
|
currentPage = page || 1;
|
|
var $body = $('#peptide-log-body');
|
|
$body.html('<tr><td colspan="4" style="text-align:center;color:#999;">Loading...</td></tr>');
|
|
|
|
$.post(peptideNewsAdmin.ajax_url, {
|
|
action: 'peptide_news_get_logs',
|
|
nonce: peptideNewsAdmin.admin_nonce,
|
|
page: currentPage,
|
|
level: $('#peptide-log-level').val(),
|
|
context: $('#peptide-log-context').val()
|
|
}, function(response) {
|
|
if (!response.success) {
|
|
$body.html('<tr><td colspan="4" style="color:#d63638;">Error loading logs.</td></tr>');
|
|
return;
|
|
}
|
|
|
|
var rows = response.data.rows;
|
|
if (rows.length === 0) {
|
|
$body.html('<tr><td colspan="4" style="text-align:center;color:#999;">No log entries.</td></tr>');
|
|
$('#peptide-log-pagination').empty();
|
|
$('#peptide-log-status').text('0 entries');
|
|
return;
|
|
}
|
|
|
|
var html = '';
|
|
$.each(rows, function(i, row) {
|
|
html += '<tr>';
|
|
html += '<td style="font-size:12px;white-space:nowrap;">' + $('<span>').text(row.created_at).html() + '</td>';
|
|
html += '<td>' + levelBadge(row.level) + '</td>';
|
|
html += '<td style="font-size:12px;">' + $('<span>').text(row.context).html() + '</td>';
|
|
html += '<td style="font-size:13px;word-break:break-word;">' + $('<span>').text(row.message).html() + '</td>';
|
|
html += '</tr>';
|
|
});
|
|
$body.html(html);
|
|
|
|
// Pagination.
|
|
var totalPages = response.data.total_pages;
|
|
var pagHtml = '<span style="color:#666;font-size:12px;">Page ' + currentPage + ' of ' + totalPages + ' (' + response.data.total + ' entries)</span>';
|
|
if (currentPage > 1) {
|
|
pagHtml += ' <button type="button" class="button button-small peptide-log-page" data-page="' + (currentPage - 1) + '">« Prev</button>';
|
|
}
|
|
if (currentPage < totalPages) {
|
|
pagHtml += ' <button type="button" class="button button-small peptide-log-page" data-page="' + (currentPage + 1) + '">Next »</button>';
|
|
}
|
|
$('#peptide-log-pagination').html(pagHtml);
|
|
$('#peptide-log-status').text(response.data.total + ' entries');
|
|
});
|
|
}
|
|
|
|
// Bindings.
|
|
$('#peptide-log-refresh').on('click', function() { loadLogs(1); });
|
|
$('#peptide-log-level, #peptide-log-context').on('change', function() { loadLogs(1); });
|
|
$(document).on('click', '.peptide-log-page', function() { loadLogs($(this).data('page')); });
|
|
|
|
$('#peptide-log-clear').on('click', function() {
|
|
if (!confirm('<?php echo esc_js( __( 'Clear all log entries?', 'peptide-news' ) ); ?>')) return;
|
|
$.post(peptideNewsAdmin.ajax_url, {
|
|
action: 'peptide_news_clear_logs',
|
|
nonce: peptideNewsAdmin.admin_nonce
|
|
}, function(response) {
|
|
if (response.success) loadLogs(1);
|
|
});
|
|
});
|
|
|
|
// Initial load.
|
|
loadLogs(1);
|
|
})(jQuery);
|
|
</script>
|
|
<?php
|
|
|
|
echo '</div>';
|
|
}
|
|
|
|
public function render_dashboard_page() {
|
|
if ( ! current_user_can( 'manage_options' ) ) {
|
|
return;
|
|
}
|
|
|
|
// Handle CSV export before any HTML output.
|
|
if ( isset( $_GET['export'] ) && 'csv' === $_GET['export'] ) {
|
|
check_admin_referer( 'peptide_news_export_csv' );
|
|
$this->export_csv();
|
|
return;
|
|
}
|
|
|
|
include PEPTIDE_NEWS_PLUGIN_DIR . 'admin/partials/dashboard.php';
|
|
}
|
|
|
|
/**
|
|
* Stream a CSV export of click analytics data.
|
|
*/
|
|
private function export_csv() {
|
|
if ( ! current_user_can( 'manage_options' ) ) {
|
|
wp_die( esc_html__( 'Unauthorized.', 'peptide-news' ) );
|
|
}
|
|
|
|
$raw_start = isset( $_GET['start_date'] ) ? sanitize_text_field( wp_unslash( $_GET['start_date'] ) ) : '';
|
|
$raw_end = isset( $_GET['end_date'] ) ? sanitize_text_field( wp_unslash( $_GET['end_date'] ) ) : '';
|
|
$start_date = preg_match( '/^\d{4}-\d{2}-\d{2}$/', $raw_start ) ? $raw_start : gmdate( 'Y-m-d', strtotime( '-30 days' ) );
|
|
$end_date = preg_match( '/^\d{4}-\d{2}-\d{2}$/', $raw_end ) ? $raw_end : gmdate( 'Y-m-d' );
|
|
|
|
$data = Peptide_News_Analytics::export_clicks_csv( $start_date, $end_date );
|
|
|
|
$filename = 'peptide-news-clicks-' . $start_date . '-to-' . $end_date . '.csv';
|
|
|
|
header( 'Content-Type: text/csv; charset=utf-8' );
|
|
header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
|
|
header( 'Pragma: no-cache' );
|
|
header( 'Expires: 0' );
|
|
|
|
$output = fopen( 'php://output', 'w' );
|
|
|
|
if ( ! empty( $data ) ) {
|
|
fputcsv( $output, array_keys( $data[0] ) );
|
|
foreach ( $data as $row ) {
|
|
fputcsv( $output, $row );
|
|
}
|
|
} else {
|
|
fputcsv( $output, array( 'No data for selected period' ) );
|
|
}
|
|
|
|
fclose( $output );
|
|
exit;
|
|
}
|
|
|
|
public function render_articles_page() {
|
|
if ( ! current_user_can( 'manage_options' ) ) {
|
|
return;
|
|
}
|
|
include PEPTIDE_NEWS_PLUGIN_DIR . 'admin/partials/articles-list.php';
|
|
}
|
|
}
|